Security review
A network holding other people's money will be attacked, so before launch the code you have deployed was attacked on purpose. What was found, what changed and what held is what stands between your money and the world. The operating rules the review produced are folded into Deploying your own network.
The review ran on 2026-06-13 and covered AIOUM (contracts + offchain), aioum-app and the
private node: an adversarial multi-agent review across 6 attack surfaces, every finding
re-verified by a skeptic prompted to refute it, then a synthesis. Of 35 raw findings, 21 were
confirmed (5 High, 9 Medium, 7 Low) and 14 were refuted.
Verdict
The protocol's money-custody logic is trustworthy — no theft path survived verification, escrow solvency is intact, and the privacy boundary's consent gate works.
Every "theft" candidate (AIOUM-02/03/04/05) resolved to griefing or self-harm with no extractable theft: an operator cannot extract value without the client's voucher, and the client always recovers the unspent deposit.
Fixed (commits a0a8399 offchain, c0139c2 app)
All 13 non-contract findings are fixed and verified: the offchain build is tsc clean, the
boundary suites pass 11/11 offchain and 8/8 in the app, next build is green, and the two HIGH
private-node fixes were verified live.
| ID | Sev | Fix |
|---|---|---|
| DTB-2 / F1 / PN-04 | HIGH | Private-node bearer token now required on every state-touching route (only /health and the OAuth callback /oauth/:provider/callback, which stores the provider's tokens after Google redirects back, are open); loopback-trust fast-path removed (opt-in PRIVATE_NODE_TRUST_LOOPBACK=1, default off). Live: GET /memory no-token → 401. |
| PN-01 / F5 | HIGH | No hardcoded aioum-dev-passphrase. Keys derive from PRIVATE_NODE_PASSPHRASE or an auto-generated 0600 .secretkey; compose default removed. Live: memory.json is a ZPB1 encrypted envelope, no plaintext. |
| F3 | HIGH | handover calls setArbitrator(DAO) before transferOwnership; deploy requires ARBITRATOR_ADDRESS on a live chain; verify-deploy asserts arbitrator()==Timelock. |
| F6 | MED | Deploy requires TREASURY_ADDRESS on a live chain; verify-deploy asserts treasury() matches. |
| DTB-1 | MED | untrusted→remember downgrade enforced at the sink (addMemory trusted flag), not the forgeable executor flag; a /mcp remember can only write note. |
| DTB-3 | MED | sensitive taint is sticky through paid hops — re-triggers the egress consent gate at every subsequent paid step (consent to one node ≠ consent to all). |
| PN-02 | MED | scrypt N=2¹⁷ + versioned ZPB1 envelope (legacy blobs still decrypt); backup/export passphrase floor raised to 12. |
| PN-03 | MED | Magic-header ciphertext detection ends the ~2.3%-of-restarts memory wipe; an undecryptable encrypted store is backed up (.bak), never overwritten. |
| MP-1 | MED | Settle loop commits results before settle (commitResults needs Open), so dispute evidence is actually anchored. |
| MP-2 | MED | Executor persists the opened session in the checkpoint before serving the call, so a crash no longer re-opens and double-pays the deposit. |
| MP-4 | LOW | Settle/commit tx.wait() bounded by timeout; the broadcast hash is recorded durably first, so a tx still pending in the mempool is waited out — never duplicated (NonceManager assigns fresh nonces, so a "retry" cannot same-nonce replace). A dropped tx gets a nonce resync + fee-bumped rebroadcast; a session found settled/reclaimed on-chain is cleaned up instead of retried. One stuck tx can't wedge other sessions. |
| PN-06 | LOW | OAuth callback validates r.ok/access_token (no longer persists an error JSON as a secret); stamps expires_at; store writes are atomic. |
| F7 | LOW | Monitor binds 127.0.0.1 by default; non-loopback requires MONITOR_HOST + a MONITOR_SCRAPE_TOKEN that gates /metrics. |
Contract-level findings
Five findings concern the rules the chain enforces on every session. Each is bounded to griefing or self-harm; none allows theft.
AIOUM-01 (HIGH) — gas sponsorship is signature-gated
src/aa/AIOUMPaymaster.sol:21-28 — _validatePaymasterUserOp returns ("", 0) with no checks,
so anyone pointing paymasterAndData at a funded copy would drain its EntryPoint deposit. That
is the local development paymaster. The network's gas sponsor, AIOUMVerifyingPaymaster
(src/aa/AIOUMVerifyingPaymaster.sol), sponsors only UserOps carrying a time-boxed signature
from sponsorSigner over the op's sender, nonce, initCode, callData and gas fields, the
paymaster gas limits, the chain and the paymaster instance, so a sponsorship cannot be replayed
for a different op, gas budget, chain or paymaster; rotating the signer voids every outstanding
sponsorship. Deploy installs it on a real chain whenever SPONSOR_SIGNER_ADDRESS is set,
unfunded (USE_VERIFYING_PAYMASTER=1 selects it on the local development network); the bundler
relay (A12) relays only openSessionFor from AIOUMAccount senders with per-epoch gas caps; and
verify-deploy refuses a funded accept-everything paymaster on a live chain.
AIOUM-02 (MED) — session-key vouchers are validated at settle time
src/aa/AIOUMAccount.sol:175-197 · src/payments/PaymentManager.sol:356 — a client can
revokeSessionKey (or let validUntil lapse) after delivery; isValidSignature then fails,
settle reverts, and the client can reclaim() the deposit. Exposure is one session's unsettled
balance: the operator loses payment for calls it served, never funds. Griefing, not theft. Fixed:
openedBy[sessionId] is set only for a then-valid key, and isValidSignature accepts that key's
vouchers for that session whatever happens to the key afterwards.
AIOUM-03 (LOW) — Permit2 / EIP-3009 authorization is not bound to serviceId
src/payments/PaymentManager.sol:227-263 — a relayer can lock a victim's captured authorization
into a service of its choosing. The deposit stays the client's and comes back through reclaim()
after the inactivity timeout. Griefing, no theft.
AIOUM-04 (LOW) — session-key spent is consumed during validation
src/aa/AIOUMAccount.sol:153-158 — the budget decrements during validation and is not refunded
if the open reverts, so spendCap is a lifetime allowance: a reverted open consumes part of it,
and the owner raises it again with authorizeSessionKey (any cap ≥ spent). No funds move.
AIOUM-05 (LOW) — an operator can unstake to zero while sessions are open
src/staking/StakingManager.sol:242-258 · src/payments/PaymentManager.sol:449,500-516 —
settlement reverts when a delegator share is owed but total stake is 0. Self-harm only: the
operator bricks its own settlement; the client's deposit remains reclaimable. Listing requires
active stake, and an operator keeps stake above zero while sessions are open.
What held under attack (probed, not re-litigated)
Escrow solvency and fund custody held: deposits are escrowed under the real client, spent
only advances inside _settle behind a valid voucher, reclaim() refunds deposited − spent
after the timeout, and the four StdInvariants (per-asset solvency, no-overpay,
split-conservation, active-count integrity) pass. Signature and voucher binding held: vouchers
are bound to the client and verified via SignatureChecker/ERC-1271; AIOUM-02 is over-strict
re-validation, a liveness property, not forgery. The privacy boundary held: sensitive memory
never leaves the node without an explicit per-step allowContextEgress consent gate, and the
taint fixes (DTB-1 downgrade-on-write, DTB-3 consent-laundering) never touched that first
consent. Remote exposure held: the private node is not internet-reachable by default (compose
publishes 127.0.0.1:8770), and the now-closed loopback bypass required a local foothold.
Operating rules that follow from the review
Four rules, each a line on the launch checklist. Gas sponsorship runs through
AIOUMVerifyingPaymaster with its sponsor signer, and the deployer, governance and relayer keys
live in a KMS, never as EOAs on a laptop. Governance keys sit in the Timelock/DAO: after
npm run handover, npm run verify-deploy confirms owner(), arbitrator() and treasury()
resolve to governance on-chain before anything is funded. The private node is single-tenant (or
socket-gated); its trust model assumes an unshared host. Operators use strong passphrases and
protect their backup blobs; the export passphrase floor is 12 characters.
The full playbook is Deploying your own network.
Next: Testing guide — the checks, static and live, that keep this record true on every change.