Skip to content

Wallet security architecture: unified signer seam, generate-in-place keys with restore proof, per-wallet signing policy #673

Description

@fengtality

Motivation / threat model

Gateway decrypts every wallet key inside one Node process that also runs the HTTP server and the full SDK stacks of all DEX connectors — thousands of transitive npm dependencies. Any compromised package in that tree can read the decrypted key bytes of every wallet Gateway holds, and Gateway will sign whatever its route handlers assemble: the only check today is "did the request reach the signing code."

Recent work improved parts of this story: #659 hardened the keystore at rest (scrypt + AES-256-GCM), the Swig integration (feat/swig-solana-clean) bounds one wallet type on one chain with on-chain policy, and #662 tracks a pluggable KMS signer. This issue proposes the chain-agnostic architecture that ties those together: three composable changes, each independently useful, sequenced so each builds on the last.

The blunt version of the current trust model, which our docs should state either way: every wallet Gateway holds is fully exposed to every dependency of every connector. The proposals below change that answer.


Proposal 1 — a unified signer seam

All signing flows through one per-chain Signer interface: serialized message in, signature out. No raw Keypair/Wallet objects are ever passed into third-party connector SDKs.

Today several connectors hand the keypair to SDK functions that sign internally. Each of those call sites is a place where key material enters third-party code, and a place where no policy or alternative backend can ever be inserted without a breaking refactor.

  • Connectors build unsigned transactions; the signer seam produces signatures.
  • Where an SDK insists on a signer object, pass a shim that implements the chain's signer interface (e.g. Solana's Signer trait equivalent in web3.js, ethers' Signer) and delegates to the seam — the SDK never sees key bytes.
  • The Swig work already built this shape locally (SwigDelegateSigner, with local shipped and kms as the declared seam). This proposal promotes it from a Swig-specific detail to the wallet architecture for every wallet type.

Payoff: by itself, none visible — behavior is identical. It is enabling infrastructure: #662's KMS backend, proposal 2's restore-gating, and proposal 3's policy engine all become "one more implementation behind the interface" instead of cross-cutting refactors. That's also the honest tradeoff: refactor cost across all connectors, bought for optionality.

Proposal 2 — generate-in-place keys, with a restore ceremony

Gateway generates wallet keys itself and returns only the public key. The private key never crosses a process boundary — not pasted in over HTTP, not exportable in plaintext over the API.

Today POST /wallet/add accepts a pasted private key in an HTTP body: it transits the network layer, lands in JS heap as a string, and normalizes the pattern of keys moving between systems in plaintext. A generate-in-place endpoint eliminates the import path for new wallets.

The tradeoff is durability: an imported key is inherently backed up (the user pasted it from somewhere); a generated key's only copy is one encrypted file on one host. Disk failure or a lost Docker volume becomes permanent fund loss. So this proposal must ship with a backup story — and the trap to avoid is a plaintext export endpoint, because an export path reachable over the API is an exfiltration path for any attacker who can call the API.

Backup design

  • At generation, Gateway immediately produces a sealed backup blob encrypted to a recipient key the user supplies up front (e.g. an age recipient whose secret half lives offline — paper, hardware token, password manager). The blob is safe to hand out over the API because it is useless without the offline identity, and it is sealed to a different secret than the Gateway passphrase, so it survives passphrase loss.
  • Optional consumer-familiar path: one-time BIP39 mnemonic display at generation. Strictly weaker (the secret crosses the screen once) but familiar; offer it as the casual default with the sealed-blob path for operators.

Restore ceremony (upfront proof)

