Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions RemoteSigner.Tests/FakeKmsClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using Amazon.KeyManagementService;
using Amazon.KeyManagementService.Model;
using Amazon.Runtime;

namespace RemoteSigner.Tests;

/// <summary>
/// Fake KMS whose Encrypt/Decrypt are identity transforms, capturing requests, so the whole
/// encrypt -> decrypt -> re-derive pipeline runs without AWS credentials
/// </summary>
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<EncryptResponse> EncryptAsync(EncryptRequest request,
CancellationToken cancellationToken = default)
{
LastEncryptRequest = request;
return Task.FromResult(new EncryptResponse
{
CiphertextBlob = new MemoryStream(request.Plaintext.ToArray())
});
}

public override Task<DecryptResponse> DecryptAsync(DecryptRequest request,
CancellationToken cancellationToken = default)
{
LastDecryptRequest = request;
Interlocked.Increment(ref DecryptCalls);
return Task.FromResult(new DecryptResponse
{
Plaintext = new MemoryStream(request.CiphertextBlob.ToArray())
});
}
}
145 changes: 145 additions & 0 deletions RemoteSigner.Tests/RoutingTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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
/// </summary>
[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<SigHash>(golden.SigHash), golden.Network));

var response = await Handle(path, body);

response.StatusCode.Should().Be(200);
JsonSerializer.Deserialize<SignPSBTResponse>(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<APIGatewayHttpApiV2ProxyResponse> Handle(string? path, string body) =>
_function.FunctionHandler(new APIGatewayHttpApiV2ProxyRequest { RawPath = path, Body = body },
new TestLambdaContext());

private static SparkSignerEnvelope Envelope(APIGatewayHttpApiV2ProxyResponse response) =>
JsonSerializer.Deserialize<SparkSignerEnvelope>(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<List<GoldenSignatureTests.GoldenCase>>(File.ReadAllText(path),
new JsonSerializerOptions(JsonSerializerDefaults.Web))!.Single(c => c.Name == name);
}
}
36 changes: 0 additions & 36 deletions RemoteSigner.Tests/SeedCeremonyTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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";

