▸ prd.vault

vault

21 — Trust Boundaries

docs/21-Trust-Boundaries.mdtype: security-architectureupdated: 2026-08-10

Trust Boundaries

This page cuts the system by trust, deliberately not by architecture or data flow — those live in 17-System-Design and 20-Data-Flows. Here the only question is: who is trusted with what, and what stands at each crossing.

Trust zones

Zones are ordered from untrusted outward to most restricted inward. A component belongs to exactly one zone; every arrow between zones in the diagram below is a controlled crossing.

Zone Holds Trust assumption
Z0 — Public internet Browsers, merchant REST clients, provider webhook senders, attackers None. Everything arriving is hostile until authenticated
Z1 — Edge WAF, ALB, TLS termination AWS-managed; enforces rate and origin policy before the app sees a byte
Z2 — Application Web containers Authenticated identity per request; holds session and API-key verification logic
Z3 — Execution Worker No inbound network path at all; trusted to move money because only it talks to custody for transfers
Z4 — Data PostgreSQL The system of record: ledger, sessions, queues, audit. Compromise here is compromise of everything
Z5 — Control plane Secrets Manager, KMS, SSM Holds the credentials for every other zone; access is IAM-scoped per role
Z6 — Custody The Fireblocks API Co-Signer host and the callback handler, in their own AWS account and VPC Holds the MPC key share and the key the Co-Signer trusts for approvals. No application credential can reach it; it reaches the application only through read-only attestation endpoints. See 28-External-Wallet-Strategy
ZX — External providers Fireblocks, Sumsub, Mercuryo, the chain node, Elliptic, the sanctions/risk service, CEX.IO, Resend, Better Stack Contracted parties, each trusted for one function and nothing else. See ZX — what each provider actually receives
flowchart TB subgraph z0["Z0 — untrusted"] actor["Browser · REST client · webhook sender"] end subgraph z1["Z1 — edge"] edge["WAF rules + per-IP rate limit<br/>TLS 1.2+, admin host allowlist"] end subgraph z2["Z2 — application"] app["Session cookie / API key / webhook signature<br/>verified here, per request"] end subgraph z3["Z3 — execution"] wrk["Worker — no listener,<br/>no inbound path"] end subgraph z4["Z4 — data"] dbz[("PostgreSQL<br/>row-level context on financial mutations,<br/>append-only audit")] end subgraph z5["Z5 — control plane"] sec["Secrets Manager + KMS<br/>IAM role per tier, EncryptionContext pinned"] end subgraph zx["ZX — providers"] px["Custody · KYC · rates · email · accounting"] end actor -->|"TLS; nothing else promised"| edge edge -->|"only allowed requests"| app app -->|"queue rows, never direct execution"| dbz wrk -->|"advisory-locked ledger writes"| dbz app -.->|"IAM role, read"| sec wrk -.->|"IAM role, read"| sec wrk -->|"signed, idempotent calls"| px px -->|"signature verified at the inbox"| app
Figure 1 — Trust zones and their controlled crossings — each label names the control, not the payload

What each crossing enforces

