Skip to content

feat(dlv): VaultStateAnchorV2 — history-bound, with q inside the signature - #692

Merged
cryptskii merged 2 commits into
mainfrom
feat/vault-state-anchor-v2
Aug 22, 2026
Merged

cryptskii merged 2 commits into
mainfrom
feat/vault-state-anchor-v2

Conversation

@cryptskii

Copy link
Copy Markdown
Collaborator

Based on main, not stacked.

Implements Rev 15 Def 6.4:

p_v = H(DSM/vault-state-anchor/v2 ‖ vault_id ‖ generation
        ‖ parent_state_commitment ‖ reserves_digest ‖ storage_set_id ‖ q)

The two new fields

parent_state_commitment is h_n — c_{n-1} for n > 0, the genesis value at n = 0. Req 6.5 demands the anchor bind the history that produced the reserves, so two histories reaching identical reserves must bind differently. The legacy anchor cannot: it commits (vault_id, sequence, reserves_digest, storage_set_id) and nothing about provenance. A test pins that limitation on the legacy digest directly, so the reason for the migration is recorded rather than asserted.

q is the owner-committed threshold. Req 15.8 has Class K count against the exact committed S; Req 6.11 forbids lowering it for an unavailable member. Substituting q here breaks the signature.

Clean cut — Req 6.6

Separate type, separate domain, separate error enum. No conversion, no TryFrom, no verify-then-fall-back helper. The separation is structural, and both directions are tested: a legacy signature lifted onto a V2-shaped anchor does not verify, and a V2 signature pushed back into the legacy shape does not either.

Mutation testing found a real gap in my own tests

Swapping the domain tag wholesale initially passed all 7 tests — signing and verification both went through the same code, so the suite was self-consistent rather than conformant. That is exactly the failure mode a cross-check exists to prevent, and it was live in my first draft.

The fix recomputes the binding from the literal specification bytes, pinning the domain string, the 0x00 separator, the field order and the integer widths to Rev 15 rather than to the implementation:

Mutation Before the fix After
domain tag swapped wholesale all green red
parent_state_commitment ↔ reserves_digest transposed — red

End to end

A test wires the anchor to the CCB encoder: h_0 is the genesis value and is asserted not to be an all-zero sentinel, h_1 is c_0 computed over the real prior state, and successive generations bind differently. Drift between encoder and anchor is now a test failure rather than a latent divergence.

Local gate

  • cargo fmt --all -- --check clean
  • cargo clippy --all-targets -- -D warnings clean
  • cargo test -p dsm --lib — 1652 passed, 0 failed (1644 before, +8)

Not in this PR

Wiring V2 into the vault lifecycle — birth, composition, publication — and retiring V1. This is the primitive plus its proofs; adopting it is a separate change, so a serialization bug and a lifecycle bug stay distinguishable.

…ature

Implements Rev 15 Definition 6.4:

  p_v = H(DSM/vault-state-anchor/v2 || vault_id || generation
          || parent_state_commitment || reserves_digest || storage_set_id || q)

Two fields separate this from the legacy anchor and both are the point.

parent_state_commitment is h_n of Definition 4.1 — c_{n-1} for n > 0, the
domain-separated genesis value at n = 0. Requirement 6.5 demands the anchor bind
the history that produced the advertised reserves, so two histories arriving at
identical reserves must bind differently. The legacy anchor cannot: it commits
(vault_id, sequence, reserves_digest, storage_set_id) and nothing about how the
reserves came to be. A test pins that limitation on the legacy digest directly,
so the reason for the migration is recorded rather than asserted.

q is the owner-committed threshold. Requirement 15.8 has Class K count against
the exact owner-committed S, and Requirement 6.11 forbids lowering the threshold
because a member is unavailable. A locally recomputed threshold is not
committed; here, substituting q breaks the signature.

Clean cut per Requirement 6.6. Separate type, separate domain, separate error
enum, and no conversion, TryFrom, or verify-then-fall-back helper anywhere. The
separation is structural: a legacy signature lifted onto a V2-shaped anchor does
not verify, and a V2 signature pushed back into the legacy shape does not
either. Both directions are tested.

MUTATION TESTING FOUND A GAP AND IT IS FIXED. Swapping the domain tag wholesale
initially passed every test, because signing and verification went through the
same code — self-consistency is not conformance. The added test recomputes the
binding from the LITERAL specification bytes, so the domain string, the 0x00
separator, the field order and the integer widths are pinned to Rev 15 rather
than to whatever the implementation happens to do. With it, swapping the domain
turns the suite red, and so does transposing parent_state_commitment with
reserves_digest.

An end-to-end test wires the anchor to the CCB encoder: h_0 is the genesis value
and is asserted not to be an all-zero sentinel, h_1 is c_0 computed over the real
prior state, and successive generations bind differently. A drift between the
encoder and the anchor is now a test failure rather than a latent divergence.

Local gate: fmt clean, clippy --all-targets -D warnings clean, cargo test -p dsm
--lib 1652 passed, 0 failed.
Four review corrections to the VaultStateAnchorV2 primitive.

The verifier no longer trusts a self-declared key. It previously checked the
signature against owner_public_key carried by the anchor itself, which proves
only that SOME key signed those bytes: anyone can generate a SPHINCS+ pair, put
any vault_id in an anchor, sign it, and pass. verify_vault_state_anchor_v2 now
takes the expected owner key as a parameter, compares it before touching the
signature, and returns OwnerKeyMismatch otherwise. The caller must supply a key
it authenticated elsewhere — birth state, reserve-owner record, composed V_n.
The legacy anchor has the same weakness; the clean cut is where it stops being
propagated rather than inherited.

A new test drives the attack: a stranger's anchor verifies against the
stranger's own key, is refused against the owner's, and relabelling the embedded
key does not conjure the owner's signature.

The end-to-end test now derives every anchor input from the state it describes.
It previously carried h_n across correctly but signed with an invented
reserves_digest and storage_set_id, which proved the lineage field could be
copied rather than that one state yields one anchor. Reserves digest, set id and
q all come from V_n now, and two assertions confirm the inputs are derived
rather than incidental.

The legacy-domain comparison was not comparing against the legacy construction.
It built the preimage from b"DSM/vault-state-anchor\0" and then appended a
second zero byte, but V1 hashes that literal directly — the NUL is inside the
tag and there is no separate separator — and its payload carries neither a
parent state commitment nor q. The reconstruction is now exact and is
cross-checked by verifying V1's own signature against it, so the test pins the
real legacy bytes instead of a guess.

The module now records that signed is not validated. This layer holds
storage_set_id, not the resolved set, so it cannot count members and cannot
check q against the Req 6.13 profile — the tests deliberately sign q=3 to show
an out-of-profile value signs and verifies fine here. Closing Area 3 needs the
lifecycle verifier to resolve authenticated S, require five members and q=4, and
only then verify the anchor. Signature inclusion is a precondition of the rule,
not the rule.

Mutations still caught after the changes: swapping the domain tag and
transposing parent_state_commitment with reserves_digest each turn the suite
red, restored 9 passed.

Local gate: fmt clean, clippy --all-targets -D warnings clean, cargo test -p dsm
--lib 1653 passed, 0 failed.
@cryptskii
cryptskii merged commit 808b04a into main Aug 22, 2026
18 checks passed
@cryptskii
cryptskii deleted the feat/vault-state-anchor-v2 branch September 23, 2026 21:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant