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
126 changes: 122 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,12 @@ creates, signs, serializes, and verifies OWIDs.

This package provides the core data structure, the binary and base 64 wire
format, the ECDSA signing and verification, a creator that binds a domain to a
signing key, and framework agnostic helpers for the well known end points. It
has no network access of its own, so retrieving a creator public key over HTTP
is left to the caller.
signing key, framework agnostic helpers for the well known end points, and
the fetch of another creator's public key from its well known end point. The
core has no network access of its own. The one module that reaches the
network is `owid.public_key_fetch`, which uses the standard library `urllib`,
is imported only by a caller that asks for it, and takes a transport of the
caller's own where `urllib` is not the right client.

Version 3 is the current version produced for new OWIDs. Versions 1 and 2 are
deprecated and are supported for reading existing data only.
Expand Down Expand Up @@ -153,6 +156,85 @@ else:
pass
```

## Verifying an identifier signed in an earlier week

Creators rotate their signing key, weekly in the case of the 51Degrees cloud,
so the key that is current when an identifier is checked is not the key that
signed the identifier unless the check happens in the same week. Verifying
anything older than a few days means asking for the key that was in force on
the date the identifier carries.

`owid.public_key_fetch` asks the creator for that key. The request is
`/owid/api/v{n}/public-key?date={minutes}&format=pkcs`, where the version in
the path is the version byte of the identifier being checked and the minutes
are counted from 2020-01-01 in the same way the identifier stores its date. A
creator that ignores the parameter returns its current key, so every
identifier it signed under an earlier key reads as not matching, which is why
a creator that rotates its key has to honour the date. Keys already fetched
are held against the URL they came from, which names the domain, the version
and the minute, up to 1024 of them before the store is emptied, and
`clear_cache()` empties it on demand. Each request waits at most ten seconds.

```python
from owid import SignatureStatus, public_key_fetch

# A creator on a domain that cannot exist, so the example shows the shape of
# the call and the status a key that cannot be obtained produces.
remote_creator = Creator("creator.invalid", Crypto.new())
remote = remote_creator.create_string("from another creator")

fetched = public_key_fetch.signature_status(remote, "https")
if fetched is SignatureStatus.KEY_UNAVAILABLE:
# The key could not be obtained, so the signature was never examined.
# Only SIGNATURE_INVALID means the identifier should be distrusted.
pass
assert fetched is SignatureStatus.KEY_UNAVAILABLE
```

A caller whose environment needs its own HTTP client passes a transport as
the last argument, being a callable that takes the URL and the timeout in
seconds, returns the response code and the body as bytes, and raises
`OSError` where no response could be obtained at all.

Where the whole published schedule is already held, `PublicKeySchedule`
chooses the key without any request. The rule is the one the cloud itself
applies, being the latest key whose start is at or before the date asked
about.

```python
from datetime import datetime, timezone
from owid import DatedPublicKey, PublicKeySchedule

last_week_pem = Crypto.new().public_key_pem()
schedule = PublicKeySchedule([
DatedPublicKey(datetime(2026, 8, 24, tzinfo=timezone.utc), last_week_pem),
DatedPublicKey(
datetime(2026, 8, 31, tzinfo=timezone.utc), crypto.public_key_pem()
),
])
chosen = schedule.key_for(owid)
assert schedule.signature_status(owid) is SignatureStatus.SIGNATURE_VALID
```

Both examples are run by `tests/test_readme.py`, as the rest of the examples
in this file are. The fetch one runs against a creator domain in the reserved
`.invalid` name space, so it shows the status a key that cannot be obtained
produces, whilst the case where the key does arrive and the identifier
verifies is covered by `tests/test_public_key_fetch.py` against a stand in on
the loopback address.

The only date a key carries here is the date the key came into force. The
moment key material was generated is not that date and plays no part in the
choice, because a creator may generate several weeks of keys in one run, and
a key whose period has not started has signed nothing.

A creator that rotates its key answers the date parameter of its own public
key end point with `endpoints.public_key_response_at`, which returns the
status code and body for the request: the key in force at the date asked, the
key in force now for a request without a date or with a date later than now,
404 where no key is in force, and 400 where the date is not a count of
minutes.

## How an OWID comes into being

An OWID is only worth anything because it is signed, so a caller cannot build
Expand Down Expand Up @@ -340,6 +422,37 @@ opaque crypto error.
with the `domain`, `name`, `publicKeySPKI`, and `contractURL` fields.
- `public_key_response(creator, format)` returns the public key PEM. The
format must be `spki` or `pkcs`.
- `public_key_response_at(schedule, format, date, now=None)` returns the
status code and body for a creator that rotates its key, choosing from a
`PublicKeySchedule` the way the specification requires.

`public_key_fetch`

- `public_key_url(owid, scheme)` builds the request, naming the version of the
OWID and the minute the OWID was signed.
- `public_key_pem(owid, scheme, transport=None)` returns the key, raising
`PublicKeyFetchError`, which carries the status to report, the domain and
the response code.
- `signature_status(owid, scheme, others=None, transport=None)` answers with
the status, so a key that could not be fetched is `KEY_UNAVAILABLE`, one
that could not be read is `INVALID_KEY`, and neither is mistaken for a
signature that does not match. `verify` takes the same arguments and
answers True only for `SIGNATURE_VALID`.
- `clear_cache()` empties the keys already fetched.

`PublicKeySchedule` and `DatedPublicKey`

- `PublicKeySchedule(keys)` takes the keys in any order.
- `key_in_force(date)` and `key_for(owid)` return the latest key whose start
is at or before the date, or the date of the OWID, and None where the
schedule does not reach back that far.
- `current()` returns the key in force now, and `last()` the key with the
latest start, which for a schedule published ahead of time is usually a
key that has not begun. `signature_status(owid, others=None)` chooses the
key and answers with the status, and `verify` answers True only for
`SIGNATURE_VALID`.
- `DatedPublicKey(starts_at, public_key_pem)` is one key and the date the key
came into force, both read only. A naive datetime is read as UTC.

## Data structure notes

Expand Down Expand Up @@ -391,7 +504,12 @@ behaviour of each module. `tests/test_parse_contract.py` holds the cross
language status matrix, being the reasons a read reports and the proof that an
OWID cannot be held unsigned. `tests/test_readme.py` runs the Python examples
in this file in the order they appear, so documentation naming a method that
does not exist fails the build. Run them from the repository root.
does not exist fails the build. `tests/test_public_key_fetch.py` drives the
real fetch against a stand in for a creator's public key end point on the
loopback address, serving the published 51d.es schedule, and
`tests/test_public_key_schedule.py` checks the choice of key against a genuine
identifier the 51Degrees cloud issued on 4 September 2026. Run them from the
repository root.

```
python -m unittest discover
Expand Down
9 changes: 8 additions & 1 deletion owid/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,29 +30,36 @@
from . import endpoints
from .creator import Configuration, Creator
from .crypto import Crypto
from .error import OwidError
from .error import OwidError, PublicKeyFetchError
from .io import SIGNATURE_LENGTH
from .owid import Owid
from .parse import ParseResult
from .public_key_schedule import DatedPublicKey, PublicKeySchedule
from .status import ParseStatus, SignatureStatus
from .version import DEFAULT_VERSION, Version

__all__ = [
"Configuration",
"Creator",
"Crypto",
"DatedPublicKey",
"OwidError",
"PublicKeyFetchError",
"Owid",
# A caller cannot act on a read without naming the reason it carries, so
# the result and both status vocabularies sit beside the type they
# describe rather than in a module a reader has to go looking for.
"ParseResult",
"ParseStatus",
"PublicKeySchedule",
"SignatureStatus",
"Version",
"DEFAULT_VERSION",
"SIGNATURE_LENGTH",
"endpoints",
# public_key_fetch is imported by the caller that wants it, as
# "from owid import public_key_fetch", so importing the package never
# loads the network client.
]

__version__ = "0.1.0"
67 changes: 67 additions & 0 deletions owid/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,22 @@
public key of the creator, and the public key end point at
/owid/api/v{version}/public-key returning the public key as PEM text. The
format query parameter must be spki or pkcs.

A creator that rotates its signing key answers the optional date parameter of
the public key end point with public_key_response_at, which chooses from the
published schedule the way the specification requires.
"""

from __future__ import annotations

import json
from datetime import datetime, timedelta, timezone
from typing import Optional, Tuple, Union

from . import io
from .creator import Creator
from .error import OwidError
from .public_key_schedule import PublicKeySchedule
from .version import Version


Expand Down Expand Up @@ -75,3 +83,62 @@ def public_key_response(creator: Creator, format: str) -> str:
"format parameter 'spki' or 'pkcs' must be provided, "
"received '{0}'".format(format)
)


def public_key_response_at(
schedule: PublicKeySchedule,
format: str,
date: Union[str, int, None],
now: Optional[datetime] = None,
) -> Tuple[int, str]:
"""Returns the status code and text body for the public key end point of
a creator that rotates its key, chosen from the schedule the way the
specification requires.

