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 @@ + */ + 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