A backup that has never been restored is not a backup. Make verification a protocol invariant, not a docs recommendation:

  1. Generate → wallet enters state pending-restore-proof. It can receive funds (deposits can't be blocked) but cannot sign — enforced as one rule at the signer seam from proposal 1.
  2. Restore elsewhere → the user takes the sealed blob + offline identity to any machine (ideally a different one), where a small restore tool reconstructs the keypair and signs a challenge nonce issued at generation time.
  3. ProvePOST /wallet/verify-restore with {address, signature}. Valid signature against the pinned pubkey ⇒ state flips to active.

The challenge-signature proves the user reconstructed a working signing key from the backup alone, and the proof travels back as 64 harmless bytes — the recovery secret never touches the primary host again. The ordering is self-protecting: a wallet stuck in pending never signed anything, so it is safely discarded and regenerated.

Periodic restore drill

A restore proven at t=0 says little at t=2 years. Add a documented no-funds drill (suggested quarterly): restore the blob to a throwaway context, sign a fresh challenge, submit; Gateway records lastRestoreProofAt. Staleness warns in wallet status — it must not disable signing; bricking a production bot over a lapsed drill is the kind of security that gets features turned off.

Known honest limits: a user can "verify" on the same host seconds after generation with blob and identity in the same directory — that proves little, can't be prevented, and shouldn't contort the design. The ceremony's job is making the lazy default be "backup verified once" instead of "backup never tested."

Proposal 3 — per-wallet signing policy

Each wallet gets an optional policy config declaring what it may sign, enforced at the signer seam by parsing the actual transaction bytes — not the request that claimed to describe them.

# illustrative
allowedPrograms:            # Solana: program IDs / EVM: contract addresses
  - <dex program>
  - <token programs, system>
pinnedPools:
  - <pool address>
spendCaps:
  USDC: 5000 / 24h          # rolling notional
sweepDestination: <treasury> # bare transfers allowed ONLY to this address

Semantics, all non-negotiable for the policy to mean anything:

  • Allow-list shaped: an instruction that matches nothing is a refusal, never a pass-through.
  • Refuse what you can't parse — including v0 messages whose policy-relevant accounts hide behind unresolvable address lookup tables.
  • No bypass hatch. No unsafeAllowAll flag, ever; the moment it exists the engine is decorative.
  • Refusals are logged with a reason; a refusal spike is an incident signal.
  • The sweep destination is a config-side constant, not a request parameter (a per-mint destination limit would block swaps — the pool vault is the destination of a swap — which is why cap + pinned-sweep is the right shape, a lesson from the Swig integration).

Where this sits relative to the existing options:

enforcement scope survives host compromise extra trust root
Swig (feat/swig-solana-clean) on-chain Solana, Swig wallets Swig program + its upgrade key
Proposal 3 in-process, at the seam every chain, every wallet ❌ (key still on the box) none
External signer / KMS (#662) separate process/service per backend partially backend

They compose rather than compete: proposal 3 defeats the most likely attacks (poisoned dependency, compromised API caller) for everyone with zero extra infrastructure; Swig or a KMS backend can layer on top for the host-compromise tail. For a Swig wallet, the in-process policy additionally hedges against a poisoned Swig SDK, since Gateway independently parses what the SDK built.

It changes the user-facing answer from "a bad dependency loses everything in every wallet" to "an attacker can trade on your allowlisted pool within your caps until noticed, and move funds only to your own treasury." That is the difference between software users test with $100 and software they run with real inventory.


Suggested sequencing

  1. Signer seam (proposal 1) — first, because Swig: add KMS/HSM delegate-signer backend (keyless custody) #662 needs the same seam and everything else bolts onto it.
  2. Generate-in-place + restore ceremony (proposal 2) — quick win once the seam exists (pending-restore-proof ⇒ refuse to sign is one policy rule); plus the trust-model paragraph in the docs.
  3. Per-wallet policy (proposal 3) — the headline feature; per-chain parsers can land incrementally (Solana first, EVM after), with wallets lacking a policy file behaving exactly as today.

All three are opt-in / non-breaking: existing imported wallets keep working, wallets without a policy are unrestricted, and the seam is internal refactoring.

Open questions

  • Minimum viable policy vocabulary per chain (Solana program-id + pool pinning is straightforward; EVM needs to-address + selector allowlists and approval-amount checks).
  • Whether the rolling spend-cap counter should persist across restarts (restart-resets is attacker-favorable; persistence adds state).
  • Mnemonic path verification UX (shuffled-word quiz vs delayed challenge).
  • Whether POST /wallet/add (key import) should eventually warn, be dev-mode-only, or remain as-is for migration ergonomics.

Related: #659 (hardened keystore, shipped), #662 (KMS/HSM signer backend), feat/swig-solana-clean (on-chain policy for Swig wallets on Solana).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions