From 314d82d565f26e17c7c3d3647a80f0bc0173e81a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=CC=81=20A=2EP?= <53834183+Jossec101@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:09:24 +0200 Subject: [PATCH 1/3] Document and smoke-test Spark signing; print the Spark identity at the ceremony - README: Spark signing section (which seed, served operations, policy variables, enabling it on a deployed function within the 4 KB environment limit, concurrency). - just set-memory: one-off 512 MB for the Spark routes. The tools defaults match it and no longer carry environment-variables, which a 'dotnet lambda deploy-function' would apply over the whole env map, dropping every other MF_ and SPARK_ variable. - just spark-smoke-local: the real image under the Lambda runtime interface emulator with a throwaway floci KMS and the public dev seed. Checks /spark/info, the refusal of other wallets and the golden PSBT signature. - seed-ceremony verify --spark-account prints the seed's Spark identity (NBitcoin only), which NodeGuard pins as SPARK_IDENTITY_PUBKEY. --- README.md | 63 +++++++++++++ RemoteSigner.SeedCeremony/Ceremony.cs | 14 +++ .../Commands/VerifyCommand.cs | 11 +++ RemoteSigner.SeedCeremony/Program.cs | 5 +- RemoteSigner.Tests/SeedCeremonyTest.cs | 12 +++ RemoteSigner/aws-lambda-tools-defaults.json | 3 +- justfile | 13 ++- scripts/spark-smoke-local.sh | 94 +++++++++++++++++++ 8 files changed, 210 insertions(+), 5 deletions(-) create mode 100755 scripts/spark-smoke-local.sh diff --git a/README.md b/README.md index c3874a0..d158ef1 100644 --- a/README.md +++ b/README.md @@ -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 the NSpark fork 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 in the fork. 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` | yes | Spark account index. 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: diff --git a/RemoteSigner.SeedCeremony/Ceremony.cs b/RemoteSigner.SeedCeremony/Ceremony.cs index a75c0ae..86d98eb 100644 --- a/RemoteSigner.SeedCeremony/Ceremony.cs +++ b/RemoteSigner.SeedCeremony/Ceremony.cs @@ -47,6 +47,20 @@ public static CeremonyResult Derive(Mnemonic mnemonic, Network network, KeyPath return new CeremonyResult(masterFingerprint, accountXpub, $"MF_{masterFingerprint}"); } + /// + /// 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 + /// + /// + /// The signer's SPARK_ACCOUNT + 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() diff --git a/RemoteSigner.SeedCeremony/Commands/VerifyCommand.cs b/RemoteSigner.SeedCeremony/Commands/VerifyCommand.cs index 36459e4..8400a23 100644 --- a/RemoteSigner.SeedCeremony/Commands/VerifyCommand.cs +++ b/RemoteSigner.SeedCeremony/Commands/VerifyCommand.cs @@ -6,6 +6,7 @@ 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. +/// With --spark-account, also prints the Spark identity the signer will serve for the seed. /// Never prints the seed /// public static class VerifyCommand @@ -14,6 +15,11 @@ public static async Task 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"); @@ -56,6 +62,11 @@ public static async Task 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; } } diff --git a/RemoteSigner.SeedCeremony/Program.cs b/RemoteSigner.SeedCeremony/Program.cs index e2ad02c..aa6043d 100644 --- a/RemoteSigner.SeedCeremony/Program.cs +++ b/RemoteSigner.SeedCeremony/Program.cs @@ -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 [--xpub ] + seed-ceremony verify --in [--xpub ] [--spark-account ] [--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. + 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. @@ -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}'") }; } diff --git a/RemoteSigner.Tests/SeedCeremonyTest.cs b/RemoteSigner.Tests/SeedCeremonyTest.cs index 2cceabc..fb16121 100644 --- a/RemoteSigner.Tests/SeedCeremonyTest.cs +++ b/RemoteSigner.Tests/SeedCeremonyTest.cs @@ -4,6 +4,7 @@ using Amazon.KeyManagementService.Model; using FluentAssertions; using NBitcoin; +using NSpark.Signer; using RemoteSigner.SeedCeremony; using Xunit; @@ -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")] diff --git a/RemoteSigner/aws-lambda-tools-defaults.json b/RemoteSigner/aws-lambda-tools-defaults.json index c905ac7..1194254 100644 --- a/RemoteSigner/aws-lambda-tools-defaults.json +++ b/RemoteSigner/aws-lambda-tools-defaults.json @@ -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", @@ -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" diff --git a/justfile b/justfile index e7049fb..ebbf696 100755 --- a/justfile +++ b/justfile @@ -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}} @@ -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)" \ No newline at end of file + 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}} diff --git a/scripts/spark-smoke-local.sh b/scripts/spark-smoke-local.sh new file mode 100755 index 0000000..a3089ff --- /dev/null +++ b/scripts/spark-smoke-local.sh @@ -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" From ddb354b1db4c0e7d494a17dc8e3c6377f9a4847f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=CC=81=20A=2EP?= <53834183+Jossec101@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:57:10 +0200 Subject: [PATCH 2/3] README: NSpark comes from orklabs/nspark, and SPARK_ACCOUNT defaults to 0 --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d158ef1..6840482 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,7 @@ 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 the NSpark fork in [vendor/nspark](vendor/nspark/) (`docs/remote-signer.md` there). Every other path, including none, is the PSBT signer above, unchanged. +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 ([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. @@ -169,7 +169,7 @@ Spark reuses one existing `MF_` seed, and only that one: `SPARK_SEED_FINGERPRINT - 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 in the fork. NodeGuard also keeps Spark balances transit-only. +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 @@ -180,7 +180,7 @@ Never served (404): ECIES decryption, private key export, two-phase FROST nonces | Variable | Required | Meaning | |---|---|---| | `SPARK_SEED_FINGERPRINT` | to enable Spark | Master fingerprint of the `MF_` seed that signs for Spark | -| `SPARK_ACCOUNT` | yes | Spark account index. It must match NodeGuard's `SPARK_ACCOUNT` | +| `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 | From 34b89969a612b2ec69df4806f004e2146fcfe295 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=CC=81=20A=2EP?= <53834183+Jossec101@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:16:02 +0200 Subject: [PATCH 3/3] README: NSpark comes from the Elenpay/nspark fork --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6840482..74790f0 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,7 @@ 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 ([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. +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.