You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
vouch markets Sources as "content-addressed by sha256" (README "Object model"; "Sources are content-hashed"). storage.put_source derives the id from the bytes, and verify.verify_source re-checks sha256(content) == source.id. But bundle.import_apply and sync.sync_apply write sources/<sha>/{meta.yaml,content} straight from the tarball and never verify that the content hashes to its claimed id. So a hand-built, manifest-consistent bundle can land a Source whose content does not match its content-address.
This is the same structural shape as #81 (uncited claims) and the dangling-reference gap (#123): the invariant is documented and has a detector (verify_source / vouch doctor), but no write path enforces it.
claimed source id : aaaaaaaaaaaaaaaa... (well-formed hex, but a lie)
sha256(content) : d6cc72573b97385e... (does NOT match)
import_check.ok = True issues=[]
import_apply wrote 2 files
landed source: id=aaaaaaaaaaaaaaaa... hash=aaaaaaaaaaaaaaaa...
verify_source.stored_ok = False (KB is now internally inconsistent)
The per-file sha256 gate added in #74 does NOT catch this: it only proves each tar member's bytes match the manifest entry, not that the content matches the directory's content-address. The manifest honestly records the real hash of the (attacker-chosen) content bytes, so #74 passes.
Affected paths:
bundle.import_check / bundle.import_apply (src/vouch/bundle.py) - _validate_content explicitly skips sources/<sha>/content, and nothing compares sha256(content) to <sha> or to meta.yaml's id/hash.
sync.sync_check / sync.sync_apply (src/vouch/sync.py) - same per-file validation path via bundle._validate_content.
Why this matters
It breaks the headline "content-addressed by sha256" guarantee on every import/sync path.
It is exploitable: an attacker can substitute the actual evidence behind a legitimate-looking source id. A claim that "cites source X" then points at bytes that were never hashed to X, while meta.yaml still advertises the original hash. The audit log records a clean bundle.import / sync.apply event, so an operator has no signal the source content was swapped.
verify_source / vouch doctor flags stored_ok=False only after the inconsistent source has already landed - detection without prevention.
Suggested fix
Add a per-file content-addressing check on the import/sync validation path (mirrors the verify_source invariant, enforced before write):
For each sources/<sha>/content member, require sha256(content) == <sha>.
For each sources/<sha>/meta.yaml, require id == <sha> and (when set) hash == <sha>.
No on-disk-layout, schema, or bundle-format change - honest bundles produced by vouch export are unaffected; only data that violates the documented content-addressing invariant starts being rejected at import time.
Problem
vouch markets Sources as "content-addressed by sha256" (README "Object model"; "Sources are content-hashed").
storage.put_sourcederives the id from the bytes, andverify.verify_sourcere-checkssha256(content) == source.id. Butbundle.import_applyandsync.sync_applywritesources/<sha>/{meta.yaml,content}straight from the tarball and never verify that the content hashes to its claimed id. So a hand-built, manifest-consistent bundle can land a Source whose content does not match its content-address.This is the same structural shape as #81 (uncited claims) and the dangling-reference gap (#123): the invariant is documented and has a detector (
verify_source/vouch doctor), but no write path enforces it.End-to-end repro on
testHEAD (696f028):The per-file sha256 gate added in #74 does NOT catch this: it only proves each tar member's bytes match the manifest entry, not that the content matches the directory's content-address. The manifest honestly records the real hash of the (attacker-chosen) content bytes, so #74 passes.
Affected paths:
bundle.import_check/bundle.import_apply(src/vouch/bundle.py) -_validate_contentexplicitly skipssources/<sha>/content, and nothing comparessha256(content)to<sha>or tometa.yaml'sid/hash.sync.sync_check/sync.sync_apply(src/vouch/sync.py) - same per-file validation path viabundle._validate_content.Why this matters
meta.yamlstill advertises the original hash. The audit log records a cleanbundle.import/sync.applyevent, so an operator has no signal the source content was swapped.verify_source/vouch doctorflagsstored_ok=Falseonly after the inconsistent source has already landed - detection without prevention.Suggested fix
Add a per-file content-addressing check on the import/sync validation path (mirrors the
verify_sourceinvariant, enforced before write):sources/<sha>/contentmember, requiresha256(content) == <sha>.sources/<sha>/meta.yaml, requireid == <sha>and (when set)hash == <sha>.bundle.import_check(soimport_applyinherits the refusal, same as bug: import_check / import_apply never verify member sha256 against manifest — bundle integrity gate is bypassable #74/bug: Claim model has no min-evidence validator — uncited claims land via bundle import, put_claim, update_claim #81) and intosync's per-file validation (sosync_check/sync_applyrefuse).No on-disk-layout, schema, or bundle-format change - honest bundles produced by
vouch exportare unaffected; only data that violates the documented content-addressing invariant starts being rejected at import time.