The date parameter is the OWID's own date, counted in whole minutes since
2020-01-01, and the key served is the one in force then, being the latest
key whose start is at or before it. A request without a date, or with a
date later than the moment of the request, is served the key in force at
that moment, so a caller cannot ask for a key whose period has not begun.
The answer is 200 with the PEM, 404 with an empty body where no key is in
force at the date, and 400 with an empty body where the date is not a
count of minutes. The moment of the request is now, and a test may supply
it.

Raises OwidError if the format is not spki or pkcs.
"""
if format not in ("spki", "pkcs"):
raise OwidError(
"format parameter 'spki' or 'pkcs' must be provided, "
"received '{0}'".format(format)
)
moment = now if now is not None else datetime.now(timezone.utc)
asked = moment
if date is not None and date != "":
minutes = _minutes(date)
if minutes is None:
return 400, ""
if minutes <= io.MAXIMUM_MINUTES:
asked = io.BASE_DATE + timedelta(minutes=minutes)
if asked > moment:
asked = moment
key = schedule.key_in_force(asked)
if key is None:
return 404, ""
return 200, key.public_key_pem


def _minutes(date: Union[str, int]) -> Optional[int]:
"""The date parameter as a count of minutes, or None where it is not an
unsigned 32 bit integer, written in decimal digits when it is text."""
if isinstance(date, bool):
return None
if isinstance(date, int):
value = date
elif isinstance(date, str) and date.isascii() and date.isdigit():
value = int(date)
else:
return None
if value < 0 or value > 0xFFFFFFFF:
return None
return value
34 changes: 32 additions & 2 deletions owid/error.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@
# License for the specific language governing permissions and limitations
# under the License.
# ****************************************************************************
"""The error type raised across the package.
"""The error types raised across the package.

A single exception type carries a human readable message. It is raised where
OwidError carries a human readable message. It is raised where
the fault lies in the calling code or in the local key material, being a
domain or payload that cannot be written, a key that cannot be imported or
exported, an attempt to construct an OWID directly, and a version the writer
Expand All @@ -25,13 +25,43 @@
are an ordinary outcome, so the parse surfaces answer with a ParseResult
carrying a ParseStatus, and a signature that cannot be judged is reported as a
SignatureStatus rather than as an exception.

PublicKeyFetchError is the one subclass. It is raised by public_key_fetch
when the public key of another creator could not be obtained, and it carries
the status to report so that the caller never mistakes an outage for a
forgery.
"""

from __future__ import annotations

from .status import SignatureStatus


class OwidError(Exception):
"""Raised when an OWID can not be created, written, signed, or verified,
and never for external data that turns out not to be an OWID."""

pass


class PublicKeyFetchError(OwidError):
"""Raised by owid.public_key_fetch when the public key of a creator could
not be obtained.

Carries the status a caller should report for the identifier, which is
never a signature that does not match because the signature was never
examined, the domain the key was asked of, and the response code, which
is 0 where no response arrived at all.
"""

def __init__(
self,
message: str,
status: SignatureStatus,
domain: str,
status_code: int = 0,
) -> None:
super().__init__(message)
self.status = status
self.domain = domain
self.status_code = status_code
13 changes: 13 additions & 0 deletions owid/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,19 @@ def read_date(self, version: Version) -> datetime:
raise OwidError("OWID version '{0}' not supported".format(version.as_byte()))


def minutes_since_base(date: datetime) -> int:
"""Returns the whole minutes from the base date to the date, or -1 where
the count cannot be held in the four byte field of versions 2 and 3, being
a date before the base or beyond the field. The arithmetic is the one
write_date uses, so the value a fetch names is the value the OWID
carries."""
delta = date - BASE_DATE
minutes = int(delta.total_seconds() // 60)
if minutes < 0 or minutes > 0xFFFFFFFF:
return -1
return minutes


def write_byte(buffer: bytearray, value: int) -> None:
"""Appends a single byte."""
buffer.append(value)
Expand Down
Loading
Loading