diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c47b16c..40b0e94 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,17 +8,22 @@ on: jobs: test: - name: test strategy: fail-fast: false matrix: os: [ubuntu-latest, windows-latest] + # The floor composer.json supports, and the two most recent releases, + # because the library uses enums and read only properties that arrived + # in 8.1 and must keep working on it. + php-version: ['8.1', '8.3', '8.4'] runs-on: ${{ matrix.os }} + name: test php ${{ matrix.php-version }} on ${{ matrix.os }} steps: - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: - php-version: '8.3' + php-version: ${{ matrix.php-version }} extensions: mbstring, openssl - run: composer install --no-interaction --no-progress - run: php vendor/bin/phpunit + - run: php tests/run.php diff --git a/README.md b/README.md index 182c02a..fade239 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,8 @@ Versions 1 and 2 of the wire format are deprecated and supported for reading existing data only. New OWIDs use version 3. Fetching a creator public key over HTTP is out of scope. The -`verifyWithPublicKey` method accepts a public key PEM that the caller has -already obtained, so any HTTP client can supply it. +`verifyWithPublicKey` and `signatureStatus` methods accept a public key PEM +that the caller has already obtained, so any HTTP client can supply it. ## Payload size and application limits @@ -44,10 +44,12 @@ format defines no smaller payload limit. The null-terminated domain carries no length before it either, so the protocol alone is not an application input limit for the complete envelope. -This library validates that the declared payload length agrees with the bytes -present before it extracts the payload. A large declaration without the -corresponding bytes is malformed and is rejected without allocating the -declared size. A matching large payload is not malformed merely because it is +This library checks that the declared payload length agrees with the bytes +present before it extracts the payload, and reports the disagreement as +`ParseStatus::ByteCountMismatch` on the whole buffer surfaces, or as +`ParseStatus::UnexpectedEnd` on the framed one. A large declaration without +the corresponding bytes is malformed and is rejected without allocating the +declared size either way. A matching large payload is not malformed merely because it is large, and parsing work and memory use scale with the bytes actually present. The domain is read the same way. Because nothing declares its length, the @@ -58,11 +60,10 @@ name may be, is rejected for a cost set by that maximum rather than by the length of the buffer. The same maximum binds the write, so this library cannot produce an OWID it -would then refuse to read. A `Creator` refuses a domain longer than the -maximum when the domain is supplied, which is the earliest point the caller -can be told, and the serialization refuses one as well, so a domain that -reaches the `Owid` domain field by any other route is caught before the -signature is calculated. +would then refuse to read. A `Creator` refuses a domain longer than the maximum +when the domain is supplied, which is the earliest point the caller can be +told, and the write helpers refuse one as well, so a caller writing the format +with them directly is held to the same bound. The in-memory APIs remain subject to PHP string, platform, address-space and available-memory limits. Applications accepting untrusted OWIDs must choose @@ -70,11 +71,11 @@ limits suitable for their use case and enforce them before buffering the binary form or decoding Base64. An implementation capacity failure or an application policy rejection is distinct from an invalid OWID. -For transport input, limit the complete HTTP body or encoded envelope; allow -for the domain and other OWID fields as well as the payload. After parsing, -`strlen($owid->payload)` reports the actual payload size without another copy -and can be used for downstream policy. The parser cannot choose either limit -on behalf of the application. +For transport input, limit the complete HTTP body or encoded envelope, and +allow for the domain and other OWID fields as well as the payload. After a +successful read, `strlen($result->owid->payload)` reports the actual payload +size without another copy and can be used for downstream policy. The reader +cannot choose either limit on behalf of the application. ## Installation @@ -89,8 +90,8 @@ extensions, all of which ship with a standard PHP build. ## Usage -Create a creator that holds the signing keys, sign a payload, serialize it, -then decode and verify it later with the public key. +Create a creator that holds the signing keys, create a signed OWID, serialize +it, then read it back later and verify it with the public key. ```php use SwanCommunity\Owid\Creator; @@ -101,59 +102,183 @@ use SwanCommunity\Owid\Owid; $crypto = Crypto::new(); $creator = new Creator('example.com', $crypto); -// Create and sign an OWID with a payload. -$owid = $creator->signString('Hello World'); +// Create a signed OWID with a payload. There is no unsigned stage. +$owid = $creator->create('Hello World'); // Serialize to base 64 for storage or transmission. $encoded = $owid->asBase64(); -// Later, or elsewhere, decode and verify with the creator public key. -$copy = Owid::fromBase64($encoded); -$publicPem = $crypto->publicKeyPem(); -$valid = $copy->verifyWithPublicKey($publicPem); +// Later, or elsewhere, read it back. Input from outside may be anything at +// all, so reading answers rather than raising. +$result = Owid::tryFromBase64($encoded); +if ($result->ok) { + $publicPem = $crypto->publicKeyPem(); + $valid = $result->owid->verifyWithPublicKey($publicPem); +} else { + // $result->status names which of the expected problems it was, for + // example ParseStatus::InvalidBase64 or ParseStatus::ByteCountMismatch. + $reason = $result->status->value; +} ``` -Chain OWIDs by signing one together with others. The same others, in the same +Chain OWIDs by creating one that covers others. The same others, in the same order, must be supplied when verifying. ```php -$root = $creator->signString('root'); - -$party = new Owid(); -$party->payload = 'party'; -$creator->signWithOthers($party, [$root]); +$root = $creator->create('root'); +$party = $creator->create('party', [$root]); // Verifying the party requires the root as the single other. -$party->verifyWithPublicKey($publicPem, [$root]); +$party->verifyWithPublicKey($crypto->publicKeyPem(), [$root]); +``` + +Where the difference between a signature that does not match and a check that +could not be made changes what your code should do, ask for the status instead +of a true or false answer. A key that cannot be read is reported as a fault in +the key and never as a forgery. + +```php +use SwanCommunity\Owid\SignatureStatus; + +$status = $owid->signatureStatus($crypto->publicKeyPem()); +if ($status === SignatureStatus::SignatureValid) { + // Genuine. +} elseif ($status === SignatureStatus::SignatureInvalid) { + // The only status that means the identifier should be distrusted. +} else { + // InvalidKey, VerificationError and the rest mean the question could not + // be answered, which is an operational fault rather than an attack. +} ``` +## How an OWID comes into existence + +An OWID is only worth anything because it is signed, so a caller cannot build +one. An instance arrives by exactly two routes. + +1. Reading bytes that were already a complete OWID, with `Owid::tryFromBase64`, + `Owid::tryFromByteArray` or `Owid::tryFromFrame`. +2. `Creator::create`, which owns the version, the domain, the date and the + signature, and returns a finished OWID. + +The constructor is private and the fields are read only, both enforced by PHP +itself. There is no way to obtain a half made OWID and no way to sign one that +already exists, because an unsigned OWID is indistinguishable from a signed one +to the code downstream of it, and the difference only surfaces later when a +verification fails somewhere nobody is watching. + +## Reading data that may not be an OWID + +An OWID is read from whatever a caller was handed, which on a public end point +means anything at all, so being malformed is an ordinary outcome rather than an +exceptional one. The `try` methods report it instead of raising, because +raising costs the construction and unwinding of an exception for every bad +input and whoever sends the data chooses how often that happens. + +Every read reports the same three facts. + +1. `$result->ok`, whether it worked. +2. `$result->owid`, the OWID on success and null on failure. +3. `$result->status`, a `ParseStatus` naming the reason, which is `Parsed` on + success. + +A result also carries `$result->consumed`, the number of bytes the envelope +occupied, which a caller reading several OWIDs from one buffer adds to its +offset to reach the next. + +`tryFromBase64` and `tryFromByteArray` require the value to be one whole OWID +and nothing else, so bytes after the envelope are refused. `tryFromFrame` reads +one OWID from a buffer that may carry more after it and leaves the rest alone, +because what follows may be the next envelope. + +A frame whose declared payload runs past the bytes supplied is +`ParseStatus::UnexpectedEnd`, because there the bytes may still be arriving and +a caller has to be able to tell waiting for more from giving up. +`ParseStatus::ByteCountMismatch` belongs to the whole buffer surfaces, where +every byte is present by definition and a declaration that disagrees with them +is the finding. + +The marker for a node that is absent, a single zero byte written by +`Owid::emptyToBuffer`, is `ParseStatus::AbsentNode`. No OWID is handed back, +because the marker carries no domain, date, payload or signature and so can +never verify, and reading one as an identifier would be the one way an instance +with no signature could reach calling code. It is not an unknown version, +because version 0 is supported and meaningful, and it is not a malformed frame +either. The result counts its one byte as consumed, so a caller walking a run +of frames steps over the absent node and reads the next one. + +```php +use SwanCommunity\Owid\ParseStatus; + +// A buffer holding one OWID, a node that is absent, then another OWID. +$framedBuffer = $creator->create('first')->asByteArray(); +Owid::emptyToBuffer($framedBuffer); +$framedBuffer .= $creator->create('second')->asByteArray(); + +$offset = 0; +$identifiers = []; +while ($offset < strlen($framedBuffer)) { + $frame = Owid::tryFromFrame($framedBuffer, $offset); + if ($frame->status === ParseStatus::AbsentNode) { + // A node that is not there, which is not the same as a bad frame. + } elseif ($frame->ok) { + $identifiers[] = $frame->owid; + } else { + break; + } + $offset += $frame->consumed; +} +``` + +Reading is not verification. A successfully read OWID is structurally valid and +nothing more, and whether its signature is genuine is a separate question with +a separate answer. + ## Interface The public classes live in the `SwanCommunity\Owid` namespace. - `Owid` is the node in a tree. It holds the version, domain, date, payload, - and signature. - - `Owid::fromBase64`, `Owid::fromByteArray` parse a signed OWID. - - `asBase64`, `asByteArray` serialize a signed OWID. + and signature, all read only. + - `Owid::tryFromBase64`, `Owid::tryFromByteArray` read one complete OWID and + report a `ParseResult`. + - `Owid::tryFromFrame` reads one OWID from a buffer that carries more after + it, reporting how many bytes it occupied, and reports a node that is absent + as `ParseStatus::AbsentNode` rather than as a fault. + - `asBase64`, `asByteArray` serialize an OWID, and + `Owid::emptyToBuffer` writes the marker for one that is not present. - `payloadAsString` returns the raw payload bytes, `payloadAsPrintable` returns lower case zero padded hexadecimal, `payloadAsBase64` returns the padded base 64 form. - - `verifyWithCrypto`, `verifyWithPublicKey` verify the OWID and any others - it was signed with. + - `verifyWithCrypto`, `verifyWithPublicKey` answer true or false for the OWID + and any others it was signed with. + - `signatureStatus`, `signatureStatusWithCrypto` answer with a + `SignatureStatus`, which keeps a signature that does not match apart from a + check that could not be made. - `ageMinutes` returns the minutes elapsed since creation. +- `ParseResult` carries `ok`, `owid`, `status` and `consumed`. +- `ParseStatus` names why a read succeeded or failed, in the vocabulary shared + with the other OWID implementations. +- `SignatureStatus` names the outcome of asking whether a signature is genuine. - `Crypto` holds the keys. - `Crypto::new` generates a P-256 key pair. - `Crypto::newSignOnly` accepts a PKCS#8 or SEC1 private key PEM. - - `Crypto::newVerifyOnly` accepts an SPKI public key PEM. - - `signByteArray`, `verifyByteArray` operate on raw bytes. + - `Crypto::newVerifyOnly` accepts an SPKI public key PEM, and + `Crypto::tryVerifyOnly` returns null instead of raising when the material + cannot be read. + - `signByteArray`, `verifyByteArray` and `signatureStatus` operate on + raw bytes. - `publicKeyPem`, `privateKeyPem` export the keys as PEM. - `Creator` binds a domain to a signing `Crypto`. - - `sign`, `signWithOthers` set the domain, date, and version then sign. - - `signString`, `signBytes` create and sign in one call. + - `create($payload, $others = [])` creates and signs a new OWID in one call. + A PHP string is a byte array, so the payload may be text or raw bytes. - `Endpoints` returns the path and body strings for the well known end points without binding to any web framework. - `Version` is the wire format version enum. -- `OwidException` is raised for every error. +- `OwidException` is raised for a fault in the program, such as a creator + configured with a domain that is too long, a key that cannot be used, or + fields that cannot be written. Data arriving from outside is reported with a + `ParseStatus` instead. ## Data structure notes diff --git a/src/Creator.php b/src/Creator.php index f77d820..c5d7b88 100644 --- a/src/Creator.php +++ b/src/Creator.php @@ -20,8 +20,6 @@ namespace SwanCommunity\Owid; -use DateTimeImmutable; - /** * Needed to create new OWIDs. * @@ -93,60 +91,27 @@ public function crypto(): Crypto } /** - * Signs the OWID provided, setting the domain to the creator domain, the - * date to the current time, and the version to the current version. + * Creates and signs a new OWID for this creator carrying the payload + * given, and covering any others given. * - * @throws OwidException when the fields can not be encoded or the signing - * operation fails. - */ - public function sign(Owid $owid): void - { - $this->signWithOthers($owid, []); - } - - /** - * Signs the OWID provided together with the other OWIDs provided. The same - * others, in the same order, must be passed when verifying. + * This is the only way to make an OWID, and it makes a finished one. The + * creator owns the version, the domain, the date and the signature, so a + * caller supplies the payload and nothing else and there is no moment at + * which an unsigned OWID exists. Signing an OWID that already exists is + * not offered, because there is nothing outside to sign and re-signing one + * would replace a signature its fields were read with. + * + * A PHP string is a byte array, so the payload may be text or raw bytes + * and there is one method rather than a pair. * - * @param array $others + * @param array $others covered by the signature, and required + * in the same order when verifying * * @throws OwidException when the fields can not be encoded or the signing * operation fails. */ - public function signWithOthers(Owid $owid, array $others): void - { - $owid->version = Version::default(); - $owid->domain = $this->domain; - $owid->date = new DateTimeImmutable('now'); - $data = $owid->dataForCrypto($others); - $owid->signature = $this->crypto->signByteArray($data); - if (strlen($owid->signature) !== OwidException::SIGNATURE_LENGTH) { - throw OwidException::invalidSignatureLength(strlen($owid->signature)); - } - } - - /** - * Creates a new signed OWID for the creator containing the string as the - * payload. - * - * @throws OwidException when the OWID can not be signed. - */ - public function signString(string $value): Owid - { - return $this->signBytes($value); - } - - /** - * Creates a new signed OWID for the creator containing the bytes as the - * payload. - * - * @throws OwidException when the OWID can not be signed. - */ - public function signBytes(string $value): Owid + public function create(string $payload, array $others = []): Owid { - $owid = new Owid(); - $owid->payload = $value; - $this->sign($owid); - return $owid; + return Owid::createSignedBy($this, $payload, $others); } } diff --git a/src/Crypto.php b/src/Crypto.php index ff70a59..f7a982d 100644 --- a/src/Crypto.php +++ b/src/Crypto.php @@ -110,6 +110,32 @@ public static function newVerifyOnly(string $publicPem): self return new self(null, $key); } + /** + * Creates an instance for verifying OWIDs from the public key PEM + * provided, or returns null when the material cannot be read as a public + * key. + * + * Key material arriving from a well known end point is external data, so + * being unreadable is an operational fault rather than an exceptional one. + * Answering with null lets the caller report it as InvalidKey, which is + * what was needed on 30 August 2026 when the end points served PEM a + * strict parser rejects, because reporting that as an invalid signature + * would have read as an attack. The reason the material could not be read + * is deliberately not returned, as the caller has nothing to do with it + * beyond reporting the key as unusable. + */ + public static function tryVerifyOnly(string $publicPem): ?self + { + try { + return self::newVerifyOnly($publicPem); + } catch (OwidException $e) { + // Catching here costs an exception only when an operator's key is + // wrong, which is rare and not chosen by whoever sends + // identifiers, unlike a parse failure. + return null; + } + } + /** * Signs the byte array with the private key and returns the 64 byte * signature in the raw r concatenated with s form. @@ -155,6 +181,29 @@ public function verifyByteArray(string $data, string $signature): bool if (strlen($signature) !== OwidException::SIGNATURE_LENGTH) { throw OwidException::invalidSignatureLength(strlen($signature)); } + return $this->signatureStatus($data, $signature) === + SignatureStatus::SignatureValid; + } + + /** + * Says whether the signature is genuine for the data, and where the + * question could not be answered, says that instead. + * + * A signature of the wrong length, a key this instance does not hold, or a + * provider failure each leave the signature unjudged, and none of them is + * reported as SignatureInvalid, which means only that a well formed + * signature did not match. + */ + public function signatureStatus( + string $data, + string $signature + ): SignatureStatus { + if ($this->verifyingKey === null) { + return SignatureStatus::InvalidKey; + } + if (strlen($signature) !== OwidException::SIGNATURE_LENGTH) { + return SignatureStatus::InvalidSignatureLength; + } $der = self::rawToDer($signature); $result = openssl_verify( $data, @@ -163,8 +212,16 @@ public function verifyByteArray(string $data, string $signature): bool OPENSSL_ALGO_SHA256 ); // openssl_verify returns 1 for a valid signature, 0 for an invalid - // signature, and -1 when an error occurs. Only 1 means valid. - return $result === 1; + // signature, and -1 when an error occurs. The error is the provider + // failing on inputs that were both valid, which says nothing about the + // identifier and must not be reported as if it did. + if ($result === -1) { + self::lastOpenSslError(); + return SignatureStatus::VerificationError; + } + return $result === 1 + ? SignatureStatus::SignatureValid + : SignatureStatus::SignatureInvalid; } /** diff --git a/src/Io.php b/src/Io.php index 531b9cf..30c8b75 100644 --- a/src/Io.php +++ b/src/Io.php @@ -21,13 +21,16 @@ namespace SwanCommunity\Owid; use DateTimeImmutable; -use DateTimeZone; /** - * Low level read and write helpers for the OWID binary format. The format uses - * little endian unsigned 32 bit integers, null terminated strings, and a fixed - * 64 byte signature. Strings in PHP are byte arrays, so all buffers here are - * plain strings holding raw bytes. + * Low level write helpers for the OWID binary format, and the base date the + * format counts from. The format uses little endian unsigned 32 bit integers, + * null terminated strings, and a fixed 64 byte signature. Strings in PHP are + * byte arrays, so all buffers here are plain strings holding raw bytes. + * + * Reading lives in Owid, which walks the buffer by index and reports a status + * rather than raising, because the bytes come from outside and being malformed + * is an ordinary outcome for them. */ final class Io { @@ -45,177 +48,6 @@ public static function baseDate(): DateTimeImmutable return new DateTimeImmutable('@' . self::BASE_TIMESTAMP); } - /** - * Sequential reader over a byte buffer. PHP strings are used as the byte - * buffer because each element accessed by offset is a single byte. - */ - private string $buffer; - private int $length; - private int $position; - - public function __construct(string $buffer) - { - $this->buffer = $buffer; - $this->length = strlen($buffer); - $this->position = 0; - } - - /** - * Reads a single byte and returns its unsigned integer value. - * - * @throws OwidException when the buffer has no more bytes. - */ - public function readByte(): int - { - if ($this->position >= $this->length) { - throw OwidException::unexpectedEndOfBuffer(); - } - $value = ord($this->buffer[$this->position]); - $this->position += 1; - return $value; - } - - /** - * Reads the requested number of raw bytes from the buffer. - * - * @throws OwidException when the buffer is too short. - */ - public function readBytes(int $count): string - { - if ($count < 0 || $this->position + $count > $this->length) { - throw OwidException::unexpectedEndOfBuffer(); - } - $value = substr($this->buffer, $this->position, $count); - $this->position += $count; - return $value; - } - - /** - * Returns the number of unread bytes, so a top-level decoder can require - * EOF while a framed reader can deliberately leave following data. - */ - public function remaining(): int - { - return $this->length - $this->position; - } - - /** - * Reads bytes until the null terminator and returns them as a string. The - * terminator is consumed but not returned. The only such string in an - * OWID is the creator domain, and the terminator is whatever the sender - * wrote, so the search for it stops after the greatest number of - * characters a domain name can hold rather than running to the end of the - * buffer. A buffer with no terminator therefore costs the bound and not - * its own length. strcspn is used because it takes the window as an - * argument and so examines no more bytes than the window, whereas strpos - * would search the rest of the buffer. - * - * @throws OwidException when the domain has no terminator within the - * characters a domain name can hold, or the buffer - * ends before the terminator. - */ - public function readString(): string - { - $maximum = OwidException::MAXIMUM_DOMAIN_LENGTH; - $count = strcspn($this->buffer, "\0", $this->position, $maximum + 1); - if ($count > $maximum) { - throw OwidException::domainTooLong(); - } - $terminator = $this->position + $count; - if ($terminator >= $this->length) { - throw OwidException::unexpectedEndOfBuffer(); - } - $value = substr($this->buffer, $this->position, $count); - $this->position = $terminator + 1; - return $value; - } - - /** - * Reads an unsigned 32 bit little endian integer. - * - * @throws OwidException when the buffer is too short. - */ - public function readUint32(): int - { - if ($this->position + 4 > $this->length) { - throw OwidException::unexpectedEndOfBuffer(); - } - /** @var array{1: int} $unpacked */ - $unpacked = unpack('V', $this->buffer, $this->position); - $this->position += 4; - return $unpacked[1]; - } - - /** - * Reads a byte array prefixed with its length as an unsigned 32 bit - * integer. The count is bounded by the bytes present in readBytes, so - * nothing is sized by the declared number alone. The OWID payload is - * read with readPayload instead, because the payload must also be - * followed by the fixed-length signature. - * - * @throws OwidException when the buffer is too short. - */ - public function readByteArray(): string - { - $count = $this->readUint32(); - return $this->readBytes($count); - } - - /** - * Reads the length prefixed payload of an OWID, which must be followed - * by the signature. The count is whatever the sender - * declared, so it is checked against the bytes actually present before - * anything is sized by it. The count must leave at least the signature; - * a public reader consumes one OWID and leaves following framed bytes, - * while top-level byte-array parsing separately requires EOF. - * - * @throws OwidException when the declared length does not leave a - * complete signature after the payload. - */ - public function readPayload(): string - { - $count = $this->readUint32(); - $present = $this->length - $this->position; - if ($count + OwidException::SIGNATURE_LENGTH > $present) { - throw OwidException::payloadLengthMismatch($count, $present); - } - return $this->readBytes($count); - } - - /** - * Reads the fixed length signature. - * - * @throws OwidException when the buffer is too short. - */ - public function readSignature(): string - { - return $this->readBytes(OwidException::SIGNATURE_LENGTH); - } - - /** - * Reads the date using the encoding associated with the version. - * - * @throws OwidException when the version has no date encoding or the - * buffer is too short. - */ - public function readDate(Version $version): DateTimeImmutable - { - switch ($version) { - case Version::Version1: - $bytes = $this->readBytes(2); - /** @var array{1: int} $unpacked */ - $unpacked = unpack('n', $bytes); - $hours = $unpacked[1]; - return self::baseDate()->modify('+' . $hours . ' hours'); - case Version::Version2: - case Version::Version3: - $minutes = $this->readUint32(); - return self::baseDate()->modify('+' . $minutes . ' minutes'); - default: - throw OwidException::unsupportedVersion($version->asByte()); - } - } - /** * Appends a single byte, given as an unsigned integer, to the buffer. */ @@ -229,13 +61,14 @@ public static function writeByte(string &$buffer, int $value): void * in an OWID is the creator domain. The value must not contain a null * character as that would conflict with the terminator, and must not be * longer than the greatest number of characters a domain name can hold, - * because readString stops looking for the terminator at that bound and + * because reading stops looking for the terminator at that bound and * would refuse anything longer. Without this the library could write an * OWID it then refused to read, and the fault would land on whoever read * it rather than on the creator that caused it. This is the later of the - * two write side checks, and it catches a domain that reached the OWID - * by some route other than the creator, such as the public domain field - * being assigned directly. + * two write side checks. Since an OWID can only be parsed or created, and + * both routes are bounded, no OWID can now carry a domain this refuses, so + * what it guards is a caller writing the format with these helpers + * directly. * * @throws OwidException when the value contains a null byte, or is * longer than a domain name can hold. diff --git a/src/Owid.php b/src/Owid.php index 6b9a9ae..184aeae 100644 --- a/src/Owid.php +++ b/src/Owid.php @@ -26,111 +26,353 @@ * OWID structure which can be used as a node in a tree. * * An OWID records that the processor operating the domain handled the payload, - * and any other OWIDs covered by the signature, at the date and time given. - * Once signed it is immutable. Any change to the fields will cause - * verification to fail. + * and any other OWIDs covered by the signature, at the date and time given. It + * is only worth anything because it is signed, so a caller cannot build one. + * An instance arrives by exactly two routes, either from reading bytes that + * were already a complete OWID, with tryFromBase64, tryFromByteArray or + * tryFromFrame, or from a Creator that signs one into existence with + * Creator::create. There is deliberately no way to assemble a half made one, + * because an unsigned OWID is indistinguishable from a signed one to the code + * downstream of it, and the difference only surfaces later when a verification + * fails somewhere nobody is watching. + * + * The fields are read only for the same reason. A parsed OWID's signature + * covers its fields as they arrived, so a caller that could change one would + * hold something whose signature no longer describes it. PHP strings are + * values rather than references, so what a caller does with a copy of the + * payload or the signature cannot reach the OWID it came from. * * The payload and signature are held as raw byte strings because PHP strings * are byte arrays. */ final class Owid { - /** The byte version of the OWID. */ - public Version $version; - - /** Domain associated with the creator. */ - public string $domain; - - /** The date and time to the nearest minute in UTC of the creation. */ - public DateTimeImmutable $date; - - /** Raw bytes that form the payload. */ - public string $payload; - /** - * Signature for this OWID and any others provided when signing. Generated - * by the Creator instance and held as 64 raw bytes once signed. + * Built by the two routes above and by nothing else. PHP has no package + * private, so a private constructor, with the parse and creation code + * inside this class, is the strongest boundary the language offers, and it + * is enforced by the engine rather than by a check that runs at run time. */ - public string $signature; + private function __construct( + /** The byte version of the OWID. */ + public readonly Version $version, + /** Domain associated with the creator. */ + public readonly string $domain, + /** The date and time to the nearest minute in UTC of the creation. */ + public readonly DateTimeImmutable $date, + /** Raw bytes that form the payload. */ + public readonly string $payload, + /** + * Signature for this OWID and any others provided when signing. + * Generated by the Creator and held as 64 raw bytes. + */ + public readonly string $signature + ) { + } /** - * Creates a new unsigned OWID with the domain, date, and payload provided - * and the current version. With no arguments an empty OWID at the current - * time is created. + * Reads a complete OWID from its base 64 form, answering rather than + * raising when the value is not one. + * + * The value may be anything at all, because this is external data and + * failing to be an OWID is an ordinary outcome. Input with or without the + * trailing padding is accepted, as an OWID is carried both ways. The + * marker for an absent node is reported as ParseStatus::AbsentNode, with + * no OWID, as it is on the byte array surface. + * + * @param mixed $value the base 64 text, or anything a caller was handed */ - public function __construct( - string $domain = '', - ?DateTimeImmutable $date = null, - string $payload = '' - ) { - $this->version = Version::default(); - $this->domain = $domain; - $this->date = $date ?? new DateTimeImmutable('now'); - $this->payload = $payload; - $this->signature = ''; + public static function tryFromBase64(mixed $value): ParseResult + { + if ($value === null || $value === '') { + return ParseResult::failed(ParseStatus::MissingInput); + } + if (!is_string($value)) { + // A repeated query parameter with brackets reaches a PHP + // application as an array, so a caller passing on what it was + // given is not necessarily passing on a string. + return ParseResult::failed(ParseStatus::InvalidInputType); + } + // A base 64 string is four characters for every three bytes, so a + // length that leaves one character over encodes no whole byte and + // cannot have come from an encoder. + if (strlen($value) % 4 === 1) { + return ParseResult::failed(ParseStatus::InvalidBase64); + } + $buffer = base64_decode($value, true); + if ($buffer === false) { + return ParseResult::failed(ParseStatus::InvalidBase64); + } + return self::parse($buffer, 0, true); } /** - * Creates an OWID from a base 64 encoded string. Input with or without the - * trailing padding is accepted. + * Reads a complete OWID from a buffer holding exactly one. * - * @throws OwidException when the string is not valid base 64 or the bytes - * do not form a valid OWID. + * The buffer must be one whole OWID and nothing else, so bytes after the + * envelope are refused, because on this surface there is nothing else they + * could belong to. The marker for an absent node, a single zero byte, is + * not an OWID, so it is reported as ParseStatus::AbsentNode and no value + * is handed back. + * + * @param mixed $buffer the raw bytes, or anything a caller was handed */ - public static function fromBase64(string $value): self + public static function tryFromByteArray(mixed $buffer): ParseResult { - return self::fromByteArray(self::base64Decode($value)); + if ($buffer === null || $buffer === '') { + return ParseResult::failed(ParseStatus::MissingInput); + } + if (!is_string($buffer)) { + return ParseResult::failed(ParseStatus::InvalidInputType); + } + return self::parse($buffer, 0, true); } /** - * Creates an OWID from its complete binary form. Bytes missing from or - * following the envelope are refused. + * Reads one OWID from a buffer that may carry more after it, starting at + * the offset given. * - * @throws OwidException when the version is unknown, the buffer is too - * short for the remaining fields, or the declared - * payload length does not match the bytes present. + * Used where several OWIDs are framed one after another. Bytes following + * the signature are left alone, because they may be the next envelope + * rather than rubbish, and the number of bytes this one occupied is + * reported as the consumed field of the result so a caller can advance to + * the next. + * + * A frame holding the marker for an absent node reports + * ParseStatus::AbsentNode with no OWID and its one byte counted as + * consumed, so a caller adding consumed to its offset walks past the + * absent node to the next frame. That is the distinction the status + * exists to make, as an absent node is not a malformed frame. + * + * A frame whose declared payload runs past the bytes supplied is + * ParseStatus::UnexpectedEnd rather than a disagreement between the + * declaration and the bytes, because here the bytes may still be arriving + * and a caller has to be able to tell waiting for more from giving up. */ - public static function fromByteArray(string $buffer): self + public static function tryFromFrame(string $buffer, int $offset = 0): ParseResult { - $reader = new Io($buffer); - $owid = self::fromReader($reader); - if ($reader->remaining() !== 0) { - throw new OwidException( - "OWID contains '" . $reader->remaining() . - "' bytes after the envelope" + if ($offset < 0 || $offset >= strlen($buffer)) { + return ParseResult::failed(ParseStatus::MissingInput); + } + return self::parse($buffer, $offset, false); + } + + /** + * Reads the fields of one OWID from the buffer, starting at the offset + * given, and reports what was found. + * + * The buffer is walked by index and every read is checked against what is + * left, so a malformed envelope is a comparison that fails rather than an + * exception that is built and unwound. That matters because the data comes + * from outside, and whoever sends it chooses how often this fails and how + * large each attempt is. + * + * When exact is true the envelope must end where the buffer does, and when + * it is false anything after the signature is left for the caller. + */ + private static function parse( + string $buffer, + int $offset, + bool $exact + ): ParseResult { + $total = strlen($buffer); + $at = $offset; + if ($at >= $total) { + // Nothing was supplied, which is not the same as data that + // stopped part way through a field. + return ParseResult::failed(ParseStatus::MissingInput); + } + + $version = Version::tryFrom(ord($buffer[$at])); + if ($version === null) { + return ParseResult::failed(ParseStatus::UnsupportedVersion); + } + $at += 1; + + if ($version === Version::Empty) { + // The marker for a node that is absent. It is not an OWID and no + // OWID is handed back, because it carries no domain, date, payload + // or signature and nothing mistakable for an identifier may reach + // calling code. It is not a fault either, so it is reported as + // itself rather than as an unknown version, and its one byte is + // counted so that a caller walking a run of frames steps over the + // absent node and reads the next one. The first byte settles this + // on either contract, since nothing after it can make the value an + // OWID. + return ParseResult::absentNode($at - $offset); + } + + // The domain, terminated by a zero byte. Nothing declares its length, + // and the terminator is whatever the sender wrote, so the search for + // it stops at the greatest number of characters a domain name can hold + // rather than running to the end of the buffer. strcspn takes the + // window as an argument and so examines no more bytes than the window. + $maximum = OwidException::MAXIMUM_DOMAIN_LENGTH; + $count = strcspn($buffer, "\0", $at, $maximum + 1); + if ($count > $maximum) { + return ParseResult::failed(ParseStatus::InvalidDomainEncoding); + } + if ($at + $count >= $total) { + // The buffer ran out inside the domain, which is data that merely + // stopped rather than a domain that cannot be valid. + return ParseResult::failed(ParseStatus::UnexpectedEnd); + } + $domain = substr($buffer, $at, $count); + $at += $count + 1; + + // The date, whose width depends on the version. + if ($version === Version::Version1) { + if ($total - $at < 2) { + return ParseResult::failed(ParseStatus::UnexpectedEnd); + } + $hours = (ord($buffer[$at]) << 8) | ord($buffer[$at + 1]); + $at += 2; + $date = Io::baseDate()->modify('+' . $hours . ' hours'); + } else { + if ($total - $at < 4) { + return ParseResult::failed(ParseStatus::UnexpectedEnd); + } + $minutes = self::uint32($buffer, $at); + $at += 4; + $date = Io::baseDate()->modify( + '+' . sprintf('%.0F', $minutes) . ' minutes' + ); + } + if ($date === false) { + // Only a date the calendar cannot express reaches this, which the + // four byte minute count of versions 2 and 3 cannot reach, so it + // guards a runtime whose date range is narrower than the format's. + return ParseResult::failed(ParseStatus::MalformedEnvelope); + } + + if ($total - $at < 4) { + return ParseResult::failed(ParseStatus::UnexpectedEnd); + } + $declared = self::uint32($buffer, $at); + $at += 4; + + // The declaration is the sender's claim about a payload not yet read, + // so it is compared with what is actually present before anything is + // sized by it. + if ($exact) { + // Computed signed, so a buffer with fewer bytes left than a + // signature needs gives a negative count rather than wrapping, and + // a negative count can never equal a declaration. The disagreement + // is the finding even when the buffer also stopped early, because + // what a reader can say for certain is that the declared payload + // cannot leave exactly the signature the version requires, and + // that is true whichever way the bytes fall short. The comparison + // is numeric rather than identical because on a 32 bit runtime the + // declaration is a float once it passes the integer range. + $present = ($total - $at) - OwidException::SIGNATURE_LENGTH; + if ($present != $declared) { + return ParseResult::failed(ParseStatus::ByteCountMismatch); + } + } else { + // A framed reader cannot call the following bytes trailing + // rubbish, because they may be the next envelope, so all it can + // require is that this envelope is complete. Anything less is data + // that stopped early. + $available = ($total - $at) - OwidException::SIGNATURE_LENGTH; + if ($available < $declared) { + return ParseResult::failed(ParseStatus::UnexpectedEnd); + } + } + + // The bytes are all here. Whether this runtime can hold them as one + // string is a separate question with a different answer, because the + // same envelope may be readable elsewhere. The declaration is an + // integer unless it passed the integer range, which only a 32 bit + // build can do. + if (!is_int($declared)) { + return ParseResult::failed( + ParseStatus::ImplementationCapacityExceeded ); } - return $owid; + + $payload = substr($buffer, $at, $declared); + $at += $declared; + $signature = substr($buffer, $at, OwidException::SIGNATURE_LENGTH); + $at += OwidException::SIGNATURE_LENGTH; + + if ($exact && $at !== $total) { + // Unreachable while the count check above holds, and kept so that + // a future change to that arithmetic cannot silently start + // accepting bytes after the envelope. + return ParseResult::failed(ParseStatus::MalformedEnvelope); + } + + return ParseResult::parsed( + new self($version, $domain, $date, $payload, $signature), + $at - $offset + ); } /** - * Creates an OWID by reading its next fields from the reader. Bytes after - * the signature are left for a caller that frames multiple values. + * Reads the four byte little endian unsigned length at the offset given. + * + * The value is composed from the bytes rather than unpacked so that it is + * exact on a 32 bit runtime, where the top byte can carry it past the + * integer range. PHP widens the result to a float there, which holds every + * value the field can express without loss, so the comparison with the + * bytes present is made before anything narrows the declaration to + * something allocatable. * - * @throws OwidException when the version is unknown, the buffer is too - * short, or the declared payload length does not - * match the bytes present. + * @return int|float the declared count, a float only where it exceeds what + * an integer on this runtime can hold */ - public static function fromReader(Io $reader): self + private static function uint32(string $buffer, int $at): int|float { - $version = Version::fromByte($reader->readByte()); - $owid = new self(); - $owid->version = $version; - if ($version === Version::Empty) { - return $owid; + $low = ord($buffer[$at]) + | (ord($buffer[$at + 1]) << 8) + | (ord($buffer[$at + 2]) << 16); + return $low + (ord($buffer[$at + 3]) * 16777216); + } + + /** + * Creates and signs a new OWID for the creator, carrying the payload given + * and covering any others given. + * + * This is one of only two ways an OWID reaches calling code, the other + * being a successful parse. The creator owns the version, the domain, the + * date and the signature, and the caller supplies the payload, so there is + * no moment at which a partly built OWID exists for anyone to hold or pass + * on. The signature is calculated before the instance exists, which is why + * this lives here rather than on Creator, as a private constructor can only + * be reached from inside this class. + * + * @param array $others covered by the signature, and required + * in the same order when verifying + * + * @throws OwidException when the fields can not be encoded or the signing + * operation fails. + * + * @internal Callers use Creator::create, which is the public spelling. + */ + public static function createSignedBy( + Creator $creator, + string $payload, + array $others = [] + ): self { + $version = Version::default(); + $domain = $creator->domain(); + $date = new DateTimeImmutable('now'); + $data = ''; + self::writeFieldsNoSignature($data, $version, $domain, $date, $payload); + foreach ($others as $other) { + $other->toBuffer($data); } - $owid->domain = $reader->readString(); - $owid->date = $reader->readDate($version); - $owid->payload = $reader->readPayload(); - $owid->signature = $reader->readSignature(); - return $owid; + $signature = $creator->crypto()->signByteArray($data); + if (strlen($signature) !== OwidException::SIGNATURE_LENGTH) { + throw OwidException::invalidSignatureLength(strlen($signature)); + } + return new self($version, $domain, $date, $payload, $signature); } /** * Returns the OWID as a byte array. * - * @throws OwidException when the OWID has not been signed or the fields - * can not be encoded. + * @throws OwidException when the fields can not be encoded. */ public function asByteArray(): string { @@ -161,8 +403,9 @@ public function toBuffer(string &$buffer): void } /** - * Appends an empty OWID marker to the buffer. Used to indicate optional - * OWIDs in byte arrays. + * Appends the marker for a node that is absent to the buffer, which is how + * an optional OWID that is not present is written into a framed buffer. A + * read of that frame reports ParseStatus::AbsentNode. */ public static function emptyToBuffer(string &$buffer): void { @@ -177,10 +420,32 @@ public static function emptyToBuffer(string &$buffer): void */ public function toBufferNoSignature(string &$buffer): void { - Io::writeByte($buffer, $this->version->asByte()); - Io::writeString($buffer, $this->domain); - Io::writeDate($buffer, $this->date, $this->version); - Io::writeByteArray($buffer, $this->payload); + self::writeFieldsNoSignature( + $buffer, + $this->version, + $this->domain, + $this->date, + $this->payload + ); + } + + /** + * Appends the fields other than the signature to the buffer. Static so + * that creation can build the data to sign before an instance exists. + * + * @throws OwidException when the fields can not be encoded. + */ + private static function writeFieldsNoSignature( + string &$buffer, + Version $version, + string $domain, + DateTimeImmutable $date, + string $payload + ): void { + Io::writeByte($buffer, $version->asByte()); + Io::writeString($buffer, $domain); + Io::writeDate($buffer, $date, $version); + Io::writeByteArray($buffer, $payload); } /** @@ -270,28 +535,59 @@ public function verifyWithPublicKey(string $publicPem, array $others = []): bool } /** - * Returns the OWID as a base 64 string, the same as asBase64. Used when an - * OWID is converted to a string. + * Says whether the signature is genuine, and where it could not be + * checked, says that instead of reporting a forgery. * - * @throws OwidException when the OWID can not be serialized. + * A key that cannot be read is InvalidKey and never SignatureInvalid, + * because the identifier may be perfectly good and only the key material + * wrong. Use this in preference to verifyWithPublicKey wherever the + * difference between "does not match" and "could not be checked" changes + * what the caller should do. + * + * @param array $others */ - public function __toString(): string - { - return $this->asBase64(); + public function signatureStatus( + string $publicPem, + array $others = [] + ): SignatureStatus { + $crypto = Crypto::tryVerifyOnly($publicPem); + if ($crypto === null) { + return SignatureStatus::InvalidKey; + } + return $this->signatureStatusWithCrypto($crypto, $others); } /** - * Decodes a standard alphabet base 64 string, accepting input with or - * without the trailing padding. + * Says whether the signature is genuine using the crypto instance + * provided, with the same separation between a signature that does not + * match and a check that could not be made. * - * @throws OwidException when the string is not valid base 64. + * @param array $others */ - private static function base64Decode(string $value): string - { - $decoded = base64_decode($value, true); - if ($decoded === false) { - throw OwidException::base64(); + public function signatureStatusWithCrypto( + Crypto $crypto, + array $others = [] + ): SignatureStatus { + try { + $data = $this->dataForCrypto($others); + } catch (OwidException $e) { + // Every OWID encodes, because it was read or created and both + // routes bound every field, so nothing a caller can hold reaches + // this. It is kept because a check that cannot be made must have + // somewhere to go other than a signature that does not match. + return SignatureStatus::VerificationError; } - return $decoded; + return $crypto->signatureStatus($data, $this->signature); + } + + /** + * Returns the OWID as a base 64 string, the same as asBase64. Used when an + * OWID is converted to a string. + * + * @throws OwidException when the OWID can not be serialized. + */ + public function __toString(): string + { + return $this->asBase64(); } } diff --git a/src/OwidException.php b/src/OwidException.php index 49a0d65..b179a8e 100644 --- a/src/OwidException.php +++ b/src/OwidException.php @@ -23,9 +23,13 @@ use Exception; /** - * The single exception type raised when creating, reading, signing, or - * verifying OWIDs. Each named constructor produces a clear message describing - * what went wrong. + * The single exception type raised when writing, signing, or configuring a + * creator. Each named constructor produces a clear message describing what + * went wrong. + * + * Reading is not here. Bytes arriving from outside are read with the try + * methods on Owid, which report a ParseStatus, because data that is not an + * OWID is an ordinary outcome and not a fault in the program. */ final class OwidException extends Exception { @@ -47,7 +51,11 @@ final class OwidException extends Exception public const MAXIMUM_DOMAIN_LENGTH = 253; /** - * The version byte is not one supported by this implementation. + * The version has no encoding for the field being written. Only the marker + * for an absent node has none, as it carries no date, and no OWID can hold + * that version any more, so this is reached by a caller writing the format + * with the Io helpers directly. Reading reports an unknown version byte as + * a ParseStatus rather than raising. */ public static function unsupportedVersion(int $version): self { @@ -66,22 +74,6 @@ public static function invalidSignatureLength(int $length): self ); } - /** - * The buffer ended before all the expected fields were read. - */ - public static function unexpectedEndOfBuffer(): self - { - return new self('buffer ended before the OWID was complete'); - } - - /** - * The base 64 string could not be decoded. - */ - public static function base64(): self - { - return new self('base 64 decoding failed because the input is not valid'); - } - /** * The domain is empty, or contains a null character which would conflict * with the null terminated string encoding. @@ -92,15 +84,11 @@ public static function invalidDomain(string $domain): self } /** - * The domain is longer than the greatest number of characters a domain - * name can hold. Both halves of the library raise this, so both report - * the one condition the one way. On a read the domain field has no - * terminator within that many characters, so whatever the field holds - * runs past the bound, and on a write the value handed in is longer - * than the bound. The domain is not named because on a read the bytes - * are whatever the sender wrote and there may be no end to them, and - * because writeString cannot tell which of the two routes a value - * arrived by. + * The domain handed in for writing is longer than the greatest number of + * characters a domain name can hold. The same bound on a read is reported + * as ParseStatus::InvalidDomainEncoding instead, because bytes arriving + * from outside are data rather than a fault in the program. The domain is + * not named because a value that long says nothing useful in a log. */ public static function domainTooLong(): self { @@ -121,22 +109,6 @@ public static function dateOutOfRange(): self ); } - /** - * The declared payload length does not leave a complete signature after - * the payload. The declared value is whatever the sender wrote, so it is - * named alongside the bytes that were actually present. - */ - public static function payloadLengthMismatch( - int $declared, - int $present - ): self { - $signature = self::SIGNATURE_LENGTH; - return new self( - "OWID payload length '$declared' exceeds the '$present' bytes " . - "present, which must also contain the '$signature' byte signature" - ); - } - /** * The payload is larger than the unsigned 32 bit length prefix allows. */ diff --git a/src/ParseResult.php b/src/ParseResult.php new file mode 100644 index 0000000..be8afbf --- /dev/null +++ b/src/ParseResult.php @@ -0,0 +1,97 @@ +ok)`. The OWID, which is present + * only on success. A named reason, which is ParseStatus::Parsed on success and + * the specific problem otherwise. + * + * One reason is neither a success nor a fault. The marker for an absent node + * is well formed and is not an OWID, so it reports ParseStatus::AbsentNode + * with no value and with its one byte counted as consumed, which lets a caller + * walking a run of frames step over it and read the next one. + * + * The fields are read only, so a result cannot be changed into saying + * something the parse did not find. + */ +final class ParseResult +{ + /** + * Built by the parser alone, so a result always describes a parse that + * actually happened. + */ + private function __construct( + /** True when the bytes were a complete, structurally valid OWID. */ + public readonly bool $ok, + /** The OWID on success, and null on failure. */ + public readonly ?Owid $owid, + /** Parsed on success, and the specific reason otherwise. */ + public readonly ParseStatus $status, + /** + * The number of bytes accounted for, counted from the offset the read + * started at, and zero when nothing could be accounted for. A caller + * reading several OWIDs from one buffer adds this to its offset to + * reach the next. An absent node counts its one marker byte, so the + * same arithmetic steps over it. + */ + public readonly int $consumed + ) { + } + + /** + * A successful read of the OWID given, which occupied the number of bytes + * given. + * + * @internal Used by the parser in Owid. + */ + public static function parsed(Owid $owid, int $consumed): self + { + return new self(true, $owid, ParseStatus::Parsed, $consumed); + } + + /** + * A read that did not produce an OWID, for the reason given. + * + * @internal Used by the parser in Owid. + */ + public static function failed(ParseStatus $status): self + { + return new self(false, null, $status, 0); + } + + /** + * A read that found the marker for an absent node, which occupied the + * number of bytes given. + * + * Neither a value nor a fault, so ok is false, because there is no OWID, + * while the bytes are still counted, because the frame was understood. + * + * @internal Used by the parser in Owid. + */ + public static function absentNode(int $consumed): self + { + return new self(false, null, ParseStatus::AbsentNode, $consumed); + } +} diff --git a/src/ParseStatus.php b/src/ParseStatus.php new file mode 100644 index 0000000..1940475 --- /dev/null +++ b/src/ParseStatus.php @@ -0,0 +1,139 @@ +value; } - /** - * Returns the version for the byte provided. - * - * @throws OwidException when the byte is not a known version. - */ - public static function fromByte(int $value): self - { - $version = self::tryFrom($value); - if ($version === null) { - throw OwidException::unsupportedVersion($value); - } - return $version; - } + // A byte that names no version is reported by the parser as the + // UnsupportedVersion status rather than raised, so the enum's own + // tryFrom is all the reader needs and there is no throwing lookup here. } diff --git a/tests/CreatorTest.php b/tests/CreatorTest.php index 23a5b96..fd4a0f2 100644 --- a/tests/CreatorTest.php +++ b/tests/CreatorTest.php @@ -23,7 +23,6 @@ use PHPUnit\Framework\TestCase; use SwanCommunity\Owid\Crypto; use SwanCommunity\Owid\Creator; -use SwanCommunity\Owid\Owid; use SwanCommunity\Owid\OwidException; use SwanCommunity\Owid\Version; @@ -33,40 +32,48 @@ final class CreatorTest extends TestCase { /** - * Signing sets the domain, the current version, and a 64 byte signature. + * Creating sets the domain, the current version, and a 64 byte signature, + * so what the caller receives is finished rather than waiting to be + * signed. */ - public function testSignSetsFields(): void + public function testCreateSetsFields(): void { $creator = new Creator('example.com', Crypto::new()); - $owid = $creator->signString('Hello World'); + + $owid = $creator->create('Hello World'); + $this->assertSame('example.com', $owid->domain); $this->assertSame(Version::Version3, $owid->version); $this->assertSame(64, strlen($owid->signature)); } /** - * Signing overwrites whatever domain and version the OWID started with. + * A payload of raw bytes is carried unchanged, because a PHP string is a + * byte array and creation makes no distinction between text and bytes. */ - public function testSignOverwritesDomainAndVersion(): void + public function testCreateCarriesRawBytes(): void { + $payload = "\x00\xFF\x10 text"; $creator = new Creator('example.com', Crypto::new()); - $owid = new Owid('other.com'); - $owid->version = Version::Version1; - $owid->payload = 'value'; - $creator->sign($owid); - $this->assertSame('example.com', $owid->domain); - $this->assertSame(Version::Version3, $owid->version); + + $owid = $creator->create($payload); + + $this->assertSame($payload, $owid->payload); } /** - * The bytes accessor returns the same payload as the string accessor. + * There is no public way to sign an OWID that already exists, because + * there is nothing outside to sign and re-signing one would replace a + * signature its fields were read with. */ - public function testSignBytesMatchesSignString(): void + public function testNoPublicSigningSurface(): void { - $creator = new Creator('example.com', Crypto::new()); - $fromString = $creator->signString('value'); - $fromBytes = $creator->signBytes('value'); - $this->assertSame($fromString->payload, $fromBytes->payload); + foreach (['sign', 'signWithOthers', 'signString', 'signBytes'] as $gone) { + $this->assertFalse( + method_exists(Creator::class, $gone), + "Creator::$gone must not exist" + ); + } } /** @@ -96,7 +103,7 @@ public function testFromConfiguration(): void { $crypto = Crypto::new(); $creator = Creator::fromConfiguration('example.com', $crypto->privateKeyPem()); - $owid = $creator->signString('value'); + $owid = $creator->create('value'); $this->assertSame('example.com', $creator->domain()); $this->assertTrue($owid->verifyWithPublicKey($crypto->publicKeyPem())); } diff --git a/tests/CrossLanguageTest.php b/tests/CrossLanguageTest.php index 7adc759..d89782e 100644 --- a/tests/CrossLanguageTest.php +++ b/tests/CrossLanguageTest.php @@ -52,7 +52,7 @@ public static function languages(): array */ public function testSimpleVerifies(string $name, array $fixture): void { - $owid = Owid::fromBase64($fixture['simple']); + $owid = Fixtures::parseBase64($fixture['simple']); $this->assertSame('example', $owid->payloadAsString(), "$name simple payload"); $this->assertTrue( $owid->verifyWithPublicKey($fixture['spki']), @@ -69,7 +69,7 @@ public function testSimpleVerifies(string $name, array $fixture): void */ public function testUtf8Verifies(string $name, array $fixture): void { - $owid = Owid::fromBase64($fixture['utf8']); + $owid = Fixtures::parseBase64($fixture['utf8']); $this->assertSame( Fixtures::UTF8_PAYLOAD, $owid->payloadAsString(), @@ -91,8 +91,8 @@ public function testUtf8Verifies(string $name, array $fixture): void */ public function testChainVerifies(string $name, array $fixture): void { - $root = Owid::fromBase64($fixture['chain_root']); - $party = Owid::fromBase64($fixture['chain_party']); + $root = Fixtures::parseBase64($fixture['chain_root']); + $party = Fixtures::parseBase64($fixture['chain_party']); $this->assertSame('root', $root->payloadAsString()); $this->assertSame('party', $party->payloadAsString()); $this->assertTrue( @@ -118,16 +118,16 @@ public function testChainVerifies(string $name, array $fixture): void */ public function testTamperedFixturesFail(string $name, array $fixture): void { - $root = Owid::fromBase64($fixture['chain_root']); + $root = Fixtures::parseBase64($fixture['chain_root']); foreach (['simple', 'utf8', 'chain_root'] as $key) { - $owid = Owid::fromBase64($fixture[$key]); + $owid = Fixtures::parseBase64($fixture[$key]); $tampered = self::flipLastByte($owid); $this->assertFalse( $tampered->verifyWithPublicKey($fixture['spki']), "$name $key with a flipped byte should fail" ); } - $party = Owid::fromBase64($fixture['chain_party']); + $party = Fixtures::parseBase64($fixture['chain_party']); $tamperedParty = self::flipLastByte($party); $this->assertFalse( $tamperedParty->verifyWithPublicKey($fixture['spki'], [$root]), @@ -137,13 +137,15 @@ public function testTamperedFixturesFail(string $name, array $fixture): void /** * Returns a copy of the OWID with its last serialized byte, a signature - * byte, flipped. + * byte, flipped. The tampering is done to the serialized bytes and read + * back, because that is how tampering actually reaches a verifier, and + * because a signed OWID cannot be altered in memory. */ private static function flipLastByte(Owid $owid): Owid { $bytes = $owid->asByteArray(); $last = strlen($bytes) - 1; $bytes[$last] = chr(ord($bytes[$last]) ^ 0x01); - return Owid::fromByteArray($bytes); + return Fixtures::parseBytes($bytes); } } diff --git a/tests/CryptoTest.php b/tests/CryptoTest.php index 8dfc836..ff153a7 100644 --- a/tests/CryptoTest.php +++ b/tests/CryptoTest.php @@ -23,6 +23,7 @@ use PHPUnit\Framework\TestCase; use SwanCommunity\Owid\Crypto; use SwanCommunity\Owid\OwidException; +use SwanCommunity\Owid\SignatureStatus; /** * Tests the crypto operations, the DER to raw signature conversion, and the @@ -124,6 +125,53 @@ public function testEmptyPrivatePemGuard(): void } } + /** + * Key material that cannot be read answers with null rather than raising, + * so a caller handed an unusable key from a well known end point can + * report it as a fault in the key instead of as a forgery. + */ + public function testTryVerifyOnlyAnswersWithNull(): void + { + $unusable = ['', ' ', 'not a pem', "-----BEGIN PUBLIC KEY-----" . PHP_EOL]; + foreach ($unusable as $pem) { + $this->assertNull( + Crypto::tryVerifyOnly($pem), + 'unusable key material should not make an instance' + ); + } + + $crypto = Crypto::new(); + $this->assertNotNull(Crypto::tryVerifyOnly($crypto->publicKeyPem())); + } + + /** + * The status of a signature check keeps a signature that does not match + * apart from a check that could not be made, so an operational fault is + * never reported as an attack. + */ + public function testSignatureStatusSeparatesTheOutcomes(): void + { + $crypto = Crypto::new(); + $signature = $crypto->signByteArray(self::TEST_PAYLOAD); + $verifier = Crypto::newVerifyOnly($crypto->publicKeyPem()); + + $this->assertSame( + SignatureStatus::SignatureValid, + $verifier->signatureStatus(self::TEST_PAYLOAD, $signature) + ); + $this->assertSame( + SignatureStatus::SignatureInvalid, + $verifier->signatureStatus('other data', $signature) + ); + $this->assertSame( + SignatureStatus::InvalidSignatureLength, + $verifier->signatureStatus( + self::TEST_PAYLOAD, + substr($signature, 0, 63) + ) + ); + } + /** * A verify only instance can not sign. */ diff --git a/tests/DomainLengthTest.php b/tests/DomainLengthTest.php index e7fbeb3..def409c 100644 --- a/tests/DomainLengthTest.php +++ b/tests/DomainLengthTest.php @@ -26,6 +26,7 @@ use SwanCommunity\Owid\Io; use SwanCommunity\Owid\Owid; use SwanCommunity\Owid\OwidException; +use SwanCommunity\Owid\ParseStatus; use SwanCommunity\Owid\Version; /** @@ -46,8 +47,6 @@ final class DomainLengthTest extends TestCase * Returns a domain of the length given, built from labels of no more than * 63 characters separated by dots so it is shaped like a real name rather * than one long run of letters. - * - * @throws OwidException when the arithmetic below builds the wrong length. */ private static function domain(int $length): string { @@ -69,9 +68,8 @@ private static function domain(int $length): string * A version 3 envelope carrying the domain given, followed by a date, an * empty payload and the signature. The domain and its terminator are * appended here rather than through Io::writeString because these tests - * build domains the write side now refuses, and the point of them is - * what the read side does with such bytes when they arrive from - * somewhere else. + * build domains the write side refuses, and the point of them is what the + * read side does with such bytes when they arrive from somewhere else. */ private static function envelope(string $domain): string { @@ -84,19 +82,36 @@ private static function envelope(string $domain): string } /** - * Parses the bytes expecting a refusal and returns the message, so a test - * can check what the message names. Every refusal must use the library's - * own exception type, and a parse that is accepted fails the test. + * Reads the bytes expecting a refusal and returns the reason. Nothing may + * be raised, the read must report that it did not work, and no value may + * be handed back. A read that succeeds fails the test. */ - private function refusal(string $bytes, string $label): string + private function refusal(string $bytes, string $label): ParseStatus + { + $result = Owid::tryFromByteArray($bytes); + if ($result->ok) { + $this->fail("$label should have been refused"); + } + $this->assertNull($result->owid, "$label should hand back no value"); + return $result->status; + } + + /** + * Returns true when the action raises the library's exception naming the + * greatest number of characters a domain name can hold, which is how the + * write side reports the same bound the read side works to. + */ + private function refusalNamesMaximum(callable $action): bool { try { - Owid::fromByteArray($bytes); + $action(); } catch (OwidException $e) { - $this->addToAssertionCount(1); - return $e->getMessage(); + return str_contains( + $e->getMessage(), + "'" . OwidException::MAXIMUM_DOMAIN_LENGTH . "'" + ); } - $this->fail("$label should have been refused"); + return false; } /** @@ -108,7 +123,7 @@ public function testMaximumLengthDomainParses(): void $domain = self::domain(OwidException::MAXIMUM_DOMAIN_LENGTH); $bytes = self::envelope($domain); - $owid = Owid::fromByteArray($bytes); + $owid = Fixtures::parseBytes($bytes); $this->assertSame($domain, $owid->domain); $this->assertSame( @@ -120,30 +135,31 @@ public function testMaximumLengthDomainParses(): void /** * One character more than a domain name can hold is refused, even though - * the terminator is present, because the parse stops at the bound. + * the terminator is present, because the read stops at the bound and what + * runs past it cannot be a domain. */ public function testDomainOneOverMaximumIsRefused(): void { $domain = self::domain(OwidException::MAXIMUM_DOMAIN_LENGTH + 1); - $message = $this->refusal(self::envelope($domain), 'over long domain'); - - $maximum = OwidException::MAXIMUM_DOMAIN_LENGTH; - $this->assertStringContainsString("'$maximum'", $message); + $this->assertSame( + ParseStatus::InvalidDomainEncoding, + $this->refusal(self::envelope($domain), 'over long domain') + ); } /** - * Returns the seconds taken to refuse the bytes the number of times - * given, and puts the last refusal message into the reference given. + * Returns the seconds taken to refuse the bytes the number of times given, + * and puts the last reason into the reference given. */ private function timeRefusals( string $bytes, int $attempts, - string &$message + ?ParseStatus &$status ): float { $start = hrtime(true); for ($attempt = 0; $attempt < $attempts; $attempt++) { - $message = $this->refusal($bytes, 'unterminated domain'); + $status = $this->refusal($bytes, 'unterminated domain'); } return (hrtime(true) - $start) / 1e9; } @@ -153,26 +169,26 @@ private function timeRefusals( * cost that does not grow with the buffer. PHP cannot count the bytes a * single call examines, so the proof is the time taken, and it is taken * twice over buffers sixteen times apart so the result does not depend on - * how fast the machine is. A search running to the end of the buffer - * costs sixteen times as much on the larger one, whereas a search - * stopping at the bound costs the same on both, so the larger is required - * to stay inside four times the smaller. The small allowance added to - * that limit absorbs timer noise, because at the bound both runs take - * only a few thousandths of a second. A plain ceiling on the larger run - * is kept as well, in the style of the payload length checks. The peak - * memory during one refusal is held to 64 KiB above the level before it, - * where the runtime can reset its peak figure, which is PHP 8.2 and - * later, so nothing is sized by the run of characters either. + * how fast the machine is. A search running to the end of the buffer costs + * sixteen times as much on the larger one, whereas a search stopping at + * the bound costs the same on both, so the larger is required to stay + * inside four times the smaller. The small allowance added to that limit + * absorbs timer noise, because at the bound both runs take only a few + * thousandths of a second. A plain ceiling on the larger run is kept as + * well, in the style of the payload length checks. The peak memory during + * one refusal is held to 64 KiB above the level before it, where the + * runtime can reset its peak figure, which is PHP 8.2 and later, so + * nothing is sized by the run of characters either. */ public function testUnterminatedDomainIsRefusedWithoutReadingTheBuffer(): void { $prefix = chr(Version::Version3->asByte()); $small = $prefix . str_repeat('a', 1024 * 1024); $large = $prefix . str_repeat('a', 16 * 1024 * 1024); - $message = ''; + $status = null; - $smallSeconds = $this->timeRefusals($small, 1000, $message); - $largeSeconds = $this->timeRefusals($large, 1000, $message); + $smallSeconds = $this->timeRefusals($small, 1000, $status); + $largeSeconds = $this->timeRefusals($large, 1000, $status); $this->assertLessThan( 4 * $smallSeconds + 0.05, @@ -185,8 +201,7 @@ public function testUnterminatedDomainIsRefusedWithoutReadingTheBuffer(): void $largeSeconds, "unterminated domain took {$largeSeconds}s for 1,000 attempts" ); - $maximum = OwidException::MAXIMUM_DOMAIN_LENGTH; - $this->assertStringContainsString("'$maximum'", $message); + $this->assertSame(ParseStatus::InvalidDomainEncoding, $status); if (function_exists('memory_reset_peak_usage')) { $before = memory_get_usage(); memory_reset_peak_usage(); @@ -202,16 +217,19 @@ public function testUnterminatedDomainIsRefusedWithoutReadingTheBuffer(): void /** * A buffer holding only the version byte and characters filling the bound - * exactly, with no terminator after them, is refused rather than read as - * a domain, because the terminator has to be present within the bound and - * not merely absent from it. + * exactly, with no terminator after them, is data that stopped rather than + * a domain that cannot be valid, because everything read so far could + * still have been a domain had the buffer gone on. */ public function testDomainFillingTheBoundWithNoTerminatorIsRefused(): void { $bytes = chr(Version::Version3->asByte()) . str_repeat('a', OwidException::MAXIMUM_DOMAIN_LENGTH); - $this->refusal($bytes, 'domain filling the bound'); + $this->assertSame( + ParseStatus::UnexpectedEnd, + $this->refusal($bytes, 'domain filling the bound') + ); } /** @@ -224,106 +242,61 @@ public function testDomainFillingTheBoundWithNoTerminatorIsRefused(): void public function testCreatorRefusesDomainOverMaximum(): void { $domain = self::domain(OwidException::MAXIMUM_DOMAIN_LENGTH + 1); - $maximum = OwidException::MAXIMUM_DOMAIN_LENGTH; $crypto = Crypto::new(); - try { - new Creator($domain, $crypto); - $this->fail('over long creator domain should have been refused'); - } catch (OwidException $e) { - $this->assertStringContainsString("'$maximum'", $e->getMessage()); - } - - try { - Creator::fromConfiguration($domain, $crypto->privateKeyPem()); - $this->fail('over long configured domain should have been refused'); - } catch (OwidException $e) { - $this->assertStringContainsString("'$maximum'", $e->getMessage()); - } + $this->assertTrue($this->refusalNamesMaximum( + fn () => new Creator($domain, $crypto) + ), 'the creator should refuse the domain'); + $this->assertTrue($this->refusalNamesMaximum( + fn () => Creator::fromConfiguration($domain, $crypto->privateKeyPem()) + ), 'the configured creator should refuse the domain'); } /** - * The serialization refuses the same domain as well, so a value that - * reached the public domain field by a route other than the creator is - * still refused. The data the signature is calculated over is built the - * same way, so the refusal reaches that too. A domain of exactly the - * greatest length is written and parses back unchanged, so this is a - * refusal at the top of the range and nothing else. + * The write side refuses the same domain, so this library cannot produce + * bytes it would then refuse to read. The check is made through the write + * helper directly because no OWID can carry such a domain any more: one + * arrives only by being read, which stops at the bound, or by being + * created, where the creator refuses it. A domain of exactly the greatest + * length is written and read back unchanged, so this is a refusal at the + * top of the range and nothing else. */ public function testWriteRefusesDomainOverMaximum(): void { $maximum = OwidException::MAXIMUM_DOMAIN_LENGTH; - $owid = new Owid(); - $owid->payload = 'value'; - $owid->signature = str_repeat(chr(0x99), self::SIGNATURE_LENGTH); - $owid->domain = self::domain($maximum + 1); - foreach (['asByteArray', 'dataForCrypto'] as $method) { - try { - $owid->$method(); - $this->fail("$method should have refused the long domain"); - } catch (OwidException $e) { - $this->assertStringContainsString( - "'$maximum'", - $e->getMessage() - ); - } - } + $this->assertTrue($this->refusalNamesMaximum(function () use ($maximum) { + $buffer = ''; + Io::writeString($buffer, self::domain($maximum + 1)); + }), 'writing an over long domain should be refused'); - $owid->domain = self::domain($maximum); - $parsed = Owid::fromByteArray($owid->asByteArray()); - $this->assertSame($owid->domain, $parsed->domain); + $atBound = self::domain($maximum); + $parsed = Fixtures::parseBytes(self::envelope($atBound)); + $this->assertSame($atBound, $parsed->domain); } /** * The refusal happens before any signature is calculated. A creator is * refused before its crypto instance is looked at, which is shown by * handing the constructor an instance that can only verify, because a - * message naming the key rather than the maximum would mean the two - * checks ran the other way round. A domain that arrives on another OWID - * covered by the signature is refused while the data to sign is being - * built, which is before the signing key is used, so the signature field - * is left holding exactly what it held before. + * message naming the key rather than the maximum would mean the two checks + * ran the other way round. */ public function testRefusalHappensBeforeAnySignature(): void { $domain = self::domain(OwidException::MAXIMUM_DOMAIN_LENGTH + 1); - $maximum = OwidException::MAXIMUM_DOMAIN_LENGTH; $crypto = Crypto::new(); $verifyOnly = Crypto::newVerifyOnly($crypto->publicKeyPem()); - try { - new Creator($domain, $verifyOnly); - $this->fail('over long creator domain should have been refused'); - } catch (OwidException $e) { - $this->assertStringContainsString("'$maximum'", $e->getMessage()); - } - - $other = new Owid(); - $other->domain = $domain; - $other->payload = 'other'; - $other->signature = str_repeat(chr(0x11), self::SIGNATURE_LENGTH); - $owid = new Owid(); - $owid->payload = 'value'; - $owid->signature = str_repeat(chr(0x22), self::SIGNATURE_LENGTH); - - try { - (new Creator('51d.es', $crypto))->signWithOthers($owid, [$other]); - $this->fail('over long domain on another OWID should be refused'); - } catch (OwidException $e) { - $this->assertStringContainsString("'$maximum'", $e->getMessage()); - } - $this->assertSame( - str_repeat(chr(0x22), self::SIGNATURE_LENGTH), - $owid->signature, - 'the signature should not have been calculated' - ); + $this->assertTrue($this->refusalNamesMaximum( + fn () => new Creator($domain, $verifyOnly) + ), 'the domain should be refused before the key is looked at'); } /** * What the library itself signs still parses and still verifies, with an - * ordinary domain and with one of the greatest length, so the bound is - * not retrospective on anything real. + * ordinary domain and with one of the greatest length, so the bound is not + * retrospective on anything real. */ public function testLibraryOutputParses(): void { @@ -334,9 +307,9 @@ public function testLibraryOutputParses(): void foreach ($domains as $domain) { $crypto = Crypto::new(); $creator = new Creator($domain, $crypto); - $original = $creator->signString('value'); + $original = $creator->create('value'); - $parsed = Owid::fromByteArray($original->asByteArray()); + $parsed = Fixtures::parseBytes($original->asByteArray()); $this->assertSame($domain, $parsed->domain); $this->assertTrue( diff --git a/tests/Fixtures.php b/tests/Fixtures.php index 832397e..371c2ce 100644 --- a/tests/Fixtures.php +++ b/tests/Fixtures.php @@ -20,6 +20,9 @@ namespace SwanCommunity\Owid\Tests; +use RuntimeException; +use SwanCommunity\Owid\Owid; + /** * Shared test vectors. The canonical wire vectors prove the reader and writer * match the wire format. The cross language fixtures hold real signatures @@ -28,6 +31,37 @@ */ final class Fixtures { + /** + * Reads the base 64 OWID given, which a test expects to be well formed, + * and fails the run with the reason when it is not. Tests that are about + * a parse failing read the result themselves and assert its status. + */ + public static function parseBase64(string $value): Owid + { + $result = Owid::tryFromBase64($value); + if (!$result->ok) { + throw new RuntimeException( + 'fixture did not parse: ' . $result->status->value + ); + } + return $result->owid; + } + + /** + * Reads the bytes given, which a test expects to be one whole OWID, and + * fails the run with the reason when they are not. + */ + public static function parseBytes(string $bytes): Owid + { + $result = Owid::tryFromByteArray($bytes); + if (!$result->ok) { + throw new RuntimeException( + 'bytes did not parse: ' . $result->status->value + ); + } + return $result->owid; + } + /** * The CREATOR canonical wire vector. Version 2, domain 51db.uk, payload * length 341, date 664619 minutes after the base date. Unpadded base 64. diff --git a/tests/IoTest.php b/tests/IoTest.php index 7d74257..f221127 100644 --- a/tests/IoTest.php +++ b/tests/IoTest.php @@ -27,23 +27,52 @@ use SwanCommunity\Owid\Version; /** - * Tests the low level binary read and write helpers. + * Tests the low level write helpers and the base date the format counts from. + * + * Each field is written and then read back through a complete envelope, + * because reading is done by the parser in Owid rather than by a helper here, + * and reading one field on its own is not something a caller can do. */ final class IoTest extends TestCase { + /** The length of the fixed tail every valid OWID ends with. */ + private const SIGNATURE_LENGTH = 64; + /** - * A date written and read with the version 2 encoding keeps the same - * minute count. + * An envelope of the version given, carrying the domain, date and payload + * given, so a written field can be read back the way a caller reads one. */ - public function testDateRoundTripVersion2(): void + private static function envelope( + Version $version, + string $domain, + DateTimeImmutable $date, + string $payload + ): string { + $buffer = ''; + Io::writeByte($buffer, $version->asByte()); + Io::writeString($buffer, $domain); + Io::writeDate($buffer, $date, $version); + Io::writeByteArray($buffer, $payload); + return $buffer . str_repeat("\x99", self::SIGNATURE_LENGTH); + } + + /** + * A date written and read with the version 3 encoding keeps the same + * minute count in four bytes. + */ + public function testDateRoundTripVersion3(): void { $date = new DateTimeImmutable('now'); $buffer = ''; - Io::writeDate($buffer, $date, Version::Version2); - $this->assertSame(4, strlen($buffer), 'version 2 uses four bytes'); - $result = (new Io($buffer))->readDate(Version::Version2); + Io::writeDate($buffer, $date, Version::Version3); + $this->assertSame(4, strlen($buffer), 'version 3 uses four bytes'); + + $owid = Fixtures::parseBytes( + self::envelope(Version::Version3, 'example.com', $date, '') + ); + $expected = intdiv($date->getTimestamp() - Io::BASE_TIMESTAMP, 60); - $actual = intdiv($result->getTimestamp() - Io::BASE_TIMESTAMP, 60); + $actual = intdiv($owid->date->getTimestamp() - Io::BASE_TIMESTAMP, 60); $this->assertSame($expected, $actual, 'should keep the same minute count'); } @@ -57,10 +86,14 @@ public function testDateRoundTripVersion1(): void $buffer = ''; Io::writeDate($buffer, $date, Version::Version1); $this->assertSame(2, strlen($buffer), 'version 1 uses two bytes'); - $result = (new Io($buffer))->readDate(Version::Version1); + + $owid = Fixtures::parseBytes( + self::envelope(Version::Version1, 'example.com', $date, '') + ); + $this->assertSame( $date->format('Y-m-d H:i'), - $result->format('Y-m-d H:i'), + $owid->date->format('Y-m-d H:i'), 'should keep hour granularity' ); } @@ -83,9 +116,20 @@ public function testStringRoundTrip(): void { $buffer = ''; Io::writeString($buffer, 'example.com'); - $this->assertSame("\x00", $buffer[strlen($buffer) - 1], 'should be null terminated'); - $result = (new Io($buffer))->readString(); - $this->assertSame('example.com', $result); + $this->assertSame( + "\x00", + $buffer[strlen($buffer) - 1], + 'should be null terminated' + ); + + $owid = Fixtures::parseBytes(self::envelope( + Version::Version3, + 'example.com', + new DateTimeImmutable('now'), + '' + )); + + $this->assertSame('example.com', $owid->domain); } /** @@ -106,7 +150,6 @@ public function testUint32LittleEndian(): void $buffer = ''; Io::writeUint32($buffer, 0x0A242B01); $this->assertSame("\x01\x2B\x24\x0A", $buffer, 'should be little endian'); - $this->assertSame(0x0A242B01, (new Io($buffer))->readUint32(), 'should round trip'); } /** @@ -117,19 +160,27 @@ public function testByteArrayRoundTrip(): void $payload = "\x01\x02\x03\x04\x05"; $buffer = ''; Io::writeByteArray($buffer, $payload); - $reader = new Io($buffer); - $this->assertSame($payload, $reader->readByteArray()); + $this->assertSame("\x05\x00\x00\x00" . $payload, $buffer); + + $owid = Fixtures::parseBytes(self::envelope( + Version::Version3, + 'example.com', + new DateTimeImmutable('now'), + $payload + )); + + $this->assertSame($payload, $owid->payload); } /** - * Reading past the end of the buffer raises an error. + * A signature that is not the fixed length can not be written, so the + * library cannot produce an envelope whose tail it would then refuse. */ - public function testReadPastEndRejected(): void + public function testSignatureLengthEnforcedOnWrite(): void { - $reader = new Io("\x01"); - $reader->readByte(); + $buffer = ''; $this->expectException(OwidException::class); - $reader->readByte(); + Io::writeSignature($buffer, str_repeat("\x99", 63)); } /** diff --git a/tests/OwidTest.php b/tests/OwidTest.php index 422f55a..9970cb5 100644 --- a/tests/OwidTest.php +++ b/tests/OwidTest.php @@ -24,6 +24,7 @@ use SwanCommunity\Owid\Crypto; use SwanCommunity\Owid\Creator; use SwanCommunity\Owid\Owid; +use SwanCommunity\Owid\ParseStatus; use SwanCommunity\Owid\Version; /** @@ -40,13 +41,13 @@ public function testSignAndSelfVerify(): void { $crypto = Crypto::new(); $creator = new Creator('example.com', $crypto); - $owid = $creator->signString('Hello World'); + $owid = $creator->create('Hello World'); $this->assertSame('example.com', $owid->domain); $this->assertSame(Version::Version3, $owid->version); $this->assertSame(64, strlen($owid->signature)); $this->assertTrue($owid->verifyWithCrypto($crypto), 'should verify with crypto'); - $copy = Owid::fromBase64($owid->asBase64()); + $copy = Fixtures::parseBase64($owid->asBase64()); $this->assertSame($owid->payload, $copy->payload); $this->assertTrue( $copy->verifyWithPublicKey($crypto->publicKeyPem()), @@ -55,20 +56,27 @@ public function testSignAndSelfVerify(): void } /** - * A tampered copy of a locally signed OWID fails to verify. + * A signed OWID whose serialized bytes are tampered with still reads back + * as a structurally valid OWID and then fails to verify. The tampering is + * done to the bytes rather than to the OWID because the fields are read + * only, and because bytes are how tampering actually reaches a verifier. */ - public function testTamperedCopyFails(): void + public function testTamperedBytesParseThenFailVerification(): void { $crypto = Crypto::new(); $creator = new Creator('example.com', $crypto); - $owid = $creator->signString('Hello World'); + $owid = $creator->create('Hello World'); $bytes = $owid->asByteArray(); $last = strlen($bytes) - 1; $bytes[$last] = chr(ord($bytes[$last]) ^ 0xFF); - $tampered = Owid::fromByteArray($bytes); + + $result = Owid::tryFromByteArray($bytes); + + $this->assertTrue($result->ok, 'flipping a signature byte leaves the envelope readable'); + $this->assertSame(ParseStatus::Parsed, $result->status); $this->assertFalse( - $tampered->verifyWithCrypto($crypto), - 'tampered OWID should not verify' + $result->owid->verifyWithCrypto($crypto), + 'and the signature is then found not to match' ); } @@ -80,10 +88,8 @@ public function testSignedChainVerifies(): void { $crypto = Crypto::new(); $creator = new Creator('example.com', $crypto); - $root = $creator->signString('root'); - $party = new Owid(); - $party->payload = 'party'; - $creator->signWithOthers($party, [$root]); + $root = $creator->create('root'); + $party = $creator->create('party', [$root]); $this->assertTrue($root->verifyWithCrypto($crypto), 'root verifies alone'); $this->assertTrue( @@ -101,8 +107,9 @@ public function testSignedChainVerifies(): void */ public function testPayloadAccessors(): void { - $owid = new Owid(); - $owid->payload = "\x01\x03"; + $owid = (new Creator('example.com', Crypto::new())) + ->create("\x01\x03"); + $this->assertSame("\x01\x03", $owid->payloadAsString()); $this->assertSame('0103', $owid->payloadAsPrintable()); $this->assertSame('AQM=', $owid->payloadAsBase64()); @@ -115,8 +122,8 @@ public function testUtf8PayloadRoundTrip(): void { $crypto = Crypto::new(); $creator = new Creator('example.com', $crypto); - $owid = $creator->signString(Fixtures::UTF8_PAYLOAD); - $copy = Owid::fromBase64($owid->asBase64()); + $owid = $creator->create(Fixtures::UTF8_PAYLOAD); + $copy = Fixtures::parseBase64($owid->asBase64()); $this->assertSame(Fixtures::UTF8_PAYLOAD, $copy->payloadAsString()); $this->assertTrue($copy->verifyWithCrypto($crypto)); } @@ -128,7 +135,7 @@ public function testToStringIsBase64(): void { $crypto = Crypto::new(); $creator = new Creator('example.com', $crypto); - $owid = $creator->signString('value'); + $owid = $creator->create('value'); $this->assertSame($owid->asBase64(), (string) $owid); } @@ -139,7 +146,7 @@ public function testAgeMinutes(): void { $crypto = Crypto::new(); $creator = new Creator('example.com', $crypto); - $owid = $creator->signString('value'); + $owid = $creator->create('value'); $this->assertGreaterThanOrEqual(0, $owid->ageMinutes()); $this->assertLessThanOrEqual(1, $owid->ageMinutes()); } diff --git a/tests/ParseContractTest.php b/tests/ParseContractTest.php new file mode 100644 index 0000000..05a17aa --- /dev/null +++ b/tests/ParseContractTest.php @@ -0,0 +1,716 @@ +create("\x01\x02\x03"); + + $result = Owid::tryFromByteArray($owid->asByteArray()); + + $this->assertTrue($result->ok); + $this->assertInstanceOf(Owid::class, $result->owid); + $this->assertSame(ParseStatus::Parsed, $result->status); + $this->assertSame("\x01\x02\x03", $result->owid->payload); + } + + /** + * The base 64 surface reports the same three facts, so a caller reading an + * encoded identifier is not handed a different contract. + */ + public function testBase64SuccessReportsAllThreeFacts(): void + { + $owid = self::creator()->create('value'); + + $result = Owid::tryFromBase64($owid->asBase64()); + + $this->assertTrue($result->ok); + $this->assertInstanceOf(Owid::class, $result->owid); + $this->assertSame(ParseStatus::Parsed, $result->status); + $this->assertSame('value', $result->owid->payloadAsString()); + } + + /** + * Base 64 with and without the trailing padding both read, because an + * encoded OWID is carried both ways and refusing the unpadded form would + * reject a normal way of holding one. + */ + public function testPaddedAndUnpaddedBase64BothRead(): void + { + $padded = self::creator()->create('value')->asBase64(); + $unpadded = rtrim($padded, '='); + + $this->assertTrue(Owid::tryFromBase64($padded)->ok); + $this->assertTrue(Owid::tryFromBase64($unpadded)->ok); + } + + /** + * An empty payload reads. Having nothing to say is allowed, because the + * payload is what the creator had to say and an OWID carrying nothing is + * still an OWID. + */ + public function testEmptyPayloadParses(): void + { + $owid = self::creator()->create(''); + + $result = Owid::tryFromByteArray($owid->asByteArray()); + + $this->assertTrue($result->ok, $result->status->value); + $this->assertSame('', $result->owid->payload); + } + + /** + * A megabyte reads. The format's limit is the wire format's, and how much + * an application will accept is that application's policy rather than + * something this library decides for it. + */ + public function testOneMebibytePayloadParses(): void + { + $payload = str_repeat("\x5A", 1024 * 1024); + $owid = self::creator()->create($payload); + + $result = Owid::tryFromByteArray($owid->asByteArray()); + + $this->assertTrue($result->ok, $result->status->value); + $this->assertSame(strlen($payload), strlen($result->owid->payload)); + } + + /** + * Absent input is reported as nothing having been supplied, on both + * surfaces, and nothing is handed back. + */ + public function testAbsentInputIsMissingInput(): void + { + foreach ([null, ''] as $value) { + foreach (['tryFromBase64', 'tryFromByteArray'] as $method) { + $result = Owid::$method($value); + $this->assertFalse($result->ok); + $this->assertNull($result->owid); + $this->assertSame(ParseStatus::MissingInput, $result->status); + } + } + } + + /** + * Input that is not text at all is reported as the wrong sort of input + * rather than raising a type error. A repeated query parameter with + * brackets reaches a PHP application as an array, so a caller passing on + * what it was given is not necessarily passing on a string. + */ + public function testNonStringInputIsInvalidInputType(): void + { + foreach ([['a'], 5, 1.5, true, new \stdClass()] as $value) { + $result = Owid::tryFromBase64($value); + $this->assertFalse($result->ok); + $this->assertNull($result->owid); + $this->assertSame(ParseStatus::InvalidInputType, $result->status); + } + } + + /** + * Invalid base 64 is reported and not raised. + */ + public function testInvalidBase64IsReported(): void + { + foreach (['not base 64 at all!!', '####', 'AAAAA'] as $value) { + $result = Owid::tryFromBase64($value); + $this->assertFalse($result->ok, "'$value' should not read"); + $this->assertNull($result->owid); + $this->assertSame(ParseStatus::InvalidBase64, $result->status); + } + } + + /** + * A version byte this implementation does not know is reported, and no + * fields after it are read. + */ + public function testUnsupportedVersionIsReported(): void + { + $bytes = self::creator()->create('x')->asByteArray(); + $bytes[0] = chr(9); + + $result = Owid::tryFromByteArray($bytes); + + $this->assertFalse($result->ok); + $this->assertNull($result->owid); + $this->assertSame(ParseStatus::UnsupportedVersion, $result->status); + } + + /** + * One byte after a complete envelope is a disagreement between the + * declared payload and the bytes that follow it, because the declared + * payload no longer leaves exactly the signature the version requires. + */ + public function testTrailingByteIsByteCountMismatch(): void + { + $bytes = self::creator()->create('x')->asByteArray() . "\x00"; + + $result = Owid::tryFromByteArray($bytes); + + $this->assertFalse($result->ok); + $this->assertNull($result->owid); + $this->assertSame(ParseStatus::ByteCountMismatch, $result->status); + } + + /** + * Data that stops inside a field, before the payload length is even read, + * is data that ended early. Each cut is inside a different field. + */ + public function testDataStoppingInsideTheEnvelopeEndsEarly(): void + { + $bytes = self::creator()->create('x')->asByteArray(); + // Inside the domain, inside the date, and inside the payload length, + // which are the three fields read before the count check. + foreach ([5, strlen('example.com') + 3, strlen('example.com') + 8] as $cut) { + $result = Owid::tryFromByteArray(substr($bytes, 0, $cut)); + + $this->assertFalse($result->ok, "a cut at $cut should not read"); + $this->assertNull($result->owid); + $this->assertSame( + ParseStatus::UnexpectedEnd, + $result->status, + "a cut at $cut should end early" + ); + } + } + + /** + * An OWID cannot be built by a caller. The constructor is private, which + * the engine enforces, so the two routes in the documentation are the only + * two there are. Without this an unsigned OWID could be handed to code + * that cannot tell the difference. + */ + public function testConstructionFromOutsideIsImpossible(): void + { + $constructor = (new ReflectionClass(Owid::class))->getConstructor(); + $this->assertNotNull($constructor); + $this->assertTrue( + $constructor->isPrivate(), + 'the constructor must not be reachable from outside' + ); + + $this->expectException(Error::class); + /** @phpstan-ignore-next-line the point of the test is that this fails */ + new Owid(); + } + + /** + * No field can be set or rebound from outside. Every declared property is + * read only, which the engine enforces, so an OWID always says what was + * read or signed. + */ + public function testNoFieldCanBeRebound(): void + { + $owid = self::creator()->create('abc'); + $properties = (new ReflectionClass(Owid::class))->getProperties(); + $this->assertNotEmpty($properties); + foreach ($properties as $property) { + $this->assertTrue( + $property->isReadOnly(), + $property->getName() . ' must be read only' + ); + } + + foreach (['version', 'domain', 'date', 'payload', 'signature'] as $field) { + try { + $owid->$field = null; + $this->fail("$field should not be assignable"); + } catch (Error $e) { + $this->assertStringContainsString('readonly', $e->getMessage()); + } + } + } + + /** + * Writing into the payload a caller was handed does not alter the OWID. A + * PHP string is a value rather than a reference, so what the caller holds + * is its own copy and there is nothing to defend against by copying again. + */ + public function testWritingIntoAReturnedPayloadDoesNotAlterTheOwid(): void + { + $owid = self::creator()->create('abc'); + + $payload = $owid->payload; + $payload[0] = 'z'; + $signature = $owid->signature; + $signature[0] = 'z'; + + $this->assertSame('abc', $owid->payload); + $this->assertSame('zbc', $payload); + $this->assertNotSame($signature, $owid->signature); + } + + /** + * A created OWID always carries a signature, so there is no state in which + * one exists unsigned. + */ + public function testCreatedOwidIsAlwaysSigned(): void + { + $crypto = Crypto::new(); + $owid = (new Creator('example.com', $crypto))->create('value'); + + $this->assertSame(64, strlen($owid->signature)); + $this->assertTrue($owid->verifyWithCrypto($crypto)); + } + + /** + * A structurally valid identifier whose signature does not match reads, + * and then fails verification. Two questions with two answers, because + * whether the bytes form an OWID and whether the signature is genuine are + * asked and answered separately. + */ + public function testValidStructureWithBadSignatureParsesThenFailsToVerify(): void + { + $crypto = Crypto::new(); + $owid = (new Creator('example.com', $crypto))->create("\x04\x05\x06"); + $bytes = $owid->asByteArray(); + $last = strlen($bytes) - 1; + $bytes[$last] = chr(ord($bytes[$last]) ^ 0xFF); + + $result = Owid::tryFromByteArray($bytes); + + $this->assertTrue( + $result->ok, + 'flipping a signature byte leaves the envelope readable' + ); + $this->assertSame(ParseStatus::Parsed, $result->status); + $this->assertFalse($result->owid->verifyWithCrypto($crypto)); + $this->assertSame( + SignatureStatus::SignatureInvalid, + $result->owid->signatureStatusWithCrypto($crypto) + ); + } + + /** + * A key that cannot be read is reported as a fault in the key and never as + * a signature that does not match, because the identifier may be perfectly + * good and only the key material wrong. On 30 August 2026 the key end + * points served PEM a strict parser rejects, and every verification + * against it failed while the keys and the identifiers were both fine. + */ + public function testUnreadableKeyIsNotAnInvalidSignature(): void + { + $owid = self::creator()->create('value'); + + foreach (['', ' ', 'not a pem', "-----BEGIN PUBLIC KEY-----\nx\n"] as $pem) { + $status = $owid->signatureStatus($pem); + + $this->assertSame( + SignatureStatus::InvalidKey, + $status, + 'unreadable key material must not read as a forgery' + ); + } + } + + /** + * Nothing is verified during a failed read. There is no value to check a + * signature on, and the reading code names neither the crypto class nor + * any openssl call, so a failure cannot reach one. + */ + public function testNoVerificationHappensDuringAFailedParse(): void + { + $bytes = self::creator()->create('x')->asByteArray(); + $bytes[0] = chr(9); + + $result = Owid::tryFromByteArray($bytes); + + $this->assertFalse($result->ok); + $this->assertNull( + $result->owid, + 'no value means nothing exists on which to check a signature' + ); + + $method = new ReflectionMethod(Owid::class, 'parse'); + $source = file(__DIR__ . '/../src/Owid.php'); + $body = implode('', array_slice( + $source, + $method->getStartLine() - 1, + $method->getEndLine() - $method->getStartLine() + 1 + )); + // Calls rather than words, so that prose about verification in the + // comments does not read as a call to it. + foreach (['Crypto::', 'openssl_', '->verify', '::verify'] as $call) { + $this->assertStringNotContainsString( + $call, + $body, + "reading must not reach $call" + ); + } + } + + /** + * This library never fetches a key, so no read can cause a request. The + * source is scanned for the ways PHP reaches the network, which is a + * stronger statement than any single test of the parse path. + */ + public function testNothingInTheLibraryReachesTheNetwork(): void + { + $calls = [ + 'curl_init', + 'file_get_contents', + 'fopen', + 'fsockopen', + 'stream_socket_client', + 'stream_context_create', + ]; + foreach (glob(__DIR__ . '/../src/*.php') as $file) { + $source = file_get_contents($file); + foreach ($calls as $call) { + $this->assertStringNotContainsString( + $call . '(', + $source, + basename($file) . " must not call $call" + ); + } + } + } + + /** + * The marker for a node that is absent never hands back an OWID, on + * either contract. It is the one value that carries no signature, so + * reading one as an identifier would hand calling code exactly what the + * construction boundary exists to prevent, and it can never verify because + * it carries no fields either. + * + * It is reported as itself rather than as an unknown version, because + * version 0 is supported and meaningful, and rather than as a malformed + * frame, because a caller walking a run of frames has to be able to tell + * an absent node from a frame it cannot read. Its one byte is counted so + * that the same arithmetic walks over it. + */ + public function testTheAbsentOwidMarkerIsRefusedAsAWholeBuffer(): void + { + $marker = "\x00"; + + foreach (['tryFromByteArray', 'tryFromBase64'] as $method) { + $value = $method === 'tryFromBase64' ? base64_encode($marker) : $marker; + $result = Owid::$method($value); + + $this->assertFalse($result->ok, "$method should hand back no OWID"); + $this->assertNull($result->owid); + $this->assertSame(ParseStatus::AbsentNode, $result->status); + } + + $framed = Owid::tryFromFrame( + $marker . self::creator()->create('x')->asByteArray() + ); + $this->assertFalse($framed->ok, 'an absent node is not a value'); + $this->assertNull($framed->owid); + $this->assertSame(ParseStatus::AbsentNode, $framed->status); + $this->assertSame(1, $framed->consumed, 'the marker byte is counted'); + } + + /** + * A caller walking a run of frames steps over an absent node and reads the + * identifiers around it, which is the whole point of reporting the marker + * rather than refusing it. + */ + public function testAFrameWalkStepsOverAnAbsentNode(): void + { + $creator = self::creator(); + $first = $creator->create('first'); + $second = $creator->create('second'); + $buffer = $first->asByteArray(); + Owid::emptyToBuffer($buffer); + $buffer .= $second->asByteArray(); + + $payloads = []; + $absent = 0; + $offset = 0; + while ($offset < strlen($buffer)) { + $frame = Owid::tryFromFrame($buffer, $offset); + if ($frame->status === ParseStatus::AbsentNode) { + $absent += 1; + } elseif ($frame->ok) { + $payloads[] = $frame->owid->payloadAsString(); + } else { + $this->fail('the walk should not meet a malformed frame'); + } + $offset += $frame->consumed; + } + + $this->assertSame(['first', 'second'], $payloads); + $this->assertSame(1, $absent); + $this->assertSame(strlen($buffer), $offset); + } + + /** + * A buffer of no bytes is nothing having been supplied rather than data + * that stopped part way through a field. Base 64 that is only whitespace + * decodes to no bytes at all and is reported the same way. + */ + public function testAZeroLengthBufferIsMissingInput(): void + { + $this->assertSame( + ParseStatus::MissingInput, + Owid::tryFromByteArray('')->status + ); + $this->assertSame( + ParseStatus::MissingInput, + Owid::tryFromBase64("\n\n\n\n")->status, + 'base 64 that decodes to no bytes supplied nothing' + ); + } + + /** + * A framed read whose declared payload runs past the bytes supplied is + * data stopping early rather than a declaration disagreeing with data that + * is all present. A caller reading from a source that is still arriving + * has to be able to tell waiting for more bytes from giving up, and those + * are different answers. The disagreement is only meaningful on the whole + * buffer contract, where every byte is present by definition. + */ + public function testAShortFrameEndsEarlyRatherThanDisagreeing(): void + { + $bytes = self::creator()->create(str_repeat('p', 40))->asByteArray(); + $cuts = [ + 'one byte short of the signature' => strlen($bytes) - 1, + 'no signature at all' => strlen($bytes) - 64, + 'half the payload missing' => strlen($bytes) - 84, + ]; + + foreach ($cuts as $label => $cut) { + $framed = Owid::tryFromFrame(substr($bytes, 0, $cut)); + $this->assertFalse($framed->ok, "$label should not read"); + $this->assertNull($framed->owid); + $this->assertSame( + ParseStatus::UnexpectedEnd, + $framed->status, + "$label is data that stopped early" + ); + + $whole = Owid::tryFromByteArray(substr($bytes, 0, $cut)); + $this->assertSame( + ParseStatus::ByteCountMismatch, + $whole->status, + "$label on the whole buffer contract is a disagreement" + ); + } + } + + /** + * One example of each reason a read can report, so that the test below can + * require every one of them to be either produced here or named as one + * this implementation cannot reach. + * + * @return array + */ + private function parseStatusExamples(): array + { + $bytes = self::creator()->create('x')->asByteArray(); + $unknownVersion = $bytes; + $unknownVersion[0] = chr(9); + // A domain running past the greatest number of characters a domain + // name can hold, with its terminator beyond the bound. + $longDomain = chr(3) . str_repeat('a', 254) . "\x00" . + str_repeat('b', 80); + + return [ + ParseStatus::Parsed->value => + fn () => Owid::tryFromByteArray($bytes), + ParseStatus::AbsentNode->value => + fn () => Owid::tryFromByteArray("\x00"), + ParseStatus::MissingInput->value => + fn () => Owid::tryFromByteArray(''), + ParseStatus::InvalidInputType->value => + fn () => Owid::tryFromBase64(['not text']), + ParseStatus::InvalidBase64->value => + fn () => Owid::tryFromBase64('not base 64 at all!!'), + ParseStatus::UnsupportedVersion->value => + fn () => Owid::tryFromByteArray($unknownVersion), + ParseStatus::UnexpectedEnd->value => + fn () => Owid::tryFromByteArray(substr($bytes, 0, 5)), + ParseStatus::InvalidDomainEncoding->value => + fn () => Owid::tryFromByteArray($longDomain), + ParseStatus::ByteCountMismatch->value => + fn () => Owid::tryFromByteArray($bytes . "\x00"), + ]; + } + + /** + * Every reason a read can report is either produced by a test or named as + * one this implementation cannot reach, with the reason given on the enum + * member itself. A status that is neither fails this test, so a reason + * cannot be added and left silently untested. + */ + public function testEveryParseStatusIsReachedOrNamedUnreachable(): void + { + $unreachable = [ + ParseStatus::ImplementationCapacityExceeded->value, + ParseStatus::MalformedEnvelope->value, + ]; + $examples = $this->parseStatusExamples(); + + foreach ($examples as $name => $example) { + $result = $example(); + $this->assertSame( + $name, + $result->status->value, + "the example for $name should report it" + ); + $this->assertSame( + $result->status === ParseStatus::Parsed, + $result->ok, + "$name should agree with whether the read worked" + ); + } + + $this->assertSame( + [], + array_intersect(array_keys($examples), $unreachable), + 'a status cannot be both reached and unreachable' + ); + $covered = array_merge(array_keys($examples), $unreachable); + sort($covered); + $all = array_map( + static fn (ParseStatus $status): string => $status->value, + ParseStatus::cases() + ); + sort($all); + $this->assertSame( + $all, + $covered, + 'every parse status needs a test or a reason it cannot be reached' + ); + } + + /** + * One example of each outcome of asking whether a signature is genuine. + * + * @return array + */ + private function signatureStatusExamples(): array + { + $crypto = Crypto::new(); + $owid = (new Creator('example.com', $crypto))->create('value'); + $tampered = $owid->asByteArray(); + $last = strlen($tampered) - 1; + $tampered[$last] = chr(ord($tampered[$last]) ^ 0xFF); + + return [ + SignatureStatus::SignatureValid->value => + fn () => $owid->signatureStatusWithCrypto($crypto), + SignatureStatus::SignatureInvalid->value => + fn () => Owid::tryFromByteArray($tampered) + ->owid->signatureStatusWithCrypto($crypto), + SignatureStatus::InvalidSignatureLength->value => + fn () => $crypto->signatureStatus( + 'data', + str_repeat("\x00", 63) + ), + SignatureStatus::InvalidKey->value => + fn () => $owid->signatureStatus('not a pem'), + ]; + } + + /** + * Every signature outcome is either produced by a test or named as one + * this implementation cannot reach, for the same reason as the parse + * statuses above. + */ + public function testEverySignatureStatusIsReachedOrNamedUnreachable(): void + { + // This library never fetches a key, and it verifies data already held + // in memory, so the first two cannot arise here. The reason for the + // third is on the member itself: every OWID is either read or created, + // both routes bound every field, and the marker for an absent node is + // no longer handed to anyone, so nothing a caller can hold fails to be + // written into the data a signature covers. + $unreachable = [ + SignatureStatus::KeyUnavailable->value, + SignatureStatus::ImplementationCapacityExceeded->value, + SignatureStatus::VerificationError->value, + ]; + $examples = $this->signatureStatusExamples(); + + foreach ($examples as $name => $example) { + $this->assertSame( + $name, + $example()->value, + "the example for $name should report it" + ); + } + + $covered = array_merge(array_keys($examples), $unreachable); + sort($covered); + $all = array_map( + static fn (SignatureStatus $status): string => $status->value, + SignatureStatus::cases() + ); + sort($all); + $this->assertSame( + $all, + $covered, + 'every signature status needs a test or a reason it cannot be reached' + ); + } + + /** + * The properties a caller reads are the ones the parser filled, so the + * result of a read cannot be quietly changed into saying something else. + */ + public function testResultFieldsAreReadOnly(): void + { + $result = Owid::tryFromByteArray(self::creator()->create('x')->asByteArray()); + + foreach (['ok', 'owid', 'status', 'consumed'] as $field) { + try { + $result->$field = null; + $this->fail("$field should not be assignable"); + } catch (Error $e) { + $this->assertStringContainsString('readonly', $e->getMessage()); + } + } + } +} diff --git a/tests/PayloadLengthTest.php b/tests/PayloadLengthTest.php index 1b302b2..7cd2f98 100644 --- a/tests/PayloadLengthTest.php +++ b/tests/PayloadLengthTest.php @@ -26,13 +26,14 @@ use SwanCommunity\Owid\Io; use SwanCommunity\Owid\Owid; use SwanCommunity\Owid\OwidException; +use SwanCommunity\Owid\ParseStatus; use SwanCommunity\Owid\Version; /** * The payload length field of an OWID is whatever the sender declared, so - * parsing must check it against the bytes present before sizing anything by - * it. These tests prove that a declared length that does not leave a complete - * signature after the payload is refused, that refusing it costs nothing + * reading must check it against the bytes present before sizing anything by + * it. These tests prove that a declared length which does not leave exactly + * the signature after the payload is refused, that refusing it costs nothing * sized by the declared number, and that a correctly sized envelope still * parses. The 64 byte signature is the fixed tail every valid OWID ends with. */ @@ -45,9 +46,9 @@ final class PayloadLengthTest extends TestCase /** * A version 3 envelope, being the version byte, the domain with its - * terminator, four minute bytes, the declared payload length, the - * payload bytes given and the signature bytes given, so a test can make - * the declared length and the bytes present disagree. + * terminator, four minute bytes, the declared payload length, the payload + * bytes given and the signature bytes given, so a test can make the + * declared length and the bytes present disagree. */ private static function envelope( int $declaredLength, @@ -74,29 +75,30 @@ private static function signature( } /** - * Parses the bytes expecting a refusal and returns the message, so a - * test can check what the message names. Every refusal must use the - * library's own exception type, and a parse that is accepted fails the - * test. + * Reads the bytes expecting a refusal and returns the reason, so a test + * can say which of the expected problems it was. Every part of a refusal + * is asserted here, being that nothing was raised, that the read reports + * it did not work, and that no value was handed back. A read that succeeds + * fails the test. */ - private function refusal(string $bytes, string $label): string + private function refusal(string $bytes, string $label): ParseStatus { - try { - Owid::fromByteArray($bytes); - } catch (OwidException $e) { - $this->addToAssertionCount(1); - return $e->getMessage(); + $result = Owid::tryFromByteArray($bytes); + if ($result->ok) { + $this->fail("$label should have been refused"); } - $this->fail("$label should have been refused"); + $this->assertNull($result->owid, "$label should hand back no value"); + $this->assertSame(0, $result->consumed, "$label should consume nothing"); + return $result->status; } /** - * The declared length matches the bytes present, the signature is the - * last 64 bytes, and the envelope parses to the same payload. + * The declared length matches the bytes present, the signature is the last + * 64 bytes, and the envelope parses to the same payload. */ public function testDeclaredLengthMatchesParses(): void { - $owid = Owid::fromByteArray(self::envelope( + $owid = Fixtures::parseBytes(self::envelope( self::PAYLOAD_LENGTH, self::payload(), self::signature() @@ -115,7 +117,7 @@ public function testMatchingOneMebibytePayloadParses(): void { $payload = str_repeat("\x5A", 1024 * 1024); - $owid = Owid::fromByteArray(self::envelope( + $owid = Fixtures::parseBytes(self::envelope( strlen($payload), $payload, self::signature() @@ -132,8 +134,8 @@ public function testLibraryOutputParses(): void { $crypto = Crypto::new(); $creator = new Creator('51d.es', $crypto); - $original = $creator->signBytes(self::payload()); - $parsed = Owid::fromByteArray($original->asByteArray()); + $original = $creator->create(self::payload()); + $parsed = Fixtures::parseBytes($original->asByteArray()); $this->assertSame(self::payload(), $parsed->payload); $this->assertTrue( $parsed->verifyWithCrypto($crypto), @@ -142,16 +144,19 @@ public function testLibraryOutputParses(): void } /** - * One more or one fewer than the bytes present is refused, because - * either overruns the payload or leaves bytes after the top-level value. + * One more or one fewer than the bytes present is refused, because either + * overruns the payload or leaves bytes after the top-level value. */ public function testDeclaredLengthOffByOneIsRefused(): void { $declaredLengths = [self::PAYLOAD_LENGTH - 1, self::PAYLOAD_LENGTH + 1]; foreach ($declaredLengths as $declared) { - $this->refusal( - self::envelope($declared, self::payload(), self::signature()), - "declared $declared" + $this->assertSame( + ParseStatus::ByteCountMismatch, + $this->refusal( + self::envelope($declared, self::payload(), self::signature()), + "declared $declared" + ) ); } } @@ -167,13 +172,19 @@ public function testTrailingByteAfterSignatureIsRefused(): void self::payload(), self::signature() ); - $this->refusal($bytes . "\x00", 'trailing byte'); + + $this->assertSame( + ParseStatus::ByteCountMismatch, + $this->refusal($bytes . "\x00", 'trailing byte') + ); } /** - * A short signature is refused. The declared payload length is right for - * the payload, but the bytes after it are fewer than a signature. The - * message names the bytes present, one short of payload and signature. + * A short signature is refused as a disagreement between the declaration + * and the bytes rather than as data that stopped early. The declared + * payload length is right for the payload, but what follows it is fewer + * bytes than a signature, so the declared payload cannot leave exactly the + * signature the version requires. */ public function testShortSignatureIsRefused(): void { @@ -182,35 +193,37 @@ public function testShortSignatureIsRefused(): void self::payload(), self::signature(self::SIGNATURE_LENGTH - 1) ); - $message = $this->refusal($bytes, '63 byte signature'); - $present = self::PAYLOAD_LENGTH + self::SIGNATURE_LENGTH - 1; - $this->assertStringContainsString("'$present'", $message); + + $this->assertSame( + ParseStatus::ByteCountMismatch, + $this->refusal($bytes, '63 byte signature') + ); } /** * A large declaration whose payload bytes are absent is refused without - * anything sized by the declared number. PHP cannot count allocations - * per call, so the envelope of a few dozen bytes that declares 64 MiB, - * then 2 GiB, then the largest unsigned 32 bit value while carrying none - * of those bytes is parsed 1,000 times each. The numeric values remain - * valid when the matching payload is present. The attempts must finish - * well inside a second, which a parse that sized a buffer by the - * declaration could not do. Where the runtime - * can reset its peak memory figure (PHP 8.2 and later) the peak during - * one refusal must also stay under 64 KiB above the level before it. + * anything sized by the declared number. PHP cannot count allocations per + * call, so the envelope of a few dozen bytes that declares 64 MiB, then + * 2 GiB, then the largest unsigned 32 bit value while carrying none of + * those bytes is read 1,000 times each. The numeric values remain valid + * when the matching payload is present. The attempts must finish well + * inside a second, which a read that sized a buffer by the declaration + * could not do. Where the runtime can reset its peak memory figure + * (PHP 8.2 and later) the peak during one refusal must also stay under + * 64 KiB above the level before it. */ public function testMismatchedLargeDeclarationIsRefusedQuickly(): void { $declaredLengths = [64 * 1024 * 1024, 0x7FFFFFFF, 0xFFFFFFFF]; foreach ($declaredLengths as $declared) { $bytes = self::envelope($declared, '', ''); - $message = ''; + $status = null; $start = hrtime(true); for ($attempt = 0; $attempt < 1000; $attempt++) { - $message = $this->refusal($bytes, "declared $declared"); + $status = $this->refusal($bytes, "declared $declared"); } $elapsed = (hrtime(true) - $start) / 1e9; - $this->assertStringContainsString("'$declared'", $message); + $this->assertSame(ParseStatus::ByteCountMismatch, $status); $this->assertLessThan( 1.0, $elapsed, @@ -232,20 +245,21 @@ public function testMismatchedLargeDeclarationIsRefusedQuickly(): void /** * An empty payload, declared length zero, followed by the signature is a - * valid OWID and parses. + * valid OWID and parses. Having nothing to say is allowed. */ public function testEmptyPayloadParses(): void { - $owid = Owid::fromByteArray(self::envelope(0, '', self::signature())); + $owid = Fixtures::parseBytes(self::envelope(0, '', self::signature())); $this->assertSame('', $owid->payload); $this->assertSame(self::signature(), $owid->signature); } /** - * The public reader consumes one OWID and leaves following framed bytes; - * the byte-array entry point remains strict about EOF. + * The framed reader consumes one OWID and leaves the following envelope + * for the next read, reporting how many bytes this one occupied, while the + * byte array entry point stays strict about the end of the buffer. */ - public function testFromReaderLeavesFollowingEnvelopeUnread(): void + public function testFramedReadLeavesFollowingEnvelopeUnread(): void { $firstBytes = self::envelope( self::PAYLOAD_LENGTH, @@ -253,14 +267,57 @@ public function testFromReaderLeavesFollowingEnvelopeUnread(): void self::signature() ); $secondBytes = self::envelope(0, '', self::signature()); - $reader = new Io($firstBytes . $secondBytes); + $buffer = $firstBytes . $secondBytes; + + $first = Owid::tryFromFrame($buffer); + $this->assertTrue($first->ok, 'the first envelope should read'); + $this->assertSame(self::payload(), $first->owid->payload); + $this->assertSame(strlen($firstBytes), $first->consumed); + + $second = Owid::tryFromFrame($buffer, $first->consumed); + $this->assertTrue($second->ok, 'the second envelope should read'); + $this->assertSame('', $second->owid->payload); + $this->assertSame( + strlen($buffer), + $first->consumed + $second->consumed, + 'the two envelopes should account for the whole buffer' + ); + + $this->assertSame( + ParseStatus::ByteCountMismatch, + $this->refusal($buffer, 'two envelopes on the exact surface') + ); + } + + /** + * A framed read of an envelope that stops early is data that ended rather + * than a declaration that disagrees, because what follows a framed + * envelope may be the next one, so the reader cannot say the bytes are + * wrong, only that they are not all here. + */ + public function testFramedReadOfATruncatedEnvelopeEndsEarly(): void + { + $bytes = self::envelope( + self::PAYLOAD_LENGTH, + self::payload(), + self::signature() + ); + + $result = Owid::tryFromFrame(substr($bytes, 0, strlen($bytes) - 1)); - $first = Owid::fromReader($reader); - $this->assertSame(self::payload(), $first->payload); - $this->assertSame(strlen($secondBytes), $reader->remaining()); + $this->assertFalse($result->ok); + $this->assertNull($result->owid); + $this->assertSame(ParseStatus::UnexpectedEnd, $result->status); + } - $second = Owid::fromReader($reader); - $this->assertSame('', $second->payload); - $this->assertSame(0, $reader->remaining()); + /** + * The signature length and the greatest number of characters a domain name + * can hold are the bounds the reader works to, and both are published so a + * caller can size its own limits. + */ + public function testPublishedBounds(): void + { + $this->assertSame(64, OwidException::SIGNATURE_LENGTH); + $this->assertSame(253, OwidException::MAXIMUM_DOMAIN_LENGTH); } } diff --git a/tests/ReadmeTest.php b/tests/ReadmeTest.php new file mode 100644 index 0000000..ab1decd --- /dev/null +++ b/tests/ReadmeTest.php @@ -0,0 +1,120 @@ + + */ + private static function examples(): array + { + $readme = file_get_contents(__DIR__ . '/../README.md'); + self::assertNotFalse($readme, 'the README should be readable'); + $matches = []; + preg_match_all('/```php\r?\n(.*?)```/s', $readme, $matches); + return $matches[1]; + } + + /** + * Every documented example compiles and runs, and the identifier the first + * one creates reads back and verifies. + */ + public function testReadmeExamplesRun(): void + { + $examples = self::examples(); + $this->assertGreaterThanOrEqual( + 4, + count($examples), + 'the README should still carry its examples' + ); + + $checks = <<<'PHP' + + if (!$result->ok) { + fwrite(STDERR, 'the example identifier did not read back'); + exit(1); + } + if ($valid !== true) { + fwrite(STDERR, 'the example identifier did not verify'); + exit(1); + } + if ($status !== SignatureStatus::SignatureValid) { + fwrite(STDERR, 'the example status was ' . $status->value); + exit(1); + } + if (count($identifiers) !== 2) { + fwrite(STDERR, 'the frame walk found ' . count($identifiers)); + exit(1); + } + if ($offset !== strlen($framedBuffer)) { + fwrite(STDERR, 'the frame walk stopped at ' . $offset); + exit(1); + } + PHP; + + $script = "&1', + $output, + $code + ); + $this->assertSame( + 0, + $code, + "the README examples failed:\n" . implode("\n", $output) + ); + $this->assertSame( + [], + $output, + 'the README examples should run without output' + ); + } finally { + unlink($path); + unlink($reserved); + } + } +} diff --git a/tests/WireFormatTest.php b/tests/WireFormatTest.php index c58d185..023139c 100644 --- a/tests/WireFormatTest.php +++ b/tests/WireFormatTest.php @@ -21,7 +21,9 @@ namespace SwanCommunity\Owid\Tests; use PHPUnit\Framework\TestCase; -use SwanCommunity\Owid\OwidException; +use SwanCommunity\Owid\ParseStatus; +use SwanCommunity\Owid\Creator; +use SwanCommunity\Owid\Crypto; use SwanCommunity\Owid\Owid; use SwanCommunity\Owid\Version; @@ -46,7 +48,7 @@ public function testCanonicalVectorsRoundTripByteExact(): void foreach ($vectors as $name => $value) { $bytes = base64_decode($value, true); $this->assertNotFalse($bytes, "vector $name should decode"); - $owid = Owid::fromByteArray($bytes); + $owid = Fixtures::parseBytes($bytes); $this->assertSame( bin2hex($bytes), bin2hex($owid->asByteArray()), @@ -60,7 +62,7 @@ public function testCanonicalVectorsRoundTripByteExact(): void */ public function testCreatorVectorFields(): void { - $owid = Owid::fromBase64(Fixtures::CANONICAL_CREATOR); + $owid = Fixtures::parseBase64(Fixtures::CANONICAL_CREATOR); $this->assertSame('51db.uk', $owid->domain); $this->assertSame(Version::Version2, $owid->version); $this->assertSame(341, strlen($owid->payload)); @@ -76,7 +78,7 @@ public function testCreatorVectorFields(): void */ public function testSupplierVectorPayloadForms(): void { - $owid = Owid::fromBase64(Fixtures::CANONICAL_SUPPLIER); + $owid = Fixtures::parseBase64(Fixtures::CANONICAL_SUPPLIER); $this->assertSame('pop-up.swan-demo.uk', $owid->domain); $this->assertSame("\x01\x03", $owid->payload); $this->assertSame('0103', $owid->payloadAsPrintable()); @@ -88,7 +90,7 @@ public function testSupplierVectorPayloadForms(): void */ public function testBadVectorParses(): void { - $owid = Owid::fromBase64(Fixtures::CANONICAL_BAD); + $owid = Fixtures::parseBase64(Fixtures::CANONICAL_BAD); $this->assertSame('badssp.swan-demo.uk', $owid->domain); $this->assertSame(64, strlen($owid->signature)); } @@ -98,10 +100,10 @@ public function testBadVectorParses(): void */ public function testDecodeAcceptsPaddedAndUnpadded(): void { - $padded = Owid::fromBase64(Fixtures::CANONICAL_SUPPLIER . ''); + $padded = Fixtures::parseBase64(Fixtures::CANONICAL_SUPPLIER . ''); $reEncoded = $padded->asBase64(); $this->assertStringEndsWith('=', $reEncoded, 'encoding always pads'); - $fromPadded = Owid::fromBase64($reEncoded); + $fromPadded = Fixtures::parseBase64($reEncoded); $this->assertSame( bin2hex($padded->asByteArray()), bin2hex($fromPadded->asByteArray()) @@ -109,35 +111,76 @@ public function testDecodeAcceptsPaddedAndUnpadded(): void } /** - * An empty OWID marker is a single zero byte and reads back as the empty - * version. + * The marker for a node that is absent is a single zero byte, and reading + * a whole buffer holding one reports it as an absent node and hands back + * no OWID. The marker carries no domain, date, payload or signature, so it + * can never verify, and reading one as an OWID would hand a caller the one + * kind of instance that has no signature. It is not an unknown version + * either, because version 0 is supported and meaningful. */ - public function testEmptyOwidMarker(): void + public function testEmptyOwidMarkerIsRefusedAsAWholeBuffer(): void { $buffer = ''; Owid::emptyToBuffer($buffer); $this->assertSame("\x00", $buffer); - $owid = Owid::fromByteArray($buffer); - $this->assertSame(Version::Empty, $owid->version); + + $result = Owid::tryFromByteArray($buffer); + + $this->assertFalse($result->ok); + $this->assertNull($result->owid); + $this->assertSame(ParseStatus::AbsentNode, $result->status); + } + + /** + * A framed buffer whose first frame is the marker reports an absent node, + * hands back no OWID, counts its one byte, and leaves the OWID that + * follows to be read next. A caller walking the frames can therefore tell + * an absent node from a frame that is malformed. + */ + public function testEmptyOwidMarkerIsReadWhenFramed(): void + { + $owid = (new Creator('example.com', Crypto::new()))->create('value'); + $buffer = ''; + Owid::emptyToBuffer($buffer); + $buffer .= $owid->asByteArray(); + + $marker = Owid::tryFromFrame($buffer); + $this->assertFalse($marker->ok, 'an absent node is not a value'); + $this->assertNull($marker->owid); + $this->assertSame(ParseStatus::AbsentNode, $marker->status); + $this->assertSame(1, $marker->consumed); + + $next = Owid::tryFromFrame($buffer, $marker->consumed); + $this->assertTrue($next->ok); + $this->assertSame('value', $next->owid->payloadAsString()); } /** - * An unknown version byte is rejected. + * An unknown version byte is reported rather than raised, and nothing is + * handed back to read. */ - public function testUnknownVersionRejected(): void + public function testUnknownVersionReported(): void { - $this->expectException(OwidException::class); - Owid::fromByteArray("\x09rest"); + $result = Owid::tryFromByteArray("\x09rest"); + + $this->assertFalse($result->ok); + $this->assertNull($result->owid); + $this->assertSame(ParseStatus::UnsupportedVersion, $result->status); } /** - * A truncated buffer is rejected. + * A buffer that stops inside the envelope is reported as data that ended + * early, and nothing is handed back to read. */ - public function testTruncatedBufferRejected(): void + public function testTruncatedBufferReported(): void { $bytes = base64_decode(Fixtures::CANONICAL_SUPPLIER, true); $this->assertNotFalse($bytes); - $this->expectException(OwidException::class); - Owid::fromByteArray(substr($bytes, 0, 10)); + + $result = Owid::tryFromByteArray(substr($bytes, 0, 10)); + + $this->assertFalse($result->ok); + $this->assertNull($result->owid); + $this->assertSame(ParseStatus::UnexpectedEnd, $result->status); } } diff --git a/tests/run.php b/tests/run.php index e622f71..0449c61 100644 --- a/tests/run.php +++ b/tests/run.php @@ -30,6 +30,9 @@ require __DIR__ . '/../src/OwidException.php'; require __DIR__ . '/../src/Version.php'; require __DIR__ . '/../src/Io.php'; +require __DIR__ . '/../src/ParseStatus.php'; +require __DIR__ . '/../src/SignatureStatus.php'; +require __DIR__ . '/../src/ParseResult.php'; require __DIR__ . '/../src/Crypto.php'; require __DIR__ . '/../src/Owid.php'; require __DIR__ . '/../src/Creator.php'; @@ -37,12 +40,15 @@ require __DIR__ . '/Fixtures.php'; use DateTimeImmutable; +use Error; use SwanCommunity\Owid\Crypto; use SwanCommunity\Owid\Creator; use SwanCommunity\Owid\Endpoints; use SwanCommunity\Owid\Io; use SwanCommunity\Owid\Owid; use SwanCommunity\Owid\OwidException; +use SwanCommunity\Owid\ParseStatus; +use SwanCommunity\Owid\SignatureStatus; use SwanCommunity\Owid\Version; /** @@ -67,7 +73,8 @@ public function check(string $name, bool $condition): void /** * Records a pass when the callable raises an OwidException, otherwise a - * fail. + * fail. Used for the write and configuration side, where a fault is a + * fault in the program rather than data arriving from outside. */ public function checkThrows(string $name, callable $callable): void { @@ -79,6 +86,24 @@ public function checkThrows(string $name, callable $callable): void } } + /** + * Records a pass when reading the bytes reports the status given, hands + * back no value, and raises nothing. + */ + public function checkRefused( + string $name, + string $bytes, + ParseStatus $expected + ): void { + $result = Owid::tryFromByteArray($bytes); + $this->check( + $name, + !$result->ok && + $result->owid === null && + $result->status === $expected + ); + } + public function summary(): int { $total = $this->passed + $this->failed; @@ -89,14 +114,43 @@ public function summary(): int } /** - * Returns a copy of the OWID with its last serialized byte flipped. + * Reads a value the runner expects to be a well formed OWID, and stops the run + * with the reason when it is not. */ -function flipLastByte(Owid $owid, array $others = []): Owid +function parse(string $value): Owid +{ + $result = Owid::tryFromBase64($value); + if (!$result->ok) { + echo 'FAIL fixture did not read: ' . $result->status->value . PHP_EOL; + exit(1); + } + return $result->owid; +} + +/** + * Reads bytes the runner expects to be one whole OWID, and stops the run with + * the reason when they are not. + */ +function parseBytes(string $bytes): Owid +{ + $result = Owid::tryFromByteArray($bytes); + if (!$result->ok) { + echo 'FAIL bytes did not read: ' . $result->status->value . PHP_EOL; + exit(1); + } + return $result->owid; +} + +/** + * Returns a copy of the OWID with its last serialized byte flipped, read back + * from the bytes because that is how tampering reaches a verifier. + */ +function flipLastByte(Owid $owid): Owid { $bytes = $owid->asByteArray(); $last = strlen($bytes) - 1; $bytes[$last] = chr(ord($bytes[$last]) ^ 0x01); - return Owid::fromByteArray($bytes); + return parseBytes($bytes); } $runner = new Runner(); @@ -109,50 +163,54 @@ function flipLastByte(Owid $owid, array $others = []): Owid ]; foreach ($vectors as $name => $value) { $bytes = base64_decode($value, true); - $owid = Owid::fromByteArray($bytes); + $owid = parseBytes($bytes); $runner->check( "canonical $name round trips byte exact", $bytes === $owid->asByteArray() ); } -$creator = Owid::fromBase64(Fixtures::CANONICAL_CREATOR); -$runner->check('creator domain is 51db.uk', $creator->domain === '51db.uk'); -$runner->check('creator version is 2', $creator->version === Version::Version2); -$runner->check('creator payload length is 341', strlen($creator->payload) === 341); +$creatorVector = parse(Fixtures::CANONICAL_CREATOR); +$runner->check('creator domain is 51db.uk', $creatorVector->domain === '51db.uk'); +$runner->check('creator version is 2', $creatorVector->version === Version::Version2); +$runner->check('creator payload length is 341', strlen($creatorVector->payload) === 341); $runner->check( 'creator date is 2021-04-06T12:59Z', - $creator->date->format('Y-m-d\TH:i\Z') === '2021-04-06T12:59Z' + $creatorVector->date->format('Y-m-d\TH:i\Z') === '2021-04-06T12:59Z' ); -$runner->check('creator first signature byte is 74', ord($creator->signature[0]) === 74); -$runner->check('creator last signature byte is 64', ord($creator->signature[63]) === 64); +$runner->check('creator first signature byte is 74', ord($creatorVector->signature[0]) === 74); +$runner->check('creator last signature byte is 64', ord($creatorVector->signature[63]) === 64); -$supplier = Owid::fromBase64(Fixtures::CANONICAL_SUPPLIER); +$supplier = parse(Fixtures::CANONICAL_SUPPLIER); $runner->check('supplier payload printable is 0103', $supplier->payloadAsPrintable() === '0103'); $runner->check('supplier payload base64 is AQM=', $supplier->payloadAsBase64() === 'AQM='); $runner->check( - 'bad vector parses', - Owid::fromBase64(Fixtures::CANONICAL_BAD)->domain === 'badssp.swan-demo.uk' + 'bad vector reads', + parse(Fixtures::CANONICAL_BAD)->domain === 'badssp.swan-demo.uk' ); // B. Cross language signed fixtures. foreach (Fixtures::crossLanguage() as $lang => $fixture) { $spki = $fixture['spki']; - $simple = Owid::fromBase64($fixture['simple']); + $simple = parse($fixture['simple']); $runner->check("$lang simple payload is example", $simple->payloadAsString() === 'example'); $runner->check("$lang simple verifies", $simple->verifyWithPublicKey($spki)); + $runner->check( + "$lang simple reports a valid signature", + $simple->signatureStatus($spki) === SignatureStatus::SignatureValid + ); - $utf8 = Owid::fromBase64($fixture['utf8']); + $utf8 = parse($fixture['utf8']); $runner->check( "$lang utf8 payload text matches", $utf8->payloadAsString() === Fixtures::UTF8_PAYLOAD ); $runner->check("$lang utf8 verifies", $utf8->verifyWithPublicKey($spki)); - $root = Owid::fromBase64($fixture['chain_root']); - $party = Owid::fromBase64($fixture['chain_party']); + $root = parse($fixture['chain_root']); + $party = parse($fixture['chain_party']); $runner->check("$lang chain root verifies alone", $root->verifyWithPublicKey($spki)); $runner->check( "$lang chain party verifies with root", @@ -164,7 +222,7 @@ function flipLastByte(Owid $owid, array $others = []): Owid ); foreach (['simple', 'utf8', 'chain_root'] as $key) { - $tampered = flipLastByte(Owid::fromBase64($fixture[$key])); + $tampered = flipLastByte(parse($fixture[$key])); $runner->check( "$lang $key with flipped byte fails", !$tampered->verifyWithPublicKey($spki) @@ -177,40 +235,118 @@ function flipLastByte(Owid $owid, array $others = []): Owid ); } -// Sign and self verify, plus a tampered copy fails. +// Create and self verify, plus a tampered copy fails. $crypto = Crypto::new(); $signer = new Creator('example.com', $crypto); -$signed = $signer->signString('Hello World'); -$runner->check('signed OWID domain set by creator', $signed->domain === 'example.com'); -$runner->check('signed OWID version is 3', $signed->version === Version::Version3); -$runner->check('signed OWID has 64 byte signature', strlen($signed->signature) === 64); -$runner->check('signed OWID verifies with crypto', $signed->verifyWithCrypto($crypto)); -$copy = Owid::fromBase64($signed->asBase64()); -$runner->check( - 'signed OWID verifies via public key after round trip', +$signed = $signer->create('Hello World'); +$runner->check('created OWID domain set by creator', $signed->domain === 'example.com'); +$runner->check('created OWID version is 3', $signed->version === Version::Version3); +$runner->check('created OWID has 64 byte signature', strlen($signed->signature) === 64); +$runner->check('created OWID verifies with crypto', $signed->verifyWithCrypto($crypto)); +$copy = parse($signed->asBase64()); +$runner->check( + 'created OWID verifies via public key after round trip', $copy->verifyWithPublicKey($crypto->publicKeyPem()) ); $tamperedLocal = flipLastByte($signed); $runner->check('tampered local OWID fails', !$tamperedLocal->verifyWithCrypto($crypto)); +// A caller cannot build an OWID, and cannot change one. +$constructorIsPrivate = (new \ReflectionClass(Owid::class))->getConstructor()->isPrivate(); +$runner->check('the OWID constructor is private', $constructorIsPrivate); +$reboundRefused = false; +try { + $signed->payload = 'other'; +} catch (Error $e) { + $reboundRefused = str_contains($e->getMessage(), 'readonly'); +} +$runner->check('an OWID field cannot be rebound', $reboundRefused); +$payloadCopy = $signed->payload; +$payloadCopy[0] = 'z'; +$runner->check( + 'writing into a returned payload does not alter the OWID', + $signed->payload === 'Hello World' +); +$runner->check( + 'the creator offers no way to sign an existing OWID', + !method_exists(Creator::class, 'sign') && + !method_exists(Creator::class, 'signWithOthers') && + !method_exists(Creator::class, 'signString') && + !method_exists(Creator::class, 'signBytes') +); + // Local chain. -$localRoot = $signer->signString('root'); -$localParty = new Owid(); -$localParty->payload = 'party'; -$signer->signWithOthers($localParty, [$localRoot]); -$runner->check('local chain party verifies with root', $localParty->verifyWithCrypto($crypto, [$localRoot])); +$localRoot = $signer->create('root'); +$localParty = $signer->create('party', [$localRoot]); +$runner->check( + 'local chain party verifies with root', + $localParty->verifyWithCrypto($crypto, [$localRoot]) +); $runner->check('local chain party fails with no others', !$localParty->verifyWithCrypto($crypto)); // UTF-8 payload round trip. -$utf8Signed = $signer->signString(Fixtures::UTF8_PAYLOAD); -$utf8Copy = Owid::fromBase64($utf8Signed->asBase64()); +$utf8Signed = $signer->create(Fixtures::UTF8_PAYLOAD); +$utf8Copy = parse($utf8Signed->asBase64()); $runner->check('utf8 payload round trips as text', $utf8Copy->payloadAsString() === Fixtures::UTF8_PAYLOAD); +// Reading answers rather than raising, with the same three facts every time. +$goodResult = Owid::tryFromByteArray($signed->asByteArray()); +$runner->check( + 'a successful read reports it worked, a value and Parsed', + $goodResult->ok && + $goodResult->owid !== null && + $goodResult->status === ParseStatus::Parsed +); +$runner->check( + 'absent input is missing input', + Owid::tryFromBase64(null)->status === ParseStatus::MissingInput && + Owid::tryFromBase64('')->status === ParseStatus::MissingInput && + Owid::tryFromByteArray(null)->status === ParseStatus::MissingInput +); +$runner->check( + 'input that is not text is the wrong sort of input', + Owid::tryFromBase64(['a'])->status === ParseStatus::InvalidInputType && + Owid::tryFromBase64(5)->status === ParseStatus::InvalidInputType +); +$runner->check( + 'invalid base 64 is reported', + Owid::tryFromBase64('not base 64 at all!!')->status === ParseStatus::InvalidBase64 +); +$runner->checkRefused( + 'an unknown version byte is reported', + "\x09rest", + ParseStatus::UnsupportedVersion +); +$runner->checkRefused( + 'a buffer stopping inside the envelope ends early', + substr($signed->asByteArray(), 0, 8), + ParseStatus::UnexpectedEnd +); +$runner->checkRefused( + 'a trailing byte is a byte count mismatch', + $signed->asByteArray() . "\x00", + ParseStatus::ByteCountMismatch +); +$badSignatureBytes = $signed->asByteArray(); +$badSignatureBytes[strlen($badSignatureBytes) - 1] = chr( + ord($badSignatureBytes[strlen($badSignatureBytes) - 1]) ^ 0xFF +); +$badSignatureResult = Owid::tryFromByteArray($badSignatureBytes); +$runner->check( + 'a valid structure with a bad signature reads and then fails to verify', + $badSignatureResult->ok && !$badSignatureResult->owid->verifyWithCrypto($crypto) +); +$runner->check( + 'a key that cannot be read is not an invalid signature', + $signed->signatureStatus('not a pem') === SignatureStatus::InvalidKey +); + // Empty PEM guards. $runner->checkThrows('empty public PEM guard', fn () => Crypto::newVerifyOnly(' ')); $runner->checkThrows('empty private PEM guard', fn () => Crypto::newSignOnly('')); $runner->checkThrows('invalid public PEM rejected', fn () => Crypto::newVerifyOnly('invalid')); $runner->checkThrows('invalid private PEM rejected', fn () => Crypto::newSignOnly('invalid')); +$runner->check('unreadable PEM answers with null', Crypto::tryVerifyOnly('invalid') === null); // Crypto via PEM. $pemSigner = Crypto::newSignOnly($crypto->privateKeyPem()); @@ -219,6 +355,11 @@ function flipLastByte(Owid $owid, array $others = []): Owid $runner->check('sign via imported private key yields 64 bytes', strlen($sig) === 64); $runner->check('verify via imported public key', $pemVerifier->verifyByteArray('test', $sig)); $runner->check('verify rejects other data', !$pemVerifier->verifyByteArray('other', $sig)); +$runner->check( + 'a signature of the wrong length is not a signature that does not match', + $pemVerifier->signatureStatus('test', str_repeat("\x00", 63)) === + SignatureStatus::InvalidSignatureLength +); $runner->checkThrows( 'verify rejects wrong length signature', fn () => $crypto->verifyByteArray('test', str_repeat("\x00", 63)) @@ -240,32 +381,58 @@ function flipLastByte(Owid $owid, array $others = []): Owid Crypto::derToRaw(Crypto::rawToDer($rawSmall)) === $rawSmall ); -// Io helpers. +/** + * A version 3 envelope carrying the domain, date and payload given, followed + * by the signature bytes given, built with the write helpers so that what is + * written can be read back the way a caller reads it. + */ +function envelope( + string $domain, + DateTimeImmutable $date, + string $payload, + string $signature +): string { + $buffer = ''; + Io::writeByte($buffer, Version::Version3->asByte()); + Io::writeString($buffer, $domain); + Io::writeDate($buffer, $date, Version::Version3); + Io::writeByteArray($buffer, $payload); + return $buffer . $signature; +} + +// Io write helpers, read back through a complete envelope. $buffer = ''; Io::writeUint32($buffer, 0x0A242B01); $runner->check('uint32 is little endian', $buffer === "\x01\x2B\x24\x0A"); -$runner->check('uint32 round trips', (new Io($buffer))->readUint32() === 0x0A242B01); $buffer = ''; Io::writeString($buffer, 'example.com'); $runner->check('string is null terminated', $buffer[strlen($buffer) - 1] === "\x00"); -$runner->check('string round trips', (new Io($buffer))->readString() === 'example.com'); -$buffer = ''; $now = new DateTimeImmutable('now'); +$fullSignature = str_repeat("\x99", 64); +$written = parseBytes(envelope('example.com', $now, "\x01\x02", $fullSignature)); +$runner->check('written domain reads back', $written->domain === 'example.com'); +$runner->check('written payload reads back', $written->payload === "\x01\x02"); +$runner->check( + 'written date reads back to the minute', + intdiv($written->date->getTimestamp() - Io::BASE_TIMESTAMP, 60) === + intdiv($now->getTimestamp() - Io::BASE_TIMESTAMP, 60) +); +$buffer = ''; Io::writeDate($buffer, $now, Version::Version2); $runner->check('version 2 date uses four bytes', strlen($buffer) === 4); -$readMinutes = intdiv( - (new Io($buffer))->readDate(Version::Version2)->getTimestamp() - Io::BASE_TIMESTAMP, - 60 -); -$wantMinutes = intdiv($now->getTimestamp() - Io::BASE_TIMESTAMP, 60); -$runner->check('version 2 date round trips to the minute', $readMinutes === $wantMinutes); $buffer = ''; $v1date = Io::baseDate()->modify('+12345 hours'); Io::writeDate($buffer, $v1date, Version::Version1); $runner->check('version 1 date uses two bytes', strlen($buffer) === 2); +$v1buffer = ''; +Io::writeByte($v1buffer, Version::Version1->asByte()); +Io::writeString($v1buffer, 'example.com'); +Io::writeDate($v1buffer, $v1date, Version::Version1); +Io::writeByteArray($v1buffer, ''); +$v1owid = parseBytes($v1buffer . $fullSignature); $runner->check( - 'version 1 date round trips to the hour', - (new Io($buffer))->readDate(Version::Version1)->format('Y-m-d H:i') === $v1date->format('Y-m-d H:i') + 'version 1 date reads back to the hour', + $v1owid->date->format('Y-m-d H:i') === $v1date->format('Y-m-d H:i') ); $runner->checkThrows( 'date before base date rejected', @@ -281,12 +448,60 @@ function () { Io::writeString($buffer, "bad\x00value"); } ); +$runner->checkThrows( + 'signature of the wrong length rejected on write', + function () { + $buffer = ''; + Io::writeSignature($buffer, str_repeat("\x99", 63)); + } +); // Empty marker. $buffer = ''; Owid::emptyToBuffer($buffer); -$runner->check('empty marker is a single zero byte', $buffer === "\x00"); -$runner->check('empty marker reads as empty version', Owid::fromByteArray($buffer)->version === Version::Empty); +$runner->check('empty marker is a single zero byte', $buffer === ""); +// The marker for a node that is absent is not an OWID, so no value is handed +// back on either contract, and it is not an unknown version either, because +// version 0 is supported and meaningful. Its one byte is counted so that a +// caller walking a run of frames steps over the absent node. +$markerWhole = Owid::tryFromByteArray($buffer); +$runner->check( + 'the marker for an absent node hands back no OWID as a whole buffer', + !$markerWhole->ok && + $markerWhole->owid === null && + $markerWhole->status === ParseStatus::AbsentNode +); +$markerFrame = Owid::tryFromFrame($buffer . $signed->asByteArray()); +$runner->check( + 'the marker for an absent node is reported and counted when framed', + !$markerFrame->ok && + $markerFrame->owid === null && + $markerFrame->status === ParseStatus::AbsentNode && + $markerFrame->consumed === 1 +); +$afterMarker = Owid::tryFromFrame($buffer . $signed->asByteArray(), 1); +$runner->check( + 'a frame walk steps over an absent node and reads the next OWID', + $afterMarker->ok && $afterMarker->owid->payloadAsString() === 'Hello World' +); +// A framed envelope that stops early is data that stopped, not a declaration +// disagreeing with data that is all present, because the bytes may still be +// arriving and waiting for more is a different answer from giving up. +$shortFrame = substr($signed->asByteArray(), 0, strlen($signed->asByteArray()) - 1); +$runner->check( + 'a short frame ends early rather than disagreeing', + Owid::tryFromFrame($shortFrame)->status === ParseStatus::UnexpectedEnd && + Owid::tryFromByteArray($shortFrame)->status === ParseStatus::ByteCountMismatch +); +$runner->check( + 'a buffer of no bytes is missing input', + Owid::tryFromByteArray('')->status === ParseStatus::MissingInput && + Owid::tryFromBase64(" + + + +")->status === ParseStatus::MissingInput +); // Creator behaviour. $runner->checkThrows('empty domain rejected', fn () => new Creator(' ', Crypto::new())); @@ -295,9 +510,9 @@ function () { fn () => new Creator('example.com', $pemVerifier) ); $fromConfig = Creator::fromConfiguration('example.com', $crypto->privateKeyPem()); -$configOwid = $fromConfig->signString('value'); +$configOwid = $fromConfig->create('value'); $runner->check( - 'creator from configuration signs verifiable OWID', + 'creator from configuration creates a verifiable OWID', $configOwid->verifyWithPublicKey($crypto->publicKeyPem()) ); @@ -305,9 +520,9 @@ function () { $endpointCreator = new Creator('example.com', Crypto::new()); $body = Endpoints::creatorResponse($endpointCreator, 'Example Org', 'https://terms.example'); $runner->check('creator response has publicKeySPKI field', str_contains($body, 'publicKeySPKI')); -$parsed = json_decode($body, true); -$runner->check('creator response domain is example.com', $parsed['domain'] === 'example.com'); -$runner->check('creator response name is Example Org', $parsed['name'] === 'Example Org'); +$parsedBody = json_decode($body, true); +$runner->check('creator response domain is example.com', $parsedBody['domain'] === 'example.com'); +$runner->check('creator response name is Example Org', $parsedBody['name'] === 'Example Org'); $runner->check( 'public key response returns PEM for spki', str_contains(Endpoints::publicKeyResponse($endpointCreator, 'spki'), 'BEGIN PUBLIC KEY') @@ -343,13 +558,13 @@ function payloadEnvelope(int $declared, string $payload, string $signature): str $lengthPayload = str_repeat("\x5A", 37); $lengthSignature = str_repeat("\x99", 64); $runner->check( - 'matching payload length parses', - Owid::fromByteArray(payloadEnvelope(37, $lengthPayload, $lengthSignature))->payload === $lengthPayload + 'matching payload length reads', + parseBytes(payloadEnvelope(37, $lengthPayload, $lengthSignature))->payload === $lengthPayload ); $largeLengthPayload = str_repeat("\x5A", 1024 * 1024); $runner->check( - 'matching one mebibyte payload parses', - Owid::fromByteArray(payloadEnvelope( + 'matching one mebibyte payload reads', + parseBytes(payloadEnvelope( strlen($largeLengthPayload), $largeLengthPayload, $lengthSignature @@ -357,31 +572,34 @@ function payloadEnvelope(int $declared, string $payload, string $signature): str ); unset($largeLengthPayload); $runner->check( - 'empty payload with signature parses', - Owid::fromByteArray(payloadEnvelope(0, '', $lengthSignature))->payload === '' + 'empty payload with signature reads', + parseBytes(payloadEnvelope(0, '', $lengthSignature))->payload === '' ); foreach ([36, 38] as $declared) { - $runner->checkThrows( + $runner->checkRefused( "payload length $declared off by one refused", - fn () => Owid::fromByteArray(payloadEnvelope($declared, $lengthPayload, $lengthSignature)) + payloadEnvelope($declared, $lengthPayload, $lengthSignature), + ParseStatus::ByteCountMismatch ); } -$runner->checkThrows( +$runner->checkRefused( 'trailing byte after signature refused', - fn () => Owid::fromByteArray(payloadEnvelope(37, $lengthPayload, $lengthSignature) . "\x00") + payloadEnvelope(37, $lengthPayload, $lengthSignature) . "\x00", + ParseStatus::ByteCountMismatch ); -$runner->checkThrows( +$runner->checkRefused( '63 byte signature refused', - fn () => Owid::fromByteArray(payloadEnvelope(37, $lengthPayload, str_repeat("\x99", 63))) + payloadEnvelope(37, $lengthPayload, str_repeat("\x99", 63)), + ParseStatus::ByteCountMismatch ); foreach ([64 * 1024 * 1024, 0x7FFFFFFF, 0xFFFFFFFF] as $declared) { $refused = true; + $bytes = payloadEnvelope($declared, '', ''); $start = hrtime(true); for ($attempt = 0; $attempt < 1000; $attempt++) { - try { - Owid::fromByteArray(payloadEnvelope($declared, '', '')); + $result = Owid::tryFromByteArray($bytes); + if ($result->ok || $result->status !== ParseStatus::ByteCountMismatch) { $refused = false; - } catch (OwidException $e) { } } $elapsed = (hrtime(true) - $start) / 1e9; @@ -391,6 +609,18 @@ function payloadEnvelope(int $declared, string $payload, string $signature): str ); } +// The framed reader leaves what follows for the next read. +$framed = payloadEnvelope(37, $lengthPayload, $lengthSignature) . + payloadEnvelope(0, '', $lengthSignature); +$firstFrame = Owid::tryFromFrame($framed); +$secondFrame = Owid::tryFromFrame($framed, $firstFrame->consumed); +$runner->check( + 'the framed reader reads one envelope and leaves the next', + $firstFrame->ok && + $secondFrame->ok && + $firstFrame->consumed + $secondFrame->consumed === strlen($framed) +); + // Domain length. The zero terminator is whatever the sender wrote, so the // search for it stops at the greatest number of characters a domain name can // hold rather than running to the end of the buffer. @@ -406,9 +636,9 @@ function domainOfLength(int $length): string return implode('.', $labels); } // The domain and its terminator are appended here rather than through -// Io::writeString because these checks build domains the write side now -// refuses, and the point of them is what the read side does with such bytes -// when they arrive from somewhere else. +// Io::writeString because these checks build domains the write side refuses, +// and the point of them is what the read side does with such bytes when they +// arrive from somewhere else. function domainEnvelope(string $domain): string { $buffer = ''; @@ -420,27 +650,25 @@ function domainEnvelope(string $domain): string } $maximumDomain = domainOfLength(OwidException::MAXIMUM_DOMAIN_LENGTH); $maximumBytes = domainEnvelope($maximumDomain); -$maximumOwid = Owid::fromByteArray($maximumBytes); +$maximumOwid = parseBytes($maximumBytes); $runner->check( - 'domain of the greatest length parses', + 'domain of the greatest length reads', $maximumOwid->domain === $maximumDomain ); $runner->check( 'domain of the greatest length round trips byte exact', $maximumOwid->asByteArray() === $maximumBytes ); -$runner->checkThrows( +$runner->checkRefused( 'domain one character over the greatest length refused', - fn () => Owid::fromByteArray( - domainEnvelope(domainOfLength(OwidException::MAXIMUM_DOMAIN_LENGTH + 1)) - ) + domainEnvelope(domainOfLength(OwidException::MAXIMUM_DOMAIN_LENGTH + 1)), + ParseStatus::InvalidDomainEncoding ); -$runner->checkThrows( +$runner->checkRefused( 'domain filling the bound with no terminator refused', - fn () => Owid::fromByteArray( - chr(Version::Version3->asByte()) . - str_repeat('a', OwidException::MAXIMUM_DOMAIN_LENGTH) - ) + chr(Version::Version3->asByte()) . + str_repeat('a', OwidException::MAXIMUM_DOMAIN_LENGTH), + ParseStatus::UnexpectedEnd ); // The cost of a buffer with no terminator is timed over two buffers sixteen // times apart, so the result does not depend on how fast the machine is. A @@ -452,10 +680,9 @@ function timeDomainRefusals(string $bytes, int $attempts, bool &$refused): float { $start = hrtime(true); for ($attempt = 0; $attempt < $attempts; $attempt++) { - try { - Owid::fromByteArray($bytes); + $result = Owid::tryFromByteArray($bytes); + if ($result->ok || $result->status !== ParseStatus::InvalidDomainEncoding) { $refused = false; - } catch (OwidException $e) { } } return (hrtime(true) - $start) / 1e9; @@ -476,17 +703,17 @@ function timeDomainRefusals(string $bytes, int $attempts, bool &$refused): float ); unset($smallUnterminated, $largeUnterminated); $maximumCrypto = Crypto::new(); -$maximumSigned = (new Creator($maximumDomain, $maximumCrypto))->signString('value'); -$maximumParsed = Owid::fromByteArray($maximumSigned->asByteArray()); +$maximumSigned = (new Creator($maximumDomain, $maximumCrypto))->create('value'); +$maximumParsed = parseBytes($maximumSigned->asByteArray()); $runner->check( - 'signed OWID with the greatest length domain parses and verifies', + 'created OWID with the greatest length domain reads and verifies', $maximumParsed->domain === $maximumDomain && $maximumParsed->verifyWithCrypto($maximumCrypto) ); // The write is bounded as well, at the creator where the domain is supplied -// and again in the serialization, so this library cannot produce an OWID it -// would then refuse to read. +// and again in the write helper, so this library cannot produce bytes it would +// then refuse to read. function domainRefusalNamesMaximum(callable $action): bool { try { @@ -515,30 +742,16 @@ function domainRefusalNamesMaximum(callable $action): bool ) ) ); -$overLongOwid = new Owid(); -$overLongOwid->domain = $overLongDomain; -$overLongOwid->payload = 'value'; -$overLongOwid->signature = str_repeat(chr(0x99), 64); -$runner->check( - 'serializing a domain over the greatest length is refused', - domainRefusalNamesMaximum(fn () => $overLongOwid->asByteArray()) -); -$runner->check( - 'building signing data for a domain over the greatest length is refused', - domainRefusalNamesMaximum(fn () => $overLongOwid->dataForCrypto()) -); -$atBoundOwid = new Owid(); -$atBoundOwid->domain = $maximumDomain; -$atBoundOwid->payload = 'value'; -$atBoundOwid->signature = str_repeat(chr(0x99), 64); $runner->check( - 'serializing a domain of the greatest length parses back unchanged', - Owid::fromByteArray($atBoundOwid->asByteArray())->domain === $maximumDomain + 'writing a domain over the greatest length is refused', + domainRefusalNamesMaximum(function () use ($overLongDomain) { + $buffer = ''; + Io::writeString($buffer, $overLongDomain); + }) ); -// The creator refuses the domain before it looks at the crypto instance, so -// an instance that can only verify still gives the domain message and not -// the key one, and nothing is ever signed with a domain that could not be -// read back. +// The creator refuses the domain before it looks at the crypto instance, so an +// instance that can only verify still gives the domain message and not the key +// one, and nothing is ever signed with a domain that could not be read back. $runner->check( 'creator refuses the domain before looking at the crypto instance', domainRefusalNamesMaximum( @@ -548,26 +761,5 @@ function domainRefusalNamesMaximum(callable $action): bool ) ) ); -// A domain arriving on another OWID covered by the signature is refused -// while the data to sign is being built, which is before the signing key is -// used, so the signature field is left holding what it held before. -$otherOwid = new Owid(); -$otherOwid->domain = $overLongDomain; -$otherOwid->payload = 'other'; -$otherOwid->signature = str_repeat(chr(0x11), 64); -$targetOwid = new Owid(); -$targetOwid->payload = 'value'; -$targetOwid->signature = str_repeat(chr(0x22), 64); -$runner->check( - 'over long domain on another OWID is refused when signing', - domainRefusalNamesMaximum( - fn () => (new Creator('51d.es', $maximumCrypto)) - ->signWithOthers($targetOwid, [$otherOwid]) - ) -); -$runner->check( - 'signature is not calculated when the domain is refused', - $targetOwid->signature === str_repeat(chr(0x22), 64) -); exit($runner->summary());