diff --git a/README.md b/README.md index 095b79d..182c02a 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,46 @@ 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. +## Payload size and application limits + +The OWID wire format stores the payload length as an unsigned 32 bit value, +so a payload from zero through 4,294,967,295 bytes is structurally valid. The +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 +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 +search for its terminator stops at the greatest number of characters a domain +name can hold, which RFC 1035 section 2.3.4 fixes for the presentation form +this library stores. A domain with no terminator, or one longer than a domain +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. + +The in-memory APIs remain subject to PHP string, platform, address-space and +available-memory limits. Applications accepting untrusted OWIDs must choose +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. + ## Installation Require the package with Composer. diff --git a/src/Creator.php b/src/Creator.php index 5fa39da..f77d820 100644 --- a/src/Creator.php +++ b/src/Creator.php @@ -35,9 +35,15 @@ final class Creator /** * Creates a new creator for the domain using the crypto instance for - * signing. + * signing. The domain is bounded here, at the earliest point the caller + * can be told, so a creator configured with a domain longer than a + * domain name can hold is refused when the domain is supplied rather + * than when an OWID is later serialized. The check comes before the + * crypto instance is looked at, so nothing is signed with a domain this + * same library would then refuse to read. * - * @throws OwidException when the domain is empty or whitespace, or the + * @throws OwidException when the domain is empty or whitespace, is + * longer than a domain name can hold, or the * crypto instance can not sign. */ public function __construct(string $domain, Crypto $crypto) @@ -45,6 +51,9 @@ public function __construct(string $domain, Crypto $crypto) if (trim($domain) === '') { throw OwidException::invalidDomain($domain); } + if (strlen($domain) > OwidException::MAXIMUM_DOMAIN_LENGTH) { + throw OwidException::domainTooLong(); + } if (!$crypto->canSign()) { throw OwidException::keyMissing('generate a signature'); } @@ -55,7 +64,8 @@ public function __construct(string $domain, Crypto $crypto) /** * Creates a new creator from the domain and the private key PEM provided. * - * @throws OwidException when the domain is empty or whitespace, or the + * @throws OwidException when the domain is empty or whitespace, is + * longer than a domain name can hold, or the * private key PEM is not valid. */ public static function fromConfiguration(string $domain, string $privatePem): self diff --git a/src/Io.php b/src/Io.php index c66e939..531b9cf 100644 --- a/src/Io.php +++ b/src/Io.php @@ -50,11 +50,13 @@ public static function baseDate(): DateTimeImmutable * 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; } @@ -65,7 +67,7 @@ public function __construct(string $buffer) */ public function readByte(): int { - if ($this->position >= strlen($this->buffer)) { + if ($this->position >= $this->length) { throw OwidException::unexpectedEndOfBuffer(); } $value = ord($this->buffer[$this->position]); @@ -80,7 +82,7 @@ public function readByte(): int */ public function readBytes(int $count): string { - if ($count < 0 || $this->position + $count > strlen($this->buffer)) { + if ($count < 0 || $this->position + $count > $this->length) { throw OwidException::unexpectedEndOfBuffer(); } $value = substr($this->buffer, $this->position, $count); @@ -88,23 +90,42 @@ public function readBytes(int $count): string 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. + * 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 no terminator is found. + * @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 { - $terminator = strpos($this->buffer, "\0", $this->position); - if ($terminator === false) { + $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, - $terminator - $this->position - ); + $value = substr($this->buffer, $this->position, $count); $this->position = $terminator + 1; return $value; } @@ -116,15 +137,21 @@ public function readString(): string */ public function readUint32(): int { - $bytes = $this->readBytes(4); + if ($this->position + 4 > $this->length) { + throw OwidException::unexpectedEndOfBuffer(); + } /** @var array{1: int} $unpacked */ - $unpacked = unpack('V', $bytes); + $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. + * 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. */ @@ -134,6 +161,27 @@ public function readByteArray(): string 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. * @@ -177,16 +225,29 @@ public static function writeByte(string &$buffer, int $value): void } /** - * Writes the string followed by the null terminator. The string must not - * contain a null character as that would conflict with the terminator. + * Writes the string followed by the null terminator. The only such string + * 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 + * 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. * - * @throws OwidException when the value contains a null byte. + * @throws OwidException when the value contains a null byte, or is + * longer than a domain name can hold. */ public static function writeString(string &$buffer, string $value): void { if (strpos($value, "\0") !== false) { throw OwidException::invalidDomain($value); } + if (strlen($value) > OwidException::MAXIMUM_DOMAIN_LENGTH) { + throw OwidException::domainTooLong(); + } $buffer .= $value . "\0"; } diff --git a/src/Owid.php b/src/Owid.php index 6e29f42..6b9a9ae 100644 --- a/src/Owid.php +++ b/src/Owid.php @@ -83,21 +83,33 @@ public static function fromBase64(string $value): self } /** - * Creates an OWID from its binary form. + * Creates an OWID from its complete binary form. Bytes missing from or + * following the envelope are refused. * - * @throws OwidException when the version is unknown or the buffer is too - * short for the remaining fields. + * @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. */ public static function fromByteArray(string $buffer): self { - return self::fromReader(new Io($buffer)); + $reader = new Io($buffer); + $owid = self::fromReader($reader); + if ($reader->remaining() !== 0) { + throw new OwidException( + "OWID contains '" . $reader->remaining() . + "' bytes after the envelope" + ); + } + return $owid; } /** - * Creates an OWID by reading the next fields from the reader. + * Creates an OWID by reading its next fields from the reader. Bytes after + * the signature are left for a caller that frames multiple values. * - * @throws OwidException when the version is unknown or the buffer is too - * short. + * @throws OwidException when the version is unknown, the buffer is too + * short, or the declared payload length does not + * match the bytes present. */ public static function fromReader(Io $reader): self { @@ -109,7 +121,7 @@ public static function fromReader(Io $reader): self } $owid->domain = $reader->readString(); $owid->date = $reader->readDate($version); - $owid->payload = $reader->readByteArray(); + $owid->payload = $reader->readPayload(); $owid->signature = $reader->readSignature(); return $owid; } diff --git a/src/OwidException.php b/src/OwidException.php index 326585b..49a0d65 100644 --- a/src/OwidException.php +++ b/src/OwidException.php @@ -34,6 +34,18 @@ final class OwidException extends Exception */ public const SIGNATURE_LENGTH = 64; + /** + * The greatest number of characters an OWID domain can hold. RFC 1035 + * section 2.3.4, "Size limits", restricts the total length of a domain + * name, counting label octets and label length octets, to 255 octets or + * less. That 255 is the wire format, which spends one length octet on + * every label and one zero octet on the root, whereas OWID stores the + * presentation form, the text "example.com", where the dots stand in for + * the label length octets and the root has no text at all, so exactly + * two of those 255 octets have no character here and the limit is 253. + */ + public const MAXIMUM_DOMAIN_LENGTH = 253; + /** * The version byte is not one supported by this implementation. */ @@ -79,6 +91,26 @@ public static function invalidDomain(string $domain): self return new self("domain '$domain' is not valid"); } + /** + * 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. + */ + public static function domainTooLong(): self + { + $maximum = self::MAXIMUM_DOMAIN_LENGTH; + return new self( + "OWID domain is longer than the '$maximum' characters a domain " . + "name can hold" + ); + } + /** * The date can not be represented in the encoding used by the version. */ @@ -89,6 +121,22 @@ 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/tests/DomainLengthTest.php b/tests/DomainLengthTest.php new file mode 100644 index 0000000..e7fbeb3 --- /dev/null +++ b/tests/DomainLengthTest.php @@ -0,0 +1,348 @@ + 64) { + $labels[] = str_repeat('a', 63); + $remaining -= 64; + } + $labels[] = str_repeat('a', $remaining); + $domain = implode('.', $labels); + if (strlen($domain) !== $length) { + throw new OwidException('test domain built to the wrong length'); + } + return $domain; + } + + /** + * 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. + */ + private static function envelope(string $domain): string + { + $buffer = ''; + Io::writeByte($buffer, Version::Version3->asByte()); + $buffer .= $domain . chr(0); + Io::writeUint32($buffer, 1000); + Io::writeUint32($buffer, 0); + return $buffer . str_repeat("\x99", self::SIGNATURE_LENGTH); + } + + /** + * 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. + */ + private function refusal(string $bytes, string $label): string + { + try { + Owid::fromByteArray($bytes); + } catch (OwidException $e) { + $this->addToAssertionCount(1); + return $e->getMessage(); + } + $this->fail("$label should have been refused"); + } + + /** + * A domain of the greatest length a domain name can hold parses, and the + * envelope round trips back to the same bytes. + */ + public function testMaximumLengthDomainParses(): void + { + $domain = self::domain(OwidException::MAXIMUM_DOMAIN_LENGTH); + $bytes = self::envelope($domain); + + $owid = Owid::fromByteArray($bytes); + + $this->assertSame($domain, $owid->domain); + $this->assertSame( + OwidException::MAXIMUM_DOMAIN_LENGTH, + strlen($owid->domain) + ); + $this->assertSame($bytes, $owid->asByteArray()); + } + + /** + * One character more than a domain name can hold is refused, even though + * the terminator is present, because the parse stops at the bound. + */ + 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); + } + + /** + * Returns the seconds taken to refuse the bytes the number of times + * given, and puts the last refusal message into the reference given. + */ + private function timeRefusals( + string $bytes, + int $attempts, + string &$message + ): float { + $start = hrtime(true); + for ($attempt = 0; $attempt < $attempts; $attempt++) { + $message = $this->refusal($bytes, 'unterminated domain'); + } + return (hrtime(true) - $start) / 1e9; + } + + /** + * A buffer whose domain field has no terminator at all is refused for a + * 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. + */ + 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 = ''; + + $smallSeconds = $this->timeRefusals($small, 1000, $message); + $largeSeconds = $this->timeRefusals($large, 1000, $message); + + $this->assertLessThan( + 4 * $smallSeconds + 0.05, + $largeSeconds, + "sixteen times the buffer took {$largeSeconds}s against " . + "{$smallSeconds}s, so the cost grows with the buffer" + ); + $this->assertLessThan( + 1.0, + $largeSeconds, + "unterminated domain took {$largeSeconds}s for 1,000 attempts" + ); + $maximum = OwidException::MAXIMUM_DOMAIN_LENGTH; + $this->assertStringContainsString("'$maximum'", $message); + if (function_exists('memory_reset_peak_usage')) { + $before = memory_get_usage(); + memory_reset_peak_usage(); + $this->refusal($large, 'unterminated domain'); + $peak = memory_get_peak_usage() - $before; + $this->assertLessThan( + 64 * 1024, + $peak, + "unterminated domain raised peak memory by $peak bytes" + ); + } + } + + /** + * 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. + */ + public function testDomainFillingTheBoundWithNoTerminatorIsRefused(): void + { + $bytes = chr(Version::Version3->asByte()) . + str_repeat('a', OwidException::MAXIMUM_DOMAIN_LENGTH); + + $this->refusal($bytes, 'domain filling the bound'); + } + + /** + * A creator is refused a domain one character longer than a domain name + * can hold, at the point the domain is supplied, so the caller is told + * when the configuration is wrong rather than when an OWID is later + * serialized. Both ways of making a creator are covered, and the message + * names the maximum. + */ + 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()); + } + } + + /** + * 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. + */ + 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() + ); + } + } + + $owid->domain = self::domain($maximum); + $parsed = Owid::fromByteArray($owid->asByteArray()); + $this->assertSame($owid->domain, $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. + */ + 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' + ); + } + + /** + * 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. + */ + public function testLibraryOutputParses(): void + { + $domains = [ + '51d.es', + self::domain(OwidException::MAXIMUM_DOMAIN_LENGTH), + ]; + foreach ($domains as $domain) { + $crypto = Crypto::new(); + $creator = new Creator($domain, $crypto); + $original = $creator->signString('value'); + + $parsed = Owid::fromByteArray($original->asByteArray()); + + $this->assertSame($domain, $parsed->domain); + $this->assertTrue( + $parsed->verifyWithCrypto($crypto), + "parsed copy of '$domain' should verify" + ); + } + } +} diff --git a/tests/PayloadLengthTest.php b/tests/PayloadLengthTest.php new file mode 100644 index 0000000..1b302b2 --- /dev/null +++ b/tests/PayloadLengthTest.php @@ -0,0 +1,266 @@ +asByte()); + Io::writeString($buffer, '51d.es'); + Io::writeUint32($buffer, 1000); + Io::writeUint32($buffer, $declaredLength); + return $buffer . $payload . $signature; + } + + private static function payload(): string + { + return str_repeat("\x5A", self::PAYLOAD_LENGTH); + } + + private static function signature( + int $length = self::SIGNATURE_LENGTH + ): string { + return str_repeat("\x99", $length); + } + + /** + * 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. + */ + private function refusal(string $bytes, string $label): string + { + try { + Owid::fromByteArray($bytes); + } catch (OwidException $e) { + $this->addToAssertionCount(1); + return $e->getMessage(); + } + $this->fail("$label should have been refused"); + } + + /** + * 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( + self::PAYLOAD_LENGTH, + self::payload(), + self::signature() + )); + $this->assertSame(self::payload(), $owid->payload); + $this->assertSame(self::signature(), $owid->signature); + $this->assertSame('51d.es', $owid->domain); + } + + /** + * A payload materially larger than an ordinary identifier remains valid + * when its declaration and bytes agree. Application policy is separate + * from format validity. + */ + public function testMatchingOneMebibytePayloadParses(): void + { + $payload = str_repeat("\x5A", 1024 * 1024); + + $owid = Owid::fromByteArray(self::envelope( + strlen($payload), + $payload, + self::signature() + )); + + $this->assertSame($payload, $owid->payload); + } + + /** + * A round trip through the library's own signing path still parses and + * verifies, so the check agrees with what the library itself produces. + */ + public function testLibraryOutputParses(): void + { + $crypto = Crypto::new(); + $creator = new Creator('51d.es', $crypto); + $original = $creator->signBytes(self::payload()); + $parsed = Owid::fromByteArray($original->asByteArray()); + $this->assertSame(self::payload(), $parsed->payload); + $this->assertTrue( + $parsed->verifyWithCrypto($crypto), + 'parsed copy should verify' + ); + } + + /** + * 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" + ); + } + } + + /** + * A byte after the signature is refused because a top-level decoder + * requires the signature to end the envelope. + */ + public function testTrailingByteAfterSignatureIsRefused(): void + { + $bytes = self::envelope( + self::PAYLOAD_LENGTH, + self::payload(), + self::signature() + ); + $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. + */ + public function testShortSignatureIsRefused(): void + { + $bytes = self::envelope( + self::PAYLOAD_LENGTH, + 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); + } + + /** + * 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. + */ + public function testMismatchedLargeDeclarationIsRefusedQuickly(): void + { + $declaredLengths = [64 * 1024 * 1024, 0x7FFFFFFF, 0xFFFFFFFF]; + foreach ($declaredLengths as $declared) { + $bytes = self::envelope($declared, '', ''); + $message = ''; + $start = hrtime(true); + for ($attempt = 0; $attempt < 1000; $attempt++) { + $message = $this->refusal($bytes, "declared $declared"); + } + $elapsed = (hrtime(true) - $start) / 1e9; + $this->assertStringContainsString("'$declared'", $message); + $this->assertLessThan( + 1.0, + $elapsed, + "declared $declared took {$elapsed}s for 1,000 attempts" + ); + if (function_exists('memory_reset_peak_usage')) { + $before = memory_get_usage(); + memory_reset_peak_usage(); + $this->refusal($bytes, "declared $declared"); + $peak = memory_get_peak_usage() - $before; + $this->assertLessThan( + 64 * 1024, + $peak, + "declared $declared raised peak memory by $peak bytes" + ); + } + } + } + + /** + * An empty payload, declared length zero, followed by the signature is a + * valid OWID and parses. + */ + public function testEmptyPayloadParses(): void + { + $owid = Owid::fromByteArray(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. + */ + public function testFromReaderLeavesFollowingEnvelopeUnread(): void + { + $firstBytes = self::envelope( + self::PAYLOAD_LENGTH, + self::payload(), + self::signature() + ); + $secondBytes = self::envelope(0, '', self::signature()); + $reader = new Io($firstBytes . $secondBytes); + + $first = Owid::fromReader($reader); + $this->assertSame(self::payload(), $first->payload); + $this->assertSame(strlen($secondBytes), $reader->remaining()); + + $second = Owid::fromReader($reader); + $this->assertSame('', $second->payload); + $this->assertSame(0, $reader->remaining()); + } +} diff --git a/tests/run.php b/tests/run.php index 65f7043..e622f71 100644 --- a/tests/run.php +++ b/tests/run.php @@ -329,4 +329,245 @@ function () { Endpoints::publicKeyPath(Version::Version3) === '/owid/api/v3/public-key' ); +// Payload length. The declared length is checked against the bytes present +// before anything is sized by it, and exactly the signature must follow. +function payloadEnvelope(int $declared, string $payload, string $signature): string +{ + $buffer = ''; + Io::writeByte($buffer, Version::Version3->asByte()); + Io::writeString($buffer, '51d.es'); + Io::writeUint32($buffer, 1000); + Io::writeUint32($buffer, $declared); + return $buffer . $payload . $signature; +} +$lengthPayload = str_repeat("\x5A", 37); +$lengthSignature = str_repeat("\x99", 64); +$runner->check( + 'matching payload length parses', + Owid::fromByteArray(payloadEnvelope(37, $lengthPayload, $lengthSignature))->payload === $lengthPayload +); +$largeLengthPayload = str_repeat("\x5A", 1024 * 1024); +$runner->check( + 'matching one mebibyte payload parses', + Owid::fromByteArray(payloadEnvelope( + strlen($largeLengthPayload), + $largeLengthPayload, + $lengthSignature + ))->payload === $largeLengthPayload +); +unset($largeLengthPayload); +$runner->check( + 'empty payload with signature parses', + Owid::fromByteArray(payloadEnvelope(0, '', $lengthSignature))->payload === '' +); +foreach ([36, 38] as $declared) { + $runner->checkThrows( + "payload length $declared off by one refused", + fn () => Owid::fromByteArray(payloadEnvelope($declared, $lengthPayload, $lengthSignature)) + ); +} +$runner->checkThrows( + 'trailing byte after signature refused', + fn () => Owid::fromByteArray(payloadEnvelope(37, $lengthPayload, $lengthSignature) . "\x00") +); +$runner->checkThrows( + '63 byte signature refused', + fn () => Owid::fromByteArray(payloadEnvelope(37, $lengthPayload, str_repeat("\x99", 63))) +); +foreach ([64 * 1024 * 1024, 0x7FFFFFFF, 0xFFFFFFFF] as $declared) { + $refused = true; + $start = hrtime(true); + for ($attempt = 0; $attempt < 1000; $attempt++) { + try { + Owid::fromByteArray(payloadEnvelope($declared, '', '')); + $refused = false; + } catch (OwidException $e) { + } + } + $elapsed = (hrtime(true) - $start) / 1e9; + $runner->check( + "declared length $declared refused 1000 times in under a second", + $refused && $elapsed < 1.0 + ); +} + +// 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. +function domainOfLength(int $length): string +{ + $labels = []; + $remaining = $length; + while ($remaining > 64) { + $labels[] = str_repeat('a', 63); + $remaining -= 64; + } + $labels[] = str_repeat('a', $remaining); + 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. +function domainEnvelope(string $domain): string +{ + $buffer = ''; + Io::writeByte($buffer, Version::Version3->asByte()); + $buffer .= $domain . chr(0); + Io::writeUint32($buffer, 1000); + Io::writeUint32($buffer, 0); + return $buffer . str_repeat("\x99", 64); +} +$maximumDomain = domainOfLength(OwidException::MAXIMUM_DOMAIN_LENGTH); +$maximumBytes = domainEnvelope($maximumDomain); +$maximumOwid = Owid::fromByteArray($maximumBytes); +$runner->check( + 'domain of the greatest length parses', + $maximumOwid->domain === $maximumDomain +); +$runner->check( + 'domain of the greatest length round trips byte exact', + $maximumOwid->asByteArray() === $maximumBytes +); +$runner->checkThrows( + 'domain one character over the greatest length refused', + fn () => Owid::fromByteArray( + domainEnvelope(domainOfLength(OwidException::MAXIMUM_DOMAIN_LENGTH + 1)) + ) +); +$runner->checkThrows( + 'domain filling the bound with no terminator refused', + fn () => Owid::fromByteArray( + chr(Version::Version3->asByte()) . + str_repeat('a', OwidException::MAXIMUM_DOMAIN_LENGTH) + ) +); +// 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 +// 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. +// The small allowance absorbs timer noise, because at the bound both runs +// take only a few thousandths of a second. +function timeDomainRefusals(string $bytes, int $attempts, bool &$refused): float +{ + $start = hrtime(true); + for ($attempt = 0; $attempt < $attempts; $attempt++) { + try { + Owid::fromByteArray($bytes); + $refused = false; + } catch (OwidException $e) { + } + } + return (hrtime(true) - $start) / 1e9; +} +$versionPrefix = chr(Version::Version3->asByte()); +$smallUnterminated = $versionPrefix . str_repeat('a', 1024 * 1024); +$largeUnterminated = $versionPrefix . str_repeat('a', 16 * 1024 * 1024); +$refusedUnterminated = true; +$smallSeconds = timeDomainRefusals($smallUnterminated, 1000, $refusedUnterminated); +$largeSeconds = timeDomainRefusals($largeUnterminated, 1000, $refusedUnterminated); +$runner->check( + 'unterminated domain refused for a cost that does not grow with the buffer', + $refusedUnterminated && $largeSeconds < 4 * $smallSeconds + 0.05 +); +$runner->check( + 'unterminated domain refused 1000 times in under a second', + $refusedUnterminated && $largeSeconds < 1.0 +); +unset($smallUnterminated, $largeUnterminated); +$maximumCrypto = Crypto::new(); +$maximumSigned = (new Creator($maximumDomain, $maximumCrypto))->signString('value'); +$maximumParsed = Owid::fromByteArray($maximumSigned->asByteArray()); +$runner->check( + 'signed OWID with the greatest length domain parses 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. +function domainRefusalNamesMaximum(callable $action): bool +{ + try { + $action(); + } catch (OwidException $e) { + return str_contains( + $e->getMessage(), + "'" . OwidException::MAXIMUM_DOMAIN_LENGTH . "'" + ); + } + return false; +} +$overLongDomain = domainOfLength(OwidException::MAXIMUM_DOMAIN_LENGTH + 1); +$runner->check( + 'creator refuses a domain over the greatest length, naming the maximum', + domainRefusalNamesMaximum( + fn () => new Creator($overLongDomain, $maximumCrypto) + ) +); +$runner->check( + 'creator from configuration refuses a domain over the greatest length', + domainRefusalNamesMaximum( + fn () => Creator::fromConfiguration( + $overLongDomain, + $maximumCrypto->privateKeyPem() + ) + ) +); +$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 +); +// 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( + fn () => new Creator( + $overLongDomain, + Crypto::newVerifyOnly($maximumCrypto->publicKeyPem()) + ) + ) +); +// 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());