From f20477227160e14097568ab7a3f8788c12f4bac8 Mon Sep 17 00:00:00 2001 From: maurice Date: Thu, 29 Oct 2026 16:57:58 +0100 Subject: [PATCH] SPEC-046: readVerified(), the verifier's report of the same read C2paVerifierReader::readVerified() returns a VerifiedRead: the ManifestReport and the verifier's VerificationReport of one verify() call, so the evidence a caller keeps is that of the verdict it shows. The reader takes an optional, already configured Verifier, and the trust settings also as the verifier's TrustSettings object, so fromTrustLists() names the lists in the evidence. read() is unchanged. The verifier range moves to ^0.10 (SPEC-042 Amendment 11, ADR-0007 addendum): the evidence document exists from verifier 0.10 on. Fixtures copied from the verifier's revocation probes, with a README naming their source and hashes. --- CHANGELOG.md | 28 ++- composer.json | 4 +- ...0007-the-verifier-as-an-optional-reader.md | 4 +- docs/readers.md | 37 +++- ...C-042-pure-php-reader-via-c2pa-verifier.md | 24 ++- ...46-the-verifier-report-of-the-same-read.md | 203 ++++++++++++++++++ src/Core/Reading/C2paVerifierReader.php | 35 ++- src/Core/Reading/VerifiedRead.php | 27 +++ tests/Fixtures/spec046-README.md | 22 ++ tests/Fixtures/spec046-cs-parent.png | Bin 0 -> 37679 bytes tests/Fixtures/spec046-p-good.der | Bin 0 -> 366 bytes tests/Fixtures/spec046-throw-away-root.pem | 14 ++ .../spec046-throw-away-root.settings.json | 9 + tests/Unit/Reading/VerifiedReadTest.php | 192 +++++++++++++++++ tests/Unit/VerifierVersionRangeTest.php | 16 +- 15 files changed, 584 insertions(+), 31 deletions(-) create mode 100644 specs/SPEC-046-the-verifier-report-of-the-same-read.md create mode 100644 src/Core/Reading/VerifiedRead.php create mode 100644 tests/Fixtures/spec046-README.md create mode 100644 tests/Fixtures/spec046-cs-parent.png create mode 100644 tests/Fixtures/spec046-p-good.der create mode 100644 tests/Fixtures/spec046-throw-away-root.pem create mode 100644 tests/Fixtures/spec046-throw-away-root.settings.json create mode 100644 tests/Unit/Reading/VerifiedReadTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b89d5a..82b0bb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,12 +19,32 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- **`C2paVerifierReader::readVerified()`** (SPEC-046) returns a `VerifiedRead`: + the `ManifestReport` and the verifier's own `VerificationReport` of one and + the same read, so the evidence a caller keeps + (`$read->verification->evidenceToJson()`) is the evidence of the verdict it + shows. `read()` is unchanged. +- **The reader takes an optional `Verifier`**, configured as the caller + chooses, such as one with a revocation source; without one it builds + `new Verifier` as before. +- **Trust settings may also be the verifier's `TrustSettings` object**, such as + `TrustSettings::fromTrustLists()`, so the evidence names the lists a verdict + was judged against. A settings document as a string still works. + +### Changed + +- **The verifier range moves to 0.10** (`^0.10`, `conflict` `<0.10 || >=0.11`, + SPEC-042 Amendment 11): the evidence document exists from verifier 0.10 on. + 0.10.0 also carries a security review's fixes, six of them for files it + wrongly called `Trusted`. + ### Documentation -- **What the verifier route does not pass on** (`docs/readers.md`). Verifier - 0.9's evidence behind a verdict and its revocation source are reachable - only by calling the verifier directly; this package's reader passes no - source and reads no evidence. +- **The verifier route and its evidence** (`docs/readers.md`): what + `readVerified()` gives, and why calling the verifier a second time for the + evidence is not the same read. - **0.19.6 said no verdict of this package's reader changes with verifier 0.9. One can:** a signer issued directly by a trust anchor, whose file carries only its own certificate and a stapled `revoked`, is now `Invalid` diff --git a/composer.json b/composer.json index 1ce2ddc..9897f6b 100644 --- a/composer.json +++ b/composer.json @@ -46,11 +46,11 @@ "pestphp/pest": "^4.0", "php-http/mock-client": "^1.6", "phpstan/phpstan": "^2.0", - "provemark/c2pa-verifier": "^0.9", + "provemark/c2pa-verifier": "^0.10", "vlucas/phpdotenv": "^5.6" }, "conflict": { - "provemark/c2pa-verifier": "<0.9 || >=0.10" + "provemark/c2pa-verifier": "<0.10 || >=0.11" }, "autoload": { "psr-4": { diff --git a/docs/adr/ADR-0007-the-verifier-as-an-optional-reader.md b/docs/adr/ADR-0007-the-verifier-as-an-optional-reader.md index 167a554..dffe6dd 100644 --- a/docs/adr/ADR-0007-the-verifier-as-an-optional-reader.md +++ b/docs/adr/ADR-0007-the-verifier-as-an-optional-reader.md @@ -94,4 +94,6 @@ on purpose. On 2026-10-25 the floor moved to 0.8.2 (`<0.8.2 || >=0.9`, SPEC-042 Amendment 9): 0.8.2 refuses two signatures that `c2patool` refuses and 0.8.1 called `Valid` or `Trusted`, so 0.8.1 is left out on purpose. On 2026-10-28 the range moved to `^0.9` (`<0.9 || >=0.10`, SPEC-042 -Amendment 10): one minor, as measured. +Amendment 10): one minor, as measured. On 2026-10-29 it moved to `^0.10` +(`<0.10 || >=0.11`, SPEC-042 Amendment 11): `readVerified()` (SPEC-046) +hands on the evidence document verifier 0.10 introduced. diff --git a/docs/readers.md b/docs/readers.md index 0a30c32..b0e575e 100644 --- a/docs/readers.md +++ b/docs/readers.md @@ -289,15 +289,34 @@ else differs: A temp directory that cannot be written is a `ReadFailedException`. What the verifier itself allocates while parsing is not bounded here, and the service path's 20 MB body cap does not apply, so bound uploads before they reach it. -- **What the verifier offers beyond its JSON report is not passed on.** This - reader takes the verifier's `toJson()` and builds the same `ManifestReport` - the other two readers give. Since verifier 0.9 its own report can also show - the evidence behind a verdict (the anchor and time each manifest was judged - at, the certificates, the time-stamps and OCSP responses found, which trust - lists were used), and a caller may hand it a revocation source so that a - signer is checked online. None of that reaches you through this package: it - passes no revocation source and reads no evidence. To use them, call the - verifier directly; its README lists both in its public contract. +- **The verifier's own report of the same read: `readVerified()`.** `read()` + gives the same `ManifestReport` the other two readers give. The verifier's + own report also holds the evidence behind a verdict (the anchor and time + each manifest was judged at, the certificates, the time-stamps and OCSP + responses found, which trust lists were used), and writes it down as a + document to keep. `readVerified($asset)` returns both, from one and the + same read (SPEC-046): + + ```php + use Provemark\C2paVerifier\Trust\TrustSettings; + use Provemark\C2paVerifier\Verifier\Verifier; + + $reader = new C2paVerifierReader( + TrustSettings::fromTrustLists($signerPem, $tsaPem), // or a settings document as a string + new Verifier(revocationSource: $source), // optional: a verifier configured your way + ); + $read = $reader->readVerified($asset); + $read->report; // the ManifestReport, as read() gives it + $read->verification->evidenceToJson(); // what that verdict rested on, to keep + ``` + + Calling the verifier a second time for the evidence would be a second read, + at another moment: a revocation answer or the clock may differ, and the + evidence would not be that of the verdict shown. The evidence names the + trust lists only when they come as `TrustSettings::fromTrustLists()`; a + settings document gives none. Keeping the document, and recording which + file it belongs to, is yours to do. A refused or unreadable asset throws the + same `ReadFailedException` as `read()`. - **A stapled `revoked` is believed in one case the service does not.** A signer issued directly by a trust anchor, whose file carries only its own certificate and a stapled OCSP response saying `revoked`, is `Invalid` here diff --git a/specs/SPEC-042-pure-php-reader-via-c2pa-verifier.md b/specs/SPEC-042-pure-php-reader-via-c2pa-verifier.md index 9172a73..d7a11bf 100644 --- a/specs/SPEC-042-pure-php-reader-via-c2pa-verifier.md +++ b/specs/SPEC-042-pure-php-reader-via-c2pa-verifier.md @@ -499,6 +499,28 @@ with 0.8.2. The new pin was seen red on the old range first. The release that carries this is a patch. No criterion changes. AC9's test takes the new pin. +## Amendment 11 (2026-10-29, approved by Maurice van Loon the same day) + +The verifier released 0.10.0 on 2026-10-29, a minor: it adds +`VerificationReport::evidenceToArray()` and `evidenceToJson()` (its +SPEC-089) and carries the fixes of a security review of its whole code, six +of them for files it wrongly called `Trusted`. SPEC-046's `readVerified()` +hands that report on, so it needs 0.10. + +**The range moves to 0.10**: `require-dev` is `"provemark/c2pa-verifier": +"^0.10"` and `conflict` is `"<0.10 || >=0.11"`, one minor as before. +`VerifierVersionRangeTest` pins it ("tests against the verifier that writes +the evidence down as a document"), in place of Amendment 10's pin. + +Measured on 2026-10-29 with 0.10.0 installed, before any other change: +`composer check` (Pint, PHPStan, Deptrac, and 518 tests passed, 7 skipped) +with one failure, Amendment 10's pin, as it should; and the integration +tests that use the verifier (`VerifierReaderEquivalenceTest`, +`IngredientEquivalenceTest`, `RemoteManifestFetchTest` against the running +service: 52 passed, 4 skipped, as with 0.9.0). The new pin was seen red +on the old `composer.json` first. No criterion changes. AC9's test takes the +new pin. + ## API sketch ```php @@ -561,5 +583,5 @@ required); 11 verifier comparisons ran in each. | AC6 | `tests/Unit/Reading/C2paVerifierReaderTest.php` :: "refuses trust settings the verifier refuses, at construction", "verifies without trust when no settings are given", "treats empty settings as no settings" | `src/Core/Reading/C2paVerifierReader.php` `buildSettings()`; `src/Core/Reading/Exception/TrustSettingsRejectedException.php` | | AC7 | `tests/Unit/Reading/C2paVerifierReaderTest.php` :: "reports itself available when the verifier is installed", "names the package to install when the verifier is absent" | `src/Core/Reading/C2paVerifierReader.php` `isAvailable()`, `__construct()`; `src/Core/Reading/Exception/VerifierMissingException.php` | | AC8 | `tests/Unit/Laravel/ReaderSelectionTest.php` :: "binds the pure-PHP reader when the mode is verifier", "reports verifier as the mode, configured and resolved", "passes the configured settings file to the pure-PHP reader", "treats an empty settings value as no trust, not as an error", "refuses a settings path that does not exist, when the reader is resolved", "refuses a settings file the verifier refuses, when the reader is resolved", "names verifier among the accepted modes when refusing", "does not resolve auto to the verifier, though it is installed" | `src/Laravel/ReaderFactory.php` `MODES`, `make()`, `verifierSettings()`; `config/content-credentials.php` `verifier_settings` | -| AC9 | `tests/Unit/VerifierVersionRangeTest.php` :: "refuses every verifier version outside the tested range", "tests against the verifier that reads revocation against the issuer its walk verified" (Amendment 10; Amendments 7 to 9's pins before it), "keeps the verifier out of require"; `tests/Integration/VerifierReaderEquivalenceTest.php` :: "agrees with the service reader through the verifier", "agrees with the in-process reader through the verifier" | `.github/workflows/ci.yml` steps "Assert the verifier comparison ran (SPEC-042)" and "Assert the verifier-extension comparison ran (SPEC-042)" (counted 0 on a skipped run, 11 on a real one); `composer.json` `require-dev`, `conflict` (ADR-0007 addendum); `docs/adr/ADR-0007-the-verifier-as-an-optional-reader.md` | +| AC9 | `tests/Unit/VerifierVersionRangeTest.php` :: "refuses every verifier version outside the tested range", "tests against the verifier that writes the evidence down as a document" (Amendment 11; Amendments 7 to 10's pins before it), "keeps the verifier out of require"; `tests/Integration/VerifierReaderEquivalenceTest.php` :: "agrees with the service reader through the verifier", "agrees with the in-process reader through the verifier" | `.github/workflows/ci.yml` steps "Assert the verifier comparison ran (SPEC-042)" and "Assert the verifier-extension comparison ran (SPEC-042)" (counted 0 on a skipped run, 11 on a real one); `composer.json` `require-dev`, `conflict` (ADR-0007 addendum); `docs/adr/ADR-0007-the-verifier-as-an-optional-reader.md` | | AC10 | `tests/Unit/Reading/C2paVerifierReaderTest.php` :: "adds little to peak memory when reading a large asset", "fails with ReadFailedException when the copy cannot spill to disk" | `src/Core/Reading/C2paVerifierReader.php` `IN_MEMORY_BYTES`, `read()` (Amendment 3) | diff --git a/specs/SPEC-046-the-verifier-report-of-the-same-read.md b/specs/SPEC-046-the-verifier-report-of-the-same-read.md new file mode 100644 index 0000000..3698bfb --- /dev/null +++ b/specs/SPEC-046-the-verifier-report-of-the-same-read.md @@ -0,0 +1,203 @@ +# SPEC-046: The verifier's report of the same read + +| Field | Value | +|------------|---------------------------------------------------| +| Status | implemented | +| Author | Maurice van Loon (maintainer) | +| Approved | Maurice van Loon, 2026-10-28 | +| Supersedes | — | + +> Lifecycle: `draft` → maintainer approves → `approved` → tests-first → +> `implemented` (Traceability filled). No implementation code while `draft`. +> Only the Traceability section of an `approved` spec may change without a new +> approval; everything else needs a proposed amendment. + +## Problem + +`C2paVerifierReader` (SPEC-042) calls `provemark/c2pa-verifier`, turns its +report into JSON, and gives the caller a `ManifestReport`. Everything the +verifier recorded beyond that shape is dropped at that point: the time the +signer was judged at, the anchor and the trust list that made it trusted, +the certificates, time-stamp tokens and OCSP responses as the file carried +them (verifier SPEC-081 to SPEC-085), and since verifier 0.10 the same as a +document to keep (`VerificationReport::evidenceToJson()`, verifier +SPEC-089). `docs/readers.md` says so since 0.19.6. + +A caller that wants to keep that evidence next to the verdict it shows — an +archive of what a published image was judged on — has two ways today, and +both are wrong: + +- call the verifier itself a second time. That is a second read, at a + second moment, possibly with other settings: the evidence it keeps is not + the evidence of the verdict it shows. A revocation answer or the clock can + differ between the two; +- skip this reader and use the verifier directly, losing the + `ManifestReport` every other part of the integration is written against. + +The reader also always builds its own `new Verifier`. A caller cannot give +it a verifier configured differently, such as one with a revocation source +(verifier SPEC-086), so the reader route never asks one (`docs/readers.md`). + +## Scope + +**In scope** + +- `C2paVerifierReader` accepts an optional, already configured `Verifier`; + without one it builds `new Verifier` as now. +- A second read method, `readVerified()`, that returns the `ManifestReport` + and the verifier's `VerificationReport` of one and the same `verify()` + call. `read()` keeps its signature and its result and is that method's + `ManifestReport`. +- The trust settings also as the verifier's `TrustSettings` object, so that + a caller can pass `TrustSettings::fromTrustLists()` and the evidence names + the lists it was judged against (open question 1). +- The verifier range moves to `^0.10` (`require-dev`, `conflict`), because + `evidenceToArray()` and `evidenceToJson()` exist from verifier 0.10 on: an + amendment to SPEC-042 and the ADR-0007 range addendum, in the same step. + +**Out of scope** (each needs its own spec before it may be built) + +- Storing, signing or sending the evidence. Keeping it, and recording which + file it belongs to, is the caller's task (the Drupal module's archive). +- Anything for `SigningServiceReader` and `ExtC2paReader`: neither has + such a report to give. `ReaderInterface` does not change. +- A validation time passed to the reader (verifier SPEC-073). The reader + judges at the verifier's clock, as now. +- Anything in `ManifestReport`: its accessors and its shape stay as they are. + +## Behavior + +- **AC1 — one read, two reports** + - Given a signed fixture and the reader with trust settings + - When `readVerified($asset)` is called + - Then it returns a `VerifiedRead` whose `report` equals, accessor by + accessor, what `read($asset)` returns for the same asset, and whose + `verification` has the same `validation_state` as `report`; the + verifier's `verify()` ran once for it. + +- **AC2 — the evidence of that read** + - Given the `VerifiedRead` of AC1 + - When `verification->evidenceToJson()` is called + - Then it is format `c2pa-verifier/evidence`, version 1, with the same + `validation_state` and `active_manifest` as the `ManifestReport`. + +- **AC3 — the verifier given is the one that reads** + - Given a `Verifier` built with a revocation source that records each + request, and an asset with a trusted signer whose status the verifier + asks that source for + - When `readVerified($asset)` is called on a reader built with it + - Then the source was asked, and the evidence lists the response under + `source: "fetched"`. + +- **AC4 — read() is unchanged** + - Given the existing SPEC-042 tests and the reader built without a + `Verifier` + - When they run + - Then they pass unchanged, and `read()` returns what it returned before. + +- **AC5 — a refusal is a refusal, never half a result** *(error path)* + - Given an asset of a type the reader refuses (TIFF, SVG), and an asset + the verifier cannot read (truncated) + - When `readVerified($asset)` is called + - Then it throws the same `ReadFailedException` as `read()` does, with + the same message, and returns no `VerifiedRead`. + +- **AC6 — the range is the verifier that has the evidence document** + *(error path)* + - Given `composer.json` + - When `VerifierVersionRangeTest` runs + - Then every verifier version below 0.10 or from 0.11 on is refused, and + the tested pin is a 0.10 release. + +- **AC7 — the lists judged against are named** + - Given a signer list and a TSA list as PEM, passed as + `TrustSettings::fromTrustLists($signers, $tsas)` + - When `readVerified($asset)` is called + - Then the evidence's `trust_lists` holds the two lists, `manifest` first, + each with the `sha256` of the PEM as given; and `report` equals what the + same lists passed as a settings document give. + +- **AC8 — an object the reader cannot use is refused** *(error path)* + - Given `$trustSettingsJson` that is neither null, a string nor a + `TrustSettings` + - When the reader is built + - Then PHP's own `TypeError` is thrown; a blank string and null still + mean "without trust". + +## API sketch + +```php +namespace Provemark\ContentCredentials\Core\Reading; + +final class C2paVerifierReader implements ReaderInterface +{ + public function __construct( + string|TrustSettings|null $trustSettingsJson = null, // the object is new + ?Verifier $verifier = null, // new, last: existing calls keep working + ) {} + + public function read(Asset $asset): ManifestReport; // = readVerified($asset)->report + + /** @throws ReadFailedException as read() does */ + public function readVerified(Asset $asset): VerifiedRead; // new +} + +final readonly class VerifiedRead +{ + public function __construct( + public ManifestReport $report, + public \Provemark\C2paVerifier\Verifier\VerificationReport $verification, + ) {} +} +``` + +`VerifiedRead` names a verifier class in its signature. PHP loads it only +when the class is used, so a host without the verifier is unaffected +(ADR-0007): it cannot build this reader in the first place. + +## Open questions + +1. **Trust settings as an object too (non-blocker).** The reader takes + settings only as JSON (`TrustSettings::fromJson()`). The verifier names + the trust lists in its evidence only when they come through + `TrustSettings::fromTrustLists()` (verifier SPEC-081); a settings + document gives `trust_lists: []`. The Drupal module builds a settings + document from two PEM lists, so its archive would not name the lists it + judged against. Proposal: `$trustSettingsJson` also accepts a + `TrustSettings`, and the module passes `fromTrustLists()`; one more AC. + *Status 2026-10-28: decided by Maurice van Loon, as proposed (AC7, AC8).* +2. **The name `readVerified()` (non-blocker).** Alternatives: + `readWithReport()`, `inspect()`. Proposal: `readVerified()`. + *Status 2026-10-28: decided by Maurice van Loon, as proposed.* +3. **A fixture for AC3 (blocker before tests).** It needs a signer that is + trusted and carries an OCSP url the verifier asks the source for. To be + measured before the tests are written; the verifier's SPEC-086 + fixtures are the candidates. + *Status 2026-10-28: measured against verifier `main` (`7a2098e`). The + verifier's `revocation/cs-parent.png`, judged against + `revocation/throw-away-root.pem` (as its settings document and as + `fromTrustLists()`), is `Trusted`, and its signer P is asked of the + source once per read. Answered with P's good response (the one in + `revocation/cs-good.png` whose SHA-256 starts `427b01dc`), the evidence + lists it as `fetched`, `chosen`, `good`; its `nextUpdate` is + 2036-10-06, so the test does not age before then. The library copies + `cs-parent.png`, the root and that response (as DER), with a README + naming their source. The same fixture serves AC7.* +4. **Release (non-blocker).** Library 0.20.0, a minor: the verifier range + narrows to `^0.10` and a method and a class are added. It can only be + tagged after verifier 0.10 is on Packagist. + +## Traceability + +Filled 2026-10-29. AC8 was green before the implementation: PHP refused a non-string already. It guards against a later widening; AC7 holds what is new. + +| Acceptance criterion | Test (file :: name / group) | Source (file/symbol) | +|----------------------|-----------------------------|----------------------| +| AC1 | `tests/Unit/Reading/VerifiedReadTest.php` :: "AC1: one read gives the ManifestReport and the verifier report of the same verify() call" | `src/Core/Reading/C2paVerifierReader.php` `readVerified()`, `read()`; `src/Core/Reading/VerifiedRead.php` | +| AC2 | `tests/Unit/Reading/VerifiedReadTest.php` :: "AC2: the verifier report holds the evidence of that read" | `src/Core/Reading/VerifiedRead.php` `$verification` | +| AC3 | `tests/Unit/Reading/VerifiedReadTest.php` :: "AC3: the verifier given is the one that reads, and its source answers" | `src/Core/Reading/C2paVerifierReader.php` `__construct()` `$verifier`; `tests/Fixtures/spec046-README.md` | +| AC4 | `tests/Unit/Reading/VerifiedReadTest.php` :: "AC4: read() without a Verifier gives what readVerified() gives as its report"; `tests/Unit/Reading/C2paVerifierReaderTest.php` (unchanged) | `src/Core/Reading/C2paVerifierReader.php` `read()` | +| AC5 | `tests/Unit/Reading/VerifiedReadTest.php` :: "AC5: a refusal is the same refusal, never half a result" | `src/Core/Reading/C2paVerifierReader.php` `readVerified()` | +| AC6 | `tests/Unit/VerifierVersionRangeTest.php` :: "tests against the verifier that writes the evidence down as a document" | `composer.json` `require-dev`, `conflict`; SPEC-042 Amendment 11; `docs/adr/ADR-0007-the-verifier-as-an-optional-reader.md` | +| AC7 | `tests/Unit/Reading/VerifiedReadTest.php` :: "AC7: lists given as TrustSettings::fromTrustLists() are named in the evidence" | `src/Core/Reading/C2paVerifierReader.php` `__construct()` `string|TrustSettings|null` | +| AC8 | `tests/Unit/Reading/VerifiedReadTest.php` :: "AC8: anything but null, a string or a TrustSettings is a TypeError from PHP itself; blank and null mean without trust" | `src/Core/Reading/C2paVerifierReader.php` `__construct()`, `buildSettings()` | diff --git a/src/Core/Reading/C2paVerifierReader.php b/src/Core/Reading/C2paVerifierReader.php index a0fe6b9..c5be270 100644 --- a/src/Core/Reading/C2paVerifierReader.php +++ b/src/Core/Reading/C2paVerifierReader.php @@ -25,6 +25,10 @@ * Measured against verifier v0.5.0 on 2026-10-07: eleven of the thirteen media * types agree with both other readers on every accessor, trust and timestamp * included. + * + * readVerified() gives the verifier's own report of the same read next to the + * ManifestReport (SPEC-046), and a caller may hand in a Verifier configured + * its own way, such as one with a revocation source. */ final class C2paVerifierReader implements ReaderInterface { @@ -40,14 +44,19 @@ final class C2paVerifierReader implements ReaderInterface private readonly ?TrustSettings $settings; + private readonly Verifier $verifier; + /** - * @param string|null $trustSettingsJson the contents of a settings file in c2patool's - * JSON shape; null or blank verifies without trust + * @param string|TrustSettings|null $trustSettingsJson the contents of a settings file in + * c2patool's JSON shape, or the verifier's TrustSettings, such as + * TrustSettings::fromTrustLists() (SPEC-046 AC7); null or blank verifies without trust + * @param Verifier|null $verifier the verifier that reads, configured as the caller chose + * (SPEC-046 AC3); null builds `new Verifier` * * @throws VerifierMissingException when provemark/c2pa-verifier is not installed * @throws TrustSettingsRejectedException when the verifier refuses the settings */ - public function __construct(?string $trustSettingsJson = null) + public function __construct(string|TrustSettings|null $trustSettingsJson = null, ?Verifier $verifier = null) { if (! self::isAvailable()) { throw new VerifierMissingException( @@ -57,7 +66,8 @@ public function __construct(?string $trustSettingsJson = null) ); } - $this->settings = self::buildSettings($trustSettingsJson); + $this->settings = $trustSettingsJson instanceof TrustSettings ? $trustSettingsJson : self::buildSettings($trustSettingsJson); + $this->verifier = $verifier ?? new Verifier; } public static function isAvailable(): bool @@ -78,6 +88,18 @@ public static function supports(MediaType $type): bool } public function read(Asset $asset): ManifestReport + { + return $this->readVerified($asset)->report; + } + + /** + * read()'s ManifestReport together with the verifier's VerificationReport of the same + * verify() call (SPEC-046): one read, so the evidence it carries is the evidence of the + * verdict the ManifestReport shows. + * + * @throws ReadFailedException as read() does, with the same message; never half a result + */ + public function readVerified(Asset $asset): VerifiedRead { if (! self::supports($asset->mediaType)) { throw new ReadFailedException(sprintf( @@ -112,7 +134,8 @@ public function read(Asset $asset): ManifestReport // Through JSON rather than toArray(), so the parser sees exactly // what was measured: the report as the verifier serialises it. - $json = (new Verifier)->verify($stream, $this->settings)->toJson(); + $verification = $this->verifier->verify($stream, $this->settings); + $json = $verification->toJson(); $report = json_decode($json, true, 512, JSON_THROW_ON_ERROR); } catch (ReadFailedException $e) { throw $e; @@ -129,7 +152,7 @@ public function read(Asset $asset): ManifestReport throw new ReadFailedException('Could not read the asset: the verifier report was not an object.'); } - return VerifierOutcome::toManifestReport($report); + return new VerifiedRead(VerifierOutcome::toManifestReport($report), $verification); } private static function buildSettings(?string $json): ?TrustSettings diff --git a/src/Core/Reading/VerifiedRead.php b/src/Core/Reading/VerifiedRead.php new file mode 100644 index 0000000..d404caa --- /dev/null +++ b/src/Core/Reading/VerifiedRead.php @@ -0,0 +1,27 @@ +}=g=UjfFL1Kf`oK~bc3`s(jXuq-QDmX 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 literal 0 HcmV?d00001 diff --git a/tests/Fixtures/spec046-p-good.der b/tests/Fixtures/spec046-p-good.der new file mode 100644 index 0000000000000000000000000000000000000000..db203eaf0e5ea14cfc2cac09c34ac9bedb7eec4d GIT binary patch literal 366 zcmXqLV$9-VWLVI|m~7C*7|+J3&Bn;e%5K2O$kN0Z02J~uX#BLW(xBW>+dz|zIh2Kq zN6FbJz)>MAwJ0+!Gqp&eB%>(5TsN^iu~H!e$WJUv&Zsn$G!O?V=H?M_^AA!8aSaaf z^mAA6_w$J~)HTosNip-NgdlW!=9Q!t<))@&CYGcsXcVQESsZ?3rs(V}0bA)%tG>{lgtYX0nKM zF5xU{xVU`&$IG5?`KL)=Eu7B6#l)!9z<^=o0zr_FF~}A(m@Qmv99nH2=WJP+nV3Bp z4BVI$8Kz(4va@7;WWMd1S69iS5Jkb7^7I2c-}_&xem6nuOuaf#$>Cqq($k7m%bqN# bn{fK`zFe-Nv+pi1e^c~>)yp8 */ + public array $subjects = []; +} + +/** Answers P's good response about P, nothing about anything else, and records every request. */ +function spec046Source(Spec046Requests $requests): RevocationSource +{ + return new class($requests) implements RevocationSource + { + public function __construct(private Spec046Requests $requests) {} + + public function ocspResponse(string $subject, string $issuer, array $urls): ?string + { + $parsed = openssl_x509_parse("-----BEGIN CERTIFICATE-----\n".chunk_split(base64_encode($subject), 64, "\n")."-----END CERTIFICATE-----\n"); + $cn = is_array($parsed) && is_array($parsed['subject'] ?? null) && is_string($parsed['subject']['CN'] ?? null) ? $parsed['subject']['CN'] : '?'; + $this->requests->subjects[] = $cn; + + return $cn === 'Signer P (revocation)' ? spec046Fixture('p-good.der') : null; + } + }; +} + +/** @return array every accessor of the report, by name */ +function spec046Accessors(ManifestReport $report): array +{ + return [ + 'hasManifest' => $report->hasManifest(), + 'activeManifestLabel' => $report->activeManifestLabel(), + 'declaredSpecVersion' => $report->declaredSpecVersion(), + 'signer' => $report->signer(), + 'assertions' => $report->assertions(), + 'validationStatusCodes' => $report->validationStatusCodes(), + 'isTrusted' => $report->isTrusted(), + 'validationState' => $report->validationState(), + 'isSignatureValid' => $report->isSignatureValid(), + 'hasTimestamp' => $report->hasTimestamp(), + 'isAiGenerated' => $report->isAiGenerated(), + 'involvesGenerativeAi' => $report->involvesGenerativeAi(), + 'isVerifiedAiGenerated' => $report->isVerifiedAiGenerated(), + 'softwareAgents' => $report->softwareAgents(), + 'digitalSourceTypes' => $report->digitalSourceTypes(), + 'ingredients' => $report->ingredients(), + ]; +} + +/** @return array */ +function spec046Evidence(VerifiedRead $read): array +{ + $evidence = json_decode($read->verification->evidenceToJson(), true, 512, JSON_THROW_ON_ERROR); + assert(is_array($evidence)); + + /** @var array $evidence */ + return $evidence; +} + +it('AC1: one read gives the ManifestReport and the verifier report of the same verify() call', function () { + $requests = new Spec046Requests; + $reader = new C2paVerifierReader(spec046Fixture('throw-away-root.settings.json'), new Verifier(revocationSource: spec046Source($requests))); + + $read = $reader->readVerified(spec046Parent()); + // one verify() asks the source once about the intermediate and once about P + expect($requests->subjects)->toBe(['Throw-away Intermediate (revocation)', 'Signer P (revocation)']); + + expect($read)->toBeInstanceOf(VerifiedRead::class) + ->and(spec046Accessors($read->report))->toEqual(spec046Accessors($reader->read(spec046Parent()))) + ->and($read->report->validationState()?->value)->toBe('Trusted') + ->and($read->verification->result->toArray()['validation_state'] ?? null)->toBe('Trusted'); +})->group('SPEC-046'); + +it('AC2: the verifier report holds the evidence of that read', function () { + $read = (new C2paVerifierReader(spec046Fixture('throw-away-root.settings.json')))->readVerified(spec046Parent()); + $evidence = spec046Evidence($read); + + expect($evidence['format'] ?? null)->toBe('c2pa-verifier/evidence') + ->and($evidence['version'] ?? null)->toBe(1) + ->and($evidence['validation_state'] ?? null)->toBe($read->report->validationState()?->value) + ->and($evidence['active_manifest'] ?? null)->toBe($read->report->activeManifestLabel()); +})->group('SPEC-046'); + +it('AC3: the verifier given is the one that reads, and its source answers', function () { + $requests = new Spec046Requests; + $reader = new C2paVerifierReader(spec046Fixture('throw-away-root.settings.json'), new Verifier(revocationSource: spec046Source($requests))); + + $evidence = spec046Evidence($reader->readVerified(spec046Parent())); + $responses = []; + foreach ((array) ($evidence['manifests'] ?? []) as $manifest) { + foreach ((array) (is_array($manifest) ? ($manifest['ocsp'] ?? []) : []) as $entry) { + if (is_array($entry)) { + $response = is_string($entry['response'] ?? null) ? base64_decode($entry['response'], true) : false; + $responses[] = [$entry['source'] ?? null, $entry['outcome'] ?? null, $entry['cert_status'] ?? null, $response === false ? null : hash('sha256', $response)]; + } + } + } + + expect($requests->subjects)->toContain('Signer P (revocation)') + ->and($responses)->toBe([['fetched', 'chosen', 'good', hash('sha256', spec046Fixture('p-good.der'))]]); +})->group('SPEC-046'); + +it('AC4: read() without a Verifier gives what readVerified() gives as its report', function () { + $reader = new C2paVerifierReader(spec046Fixture('throw-away-root.settings.json')); + + expect(spec046Accessors($reader->read(spec046Parent())))->toEqual(spec046Accessors($reader->readVerified(spec046Parent())->report)) + ->and($reader->read(spec046Parent())->validationState()?->value)->toBe('Trusted'); +})->group('SPEC-046'); + +it('AC5: a refusal is the same refusal, never half a result', function (Asset $asset) { + $reader = new C2paVerifierReader; + $message = static function (callable $read): string { + try { + $read(); + } catch (ReadFailedException $e) { + return $e->getMessage(); + } + + return ''; + }; + + $fromRead = $message(fn () => $reader->read($asset)); + $fromReadVerified = $message(fn () => $reader->readVerified($asset)); + + expect($fromRead)->not->toBe('') + ->and($fromReadVerified)->toBe($fromRead); +})->with([ + 'TIFF' => fn () => new Asset((string) file_get_contents(dirname(__DIR__, 2).'/Fixtures/signed-spec042.tiff'), MediaType::Tiff), + 'SVG' => fn () => new Asset((string) file_get_contents(dirname(__DIR__, 2).'/Fixtures/signed-spec042.svg'), MediaType::Svg), + 'truncated' => fn () => new Asset(substr(spec046Fixture('cs-parent.png'), 0, 20000), MediaType::Png), +])->group('SPEC-046'); + +it('AC7: lists given as TrustSettings::fromTrustLists() are named in the evidence', function () { + $signers = spec046Fixture('throw-away-root.pem'); + $tsas = (string) file_get_contents(dirname(__DIR__, 3).'/certs/trust_anchors.pem'); + + $read = (new C2paVerifierReader(TrustSettings::fromTrustLists($signers, $tsas)))->readVerified(spec046Parent()); + $lists = array_map( + static fn (mixed $list): array => is_array($list) ? [$list['kind'] ?? null, $list['sha256'] ?? null] : [], + (array) (spec046Evidence($read)['trust_lists'] ?? []), + ); + $document = json_encode(['verify' => ['verify_trust' => true], 'trust' => ['trust_anchors' => $signers.$tsas]], JSON_THROW_ON_ERROR); + + expect($lists)->toBe([['manifest', hash('sha256', $signers)], ['tsa', hash('sha256', $tsas)]]) + ->and(spec046Accessors($read->report))->toEqual(spec046Accessors((new C2paVerifierReader($document))->read(spec046Parent()))); +})->group('SPEC-046'); + +it('AC8: anything but null, a string or a TrustSettings is a TypeError from PHP itself; blank and null mean without trust', function () { + expect(fn () => new C2paVerifierReader(42))->toThrow(TypeError::class) // @phpstan-ignore argument.type + ->and(fn () => new C2paVerifierReader(new stdClass))->toThrow(TypeError::class); // @phpstan-ignore argument.type + + foreach ([null, '', " \n"] as $none) { + expect((new C2paVerifierReader($none))->read(spec046Parent())->validationState()?->value)->toBe('Valid'); + } +})->group('SPEC-046'); diff --git a/tests/Unit/VerifierVersionRangeTest.php b/tests/Unit/VerifierVersionRangeTest.php index 6968c0f..6e44309 100644 --- a/tests/Unit/VerifierVersionRangeTest.php +++ b/tests/Unit/VerifierVersionRangeTest.php @@ -47,14 +47,14 @@ function spec042ComposerSection(string $section): array ->toBe(sprintf('<%s || >=0.%d', $floor, $minor + 1)); })->group('SPEC-042'); -it('tests against the verifier that reads revocation against the issuer its walk verified', function () { - // SPEC-042 Amendment 10: 0.9 is the range. 0.9.0 grows the verifier's - // contract (evidence, a caller's revocation source, two status codes) and - // reads stored OCSP responses against the issuer its chain walk verified. - // 0.8.x stays out: one minor, as this package measured it. - expect(spec042ComposerSection('require-dev')['provemark/c2pa-verifier'] ?? null)->toBe('^0.9') - ->and(spec042ComposerSection('conflict')['provemark/c2pa-verifier'] ?? null)->toBe('<0.9 || >=0.10'); -})->group('SPEC-042'); +it('tests against the verifier that writes the evidence down as a document', function () { + // SPEC-042 Amendment 11, SPEC-046 AC6: 0.10 is the range. 0.10.0 adds + // evidenceToArray() and evidenceToJson(), which readVerified() exists to + // hand on, and carries the fixes of a security review (six wrongly Trusted + // kinds of file). 0.9.x stays out: one minor, as this package measured it. + expect(spec042ComposerSection('require-dev')['provemark/c2pa-verifier'] ?? null)->toBe('^0.10') + ->and(spec042ComposerSection('conflict')['provemark/c2pa-verifier'] ?? null)->toBe('<0.10 || >=0.11'); +})->group('SPEC-042', 'SPEC-046'); it('keeps the verifier out of require', function () { // ADR-0007: optional. A hard requirement would add ext-mbstring to every