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