Vault adversarial analysis: properties, bypass enumeration, operational limits
Vault properties, bypass enumeration and operational limits.
This page reproduces docs/VAULT-ADVERSARIAL.md from qanum-core v0.9.0 (release tag). Links to files that are not published on this site are shown as plain text until the source repository is public.
Contents
Status: canonical. Companion docs: THREAT-MODEL.md §4, WALLET-GUIDE.md §4.2, whitepaper §14. Why this document exists: a review before the first public release (not an independent audit) asked for a written, code-anchored demonstration that Qanum’s cryptographic diversity is a property of the spending policy, not “two algorithms present in the protocol”, and posed six specific questions (no-bypass proof; what is committed at spend initiation; who can cancel and how; behaviour under censorship, congestion and reorgs; real reaction time; required surveillance). This document answers all six against the tree, states what vaults deliberately do not solve, and pins every property to tests.
1. The state machine, as consensus enforces it
Vaults are not a wallet convention: they are output templates whose
transitions are validated in consensus, in three parallel copies: the
no-crypto validator (validator.rs, used under assumevalid), the full
verifier path (validator.rs), and the block-connect path, all applying
the same shape and height checks. Structural checking is priced in the
consensus budget: VU_VAULT_STRUCTURAL_CHECK = 10 VU (constants.rs),
against 100 VU per ML-DSA-44 verification. Height conditions are checked
inside the input validator itself (validator.rs), so neither assumevalid
nor the signature cache can skip them.
State 1: VAULT (0x05), 72-byte payload (validator.rs,
parse_vault_payload and the output rules):
| Field | Consensus restriction |
|---|---|
hot_suite_id |
ML-DSA-44 or ML-DSA-87 (KeyRole::VaultHot); SLH-DSA is never a hot suite |
hot_key_id (32 B) |
Free; must differ from cold_key_id (identical keys = OutputPayload, invalid by consensus) |
cold_suite_id |
ML-DSA-44, SLH-DSA-SHAKE-128s, ML-DSA-87 or SLH-DSA-SHAKE-256s (KeyRole::VaultCold) |
cold_key_id (32 B) |
Free; must differ from hot_key_id |
delay_blocks (u32) |
Bounded by consensus to 144..=5 256 000 blocks (validator.rs) |
On a chain that retires the level-1/2 suites (the test network does so from genesis), a new vault uses ML-DSA-87 as the hot suite and ML-DSA-87 or SLH-DSA-SHAKE-256s as the cold suite. Consensus does not require the cold suite to differ from the hot suite. The suite diversity this document relies on holds when the owner picks an SLH-DSA cold key (hash-based, a different hardness assumption from the lattice-based hot key). With an ML-DSA cold key the vault still gives the reaction window against hot-key theft, but no protection against a break of ML-DSA.
State 2: VAULT_PENDING (0x06), 76-byte payload
(validator.rs, parse_vault_pending_payload and the output rules):
same hot/cold key IDs and suites, plus unlock_height (u64), with the
rule that unlock_height == 0 is invalid: a disguised instant spend.
Transitions out of VAULT (validator.rs), exactly two:
- Path 0 (hot): shape-constrained. Exactly one input and one
auth;
vout[0]must beVAULT_PENDINGwith a payload byte-identical to the expected one: same keys,unlock_height = current_height + delay(VaultInitPendingMismatchotherwise), plus an optionalvout[1] = VAULTchange output whose payload must equal the original (VaultInitChangeMismatch). There is no hot spend shape that produces a free output: the hot branch is forced through the cancellable window. - Path 1 (cold): cold-suite signature -> immediate spend to free destinations. This is emergency recovery (hot key lost) and, deliberately, the handbrake against a stolen hot key.
Transitions out of VAULT_PENDING (validator.rs), exactly two:
- Path 0 (finalize, hot): requires
current_height >= unlock_height(HeightConditionotherwise) plus a hot-suite signature. Free destinations. - Path 1 (cancel, cold): cold-suite signature, no height condition: cancellable at any height, before or after unlock. Free destinations.
Every transition is signed under the protocol’s single sighash
(SIGHASH_ALL, constants.rs), which commits to all outputs of the
signing transaction: nobody can sign “a spend” and redirect it afterwards.
Wallet/CLI expose all four operations as first-class commands
(vault init/recover/finalize/cancel, src/bin/qanum_cli/custody.rs),
with builders that pre-check invariants (wallet_manager.rs).
2. What each custody tool covers (property table)
| Property | Threat | Covered by |
|---|---|---|
| Recovery from key loss | Owner loses the hot key | P2RKPQ (0x02) delayed recovery path; VAULT cold path |
| Reaction to key theft | Attacker obtains a valid hot key | VAULT: the hot branch must cross a cancellable window |
| Resistance to suite break | Attacker can forge one suite’s signatures | Window + cancellation signed by a key of the other suite family, when the cold key is SLH-DSA; a conditional mitigation, not a guarantee |
P2RKPQ is an anti-loss tool, not anti-theft: its recovery path is as
available to a key thief as to the owner (its delay bounds apply the same
way, validator.rs). VAULT is the anti-theft tool with a reaction
window: it does not stop the thief from initiating; it guarantees the
initiation is visible, cancellable and non-instant. No template stops a
spender who controls (or can forge) both keys; by design, the goal is
that stealing/breaking one suite is not enough.
3. The reviewer’s six questions
3.1 Is there any bypass path?
Enumeration of every spend route of a VAULT / VAULT_PENDING output:
- Hot spend of
VAULT-> can only produce the byte-fixedVAULT_PENDINGand an optional identicalVAULTchange (validator.rs). No bypass. - Cold spend of
VAULT-> immediate spend. A feature (recovery), not a bypass; requires the cold key. - Hot spend of
VAULT_PENDINGbefore unlock ->HeightCondition(validator.rs). Blocked. - Hot spend of
VAULT_PENDINGat/after unlock -> finalize to free destinations. By design. - Cold spend of
VAULT_PENDING-> cancel at any height. By design. - Identical hot and cold keys ->
OutputPayloadat creation, for bothVAULTandVAULT_PENDING. Impossible by consensus to build a single-key vault. - Suite confusion / wrong-length payloads -> parsers require exactly 72/76 bytes and whitelisted suites. Blocked.
- Route confusion across the three validators -> all three apply the same shape and height logic (height lives in the input validator itself), so assumevalid and the signature cache cannot skip it.
Conclusion: no bypass of the window exists for the hot branch. The only fast door is the cold key, a stated property (§5.1).
3.2 What is committed when a spend is initiated?
No destinations. Neither finalize nor cancel destinations are committed
at init: VAULT_PENDING fixes keys, unlock_height and the value assigned
by the init; final destinations are chosen when the finalize or cancel is
transmitted. This is stated plainly because it defines what the window
buys: the window buys reaction time, not destination freezing. Value is
fixed in two steps: the hot init splits the vault’s value between
VAULT_PENDING (vout[0]) and optional VAULT change (vout[1]); each
output then keeps its value until it exits its own state.
Operational consequence: a defender cancelling does not need to guess where
the money was going; detecting the VAULT_PENDING is enough. The attacker
cannot shrink the window either: delay_blocks lives in the original
VAULT payload and the init must reproduce it; the attacker only chooses
when the window starts.
3.3 Who can cancel, under what conditions?
Only the cold key, via VAULT_PENDING path 1: no height condition, no
extra shape constraints, normal fees. The hot key cannot cancel: by
construction, path 0 requires current_height >= unlock_height. This
asymmetry is the vault’s semantics: cancellation authority lives outside
the key set a hot-key thief may hold. The uncomfortable symmetry must
also be stated: whoever controls the cold key can spend immediately through
the same route. Vaults protect against hot-key theft, not cold-key theft
(§5.1).
Practical conditions: the cancel is a normal transaction; it needs a
sufficient feerate for relay (min_fee_rate, default 1 joma/byte) and, if
it conflicts with a queued finalize, must satisfy the replacement rules
(pay replaced_sum + min_fee_rate × size and a feerate strictly higher
than the best evicted tx; mempool.rs replacement checks). See §3.4 and
the congestion note below for the operational detail.
3.4 Censorship, congestion, reorganizations
Reorgs: immune by construction. unlock_height is an absolute
height, not a relative lock (Qanum has no relative locktimes; windows use
absolute heights, consistent with the channel design in whitepaper §4.3). A
reorg only moves current_height: a finalize keeps waiting, a cancel stays
valid at every height. If the reorg un-confirms the init itself, the vault
simply returns to VAULT state, benign; a later re-init recomputes
unlock_height = height + delay at the new confirmation height
(validator.rs), never shorter. Mempool rebuild after reorg uses
the live policy and re-validates every candidate at the new height
(mempool_reorg_invariants.rs, node_mempool_reorg.rs).
Congestion: the real flank, measured and pinned. Cancel and finalize are competing transactions for the same outpoint: until one confirms, the higher feerate wins (always-RBF). Three operational facts:
- Mempool defaults sized for congestion. The mempool default is 32 MB
/ 40 000 entries (≈ ten minutes of full blocks), operator-tunable per
node profile via
maxmempool/maxmempoolentries(STORAGE.md “Mempool RAM profiles”). A pool sized for a single full block would let one block of traffic crowd out an urgent cancel. - The urgent-cancel scenario is pinned in the suite
(
tests/mempool_congestion_vault_cancel.rs, 5 tests): a fee-bumped cold cancel is admitted across a saturated pool, displacing cheap fillers; its RBF enforces the incremental fee and strictly-higher-feerate rules; the race between a confirmed cancel and an attacker finalize endsMissingInput/HeightConditionas it should;MempoolFullis honest under both caps;MAX_REPLACEMENT_CANDIDATES(100) bounds a single replacement. - The in-pool half of the race is pinned too
(
tests/vault_cancel_vs_finalize_congestion.rs, 4 tests): beforeunlock_heightthe attacker finalize cannot enter at any fee (HeightCondition) while the cold cancel enters at any height; a pinned finalize is displaced by the cold cancel via RBF; the fee war is symmetric by policy (same-fee steal rejected, overpay replaces, the cold re-bump wins the slot back); a confirmed finalize closes the window by spend, not time. - Two mempool semantics operators must know. (1) The mempool validates
at
tip + 1(the next block’s height), so a finalize becomes acceptable one block beforeunlock_heightis reached on chain. (2) The house anti-pinning rule is stricter than BIP125-2: a replacement may not spend the output of any unreplaced mempool parent, even one the replaced transaction already spent (mempool.rs,ReplacementSpendsUnreplacedMempoolInput). Consequence: fee-bumping an unconfirmed cancel requires the vault-init to be confirmed first; the operational substitute is a pre-signed cancel (§3.6).
Still open: package relay on the wire is not implemented (whitepaper §13.3); efficient propagation of a parent+child package under sustained congestion is not guaranteed. Tracked as mid-term hardening, not as a vault-specific defect.
Active censorship. A miner (or coalition) can refuse the cancel while including the finalize; the structural defence is that the cancel is valid at every height, so censorship must be continuous until the unlock. Short of a majority attacker, any honest miner includes it. No UTXO protocol can offer more against a censoring majority; that is the general PoW threat model, not a vault weakness.
3.5 How much real reaction time does the user have?
The reaction budget is delay − (detection + propagation + confirmation):
- Consensus floor: 144 blocks = 3 hours (75 s target). That floor suits an operational hot wallet; cold custody should use another order of magnitude: 1 152 blocks ≈ 1 day; 40 320 ≈ 35 days; the consensus ceiling (5 256 000) ≈ 12.5 years.
- Detection: instant if the mempool is watched (
VAULT_PENDINGis visible from first acceptance); up to one block late if only the chain is watched. - Cancel confirmation: 1 block minimum in practice; under congestion, whatever the fee market dictates (§3.4).
Example: a 1 152-block vault, mempool surveillance, moderate congestion ->
the effective reaction is roughly 23 of the 24 window hours. A 144-block
vault without mempool surveillance and under congestion -> the real margin
can shrink to ~2 hours. Operational rule: the effective window is the
window minus expected congestion time; choose delay accordingly.
3.6 What surveillance infrastructure is needed?
- Own full node with the cold wallet imported watch-only: the wallet
lists UTXOs with their template name, so the appearance of a
VAULT_PENDINGcarrying your key IDs is detectable with no online keys. This is the reasonable minimum. - Mempool surveillance: there is no dedicated surveillance RPC today
(
listvaults-style alerts); practical detection isgetrawmempool/ UTXO listing plus an external job. An operational gap, not a consensus one (see §6). - Alarm-only watchtower: a valuable property of the design, the alert is not custody. A third party may watch and warn but cannot cancel without the cold key; delegated trust is limited to the availability of the warning, never to fund integrity.
- Pre-signed cold cancel (recommended pattern): path 1 needs no online state. Signing the cancel at vault-creation time (generous fee, no block locktime) and keeping it offline reduces reaction time to pure broadcast. Command reference in WALLET-GUIDE.md §4.2.
4. Test anchors
Every property above is pinned in the suite:
| Property | Tests |
|---|---|
| Init shape, byte-identical pending payload, bypass shapes rejected | tests/block_validation.rs (block_valid_with_vault_init, TV-005 bypass cases) |
| Delay bounds, key ordering, pre-check order | wallet_manager.rs unit tests (delay checked before keys) |
| Cold cancel crosses a saturated pool; eviction by descendant-package feerate | tests/mempool_congestion_vault_cancel.rs (5 tests) |
RBF of the cancel: incremental fee + strictly-higher feerate; MAX_REPLACEMENT_CANDIDATES; honest MempoolFull |
same file |
Confirmed cancel beats attacker finalize; HeightCondition at tip+1 |
same file |
| Mempool rebuild/reorg keeps live policy | tests/mempool_reorg_invariants.rs, tests/node_mempool_reorg.rs |
5. What vaults do not solve
An honest analysis ends with the out-of-scope list:
- Cold-key theft or compromise -> immediate spend via path 1. Mitigation: the cold key lives offline (hardware / air-gap); the design deliberately roots authority in the cold key. (Alternative: a delay on cancel too, rejected for now because it sacrifices instant recovery.)
- Simultaneous forgery of both suites -> no template helps; that is the suite-migration scenario (whitepaper §6.5), not daily custody.
- Malware on the hot machine -> can steal the hot key and start the window at will; the window still gives the owner the chance to cancel, unless the cold key is handled on the same compromised machine.
- Physical coercion of any key holder.
- A confirmed finalize is irreversible: the race is played in the mempool; once the finalize mines, the cancel has no input left to spend. The entire value of the vault is surveillance during the window.
6. Findings raised by this analysis and how they are handled
| Severity | Finding | Status |
|---|---|---|
| Low (docs) | Finalize/cancel destinations are not committed at init, and the whitepaper did not describe the state machine. | Covered by whitepaper §14.3 and this document. |
| Info (design) | Cancel symmetry: the cold key also spends instantly. Vaults protect hot-key theft, not cold-key theft. | Stated here (§5.1) and in whitepaper §14.3. |
| Medium (ops) | The window is only worth what the mempool is worth under congestion (a small default pool; no package relay). | Fixed: 32 MB / 40 000 entries + maxmempoolentries, profiles in STORAGE.md, urgent-cancel scenario pinned by mempool_congestion_vault_cancel.rs. Package relay remains open as mid-term hardening. |
| Low (ops) | No dedicated surveillance RPC (listvaultspending, alerts); detection needs an external job over getrawmempool / UTXO listing. |
Open: candidate minor RPC AIP; USER-GUIDE item meanwhile. |
| Low (practice) | Pre-signed cold cancel was undocumented as an operational pattern despite being free under the current machine. | Documented here (§3.6) and linked from WALLET-GUIDE §4.2. |
7. Bottom line
The reviewer’s request (“cryptographic diversity must be a demonstrated property of the spending policy”) is satisfied by the consensus VAULT machine: the compromised (hot) branch cannot spend without crossing a cancellable window whose cancellation authority lives in another key, of another suite family when the cold key is SLH-DSA, with no height condition, and no bypass of that window exists. What was missing was the demonstration itself; this document, together with the pinned tests, supplies it, including the parts the design deliberately does not cover.