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
3 changes: 2 additions & 1 deletion NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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) |
99 changes: 99 additions & 0 deletions notes/step-77-spec-029-am1-and-a-policy-gap.md
Original file line number Diff line number Diff line change
@@ -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.
Loading