Crossing Control Identity carried
Z0 → Z1 TLS termination, WAF managed rules, per-IP rate limit, admin hostname allowlist None yet — anonymous traffic
Z1 → Z2 Session cookie (NextAuth, revocable via session version) · API key (sha256-hashed, expiring) · webhook signature (per-provider scheme) The authenticated principal: user, merchant key, or provider
Z2 → Z4 Zod validation at every public boundary; financial mutations run inside transactions with per-account advisory locks and row-level context The principal's user id, attached to the transaction
Z2 → Z3 Indirect only — a pg-boss row. The web tier cannot invoke the worker; it can only persist intent Job payload carries the owning user id
Z3 → ZX Deterministic idempotency key per mutation; KYC and halt gates checked before any custody call Platform credentials, scoped to the provider
ZX → Z2 Signature verification per provider (JWS/JWKS for custody, HMAC elsewhere), then an idempotent inbox insert — business logic never sees an unverified payload The provider, proven by signature
Z2/Z3 → Z5 IAM instance role; KMS decrypt conditioned on EncryptionContext; secrets land on disk 0600 The tier's role, not a person
ZX → Z6 The Co-Signer verifies each signing/approval request from Fireblocks inside the Nitro enclave, then asks the callback handler over HTTPS with an RS256 JWT; the handler answers with its own RS256 JWT. No inbound path into Z6 exists from anywhere else The Co-Signer, proven by its key
Z6 → Z2 Bearer held only by the handler, WAF rule admitting only the custody NAT address to /api/custody/*, read-only routes; the answer is an Ed25519-signed attestation the handler pins, so the bearer alone cannot mint an approval The handler; the attestation names the wallet or transfer it vouches for

ZX — what each provider actually receives

"Trusted for their function" is only meaningful if the function is bounded. Each row below is the whole of what crosses that boundary: what leaves the platform, what comes back, whether personal data is involved, and what an attacker would hold if that provider — or our credential for it — were compromised.

Provider What we send What comes back Personal data Credential we hold If that provider is compromised
Fireblocks Vault and address requests keyed by merchant reference and payment-order id; transfer requests — asset, amount in minor units, destination wallet id, deterministic external tx id, a payout:batch:row note Deposit addresses, transaction status, on-chain tx hash, network fee, vault balances None. No name, email or address ever crosses this boundary API key + RSA signing key, held in Secrets Manager and written to disk 0600 The severe case: custody of funds. Bounded by transfers going only to whitelisted external wallets, and by the hourly balance reconciliation that halts outbound flow on drift
Sumsub A user id and a verification level name — nothing else Review status and verdict, applicant id, and first name / last name / country Yes, inbound. Identity documents go from the browser straight to Sumsub and never touch the platform; only the three name/country fields are persisted App token + secret key, HMAC per request Identity documents for verified users. The platform holds no copies, so this exposure is theirs, not ours to leak
Mercuryo EUR amount and a card payment token Charge status and settlement callbacks Card data never reaches the platform — the token is the only thing we see or store API key; separate webhook signing key Payment-instrument data and settlement authority for card orders
CEX.IO An asset symbol A price in EUR None. No account, order or customer identifier is sent — the request is indistinguishable between users None for public quotes Mispriced internal conversions. It never prices a card trade — the acquirer does that — so the exposure is quote drift, not data loss
Resend A recipient email address and the rendered message Delivery result Yes, outbound. Email addresses and whatever the message body carries API key The customer email list and the content of transactional mail, including magic links in flight
Chain node Address strings for validation, fee-estimate queries, signed transactions to broadcast Validity, fee estimates, on-chain transaction state None beyond public on-chain addresses Node or RPC credentials Chain-state lies — mitigated because custody, not this node, is the authority for whether a transfer settled
Elliptic / sanctions screening A destination address, its network and currency A risk score and sanction status Addresses only, never the account behind them API key Knowledge of which addresses the platform screens. It cannot approve a payout — a failed lookup blocks the batch rather than passing it
Better Stack Application logs, OTLP traces and worker heartbeats, passed through central redaction of auth headers, tokens, secrets, challenges and PII Nothing consumed by the platform Redacted by design — the redactor is the control, and it is the one to test Ingest tokens Operational visibility into the platform, and whatever the redactor failed to strip

Three properties hold across every row:

  • No provider holds a platform credential. Each direction authenticates with material scoped to that provider: our key for outbound, their signature for inbound. A compromised provider cannot authenticate as us to a different one.
  • No provider is trusted to be correct. Custody balances are re-checked against the ledger hourly; webhook payloads are signature-verified and schema-validated before business logic sees them; a provider reporting nonsense trips the same circuit breaker as an internal bug.
  • The blast radii do not overlap. Custody holds funds but no identity; KYC holds identity but no funds; the rate oracle holds neither and is not even told who is asking.

Privilege asymmetries worth knowing

These are the places where the boundary is thinner than the diagram suggests — stated here because a trust analysis that hides them is not one.

  • The application connects to the database as one role. Row-level context scopes financial mutations, but there is no per-tenant database credential; the application process is trusted with the whole schema.
  • Admin is a role, not a zone. Operators authenticate through the same Z1→Z2 crossing as users, gated additionally by the admin hostname allowlist at the edge and the persisted role check in the application. Every admin holds every admin capability.
  • The worker is the single most trusted process. It holds custody credentials and writes the ledger. Its compensating controls are the absence of any inbound path, idempotency on everything it sends, and the reconciliation halt that freezes outbound flow when ledger and custody disagree — see 20-Data-Flows#Flow 4 — Reconciliation, accounting and providers.
  • Providers are trusted for availability, not correctness. Custody balances are independently re-checked hourly against the ledger; a provider reporting nonsense trips the same circuit breaker as an internal bug.