feat(custody): add threshold signing and safe key rotation control plane - #158
Merged
Conversation
Adds an off-chain custody control plane for issuer/distribution/treasury Stellar accounts: signer roles and weighted thresholds per operation category (issuance, payout, emergency, recovery, rotation), a SignerAdapter interface so the app process never receives raw signing secrets (testnet-only LocalDevSignerAdapter for dev/CI, a documented KMS/HSM adapter contract for production), network/source/sequence/time/memo/intent-bound envelopes with cross-network, stale-sequence, and cross-intent replay protection, overlap-safe signer-set rotation with a recovery quorum path and rollback, and tamper-evident audit logging of every approval request, approval, and ledger result via the existing audit-log infrastructure.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds an off-chain custody control plane for Stellar issuer/distribution/treasury
accounts: signer roles and weighted thresholds per operation category, a
pluggable signer adapter so the app never touches raw secrets, replay-proof
envelopes, and overlap-safe signer rotation with a recovery path.
This repo had no existing transaction-signing/custody code (confirmed by a full
audit before starting) — only public-key config and a design doc
(
docs/stellar-asset-and-soroban-design.md) specifying the target model. This PRimplements that model as a new
lib/custody/module, following existingconventions in the codebase (maker-checker + versioned state from
lib/stellar/pool-assets.ts, the local-signer-now/KMS-later pattern fromlib/security/audit-checkpoint.ts, and the tamper-evident audit log).What's done / resolved
lib/custody/policy.tsdefines defaultthresholds per category (issuance, payout, emergency, recovery, rotation),
validated against Stellar-style weighted-multisig invariants
(
validateSignerSetInvariantsrejects any threshold that would permanentlylock an account).
lib/custody/signer-adapter.tsdefines aSignerAdapterinterface (getPublicKey/sign) that never exposes keymaterial to the app.
LocalDevSignerAdapteris a testnet-only referenceimplementation (ephemeral in-memory keys, never persisted) that is
structurally incapable of running against mainnet.
createExternalSignerAdapter()is the production contract stub — no vendor is mandated, per the issue's
non-goal.
lib/custody/envelope.tsbinds all of these into one hash; replay isrejected across networks, stale sequences, and expired approval windows.
Cross-intent replay is rejected at submission time by recomputing the actual
on-chain operations and comparing their hash to what signers approved
(
lib/custody/operations.ts).require a
payoutPolicy(destination allowlist + per-operation/daily limits)on the active signer set, enforced before any request is created. Every
approval is checked against real, currently-authorized signers — no
fabricated identities can satisfy quorum (this was caught and fixed during
review, see below).
lib/custody/rotation.ts— propose → approve → activate (old set enters a "retiring" overlap window,
both sets valid) → retire (refuses while any request is still pending against
the old set) → rollback (reactivates the previous set safely, never leaving
zero active sets). A
recoveryquorum path handles lost/compromised signers.ledger submission result is written through the existing
logAuditEvent(..., criticalAction: true)into the append-only hash-chainedaudit log.
docs/custody-signer-rotation.mdcovers signercompromise, lost signer, stuck sequence, partial signature/signer outage, and
outage scenarios, plus known limitations.
Required tests (
__tests__/lib/custody/, 81 tests, all passing)Threshold matrices and invariant rejection, stale-sequence rejection,
cross-network/cross-intent replay rejection, rotation with pending approvals
(retirement blocked), signer outage (quorum tolerates non-participating
signers), recovery-quorum rotation, and secret-scanning (no raw key material
ever returned by the signer adapter or logged).
Process note
This went through two rounds of independent code review. The first found a
critical gap (
approveRotationdidn't verify approvers were real signers,letting fabricated identities satisfy quorum) plus 4 lower-severity issues
(a rollback ordering bug that could zero out active signer sets, the payout
allowlist/limits being implemented but never wired in, quorum counting heads
instead of signer weight, and the default submit path never producing an
"ambiguous" classification for network timeouts). All 5 were fixed and
re-reviewed to a clean pass; 3 minor non-blocking edge cases are documented
under "Known limitations" in the runbook doc.
Non-goals honored
No custody vendor (KMS/HSM provider) is mandated — the adapter interface is
vendor-agnostic by design.
Closes #131