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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/c2pa-primer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 10 additions & 4 deletions docs/service.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 14 additions & 1 deletion service/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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})`;
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions specs/SPEC-011-assertion-limits.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
153 changes: 151 additions & 2 deletions specs/SPEC-029-actions-structure-validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (approved 2026-11-02)

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",
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
Expand Down Expand Up @@ -352,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()` |
| 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 |
Loading
Loading