From 2809a54d8ca9127e2713b7aa5432dc5d342f17bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=CC=81=20A=2EP?= <53834183+Jossec101@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:27:14 +0200 Subject: [PATCH 1/2] Add seed-ceremony CLI for provisioning remote signer seeds New console project RemoteSigner.SeedCeremony (assembly 'seed-ceremony') replacing the old provisioning flow of pasting the mnemonic into the GenerateEncryptedSeedTest unit test. It references the RemoteSigner project and reuses its SignPSBTConfig/EncryptSeedphrase/DecryptSeedphrase/ ParseNetwork, so the emitted MF_* env var is compatible by construction. Commands: - generate: fresh 24-word mnemonic (interactive TTY enforced, shown once, backup quiz, screen+scrollback wipe), KMS-encrypt, emit manifest - encrypt: same for an existing mnemonic (--seed-file or hidden prompt, never argv) - verify: preflight gate - KMS-decrypts the manifest env value through the lambda's own decrypt path and re-derives fingerprint/env name/xpub The manifest holds public data only (env name/value, master fingerprint, account xpub, derivation path, network). AWS credentials come from the default chain, optionally --profile/--region. Function.cs: additive refactors only - EncryptSeedphrase overload with an injected IAmazonKeyManagementService and public static DecryptSeedphrase extracted from DecryptSeed (existing signatures delegate). Also: 10 new tests (derivation matches NodeGuard's InternalWallet.GetXPUB, env value round-trips the lambda's deserialization, fake-KMS encrypt/ decrypt round trip, verify failure modes), just ceremony-* recipes, README ceremony + snapshot->merge->apply env procedure, and removal of the stale AWS_KMS_KEY_ID NodeGuard env var mention (NodeGuard never reads it). --- NodeGuard Remote Signer.sln | 37 +++ README.md | 58 ++++- RemoteSigner.SeedCeremony/Ceremony.cs | 90 +++++++ RemoteSigner.SeedCeremony/CeremonyManifest.cs | 39 ++++ .../Commands/EncryptAndEmit.cs | 71 ++++++ .../Commands/EncryptCommand.cs | 45 ++++ .../Commands/GenerateCommand.cs | 36 +++ .../Commands/VerifyCommand.cs | 61 +++++ RemoteSigner.SeedCeremony/ConsoleSafety.cs | 101 ++++++++ RemoteSigner.SeedCeremony/Options.cs | 85 +++++++ RemoteSigner.SeedCeremony/Program.cs | 80 +++++++ .../RemoteSigner.SeedCeremony.csproj | 17 ++ RemoteSigner.Tests/RemoteSigner.Tests.csproj | 1 + RemoteSigner.Tests/SeedCeremonyTest.cs | 220 ++++++++++++++++++ RemoteSigner/Function.cs | 27 ++- justfile | 14 +- 16 files changed, 975 insertions(+), 7 deletions(-) create mode 100644 RemoteSigner.SeedCeremony/Ceremony.cs create mode 100644 RemoteSigner.SeedCeremony/CeremonyManifest.cs create mode 100644 RemoteSigner.SeedCeremony/Commands/EncryptAndEmit.cs create mode 100644 RemoteSigner.SeedCeremony/Commands/EncryptCommand.cs create mode 100644 RemoteSigner.SeedCeremony/Commands/GenerateCommand.cs create mode 100644 RemoteSigner.SeedCeremony/Commands/VerifyCommand.cs create mode 100644 RemoteSigner.SeedCeremony/ConsoleSafety.cs create mode 100644 RemoteSigner.SeedCeremony/Options.cs create mode 100644 RemoteSigner.SeedCeremony/Program.cs create mode 100644 RemoteSigner.SeedCeremony/RemoteSigner.SeedCeremony.csproj create mode 100644 RemoteSigner.Tests/SeedCeremonyTest.cs diff --git a/NodeGuard Remote Signer.sln b/NodeGuard Remote Signer.sln index a3a2d9c..30c272e 100644 --- a/NodeGuard Remote Signer.sln +++ b/NodeGuard Remote Signer.sln @@ -4,19 +4,56 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RemoteSigner", "RemoteSigne EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RemoteSigner.Tests", "RemoteSigner.Tests\RemoteSigner.Tests.csproj", "{81B1A3F0-BAC6-4FB6-954A-DC8AC78E5C8A}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RemoteSigner.SeedCeremony", "RemoteSigner.SeedCeremony\RemoteSigner.SeedCeremony.csproj", "{2F5A35FE-A8BB-4F21-B671-35D6DDCA403C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {CAC41215-D95B-4190-8DAD-C115DAE07627}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {CAC41215-D95B-4190-8DAD-C115DAE07627}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CAC41215-D95B-4190-8DAD-C115DAE07627}.Debug|x64.ActiveCfg = Debug|Any CPU + {CAC41215-D95B-4190-8DAD-C115DAE07627}.Debug|x64.Build.0 = Debug|Any CPU + {CAC41215-D95B-4190-8DAD-C115DAE07627}.Debug|x86.ActiveCfg = Debug|Any CPU + {CAC41215-D95B-4190-8DAD-C115DAE07627}.Debug|x86.Build.0 = Debug|Any CPU {CAC41215-D95B-4190-8DAD-C115DAE07627}.Release|Any CPU.ActiveCfg = Release|Any CPU {CAC41215-D95B-4190-8DAD-C115DAE07627}.Release|Any CPU.Build.0 = Release|Any CPU + {CAC41215-D95B-4190-8DAD-C115DAE07627}.Release|x64.ActiveCfg = Release|Any CPU + {CAC41215-D95B-4190-8DAD-C115DAE07627}.Release|x64.Build.0 = Release|Any CPU + {CAC41215-D95B-4190-8DAD-C115DAE07627}.Release|x86.ActiveCfg = Release|Any CPU + {CAC41215-D95B-4190-8DAD-C115DAE07627}.Release|x86.Build.0 = Release|Any CPU {81B1A3F0-BAC6-4FB6-954A-DC8AC78E5C8A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {81B1A3F0-BAC6-4FB6-954A-DC8AC78E5C8A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {81B1A3F0-BAC6-4FB6-954A-DC8AC78E5C8A}.Debug|x64.ActiveCfg = Debug|Any CPU + {81B1A3F0-BAC6-4FB6-954A-DC8AC78E5C8A}.Debug|x64.Build.0 = Debug|Any CPU + {81B1A3F0-BAC6-4FB6-954A-DC8AC78E5C8A}.Debug|x86.ActiveCfg = Debug|Any CPU + {81B1A3F0-BAC6-4FB6-954A-DC8AC78E5C8A}.Debug|x86.Build.0 = Debug|Any CPU {81B1A3F0-BAC6-4FB6-954A-DC8AC78E5C8A}.Release|Any CPU.ActiveCfg = Release|Any CPU {81B1A3F0-BAC6-4FB6-954A-DC8AC78E5C8A}.Release|Any CPU.Build.0 = Release|Any CPU + {81B1A3F0-BAC6-4FB6-954A-DC8AC78E5C8A}.Release|x64.ActiveCfg = Release|Any CPU + {81B1A3F0-BAC6-4FB6-954A-DC8AC78E5C8A}.Release|x64.Build.0 = Release|Any CPU + {81B1A3F0-BAC6-4FB6-954A-DC8AC78E5C8A}.Release|x86.ActiveCfg = Release|Any CPU + {81B1A3F0-BAC6-4FB6-954A-DC8AC78E5C8A}.Release|x86.Build.0 = Release|Any CPU + {2F5A35FE-A8BB-4F21-B671-35D6DDCA403C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2F5A35FE-A8BB-4F21-B671-35D6DDCA403C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2F5A35FE-A8BB-4F21-B671-35D6DDCA403C}.Debug|x64.ActiveCfg = Debug|Any CPU + {2F5A35FE-A8BB-4F21-B671-35D6DDCA403C}.Debug|x64.Build.0 = Debug|Any CPU + {2F5A35FE-A8BB-4F21-B671-35D6DDCA403C}.Debug|x86.ActiveCfg = Debug|Any CPU + {2F5A35FE-A8BB-4F21-B671-35D6DDCA403C}.Debug|x86.Build.0 = Debug|Any CPU + {2F5A35FE-A8BB-4F21-B671-35D6DDCA403C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2F5A35FE-A8BB-4F21-B671-35D6DDCA403C}.Release|Any CPU.Build.0 = Release|Any CPU + {2F5A35FE-A8BB-4F21-B671-35D6DDCA403C}.Release|x64.ActiveCfg = Release|Any CPU + {2F5A35FE-A8BB-4F21-B671-35D6DDCA403C}.Release|x64.Build.0 = Release|Any CPU + {2F5A35FE-A8BB-4F21-B671-35D6DDCA403C}.Release|x86.ActiveCfg = Release|Any CPU + {2F5A35FE-A8BB-4F21-B671-35D6DDCA403C}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE EndGlobalSection EndGlobal diff --git a/README.md b/README.md index fdcc53d..767019d 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,11 @@ To enable mode #1 set env var as follows `ENABLE_REMOTE_SIGNER = false`, otherwi "AWS_ACCESS_KEY_ID": "********", "AWS_SECRET_ACCESS_KEY": "********", "AWS_REGION": "eu-west-1", -"AWS_KMS_KEY_ID": "mrk-cec3e3ef59bc4616a6f44da60bfea0ba", "REMOTE_SIGNER_ENDPOINT": "https://*.lambda-url.eu-west-1.on.aws/" ``` +> Note: NodeGuard does not read any `AWS_KMS_KEY_ID` env var — the KMS key id lives only inside this function's per-fingerprint `MF_*` configuration (see below). + They are detailed as follows: - AWS_ACCESS_KEY_ID: IAM-based user account id used to auth against AWS lambda @@ -70,9 +71,58 @@ Request output body fields: - Psbt: The base64-encoded signed PSBT -### Encrypted Seedphrase generation +### Encrypted Seedphrase generation — the seed-ceremony CLI + +Seeds are provisioned with the `seed-ceremony` console tool in [RemoteSigner.SeedCeremony](RemoteSigner.SeedCeremony/), which reuses this function's own encryption/derivation code so the output can never drift from what the lambda expects. It replaces the old flow of pasting the mnemonic into the `GenerateEncryptedSeedTest` unit test — never do that anymore. + +AWS credentials come from the [default AWS SDK chain](https://docs.aws.amazon.com/sdk-for-net/latest/developer-guide/creds-assign.html) (env vars, profiles, SSO), optionally overridden with `--profile`/`--region`. The only KMS permission needed is `kms:Encrypt` (`kms:Decrypt` too for `verify`). + +```bash +# Generate a NEW 24-word seed (interactive terminal required: shows the words once, +# quizzes the backup, wipes the screen) and write the manifest file: +just ceremony-generate mrk-xxxxxxxx mainnet manifest.json + +# Or encrypt an EXISTING seed (hidden prompt or --seed-file, never a CLI argument): +just ceremony-encrypt mrk-xxxxxxxx mainnet manifest.json + +# Preflight gate before touching the lambda: KMS-decrypts the manifest's env value and +# checks the fingerprint, env var name and account xpub all re-derive identically: +just ceremony-verify manifest.json +``` + +The manifest contains only public data (env var name/value with the KMS ciphertext, master fingerprint, account xpub, derivation path, network): + +- `EnvName`/`EnvValue` go to the lambda configuration (next section). +- `MasterFingerprint`/`AccountXpub` are what NodeGuard needs for its internal wallet (`/setup-internal-wallet`, or the rotation runbook in the NodeGuard repo at `docs/internal-wallet-rotation.md`). +- The derivation path must match NodeGuard's `DEFAULT_DERIVATION_PATH` (default `m/48'/1'`); pass `--derivation-path` if your deployment overrides it. + +### Applying a new seed to the lambda (snapshot → merge → apply) -Right now, the easiest way to encrypt a wallet seedphrase (AKA Mnemomnic) is to use the function `EncryptSeedphrase` in the `Function.cs` class in the Remote signer by invoking a unit test to generate an encrypted seedphrase which is in the `FunctionTest.cs` named `GenerateEncryptedSeedTest`. Take into account that you must use [AWS SDK Credentials for .NET](https://docs.aws.amazon.com/sdk-for-net/v3/developer-guide/net-dg-config-creds.html) to call AWS KMS. +`aws lambda update-function-configuration --environment` **replaces the entire env var map** — applying only the new entry would delete every other `MF_*` seed. Always snapshot and merge: + +```bash +FN=SignPSBT-stg # or SignPSBT-prod +REGION=eu-central-1 +TS=$(date +%Y%m%dT%H%M%S) + +# 1. Snapshot the current env vars (keep this file: it is the rollback artifact) +aws lambda get-function-configuration --function-name "$FN" --region "$REGION" \ + --query 'Environment.Variables' > "env-$FN-$TS.json" + +# 2. Merge the manifest's entry into the snapshot (file-based, nothing sensitive inline) +jq --slurpfile m manifest.json \ + '{Variables: (. + {($m[0].EnvName): $m[0].EnvValue})}' \ + "env-$FN-$TS.json" > "env-$FN-merged.json" + +# 3. Sanity-check: every old MF_* key still present, plus the new one; stays under the 4 KB limit +jq -r '.Variables | keys[]' "env-$FN-merged.json" +wc -c "env-$FN-merged.json" + +# 4. Apply from the file and wait +aws lambda update-function-configuration --function-name "$FN" --region "$REGION" \ + --environment "file://env-$FN-merged.json" +aws lambda wait function-updated-v2 --function-name "$FN" --region "$REGION" +``` ### Setting the function main config @@ -80,7 +130,7 @@ The lambda function uses environment variables as a key-value dictionary for con The environment variable key must start with a prefix as `MF_{Master Fingerprint}` (e.g. MF_ed0210c8) -The configuration has two fields: +The configuration has the following fields: - EncryptedSeedphrase: The encrypted seedphrase as explained above - AwsKmsKeyId: Symmetric key generated by AWS KMS which decrypts the seedphrase diff --git a/RemoteSigner.SeedCeremony/Ceremony.cs b/RemoteSigner.SeedCeremony/Ceremony.cs new file mode 100644 index 0000000..a49c7a9 --- /dev/null +++ b/RemoteSigner.SeedCeremony/Ceremony.cs @@ -0,0 +1,90 @@ +using System.Text.Json; +using NBitcoin; + +namespace RemoteSigner.SeedCeremony; + +/// +/// Public identifiers derived from a mnemonic during a seed ceremony +/// +/// 8 lowercase hex chars, the MF_ env var suffix +/// The xpub at the account derivation path (NodeGuard's InternalWallets.XPUB) +/// The Lambda env var name, MF_{MasterFingerprint} +public sealed record CeremonyResult(string MasterFingerprint, string AccountXpub, string EnvName); + +/// +/// Pure derivation and output-assembly logic of the seed ceremony, kept free of console/AWS I/O so +/// it can be unit tested. Everything here must stay call-for-call compatible with the lambda +/// (Function.SignPSBT fingerprint handling) and with NodeGuard's InternalWallet.GetXPUB +/// +public static class Ceremony +{ + /// + /// Generates a fresh 24-word english mnemonic without BIP39 passphrase (all consumers derive + /// with Mnemonic.DeriveExtKey() and no passphrase) + /// + public static Mnemonic GenerateMnemonic() + { + return new Mnemonic(Wordlist.English, WordCount.TwentyFour); + } + + /// + /// Derives the public identifiers NodeGuard and the remote signer need from a mnemonic. The + /// fingerprint mirrors Function.SignPSBT (extKey.GetWif(network).GetPublicKey().GetHDFingerPrint()) + /// and the account xpub mirrors NodeGuard's InternalWallet.GetXPUB (master derived at the + /// account path, neutered) + /// + /// + /// + /// Account-level derivation path, e.g. m/48'/1' + public static CeremonyResult Derive(Mnemonic mnemonic, Network network, KeyPath accountPath) + { + var masterKey = mnemonic.DeriveExtKey().GetWif(network); + + var masterFingerprint = masterKey.GetPublicKey().GetHDFingerPrint().ToString(); + + var accountXpub = masterKey.Derive(accountPath).Neuter().ToWif(); + + return new CeremonyResult(masterFingerprint, accountXpub, $"MF_{masterFingerprint}"); + } + + /// + /// Builds the MF_* env var value by serializing the lambda's own SignPSBTConfig DTO, so the + /// JSON shape/casing can never drift from what the lambda deserializes + /// + /// + /// + public static string BuildEnvValue(string encryptedSeedphraseBase64, string kmsKeyId) + { + var config = new SignPSBTConfig + { + EncryptedSeedphrase = encryptedSeedphraseBase64, + AwsKmsKeyId = kmsKeyId + }; + + return JsonSerializer.Serialize(config); + } + + /// + /// Checks that a manifest is internally consistent with the (already decrypted) mnemonic it + /// was produced from: env var name, master fingerprint and account xpub must all re-derive + /// identically. Throws with a specific message on the first mismatch + /// + /// + /// + public static void VerifyManifest(CeremonyManifest manifest, Mnemonic mnemonic) + { + var result = Derive(mnemonic, Function.ParseNetwork(manifest.Network), KeyPath.Parse(manifest.DerivationPath)); + + if (!string.Equals(manifest.MasterFingerprint, result.MasterFingerprint, StringComparison.Ordinal)) + throw new ArgumentException( + $"Master fingerprint mismatch: the manifest says {manifest.MasterFingerprint} but the decrypted seed derives {result.MasterFingerprint}"); + + if (!string.Equals(manifest.EnvName, result.EnvName, StringComparison.Ordinal)) + throw new ArgumentException( + $"Env var name mismatch: the manifest says {manifest.EnvName} but the decrypted seed derives {result.EnvName}"); + + if (!string.Equals(manifest.AccountXpub, result.AccountXpub, StringComparison.Ordinal)) + throw new ArgumentException( + $"Account xpub mismatch at {manifest.DerivationPath} on {manifest.Network}: the manifest says {manifest.AccountXpub} but the decrypted seed derives {result.AccountXpub}"); + } +} diff --git a/RemoteSigner.SeedCeremony/CeremonyManifest.cs b/RemoteSigner.SeedCeremony/CeremonyManifest.cs new file mode 100644 index 0000000..23f454e --- /dev/null +++ b/RemoteSigner.SeedCeremony/CeremonyManifest.cs @@ -0,0 +1,39 @@ +using System.Text.Json; + +namespace RemoteSigner.SeedCeremony; + +/// +/// The public output of a seed ceremony: everything an operator needs to configure the lambda +/// (EnvName/EnvValue) and NodeGuard (MasterFingerprint/AccountXpub). Contains ciphertext and +/// public key material only — never the mnemonic +/// +public sealed class CeremonyManifest +{ + public required string EnvName { get; init; } + + /// Byte-exact MF_* env var value (serialized SignPSBTConfig) + public required string EnvValue { get; init; } + + public required string MasterFingerprint { get; init; } + + public required string AccountXpub { get; init; } + + public required string DerivationPath { get; init; } + + public required string Network { get; init; } + + public required string CreatedAtUtc { get; init; } + + private static readonly JsonSerializerOptions SerializerOptions = new() { WriteIndented = true }; + + public string ToJson() + { + return JsonSerializer.Serialize(this, SerializerOptions); + } + + public static CeremonyManifest FromJson(string json) + { + return JsonSerializer.Deserialize(json) + ?? throw new ArgumentException("The manifest could not be deserialized", nameof(json)); + } +} diff --git a/RemoteSigner.SeedCeremony/Commands/EncryptAndEmit.cs b/RemoteSigner.SeedCeremony/Commands/EncryptAndEmit.cs new file mode 100644 index 0000000..04950b2 --- /dev/null +++ b/RemoteSigner.SeedCeremony/Commands/EncryptAndEmit.cs @@ -0,0 +1,71 @@ +using NBitcoin; + +namespace RemoteSigner.SeedCeremony.Commands; + +/// +/// Shared tail of the generate/encrypt commands: derive the public identifiers, KMS-encrypt the +/// mnemonic through the lambda's own EncryptSeedphrase, assemble the manifest and emit it +/// +public static class EncryptAndEmit +{ + public const string DefaultDerivationPath = "m/48'/1'"; + + public static async Task Run(Mnemonic mnemonic, Options options) + { + var kmsKeyId = options.Require("--kms-key-id"); + var networkArg = options.Require("--network"); + var derivationPath = options.GetOrDefault("--derivation-path", DefaultDerivationPath); + var outputFormat = options.GetOrDefault("--output", "text"); + var outPath = options.Get("--out"); + + if (outputFormat is not ("text" or "json")) + throw new UsageException($"--output must be 'text' or 'json', got '{outputFormat}'"); + + var network = Function.ParseNetwork(networkArg); + var accountPath = KeyPath.Parse(derivationPath); + + var derived = Ceremony.Derive(mnemonic, network, accountPath); + + var kmsClient = options.CreateKmsClient(); + var encryptedSeedphrase = await new Function().EncryptSeedphrase(mnemonic.ToString(), kmsKeyId, kmsClient); + + var manifest = new CeremonyManifest + { + EnvName = derived.EnvName, + EnvValue = Ceremony.BuildEnvValue(encryptedSeedphrase, kmsKeyId), + MasterFingerprint = derived.MasterFingerprint, + AccountXpub = derived.AccountXpub, + DerivationPath = derivationPath, + Network = networkArg.ToLowerInvariant(), + CreatedAtUtc = DateTime.UtcNow.ToString("O") + }; + + if (outPath != null) + { + await File.WriteAllTextAsync(outPath, manifest.ToJson()); + Console.Error.WriteLine($"Manifest written to {outPath}"); + } + + if (outputFormat == "json") + { + Console.WriteLine(manifest.ToJson()); + } + else + { + Console.WriteLine($"Env var name : {manifest.EnvName}"); + Console.WriteLine($"Master fingerprint : {manifest.MasterFingerprint}"); + Console.WriteLine($"Account xpub : {manifest.AccountXpub}"); + Console.WriteLine($"Derivation path : {manifest.DerivationPath}"); + Console.WriteLine($"Network : {manifest.Network}"); + Console.WriteLine(outPath != null + ? "Env var value : (in the manifest file, keep it for the lambda env merge)" + : $"Env var value : {manifest.EnvValue}"); + Console.Error.WriteLine(); + Console.Error.WriteLine("Next steps: run 'seed-ceremony verify --in ' as preflight, merge the env"); + Console.Error.WriteLine("var into the lambda configuration (snapshot -> jq merge -> apply, see README), and"); + Console.Error.WriteLine("insert the fingerprint + xpub into NodeGuard."); + } + + return 0; + } +} diff --git a/RemoteSigner.SeedCeremony/Commands/EncryptCommand.cs b/RemoteSigner.SeedCeremony/Commands/EncryptCommand.cs new file mode 100644 index 0000000..d3219e0 --- /dev/null +++ b/RemoteSigner.SeedCeremony/Commands/EncryptCommand.cs @@ -0,0 +1,45 @@ +using NBitcoin; + +namespace RemoteSigner.SeedCeremony.Commands; + +/// +/// Encrypts an EXISTING mnemonic. The mnemonic is read from --seed-file or a hidden interactive +/// prompt — never from command-line arguments, which leak via shell history and process listings +/// +public static class EncryptCommand +{ + public static async Task Run(Options options) + { + var seedFile = options.Get("--seed-file"); + + string mnemonicString; + if (seedFile != null) + { + mnemonicString = (await File.ReadAllTextAsync(seedFile)).Trim(); + } + else if (Console.IsInputRedirected) + { + Console.Error.WriteLine("warning: reading the mnemonic from redirected stdin; prefer an interactive prompt or --seed-file"); + mnemonicString = (Console.In.ReadLine() ?? string.Empty).Trim(); + } + else + { + mnemonicString = ConsoleSafety.ReadSecretLine("Enter the mnemonic (input hidden): "); + } + + if (string.IsNullOrWhiteSpace(mnemonicString)) + throw new UsageException("No mnemonic was provided"); + + Mnemonic mnemonic; + try + { + mnemonic = new Mnemonic(mnemonicString); + } + catch (Exception) + { + throw new ArgumentException("The provided mnemonic is not a valid BIP39 mnemonic"); + } + + return await EncryptAndEmit.Run(mnemonic, options); + } +} diff --git a/RemoteSigner.SeedCeremony/Commands/GenerateCommand.cs b/RemoteSigner.SeedCeremony/Commands/GenerateCommand.cs new file mode 100644 index 0000000..f9a297b --- /dev/null +++ b/RemoteSigner.SeedCeremony/Commands/GenerateCommand.cs @@ -0,0 +1,36 @@ +namespace RemoteSigner.SeedCeremony.Commands; + +/// +/// Generates a fresh 24-word mnemonic, shows it exactly once on an interactive terminal for the +/// paper/steel backup, quizzes the operator, wipes the screen and then encrypts + emits +/// +public static class GenerateCommand +{ + public static async Task Run(Options options) + { + ConsoleSafety.RequireInteractiveConsole("generate"); + + var mnemonic = Ceremony.GenerateMnemonic(); + var words = mnemonic.Words; + + Console.WriteLine(); + Console.WriteLine("Write down the following 24 words IN ORDER on paper/steel. They are shown only once"); + Console.WriteLine("and must never exist digitally outside this ceremony."); + Console.WriteLine(); + + for (var i = 0; i < words.Length; i++) + { + Console.WriteLine($" {i + 1,2}. {words[i]}"); + } + + Console.WriteLine(); + Console.Error.Write("Press Enter when the backup is written down..."); + Console.ReadLine(); + + ConsoleSafety.RunBackupQuiz(words); + + ConsoleSafety.ClearScreenAndScrollback(); + + return await EncryptAndEmit.Run(mnemonic, options); + } +} diff --git a/RemoteSigner.SeedCeremony/Commands/VerifyCommand.cs b/RemoteSigner.SeedCeremony/Commands/VerifyCommand.cs new file mode 100644 index 0000000..36459e4 --- /dev/null +++ b/RemoteSigner.SeedCeremony/Commands/VerifyCommand.cs @@ -0,0 +1,61 @@ +using System.Text.Json; +using NBitcoin; + +namespace RemoteSigner.SeedCeremony.Commands; + +/// +/// Preflight gate before touching the lambda: KMS-decrypts the manifest's env value through the +/// lambda's own decrypt path and re-derives fingerprint + xpub, asserting everything matches. +/// Never prints the seed +/// +public static class VerifyCommand +{ + public static async Task Run(Options options) + { + var inPath = options.Require("--in"); + + var manifest = CeremonyManifest.FromJson(await File.ReadAllTextAsync(inPath)); + + var expectedXpub = options.Get("--xpub"); + if (expectedXpub != null && !string.Equals(expectedXpub, manifest.AccountXpub, StringComparison.Ordinal)) + { + Console.Error.WriteLine($"VERIFY FAILED: the manifest xpub does not match --xpub, manifest has {manifest.AccountXpub}"); + return 1; + } + + var config = JsonSerializer.Deserialize(manifest.EnvValue); + if (config == null) + { + Console.Error.WriteLine("VERIFY FAILED: the manifest EnvValue is not a valid SignPSBTConfig JSON"); + return 1; + } + + var kmsClient = options.CreateKmsClient(); + var seed = await Function.DecryptSeedphrase(kmsClient, config); + + Mnemonic mnemonic; + try + { + mnemonic = new Mnemonic(seed); + } + catch (Exception) + { + Console.Error.WriteLine("VERIFY FAILED: the decrypted seedphrase is not a valid BIP39 mnemonic"); + return 1; + } + + try + { + Ceremony.VerifyManifest(manifest, mnemonic); + } + catch (ArgumentException e) + { + Console.Error.WriteLine($"VERIFY FAILED: {e.Message}"); + return 1; + } + + Console.WriteLine($"VERIFY OK: {manifest.EnvName} decrypts and re-derives fingerprint {manifest.MasterFingerprint} and xpub {manifest.AccountXpub} ({manifest.DerivationPath} on {manifest.Network})"); + + return 0; + } +} diff --git a/RemoteSigner.SeedCeremony/ConsoleSafety.cs b/RemoteSigner.SeedCeremony/ConsoleSafety.cs new file mode 100644 index 0000000..71156e2 --- /dev/null +++ b/RemoteSigner.SeedCeremony/ConsoleSafety.cs @@ -0,0 +1,101 @@ +using System.Text; + +namespace RemoteSigner.SeedCeremony; + +/// +/// Console handling rules that keep the mnemonic off shells, pipes and scrollback: interactive-TTY +/// enforcement, no-echo input, a written-it-down quiz and screen wiping. Prompts go to stderr so +/// stdout stays clean for machine-readable output +/// +public static class ConsoleSafety +{ + /// + /// Refuses to run when stdin or stdout is redirected — commands that display or read a + /// mnemonic must only ever talk to a live terminal + /// + /// + public static void RequireInteractiveConsole(string command) + { + if (Console.IsInputRedirected || Console.IsOutputRedirected) + throw new InvalidOperationException( + $"'{command}' displays or reads a mnemonic and requires an interactive terminal; refusing to run with redirected stdin/stdout"); + } + + /// + /// Reads one line without echoing it to the terminal + /// + /// + public static string ReadSecretLine(string prompt) + { + Console.Error.Write(prompt); + + var buffer = new StringBuilder(); + while (true) + { + var keyInfo = Console.ReadKey(intercept: true); + + if (keyInfo.Key == ConsoleKey.Enter) break; + + if (keyInfo.Key == ConsoleKey.Backspace) + { + if (buffer.Length > 0) buffer.Length--; + continue; + } + + if (keyInfo.KeyChar != '\0') buffer.Append(keyInfo.KeyChar); + } + + Console.Error.WriteLine(); + + return buffer.ToString().Trim(); + } + + /// + /// Quizzes the operator on randomly chosen word positions (input hidden) to prove the backup + /// was actually written down. Throws when an answer does not match + /// + /// + /// + public static void RunBackupQuiz(string[] words, int wordsToAsk = 3) + { + var positions = Enumerable.Range(0, words.Length) + .OrderBy(_ => Random.Shared.Next()) + .Take(wordsToAsk) + .OrderBy(x => x) + .ToList(); + + Console.Error.WriteLine(); + Console.Error.WriteLine("Backup check: re-enter the requested words (input hidden)."); + + foreach (var position in positions) + { + var answer = ReadSecretLine($" Word #{position + 1}: "); + + if (!string.Equals(answer, words[position], StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException( + $"Backup check failed on word #{position + 1}. Run the ceremony again and write down all 24 words before continuing"); + } + + Console.Error.WriteLine("Backup check passed."); + } + + /// + /// Clears the visible screen and asks the terminal to wipe its scrollback so the mnemonic + /// cannot be recovered by scrolling up + /// + public static void ClearScreenAndScrollback() + { + try + { + Console.Clear(); + } + catch (IOException) + { + // Console.Clear can throw when no real terminal is attached; the TTY requirement + // makes this unlikely, but wiping must never crash the ceremony at this point + } + + //ANSI "erase saved lines" — wipes scrollback on terminals that support it + Console.Write("\x1b[3J"); + } +} diff --git a/RemoteSigner.SeedCeremony/Options.cs b/RemoteSigner.SeedCeremony/Options.cs new file mode 100644 index 0000000..5567efc --- /dev/null +++ b/RemoteSigner.SeedCeremony/Options.cs @@ -0,0 +1,85 @@ +using Amazon; +using Amazon.KeyManagementService; +using Amazon.Runtime.CredentialManagement; + +namespace RemoteSigner.SeedCeremony; + +/// +/// Raised on invalid invocations; mapped to exit code 2 with the usage text +/// +public sealed class UsageException : Exception +{ + public UsageException(string message) : base(message) + { + } +} + +/// +/// Minimal --flag value parser. No third-party CLI framework on purpose: this tool handles seed +/// material, so the whole argument-handling surface should be reviewable at a glance +/// +public sealed class Options +{ + private readonly Dictionary _values = new(); + + public static Options Parse(IReadOnlyList args, params string[] allowedFlags) + { + var options = new Options(); + + for (var i = 0; i < args.Count; i++) + { + var flag = args[i]; + + if (!flag.StartsWith("--", StringComparison.Ordinal)) + throw new UsageException($"Unexpected argument '{flag}', flags start with --"); + + if (!allowedFlags.Contains(flag)) + throw new UsageException($"Unknown flag '{flag}'"); + + if (i + 1 >= args.Count || args[i + 1].StartsWith("--", StringComparison.Ordinal)) + throw new UsageException($"Flag '{flag}' requires a value"); + + options._values[flag] = args[++i]; + } + + return options; + } + + public string Require(string flag) + { + return _values.TryGetValue(flag, out var value) + ? value + : throw new UsageException($"Missing required flag '{flag}'"); + } + + public string? Get(string flag) + { + return _values.GetValueOrDefault(flag); + } + + public string GetOrDefault(string flag, string defaultValue) + { + return _values.GetValueOrDefault(flag, defaultValue); + } + + /// + /// Builds a KMS client from --profile/--region when given, otherwise the AWS SDK default + /// credential chain (env vars, default profile, SSO). Never takes key material as arguments + /// + public IAmazonKeyManagementService CreateKmsClient() + { + var config = new AmazonKeyManagementServiceConfig(); + + var region = Get("--region"); + if (region != null) config.RegionEndpoint = RegionEndpoint.GetBySystemName(region); + + var profile = Get("--profile"); + if (profile == null) return new AmazonKeyManagementServiceClient(config); + + var chain = new CredentialProfileStoreChain(); + if (!chain.TryGetAWSCredentials(profile, out var credentials)) + throw new UsageException($"AWS profile '{profile}' was not found"); + + return new AmazonKeyManagementServiceClient(credentials, config); + } +} diff --git a/RemoteSigner.SeedCeremony/Program.cs b/RemoteSigner.SeedCeremony/Program.cs new file mode 100644 index 0000000..e2ad02c --- /dev/null +++ b/RemoteSigner.SeedCeremony/Program.cs @@ -0,0 +1,80 @@ +using Amazon.Runtime; +using RemoteSigner.SeedCeremony; +using RemoteSigner.SeedCeremony.Commands; + +const int exitOk = 0; +const int exitFailure = 1; +const int exitUsage = 2; +const int exitAws = 3; + +const string usage = """ + seed-ceremony — provision NodeGuard remote signer seeds (generate/encrypt/verify) + + Usage: + seed-ceremony generate --kms-key-id --network + [--derivation-path m/48'/1'] [--out ] + [--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..facd8b7 100755 --- a/justfile +++ b/justfile @@ -20,4 +20,16 @@ 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}} \ No newline at end of file From 9536f33dea4a646d1c4c9e9106448be28b276e6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=CC=81=20A=2EP?= <53834183+Jossec101@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:43:13 +0200 Subject: [PATCH 2/2] Add local-emulator smoke test recipe and human-readable manifest JSON - just ceremony-test-local [endpoint]: runs encrypt -> verify against a local AWS emulator's KMS (floci/LocalStack, default localhost:4566) using the committed public dev vector, asserting fingerprint ed0210c8 round-trips. Never touches real AWS; emulator ciphertexts are throwaway. - Serialize the manifest and env value with UnsafeRelaxedJsonEscaping so operators see plain quotes and base64 plus signs instead of u0022/u002B unicode escapes. Presentation only: JSON parsers decode both encodings identically. --- RemoteSigner.SeedCeremony/Ceremony.cs | 9 ++++++++- RemoteSigner.SeedCeremony/CeremonyManifest.cs | 10 +++++++++- justfile | 19 ++++++++++++++++++- 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/RemoteSigner.SeedCeremony/Ceremony.cs b/RemoteSigner.SeedCeremony/Ceremony.cs index a49c7a9..5d0369b 100644 --- a/RemoteSigner.SeedCeremony/Ceremony.cs +++ b/RemoteSigner.SeedCeremony/Ceremony.cs @@ -47,6 +47,13 @@ public static CeremonyResult Derive(Mnemonic mnemonic, Network network, KeyPath return new CeremonyResult(masterFingerprint, accountXpub, $"MF_{masterFingerprint}"); } + //Relaxed escaping keeps base64 plus signs literal instead of the default encoder's u002B + //unicode escapes; JSON parsers decode both identically + private static readonly JsonSerializerOptions EnvValueSerializerOptions = new() + { + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + /// /// Builds the MF_* env var value by serializing the lambda's own SignPSBTConfig DTO, so the /// JSON shape/casing can never drift from what the lambda deserializes @@ -61,7 +68,7 @@ public static string BuildEnvValue(string encryptedSeedphraseBase64, string kmsK AwsKmsKeyId = kmsKeyId }; - return JsonSerializer.Serialize(config); + return JsonSerializer.Serialize(config, EnvValueSerializerOptions); } /// diff --git a/RemoteSigner.SeedCeremony/CeremonyManifest.cs b/RemoteSigner.SeedCeremony/CeremonyManifest.cs index 23f454e..cc0029a 100644 --- a/RemoteSigner.SeedCeremony/CeremonyManifest.cs +++ b/RemoteSigner.SeedCeremony/CeremonyManifest.cs @@ -1,3 +1,4 @@ +using System.Text.Encodings.Web; using System.Text.Json; namespace RemoteSigner.SeedCeremony; @@ -24,7 +25,14 @@ public sealed class CeremonyManifest public required string CreatedAtUtc { get; init; } - private static readonly JsonSerializerOptions SerializerOptions = new() { WriteIndented = true }; + //Relaxed escaping keeps the manifest human-readable instead of the default encoder's + //unicode escapes (u0022 for quotes, u002B for plus). Every JSON parser decodes both + //encodings identically; this is presentation only + private static readonly JsonSerializerOptions SerializerOptions = new() + { + WriteIndented = true, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; public string ToJson() { diff --git a/justfile b/justfile index facd8b7..ec32d0b 100755 --- a/justfile +++ b/justfile @@ -32,4 +32,21 @@ ceremony-encrypt kms_key_id network='mainnet' out='manifest.json': # 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}} \ No newline at end of file + 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