Skip to content

Sign MPC results in NATS headers and verify them on the client - #174

Closed
vietddude wants to merge 2 commits into
masterfrom
feat/result-header-signing
Closed

vietddude wants to merge 2 commits into
masterfrom
feat/result-header-signing

Conversation

@vietddude

@vietddude vietddude commented Nov 5, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Nodes sign every keygen, signing and reshare result they publish. Clients verify those signatures against node public keys pinned in their own config, and can require t+1 nodes to agree before acting on a result. This replaces the approach in #171: the signature now travels in NATS headers instead of the body, so the message body is unchanged and clients don't have to rebuild the signed bytes from a struct.

How it works

  • Signing (node): each node signs "mpcium-result-v1" || subject || body with its identity key and sets Mpc-Node-Id and Mpc-Result-Sig. Including the subject stops a result being replayed on another topic. Signing happens in one queue wrapper (resultauth.NewSigningQueue), so no publish site changed.
  • Per-node copies: result_signing.per_node_results (default false) adds the node ID to the JetStream idempotency key so every node's copy is kept. With false, JetStream keeps one copy, the same as today.
  • Verification (pkg/client): new options ResultVerifyMode (off | log | enforce, default off), NodePublicKeys, RequiredNodeSignatures and RequiredNodeSignaturesForWallet. In enforce, unsigned or forged results are terminated.
  • Aggregation: successes are counted per body hash, so a forged first copy can't block honest nodes. Each copy is acked only after its result is delivered, so a crash causes redelivery. The aggregator remembers which nodes a finished operation heard from: a late copy is dropped, and a copy from a node it already heard from counts as a new attempt (retry or replay). All subscriptions of one result type share one aggregator.
  • Tooling: mpcium-cli export-node-keys builds node_public_keys.json from identity files. Any node's identity directory works, because every node holds all peers' *_identity.json. setup_identities.sh runs it; the examples and the ECS, k8s and systemd guides use it.

Compatibility

No config change is needed to upgrade. Nodes add headers that old clients ignore, the body is unchanged, and every new option defaults to the old behaviour. Turn on per_node_results on all nodes before any client sets RequiredNodeSignatures > 1. With every node off, a client that requires more than 1 receives a single copy and every result times out.

Testing

  • Unit tests (-race) for pkg/resultauth and pkg/client: tampered body, wrong subject, unknown node, forged first copy, ack after delivery, late copies, retry after error, replay, per-wallet threshold, and shared test vectors.
  • Full e2e suite (keygen, multi-client routing, reshare, CKD signing, signing) passes with enforce, t+1 = 2 and per_node_results: true.
  • Manual end-to-end run with apex in enforce (keygen and Stellar withdrawals through the UI, plus forged and unsigned results) passed.

Usage note

With RequiredNodeSignatures > 1, every copy of a result must reach one aggregator. Within a process, all subscriptions share one. Across processes, either give each process its own ClientID or consume results from a single process. Apex does the latter by consuming MPC results on its HA leader only.

Replay protection

Results are bound to their subject and body, so a result can't be moved to another topic or edited, and late copies of a finished operation are dropped. To also reject an old, validly signed result re-sent for the same operation, a client should use a TxID that is unique per signing attempt and accept results only for the current attempt. Nodes treat TxID as opaque and sign the body that contains it, so a result can't be relabelled. Apex does this by sending <txID>#<attemptID>.

Nodes sign the exact bytes they publish (domain || subject || body) with
their identity key and send Mpc-Node-Id and Mpc-Result-Sig headers. The
message body is unchanged, so existing clients keep working.

- pkg/resultauth: sign/verify, key lookup, t+1 aggregator, signing queue
- result_signing.per_node_results (default false) keeps one copy per node
- client: ResultVerifyMode off|log|enforce, NodePublicKeys,
  RequiredNodeSignatures(+ per-wallet override); copies are acked only
  after their result is delivered
- mpcium-cli export-node-keys builds node_public_keys.json from identity files
- examples, e2e (enforce, t+1), setup script and deployment guides updated
Comment thread pkg/resultauth/resultauth.go Fixed
Comment thread cmd/mpcium-cli/export-node-keys.go Fixed
- Build the signing payload with append instead of a summed capacity (CodeQL)
- Write node_public_keys.json with 0600 (gosec)
- Split Aggregator.Add into roundFor and record to cut its complexity (Sonar)
@sonarqubecloud

sonarqubecloud Bot commented Nov 5, 2026

Copy link
Copy Markdown

@vietddude

Copy link
Copy Markdown
Collaborator Author

Apex side: fystack/apex#807

@vietddude

Copy link
Copy Markdown
Collaborator Author

Superseded by #175, which targets master directly and keeps the t+1 quorum for keygen only.

@vietddude vietddude closed this Nov 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants