Chapter 14 · 15 · 16

What we hold.
What we don't. What we audit.

Card integrations use tokenization and controlled reveal paths; legacy sealed-card data may also exist. Crypto signing uses CDP or sealed-local-key configuration. AP2 keys use encryption at rest. These implementation details are not a compliance attestation.

VGS Card tokenization
configured CDP signer Crypto signing
AES-256-GCM AP2 keys at rest
Implementation notes
Audit trail

Three classes of secrets. Three storage models.

The sections below describe card-data handling, configured crypto signing and AP2 key storage. Review each execution path and deployment configuration for its actual key-handling boundaries.

① Card PAN / CVV — tokenization and legacy seals

Card storage supports VGS aliases and legacy encrypted seals. Authorized reveal and checkout paths may handle raw card data. Review the actual provider flow and access controls rather than assuming raw PANs never reach the application.

The application server only ever holds the VGS alias (e.g. tok_card_1aB2cD3eF4gH5iJ6kL7mN8oP), the last-4, and the masked PAN. The full PAN exists only:

  • Inside Stripe / Airwallex (the issuer)
  • In the VGS vault
  • Briefly in the user's browser when explicitly revealed
cards table — what we actually storepostgresql
CREATE TABLE cards ( id uuid PRIMARY KEY, agent_identity_id uuid REFERENCES agent_identities, provider card_provider, -- stripe | airwallexprovider_card_id text, -- ic_… or aw_…vgs_alias text, -- tok_card_…last_four varchar(4), brand text, -- visa | mcbalance_cents int, status card_status, created_at timestamptz DEFAULT now()-- NO pan, NO cvv, NO exp month/year);

② Crypto private keys — Coinbase configured CDP signer

USDC signing uses CDP when configured; code also includes a sealed-local-key development/test fallback. x402 network support depends on the configured route; verify Base or Base Sepolia configuration before use.

The configured CDP path requests a signature through the provider API. The wallet address (0xabc…) and the CDP wallet ID are the only crypto-side identifiers we persist.

x402 mechanics.The x402 implementation signs EIP-3009 typed data using the configured signer. CDP and sealed-local-key paths have different key-handling responsibilities.transferWithAuthorizationtyped-data digest and use the configured signer. Key handling differs between CDP and sealed-local-key paths.

③ AP2 mandate keys — AES-256-GCM at rest

AP2 mandates are signed with an ES256 (P-256) keypair per agent. The public JWK is stored as jsonb; the private JWK is encrypted with AES-256-GCM using a master key from environment (AP2_KEY_ENCRYPTION_KEY) and stored as a binary blob.

encryption flowtypescript
import { createCipheriv, randomBytes } from 'crypto';function encryptAp2PrivateKey(privJwk: JWK) {const iv = randomBytes(12);const cipher = createCipheriv('aes-256-gcm', Buffer.from(process.env.AP2_KEY_ENCRYPTION_KEY, 'hex'), iv );const enc = Buffer.concat([ cipher.update(JSON.stringify(privJwk), 'utf8'), cipher.final() ]);return { iv, authTag: cipher.getAuthTag(), ciphertext: enc };}

API keys — fingerprint, not retention

Per-agent pak_… keys are returned once at creation or rotation. We persist:

  • SHA-256 fingerprint — the lookup token. Constant-time compared on every request.
  • First 12 chars — UI hint (pak_live_a1f9c2…).
  • Last 4 chars — UI hint.
  • Last rotated timestamp — for compliance.

If a Principal loses the raw key, there is no recovery: rotate it. The new key is shown once and never logged.

Inventory

SecretWhere it livesHow we touch it
Card PAN / CVVStripe / Airwallex / VGSnever
Crypto private keyCoinbase configured CDP signernever
AP2 private keyPostgres · AES-256-GCMDecrypt at sign time, in-memory only
Agent API keyReturned once · SHA-256 storedConstant-time fingerprint check
Principal sessionHttpOnly · Secure cookie30d sliding · rotated each request
CDP API keyEnvironment variableRead-once at boot
Stripe / Airwallex SKEnvironment variableOutbound through VGS proxy

Principals authorize. Agents act. Both signatures travel.

