# Audit report

> Audit BunkerVault, a shared post-quantum authorized vault for public ERC-20 and ETH balances (Foundry repo, solc 0.8.30, Cancun). Read README.md and docs/CONTRACT.md (specification, invariants, trust assumptions) first.
>
> SCOPE
> Primary: src/BunkerVault.sol, src/AuthorizationMessage.sol, src/interfaces/. Secondary: how the vault relies on the vendored research ML-DSA-44 verifier test/vendor/fireblocks/MLDSA44Verifier.sol (return handling, code-hash and helper pinning, 20,545-byte key blob layout); report a verifier bug only if it lets a signature verify without the key. Out of scope: lib/ (vendored forge-std, OpenZeppelin 5.6.1), test/mocks/, script/, deployments/, gas optimizations without security impact.
>
> DESIGN
> One contract holds many vaults. vaultId = keccak(domain, keccak(1312-byte ML-DSA public key)). createVault: the payer submits it themselves, an ML-DSA proof of possession over the full registration intent is verified BEFORE any fee-token call, then exactly 10**decimals fee tokens are pulled: 90% stay locked forever (totalLockedFees), 10% go to an immutable developer. The payer becomes controllerOf[vaultId], permanently. Every later operation (depositERC20, withdrawERC20, depositETH, withdrawETH, rotateKey) goes through _consumeAuthorization: msg.sender must be the controller AND a valid ML-DSA signature must cover chain id, vault address, vaultId, key hash, key code hash, operation constant, paramsHash of all arguments, exact next nonce, expiry (max 1 hour) and key epoch. The nonce is consumed before token/ETH calls, under nonReentrant. ETH is wrapped into one constructor-pinned WETH; receive() accepts ETH only from WETH during withdrawETH. A token's runtime code hash is pinned at its first deposit. rotateKey needs the old key's signature and the new key's possession signature; nonce stays monotonic, epoch increments, old keys stay burned. No owner, admin, upgrade, rescue, approval or ECDSA fallback exists.
>
> THREAT MODEL
> The vault's key file is secret. The attacker may control the controller wallet (e.g. an ECDSA break), any other EOA, contracts, malicious or reentrant tokens, the mempool and block ordering, and may register their own vaults and keys. Main question: can anyone WITHOUT a vault's ML-DSA key move, lock, freeze or misdirect that vault's assets, or take assets from other vaults or the locked fees?
>
> INVARIANTS TO BREAK
> 1. No state change of a vault without a valid signature from its current key plus a transaction from its controller.
> 2. A signature authorizes exactly one operation with exactly its signed parameters, once (no replay across nonces, epochs, operations, vaults, contracts or chains; no ambiguous encodings).
> 3. For every token: sum of publicBalanceOf over vaults == totalPublicLiability <= token.balanceOf(vault). One vault, or a malicious token, cannot reduce another vault's withdrawable balance of a well-behaved token.
> 4. totalLockedFees only grows; the fee token can never leave except the developer share at registration; the fee token is never a custody asset.
> 5. The contract holds no ETH between calls except ETH forced in, which is never credited to anyone.
> 6. Any failed operation reverts completely, including nonce consumption, asset admission and fee settlement.
> 7. Registration and rotation reject wrong key sizes, blob size/prefix/code hash, reused keys, and run PoP before any external fee call.
>
> FOCUS PER SPECIALIST
> - Math: fee split for decimals 1..18, balance-delta checks in deposits/withdrawals/fee settlement, overflow/underflow paths, heldTokens paging bounds.
> - Permissions: _consumeAuthorization and every caller of it, controller binding, executor/feePayer checks in createVault and rotateKey, domain separation in AuthorizationMessage and paramsHash encodings, rotation logic, absence of privileged paths, abuse of open admission (registering someone else's key or blob) beyond the disclosed key-squatting risk.
> - Economics: fee collection and locking, griefing (blocking a payer slot, front-running registration or rotation, permanent freezes), donations, forced ETH, cost to an attacker vs harm to victims.
> - Control flow: reentrancy through tokens, WETH and ETH recipients, the unwrapping flag and receive()/fallback(), ordering of checks/effects/interactions, tokens whose code changes after pinning (metamorphic or proxy), tokens that lie in balanceOf/totalSupply, ERC-777 style hooks, revert and return-data handling of the verifier and SafeERC20.
>
> KNOWN AND ACCEPTED, see docs/CONTRACT.md section 9 (report only if the impact goes beyond what is stated)
> Balances and amounts are public. The contract cannot prove a blob equals prepare(pk) or that a key came from honest KeyGen (clients must check this off-chain); anyone can register a victim's public key first with a blob they control, which only denies that key (rotateKey is a second, fee-free path to the same squatting; custody and nonce are unaffected). A token whose reserve falls below totalPublicLiability (seizure, blacklist, negative rebase) freezes withdrawals and deposits of that token only, until the shortfall is covered (invariant 3 is kept on purpose); issuer powers of USDC, USDT and WBTC are an external dependency. Losing or compromising the controller wallet cannot be fixed (no controller change by design). The controller wallet and Ethereum are not quantum safe. The fee token is trusted and reviewed separately. A malicious token can lie about itself; accepting a token is not certifying it, but harm must stay confined to that token. The ML-DSA verifier is unaudited research code. Each verification costs about 1.35M gas.
>
> HOW TO WORK
> forge build; forge test (103 tests). test/utils/VaultFixture.sol builds a vault with a mock verifier that accepts exactly the (key blob, message, signature) it was armed with: use authorize(...) and arm(...) to craft authorizations. test/utils/RealVerifierBase.sol deploys the real verifier. forge test --ffi adds real-key end-to-end tests (needs npm ci in test/ffi, 122 tests).
>
> DELIVERABLE
> Each finding: severity (Critical, High, Medium, Low, Informational), file:line, root cause, exact attack sequence and preconditions, impact on which vaults/assets, a Foundry test under test/ that fails on the current code (or a precise reason why one cannot be written), and a minimal fix. Mark any finding that only restates an accepted risk. Also list the invariants above you checked and could not break, with the reasoning.

| | |
|---|---|
| Repository | https://github.com/BaixoT/imd-hackathon.git |
| Commit | `1f17d13d034ffd4b44f9192b90cb6efb6afcd050` |
| Job | `a8e78e5e-eda9-4f4a-baa4-5c3633422dd1` |
| Judged | 2026-10-12 01:08 UTC |
| Findings | 4 info |

Four agents audited the code as it is at `1f17d13`, each in one area (math, permissions, economics, control flow),
and a judge reproduced, merged and ranked what they found, then read the code once more itself. Nothing in the repository was changed or deployed.

## Findings

### 1. Info: Constructor accepts the fee token, WETH or verifier contract as developerWallet, so every developer share is sent to a contract that can never move it

`src/BunkerVault.sol:163`

```
                || developerWallet_ == address(0) || developerWallet_ == address(this)
```

Merged from audit_math and audit_permissions (same root cause, same line). The constructor rejects developerWallet_ only when it is zero or the vault itself. It accepts developerWallet_ == feeToken_, developerWallet_ == weth_ and developerWallet_ == verifier_. With such a configuration every createVault still settles exactly: payer -fee, vault +lockedFee, 'developer' +developerFee, supply unchanged, so all delta checks in _collectRegistrationFee (lines 531-543) pass, but the 10% share is transferred to a contract with no code path to move fee tokens (an ERC-20 crediting its own address, WETH9, or the verifier) and is permanently unrecoverable. All constructor arguments are immutable, so the misconfiguration is permanent. Deployment-configuration gap only: custody, locked fees, nonces and invariants 1-7 are unaffected (invariant 4 is kept, only the developer share leaves), and constructor arguments are stated to come from reviewed deployment artifacts, hence informational. The existing test test_RejectsUnsafeConstructorConfiguration (test/Registration.t.sol:180) does not cover these values. Minimal fix: extend the InvalidConfiguration condition with `|| developerWallet_ == feeToken_ || developerWallet_ == weth_ || developerWallet_ == verifier_`.

**Reproduction**

Reproduced with a scratch Foundry test on test/utils/VaultFixture.sol: fixture(); for sink in [address(token), address(weth), address(verifier)] { CtorArgs a = ctorArgs; a.developer = sink; BunkerVault other = deployVault(a); } Expected: revert InvalidConfiguration, as for address(this). Actual: all three deploy. Then (18-decimal fee token) bob approves fee to `other`, the mock verifier is armed for other.registrationMessage(second), bob calls other.createVault(second): succeeds, token.balanceOf(sink) grows by exactly fee/10 = 1e17, token.balanceOf(other) == 9e17, and no function can ever move the 1e17 out of the sink.

### 2. Info: rotateKey accepts the vault's current blob as the new blob, so a rotation can burn an arbitrary key hash and bump the epoch while the authorizing key material does not change (restates accepted risk L

`src/BunkerVault.sol:274`

```
        _checkKeyBlob(rotation.pkBlob, rotation.keyCodeHash);
```

From audit_economics; reproduced. rotateKey validates rotation.pkBlob only by size, prefix and code hash and never requires it to differ from _accounts[id].pkBlob. The possession proof is then verified against the caller-chosen blob, which for the current blob is produced by the current key. Result: account(id).publicKeyHash and the KeyRotated event announce a new key K2, keyEpoch increments, usedPublicKeys[keccak(K2)] is burned globally, but the blob that actually authorizes operations is unchanged. This is a rotation-specific instance of accepted limitation L1 (the contract cannot prove blob == prepare(pk); rotation is a documented fee-free squatting path): only the controller plus the current key can trigger it, custody and nonce are unaffected and other vaults are unaffected, so it is informational and marked as restating an accepted risk. The only effect beyond L1 is that on-chain readers of publicKeyHash / KeyRotated are told a key change happened that did not. Minimal hardening (the one case rejectable on-chain): in rotateKey, after loading principal, `if (rotation.pkBlob == principal.pkBlob) revert InvalidPublicKey();`. A different address holding the same bytes still needs the off-chain prepare(pk) check L1 already requires.

**Reproduction**

Scratch Foundry test on test/utils/VaultFixture.sol: fixture(); vault.createVault(registration) (vault idA, blobA, key hash keccak(publicKeyA)). Build KeyRotation r = {publicKey: rep(0x33,1312) (unused K2), pkBlob: blobA, keyCodeHash: codeHash, executor: alice, proofOfPossession: rep(0x43,2420)} and Authorization a = {nonce 0, expiresAt registration.expiresAt, keyEpoch 0, signature()}. Arm the mock: verifier.configure(blobA, vault.rotationMessage(id,r,a), signature(), false); verifier.configureAdditional(blobA, vault.rotationPossessionMessage(id,r,a), rep(0x43,2420)). Call vault.rotateKey(id, r, a). Expected per docs/CONTRACT.md section 6 ('possession of a validated new key'): revert. Actual: succeeds; account(id).pkBlob == blobA, account(id).publicKeyHash == keccak(K2), keyEpoch == 1, nonce == 1, usedPublicKeys[keccak(K2)] == true.

### 3. Info: withdrawETH and withdrawERC20 accept data-only contracts (key blobs whose code starts with STOP) and other protocol contracts as recipient, where the value is irrecoverable

`src/BunkerVault.sol:398`

```
        _checkRecipient(recipient);
```

From audit_economics; reproduced with one correction. The previous revision added `recipient != token` to withdrawERC20 (audit F3) so tokens cannot be stranded in a contract that can never move them. The same class of sink remains reachable through addresses the vault already knows: a prepared public-key blob is a contract whose runtime begins with 0x00 (STOP), so an ETH call with value to it succeeds and the ETH is locked forever; WETH or any ERC-20 can likewise be withdrawn to a blob, to the verifier or to the fee-token contract. Correction to the specialist's claim: withdrawETH to the ML-DSA verifier does NOT succeed (the verifier has no receive/fallback, so the call fails and the vault reverts with ETHTransferFailed); only blobs and similar code-starting-with-STOP accounts swallow ETH. This is self-harm that requires a signed authorization naming the sink, so it is informational, consistent with the severity given to F3. Optional hardening: in _checkRecipient also reject account(id).pkBlob, address(verifier), address(feeToken) and, for ERC-20 withdrawals, address(weth).

**Reproduction**

Scratch Foundry test on test/utils/VaultFixture.sol with vault idA registered (blobA) and blobB etched with a 0x00-prefixed 20,545-byte blob: depositETH{value: 2 ether}(idA, auth(DEPOSIT_ETH, keccak(abi.encode(2 ether, alice)))); withdrawETH(idA, blobB, 1 ether, auth(WITHDRAW_ETH, keccak(abi.encode(blobB, 1 ether, alice)))). Expected by analogy with F3: revert InvalidRecipient. Actual: succeeds, blobB.balance == 1 ether and nothing can move it. withdrawETH(idA, address(verifier), 1 ether, ...) reverts ETHTransferFailed (specialist claim corrected). withdrawERC20(idA, weth, address(verifier), 0.5 ether, assetConfigurationHash(weth), ...) and the same to blobA both succeed: weth.balanceOf(verifier) == 0.5 ether, weth.balanceOf(blobA) == 0.5 ether.

### 4. Info: Production library AuthorizationMessage ships the unused v1 encode/DOMAIN that only a test harness consumes

`src/AuthorizationMessage.sol:18`

```
    function encode(
```

From audit_economics; reproduced. AuthorizationMessage.encode and the DOMAIN constant (BunkerVault.Authorization.v1) are referenced nowhere in src/; their only consumer is test/mocks/AuthorizationHarness.sol (lines 31 and 35), and the library comment itself says the v1 encoding remains only for the original standalone test fixture. A second, weaker message format (no vaultId, no publicKeyHash) inside the production signing library is a footgun for client implementers who import the library, and it enlarges the reviewed surface for no production benefit. No funds are at risk because the vault never verifies a v1 message. Fix: move encode/DOMAIN into the test harness (or delete them) so src/ exposes exactly one encoding, encodeAccount.

**Reproduction**

grep -rn 'AuthorizationMessage.encode(\|DOMAIN' src/ test/ --include=*.sol: src/AuthorizationMessage.sol:7 (DOMAIN definition), :25 (used inside encode), test/mocks/AuthorizationHarness.sol:31 and :35 (only callers). No call in src/BunkerVault.sol. Expected: the production library contains only the encoding the vault verifies. Actual: it also contains the v1 encoding used by no production path.

---

Judge's submission `f623dcb5edbc29a2b7a48a101afeb9eef1c06b17d4733f8562e4c449c03f7dc7`, accepted on the IdentityMD network. Acceptance means the report met the job's checks;
it is not a guarantee that the code has no other defects.
