Skip to content
Draft
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
63 changes: 63 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,69 @@ Example (json-like structure of key-value, `ed0210c8` is the master fingerprint
}}
```

## Spark signing

The same function also signs for NodeGuard's [Spark](https://www.spark.money/) wallet, which NodeGuard uses as a swap-out provider: it receives a Lightning payment on Spark and exits it on-chain to a NodeGuard wallet. Requests under `/spark/` follow the `NSpark.RemoteSigner` contract from NSpark (the [Elenpay/nspark](https://github.com/Elenpay/nspark) fork of [orklabs/nspark](https://github.com/orklabs/nspark), in [vendor/nspark](vendor/nspark/)) (`docs/remote-signer.md` there). Every other path, including none, is the PSBT signer above, unchanged.

Spark is off until `SPARK_SEED_FINGERPRINT` is set. Until then every `/spark/` request gets a 403.

### Which seed signs for Spark

Spark reuses one existing `MF_` seed, and only that one: `SPARK_SEED_FINGERPRINT` names it. The Spark keys are derived from it at `m/8797555'/{SPARK_ACCOUNT}'/…`, apart from the PSBT keys.

- Requests for any other wallet are refused before KMS is called.
- A seed marked `Compromised` is refused. Before marking the Spark seed compromised or rotating it, drain NodeGuard's Spark balance; the NodeGuard rotation runbook has the step.
- The decrypted mnemonic must be valid BIP-39 and must reproduce its `MF_` fingerprint.
- Only the derived Spark account keys are cached in memory, for `SPARK_SIGNER_CACHE_SECONDS`. The mnemonic and master key are not cached.

Because this seed also signs PSBTs, a bug on the Spark path could reach it. The protections are the single allowlisted seed, the routing that sends only `/spark/` requests to Spark code, the policy below, and the NSpark native library rebuilt from Lightspark's source. NodeGuard also keeps Spark balances transit-only.

### Operations and policy

Served operations: `info`, `identity-public-key`, `sign-with-identity`, `sign-compact-with-identity`, `deposit-public-key`, `leaf-public-key`, `static-deposit-public-key`, `sign-leaf-frost`, `build-send-tweaks`, `build-claim-tweaks` and `build-preimage-shares`.

Never served (404): ECIES decryption, private key export, two-phase FROST nonces and adaptor keys. Leaf signatures with an adaptor key are refused (403).

| Variable | Required | Meaning |
|---|---|---|
| `SPARK_SEED_FINGERPRINT` | to enable Spark | Master fingerprint of the `MF_` seed that signs for Spark |
| `SPARK_ACCOUNT` | no | Spark account index, `0` by default. It must match NodeGuard's `SPARK_ACCOUNT` |
| `SPARK_OPERATOR_KEYS` | yes | Comma-separated identity keys (compressed, hex) of the Spark operators. Key tweaks and preimage shares are only built for exactly this set. Without it, every tweak and share is refused |
| `SPARK_THRESHOLD` | with operators | The operators' signing threshold (1 to the number of operators) |
| `SPARK_ALLOWED_RECEIVERS` | yes | Comma-separated identity keys (compressed, hex) that leaves may be sent to: the SSP. The signer's own identity is always allowed |
| `SPARK_SIGNER_CACHE_SECONDS` | no | How long derived Spark keys stay cached. Default 60; 0 disables |

A malformed policy refuses every Spark request with a 500 before any seed is decrypted. `/spark/info` returns the identity and the policy, and NodeGuard checks both at startup against its own configuration.

To get the identity NodeGuard pins as `SPARK_IDENTITY_PUBKEY`, run the ceremony's `verify` on the seed's manifest with the Spark account. It prints only public data:

```bash
dotnet run --project RemoteSigner.SeedCeremony -- verify --in manifest.json --spark-account 0
```

### Enabling Spark on a deployed function

1. Raise the memory to 512 MB once: `just set-memory stg` (or `prod`).
2. Add the `SPARK_*` variables with the snapshot → merge → apply flow above, merging a file instead of a manifest entry:

```bash
jq --slurpfile s spark-env.json '{Variables: (. + $s[0])}' \
"env-$FN-$TS.json" > "env-$FN-merged.json"
```

`spark-env.json` holds the `SPARK_*` keys. They add about 400 bytes, so check that `wc -c` stays under Lambda's 4 KB environment limit.
3. Call `/spark/info` from NodeGuard (it does so at startup) and compare the identity with the ceremony's output.

Spark and PSBT requests share the function's concurrency. NodeGuard sends its Spark requests one at a time, so they hold at most one execution. If the function has reserved concurrency, leave room for that plus PSBT signing.

### Local smoke test

```bash
just spark-smoke-local
```

This builds the image and runs it under the Lambda runtime interface emulator with a throwaway [floci](https://hub.docker.com/r/floci/floci) KMS and the public dev seed (`ed0210c8`). It checks that `/spark/info` serves the dev seed's Spark identity and refuses other wallets, and that `/` still signs the golden PSBT byte-for-byte. It never touches AWS. It needs docker, the aws CLI, jq and dotnet.

## CI pipeline

The project uses GitHub Actions for continuous integration. The workflow is defined in `.github/workflows/ci.yml` and triggers on three events:
Expand Down
14 changes: 14 additions & 0 deletions RemoteSigner.SeedCeremony/Ceremony.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,20 @@ public static CeremonyResult Derive(Mnemonic mnemonic, Network network, KeyPath
return new CeremonyResult(masterFingerprint, accountXpub, $"MF_{masterFingerprint}");
}

/// <summary>
/// The Spark identity public key (hex) the remote signer serves for this seed at a Spark account:
/// the key at m/8797555'/{account}'/0', as NSpark and the Spark SDKs derive it. NodeGuard pins it
/// as SPARK_IDENTITY_PUBKEY. NBitcoin only, so the ceremony does not load the Spark libraries
/// </summary>
/// <param name="mnemonic"></param>
/// <param name="account">The signer's SPARK_ACCOUNT</param>
public static string SparkIdentityPublicKey(Mnemonic mnemonic, int account)
{
if (account < 0) throw new ArgumentOutOfRangeException(nameof(account), "The Spark account must be 0 or more");

return mnemonic.DeriveExtKey().Derive(KeyPath.Parse($"m/8797555'/{account}'/0'")).GetPublicKey().ToHex();
}

//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()
Expand Down
11 changes: 11 additions & 0 deletions RemoteSigner.SeedCeremony/Commands/VerifyCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ namespace RemoteSigner.SeedCeremony.Commands;
/// <summary>
/// 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.
/// With --spark-account, also prints the Spark identity the signer will serve for the seed.
/// Never prints the seed
/// </summary>
public static class VerifyCommand
Expand All @@ -14,6 +15,11 @@ public static async Task<int> Run(Options options)
{
var inPath = options.Require("--in");

var sparkAccountFlag = options.Get("--spark-account");
var sparkAccount = -1;
if (sparkAccountFlag != null && (!int.TryParse(sparkAccountFlag, out sparkAccount) || sparkAccount < 0))
throw new UsageException("--spark-account must be the signer's SPARK_ACCOUNT (0 or more)");

var manifest = CeremonyManifest.FromJson(await File.ReadAllTextAsync(inPath));

var expectedXpub = options.Get("--xpub");
Expand Down Expand Up @@ -56,6 +62,11 @@ public static async Task<int> Run(Options options)

Console.WriteLine($"VERIFY OK: {manifest.EnvName} decrypts and re-derives fingerprint {manifest.MasterFingerprint} and xpub {manifest.AccountXpub} ({manifest.DerivationPath} on {manifest.Network})");

if (sparkAccountFlag != null)
{
Console.WriteLine($"Spark identity at SPARK_ACCOUNT={sparkAccount} (NodeGuard's SPARK_IDENTITY_PUBKEY): {Ceremony.SparkIdentityPublicKey(mnemonic, sparkAccount)}");
}

return 0;
}
}
5 changes: 3 additions & 2 deletions RemoteSigner.SeedCeremony/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,11 @@
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 <manifest.json> [--xpub <expected>]
seed-ceremony verify --in <manifest.json> [--xpub <expected>] [--spark-account <n>]
[--profile <p>] [--region <r>]
Preflight gate: KMS-decrypt the manifest's env value and check that the
fingerprint, env var name and account xpub all re-derive identically.
With --spark-account, also print the seed's Spark identity at that account.

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.
Expand Down Expand Up @@ -57,7 +58,7 @@ The mnemonic is never accepted as a command-line argument.
"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")),
"--in", "--xpub", "--spark-account", "--profile", "--region")),
_ => throw new UsageException($"Unknown command '{command}'")
};
}
Expand Down
12 changes: 12 additions & 0 deletions RemoteSigner.Tests/SeedCeremonyTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Amazon.KeyManagementService.Model;
using FluentAssertions;
using NBitcoin;
using NSpark.Signer;
using RemoteSigner.SeedCeremony;
using Xunit;

Expand All @@ -17,6 +18,17 @@ public class SeedCeremonyTest
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";

[Theory]
[InlineData(0)]
[InlineData(1)]
public async Task SparkIdentityPublicKey_IsTheIdentityTheSignerServes(int account)
{
var served = await SparkSigner.FromMnemonic(DevMnemonic, account).GetIdentityPublicKeyAsync();

Ceremony.SparkIdentityPublicKey(new Mnemonic(DevMnemonic), account).Should()
.Be(Convert.ToHexString(served).ToLowerInvariant());
}

[Theory]
[InlineData("regtest", "tpub")]
[InlineData("testnet", "tpub")]
Expand Down
3 changes: 1 addition & 2 deletions RemoteSigner/aws-lambda-tools-defaults.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"region" : "eu-central-1",
"configuration" : "Release",
"package-type" : "Image",
"function-memory-size" : 256,
"function-memory-size" : 512,
"function-timeout" : 30,
"image-command" : "RemoteSigner::RemoteSigner.Function::FunctionHandler",
"docker-host-build-output-dir" : "./bin/Release/lambda-publish",
Expand All @@ -21,7 +21,6 @@
"function-subnets" : "",
"function-security-groups" : "",
"tracing-mode" : "PassThrough",
"environment-variables" : "\"MF_ed0210c8\"=\"{\"EncryptedSeedphrase\":\"AQICAHheBtxW\\u002B2iTBvvhvmRXaxaScHh6up1/VWCRSMlopexrdwE1C/ylXBL5pmjJ3P/UG7XnAAABBzCCAQMGCSqGSIb3DQEHBqCB9TCB8gIBADCB7AYJKoZIhvcNAQcBMB4GCWCGSAFlAwQBLjARBAxPlkxPX65p7aRcXykCARCAgb4En2Bb/nWQ6m4i3JDP\\u002BKGjaGDAVF4LR6\\u002B2Ljl7orp6pfZbCCxK6e89OBpJWi7elQM670vD/SWkYSZ9MUWUshU8n7NyBJZuZgBhtaH6j6yDhgHtBv7cwJngv0d72QEaTrH2YqLCVuoddEKEpB13ezfkf56230QD134kcJze4fITQGA6sXxQ0x\\u002BWjKOeYltpB\\u002BShk4\\u002BkaNja42ZM0MMjyrMOmQtXCkgdoTUVi6twiqU\\u002Bqr8mQEEq0aNdZzlLCI/v\",\"AwsKmsKeyId\":\"mrk-cec3e3ef59bc4616a6f44da60bfea0ba\"}\"",
"dockerfile" : "Dockerfile",
"docker-build-working-dir" : "..",
"image-tag" : "nodeguardremotesigner:latest"
Expand Down
13 changes: 12 additions & 1 deletion justfile
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ 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

# One-off: give the function the memory its Spark routes need (FROST signing, NSpark libraries)
set-memory env='stg' size='512':
aws lambda update-function-configuration --no-paginate --function-name arn:aws:lambda:eu-central-1:839166930136:function:SignPSBT-{{env}} --memory-size {{size}}
aws lambda wait function-updated-v2 --function-name arn:aws:lambda:eu-central-1:839166930136:function:SignPSBT-{{env}}

# 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}}
Expand Down Expand Up @@ -50,4 +55,10 @@ ceremony-test-local endpoint='http://localhost:4566':
| 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)"
echo "Local emulator ceremony smoke test OK (fingerprint ed0210c8 round-tripped)"

# Smoke-test the real image locally, never touching AWS: the Lambda runtime interface emulator with a
# throwaway floci KMS and the PUBLIC dev vector (ed0210c8) as the Spark seed. Checks /spark/info
# serves the dev Spark identity and / still signs the golden PSBT byte-for-byte.
spark-smoke-local floci='floci/floci:1.5.25':
scripts/spark-smoke-local.sh {{floci}}
94 changes: 94 additions & 0 deletions scripts/spark-smoke-local.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
#!/usr/bin/env bash
# Smoke-tests the real signer image locally, never touching AWS: the Lambda runtime interface
# emulator (built into the AWS base image) runs it against a throwaway floci KMS, with the PUBLIC
# dev test vector (fingerprint ed0210c8) as the Spark seed. Checks both routes:
# /spark/info returns the dev vector's Spark identity (the one ceremony verify prints), and
# refuses any other wallet;
# / still signs the golden PSBT byte-for-byte.
# Emulator ciphertexts are throwaway by design - never reuse them.
# Usage: scripts/spark-smoke-local.sh [floci-image] (needs docker, aws CLI, jq, dotnet)
set -euo pipefail

FLOCI_IMAGE="${1:-floci/floci:1.5.25}"
ROOT=$(cd "$(dirname "$0")/.." && pwd)
NAME="nodeguard-signer-smoke-$$"
IMAGE="nodeguard-remote-signer:spark-smoke"
DEV_MNEMONIC="middle teach digital prefer fiscal theory syrup enter crash muffin easily anxiety ill barely eagle swim volume consider dynamic unaware deputy middle into physical"
DEV_IDENTITY="032986ec06431b7b80a4171836174ac10c07e1cb1dff4af93082ee5f3ebca64a0a"
WORK=$(mktemp -d)

cleanup() {
docker rm -f "$NAME-kms" "$NAME-signer" >/dev/null 2>&1 || true
docker network rm "$NAME" >/dev/null 2>&1 || true
rm -rf "$WORK"
}
trap cleanup EXIT

fail() {
echo "SMOKE FAILED: $1" >&2
docker logs "$NAME-signer" 2>&1 | tail -30 >&2 || true
exit 1
}

echo "Building the signer image..."
git -C "$ROOT" submodule update --init vendor/nspark
docker build -q --platform linux/amd64 -f "$ROOT/RemoteSigner/Dockerfile" -t "$IMAGE" "$ROOT" >/dev/null

echo "Starting a throwaway KMS ($FLOCI_IMAGE)..."
docker network create "$NAME" >/dev/null
docker run -d --name "$NAME-kms" --network "$NAME" -p 127.0.0.1::4566 "$FLOCI_IMAGE" >/dev/null
export AWS_ENDPOINT_URL="http://$(docker port "$NAME-kms" 4566/tcp | head -1)"
export AWS_ACCESS_KEY_ID=test AWS_SECRET_ACCESS_KEY=test AWS_REGION=eu-central-1
for _ in $(seq 30); do aws kms list-keys >/dev/null 2>&1 && break; sleep 1; done
KEY_ID=$(aws kms create-key --query KeyMetadata.KeyId --output text)

echo "Encrypting the dev seed with the ceremony tool..."
echo "$DEV_MNEMONIC" | dotnet run --project "$ROOT/RemoteSigner.SeedCeremony" -- \
encrypt --kms-key-id "$KEY_ID" --network regtest --out "$WORK/manifest.json" >/dev/null

{
echo "AWS_ENDPOINT_URL=http://$NAME-kms:4566"
echo "AWS_ACCESS_KEY_ID=test"
echo "AWS_SECRET_ACCESS_KEY=test"
echo "AWS_REGION=eu-central-1"
echo "MF_ed0210c8=$(jq -r .EnvValue "$WORK/manifest.json")"
echo "SPARK_SEED_FINGERPRINT=ed0210c8"
echo "SPARK_ACCOUNT=0"
} > "$WORK/signer.env"

echo "Starting the signer under the Lambda runtime interface emulator..."
docker run -d --platform linux/amd64 --name "$NAME-signer" --network "$NAME" --env-file "$WORK/signer.env" \
-p 127.0.0.1::8080 "$IMAGE" RemoteSigner::RemoteSigner.Function::FunctionHandler >/dev/null
RIE="http://$(docker port "$NAME-signer" 8080/tcp | head -1)/2015-03-31/functions/function/invocations"

# A Function URL event (payload format 2.0) for a POST of $2 to path $1
invoke() {
jq -n --arg path "$1" --arg body "$2" \
'{version: "2.0", rawPath: $path, body: $body, isBase64Encoded: false,
requestContext: {http: {method: "POST", path: $path}}}' |
curl -s --fail --retry 30 --retry-all-errors --retry-delay 1 -X POST "$RIE" -d @-
}

INFO=$(invoke /spark/info '{"wallet":"ed0210c8"}') || fail "the signer did not answer"
[ "$(jq -r .statusCode <<<"$INFO")" = 200 ] || fail "/spark/info: $INFO"
IDENTITY=$(jq -r '.body | fromjson | .result.identityPublicKey' <<<"$INFO")
[ "$IDENTITY" = "$DEV_IDENTITY" ] || fail "/spark/info returned identity $IDENTITY, expected $DEV_IDENTITY"
echo "OK /spark/info -> Spark identity $IDENTITY"

CEREMONY=$(dotnet run --project "$ROOT/RemoteSigner.SeedCeremony" -- verify --in "$WORK/manifest.json" --spark-account 0)
grep -q "$DEV_IDENTITY" <<<"$CEREMONY" || fail "ceremony verify printed another Spark identity: $CEREMONY"
echo "OK ceremony verify --spark-account 0 -> the same identity"

OTHER=$(invoke /spark/info '{"wallet":"1fcce4de"}') || fail "the signer did not answer"
[ "$(jq -r .statusCode <<<"$OTHER")" = 403 ] || fail "/spark/info for another wallet was not refused: $OTHER"
echo "OK /spark/info for another wallet -> 403"

GOLDEN=$(jq -c '.[] | select(.name == "p2wsh-2of3-all")' "$ROOT/RemoteSigner.Tests/Fixtures/golden-psbt-signatures.json")
SIGNED=$(invoke / "$(jq -c '{Psbt: .psbt, EnforcedSighash: 1, Network: .network}' <<<"$GOLDEN")") ||
fail "the signer did not answer"
[ "$(jq -r .statusCode <<<"$SIGNED")" = 200 ] || fail "PSBT signing: $SIGNED"
[ "$(jq -r '.body | fromjson | .Psbt' <<<"$SIGNED")" = "$(jq -r .expected <<<"$GOLDEN")" ] ||
fail "the signed PSBT differs from the golden output"
echo "OK / -> golden PSBT signed byte-for-byte"

echo "Local Spark smoke test OK"
Loading