MVF-2026-0006 — a relabelled disclosure package verified against the genuine root
discloseverify ran ten named checks, and nine of them only compared one part of a disclosure package with another part of the same package's manifest.
| Identifier | MVF-2026-0006 | | Internal row | M26-REC-005 | | Severity | S2, priority P0 | | Affected | no released version | | Not affected | every release: 1.0.0, 1.0.1, 2.0.0, 2.0.1, 2.1.0, 2.2.0, 2.3.0, 2.4.0 and 2.5.0 | | Fixed in | 2.6.0 | | Reported | internally; Stage B1 audit, confirmed 2026-09-24 | | Published | 2026-10-05 | | CVE / GHSA | none assigned | | CVSS:3.1 | CVSS:3.1/AV:L/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:N | | CVSS:4.0 | CVSS:4.0/AV:L/AC:L/AT:N/PR:L/UI:P/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N |
Summary#
disclose_verify ran ten named checks, and nine of them only compared one part
of a disclosure package with another part of the same package's manifest. The
tenth was the only one anchored outside the package — a ledger inclusion proof
against a snapshot root the recipient obtains from the examiner by another route
— and nothing joined that anchored sub-object to the rest of the manifest or to
the bytes of the record and grant files. So the whole package floated free of its
one external anchor: a holder of one genuine package could rewrite the disclosure
block, re-sign the manifest with a key of their own, and still reach
verified: true against the genuine root.
Why this page exists although no release was affected#
Every identifier in this register has a page, so a citation never lands on a gap.
For an operator: there is nothing to do. No released version ships the
disclose_* family, the .mrec record format or the grant machinery this defect
lives in. This advisory is not a reason to upgrade.
Affected range, checked at each tag#
The family first appears in 61ddce7 (2026-09-23), after the v2.5.0 tag. At all
nine tags builtin/disclose.go is absent and no disclose_* builtin is
registered. The defect was live on dev from 2026-09-23 until the fix on
2026-09-25.
Details#
The nine internal checks were real checks — they held the listed files to the manifest's digests, the record's identity and segment count to the manifest's record block, the grant's identity and ranges to the manifest and to the record header, and the grant's indices to the view class partition. The problem was the seam. The externally anchored part proved a ledger leaf: a node id, a label, and a digest of properties rebuilt from the manifest's own copy of those properties. Nothing compared that leaf with the manifest's recipient, examiner, method, view label or view fingerprint, and nothing compared it with the bytes of the record and grant files.
The seal check compounded it. The manifest's signature was validated against the public key the manifest itself carries, and the result never reported that key — so the signature held over whatever document a forger wrote.
Two forgeries were demonstrated: rewriting the disclosure block and re-signing
with a fresh key, and dropping one disclosure's ledger block and proof beside a
different package's grant. Both reached verified: true against the genuine
root.
What this was not — the overreach to avoid#
No evidence was disclosed, and this is not a confidentiality defect. Three
things in the code stop that. The grant-opening check decrypted only the segments
the grant's real key material opens and zeroed every plaintext afterwards. The
authorisation check already held the grant's indices to the view's class
partition, so relabelling widened nothing. And the result hard-codes
"no bytes recoverable" — there is no open-the-record builtin in the family — so a
false verified: true hands the caller no bytes.
Both forgeries also start from a genuine package the forger already holds, and the grant-swap one needs a second disclosure's grant file and its passphrase. So anything a forgery redistributes is data the forger already had, which the disclosure policy already places outside the guarantee: once the recipient holds the record and the grant, both are theirs.
What the defect destroyed is attestation integrity and attribution: a package could carry a genuine, externally anchored ledger proof while naming a recipient, an examiner, a view or a granted range that the ledger never recorded.
Two narrower cautions. This was not forgeable by anyone — the privileges metric
is L because the forger must hold a genuine package and its grant passphrase,
and must hand that passphrase to the person being deceived, since verified
requires the grant to open as well as every check to pass and a caller-supplied
root. And a fixed disclose_verify still does not prove that the named examiner
and recipient are who they say they are; the signature note says so in as many
words — a signature authenticates the document, not the names in it.
The fix#
An eleventh check that compares the one externally anchored thing in the package — the ledger's recorded disclosure properties — field by field against the thing it names: the disclosure identity, case, recipient (with the fingerprint recomputed from the manifest's own recipient string), examiner, method, view label and view fingerprint against the manifest; freshly computed digests of the record and grant files against the recorded digests; the record's own identity and segment count; and the grant file's identities, ranges, granted count and descriptor root. Every disagreement is collected and named rather than short-circuiting on the first.
The result now also reports the manifest's and the record's signing public keys, so a recipient can compare the signers against keys they already trust instead of trusting a key the document carries.
Fixture#
builtin/disclose_binding_test.go,
TestAPackageTheLedgerDoesNotRecordFailsVerification.
Workarounds#
None is needed: no published release ships the family.
For a development build in the affected window there was a partial manual check: obtain the snapshot root from the examiner by a route other than the package, read the recorded disclosure properties straight out of the ledger, verify the proof, and compare those properties by hand against the manifest's disclosure and view blocks, against the digests of the record and grant files as they sit on disk, and against the record's own identity and segment count; then read the seal's public key out of the manifest and compare it with a signing key you already trust.
What that does not cover: disclose_verify's own verdict and its checks array
still report the forgery as passing, so every script, report or workflow keyed on
them stays wrong and the check cannot be automated with the pre-fix builtin; it
requires read access to the ledger store, which a recipient outside the issuing
organisation generally does not have; it cannot detect a forgery at all if the
verifier holds only the package; and it does not touch the standing limitations —
even a package that passes every comparison proves what the ledger recorded, not
that the names in it are real. The only complete remedy is the fixed build.
Credit#
Found by this project's own 2.6.0 security review, Stage B1, on 2026-09-24, and confirmed in Stage B4 the same day. No external reporter.
References#
- The entry in CHANGELOG.md under 2.6.0, Security.
- SECURITY.md for the severity scale.
- docs/DISCLOSURE_POLICY.md for what a disclosure package does and does not guarantee.
- The advisory register.