From c562c1455f42d529b84261f7a4059f9c925b71a3 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:00:30 +0200 Subject: [PATCH 1/2] Serve the Spark identity from the one allowlisted MF_ seed SPARK_SEED_FINGERPRINT names the only MF_ seed that may sign for Spark, at the SPARK_ACCOUNT Spark account (required, no default). Any other wallet is refused before KMS is called, and so is a seed marked Compromised. The decrypted mnemonic is normalized to single spaces, validated as BIP-39 and must reproduce its MF_ fingerprint; errors never echo it. Only the derived Spark account keys are cached, for SPARK_SIGNER_CACHE_SECONDS (60 by default, 0 disables). The mnemonic and master key are never cached. The signer serves an explicit list of operations; this layer serves only info and identity-public-key, so callers can pin the identity first. Everything else under /spark/ is a 404 that never touches a seed. --- RemoteSigner.Tests/RoutingTests.cs | 17 +- RemoteSigner.Tests/SparkSeedTests.cs | 283 +++++++++++++++++++++++++++ RemoteSigner/Function.cs | 13 +- RemoteSigner/SparkRoute.cs | 56 +++++- RemoteSigner/SparkSeed.cs | 135 +++++++++++++ 5 files changed, 493 insertions(+), 11 deletions(-) create mode 100644 RemoteSigner.Tests/SparkSeedTests.cs create mode 100644 RemoteSigner/SparkSeed.cs 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..1a382c6 --- /dev/null +++ b/RemoteSigner.Tests/SparkSeedTests.cs @@ -0,0 +1,283 @@ +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("")] + [InlineData("-1")] + [InlineData("one")] + public async Task TheSparkAccount_MustBeSetExplicitly(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..f0d03b7 --- /dev/null +++ b/RemoteSigner/SparkSeed.cs @@ -0,0 +1,135 @@ +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. 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) => + int.TryParse(value, out var account) && account >= 0 + ? account + : throw new InvalidOperationException($"{AccountVariable} must be set to 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)"); +} From ac078df2a9e2e9702590a5ce97bca462c8e6f854 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:42 +0200 Subject: [PATCH 2/2] Default SPARK_ACCOUNT to 0 Account 0 is NSpark's default and what NodeGuard uses unless told otherwise. An invalid value is still a configuration error. --- RemoteSigner.Tests/SparkSeedTests.cs | 14 +++++++++++++- RemoteSigner/SparkSeed.cs | 10 ++++++---- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/RemoteSigner.Tests/SparkSeedTests.cs b/RemoteSigner.Tests/SparkSeedTests.cs index 1a382c6..64adbc8 100644 --- a/RemoteSigner.Tests/SparkSeedTests.cs +++ b/RemoteSigner.Tests/SparkSeedTests.cs @@ -174,9 +174,21 @@ public async Task ASparkSeedFingerprintWithoutAnMFEntry_IsAConfigurationError() [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 TheSparkAccount_MustBeSetExplicitly(string? account) + public async Task AnInvalidSparkAccount_IsAConfigurationError(string? account) { Environment.SetEnvironmentVariable(SparkSeed.AccountVariable, account); diff --git a/RemoteSigner/SparkSeed.cs b/RemoteSigner/SparkSeed.cs index f0d03b7..0392555 100644 --- a/RemoteSigner/SparkSeed.cs +++ b/RemoteSigner/SparkSeed.cs @@ -7,7 +7,7 @@ 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. Any other wallet, and a seed marked +/// 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 /// @@ -122,9 +122,11 @@ private static HDFingerprint ParseFingerprint(string value) => : throw new InvalidOperationException($"{FingerprintVariable} is not a master fingerprint (8 hex characters)"); private static int ParseAccount(string? value) => - int.TryParse(value, out var account) && account >= 0 - ? account - : throw new InvalidOperationException($"{AccountVariable} must be set to the Spark account index (0 or more)"); + 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)