From 4463b360a8906ce8496cbca2c703e5e183b7380e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=CC=81=20A=2EP?= <53834183+Jossec101@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:03:48 +0200 Subject: [PATCH 1/2] Serve the Spark signing operations under a pinned operator policy The signer now serves every operation of the NSpark.RemoteSigner contract that a NodeGuard Spark wallet needs to receive over Lightning, claim and exit on-chain: identity signatures, public keys, leaf FROST signatures, send and claim tweaks and preimage shares. ECIES decryption, private key export, two-phase nonces and adaptor keys stay unserved (404), and leaf signatures with an adaptor key are refused (403). Policy, from the environment: - SPARK_OPERATOR_KEYS / SPARK_THRESHOLD: tweaks and preimage shares are only built for exactly this operator set at this threshold. Without operators every tweak and share is refused. - SPARK_ALLOWED_RECEIVERS: the only identities (the SSP), besides the signer's own, that leaves may be sent to. A malformed policy refuses every request (500) before any seed is decrypted. /spark/info reports the policy so NodeGuard can check it at startup. Tests drive NSpark's RemoteSparkSigner client through the Lambda handler and compare with the in-process signer, and replay every request body pinned in the fork's contract fixtures. --- RemoteSigner.Tests/RemoteSigner.Tests.csproj | 3 + RemoteSigner.Tests/RoutingTests.cs | 2 +- RemoteSigner.Tests/SparkOperationsTests.cs | 258 +++++++++++++++++++ RemoteSigner/SparkRoute.cs | 74 +++++- 4 files changed, 331 insertions(+), 6 deletions(-) create mode 100644 RemoteSigner.Tests/SparkOperationsTests.cs diff --git a/RemoteSigner.Tests/RemoteSigner.Tests.csproj b/RemoteSigner.Tests/RemoteSigner.Tests.csproj index ce23967..46bb1b3 100644 --- a/RemoteSigner.Tests/RemoteSigner.Tests.csproj +++ b/RemoteSigner.Tests/RemoteSigner.Tests.csproj @@ -22,5 +22,8 @@ + + \ No newline at end of file diff --git a/RemoteSigner.Tests/RoutingTests.cs b/RemoteSigner.Tests/RoutingTests.cs index 5cbb1f2..7e1e746 100644 --- a/RemoteSigner.Tests/RoutingTests.cs +++ b/RemoteSigner.Tests/RoutingTests.cs @@ -88,7 +88,7 @@ public async Task SparkPaths_WithoutASparkSeed_NeverReachTheSeeds(string path) } [Theory] - [InlineData("/spark/sign-leaf-frost")] + [InlineData("/spark/generate-adaptor-key")] [InlineData("/spark/export-static-deposit-private-key")] [InlineData("/spark/")] [InlineData("/spark/info/extra")] diff --git a/RemoteSigner.Tests/SparkOperationsTests.cs b/RemoteSigner.Tests/SparkOperationsTests.cs new file mode 100644 index 0000000..eedf050 --- /dev/null +++ b/RemoteSigner.Tests/SparkOperationsTests.cs @@ -0,0 +1,258 @@ +using System.Text; +using System.Text.Json; +using Amazon.Lambda.APIGatewayEvents; +using Amazon.Lambda.TestUtilities; +using FluentAssertions; +using NBitcoin; +using NSpark.RemoteSigner; +using NSpark.Signer; +using Xunit; + +namespace RemoteSigner.Tests; + +/// +/// NSpark's RemoteSparkSigner (the client NodeGuard uses) against the whole Lambda handler, with the +/// dev seed and a fake KMS: every served operation answers exactly as the in-process signer would, +/// and the policy pinned in the environment refuses everything else +/// +[Collection(SignerEnvironment.Collection)] +public class SparkOperationsTests : IDisposable +{ + 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"; + + private static readonly byte[][] OperatorKeys = [Pub(1), Pub(2), Pub(3)]; + private static readonly byte[] Ssp = Pub(9); + + private static readonly IReadOnlyList SoTargets = + [ + new("0000000000000000000000000000000000000000000000000000000000000001", 1, OperatorKeys[0]), + new("0000000000000000000000000000000000000000000000000000000000000002", 2, OperatorKeys[1]), + new("0000000000000000000000000000000000000000000000000000000000000003", 3, OperatorKeys[2]) + ]; + + private static readonly string[] Variables = + [ + "MF_ed0210c8", SparkSeed.FingerprintVariable, SparkSeed.AccountVariable, SparkRoute.OperatorKeysVariable, + SparkRoute.ThresholdVariable, SparkRoute.AllowedReceiversVariable + ]; + + private readonly SparkSigner _local = SparkSigner.FromMnemonic(DevMnemonic, 0); + private readonly FakeKmsClient _kms = new(); + private readonly Function _function; + private readonly RemoteSparkSigner _remote; + + public SparkOperationsTests() + { + Environment.SetEnvironmentVariable("MF_ed0210c8", JsonSerializer.Serialize(new SignPSBTConfig + { + AwsKmsKeyId = "mrk-test", + EncryptedSeedphrase = Convert.ToBase64String(Encoding.UTF8.GetBytes(DevMnemonic.Replace(" ", "@"))) + })); + Environment.SetEnvironmentVariable(SparkSeed.FingerprintVariable, "ed0210c8"); + Environment.SetEnvironmentVariable(SparkSeed.AccountVariable, "0"); + Environment.SetEnvironmentVariable(SparkRoute.OperatorKeysVariable, string.Join(",", OperatorKeys.Select(Hex))); + Environment.SetEnvironmentVariable(SparkRoute.ThresholdVariable, "2"); + Environment.SetEnvironmentVariable(SparkRoute.AllowedReceiversVariable, Hex(Ssp).ToUpperInvariant()); + + _function = new Function(() => _kms); + _remote = new RemoteSparkSigner(new LambdaTransport(_function), "ed0210c8"); + } + + public void Dispose() + { + foreach (var variable in Variables) Environment.SetEnvironmentVariable(variable, null); + } + + [Fact] + public async Task Info_ReportsThePinnedPolicy() + { + var info = await _remote.GetInfoAsync(); + + info.IdentityPublicKey.Should().Be(Hex(await _local.GetIdentityPublicKeyAsync())); + info.OperatorKeys.Should().Equal(OperatorKeys.Select(Hex).Order(StringComparer.Ordinal)); + info.Threshold.Should().Be(2); + info.AllowedReceivers.Should().Equal(Hex(Ssp)); + } + + [Fact] + public async Task PublicKeys_MatchTheInProcessSigner() + { + (await _remote.GetIdentityPublicKeyAsync()).Should().Equal(await _local.GetIdentityPublicKeyAsync()); + (await _remote.GetDepositPublicKeyAsync()).Should().Equal(await _local.GetDepositPublicKeyAsync()); + (await _remote.GetLeafPublicKeyAsync("leaf-1")).Should().Equal(await _local.GetLeafPublicKeyAsync("leaf-1")); + (await _remote.GetStaticDepositPublicKeyAsync(2)).Should().Equal(await _local.GetStaticDepositPublicKeyAsync(2)); + } + + [Fact] + public async Task IdentitySignatures_MatchTheInProcessSigner() + { + var hash = Enumerable.Range(0, 32).Select(i => (byte)i).ToArray(); + + (await _remote.SignWithIdentityKeyAsync(hash)).Should().Equal(await _local.SignWithIdentityKeyAsync(hash)); + (await _remote.SignCompactWithIdentityKeyAsync(hash)).Should() + .Equal(await _local.SignCompactWithIdentityKeyAsync(hash)); + } + + [Fact] + public async Task SendTweaks_ToTheSsp_AreBuiltForEveryOperator() + { + var batch = await _remote.BuildEncryptedSendTweaksAsync([new SendTweakLeafDescriptor("leaf-1", Ssp)], SoTargets, + "019a0000-0000-7000-8000-000000000001", 2); + + batch.EncryptedPackageBySoId.Keys.Should().BeEquivalentTo(SoTargets.Select(t => t.SoId)); + } + + [Fact] + public async Task SendTweaks_BackToTheSignersOwnIdentity_AreBuilt() + { + var own = await _local.GetIdentityPublicKeyAsync(); + + var batch = await _remote.BuildEncryptedSendTweaksAsync([new SendTweakLeafDescriptor("leaf-1", own)], SoTargets, + "019a0000-0000-7000-8000-000000000002", 2); + + batch.EncryptedPackageBySoId.Should().HaveCount(3); + } + + [Fact] + public async Task SendTweaks_ToAnyOtherReceiver_AreRefused() + { + var act = () => _remote.BuildEncryptedSendTweaksAsync( + [new SendTweakLeafDescriptor("leaf-1", Ssp), new SendTweakLeafDescriptor("leaf-2", Pub(42))], SoTargets, + "019a0000-0000-7000-8000-000000000003", 2); + + (await act.Should().ThrowAsync()).Which.Status.Should().Be(403); + } + + [Fact] + public async Task TweaksAndShares_ForAnotherOperatorSet_AreRefused() + { + var foreign = SoTargets.Take(2).Append(new SoTarget(SoTargets[2].SoId, 3, Pub(7))).ToList(); + var subset = SoTargets.Take(2).ToList(); + + var send = () => _remote.BuildEncryptedSendTweaksAsync([new SendTweakLeafDescriptor("leaf-1", Ssp)], foreign, + "019a0000-0000-7000-8000-000000000004", 2); + var claim = () => _remote.BuildEncryptedClaimTweaksAsync([], subset, 2); + var preimage = () => _remote.BuildEncryptedPreimageSharesAsync("019a0000-0000-7000-8000-000000000005", foreign, 2); + + (await send.Should().ThrowAsync()).Which.Status.Should().Be(403); + (await claim.Should().ThrowAsync()).Which.Status.Should().Be(403); + (await preimage.Should().ThrowAsync()).Which.Status.Should().Be(403); + } + + [Theory] + [InlineData(1u)] + [InlineData(3u)] + public async Task TweaksAndShares_AtAnotherThreshold_AreRefused(uint threshold) + { + var act = () => _remote.BuildEncryptedPreimageSharesAsync("019a0000-0000-7000-8000-000000000006", SoTargets, + threshold); + + (await act.Should().ThrowAsync()).Which.Status.Should().Be(403); + } + + [Fact] + public async Task PreimageShares_ForThePinnedOperators_AreBuilt() + { + var bundle = await _remote.BuildEncryptedPreimageSharesAsync("019a0000-0000-7000-8000-000000000007", SoTargets, 2); + + bundle.PaymentHash.Should().HaveCount(32); + bundle.EncryptedShareBySoId.Keys.Should().BeEquivalentTo(SoTargets.Select(t => t.SoId)); + } + + [Fact] + public async Task AdaptorSignatures_AreRefused() + { + var act = () => _remote.SignLeafFrostAsync("leaf-1", new byte[32], Pub(5), + new Dictionary(), adaptorPublicKey: Pub(6)); + + (await act.Should().ThrowAsync()).Which.Status.Should().Be(403); + } + + [Fact] + public async Task WithoutPinnedOperators_EveryTweakAndShareIsRefused() + { + Environment.SetEnvironmentVariable(SparkRoute.OperatorKeysVariable, null); + Environment.SetEnvironmentVariable(SparkRoute.ThresholdVariable, null); + + var act = () => _remote.BuildEncryptedPreimageSharesAsync("019a0000-0000-7000-8000-000000000008", SoTargets, 2); + + (await act.Should().ThrowAsync()).Which.Status.Should().Be(403); + (await _remote.GetIdentityPublicKeyAsync()).Should().Equal(await _local.GetIdentityPublicKeyAsync()); + } + + [Theory] + [InlineData(SparkRoute.ThresholdVariable, null)] + [InlineData(SparkRoute.ThresholdVariable, "0")] + [InlineData(SparkRoute.ThresholdVariable, "4")] + [InlineData(SparkRoute.ThresholdVariable, "two")] + [InlineData(SparkRoute.OperatorKeysVariable, "zz")] + [InlineData(SparkRoute.OperatorKeysVariable, "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798,02")] + [InlineData(SparkRoute.AllowedReceiversVariable, "0479be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798")] + public async Task AMisconfiguredPolicy_RefusesEveryRequestWithoutDecryptingTheSeed(string variable, string? value) + { + Environment.SetEnvironmentVariable(variable, value); + + var act = () => _remote.GetIdentityPublicKeyAsync(); + + var refusal = (await act.Should().ThrowAsync()).Which; + refusal.Status.Should().Be(500); + refusal.Message.Should().Contain(variable); + _kms.DecryptCalls.Should().Be(0); + } + + [Theory] + [InlineData("decrypt-ecies-with-identity")] + [InlineData("export-static-deposit-private-key")] + [InlineData("generate-leaf-frost-nonce")] + [InlineData("sign-leaf-frost-with-nonce")] + [InlineData("generate-adaptor-key")] + public async Task OperationsOutsideTheContract_Are404WithoutDecryptingTheSeed(string op) + { + var (status, _) = await new LambdaTransport(_function).InvokeAsync("/spark/" + op, """{"wallet":"ed0210c8"}""", + CancellationToken.None); + + status.Should().Be(404); + _kms.DecryptCalls.Should().Be(0); + } + + public static IEnumerable ContractFixtures() => + Directory.GetFiles(Path.Combine(AppContext.BaseDirectory, "ContractFixtures"), "*.json") + .Select(f => new object[] { Path.GetFileNameWithoutExtension(f) }) + .OrderBy(f => (string)f[0], StringComparer.Ordinal); + + [Fact] + public void TheSigner_ServesExactlyTheOperationsOfTheContractFixtures() => + ContractFixtures().Select(f => (string)f[0]).Should().BeEquivalentTo(SparkRoute.ServedOperations); + + [Theory] + [MemberData(nameof(ContractFixtures))] + public async Task EveryContractRequestBody_IsUnderstood(string op) + { + var body = (await File.ReadAllTextAsync(Path.Combine(AppContext.BaseDirectory, "ContractFixtures", op + ".json"))) + .TrimEnd('\n'); + + var (status, response) = await new LambdaTransport(_function).InvokeAsync("/spark/" + op, body, CancellationToken.None); + + // The fixtures carry placeholder keys: operations on them may be refused (403) or fail (500), + // but the body must never be rejected as malformed or the operation as unknown + status.Should().NotBe(400, response).And.NotBe(404, response); + } + + private static byte[] Pub(byte seed) => new Key(Enumerable.Repeat(seed, 32).ToArray()).PubKey.ToBytes(); + + private static string Hex(byte[] bytes) => Convert.ToHexString(bytes).ToLowerInvariant(); + + /// + /// What NodeGuard's transport does over the Function URL, minus SigV4 and HTTP + /// + private sealed class LambdaTransport(Function function) : ISparkSignerTransport + { + public async Task<(int Status, string Body)> InvokeAsync(string path, string jsonBody, CancellationToken ct) + { + var response = await function.FunctionHandler( + new APIGatewayHttpApiV2ProxyRequest { RawPath = path, Body = jsonBody }, new TestLambdaContext()); + return (response.StatusCode, response.Body); + } + } +} diff --git a/RemoteSigner/SparkRoute.cs b/RemoteSigner/SparkRoute.cs index f3928da..fc4bcc1 100644 --- a/RemoteSigner/SparkRoute.cs +++ b/RemoteSigner/SparkRoute.cs @@ -1,6 +1,7 @@ using System.Text; using System.Text.Json; using Amazon.Lambda.APIGatewayEvents; +using NBitcoin; using NSpark.RemoteSigner; namespace RemoteSigner; @@ -12,17 +13,30 @@ namespace RemoteSigner; /// public class SparkRoute { + public const string OperatorKeysVariable = "SPARK_OPERATOR_KEYS"; + public const string ThresholdVariable = "SPARK_THRESHOLD"; + public const string AllowedReceiversVariable = "SPARK_ALLOWED_RECEIVERS"; + /// - /// Read-only operations: the signer's identity, so callers can pin it before using it + /// What a NodeGuard Spark wallet needs to receive over Lightning, claim and exit to an on-chain + /// address. Not served at all: ECIES decryption, private key export, two-phase FROST nonces and + /// adaptor keys (SSP leaf swaps); sign-leaf-frost also refuses adaptor signatures /// public static readonly IReadOnlySet ServedOperations = new HashSet(StringComparer.Ordinal) { SparkSignerContract.Ops.Info, - SparkSignerContract.Ops.IdentityPublicKey + SparkSignerContract.Ops.IdentityPublicKey, + SparkSignerContract.Ops.SignWithIdentity, + SparkSignerContract.Ops.SignCompactWithIdentity, + SparkSignerContract.Ops.DepositPublicKey, + SparkSignerContract.Ops.LeafPublicKey, + SparkSignerContract.Ops.StaticDepositPublicKey, + SparkSignerContract.Ops.SignLeafFrost, + SparkSignerContract.Ops.BuildSendTweaks, + SparkSignerContract.Ops.BuildClaimTweaks, + SparkSignerContract.Ops.BuildPreimageShares }; - private static readonly SparkSignerPolicy NoPolicy = SparkSignerPolicy.Create([], 0, []); - private readonly SparkSeed _seed; public SparkRoute(SparkSeed seed) @@ -50,7 +64,17 @@ public async Task HandleAsync(APIGatewayHttpAp return Refused(400, op, "request body is not valid base64"); } - var (status, envelope) = await SparkSignerHttpHandler.HandleAsync(request.RawPath, body, _seed.ResolveAsync, NoPolicy); + SparkSignerPolicy policy; + try + { + policy = PolicyFromEnvironment(); + } + catch (InvalidOperationException e) + { + return Refused(500, op, e.Message); + } + + var (status, envelope) = await SparkSignerHttpHandler.HandleAsync(request.RawPath, body, _seed.ResolveAsync, policy); if (status != 200) { @@ -60,6 +84,46 @@ public async Task HandleAsync(APIGatewayHttpAp return Respond(status, envelope); } + /// + /// The operators that key tweaks and preimage shares may be built for (SPARK_OPERATOR_KEYS, with + /// their SPARK_THRESHOLD) and the identities, besides the signer's own, that leaves may be sent to + /// (SPARK_ALLOWED_RECEIVERS: the SSP). Without operators, every tweak and share is refused + /// + public static SparkSignerPolicy PolicyFromEnvironment() + { + var operators = PublicKeys(OperatorKeysVariable); + var receivers = PublicKeys(AllowedReceiversVariable); + if (operators.Count == 0) + { + return SparkSignerPolicy.Create([], 0, receivers); + } + + var threshold = Environment.GetEnvironmentVariable(ThresholdVariable); + if (!uint.TryParse(threshold, out var parsed) || parsed < 1 || parsed > operators.Count) + { + throw new InvalidOperationException( + $"{ThresholdVariable} must be set to the operators' signing threshold (1 to {operators.Count})"); + } + + return SparkSignerPolicy.Create(operators, parsed, receivers); + } + + private static List PublicKeys(string variable) + { + var keys = (Environment.GetEnvironmentVariable(variable) ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + foreach (var key in keys) + { + if (key.Length != 66 || !key.All(Uri.IsHexDigit) || !PubKey.TryCreatePubKey(Convert.FromHexString(key), out _)) + { + throw new InvalidOperationException($"{variable} must be comma-separated compressed public keys (hex)"); + } + } + + return keys.ToList(); + } + /// /// The operation of a /spark/ path, with repeated slashes collapsed as the router does /// From 53cc783133dc1bf110d5c53e756a2b8fb2ace43a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=CC=81=20A=2EP?= <53834183+Jossec101@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:56:55 +0200 Subject: [PATCH 2/2] Say NSpark, not the fork, where the contract fixtures come from --- RemoteSigner.Tests/RemoteSigner.Tests.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RemoteSigner.Tests/RemoteSigner.Tests.csproj b/RemoteSigner.Tests/RemoteSigner.Tests.csproj index 46bb1b3..113eb30 100644 --- a/RemoteSigner.Tests/RemoteSigner.Tests.csproj +++ b/RemoteSigner.Tests/RemoteSigner.Tests.csproj @@ -22,7 +22,7 @@ - +