diff --git a/RemoteSigner.Tests/RoutingTests.cs b/RemoteSigner.Tests/RoutingTests.cs
index f3ea913..5cbb1f2 100644
--- a/RemoteSigner.Tests/RoutingTests.cs
+++ b/RemoteSigner.Tests/RoutingTests.cs
@@ -76,8 +76,7 @@ public async Task PsbtErrors_KeepTheirPlainTextResponse()
[InlineData("/spark/info")]
[InlineData("//spark/info")]
[InlineData("/spark/identity-public-key")]
- [InlineData("/spark/sign-leaf-frost")]
- public async Task SparkPaths_NeverReachTheSeeds(string path)
+ public async Task SparkPaths_WithoutASparkSeed_NeverReachTheSeeds(string path)
{
var response = await Handle(path, """{"wallet":"ed0210c8"}""");
@@ -88,6 +87,20 @@ public async Task SparkPaths_NeverReachTheSeeds(string path)
_kms.DecryptCalls.Should().Be(0);
}
+ [Theory]
+ [InlineData("/spark/sign-leaf-frost")]
+ [InlineData("/spark/export-static-deposit-private-key")]
+ [InlineData("/spark/")]
+ [InlineData("/spark/info/extra")]
+ public async Task OperationsTheSignerDoesNotServe_Are404WithoutTouchingTheSeeds(string path)
+ {
+ var response = await Handle(path, """{"wallet":"ed0210c8"}""");
+
+ response.StatusCode.Should().Be(404);
+ Envelope(response).Error.Should().Contain("not served");
+ _kmsClientsCreated.Should().Be(0);
+ }
+
[Fact]
public async Task SparkPath_WithAPsbtRequest_IsABadRequest()
{
diff --git a/RemoteSigner.Tests/SparkSeedTests.cs b/RemoteSigner.Tests/SparkSeedTests.cs
new file mode 100644
index 0000000..64adbc8
--- /dev/null
+++ b/RemoteSigner.Tests/SparkSeedTests.cs
@@ -0,0 +1,295 @@
+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;
+
+///
+/// Which seed may sign for Spark, and how it is loaded: only MF_{SPARK_SEED_FINGERPRINT}, never a
+/// Compromised one, at SPARK_ACCOUNT, with only the derived keys cached
+///
+[Collection(SignerEnvironment.Collection)]
+public class SparkSeedTests : IDisposable
+{
+ ///
+ /// The public dev vector (fingerprint ed0210c8) used across these tests; never fund it
+ ///
+ 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";
+
+ ///
+ /// Spark identity of the dev vector at accounts 0 and 1 (m/8797555'/account'/0')
+ ///
+ private const string DevIdentityAccount0 = "032986ec06431b7b80a4171836174ac10c07e1cb1dff4af93082ee5f3ebca64a0a";
+ private const string DevIdentityAccount1 = "0346c1d72f7bdb4db20210eaa328361abe53f3144730c5a7f38846bf653c40a992";
+
+ private const string AbandonMnemonic =
+ "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
+
+ private static readonly string[] Variables =
+ [
+ "MF_ed0210c8", "MF_aabbccdd", SparkSeed.FingerprintVariable, SparkSeed.AccountVariable,
+ SparkSeed.CacheSecondsVariable
+ ];
+
+ private readonly FakeKmsClient _kms = new();
+ private readonly ManualTime _time = new();
+ private readonly Function _function;
+
+ public SparkSeedTests()
+ {
+ SetSeed("MF_ed0210c8", DevMnemonic);
+ Environment.SetEnvironmentVariable(SparkSeed.FingerprintVariable, "ed0210c8");
+ Environment.SetEnvironmentVariable(SparkSeed.AccountVariable, "0");
+ _function = new Function(() => _kms, _time);
+ }
+
+ public void Dispose()
+ {
+ foreach (var variable in Variables) Environment.SetEnvironmentVariable(variable, null);
+ }
+
+ [Fact]
+ public async Task Info_ReportsTheSparkIdentityOfTheAllowlistedSeed()
+ {
+ var (status, envelope) = await Spark("info", "ed0210c8");
+
+ status.Should().Be(200);
+ var info = envelope.Result!.Value.Deserialize(SparkSignerContract.Json)!;
+ info.ContractVersion.Should().Be(SparkSignerContract.Version);
+ info.IdentityPublicKey.Should().Be(DevIdentityAccount0);
+ _kms.LastDecryptRequest!.KeyId.Should().Be("mrk-test");
+ }
+
+ [Fact]
+ public async Task IdentityPublicKey_IsTheSameKeyAsInfo()
+ {
+ var (status, envelope) = await Spark("identity-public-key", "ed0210c8");
+
+ status.Should().Be(200);
+ Convert.ToHexString(envelope.Result!.Value.Deserialize(SparkSignerContract.Json)!).ToLowerInvariant()
+ .Should().Be(DevIdentityAccount0);
+ }
+
+ [Fact]
+ public async Task TheAccount_SelectsADifferentSparkIdentity()
+ {
+ Environment.SetEnvironmentVariable(SparkSeed.AccountVariable, "1");
+
+ var (_, envelope) = await Spark("info", "ed0210c8");
+
+ envelope.Result!.Value.Deserialize(SparkSignerContract.Json)!.IdentityPublicKey
+ .Should().Be(DevIdentityAccount1);
+ }
+
+ [Theory]
+ [InlineData("1fcce4de")]
+ [InlineData("ed0210c9")]
+ [InlineData("../../ed0210c8")]
+ [InlineData("ed0210c8ed0210c8")]
+ public async Task AnyOtherWallet_IsRefusedWithoutDecryptingAnything(string wallet)
+ {
+ var (status, envelope) = await Spark("info", wallet);
+
+ status.Should().Be(403);
+ envelope.Error.Should().Contain("not enabled for Spark");
+ _kms.DecryptCalls.Should().Be(0);
+ }
+
+ [Fact]
+ public async Task WithoutASparkSeedFingerprint_SparkIsNotEnabled()
+ {
+ Environment.SetEnvironmentVariable(SparkSeed.FingerprintVariable, null);
+
+ var (status, envelope) = await Spark("info", "ed0210c8");
+
+ status.Should().Be(403);
+ envelope.Error.Should().Contain("not enabled on this signer");
+ _kms.DecryptCalls.Should().Be(0);
+ }
+
+ [Fact]
+ public async Task ACompromisedSeed_IsRefusedWithoutDecryptingIt()
+ {
+ SetSeed("MF_ed0210c8", DevMnemonic, compromised: true);
+
+ var (status, envelope) = await Spark("info", "ed0210c8");
+
+ status.Should().Be(403);
+ envelope.Error.Should().Contain("Compromised");
+ _kms.DecryptCalls.Should().Be(0);
+ }
+
+ [Fact]
+ public async Task ASeedWhoseFingerprintDoesNotMatchItsMFEntry_IsRefused()
+ {
+ SetSeed("MF_ed0210c8", AbandonMnemonic);
+
+ var (status, envelope) = await Spark("info", "ed0210c8");
+
+ status.Should().Be(500);
+ envelope.Error.Should().Contain("master fingerprint").And.NotContain("abandon");
+ envelope.Result.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task AnInvalidMnemonic_IsReportedWithoutEchoingIt()
+ {
+ SetSeed("MF_ed0210c8", "middle teach digital prefer fiscal theory syrup enter crash muffin easily zzzz");
+
+ var (status, envelope) = await Spark("info", "ed0210c8");
+
+ status.Should().Be(500);
+ envelope.Error.Should().Contain("not a valid BIP-39 mnemonic").And.NotContain("middle");
+ }
+
+ [Fact]
+ public async Task RepeatedWhitespace_InTheStoredSeed_DerivesTheSameIdentity()
+ {
+ SetSeed("MF_ed0210c8", DevMnemonic.Replace(" ", " ") + " ");
+
+ var (status, envelope) = await Spark("info", "ed0210c8");
+
+ status.Should().Be(200);
+ envelope.Result!.Value.Deserialize(SparkSignerContract.Json)!.IdentityPublicKey
+ .Should().Be(DevIdentityAccount0);
+ }
+
+ [Fact]
+ public async Task ASparkSeedFingerprintWithoutAnMFEntry_IsAConfigurationError()
+ {
+ Environment.SetEnvironmentVariable(SparkSeed.FingerprintVariable, "aabbccdd");
+
+ var (status, envelope) = await Spark("info", "aabbccdd");
+
+ status.Should().Be(500);
+ envelope.Error.Should().Contain("MF_aabbccdd is not configured");
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ public async Task WithoutASparkAccount_AccountZeroIsUsed(string? account)
+ {
+ Environment.SetEnvironmentVariable(SparkSeed.AccountVariable, account);
+
+ var (status, envelope) = await Spark("info", "ed0210c8");
+
+ status.Should().Be(200);
+ envelope.Result!.Value.Deserialize(SparkSignerContract.Json)!.IdentityPublicKey
+ .Should().Be(DevIdentityAccount0);
+ }
+
+ [Theory]
+ [InlineData("-1")]
+ [InlineData("one")]
+ public async Task AnInvalidSparkAccount_IsAConfigurationError(string? account)
+ {
+ Environment.SetEnvironmentVariable(SparkSeed.AccountVariable, account);
+
+ var (status, envelope) = await Spark("info", "ed0210c8");
+
+ status.Should().Be(500);
+ envelope.Error.Should().Contain(SparkSeed.AccountVariable);
+ _kms.DecryptCalls.Should().Be(0);
+ }
+
+ [Fact]
+ public async Task DerivedKeys_AreCachedForSixtySecondsByDefault()
+ {
+ await Spark("info", "ed0210c8");
+ _time.Advance(TimeSpan.FromSeconds(59));
+ await Spark("identity-public-key", "ed0210c8");
+
+ _kms.DecryptCalls.Should().Be(1);
+
+ _time.Advance(TimeSpan.FromSeconds(1));
+ await Spark("info", "ed0210c8");
+
+ _kms.DecryptCalls.Should().Be(2);
+ }
+
+ [Fact]
+ public async Task ACacheOfZeroSeconds_DecryptsOnEveryRequest()
+ {
+ Environment.SetEnvironmentVariable(SparkSeed.CacheSecondsVariable, "0");
+
+ await Spark("info", "ed0210c8");
+ await Spark("info", "ed0210c8");
+
+ _kms.DecryptCalls.Should().Be(2);
+ }
+
+ [Fact]
+ public async Task ChangingTheAccount_DoesNotServeTheCachedKeys()
+ {
+ await Spark("info", "ed0210c8");
+ Environment.SetEnvironmentVariable(SparkSeed.AccountVariable, "1");
+
+ var (_, envelope) = await Spark("info", "ed0210c8");
+
+ envelope.Result!.Value.Deserialize(SparkSignerContract.Json)!.IdentityPublicKey
+ .Should().Be(DevIdentityAccount1);
+ }
+
+ [Fact]
+ public async Task FailedLoads_AreNotCached()
+ {
+ SetSeed("MF_ed0210c8", AbandonMnemonic);
+ (await Spark("info", "ed0210c8")).Status.Should().Be(500);
+
+ SetSeed("MF_ed0210c8", DevMnemonic);
+
+ (await Spark("info", "ed0210c8")).Status.Should().Be(200);
+ }
+
+ [Theory]
+ [InlineData(0, DevIdentityAccount0)]
+ [InlineData(1, DevIdentityAccount1)]
+ public void TheDevIdentities_AreThePlainBip32SparkIdentityKeys(int account, string identity) =>
+ new Mnemonic(DevMnemonic).DeriveExtKey().Derive(KeyPath.Parse($"m/8797555'/{account}'/0'")).GetPublicKey().ToHex()
+ .Should().Be(identity);
+
+ [Theory]
+ [InlineData("a b\tc\n d ", "a b c d")]
+ [InlineData("a b", "a b")]
+ public void NormalizeMnemonic_CollapsesWhitespace(string stored, string expected) =>
+ SparkSeed.NormalizeMnemonic(stored).Should().Be(expected);
+
+ private async Task<(int Status, SparkSignerEnvelope Envelope)> Spark(string op, string wallet)
+ {
+ var response = await _function.FunctionHandler(new APIGatewayHttpApiV2ProxyRequest
+ {
+ RawPath = "/spark/" + op,
+ Body = JsonSerializer.Serialize(new { wallet })
+ }, new TestLambdaContext());
+
+ return (response.StatusCode,
+ JsonSerializer.Deserialize(response.Body, SparkSignerContract.Json)!);
+ }
+
+ ///
+ /// The fake KMS decrypts to the ciphertext itself; seeds are stored with their words joined by @
+ ///
+ private static void SetSeed(string variable, string mnemonic, bool compromised = false) =>
+ Environment.SetEnvironmentVariable(variable, JsonSerializer.Serialize(new SignPSBTConfig
+ {
+ AwsKmsKeyId = "mrk-test",
+ EncryptedSeedphrase = Convert.ToBase64String(Encoding.UTF8.GetBytes(mnemonic.Replace(" ", "@"))),
+ Compromised = compromised
+ }));
+
+ private sealed class ManualTime : TimeProvider
+ {
+ private DateTimeOffset _now = DateTimeOffset.UnixEpoch;
+
+ public override DateTimeOffset GetUtcNow() => _now;
+
+ public void Advance(TimeSpan by) => _now += by;
+ }
+}
diff --git a/RemoteSigner/Function.cs b/RemoteSigner/Function.cs
index 077e712..b37ecb1 100644
--- a/RemoteSigner/Function.cs
+++ b/RemoteSigner/Function.cs
@@ -64,10 +64,19 @@ public Function() : this(CreateKmsClient)
/// without AWS
///
///
- public Function(Func kmsClientFactory)
+ public Function(Func kmsClientFactory) : this(kmsClientFactory, TimeProvider.System)
+ {
+ }
+
+ ///
+ /// Also supplies the clock that expires the cached Spark keys
+ ///
+ ///
+ ///
+ public Function(Func kmsClientFactory, TimeProvider time)
{
_kmsClientFactory = kmsClientFactory ?? throw new ArgumentNullException(nameof(kmsClientFactory));
- _sparkRoute = new SparkRoute();
+ _sparkRoute = new SparkRoute(new SparkSeed(kmsClientFactory, time));
}
///
diff --git a/RemoteSigner/SparkRoute.cs b/RemoteSigner/SparkRoute.cs
index 880840b..f3928da 100644
--- a/RemoteSigner/SparkRoute.cs
+++ b/RemoteSigner/SparkRoute.cs
@@ -2,21 +2,42 @@
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
+/// Only the operations in are served, whatever the contract defines
///
public class SparkRoute
{
+ ///
+ /// Read-only operations: the signer's identity, so callers can pin it before using it
+ ///
+ public static readonly IReadOnlySet ServedOperations = new HashSet(StringComparer.Ordinal)
+ {
+ SparkSignerContract.Ops.Info,
+ SparkSignerContract.Ops.IdentityPublicKey
+ };
+
private static readonly SparkSignerPolicy NoPolicy = SparkSignerPolicy.Create([], 0, []);
+ private readonly SparkSeed _seed;
+
+ public SparkRoute(SparkSeed seed)
+ {
+ _seed = seed ?? throw new ArgumentNullException(nameof(seed));
+ }
+
public async Task HandleAsync(APIGatewayHttpApiV2ProxyRequest request)
{
+ var op = OperationOf(request.RawPath);
+ if (!ServedOperations.Contains(op))
+ {
+ return Refused(404, op, $"operation '{op}' is not served by this signer");
+ }
+
string? body;
try
{
@@ -26,21 +47,42 @@ public async Task HandleAsync(APIGatewayHttpAp
}
catch (FormatException)
{
- return Respond(400, JsonSerializer.Serialize(new { error = "request body is not valid base64" }, SparkSignerContract.Json));
+ return Refused(400, op, "request body is not valid base64");
}
- var (status, envelope) = await SparkSignerHttpHandler.HandleAsync(request.RawPath, body, ResolveSigner, NoPolicy);
+ var (status, envelope) = await SparkSignerHttpHandler.HandleAsync(request.RawPath, body, _seed.ResolveAsync, NoPolicy);
if (status != 200)
{
- await Console.Error.WriteLineAsync($"Spark request {request.RawPath} refused with {status}");
+ await Console.Error.WriteLineAsync($"Spark {op} refused with {status}: {Error(envelope)}");
}
return Respond(status, envelope);
}
- private static Task ResolveSigner(string wallet, CancellationToken ct) =>
- Task.FromException(new SparkSignerPolicyException("Spark signing is not enabled on this signer"));
+ ///
+ /// The operation of a /spark/ path, with repeated slashes collapsed as the router does
+ ///
+ private static string OperationOf(string? path) =>
+ string.Join('/', (path ?? string.Empty).Split('/', StringSplitOptions.RemoveEmptyEntries).Skip(1));
+
+ private static string? Error(string envelope)
+ {
+ try
+ {
+ return JsonSerializer.Deserialize(envelope, SparkSignerContract.Json)?.Error;
+ }
+ catch (JsonException)
+ {
+ return null;
+ }
+ }
+
+ private static APIGatewayHttpApiV2ProxyResponse Refused(int status, string op, string error)
+ {
+ Console.Error.WriteLine($"Spark {op} refused with {status}: {error}");
+ return Respond(status, JsonSerializer.Serialize(new { error }, SparkSignerContract.Json));
+ }
private static APIGatewayHttpApiV2ProxyResponse Respond(int status, string envelope) => new()
{
diff --git a/RemoteSigner/SparkSeed.cs b/RemoteSigner/SparkSeed.cs
new file mode 100644
index 0000000..0392555
--- /dev/null
+++ b/RemoteSigner/SparkSeed.cs
@@ -0,0 +1,137 @@
+using Amazon.KeyManagementService;
+using NBitcoin;
+using NSpark.RemoteSigner;
+using NSpark.Signer;
+
+namespace RemoteSigner;
+
+///
+/// Selects and loads the one seed allowed to sign for Spark: the MF_ seed named by
+/// SPARK_SEED_FINGERPRINT, at the SPARK_ACCOUNT Spark account (0 by default). Any other wallet, and a seed marked
+/// Compromised, is refused. Only the derived Spark account keys are cached, for
+/// SPARK_SIGNER_CACHE_SECONDS (60 by default, 0 disables); the mnemonic is never kept
+///
+public class SparkSeed
+{
+ public const string FingerprintVariable = "SPARK_SEED_FINGERPRINT";
+ public const string AccountVariable = "SPARK_ACCOUNT";
+ public const string CacheSecondsVariable = "SPARK_SIGNER_CACHE_SECONDS";
+
+ private const int DefaultCacheSeconds = 60;
+
+ private readonly Func _kmsClientFactory;
+ private readonly TimeProvider _time;
+ private readonly SemaphoreSlim _lock = new(1, 1);
+ private (string Key, ISparkSigner Signer, DateTimeOffset LoadedAt)? _cached;
+
+ public SparkSeed(Func kmsClientFactory, TimeProvider time)
+ {
+ _kmsClientFactory = kmsClientFactory ?? throw new ArgumentNullException(nameof(kmsClientFactory));
+ _time = time ?? throw new ArgumentNullException(nameof(time));
+ }
+
+ ///
+ /// The signer for , which must be the master fingerprint in
+ /// SPARK_SEED_FINGERPRINT. Throws (403) to refuse it
+ ///
+ public async Task ResolveAsync(string wallet, CancellationToken ct)
+ {
+ var fingerprint = Environment.GetEnvironmentVariable(FingerprintVariable);
+ if (string.IsNullOrWhiteSpace(fingerprint))
+ {
+ throw new SparkSignerPolicyException("Spark signing is not enabled on this signer");
+ }
+
+ var allowed = ParseFingerprint(fingerprint);
+ if (!HDFingerprint.TryParse(wallet, out var requested) || requested != allowed)
+ {
+ throw new SparkSignerPolicyException("this wallet is not enabled for Spark signing");
+ }
+
+ var account = ParseAccount(Environment.GetEnvironmentVariable(AccountVariable));
+ var ttl = TimeSpan.FromSeconds(ParseCacheSeconds(Environment.GetEnvironmentVariable(CacheSecondsVariable)));
+ var key = $"{allowed}/{account}";
+
+ await _lock.WaitAsync(ct);
+ try
+ {
+ if (_cached is { } cached && cached.Key == key && _time.GetUtcNow() - cached.LoadedAt < ttl)
+ {
+ return cached.Signer;
+ }
+
+ _cached = null;
+ var signer = await LoadAsync(allowed, account);
+ if (ttl > TimeSpan.Zero)
+ {
+ _cached = (key, signer, _time.GetUtcNow());
+ }
+
+ return signer;
+ }
+ finally
+ {
+ _lock.Release();
+ }
+ }
+
+ private async Task LoadAsync(HDFingerprint fingerprint, int account)
+ {
+ var config = Function.GetConfig(fingerprint)
+ ?? throw new InvalidOperationException($"{FingerprintVariable} names {fingerprint}, but MF_{fingerprint} is not configured");
+
+ if (config.Compromised)
+ {
+ throw new SparkSignerPolicyException($"the seed {fingerprint} is marked Compromised and may not sign for Spark");
+ }
+
+ var mnemonic = NormalizeMnemonic(await Function.DecryptSeedphrase(_kmsClientFactory(), config));
+
+ // Never echo the phrase: parsing errors are reported without their message
+ HDFingerprint actual;
+ ISparkSigner signer;
+ try
+ {
+ actual = new Mnemonic(mnemonic).DeriveExtKey().GetPublicKey().GetHDFingerPrint();
+ signer = SparkSigner.FromMnemonic(mnemonic, account);
+ }
+ catch (Exception)
+ {
+ throw new InvalidOperationException($"the seed MF_{fingerprint} is not a valid BIP-39 mnemonic");
+ }
+
+ if (actual != fingerprint)
+ {
+ throw new InvalidOperationException(
+ $"the seed in MF_{fingerprint} has master fingerprint {actual}, refusing to sign for Spark with it");
+ }
+
+ return signer;
+ }
+
+ ///
+ /// Collapses any whitespace run to a single space: BIP-39 validation splits on single spaces, and
+ /// a stray double space must not derive a different (empty) wallet
+ ///
+ public static string NormalizeMnemonic(string mnemonic) =>
+ string.Join(' ', mnemonic.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
+
+ private static HDFingerprint ParseFingerprint(string value) =>
+ HDFingerprint.TryParse(value.Trim(), out var fingerprint)
+ ? fingerprint
+ : throw new InvalidOperationException($"{FingerprintVariable} is not a master fingerprint (8 hex characters)");
+
+ private static int ParseAccount(string? value) =>
+ string.IsNullOrWhiteSpace(value)
+ ? 0
+ : int.TryParse(value, out var account) && account >= 0
+ ? account
+ : throw new InvalidOperationException($"{AccountVariable} must be the Spark account index (0 or more)");
+
+ private static int ParseCacheSeconds(string? value) =>
+ string.IsNullOrWhiteSpace(value)
+ ? DefaultCacheSeconds
+ : int.TryParse(value, out var seconds) && seconds >= 0
+ ? seconds
+ : throw new InvalidOperationException($"{CacheSecondsVariable} must be a number of seconds (0 or more)");
+}