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 + }; +}