Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
207 changes: 166 additions & 41 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -58,23 +60,22 @@ 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
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

Expand All @@ -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;
Expand All @@ -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

Expand Down
65 changes: 15 additions & 50 deletions src/Creator.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,6 @@

namespace SwanCommunity\Owid;

use DateTimeImmutable;

/**
* Needed to create new OWIDs.
*
Expand Down Expand Up @@ -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<int, Owid> $others
* @param array<int, Owid> $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);
}
}
Loading
Loading