Audit response: per-capture attestation, the copy corrections, and 997 malformed files - #69
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Seven commits. 31/31 suites,
tscclean, 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.generateAssertiondid not exist on the native module, so it is new. The verifier checks the assertion offline against the credential key already inside the attestation'sauthData: 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
/2payloads stay valid and report no capture assertion. A/3payload 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 inNETWORK.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 adocs/THREAT-MODEL.mdtable 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 declaredc2pa.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.mtsflips 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, becausetest-malleability.mtsowns it byte for byte.c2pa-attacksitself 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-swiftv0.0.12 is still the latest release, so the vendored copy stays where it is.docs/INVARIANTS.md, each with the test that guards it.Not in this PR
Generated by Claude Code