From 85922e6980469fafc0e9ee4e3819a3c28d40238a Mon Sep 17 00:00:00 2001 From: maurice Date: Mon, 2 Nov 2026 13:43:42 +0100 Subject: [PATCH] Step 77: SPEC-029 Amendment 1, and a gap in REQUIRE_AI_MARKING --- NOTES.md | 3 +- .../step-77-spec-029-am1-and-a-policy-gap.md | 99 +++++++++++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 notes/step-77-spec-029-am1-and-a-policy-gap.md diff --git a/NOTES.md b/NOTES.md index 79e6c53..a488fef 100644 --- a/NOTES.md +++ b/NOTES.md @@ -43,7 +43,7 @@ Follow these before searching; most questions are already answered in one step. | `digitalSourceType` vocabulary and its traps | [26](notes/step-26-spec-022-builder-entry-point-name.md), [32](notes/step-32-digitalsourcetype-research.md), [33](notes/step-33-spec-026-three-claims.md) | | Ingredients, `c2pa.opened`, manipulated content | [35](notes/step-35-spec-028-drafted-two-questions.md), [37](notes/step-37-spec-028-implemented-article-50-2.md), [38](notes/step-38-reviewing-0-9-0-after-shipping.md) | | The two readers, ext-c2pa, and who it reaches | [23](notes/step-23-spec-019-ext-c2pa-reader.md), [24](notes/step-24-correcting-step-23-what-it-unlocks.md), [47](notes/step-47-equivalence-test-configurations.md) | -| Service hardening: limits, audit, auth ordering | [14](notes/step-14-spec-011-and-spec-012.md), [15](notes/step-15-spec-015-rate-limiting-and-concurrency.md), [29](notes/step-29-codebase-review-two-defects.md), [42](notes/step-42-spec-030-peer-identity.md), [43](notes/step-43-spec-029-and-spec-030-implemented.md) | +| Service hardening: limits, audit, auth ordering | [14](notes/step-14-spec-011-and-spec-012.md), [15](notes/step-15-spec-015-rate-limiting-and-concurrency.md), [29](notes/step-29-codebase-review-two-defects.md), [42](notes/step-42-spec-030-peer-identity.md), [43](notes/step-43-spec-029-and-spec-030-implemented.md), [77](notes/step-77-spec-029-am1-and-a-policy-gap.md) | | Container, runtime and reproducible builds | [10](notes/step-10-reproducible-service-builds-npm-ci.md), [22](notes/step-22-dependabot-first-prs-and-v0-5-3.md), [44](notes/step-44-node-24-and-container-hardening.md), [48](notes/step-48-c2pa-node-0-8-3-underflow-measured.md) | | Dependency bumps and their verification ritual | [8](notes/step-08-c2pa-node-0-8-0.md), [9](notes/step-09-c2pa-node-0-8-1.md), [48](notes/step-48-c2pa-node-0-8-3-underflow-measured.md), [67](notes/step-67-c2pa-node-0-9-6-deprecates-the-settings-path.md), [74](notes/step-74-c2pa-node-0-9-10-and-the-lost-timestamp.md) | | c2pa engine security fixes, and which engine has them | [48](notes/step-48-c2pa-node-0-8-3-underflow-measured.md), [55](notes/step-55-c2pa-node-0-9-3-and-qs.md), [64](notes/step-64-c2pa-node-0-9-5-closes-the-gap.md), [65](notes/step-65-recent-c2pa-developments-and-the-ssrf-that-is-not-one.md) | @@ -131,3 +131,4 @@ Follow these before searching; most questions are already answered in one step. | **74** | 2026-10-20 | [c2pa-node 0.9.10: the trust migration works, and signing lost its timestamp](notes/step-74-c2pa-node-0-9-10-and-the-lost-timestamp.md) | | **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) | diff --git a/notes/step-77-spec-029-am1-and-a-policy-gap.md b/notes/step-77-spec-029-am1-and-a-policy-gap.md new file mode 100644 index 0000000..e29071f --- /dev/null +++ b/notes/step-77-spec-029-am1-and-a-policy-gap.md @@ -0,0 +1,99 @@ +# Step 77 — SPEC-029 Amendment 1, and a gap in `REQUIRE_AI_MARKING` (2026-11-02) + +`POST /v1/sign` now refuses a request that carries no actions assertion +(PR #204, squash `8cbcdc6`). Writing the amendment turned up a gap in the +AI-marking policy that had been there since SPEC-011. Both are closed by the +same check. + +## Why the amendment + +SPEC-011 settled "at most one actions assertion, not required" on 2026-08-05. +SPEC-029 kept that permission, and its AC6 even tested that a request without +an actions assertion still signs. That was a statement about one engine: + +- **c2pa-rs 0.90** signs such a manifest, and every verifier reads it back + `Invalid` with `assertion.action.malformed`. +- **c2pa-rs 0.91** verifies after signing (c2pa-rs #2277) and refuses it. The + service answered 500 through its catch path, with engine wording as the + audit reason (measured on c2pa-node 0.9.10, Step 74). + +Neither outcome is useful to a caller. The amendment requires an actions +assertion to **exist**. What it says stays the caller's: SPEC-011's reasoning +was about contents, and that resolution stands, with a pointer line in +SPEC-011. The check is engine-independent, so it shipped before the bump. Of +the two 0.91 findings in Step 74, only the missing timestamp (c2pa-js#244) is +left. + +## The gap + +Measured on the current service (c2pa-node 0.9.5) with +`REQUIRE_AI_MARKING=true`: + +| request | before #204 | +|---|---| +| `extra_assertions: []` | 400, `this service is configured to sign only AI markings` | +| no `extra_assertions` key | **200, signed**, no AI marking | + +The first line of `rejectAssertions()` was +`if (assertions === undefined) return null;`. It returned before every later +check in the function, the policy check included. So a deployment whose +certificate exists only to mark AI content would sign anything, as long as the +key was left out. The early return and the policy check arrived in the same +commit (`6445e2d`, SPEC-011, 2026-08-05), so the gap is as old as the policy. +Reaching it needs the service token, so only the +deployment's own application could do it, by mistake or otherwise. + +Why nothing saw it: + +- **The criterion did not ask.** SPEC-011 AC8 speaks about a request that + carries an actions assertion. Absence was outside its wording. +- **The `hardened` profile always sent the key.** Every test that runs under + the policy sends `extra_assertions`, usually built by `ManifestBuilder`. The + absent-key path ran only in `defaults`, where the policy is off and a 200 was + the expected answer. +- **It was found by writing a criterion, not by a test.** Spelling out AC12 + ("under the policy, both forms are refused") forced the question of what the + absent key does today. The answer was measured before it was written down, + and the first draft of AC12 was wrong about it: it claimed both forms already + answered with the policy reason. + +The same shape as the rule about tests that pass while testing nothing, one +level up: a guard that is only exercised with the input that never triggers it. + +## What changed + +- `rejectAssertions()` treats an absent key as `[]`, and after the "at most + one" count refuses `actionsCount === 0` with + `an actions assertion (c2pa.actions.v2) is required`. That check sits before + the policy check, so the structural reason wins over the policy reason. +- Tests first, each seen red on the pre-amendment service: AC10 in three + datasets (no key, `[]`, only a non-actions assertion; 200 each), AC12 in two + (no key: 200; `[]`: the policy reason). They assert the error text and the + audit reason, not only the status, because on 0.9.10 a status-only refusal + test had passed for the engine's reason (Step 74). +- AC6's old "still signs" test is gone. `spec030Post()` in the `auth-limited` + tests now sends an actions assertion. +- `docs/service.md` and primer §3 say the assertion is required. + +## Verified + +- Local, default settings: integration 219 passed / 25 skipped (217 / 23 + before; the added and removed tests account for the difference exactly). +- Local, `REQUIRE_AI_MARKING=true`: 220 / 24. `--group=SPEC-030` with the + `auth-limited` settings: 11 / 4, the valid-token signing test among the + passes. +- `bin/e2e.php` trusted with the Article 50 mark, `composer check` 528 / 7. +- CI 20 / 20. The `hardened` job log shows all five new cases ran rather than + skipped. +- **Not measured:** that a c2pa-node 0.91 build now answers 400 instead of + 500. It follows from the check running before the engine, but it was not + run. That belongs to the bump. + +## Who notices + +`ManifestBuilder` users: nobody, every builder entry point emits one actions +assertion. A hand-built `new Manifest($type, [])` now gets a +`SigningFailedException` where it used to get an `Invalid` manifest; nothing in +this repository builds one. Direct callers of the service that send no actions +assertion get a 400. The behaviour is in `service/`, so it arrives through +`git pull` and a rebuild. The docs are in `docs/` and ship with the next tag.