8000 Model what a file already discloses, and judge it separately from whether its signer is trusted · Issue #18 · ynishi/asterism · GitHub
[go: up one dir, main page]

Skip to content

Model what a file already discloses, and judge it separately from whether its signer is trusted #18

Open

Description

@ynishi

Part of #13. Split out of #14.

Problem

The write side of AI disclosure has a vocabulary. The read side has none.

DisclosureRecord, DigitalSourceType and Stamped say what is being written and what landed. Nothing says what a file currently carries. There is no verification result, no validation status, no trust verdict, and no type that distinguishes "this file has no mark" from "this file's mark is intact" from "this file's mark no longer matches its bytes".

The judgement itself is not missing — it exists, in a #[cfg(test)] helper in the infrastructure crate. That helper decides which C2PA validation failures mean the bytes were edited and which do not, and explains in a doc comment why signingCredential.untrusted and claimSignature.mismatch are excluded from the tampering verdict. That is domain knowledge held where no production path can reach it, and it is the exact knowledge needed to answer the question this feature keeps running into: what does a green readback mean when the certificate is self-signed?

Why the distinction matters more than it looks

Four outcomes are routinely collapsed into "valid / invalid", and they lead somewhere different:

Outcome What it means What a user should do
No mark The file was never stamped, or a conversion stripped it Re-apply from the database
Mark present, bindings match The disclosure describes these bytes Nothing
Mark present, bindings mismatch The bytes changed after signing Do not trust the statement; re-derive
Signer not on a trust list Says nothing about the bytes Nothing, today — see below

