Skip to content

Audit response: per-capture attestation, the copy corrections, and 997 malformed files - #69

Merged
noah-pi merged 10 commits into
mainfrom
claude/source-kit-open-source-launch-w4030i
Aug 27, 2026
Merged

Audit response: per-capture attestation, the copy corrections, and 997 malformed files#69
noah-pi merged 10 commits into
mainfrom
claude/source-kit-open-source-launch-w4030i

Conversation

@noah-pi

@noah-pi noah-pi commented Aug 27, 2026

Copy link
Copy Markdown
Owner

Seven commits. 31/31 suites, tsc clean, knip clean, supply-chain gates pass.

Per-capture App Attest

App Attest ran once, at registration. The attestation said the hardware was real; nothing said the hardware was present for any particular file, so a valid attestation could ride on a capture it had nothing to do with.

The Enclave now signs one assertion per capture over SHA256(domain ‖ cleanFileSha256 ‖ signingPublicKey) — useless on any other file and to any other signer. generateAssertion did not exist on the native module, so it is new. The verifier checks the assertion offline against the credential key already inside the attestation's authData: the same object proves the hardware and the presence, with no new trust anchor.

Apple's counter rides along and is reported, never judged. Judging it means remembering the last count per device, which is a device list, and there is nowhere here that keeps one. A reader holding a run of captures from one key can see whether it advanced; nobody had to keep a roster for them to do it. That reasoning is now an invariant, so it cannot be undone by accident.

Format /2 payloads stay valid and report no capture assertion. A /3 payload that does not carry the binding it claims is rejected rather than quietly read as a /2.

The relay is gone

server/ verified attestations and counted registrations. Attestation already works with a locally generated challenge and no network at all, and an organization that wants its own registry points the app at one from Settings. Removed with it: the second audit gate, the second dependency budget, the knip entry, and the two doc sections describing the endpoints. The client-side calls stay documented as network call sites in NETWORK.md.

Copy that was wrong

The Pixel 10 passage said in-pipeline hardware signing leaves no seam. It leaves a smaller one: arbitrary images can be signed with root, and root is reachable. The same claim appeared in the README, the site, docs/SECURITY.md, and a docs/THREAT-MODEL.md table row that read "nothing in Android userspace". All four now say what the seam is rather than that there is none, linking the research inline.

Also: the metadata-stripping gap no longer names fingerprints as the answer; the Galileo bullet says authentication rather than encryption and notes that relay stays open; flash differential joins the list of things not built yet.

Three things found while building

The COSE pad is writable. The unprotected header sits outside the signature and holds a few hundred zero bytes that exist only to size the store. Nothing ever looked at them again, so a file could carry payload there and still verify. The parser now reports a pad that is not zero-filled, and any entry the format does not define. The signature is still valid in that case, which is the point — it is stated, not scored.

Video and audio never emitted digitalSourceType. Only the JPEG and PNG arms declared c2pa.created. Downstream tools read that field first, so a sealed video said less about its own origin than a sealed still of the same scene.

The standalone verifier never reported device integrity. The in-app ladder always showed it; the oracle did not, so a capture sealed on a simulator read INTACT with nothing said either way. Now stated in the ladder's own terms: self-reported, and a compromised device can say anything.

The hard binding's hole

The audit asked for an assertion committing the digest of the excluded regions. There is one excluded region and it is the manifest, so an assertion inside it hashing itself cannot exist. What the request was after is that nothing outside the manifest is unhashed, and that is now a test: one range, starting and ending exactly where the inserted segments do, and cutting it out reproduces the hash the manifest committed.

997 malformed files

test-malleability.mts flips single bytes and pins which are malleable. That finds every field whose value does not matter. It cannot find what needs a file to be the wrong shape: a box claiming more length than the file holds, a store cut off mid-claim, sixteen thousand nested boxes, a segment that appears twice.

