From b86e254fb7578abb4a0b4cc6fe77e91d9260c028 Mon Sep 17 00:00:00 2001 From: maurice Date: Mon, 2 Nov 2026 10:07:36 +0100 Subject: [PATCH 1/2] SPEC-029 Amendment 1 (draft): refuse a request without an actions assertion c2pa-rs 0.91 verifies after signing and refuses a claim-v2 manifest with no actions assertion, so the service answers 500 where SPEC-029 AC6 expected a signature (measured on c2pa-node 0.9.10, NOTES Step 74). The amendment makes the service refuse such a request itself, with a 400 and a named reason, on every engine. SPEC-011's "not required" about the contents of the actions assertion stays as it is. Found while writing it, measured on the current service: with REQUIRE_AI_MARKING=true, a body without the extra_assertions key is signed (200), because rejectAssertions() returns before the policy check when the key is absent. AC12 pins both forms. Draft: no code, no tests yet. --- .../SPEC-029-actions-structure-validation.md | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) diff --git a/specs/SPEC-029-actions-structure-validation.md b/specs/SPEC-029-actions-structure-validation.md index 98c0007..53f3885 100644 --- a/specs/SPEC-029-actions-structure-validation.md +++ b/specs/SPEC-029-actions-structure-validation.md @@ -247,6 +247,152 @@ where a live service is required. - *(Regression guard: `markingSourceTypes()` is one of the four helpers being made total, and it is the one whose behaviour a policy decision hangs on.)* +## Amendment 1: a request without an actions assertion is refused (draft) + +Proposed 2026-11-02. Sources: NOTES Step 74 (the measurement on c2pa-node +0.9.10) and Step 76. + +**Why.** *Out of scope* above excludes "requiring an actions assertion at all", +and rests that on a measurement: a request with no actions assertion signs, and +reads back `Invalid` with `assertion.action.malformed`. That measurement belongs +to one engine. c2pa-rs 0.91 verifies a manifest after signing, by default +(c2pa-rs #2277). A claim-v2 manifest with no actions assertion fails that check, +so the engine refuses to produce it and the service answers **500** through its +catch path *(measured on c2pa-node 0.9.10, NOTES Step 74; AC6's third case goes +red there)*. A caller's mistake then reads as a server error, a signing slot is +spent, and the audit reason is engine wording, which is what AC7 exists to +prevent. + +The 0.90 behaviour that AC6 protects is not worth keeping either. What +"degrading honestly" produces is a signature with this service's certificate on +a manifest that every verifier calls `Invalid`. AC6 refuses the empty array and +SPEC-028 AC13 refuses a supplied `c2pa.opened` for the same reason: a signature +spent on an artefact no verifier accepts. The absent assertion is the last route +to that result. + +**What this does not reopen.** SPEC-011 resolved "at most one, not required" on +2026-08-05, and its reasoning was about **contents**: requiring +`trainedAlgorithmicMedia` narrows the direction of a possible lie without making +the attestation truer, and it would exclude the authenticity use case. That +stands unchanged. This amendment requires only that an actions assertion +**exists**, whatever its first action says: `c2pa.created` with any +`digitalSourceType`, or the SPEC-028 edited shape. Claim v2 already requires +that much (Domain rules: the first action must be `c2pa.created` or +`c2pa.opened`). `REQUIRE_AI_MARKING` stays the only policy about contents. On +approval, SPEC-011's resolved question gets one line pointing here. + +### Changes + +**AC6, third case, replaced.** The first two cases (`data: {}` and +`actions: []`) are unchanged. The third becomes: + +- And given `extra_assertions: []`, and separately a body without an + `extra_assertions` key +- Then HTTP **400** under AC10, and nothing is signed + +The note that "the three outcomes together are the criterion" is withdrawn. It +guarded the permission this amendment removes. + +**New criteria:** + +- **AC10 — a request without an actions assertion is refused before signing** + *(error path)* + - Given a valid token and a request that passes every earlier check, whose + `extra_assertions` is absent, empty, or holds no assertion whose label + starts `c2pa.actions` + - When `/v1/sign` is called + - Then HTTP **400** with the error + `an actions assertion (c2pa.actions.v2) is required`, no `signed_content`, + and an audit record with `outcome: "rejected"` and that reason. No record + with `outcome: "failed"` exists for the request (AC3's technique) + - The check runs inside `rejectAssertions()`, after the "at most one" count. + So the media type, base64 and `creator_name` checks still answer first, and + the parent, brand and engine steps are never reached + - The answer is the same on c2pa-rs 0.90 and 0.91. This criterion does not + depend on the engine bump and may ship before it + +- **AC11 — the test asserts the reason, not the status** + - Given the AC10 test + - Then it asserts the error text and the audit reason, and it was seen red + against the code before this amendment (which answers 200) + - *(Step 74's lesson: on 0.9.10 a refusal test passed because the engine + refused for a different reason. A status code alone cannot tell this check + from the engine's.)* + +- **AC12 — under `REQUIRE_AI_MARKING`, both forms are refused, with the + structural reason** *(error path)* + - Given a service started with `REQUIRE_AI_MARKING=true` + - When a request with `extra_assertions: []` is signed, and separately one + without an `extra_assertions` key + - Then both answer **400** with the AC10 reason, not + `this service is configured to sign only AI markings`, and nothing is + signed + - *(Measured 2026-11-02 on the current service, c2pa-node 0.9.5, with + `REQUIRE_AI_MARKING=true`: `extra_assertions: []` answers 400 with the + policy reason, but **a body without the key answers 200 and is signed**. A + manifest without any AI marking, under a policy that exists to refuse + exactly that. The cause is the first line of `rejectAssertions()`: + `if (assertions === undefined) return null;` returns before the policy + check further down the same function. The `hardened` profile never saw it, + because every test there sends the key. SPEC-011 AC8's wording only covers + a request that carries an actions assertion, so this is a gap in that + criterion as much as in the code. It contradicts what the policy is for, + and the service's own answer to `[]`. It exists today, independent of this + amendment. AC10 closes it, because the requirement + check cannot be placed behind that early return; the test for the absent + key is the one that must be seen red first.)* + +**Unchanged:** AC1–AC5, AC7–AC9, the request and response shapes of +`/v1/sign`, the content policy, and `/health`. + +### Who notices + +- **`ManifestBuilder` users: nobody.** Every builder entry point emits exactly + one actions assertion, and AC1 pins that. +- **A hand-built `Manifest`.** Its constructor is public, so a caller can build + `new Manifest($type, [])` and sign it. Today that signs a manifest that reads + `Invalid`; afterwards `SigningServiceSigner` throws `SigningFailedException` + with the AC10 message. Nothing in `src/`, `tests/`, `docs/` or the README + builds one (searched 2026-11-02, only `ManifestBuilder` calls the + constructor). `src/` does not change. +- **Direct callers of the service** that send no actions assertion, which is + what this amendment is about. + +### Existing tests that change + +- `ActionsStructureTest` :: "still signs a request carrying no actions assertion + at all" is replaced by the AC10/AC12 tests. The new ones do not skip under + `REQUIRE_AI_MARKING`. +- `UnauthenticatedBoundsTest`'s `spec030Post()` sends no `extra_assertions` and + expects 200 for a legitimate signature (the `auth-limited` profile only). It + gets an actions assertion. Its 401, 413 and 429 expectations are reached + before `rejectAssertions()` and do not change. +- Read and left alone: `MediaTypesTest` (three requests) and + `RemainingMediaTypesTest` (two) send `extra_assertions: []` but expect a + refusal from an earlier check (media type, base64, body size). Every other + caller of `/v1/sign` sends an actions assertion or goes through the library. +- Measured on 0.9.10 (Step 74), where the engine already refuses these + requests: in the `defaults` profile, only AC6's third case and the SPEC-007 + AC5 test (fixed since, #184) depended on signing without an actions + assertion. The other profiles were checked by reading only. + +### Docs + +- `docs/service.md`, *Assertion limits*: "Sending no actions assertion at all + remains allowed …" is replaced by the requirement and its reason. +- `docs/c2pa-primer.md` §3: `extra_assertions` is marked required, as it must + carry the actions assertion. +- `docs/` is in the dist and the behaviour is in `service/`, so a tag delivers + the docs and not the behaviour. The CHANGELOG says both halves, as for + SPEC-039 and SPEC-043. + +### Verification before merge + +AC10–AC12 green, and the AC10 test seen red first. The integration group green +in every CI profile, `auth-limited` included. `bin/e2e.php` trusted with the +Article 50 mark intact. Not required, but useful: on a local 0.9.10 build, a +request without actions answers 400 with the AC10 reason instead of 500. + ## API sketch Illustrative only. The change is confined to `service/server.js`; the request and From 420cc9f0ee77a06143a0d95fcb73c9e27d962568 Mon Sep 17 00:00:00 2001 From: maurice Date: Mon, 2 Nov 2026 11:16:38 +0100 Subject: [PATCH 2/2] SPEC-029: refuse a request without an actions assertion (Am. 1) Amendment 1 approved 2026-11-02. rejectAssertions() treats an absent extra_assertions key as an empty array and refuses when no c2pa.actions assertion is present, with "an actions assertion (c2pa.actions.v2) is required", before the REQUIRE_AI_MARKING check. This also closes a policy gap: with REQUIRE_AI_MARKING=true a body without the key returned early and was signed with no AI marking. Tests first, seen red on the pre-amendment service: AC10 (three datasets, 200 each) and AC12 under REQUIRE_AI_MARKING (no key: 200; []: the policy reason). The old "still signs" test is replaced, and spec030Post() now sends an actions assertion. Verified locally: integration 219 passed / 25 skipped (defaults), 220 / 24 (REQUIRE_AI_MARKING=true), SPEC-030 in the auth-limited settings 11 / 4, bin/e2e.php trusted with the Article 50 mark, composer check 528 / 7. --- CHANGELOG.md | 20 +++++ docs/c2pa-primer.md | 2 +- docs/service.md | 14 +++- service/server.js | 15 +++- specs/SPEC-011-assertion-limits.md | 3 + .../SPEC-029-actions-structure-validation.md | 11 ++- tests/Integration/ActionsStructureTest.php | 83 ++++++++++++++----- .../Integration/UnauthenticatedBoundsTest.php | 10 +++ 8 files changed, 129 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33962a0..a6a3e7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,26 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Changed: in the signing service, not in a tag + +- **`POST /v1/sign` refuses a request without an actions assertion** (SPEC-029 + Amendment 1). A body without `extra_assertions`, with an empty array, or with + no `c2pa.actions` assertion now answers **400** with `an actions assertion + (c2pa.actions.v2) is required`, recorded as `rejected`, and nothing is signed. + Before, such a request was signed into a manifest every verifier reads as + `Invalid`, and on c2pa-rs 0.91 the engine refuses it after signing, which the + service would report as a 500. `ManifestBuilder` always emits the assertion, + so library users see no change. A hand-built `new Manifest($type, [])` now + gets a `SigningFailedException`. +- **This closes a gap in `REQUIRE_AI_MARKING`.** With the policy on, a body + *without* the `extra_assertions` key was signed with no AI marking at all, + because the assertion checks returned early when the key was absent. An + empty array was refused correctly. Measured 2026-11-02; reachable only with + the service token. +- The behaviour is in `service/`, so it arrives through `git pull` and a + rebuild. The matching documentation (`docs/service.md`, primer §3) is in + `docs/` and therefore in the next tag. + ### Security: in the signing service, not in a tag - **The service's lockfile takes `body-parser` 2.4.0 and `qs` 6.17.1**, for diff --git a/docs/c2pa-primer.md b/docs/c2pa-primer.md index 1c75340..ffa9c0a 100644 --- a/docs/c2pa-primer.md +++ b/docs/c2pa-primer.md @@ -66,7 +66,7 @@ The canonical assertion for "this asset is AI-generated": | `content` | yes | base64 of the raw file bytes | | `mime_type` | yes | must be in `SUPPORTED_MIME`, else 400 | | `creator_name` | no | prepended to claim_generator; string, `MAX_CREATOR_NAME` (256) | -| `extra_assertions` | no | raw C2PA assertion objects, appended; vetted per SPEC-011 | +| `extra_assertions` | yes | raw C2PA assertion objects, appended; vetted per SPEC-011; must hold exactly one actions assertion (SPEC-029 Am. 1) | | `parent` | conditional | `{content, mime_type}` of the source asset — the SPEC-028 ingredient | That is the whole body: `server.js` destructures exactly these five and ignores diff --git a/docs/service.md b/docs/service.md index 4933d11..5920bec 100644 --- a/docs/service.md +++ b/docs/service.md @@ -227,10 +227,16 @@ is signed. Tune with `MAX_ASSERTIONS`, `MAX_ASSERTION_BYTES`, An actions assertion must also have the **shape** of one: an object `data` carrying a **non-empty** array `data.actions` whose entries are objects. This is -not tunable, because there is no legitimate value to tune it to. Sending no -actions assertion at all remains allowed — that manifest reads back as `Invalid` -with `assertion.action.malformed`, which is a verifier correctly reporting a -claim-v2 rule, and it is your claim to make. +not tunable, because there is no legitimate value to tune it to. + +A request must also **carry** an actions assertion (SPEC-029 Amendment 1). A +body without `extra_assertions`, an empty array, or one holding no +`c2pa.actions` assertion returns **400** with `an actions assertion +(c2pa.actions.v2) is required`, and nothing is signed. Claim v2 requires one. +Older engines signed such a manifest and every verifier then read it as +`Invalid`; c2pa-rs 0.91 refuses it after signing, which surfaced as a 500. What +the assertion *says* is still yours: any `digitalSourceType`, or the edited +shape for manipulated content. An *empty* array is different, and it is why this constraint exists. Measured: it was the one malformed shape that got signed, and the resulting asset could diff --git a/service/server.js b/service/server.js index fb36cb6..a8ba893 100644 --- a/service/server.js +++ b/service/server.js @@ -520,7 +520,11 @@ function markActionsAsCreated(assertions) { } function rejectAssertions(assertions) { - if (assertions === undefined) return null; + // SPEC-029 Am. 1: an absent key is the same request as an empty array, and + // both are refused below. This used to `return null`, which skipped every + // later check, the AI-marking policy included: with REQUIRE_AI_MARKING=true a + // body without the key was signed without any marking (measured 2026-11-02). + if (assertions === undefined) assertions = []; if (!Array.isArray(assertions)) return 'extra_assertions must be an array'; if (assertions.length > MAX_ASSERTIONS) { return `too many assertions (max ${MAX_ASSERTIONS})`; @@ -562,6 +566,15 @@ function rejectAssertions(assertions) { return 'at most one c2pa.actions assertion is allowed'; } + // SPEC-029 Am. 1 AC10: and at least one. Claim v2 requires an actions + // assertion. On c2pa-rs 0.90 a manifest without one signs and reads back + // Invalid; on 0.91 the engine's verify-after-sign refuses it and the request + // ends in a 500. Either way a caller's mistake, so it is refused here, before + // the policy check below, whose reason would otherwise hide this one. + if (actionsCount === 0) { + return 'an actions assertion (c2pa.actions.v2) is required'; + } + // SPEC-028 AC13: the c2pa.opened action belongs to the service. c2pa-rs // inserts it under the edit intent, carrying a hash over the ingredient // assertion it builds, and a caller cannot compute that hash. Supplying one diff --git a/specs/SPEC-011-assertion-limits.md b/specs/SPEC-011-assertion-limits.md index 632b3c9..518248b 100644 --- a/specs/SPEC-011-assertion-limits.md +++ b/specs/SPEC-011-assertion-limits.md @@ -219,6 +219,9 @@ non-2xx body through `SigningFailedException` with the service message contradictory actions assertions are a correctness defect rather than a policy choice. Deployments whose certificate exists solely to mark AI content can opt in via `REQUIRE_AI_MARKING` (AC8). Reasoning recorded in Problem. + *Since SPEC-029 Amendment 1 (2026-11-02), an actions assertion is required + to **exist**, because claim v2 requires one and c2pa-rs 0.91 refuses a + manifest without it. This resolution about its **contents** stands.* - **Should `REQUIRE_AI_MARKING` check every action or only the first?** Claim v2 requires the first action to be `c2pa.created`/`c2pa.opened`, and the Art.50 marking rides on that first action. Checking only the first is simpler and diff --git a/specs/SPEC-029-actions-structure-validation.md b/specs/SPEC-029-actions-structure-validation.md index 53f3885..97aa856 100644 --- a/specs/SPEC-029-actions-structure-validation.md +++ b/specs/SPEC-029-actions-structure-validation.md @@ -247,9 +247,9 @@ where a live service is required. - *(Regression guard: `markingSourceTypes()` is one of the four helpers being made total, and it is the one whose behaviour a policy decision hangs on.)* -## Amendment 1: a request without an actions assertion is refused (draft) +## Amendment 1: a request without an actions assertion is refused (approved 2026-11-02) -Proposed 2026-11-02. Sources: NOTES Step 74 (the measurement on c2pa-node +Proposed and approved by Maurice van Loon on 2026-11-02. Sources: NOTES Step 74 (the measurement on c2pa-node 0.9.10) and Step 76. **Why.** *Out of scope* above excludes "requiring an actions assertion at all", @@ -498,7 +498,10 @@ least one test; every source file maps back to this spec. | AC3 | `tests/Integration/ActionsStructureTest.php` :: "refuses a non-array actions value before the engine is reached" | `service/server.js` `rejectActionsShape()` array check | | AC4 | `tests/Integration/ActionsStructureTest.php` :: "refuses a malformed action entry" (4 datasets) | `service/server.js` `rejectActionsShape()` entry check | | AC5 | `tests/Integration/ActionsStructureTest.php` :: "refuses an actions assertion whose data is not an object" (3 datasets) | `service/server.js` `rejectActionsShape()` data check | -| AC6 | `tests/Integration/ActionsStructureTest.php` :: "refuses an actions assertion with no actions key"; "refuses an empty actions array without signing anything"; "still signs a request carrying no actions assertion at all" | `service/server.js` `rejectActionsShape()` non-empty check | +| AC6 | `tests/Integration/ActionsStructureTest.php` :: "refuses an actions assertion with no actions key"; "refuses an empty actions array without signing anything"; third case since Am. 1: see Am. 1 AC10 | `service/server.js` `rejectActionsShape()` non-empty check | | AC7 | `tests/Integration/ActionsStructureTest.php` :: "names the constraint without leaking internals or echoing the payload" | `service/server.js` `reject()` in `POST /v1/sign` | | AC8 | `tests/Integration/ActionsStructureTest.php` :: "exposes the actions helpers, and none of them throws on an accepted payload" | `service/server.js` `actionsOf()`, `module.exports`, `require.main` guard | -| AC9 | `tests/Integration/ActionsStructureTest.php` :: "still accepts a manipulated manifest when the AI-marking policy is on" | `service/server.js` `markingSourceTypes()` via `actionsOf()` | \ No newline at end of file +| AC9 | `tests/Integration/ActionsStructureTest.php` :: "still accepts a manipulated manifest when the AI-marking policy is on" | `service/server.js` `markingSourceTypes()` via `actionsOf()` | +| Am. 1 AC10 | `tests/Integration/ActionsStructureTest.php` :: "refuses a request without an actions assertion, and records it as rejected" (3 datasets: no key, `[]`, only a non-actions assertion); `UnauthenticatedBoundsTest.php` `spec030Post()` now sends an actions assertion | `service/server.js` `rejectAssertions()`: absent key treated as `[]`, `actionsCount === 0` check after the count | +| Am. 1 AC11 | The AC10 test asserts the error text, the audit outcome `rejected` and the audit reason. Seen red 2026-11-02 against the pre-amendment service (all three datasets: 200) | — | +| Am. 1 AC12 | `tests/Integration/ActionsStructureTest.php` :: "refuses both forms under the AI-marking policy with the structural reason" (2 datasets; runs only with `REQUIRE_AI_MARKING=true`, the `hardened` CI profile). Seen red 2026-11-02 before the fix: no key answered 200, `[]` answered the policy reason | `service/server.js` `rejectAssertions()`, check placed before the `REQUIRE_AI_MARKING` check | \ No newline at end of file diff --git a/tests/Integration/ActionsStructureTest.php b/tests/Integration/ActionsStructureTest.php index a186202..368e627 100644 --- a/tests/Integration/ActionsStructureTest.php +++ b/tests/Integration/ActionsStructureTest.php @@ -59,17 +59,27 @@ function spec029Container(): ?string * `$extraAssertions` is deliberately `mixed`: the shapes under test are exactly * the ones no typed signature would let through. * + * `$omitKey` sends no `extra_assertions` key at all, ignoring `$extraAssertions`. + * * @return array{status: int, error: string, cid: string} */ -function spec029Sign(mixed $extraAssertions): array +function spec029Sign(mixed $extraAssertions, bool $omitKey = false): array { + $body = [ + 'content' => base64_encode(ServiceHarness::fixtureBytes()), + 'mime_type' => 'image/png', + ]; + + // Am. 1 AC10/AC12: a body WITHOUT the key is a different request from one + // carrying `[]`, and before the amendment it took a different path through + // rejectAssertions() — an early return that skipped the AI-marking policy. + if (! $omitKey) { + $body['extra_assertions'] = $extraAssertions; + } + $response = (new Client(['http_errors' => false]))->post(ServiceHarness::baseUrl().'/v1/sign', [ 'headers' => ['Authorization' => 'Bearer '.ServiceHarness::apiKey()], - 'json' => [ - 'content' => base64_encode(ServiceHarness::fixtureBytes()), - 'mime_type' => 'image/png', - 'extra_assertions' => $extraAssertions, - ], + 'json' => $body, ]); $decoded = json_decode((string) $response->getBody(), true); @@ -244,9 +254,8 @@ function spec029Assertion(mixed $data): array ])->group('SPEC-029', 'integration')->skip($skipUnlessReachable); // --- AC6: an actions assertion must carry at least one action ---------------- -// Three outcomes, and the third is the one that keeps SPEC-011's settled -// permission intact. Testing only the two refusals would pass against an -// implementation that also refuses the absent case. +// Its third case (no actions assertion at all) used to sign. Amendment 1 +// replaced it with a refusal, tested under AC10 below. it('refuses an actions assertion with no actions key', function () { $result = spec029Sign(spec029Assertion([])); @@ -272,19 +281,55 @@ function spec029Assertion(mixed $data): array ); })->group('SPEC-029', 'integration')->skip($skipUnlessContainer); -it('still signs a request carrying no actions assertion at all', function () { - $result = spec029Sign([]); +// --- Am. 1 AC10/AC11: no actions assertion at all is refused ---------------- +// Until Amendment 1 this case signed, and read back Invalid. On c2pa-rs 0.91 the +// engine refuses it after signing and the service answers 500, so the service +// now refuses it first. AC11: assert the reason and the audit outcome, never the +// status alone. On 0.9.10 a status-only refusal test passed because the ENGINE +// refused (NOTES Step 74), and a 400 cannot tell the two apart. - // SPEC-011 settled "at most one, not required" and SPEC-029 does not reopen - // it. Absent is a worse claim — it reads back Invalid with - // assertion.action.malformed, a verifier telling the truth about a claim-v2 - // rule — where empty is a worse artefact. - expect($result['status'])->toBe(200, 'SPEC-011 permits a request with no actions assertion'); -})->group('SPEC-029', 'integration') +const SPEC029_ACTIONS_REQUIRED = 'an actions assertion (c2pa.actions.v2) is required'; + +it('refuses a request without an actions assertion, and records it as rejected', function (bool $omitKey, mixed $assertions) { + $result = spec029Sign($assertions, $omitKey); + + expect($result['status'])->toBe(400, 'a request without an actions assertion was not refused') + ->and($result['error'])->toBe(SPEC029_ACTIONS_REQUIRED); + + $record = spec029AuditRecordFor($result['cid']); + + expect($record)->not->toBeNull('no audit record was written for the refusal'); + expect($record['outcome'] ?? null)->toBe( + 'rejected', + 'outcome "failed" means the request reached the engine and spent a signing attempt', + ); + expect($record['reason'] ?? null)->toBe(SPEC029_ACTIONS_REQUIRED); +})->with([ + 'no extra_assertions key' => [true, null], + 'an empty extra_assertions array' => [false, []], + 'only a non-actions assertion' => [false, [['label' => 'org.example.note', 'data' => ['note' => 'SPEC-029']]]], +])->group('SPEC-029', 'integration')->skip($skipUnlessContainer); + +// --- Am. 1 AC12: under the AI-marking policy, both forms, the same reason ---- +// Measured 2026-11-02 before the fix: with REQUIRE_AI_MARKING=true, a body +// without the key answered 200 and was SIGNED — no AI marking, under the policy +// that exists to refuse exactly that — because rejectAssertions() returned +// before the policy check whenever the key was absent. `[]` answered 400 with +// the policy reason. Only meaningful on a hardened service. + +it('refuses both forms under the AI-marking policy with the structural reason', function (bool $omitKey, mixed $assertions) { + $result = spec029Sign($assertions, $omitKey); + + expect($result['status'])->toBe(400, 'signed without any AI marking under REQUIRE_AI_MARKING') + ->and($result['error'])->toBe(SPEC029_ACTIONS_REQUIRED); +})->with([ + 'no extra_assertions key' => [true, null], + 'an empty extra_assertions array' => [false, []], +])->group('SPEC-029', 'integration') ->skip($skipUnlessReachable) ->skip( - fn () => (ServiceHarness::health()['require_ai_marking'] ?? false) === true - ? 'service requires an AI marking, which a request with no actions assertion cannot carry' + fn () => (ServiceHarness::health()['require_ai_marking'] ?? false) !== true + ? 'service does not require an AI marking; covered by the hardened CI profile' : false, ); diff --git a/tests/Integration/UnauthenticatedBoundsTest.php b/tests/Integration/UnauthenticatedBoundsTest.php index f564ee5..fd5a752 100644 --- a/tests/Integration/UnauthenticatedBoundsTest.php +++ b/tests/Integration/UnauthenticatedBoundsTest.php @@ -87,6 +87,16 @@ function spec030Post(string $token, int $paddingBytes = 0): array $body = [ 'content' => base64_encode(ServiceHarness::fixtureBytes()), 'mime_type' => 'image/png', + // SPEC-029 Am. 1: a legitimate signature carries an actions assertion; + // without one the service refuses with 400 before signing. + 'extra_assertions' => [[ + 'label' => 'c2pa.actions.v2', + 'data' => ['actions' => [[ + 'action' => 'c2pa.created', + 'digitalSourceType' => 'http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia', + 'softwareAgent' => ['name' => 'SPEC-030 bounds'], + ]]], + ]], ]; if ($paddingBytes > 0) {