/// <summary>
/// Fake KMS whose Encrypt/Decrypt are identity transforms, capturing requests, so the whole
/// encrypt -> decrypt -> re-derive pipeline runs without AWS credentials
/// </summary>
private class FakeKmsClient : AmazonKeyManagementServiceClient
{
public EncryptRequest? LastEncryptRequest;
public DecryptRequest? LastDecryptRequest;

public FakeKmsClient() : base(new AnonymousAWSCredentials(),
new AmazonKeyManagementServiceConfig { RegionEndpoint = Amazon.RegionEndpoint.EUCentral1 })
{
}

public override Task<EncryptResponse> EncryptAsync(EncryptRequest request,
CancellationToken cancellationToken = default)
{
LastEncryptRequest = request;
return Task.FromResult(new EncryptResponse
{
CiphertextBlob = new MemoryStream(request.Plaintext.ToArray())
});
}

public override Task<DecryptResponse> 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")]
Expand Down
53 changes: 43 additions & 10 deletions RemoteSigner/Function.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using Amazon.Lambda.Serialization.SystemTextJson;
using Amazon.Runtime.CredentialManagement;
using NBitcoin;
using NSpark.RemoteSigner;

[assembly:
LambdaSerializer(typeof(SourceGeneratorLambdaJsonSerializer<RemoteSigner.HttpApiJsonSerializerContext>))]
Expand All @@ -29,12 +30,12 @@
/// <summary>
/// Encrypted seed phrase
/// </summary>
public string EncryptedSeedphrase { get; set; }

Check warning on line 33 in RemoteSigner/Function.cs

View workflow job for this annotation

GitHub Actions / Run tests

Non-nullable property 'EncryptedSeedphrase' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.

/// <summary>
/// AWS KMS Key Id used to decrypt the encrypted seedphrase
/// </summary>
public string AwsKmsKeyId { get; set; }

Check warning on line 38 in RemoteSigner/Function.cs

View workflow job for this annotation

GitHub Actions / Run tests

Non-nullable property 'AwsKmsKeyId' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.

/// <summary>
/// When true, this seed may only co-sign true multisig inputs (threshold of 2 or more), so its
Expand All @@ -48,33 +49,53 @@

public class Function
{
private readonly Func<IAmazonKeyManagementService> _kmsClientFactory;
private readonly SparkRoute _sparkRoute;

/// <summary>
/// Lambda entry point: KMS clients come from the default AWS credential chain
/// </summary>
public Function() : this(CreateKmsClient)
{
}

/// <summary>
/// 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
/// </summary>
/// <param name="kmsClientFactory"></param>
public Function(Func<IAmazonKeyManagementService> kmsClientFactory)
{
_kmsClientFactory = kmsClientFactory ?? throw new ArgumentNullException(nameof(kmsClientFactory));
_sparkRoute = new SparkRoute();
}

/// <summary>
/// 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
/// </summary>MF
/// <param name="request"></param>
/// <param name="context"></param>
/// <returns></returns>
public async Task<APIGatewayHttpApiV2ProxyResponse> FunctionHandler(APIGatewayHttpApiV2ProxyRequest request,
ILambdaContext context)
{
if (SparkSignerHttpHandler.IsSparkPath(request.RawPath))
{
return await _sparkRoute.HandleAsync(request);
}

var response = new APIGatewayHttpApiV2ProxyResponse();

try
{
var requestBody = JsonSerializer.Deserialize<SignPSBTRequest>(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<string> GetSeed(RootedKeyPath derivationPath) => DecryptSeed(kmsClient, derivationPath);

Check warning on line 97 in RemoteSigner/Function.cs

View workflow job for this annotation

GitHub Actions / Run tests

Nullability of reference types in value of type 'Task<string?>' doesn't match target type 'Task<string>'.
var result = await SignPSBT(requestBody.Psbt, requestBody.Network, requestBody.EnforcedSighash, GetSeed);

Check warning on line 98 in RemoteSigner/Function.cs

View workflow job for this annotation

GitHub Actions / Run tests

Nullability of reference types in return type of 'Task<string> GetSeed(RootedKeyPath derivationPath)' doesn't match the target delegate 'Func<RootedKeyPath?, Task<string?>>' (possibly because of nullability attributes).

response = new APIGatewayHttpApiV2ProxyResponse()
{
Expand Down Expand Up @@ -253,9 +274,21 @@
}
}

private static async Task<string?> 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<string?> DecryptSeed(IAmazonKeyManagementService kmsClient, RootedKeyPath? derivationPath)
{
var config = GetConfig(derivationPath.MasterFingerprint);

Check warning on line 291 in RemoteSigner/Function.cs

View workflow job for this annotation

GitHub Actions / Run tests

Dereference of a possibly null reference.

if (config == null) return null;

Expand Down
52 changes: 52 additions & 0 deletions RemoteSigner/SparkRoute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
using System.Text;
using System.Text.Json;
using Amazon.Lambda.APIGatewayEvents;
using NSpark.RemoteSigner;
using NSpark.Signer;

namespace RemoteSigner;

/// <summary>
/// The /spark/ routes of the signer, served with the NSpark.RemoteSigner contract: every response is
/// a JSON envelope (<c>{"result":...}</c> or <c>{"error":"..."}</c>) with a 200/400/403/404/500 status.
/// No seed is enabled for Spark yet, so every wallet is refused
/// </summary>
public class SparkRoute
{
private static readonly SparkSignerPolicy NoPolicy = SparkSignerPolicy.Create([], 0, []);

public async Task<APIGatewayHttpApiV2ProxyResponse> 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<ISparkSigner> ResolveSigner(string wallet, CancellationToken ct) =>
Task.FromException<ISparkSigner>(new SparkSignerPolicyException("Spark signing is not enabled on this signer"));

private static APIGatewayHttpApiV2ProxyResponse Respond(int status, string envelope) => new()
{
StatusCode = status,
Headers = new Dictionary<string, string> { ["Content-Type"] = "application/json" },
Body = envelope,
IsBase64Encoded = false
};
}
Loading