Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions NodeGuard Remote Signer.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
58 changes: 54 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -70,17 +71,66 @@ 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.
Comment thread
Jossec101 marked this conversation as resolved.

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

The lambda function uses environment variables as a key-value dictionary for configuration of the different wallets that can be used to sign, the dictionary keys are the master fingerprints of the different wallets while the value of the keys are the configuration of the lambda function.

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
Expand Down
97 changes: 97 additions & 0 deletions RemoteSigner.SeedCeremony/Ceremony.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
using System.Text.Json;
using NBitcoin;

namespace RemoteSigner.SeedCeremony;

/// <summary>
/// Public identifiers derived from a mnemonic during a seed ceremony
/// </summary>
/// <param name="MasterFingerprint">8 lowercase hex chars, the MF_ env var suffix</param>
/// <param name="AccountXpub">The xpub at the account derivation path (NodeGuard's InternalWallets.XPUB)</param>
/// <param name="EnvName">The Lambda env var name, MF_{MasterFingerprint}</param>
public sealed record CeremonyResult(string MasterFingerprint, string AccountXpub, string EnvName);

/// <summary>
/// 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
/// </summary>
public static class Ceremony
{
/// <summary>
/// Generates a fresh 24-word english mnemonic without BIP39 passphrase (all consumers derive
/// with Mnemonic.DeriveExtKey() and no passphrase)
/// </summary>
public static Mnemonic GenerateMnemonic()
{
return new Mnemonic(Wordlist.English, WordCount.TwentyFour);
}

/// <summary>
/// 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)
/// </summary>
/// <param name="mnemonic"></param>
/// <param name="network"></param>
/// <param name="accountPath">Account-level derivation path, e.g. m/48'/1'</param>
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}");
}

//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
};

/// <summary>
/// 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
/// </summary>
/// <param name="encryptedSeedphraseBase64"></param>
/// <param name="kmsKeyId"></param>
public static string BuildEnvValue(string encryptedSeedphraseBase64, string kmsKeyId)
{
var config = new SignPSBTConfig
{
EncryptedSeedphrase = encryptedSeedphraseBase64,
AwsKmsKeyId = kmsKeyId
};

return JsonSerializer.Serialize(config, EnvValueSerializerOptions);
}

/// <summary>
/// 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
/// </summary>
/// <param name="manifest"></param>
/// <param name="mnemonic"></param>
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}");
}
}
47 changes: 47 additions & 0 deletions RemoteSigner.SeedCeremony/CeremonyManifest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using System.Text.Encodings.Web;
using System.Text.Json;

namespace RemoteSigner.SeedCeremony;

/// <summary>
/// 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
/// </summary>
public sealed class CeremonyManifest
{
public required string EnvName { get; init; }

/// <summary>Byte-exact MF_* env var value (serialized SignPSBTConfig)</summary>
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; }

//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()
{
return JsonSerializer.Serialize(this, SerializerOptions);
}

public static CeremonyManifest FromJson(string json)
{
return JsonSerializer.Deserialize<CeremonyManifest>(json)
?? throw new ArgumentException("The manifest could not be deserialized", nameof(json));
}
}
71 changes: 71 additions & 0 deletions RemoteSigner.SeedCeremony/Commands/EncryptAndEmit.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
using NBitcoin;

namespace RemoteSigner.SeedCeremony.Commands;

/// <summary>
/// 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
/// </summary>
public static class EncryptAndEmit
{
public const string DefaultDerivationPath = "m/48'/1'";

public static async Task<int> 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());
Comment thread
Jossec101 marked this conversation as resolved.
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 <manifest>' 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;
}
}
Loading
Loading