An AP2 IntentMandate is signed by the Principal, not the agent. Subsequent CartMandate and PaymentMandate are signed by the agent under that intent. Verifiers downstream see the chain — they can prove a Principal authorized the spending envelope.

Why two-key signing matters

If an agent's API key is leaked, an attacker can attempt cart/payment mandates — but they can't forge an IntentMandate without the Principal's key. The Principal's signing key is held in their browser via WebCrypto (non-extractable), backed by a hardware authenticator if the device supports it.

Current state. Browser-side Principal signing exists for IntentMandate. The fallback path — server-held Principal keys for Principals who haven't onboarded WebAuthn — is in production for compatibility. Phasing this out, with a hard cutover to client-side signing only, is tracked in Roadmap → Q3.

Step 1: Intent
P
Principal signs IntentMandate
WebCrypto / WebAuthn
Step 2: Cart
A
Agent signs CartMandate
AES-256-GCM AP2 key
Step 3: Payment
$
Agent signs PaymentMandate
Requires Intent + Cart

Mandate verification — strict alg

Every JWS verification call locks alg: 'ES256'. The verifier rejects any incoming JWS with a different algorithm — closing the classic JWT alg: 'none' and HS-confusion attack surface.

strict verificationtypescript
await jwtVerify(token, jwk, {algorithms: ['ES256'], // ← only ES256, never accept othersissuer: expectedDid, audience: myEndpoint, clockTolerance: '30s'});

Replay protection

  • jti uniqueness. Every mandate carries a UUIDv7 jti. ap2_mandate_audit has a unique index on (jti, action='execute') — replay at the executor is impossible.
  • Short expirations. IntentMandate: 24h. CartMandate: 1h. PaymentMandate: 1h. clockTolerance capped at 30s.
  • Single-use SPTs. Stripe Shared Payment Tokens are scoped to one merchant + one amount + one window.
  • EIP-3009 nonce. Each authorization uses a fresh nonce; the contract enforces single-use.

Append-only. Every system.

The application records ledger, mandate and activity events on supported paths. Database-level retention and immutability depend on deployment permissions and operational practices.

Audit tables

TableWhat's loggedIndexed by
agent_wallet_transactionsEvery cent that crosses a walletagent_identity_id, created_at
agent_identity_activity_logMCP tool calls, approvals, key rotationsagent_identity_id, action, created_at
ap2_mandate_auditEvery sign / verify / executejti, action, agent_identity_id
acp_spt_issuancesStripe SPTs minted by an agentagent_identity_id, stripe_token_id
acp_card_token_issuancesVGS card tokens issued for outboundagent_identity_id, vgs_alias
admin_audit_logForce-approves, manual top-ups, freezesadmin_id, action, created_at
agent_portal_session_logPrincipal logins · IP · user-agentprincipal_id, created_at

What "append-only" means here

None of the audit tables have UPDATE or DELETE triggers exposed in application code. Drizzle queries are explicit insert() only on these tables. Postgres REVOKE UPDATE, DELETE at the role level for the application user is in the operations runbook for production.

Honest disclosure.Database-level immutability depends on applied role grants. Application audit behavior alone is not an attestation or an immutable-storage guarantee.

Mandate audit — what each row contains

ap2_mandate_audit rowjson
{"id": "01J9aud…","jti": "01J9mnd…","action": "verify", // sign | verify | execute | error"agent_identity_id": "ag_01J9…","mandate_type": "payment","counterparty_did": "did:agentwallet:ag_remote…","result": "ok", // ok | error"error_code": null,"claims_snapshot": { /* full payload */ },"created_at": "2026-05-04T18:42:01Z"}

Runbooks. Every common failure.

Operations is one of the chapters that survived the deepest revisions. These are the runbooks that wake on-call.

Master CDP balance is low

Symptom. getMasterUsdcBalance() < 10 USDC alarm fires. Agent USDC sends start failing with INSUFFICIENT_MASTER_FUNDS.

  • Confirm supported USDC funding instructions for the configured wallet and network.
  • Verify with cdp.wallet(MASTER_ID).getBalance('USDC').
  • Existing pending tool calls retry automatically; no replay needed.

Widget approval queue depth

Symptom. listAllApprovals().filter(p => p.status === 'pending').length > 50.

  • Check Principal SMS/email delivery (AgentSim / AgentMail dashboards).
  • If a single Principal owns the bulk: reach out, escalate, or use force-approve with admin audit.
  • If global: a notification provider may be down. Failover to the secondary channel.

AP2 verification spike

Symptom. ap2_mandate_audit rows with result='error' spike.

  • Group by error_code and counterparty DID. Most common: SIG_INVALID, EXPIRED, NO_INTENT.
  • For SIG_INVALID from a single counterparty: their key may have rotated. Re-resolve /.well-known/agent.json.
  • For EXPIRED spike: clock drift on the executor. NTP check.

Process restart — what happens to in-flight

  • Postgres-backed: ledger entries, mandates, payouts, invoices — all durable. No loss.
  • In-memory Map: pending widget approvals are lost. The agent retries; a new approval is created. No double-spend (idempotency keys), but the Principal sees a duplicate notification.
  • MCP sessions: reconnect. Tools/list re-served on reconnect.

Required environment variables

DATABASE_URLNeon connection string
CDP_API_KEY_NAMECoinbase CDP key name
CDP_PRIVATE_KEYCoinbase CDP private key (PEM)
X402_WALLET_ADDRESSMaster wallet 0x… on Base
AP2_KEY_ENCRYPTION_KEY32-byte hex · master AES key
STRIPE_SECRET_KEYRouted via VGS outbound proxy
AIRWALLEX_API_KEYCard issuing + payouts
AGENTSIM_API_KEYPhone / SMS provisioning
AGENTMAIL_API_KEYEmail provisioning
VGS_VAULT_ID · VGS_USERNAME · VGS_PASSWORDOutbound proxy auth
ANTHROPIC_API_KEYClaude Sonnet for DE
SESSION_SECRETExpress session signing

Testing. What's there. What isn't.

We don't claim parity that doesn't exist. Here's the honest test inventory.

SurfaceCoverageStatus
AP2 mandate sign + verifySmoke test · happy pathshipped
Wallet allocateFunds advisory lockConcurrency test (2 writers)shipped
RBAC verifyAgentAccessUnit · 6 role × 12 endpoint matrixshipped
x402 EIP-3009 sign roundtripManual + Base Sepolia testnetpartial
Full DE 60s-tick replayLangSmith trace-replaypartial
End-to-end browser (Playwright)Defined · not yet shippedplanned
Chaos: Postgres restart mid-txDefined · not yet shippedplanned
Load test: 1k concurrent MCP sessionsDefined · not yet shippedplanned

Manual fixtures. The repo ships a scripts/ap2-smoke.ts and a scripts/x402-paywall-mock.ts for local end-to-end validation. Run them before any release that touches the mandate path.

Open risks. Tracked, named, not hidden.

The whole point of these docs is honesty. Here's the live list.

R-01 Wallet credit bypass.

The Principal-facing POST /agents/:id/wallet/credit calls addFunds directly — no advisory lock, no master-headroom check. The admin path enforces both.

Mitigation: Principal funder role is gated; only trusted Principals receive it.
Fix: route the portal endpoint through allocateWalletFunds. Tracked.

R-02 Widget approvals in memory.

Pending approvals live in a single in-memory Map. Process restart loses pending approvals. Single-instance only.

Mitigation: idempotency keys prevent double-spend on retry.
Fix: migrate to Redis or Postgres-backed work queue. Tracked.

R-03 DB-level append-only not universal.

Application code does not UPDATE or DELETE audit tables. Postgres role-level REVOKE is documented but not applied in every environment.

Fix: environment policy enforcement. Tracked.

R-04 Server-held Principal AP2 keys (legacy).

A subset of Principals onboarded before WebAuthn signing have keys held server-side, encrypted with AES-256-GCM.

Fix: hard cutover to client-side signing only; deprecate server-held keys. Tracked.

R-05 No formal pen-test on AP2.

AP2 is built on standard JWS primitives with strict alg pinning, but a third-party pen-test specifically targeting the mandate chain has not been completed.

Fix: pen-test scoping in flight.
Why we publish these.Review security implementation, deployment permissions and provider documentation for your account. This page is not a certification or penetration-test report.