New test-hostile-structure.mts: truncation at 128 points, overrun and zero lengths across the segment, 120 splices, doubled segments, nesting bombs, 400 random buffers. Two rules — nothing throws, and nothing of a different shape reads INTACT. 249 shape changes, all correctly rejected. 69 of 748 in-place length writes still verify, which is the documented JUMBF framing set doing its job; that number is reported rather than failed, because test-malleability.mts owns it byte for byte.

c2pa-attacks itself is not wired in: it needs Rust in CI and this covers the same ground natively.

Location precision on export

Every rung of the location ladder is already its own committed leaf — geohash-5, -7, -9, and the exact fix. The export screen offered all of it or none of it.

It now offers a ceiling. Choosing one opens that leaf and everything coarser, and leaves the finer ones closed. Nothing is rounded or recomputed at export: a coarse answer is a leaf that was committed coarse, which is why it can be proved rather than taken on trust. An override makes the bundle a custom selection whatever the profile radio says, so it exports as one. A rung this capture never committed is offered disabled, with the reason.

Video pose trace

A video take read its sensor log, used it only to compute a predicted drift path, and discarded it when that path could not be built. The prediction needs second-camera pair frames and a sealed focal length, so a take with Multiple lenses off showed nothing — and said the trace could not be read when it had just been read successfully. The trace now renders on its own twist grid: yaw against pitch rate, the same fixed ±200 °/s in every exhibit, peak taken from every sample rather than the decimated path.

Also

  • c2pa-swift v0.0.12 is still the latest release, so the vendored copy stays where it is.
  • Four new entries in docs/INVARIANTS.md, each with the test that guards it.

Not in this PR

  • The build hash. It needs an EAS build to confirm the commit actually lands in the binary, and shipping an unverified build-provenance claim is worse than shipping none.
  • Repo topics. No API surface available here; a manual setting.

Generated by Claude Code

claude added 10 commits August 26, 2026 22:43
The Pixel 10 passage said hardware signing leaves no seam. It leaves a
smaller one: arbitrary images can be signed with root, and root is
reachable. The same sentence appears in the README, the site, the
threat-model table and SECURITY.md; all four now say what the seam is
rather than that there is none.

Also: the metadata-stripping gap no longer names fingerprints as the
answer, the Galileo bullet says authentication rather than encryption
and notes that relay stays open, and flash differential joins the list
of things not built yet.
The relay was a reference implementation of a registry nobody has to
run. Attestation already works with a locally generated challenge and
no network at all, and an organization that wants its own registry
points the app at one from Settings.

Gone with it: the second audit gate, the second dependency budget, the
knip entry, and the two doc sections that described the endpoints. The
client-side calls stay documented as network call sites in NETWORK.md,
which is where they were always the reader's business.
A video take read its sensor log, used it only to compute a predicted
drift path, and discarded it when that path could not be built. The
prediction needs second-camera pair frames and a sealed focal length,
so a take with Multiple lenses off — or one whose pairs did not commit
— showed nothing, and said the trace could not be read when it had
just been read successfully.

The trace now survives on the card's state and renders on its own
twist grid when there are no frames to overlay it against: yaw against
pitch rate, the same fixed +/-200 deg/s in every exhibit, peak taken
from every sample rather than the decimated path.

The stills card was never affected: it takes the pose trace as a prop
off the record and needs no frames to show it. Which is why a burst
photo showed a trace and a video did not.
App Attest ran once, at registration. The attestation said the hardware
was real; nothing said the hardware was present for any particular
file, so a valid attestation could ride on a capture it had nothing to
do with.

The Enclave now signs one assertion per capture over

    SHA256(domain | cleanFileSha256 | signingPublicKey)

which is useless on any other file and to any other signer. The
verifier checks it offline against the credential key already inside
the attestation's authData: the same object proves the hardware and
the presence, with no new trust anchor.

