]
+ [--output text|json] [--profile ] [--region ]
+ Generate a fresh 24-word mnemonic (interactive terminal required), back it up,
+ KMS-encrypt it and emit the MF_* env var + NodeGuard xpub/fingerprint.
+
+ seed-ceremony encrypt --kms-key-id --network
+ [--seed-file ] [--derivation-path m/48'/1']
+ [--out ] [--output text|json]
+ [--profile ] [--region ]
+ Same for an EXISTING mnemonic, read from --seed-file or a hidden prompt.
+ The mnemonic is never accepted as a command-line argument.
+
+ seed-ceremony verify --in [--xpub ]
+ [--profile ] [--region ]
+ Preflight gate: KMS-decrypt the manifest's env value and check that the
+ fingerprint, env var name and account xpub all re-derive identically.
+
+ Exit codes: 0 ok, 1 verification/derivation failure, 2 usage error, 3 AWS/KMS error.
+ AWS credentials come from the default chain (env vars, profiles, SSO) — never from flags.
+ """;
+
+if (args.Length == 0)
+{
+ Console.Error.WriteLine(usage);
+ return exitUsage;
+}
+
+if (args[0] is "--help" or "-h" or "help")
+{
+ Console.WriteLine(usage);
+ return exitOk;
+}
+
+var command = args[0];
+var commandArgs = args.Skip(1).ToArray();
+
+try
+{
+ return command switch
+ {
+ "generate" => await GenerateCommand.Run(Options.Parse(commandArgs,
+ "--kms-key-id", "--network", "--derivation-path", "--out", "--output", "--profile", "--region")),
+ "encrypt" => await EncryptCommand.Run(Options.Parse(commandArgs,
+ "--kms-key-id", "--network", "--derivation-path", "--seed-file", "--out", "--output", "--profile", "--region")),
+ "verify" => await VerifyCommand.Run(Options.Parse(commandArgs,
+ "--in", "--xpub", "--profile", "--region")),
+ _ => throw new UsageException($"Unknown command '{command}'")
+ };
+}
+catch (UsageException e)
+{
+ Console.Error.WriteLine($"error: {e.Message}");
+ Console.Error.WriteLine();
+ Console.Error.WriteLine(usage);
+ return exitUsage;
+}
+catch (AmazonServiceException e)
+{
+ Console.Error.WriteLine($"AWS error: {e.Message}");
+ return exitAws;
+}
+catch (Exception e)
+{
+ Console.Error.WriteLine($"error: {e.Message}");
+ return exitFailure;
+}
diff --git a/RemoteSigner.SeedCeremony/RemoteSigner.SeedCeremony.csproj b/RemoteSigner.SeedCeremony/RemoteSigner.SeedCeremony.csproj
new file mode 100644
index 0000000..a4d5168
--- /dev/null
+++ b/RemoteSigner.SeedCeremony/RemoteSigner.SeedCeremony.csproj
@@ -0,0 +1,17 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ seed-ceremony
+ RemoteSigner.SeedCeremony
+ true
+
+
+
+
+
+
+
diff --git a/RemoteSigner.Tests/RemoteSigner.Tests.csproj b/RemoteSigner.Tests/RemoteSigner.Tests.csproj
index 35add7d..4e96afb 100644
--- a/RemoteSigner.Tests/RemoteSigner.Tests.csproj
+++ b/RemoteSigner.Tests/RemoteSigner.Tests.csproj
@@ -6,6 +6,7 @@
RemoteSigner.Tests
+
diff --git a/RemoteSigner.Tests/SeedCeremonyTest.cs b/RemoteSigner.Tests/SeedCeremonyTest.cs
new file mode 100644
index 0000000..510c862
--- /dev/null
+++ b/RemoteSigner.Tests/SeedCeremonyTest.cs
@@ -0,0 +1,220 @@
+using System.Text;
+using System.Text.Json;
+using Amazon.KeyManagementService;
+using Amazon.KeyManagementService.Model;
+using Amazon.Runtime;
+using FluentAssertions;
+using NBitcoin;
+using RemoteSigner.SeedCeremony;
+using Xunit;
+
+namespace RemoteSigner.Tests;
+
+public class SeedCeremonyTest
+{
+ ///
+ /// The dev vector already committed in FunctionTest: fingerprint ed0210c8
+ ///
+ 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";
+
+ ///
+ /// Fake KMS whose Encrypt/Decrypt are identity transforms, capturing requests, so the whole
+ /// encrypt -> decrypt -> re-derive pipeline runs without AWS credentials
+ ///
+ private class FakeKmsClient : AmazonKeyManagementServiceClient
+ {
+ public EncryptRequest? LastEncryptRequest;
+ public DecryptRequest? LastDecryptRequest;
+
+ public FakeKmsClient() : base(new AnonymousAWSCredentials(),
+ new AmazonKeyManagementServiceConfig { RegionEndpoint = Amazon.RegionEndpoint.EUCentral1 })
+ {
+ }
+
+ public override Task EncryptAsync(EncryptRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ LastEncryptRequest = request;
+ return Task.FromResult(new EncryptResponse
+ {
+ CiphertextBlob = new MemoryStream(request.Plaintext.ToArray())
+ });
+ }
+
+ public override Task 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")]
+ [InlineData("mainnet", "xpub")]
+ public void Derive_DevMnemonic_ProducesExpectedIdentifiers(string network, string expectedXpubPrefix)
+ {
+ // Act
+ var result = Ceremony.Derive(new Mnemonic(DevMnemonic), Function.ParseNetwork(network),
+ KeyPath.Parse("m/48'/1'"));
+
+ // Assert
+ result.MasterFingerprint.Should().Be("ed0210c8");
+ result.EnvName.Should().Be("MF_ed0210c8");
+ result.AccountXpub.Should().StartWith(expectedXpubPrefix);
+ }
+
+ [Fact]
+ public void Derive_MatchesNodeGuardInternalWalletDerivation()
+ {
+ // Arrange: NodeGuard's InternalWallet.GetXPUB algorithm, computed inline
+ var network = Network.RegTest;
+ var expectedXpub = new Mnemonic(DevMnemonic).DeriveExtKey().GetWif(network)
+ .Derive(new KeyPath("m/48'/1'")).Neuter().ToWif();
+
+ // Act
+ var result = Ceremony.Derive(new Mnemonic(DevMnemonic), network, KeyPath.Parse("m/48'/1'"));
+
+ // Assert
+ result.AccountXpub.Should().Be(expectedXpub);
+ var act = () => new BitcoinExtPubKey(result.AccountXpub, network);
+ act.Should().NotThrow();
+ }
+
+ [Fact]
+ public void BuildEnvValue_RoundTripsThroughLambdaConfigDeserialization()
+ {
+ // Act
+ var envValue = Ceremony.BuildEnvValue("AQIC-ciphertext", "mrk-123");
+
+ // Assert: the lambda deserializes MF_* values with default options and case-sensitive names
+ envValue.Should().Contain("\"EncryptedSeedphrase\"").And.Contain("\"AwsKmsKeyId\"");
+
+ var config = JsonSerializer.Deserialize(envValue);
+ config.Should().NotBeNull();
+ config!.EncryptedSeedphrase.Should().Be("AQIC-ciphertext");
+ config.AwsKmsKeyId.Should().Be("mrk-123");
+ }
+
+ [Fact]
+ public void GenerateMnemonic_Produces24UniqueEnglishWordMnemonics()
+ {
+ // Act
+ var first = Ceremony.GenerateMnemonic();
+ var second = Ceremony.GenerateMnemonic();
+
+ // Assert
+ first.Words.Should().HaveCount(24);
+ var act = () => new Mnemonic(first.ToString());
+ act.Should().NotThrow();
+ first.ToString().Should().NotBe(second.ToString());
+ }
+
+ [Fact]
+ public async Task Encrypt_ReplacesWhitespacesAndUsesSymmetricDefault()
+ {
+ // Arrange
+ var fakeKms = new FakeKmsClient();
+
+ // Act
+ var encryptedBase64 = await new Function().EncryptSeedphrase(DevMnemonic, "mrk-123", fakeKms);
+
+ // Assert: the KMS plaintext must be the @-joined mnemonic (KMS strips whitespaces)
+ fakeKms.LastEncryptRequest.Should().NotBeNull();
+ fakeKms.LastEncryptRequest!.EncryptionAlgorithm.Should().Be(EncryptionAlgorithmSpec.SYMMETRIC_DEFAULT);
+ fakeKms.LastEncryptRequest.KeyId.Should().Be("mrk-123");
+ Encoding.UTF8.GetString(fakeKms.LastEncryptRequest.Plaintext.ToArray())
+ .Should().Be(DevMnemonic.Replace(" ", "@"));
+
+ Convert.FromBase64String(encryptedBase64).Should()
+ .BeEquivalentTo(Encoding.UTF8.GetBytes(DevMnemonic.Replace(" ", "@")));
+ }
+
+ [Fact]
+ public async Task EncryptThenDecrypt_RoundTripsTheMnemonic()
+ {
+ // Arrange
+ var fakeKms = new FakeKmsClient();
+ var encryptedBase64 = await new Function().EncryptSeedphrase(DevMnemonic, "mrk-123", fakeKms);
+ var config = JsonSerializer.Deserialize(Ceremony.BuildEnvValue(encryptedBase64, "mrk-123"));
+
+ // Act: the lambda's own decrypt path
+ var seed = await Function.DecryptSeedphrase(fakeKms, config!);
+
+ // Assert
+ seed.Should().Be(DevMnemonic);
+ fakeKms.LastDecryptRequest!.KeyId.Should().Be("mrk-123");
+ fakeKms.LastDecryptRequest.EncryptionAlgorithm.Should().Be(EncryptionAlgorithmSpec.SYMMETRIC_DEFAULT);
+ }
+
+ private static CeremonyManifest BuildManifest(string network = "regtest",
+ string derivationPath = "m/48'/1'", string? fingerprint = null, string? xpub = null)
+ {
+ var derived = Ceremony.Derive(new Mnemonic(DevMnemonic), Function.ParseNetwork(network),
+ KeyPath.Parse(derivationPath));
+
+ return new CeremonyManifest
+ {
+ EnvName = $"MF_{fingerprint ?? derived.MasterFingerprint}",
+ EnvValue = Ceremony.BuildEnvValue("AQIC", "mrk-123"),
+ MasterFingerprint = fingerprint ?? derived.MasterFingerprint,
+ AccountXpub = xpub ?? derived.AccountXpub,
+ DerivationPath = derivationPath,
+ Network = network,
+ CreatedAtUtc = "2026-01-01T00:00:00.0000000Z"
+ };
+ }
+
+ [Fact]
+ public void VerifyManifest_ConsistentManifest_DoesNotThrow()
+ {
+ var act = () => Ceremony.VerifyManifest(BuildManifest(), new Mnemonic(DevMnemonic));
+ act.Should().NotThrow();
+ }
+
+ [Fact]
+ public void VerifyManifest_FingerprintMismatch_Throws()
+ {
+ var act = () => Ceremony.VerifyManifest(BuildManifest(fingerprint: "00000000"), new Mnemonic(DevMnemonic));
+ act.Should().Throw().WithMessage("*fingerprint mismatch*");
+ }
+
+ [Fact]
+ public void VerifyManifest_XpubMismatch_Throws()
+ {
+ var wrongXpub = Ceremony.Derive(new Mnemonic(DevMnemonic), Network.RegTest, KeyPath.Parse("m/48'/0'"))
+ .AccountXpub;
+ var act = () => Ceremony.VerifyManifest(BuildManifest(xpub: wrongXpub), new Mnemonic(DevMnemonic));
+ act.Should().Throw().WithMessage("*xpub mismatch*");
+ }
+
+ [Fact]
+ public void VerifyManifest_WrongNetworkXpub_Throws()
+ {
+ // A manifest claiming mainnet while carrying the regtest tpub must fail on the xpub check
+ var regtestXpub = Ceremony.Derive(new Mnemonic(DevMnemonic), Network.RegTest, KeyPath.Parse("m/48'/1'"))
+ .AccountXpub;
+ var act = () => Ceremony.VerifyManifest(BuildManifest(network: "mainnet", xpub: regtestXpub),
+ new Mnemonic(DevMnemonic));
+ act.Should().Throw().WithMessage("*xpub mismatch*");
+ }
+
+ [Fact]
+ public void Manifest_JsonRoundTrips()
+ {
+ var manifest = BuildManifest();
+ var roundTripped = CeremonyManifest.FromJson(manifest.ToJson());
+
+ roundTripped.EnvName.Should().Be(manifest.EnvName);
+ roundTripped.EnvValue.Should().Be(manifest.EnvValue);
+ roundTripped.MasterFingerprint.Should().Be(manifest.MasterFingerprint);
+ roundTripped.AccountXpub.Should().Be(manifest.AccountXpub);
+ roundTripped.DerivationPath.Should().Be(manifest.DerivationPath);
+ roundTripped.Network.Should().Be(manifest.Network);
+ }
+}
diff --git a/RemoteSigner/Function.cs b/RemoteSigner/Function.cs
index 4a0948e..3a191e1 100644
--- a/RemoteSigner/Function.cs
+++ b/RemoteSigner/Function.cs
@@ -204,6 +204,18 @@ public async Task FunctionHandler(APIGatewayHt
throw new ArgumentException(message, nameof(config));
}
+ return await DecryptSeedphrase(kmsClient, config);
+ }
+
+ ///
+ /// Decrypts the seedphrase of a signing configuration with AWS KMS and restores the original
+ /// whitespaces (the words are stored joined with @ because AWS KMS removes whitespaces)
+ ///
+ ///
+ ///
+ /// The plaintext mnemonic
+ public static async Task DecryptSeedphrase(IAmazonKeyManagementService kmsClient, SignPSBTConfig config)
+ {
var decryptedSeed = await kmsClient.DecryptAsync(new DecryptRequest
{
CiphertextBlob = new MemoryStream(Convert.FromBase64String(config.EncryptedSeedphrase)),
@@ -284,6 +296,19 @@ public async Task ValidateXPub(PSBT psbt, BitcoinExtKey masterXpriv)
///
/// Base64 encrypted seedphrase
public async Task EncryptSeedphrase(string mnemonicString, string keyId)
+ {
+ return await EncryptSeedphrase(mnemonicString, keyId, new AmazonKeyManagementServiceClient());
+ }
+
+ ///
+ /// Overload of with an injected KMS client so
+ /// callers (e.g. the seed-ceremony CLI) can control credentials/region and tests can fake KMS
+ ///
+ ///
+ ///
+ ///
+ /// Base64 encrypted seedphrase
+ public async Task EncryptSeedphrase(string mnemonicString, string keyId, IAmazonKeyManagementService kmsClient)
{
if (string.IsNullOrWhiteSpace(mnemonicString))
throw new ArgumentException("Value cannot be null or whitespace.", nameof(mnemonicString));
@@ -305,8 +330,6 @@ public async Task EncryptSeedphrase(string mnemonicString, string keyId)
throw;
}
- var kmsClient = new AmazonKeyManagementServiceClient();
-
//To avoid KMS removing whitespaces and dismantling the seedphrase
mnemonicString = mnemonicString.Replace(" ", "@");
diff --git a/justfile b/justfile
index 2e91883..ec32d0b 100755
--- a/justfile
+++ b/justfile
@@ -20,4 +20,33 @@ deploy: build-docker-image push-docker-image
deploy-no-cli env='stg': build-docker-image push-docker-image
# Update the AWS Lambda function code without using Gum
- aws lambda update-function-code --no-paginate --function-name arn:aws:lambda:eu-central-1:839166930136:function:SignPSBT-{{env}} --image-uri 839166930136.dkr.ecr.eu-central-1.amazonaws.com/nodeguardremotesigner:latest --publish
\ No newline at end of file
+ aws lambda update-function-code --no-paginate --function-name arn:aws:lambda:eu-central-1:839166930136:function:SignPSBT-{{env}} --image-uri 839166930136.dkr.ecr.eu-central-1.amazonaws.com/nodeguardremotesigner:latest --publish
+
+# Seed ceremony: generate a NEW 24-word seed, KMS-encrypt it and write the manifest (interactive terminal required)
+ceremony-generate kms_key_id network='mainnet' out='manifest.json':
+ dotnet run --project RemoteSigner.SeedCeremony -- generate --kms-key-id {{kms_key_id}} --network {{network}} --out {{out}}
+
+# Seed ceremony: KMS-encrypt an EXISTING seed (prompted, hidden input — never passed as an argument)
+ceremony-encrypt kms_key_id network='mainnet' out='manifest.json':
+ dotnet run --project RemoteSigner.SeedCeremony -- encrypt --kms-key-id {{kms_key_id}} --network {{network}} --out {{out}}
+
+# Seed ceremony: preflight-verify a manifest (KMS-decrypt + re-derive fingerprint/xpub) before touching the lambda
+ceremony-verify manifest='manifest.json':
+ dotnet run --project RemoteSigner.SeedCeremony -- verify --in {{manifest}}
+
+# Smoke-test the ceremony encrypt->verify flow against a LOCAL AWS emulator's KMS (floci, LocalStack, ...)
+# already listening on the given endpoint. Never touches real AWS. Uses the committed PUBLIC dev test
+# vector (fingerprint ed0210c8); emulator ciphertexts are throwaway by design - never reuse them.
+ceremony-test-local endpoint='http://localhost:4566':
+ #!/usr/bin/env bash
+ set -euo pipefail
+ export AWS_ENDPOINT_URL={{endpoint}} AWS_ACCESS_KEY_ID=test AWS_SECRET_ACCESS_KEY=test AWS_REGION=eu-central-1
+ aws kms list-keys >/dev/null || { echo "No AWS emulator reachable at {{endpoint}}"; exit 1; }
+ KEY_ID=$(aws kms create-key --query KeyMetadata.KeyId --output text)
+ MANIFEST=$(mktemp)
+ trap 'rm -f "$MANIFEST"' EXIT
+ echo "middle teach digital prefer fiscal theory syrup enter crash muffin easily anxiety ill barely eagle swim volume consider dynamic unaware deputy middle into physical" \
+ | dotnet run --project RemoteSigner.SeedCeremony -- encrypt --kms-key-id "$KEY_ID" --network regtest --out "$MANIFEST"
+ dotnet run --project RemoteSigner.SeedCeremony -- verify --in "$MANIFEST"
+ grep -q '"MF_ed0210c8"' "$MANIFEST"
+ echo "Local emulator ceremony smoke test OK (fingerprint ed0210c8 round-tripped)"
\ No newline at end of file