diff --git a/RemoteSigner.Tests/FakeKmsClient.cs b/RemoteSigner.Tests/FakeKmsClient.cs
new file mode 100644
index 0000000..0014db7
--- /dev/null
+++ b/RemoteSigner.Tests/FakeKmsClient.cs
@@ -0,0 +1,42 @@
+using Amazon.KeyManagementService;
+using Amazon.KeyManagementService.Model;
+using Amazon.Runtime;
+
+namespace RemoteSigner.Tests;
+
+///
+/// Fake KMS whose Encrypt/Decrypt are identity transforms, capturing requests, so the whole
+/// encrypt -> decrypt -> re-derive pipeline runs without AWS credentials
+///
+internal class FakeKmsClient : AmazonKeyManagementServiceClient
+{
+ public EncryptRequest? LastEncryptRequest;
+ public DecryptRequest? LastDecryptRequest;
+ public int DecryptCalls;
+
+ public FakeKmsClient() : base(new AnonymousAWSCredentials(),
+ new AmazonKeyManagementServiceConfig { RegionEndpoint = Amazon.RegionEndpoint.EUCentral1 })
+ {
+ }
+
+ public override Task EncryptAsync(EncryptRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ LastEncryptRequest = request;
+ return Task.FromResult(new EncryptResponse
+ {
+ CiphertextBlob = new MemoryStream(request.Plaintext.ToArray())
+ });
+ }
+
+ public override Task DecryptAsync(DecryptRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ LastDecryptRequest = request;
+ Interlocked.Increment(ref DecryptCalls);
+ return Task.FromResult(new DecryptResponse
+ {
+ Plaintext = new MemoryStream(request.CiphertextBlob.ToArray())
+ });
+ }
+}
diff --git a/RemoteSigner.Tests/RoutingTests.cs b/RemoteSigner.Tests/RoutingTests.cs
new file mode 100644
index 0000000..f3ea913
--- /dev/null
+++ b/RemoteSigner.Tests/RoutingTests.cs
@@ -0,0 +1,145 @@
+using System.Text;
+using System.Text.Json;
+using Amazon.Lambda.APIGatewayEvents;
+using Amazon.Lambda.TestUtilities;
+using FluentAssertions;
+using NBitcoin;
+using NSpark.RemoteSigner;
+using Xunit;
+
+namespace RemoteSigner.Tests;
+
+///
+/// The whole Lambda handler with a fake KMS: only the /spark/ prefix may reach Spark code, and every
+/// other path must keep signing PSBTs byte-for-byte as before
+///
+[Collection(SignerEnvironment.Collection)]
+public class RoutingTests : IDisposable
+{
+ private const string Mnemonic =
+ "middle teach digital prefer fiscal theory syrup enter crash muffin easily anxiety ill barely eagle swim volume consider dynamic unaware deputy middle into physical";
+
+ private readonly FakeKmsClient _kms = new();
+ private readonly Function _function;
+ private int _kmsClientsCreated;
+
+ public RoutingTests()
+ {
+ // The fake KMS decrypts to the ciphertext itself; seeds are stored with their words joined by @
+ var config = new SignPSBTConfig
+ {
+ AwsKmsKeyId = "mrk-test",
+ EncryptedSeedphrase = Convert.ToBase64String(Encoding.UTF8.GetBytes(Mnemonic.Replace(" ", "@")))
+ };
+ Environment.SetEnvironmentVariable("MF_ed0210c8", JsonSerializer.Serialize(config));
+
+ _function = new Function(() =>
+ {
+ _kmsClientsCreated++;
+ return _kms;
+ });
+ }
+
+ public void Dispose() => Environment.SetEnvironmentVariable("MF_ed0210c8", null);
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData("/")]
+ [InlineData("/sign")]
+ [InlineData("/spark")]
+ [InlineData("/sparkle/info")]
+ [InlineData("/SPARK/info")]
+ public async Task NonSparkPaths_SignThePsbtExactlyAsBefore(string? path)
+ {
+ var golden = Golden("p2wsh-2of3-all");
+ var body = JsonSerializer.Serialize(new SignPSBTRequest(golden.Psbt, Enum.Parse(golden.SigHash), golden.Network));
+
+ var response = await Handle(path, body);
+
+ response.StatusCode.Should().Be(200);
+ JsonSerializer.Deserialize(response.Body)!.Psbt.Should().Be(golden.Expected);
+ _kms.LastDecryptRequest!.KeyId.Should().Be("mrk-test");
+ }
+
+ [Fact]
+ public async Task PsbtErrors_KeepTheirPlainTextResponse()
+ {
+ var response = await Handle("/", "not json");
+
+ response.StatusCode.Should().Be(500);
+ response.Headers.Should().BeNull();
+ response.Body.Should().NotBeNullOrEmpty();
+ }
+
+ [Theory]
+ [InlineData("/spark/info")]
+ [InlineData("//spark/info")]
+ [InlineData("/spark/identity-public-key")]
+ [InlineData("/spark/sign-leaf-frost")]
+ public async Task SparkPaths_NeverReachTheSeeds(string path)
+ {
+ var response = await Handle(path, """{"wallet":"ed0210c8"}""");
+
+ response.StatusCode.Should().Be(403);
+ response.Headers["Content-Type"].Should().Be("application/json");
+ Envelope(response).Error.Should().Contain("not enabled");
+ _kmsClientsCreated.Should().Be(0);
+ _kms.DecryptCalls.Should().Be(0);
+ }
+
+ [Fact]
+ public async Task SparkPath_WithAPsbtRequest_IsABadRequest()
+ {
+ var golden = Golden("p2wsh-2of3-all");
+ var body = JsonSerializer.Serialize(new SignPSBTRequest(golden.Psbt, SigHash.All, golden.Network));
+
+ var response = await Handle("/spark/info", body);
+
+ response.StatusCode.Should().Be(400);
+ Envelope(response).Error.Should().Contain("wallet");
+ _kms.DecryptCalls.Should().Be(0);
+ }
+
+ [Fact]
+ public async Task SparkPath_DecodesABase64Body()
+ {
+ var response = await _function.FunctionHandler(new APIGatewayHttpApiV2ProxyRequest
+ {
+ RawPath = "/spark/info",
+ IsBase64Encoded = true,
+ Body = Convert.ToBase64String("""{"wallet":"ed0210c8"}"""u8.ToArray())
+ }, new TestLambdaContext());
+
+ // Reaching the wallet check means the body was decoded and parsed
+ response.StatusCode.Should().Be(403);
+ }
+
+ [Fact]
+ public async Task SparkPath_WithAnInvalidBase64Body_IsABadRequest()
+ {
+ var response = await _function.FunctionHandler(new APIGatewayHttpApiV2ProxyRequest
+ {
+ RawPath = "/spark/info",
+ IsBase64Encoded = true,
+ Body = "not base64!"
+ }, new TestLambdaContext());
+
+ response.StatusCode.Should().Be(400);
+ Envelope(response).Error.Should().Contain("base64");
+ }
+
+ private Task Handle(string? path, string body) =>
+ _function.FunctionHandler(new APIGatewayHttpApiV2ProxyRequest { RawPath = path, Body = body },
+ new TestLambdaContext());
+
+ private static SparkSignerEnvelope Envelope(APIGatewayHttpApiV2ProxyResponse response) =>
+ JsonSerializer.Deserialize(response.Body, SparkSignerContract.Json)!;
+
+ private static GoldenSignatureTests.GoldenCase Golden(string name)
+ {
+ var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", "golden-psbt-signatures.json");
+ return JsonSerializer.Deserialize>(File.ReadAllText(path),
+ new JsonSerializerOptions(JsonSerializerDefaults.Web))!.Single(c => c.Name == name);
+ }
+}
diff --git a/RemoteSigner.Tests/SeedCeremonyTest.cs b/RemoteSigner.Tests/SeedCeremonyTest.cs
index 588fc64..2cceabc 100644
--- a/RemoteSigner.Tests/SeedCeremonyTest.cs
+++ b/RemoteSigner.Tests/SeedCeremonyTest.cs
@@ -2,7 +2,6 @@
using System.Text.Json;
using Amazon.KeyManagementService;
using Amazon.KeyManagementService.Model;
-using Amazon.Runtime;
using FluentAssertions;
using NBitcoin;
using RemoteSigner.SeedCeremony;
@@ -18,41 +17,6 @@ public class SeedCeremonyTest
private const string DevMnemonic =
"middle teach digital prefer fiscal theory syrup enter crash muffin easily anxiety ill barely eagle swim volume consider dynamic unaware deputy middle into physical";
- ///
- /// Fake KMS whose Encrypt/Decrypt are identity transforms, capturing requests, so the whole
- /// encrypt -> decrypt -> re-derive pipeline runs without AWS credentials
- ///
- private class FakeKmsClient : AmazonKeyManagementServiceClient
- {
- public EncryptRequest? LastEncryptRequest;
- public DecryptRequest? LastDecryptRequest;
-
- public FakeKmsClient() : base(new AnonymousAWSCredentials(),
- new AmazonKeyManagementServiceConfig { RegionEndpoint = Amazon.RegionEndpoint.EUCentral1 })
- {
- }
-
- public override Task EncryptAsync(EncryptRequest request,
- CancellationToken cancellationToken = default)
- {
- LastEncryptRequest = request;
- return Task.FromResult(new EncryptResponse
- {
- CiphertextBlob = new MemoryStream(request.Plaintext.ToArray())
- });
- }
-
- public override Task DecryptAsync(DecryptRequest request,
- CancellationToken cancellationToken = default)
- {
- LastDecryptRequest = request;
- return Task.FromResult(new DecryptResponse
- {
- Plaintext = new MemoryStream(request.CiphertextBlob.ToArray())
- });
- }
- }
-
[Theory]
[InlineData("regtest", "tpub")]
[InlineData("testnet", "tpub")]
diff --git a/RemoteSigner/Function.cs b/RemoteSigner/Function.cs
index 8af4a26..077e712 100644
--- a/RemoteSigner/Function.cs
+++ b/RemoteSigner/Function.cs
@@ -9,6 +9,7 @@
using Amazon.Lambda.Serialization.SystemTextJson;
using Amazon.Runtime.CredentialManagement;
using NBitcoin;
+using NSpark.RemoteSigner;
[assembly:
LambdaSerializer(typeof(SourceGeneratorLambdaJsonSerializer))]
@@ -48,8 +49,30 @@ public class SignPSBTConfig
public class Function
{
+ private readonly Func _kmsClientFactory;
+ private readonly SparkRoute _sparkRoute;
+
+ ///
+ /// Lambda entry point: KMS clients come from the default AWS credential chain
+ ///
+ public Function() : this(CreateKmsClient)
+ {
+ }
+
///
- /// A lambda function that takes a psbt and signs it, it it assumed that the psbt inputs come from the same wallet
+ /// Supplies the KMS client the seeds are decrypted with, so tests can run the whole handler
+ /// without AWS
+ ///
+ ///
+ public Function(Func kmsClientFactory)
+ {
+ _kmsClientFactory = kmsClientFactory ?? throw new ArgumentNullException(nameof(kmsClientFactory));
+ _sparkRoute = new SparkRoute();
+ }
+
+ ///
+ /// A lambda function that takes a psbt and signs it, it it assumed that the psbt inputs come from the same wallet.
+ /// Requests under /spark/ go to the Spark signer instead; every other path, including none, is the PSBT signer
/// MF
///
///
@@ -57,6 +80,11 @@ public class Function
public async Task FunctionHandler(APIGatewayHttpApiV2ProxyRequest request,
ILambdaContext context)
{
+ if (SparkSignerHttpHandler.IsSparkPath(request.RawPath))
+ {
+ return await _sparkRoute.HandleAsync(request);
+ }
+
var response = new APIGatewayHttpApiV2ProxyResponse();
try
@@ -64,14 +92,7 @@ public async Task FunctionHandler(APIGatewayHt
var requestBody = JsonSerializer.Deserialize(request.Body);
if (requestBody == null) throw new ArgumentNullException(nameof(requestBody), "Request body not found");
-#if DEBUG
- //AWS SDK v4 removed StoredProfileAWSCredentials; resolve the "default" profile explicitly
- if (!new CredentialProfileStoreChain().TryGetAWSCredentials("default", out var debugCredentials))
- throw new InvalidOperationException("AWS profile 'default' was not found");
- var kmsClient = new AmazonKeyManagementServiceClient(debugCredentials, RegionEndpoint.EUCentral1);
-#else
- var kmsClient = new AmazonKeyManagementServiceClient();
-#endif
+ var kmsClient = _kmsClientFactory();
Task GetSeed(RootedKeyPath derivationPath) => DecryptSeed(kmsClient, derivationPath);
var result = await SignPSBT(requestBody.Psbt, requestBody.Network, requestBody.EnforcedSighash, GetSeed);
@@ -253,7 +274,19 @@ private static void EnsureCompromisedSeedOnlyCoSignsMultisig(PSBTInput psbtInput
}
}
- private static async Task DecryptSeed(AmazonKeyManagementServiceClient kmsClient, RootedKeyPath? derivationPath)
+ private static IAmazonKeyManagementService CreateKmsClient()
+ {
+#if DEBUG
+ //AWS SDK v4 removed StoredProfileAWSCredentials; resolve the "default" profile explicitly
+ if (!new CredentialProfileStoreChain().TryGetAWSCredentials("default", out var debugCredentials))
+ throw new InvalidOperationException("AWS profile 'default' was not found");
+ return new AmazonKeyManagementServiceClient(debugCredentials, RegionEndpoint.EUCentral1);
+#else
+ return new AmazonKeyManagementServiceClient();
+#endif
+ }
+
+ private static async Task DecryptSeed(IAmazonKeyManagementService kmsClient, RootedKeyPath? derivationPath)
{
var config = GetConfig(derivationPath.MasterFingerprint);
diff --git a/RemoteSigner/SparkRoute.cs b/RemoteSigner/SparkRoute.cs
new file mode 100644
index 0000000..880840b
--- /dev/null
+++ b/RemoteSigner/SparkRoute.cs
@@ -0,0 +1,52 @@
+using System.Text;
+using System.Text.Json;
+using Amazon.Lambda.APIGatewayEvents;
+using NSpark.RemoteSigner;
+using NSpark.Signer;
+
+namespace RemoteSigner;
+
+///
+/// The /spark/ routes of the signer, served with the NSpark.RemoteSigner contract: every response is
+/// a JSON envelope ({"result":...} or {"error":"..."}) with a 200/400/403/404/500 status.
+/// No seed is enabled for Spark yet, so every wallet is refused
+///
+public class SparkRoute
+{
+ private static readonly SparkSignerPolicy NoPolicy = SparkSignerPolicy.Create([], 0, []);
+
+ public async Task HandleAsync(APIGatewayHttpApiV2ProxyRequest request)
+ {
+ string? body;
+ try
+ {
+ body = request.IsBase64Encoded && request.Body != null
+ ? Encoding.UTF8.GetString(Convert.FromBase64String(request.Body))
+ : request.Body;
+ }
+ catch (FormatException)
+ {
+ return Respond(400, JsonSerializer.Serialize(new { error = "request body is not valid base64" }, SparkSignerContract.Json));
+ }
+
+ var (status, envelope) = await SparkSignerHttpHandler.HandleAsync(request.RawPath, body, ResolveSigner, NoPolicy);
+
+ if (status != 200)
+ {
+ await Console.Error.WriteLineAsync($"Spark request {request.RawPath} refused with {status}");
+ }
+
+ return Respond(status, envelope);
+ }
+
+ private static Task ResolveSigner(string wallet, CancellationToken ct) =>
+ Task.FromException(new SparkSignerPolicyException("Spark signing is not enabled on this signer"));
+
+ private static APIGatewayHttpApiV2ProxyResponse Respond(int status, string envelope) => new()
+ {
+ StatusCode = status,
+ Headers = new Dictionary { ["Content-Type"] = "application/json" },
+ Body = envelope,
+ IsBase64Encoded = false
+ };
+}