The last row is not a defect of the file. C2PA's own validation state stays Valid when signingCredential.untrusted is the only failure, and the reference implementation's Display for a failure summary filters it out entirely.
(https://spec.c2pa.org/specifications/specifications/2.3/specs/C2PA_Specification.html#_validation_states)

There is a second reason to keep the axes apart, and it is a trap. In the reference implementation, trust checking is on by default while the trust anchor set is empty outside of its own test configuration. A release build therefore reports every signer as untrusted, including a genuine conformance-program certificate, until anchors are configured. A verdict type that folded trust into validity would make a correctly signed file look broken in production and fine under cargo test.

Scope

  • A domain vocabulary for what a file carries and how it stands up, with the integrity axis and the trust axis separate.
  • A port for reading it, alongside the existing write-only port.
  • An adapter implementing that port over the C2PA reader and the existing XMP packet reader, which already exist and are already exercised by tests.
  • Registered settings for the trust configuration, so anchors and the on/off of trust checking are visible and explainable rather than implicit in a library default.

Not in scope: signing, the export path wiring, and the certificate policy — those stay in #14.

Acceptance criteria

  1. A domain type expresses, for one file, which of the four outcomes above holds, and the trust axis is a separate value from the integrity axis. It is not an Option, for the reason the series key already records: "not about this material" and "about it and found nothing" are different answers.
  2. The XMP half and the manifest half are reported independently, matching the shape the write outcome already uses — the two fail independently and a caller must be able to see which one landed.
  3. The mapping from validation status codes to the verdict lives in the domain, with unit tests over the codes, and no longer in a test helper. Unrecognised codes are not silently treated as success.
  4. The read port is declared in the core beside the write port, and an infrastructure adapter implements it. The core does not name the C2PA SDK.
  5. Trust checking and its anchor material are registered settings, so a deployment can turn trust evaluation on with real anchors without a code change, and the default is explainable in one sentence.
  6. A verdict is reachable for a file that carries nothing, without that being an error.

Step 1 landed in #273

Done — the domain half: Mark, Signer and Carried in crates/asterism-core/src/domain/disclosure/carried.rs, the status-code mapping as integrity_of with unit tests over the codes, and the DisclosureReader port beside the writer. Criteria 1, 2, 3, and the port half of 4.

Not done — the adapter, the trust configuration, and every test that reads a real file back: the rest of 4, and 6. Criterion 5 is withdrawn by the comment below, which replaces it.

What that step settled, which changes what is left

claimSignature.mismatch is not a trust verdict. Measured on this repository's own signed files and pinned as what_the_sdk_reports_for_this_builds_own_signatures:

certificate failures signature issuer
with an organizationName signingCredential.untrusted named
without one signingCredential.untrusted, claimSignature.mismatch absent

So the mismatch tracks the missing attribute — #179's upstream defect — rather than the trust check, and the manifest's signature issuer is the one field that separates that defect from a real forgery. integrity_of takes it, and an unnamed signer's mismatch answers Mark::Undetermined, the fifth state the comment below asked for.

The fixture question #179 raises is narrower than it looked: issued_shaped_pair already carries an organisation, so the integration tests this issue asks for can be written against it today. What #179 still owns is that self_signed_pair does not, and that the tree reasons from it in places as though its behaviour were the SDK's.

Open — to be settled while implementing

  • Whether the verdict carries the parsed disclosure back (the packet's terms and the manifest's assertions) or only the judgement. Reading the packet back into a DisclosureRecord would let the application compare what a file says against what the database says; that comparison is the natural next feature and may or may not belong in the same type.
  • Whether an unreadable packet that was just written stays an error variant or becomes one of the outcomes.
  • Where the verdict surfaces — this issue can land without any HTTP or IPC exposure.

Verification

  • Unit tests over the status-code mapping, pure, with no file and no SDK. Done in Say what a file already carries, apart from who signed it #273.
  • An integration test that signs with a throwaway certificate, reads back, and asserts the verdict is "intact, untrusted" — not "invalid". Against issued_shaped_pair, whose certificate carries an organisation; against self_signed_pair the honest assertion is "undetermined, untrusted", and both are worth pinning.
  • An integration test that edits a signed file's packet and asserts the verdict changes on the integrity axis while the trust axis does not move.
  • An integration test that a file carrying nothing yields the no-mark outcome rather than an error.

Activity

  1. ynishi commented on Aug 26, 2026

    @ynishi
    OwnerAuthor

    Two things settled while implementing, both of which change what this issue asks for. Recording them here rather than only in the branch, because one withdraws an acceptance criterion and the other adds a state to the vocabulary criterion 1 enumerates.

    Criterion 5 is withdrawn

    It asked for trust checking and its anchor material to be registered settings. That names a mechanism, and the mechanism collides with a rule this repository had already decided. core_init.rs refuses a registry key holding a path for two reasons: PUT /asterism/settings/{key} would let whoever reaches that route choose which file the process opens at the next launch, and a registry key is readable back with every layer, publishing the location.

    The first reason applies to a trust anchor bundle unchanged. Whoever can write disclosure.verify.trust_anchors can point the verifier at a bundle of their own and make any signer come back trusted — a trust root is the one input an attacker wants to substitute rather than to read, so "it is public certificate material" answers the second reason and not the first. The preferences screen renders every registry row, so the two keys would also have arrived as editable controls on a user preferences screen, which is not what an arrangement with a certificate authority is.

    So the keys are gone. Trust configuration is the composition root's to read, in the same shape as the signing variables, and it lands in the change that first reads a disclosure back — the same principle asterism-ui's build.rs states for bindings: a thing enters in the change that consumes it. What this change lands is the vocabulary, the port, the adapter, and the parse-and-refuse policy; the default, trust evaluation off, is explainable in one sentence.

    The replacement wording:

    1. How trust evaluation is turned on, and where its anchors come from, is decided and recorded — the composition root reads them, in the same shape as the signing variables, and not as registry keys: a trust root chosen through PUT /asterism/settings/{key} would let that route decide which signers come back trusted. The configuration lands in the change that first reads a disclosure back; this one lands the vocabulary, the port and the adapter, and the default — trust evaluation off — is explainable in one sentence.

    Criterion 1 needs a fourth answer, and the reason is measured

    Criterion 1 asks for a type expressing which of four outcomes holds, with the trust axis separate from the integrity axis, and argues against Option because "not about this material" and "about it and found nothing" are different answers. Implementing it turned up a case where the integrity axis has to say a third thing, for the same reason.

    c2pa 0.90.12 emits claimSignature.mismatch both for a genuine forgery and for a signing certificate whose subject carries no organizationName. The second is an upstream defect — the organisation is fetched for display after the signature has already verified, and its absence becomes an error that claim.rs folds into that one code (contentauth/c2pa-rs#2262, accepted, fix PR #2540 unmerged; the C2PA specification requires no such attribute). Measured against c2pa =0.90.12 with this workspace's own features, the two cases are identical across every field of validation_results() — same code, same explanation "claim signature is not valid", same success list including assertion.dataHash.match, same validation_state: Invalid. The local write-up is #179.

    One field separates them: Manifest::signature_info().issuer, which is None exactly when the attribute is absent, and which c2pa reads out of the certificate rather than out of the verification — so it still answers for a signature that did not verify. A named signer therefore rules the upstream defect out, and an unnamed one rules nothing in. That discriminator is in the branch.

    What it leaves is a case with no honest answer in the vocabulary as this issue drew it: an unnamed signer's mismatch, where the file may have been forged and may be carrying the upstream defect, and nothing available can tell. Routing it to the trust axis says a signer was refused when none was — and on the default configuration, where trust evaluation is off, it reaches a caller on no axis at all, so a forged claim signature over untouched asset bytes reads as intact. Routing it to Broken says the bytes changed, which may be false.

    So the integrity axis gains a state meaning the question was asked and could not be answered, and a file in that state does not report as holding. "The bytes changed" and "I cannot tell whether the bytes changed" lead somewhere different, which is this issue's own argument one step further in.

    Criterion 1's "four outcomes" becomes five, and the table in the body — no mark / mark present and bindings match / mark present and bindings mismatch / signer not on a trust list — gains a row: mark present, and whether its bindings hold cannot be established, with the reason carried as words.

    What this does not change

    The write side. Measured through this repository's own writer, with an O=-carrying certificate, both halves written: ValidationState::Valid with an empty failure list on PNG, MP4 and MOV. Nothing about the manifest, the ordering of the two halves, or the hard binding is implicated, and README.md's statement that a self-issued certificate validates as signingCredential.untrusted while the validation state stays Valid is true of such a certificate. It would read as false if demonstrated with the fixture this repository currently ships, which is what #179 carries.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

      0