Apple's counter rides along in the assertion. It is reported, never
judged. Judging it means remembering the last count per device, which
is a device list, and there is nowhere here that keeps one. A reader
holding a run of captures from one key can see whether it advanced;
nobody had to keep a roster for them to do it.

Format /2 payloads stay valid and report no capture assertion. A /3
payload that does not carry the binding it claims is rejected rather
than quietly read as a /2.
The audit asked for an assertion committing the digest of the excluded
regions. There is only one excluded region and it is the manifest
itself, so an assertion inside it hashing itself is not a thing that
can exist. What the request was really after is that nothing outside
the manifest is unhashed, and that is now a test: one range, starting
and ending exactly where the inserted segments do, and cutting it out
reproduces the hash the manifest committed.

The real hole is elsewhere. The COSE unprotected header sits outside
the signature and holds a pad of a few hundred bytes whose only job is
to make the store the size the exclusion reserved. Those bytes are
written as zeros and nothing ever looked at them again, so a file
could carry payload there and still verify. The parser now reports a
pad that is not zero-filled, and any entry the format does not define.

Both rules are in INVARIANTS.md with the tests that guard them.
Two absences, both found by walking the paths rather than the code.

Video and audio never emitted c2pa.actions.v2. The JPEG and PNG arms
have always declared c2pa.created with digitalSourceType
digitalCapture; the BMFF arm was built without it, so a sealed video
told a downstream reader less about its own origin than a sealed still
of the same scene. Downstream tools read digitalSourceType first.

The standalone verifier never reported the device-integrity block. The
app's own ladder has always shown it, so a capture sealed on a
simulator read INTACT in the oracle with nothing said either way. It
is stated now, in the same terms the ladder uses: self-reported, and a
compromised device can say anything. Stated, never scored.
The lab flipped single bytes and pinned which ones were malleable. That
finds every field whose value does not matter. It cannot find what
needs a file to be the wrong shape: a box claiming more length than the
file holds, a store cut off mid-claim, sixteen thousand nested boxes, a
segment that appears twice.

Those are the files a reader actually meets, because a reader is handed
files by strangers. Nothing throws, and nothing of a different shape
verifies. The in-place length writes are reported rather than judged —
69 of 748 still verify, which is the documented framing set doing its
job, and test-malleability.mts owns that number.

Also closes the c2pa-rs pin question: v0.0.12 is still the latest
c2pa-swift release, so the vendored copy stays where it is.
Every rung of the location ladder is already its own committed leaf:
geohash-5, -7, -9 and the exact fix, each openable on its own. Nothing
in the export screen offered them, so the only real choices were all of
it or none of it.

The control sits under the profile picker and is authoritative for
location alone. Choosing a ceiling opens that leaf and everything
coarser, and leaves the finer ones closed. Nothing is rounded or
recomputed at export: a coarse answer is a leaf that was committed
coarse, which is why it can be proved rather than taken on trust.

An override makes the bundle a custom selection whatever the profile
radio says, so it exports as one. A bundle records what it opens, never
a preset it no longer matches.

A rung this capture never committed is offered disabled, with the
reason, rather than silently missing.
The staged tree's App Attest shim returns null with no arguments,
because there is no Enclave off-device. The real module now takes the
capture's media hash, so five call sites passed an argument the shim
would not accept and the staged typecheck failed.

The shim takes it and ignores it, which is the honest shape: the
per-capture assertion needs hardware that is not here either.
…coarse

Two things this branch built are absent from the public description.

The attestation section described only the enrollment binding, which
says the hardware exists. The per-capture assertion says it was present
for a particular file, which is the more interesting half.

The disclosure section said the choosing happens before the shutter and
not on export. The committing happens before the shutter; the choosing
now happens on export too, and location is committed at four precisions
so that choice can be coarse without being unprovable.
@noah-pi
noah-pi merged commit 420a0c5 into main Aug 27, 2026
5 checks passed
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.

2 participants