Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,4 @@ Follow these before searching; most questions are already answered in one step.
| **75** | 2026-10-22 | [c2patool's own `--allowed_list` confers no trust, reported upstream](notes/step-75-c2patool-allowed-list-reported-upstream.md) |
| **76** | 2026-11-02 | [c2pa-node 0.9.11 not taken, and an audit step red for a week](notes/step-76-c2pa-node-0-9-11-not-taken-and-a-red-audit-step.md) |
| **77** | 2026-11-02 | [SPEC-029 Amendment 1, and a gap in `REQUIRE_AI_MARKING`](notes/step-77-spec-029-am1-and-a-policy-gap.md) |
| **78** | 2026-11-09 | [An expired rogue fixture that kept passing (SPEC-045 Amendment 5)](notes/step-78-expired-rogue-fixture.md) |
51 changes: 51 additions & 0 deletions notes/step-78-expired-rogue-fixture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Step 78 — An expired rogue fixture that kept passing (2026-11-09)

SPEC-045 Amendment 5, test only. No code changes, no release.

## What happened

`tests/Fixtures/spec045-c2patool027-rogue.png` holds an AI original signed by
a rogue CA (*Rogue Root*, *Rogue Signer*), re-signed once with c2patool
0.27.22 and the C2PA test signer. Both rogue certificates were valid for 30
days: 2026-10-10 06:38:52 to **2026-11-09 06:38:52 UTC**. The outer signature
has no time-stamp.

From that moment the file reads `Invalid` (`signingCredential.expired`) on
both readers, instead of a trusted file over an untrusted ingredient
(measured with `certs/c2pa-trust.settings.json`):

| File | Outer | Ingredient |
|---|---|---|
| expired fixture | `Invalid`, not trusted | not trusted |
| rebuild | `Trusted` | not trusted |

AC3's table row (`'c2patool 0.27 rogue' => [false]`) checks only the
ingredient, and under Amendment 2 nothing below an untrusted report is
trusted. So the row stayed green: it passed for a reason it was not written
for. Nothing here noticed.

The Drupal module had copied the file and asserts the log line, which names
the outer verdict. Its CI went red on its 1.0.0-alpha5 release commit, the
morning the certificate expired (content_credentials #3636349, its NOTES
Step 67).

## The fix

- The fixture is the module's rebuild, byte for byte: a new P-256 rogue CA
and signer valid to 2036-11-05, an AI original
(`trainedAlgorithmicMedia`) signed by it, re-signed with c2patool 0.27.22
and `certs/es256_*`, no time-stamp. The recipe and keys are kept in the
module's development notes.
- A new test, "keeps a trusted file trusted over its untrusted ingredient"
(verifier, extension), pins the precondition the row relies on. Run red on
the expired file (2 failed), green on the rebuild.
- `composer check`: 530 passed, 7 skipped.

## Still open

- The rebuilt file expires with the C2PA test signer on **2030-08-26**
(outer signature, no time-stamp). Accepted.
- The other rogue fixtures (`spec045-c2pasign-rogue.png`, same expired chain;
`spec045-graft.png`, to 2027) are expected untrusted either way; their rows
do not depend on the rogue certificate's dates.
- No guard for fixture certificate expiry in general.
24 changes: 23 additions & 1 deletion specs/SPEC-045-ingredients-with-their-own-verdict.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,24 @@ to.
level trusted, and the integration comparison includes that file.
- The tested verifier range moves to `^0.6.1` (SPEC-042 Amendment 5).

## Amendment 5 — a rogue fixture that is still what its row says (approved 2026-11-09)

Test only; no code changes. `spec045-c2patool027-rogue.png` carried a rogue CA
and signer valid for 30 days, until 2026-11-09 06:38:52 UTC. Since then the
file reads `Invalid` (`signingCredential.expired`, no time-stamp) instead of a
trusted file over an untrusted ingredient. AC3's table row still passed,
because under Amendment 2 nothing below an untrusted report is trusted, so it
kept passing for a reason it was not written for. The module, which copied
the file, went red on it (content_credentials #3636349).

- The fixture is replaced by the module's rebuild: the same construction with
a rogue CA and signer valid to 2036-11-05, re-signed with c2patool 0.27.22 and
the C2PA test signer, without a time-stamp. It expires with the test signer
on 2030-08-26.
- A new test pins the precondition the row relies on: the file itself is
trusted and its ingredient is not, on every route. It was watched failing
on the expired file.

## Open questions

Resolved at approval (maintainer, 2026-10-10):
Expand All @@ -302,7 +320,7 @@ Resolved at approval (maintainer, 2026-10-10):
|----------------------|-----------------------------|----------------------|
| AC1 | `tests/Unit/Reading/IngredientsTest.php` :: "reports the AI origin two ingredients down a C2PA Sign chain" (verifier, extension) | `src/Core/Reading/ManifestStoreParser.php` `parseIngredients()`; `src/Core/Reading/IngredientReport.php`; `src/Core/Reading/ManifestReport.php` `ingredients()` |
| AC2 | `tests/Integration/IngredientEquivalenceTest.php` :: "reports the same ingredients through the service and the verifier", "reports the same ingredients through the service and ext-c2pa"; `tests/Integration/ServiceHarness.php` `accessors()` / `ingredientTree()` (so the SPEC-019 and SPEC-042 comparisons cover it); `tests/Unit/Reading/IngredientsTest.php` :: "reads a logo-bearing C2PA Sign chain through the verifier as c2pa-rs does" (Amendment 4, replacing the pinned exception), "trusts the same logo-bearing ingredient through c2pa-rs" | `src/Core/Reading/ManifestStoreParser.php` `ingredientDeltas()` |
| AC3 | `tests/Unit/Reading/IngredientsTest.php` :: "gives each ingredient the verdict of the SPEC-045 table" (5 files × verifier, extension), "trusts a synthetic ingredient only when every AC3 condition holds" (7 cases), "keeps the relationship verbatim"; Amendment 1: "trusts the good synthetic chain when the writer recorded only allowed codes", "trusts no ingredient once a writer recorded a failure code anywhere" (6 cases), "looks for recorded codes in every manifest of the store, not only the active one"; Amendment 2: "trusts no ingredient of a report that is not trusted", "trusts no grandchild of an untrusted ingredient", "trusts a grandchild when every link above it is trusted", "counts neither of two deltas under the same key" (2 orders), "counts an unreadable failure in a delta as a failure" (4 cases, `null` from the second review), "counts a malformed delta as one of two under the same key" (2 cases), "reads a large writer record in bounded time", and the `graft` row of the table test; Amendment 3: "treats keys that differ only in the leading slash as one key" (2 orders), "still finds a delta written in the relative form", "trusts neither of two ingredient entries that share a label" | `src/Core/Reading/ManifestStoreParser.php` `ingredientTrusted()`, `recordedByWriter()`; Amendment 1: `RECORDABLE_CODES`, `recordsHideNothing()`; Amendment 2: `fromArray()` (the report's trust as the first link), `parseIngredients()` `$aboveTrusted`, `recordHidesNothing()`, `ingredientDeltas()`; Amendment 3: `normalisedUri()`, `parseIngredients()` `$labelCounts` |
| AC3 | `tests/Unit/Reading/IngredientsTest.php` :: "gives each ingredient the verdict of the SPEC-045 table" (5 files × verifier, extension), "trusts a synthetic ingredient only when every AC3 condition holds" (7 cases), "keeps the relationship verbatim"; Amendment 1: "trusts the good synthetic chain when the writer recorded only allowed codes", "trusts no ingredient once a writer recorded a failure code anywhere" (6 cases), "looks for recorded codes in every manifest of the store, not only the active one"; Amendment 2: "trusts no ingredient of a report that is not trusted", "trusts no grandchild of an untrusted ingredient", "trusts a grandchild when every link above it is trusted", "counts neither of two deltas under the same key" (2 orders), "counts an unreadable failure in a delta as a failure" (4 cases, `null` from the second review), "counts a malformed delta as one of two under the same key" (2 cases), "reads a large writer record in bounded time", and the `graft` row of the table test; Amendment 3: "treats keys that differ only in the leading slash as one key" (2 orders), "still finds a delta written in the relative form", "trusts neither of two ingredient entries that share a label"; Amendment 5: "keeps a trusted file trusted over its untrusted ingredient" (verifier, extension) | `src/Core/Reading/ManifestStoreParser.php` `ingredientTrusted()`, `recordedByWriter()`; Amendment 1: `RECORDABLE_CODES`, `recordsHideNothing()`; Amendment 2: `fromArray()` (the report's trust as the first link), `parseIngredients()` `$aboveTrusted`, `recordHidesNothing()`, `ingredientDeltas()`; Amendment 3: `normalisedUri()`, `parseIngredients()` `$labelCounts` |
| AC4 | `tests/Unit/Reading/IngredientsTest.php` :: "does not trust any ingredient when the reader has no anchors", "does not trust an ingredient without a manifest, or without its own delta", "does not trust an ingredient without a manifest even under a good delta" | `src/Core/Reading/ManifestStoreParser.php` `parseIngredients()`, `ingredientTrusted()` |
| AC5 | `tests/Unit/Reading/IngredientsTest.php` :: "stops at a manifest that names itself", "stops at a cycle through an ancestor", "reports an ingredient naming a label that is not in the store as having no manifest", "reports no deeper than depth 8", "reports no more than 64 ingredients in total", "shares the 64 budget across a deep and wide tree" | `src/Core/Reading/ManifestStoreParser.php` `MAX_INGREDIENT_DEPTH`, `MAX_INGREDIENTS`, `parseIngredients()` |
| AC6 | `tests/Unit/Reading/IngredientsTest.php` :: "degrades malformed ingredient data instead of throwing" (5 cases), "treats an ingredient with a non-string relationship as having none, and still judges it", "reports no ingredients for an empty report" | `src/Core/Reading/ManifestStoreParser.php` `parseIngredients()`, `codesIn()`, `ingredientDeltas()` |
Expand Down Expand Up @@ -336,3 +354,7 @@ Amendment 4 (verifier 0.6.1): the pinned logo test turned red on 0.6.1 as
designed and was replaced by an agreement test; the logo fixture joined the
integration comparison (`c2pasign logo`). `composer check` 519 passed;
integration against the local service with trust settings 214 passed.

Amendment 5 (expired rogue fixture): the new precondition test was run red
on the expired `spec045-c2patool027-rogue.png` (2 failed: verifier,
extension) and green on the rebuild; the SPEC-045 group 80 passed.
Binary file modified tests/Fixtures/spec045-c2patool027-rogue.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 9 additions & 0 deletions tests/Unit/Reading/IngredientsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,15 @@ function spec045GoodDelta(string $in, string $ingredientLabel = 'c2pa.ingredient
'c2pa-rs CIE-sig-CA' => ['spec045-c2pa-rs-CIE-sig-CA.jpg', [false, false]],
])->group('SPEC-045');

// Amendment 5: the rogue row above is only worth having while the file itself
// is trusted; an expired fixture made it pass for another reason.
it('keeps a trusted file trusted over its untrusted ingredient', function (string $route) {
$report = spec045Read($route, 'spec045-c2patool027-rogue.png');

expect($report->isTrusted())->toBeTrue()
->and($report->ingredients()[0]->isTrusted())->toBeFalse();
})->with(spec045Routes())->group('SPEC-045');

it('keeps the relationship verbatim', function () {
$report = spec045Read('verifier', 'spec045-c2pa-rs-CIE-sig-CA.jpg');

Expand Down
Loading