Skip to content

validation gap: bundle/sync import doesn't enforce Source content-addressing (sha256(content) == id) #125

Description

@galuis116

Problem

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.

End-to-end repro on test HEAD (696f028):

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):

  1. For each sources/<sha>/content member, require sha256(content) == <sha>.
  2. For each sources/<sha>/meta.yaml, require id == <sha> and (when set) hash == <sha>.
  3. Wire it into bundle.import_check (so import_apply inherits 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 into sync's per-file validation (so sync_check/sync_apply refuse).

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions