From be98022aed33ac9d831e0a20f28e1bb577ffc49b Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 5 Sep 2026 20:02:38 +0100 Subject: [PATCH] Add the dated public key fetch and the key schedule Python had none of The package could verify only with a key the caller had already obtained, so every application had to build the request to a creator's well known end point, choose the key in force on the OWID's date, cache and time out on its own. The other ports carry that inside the library, and the specification (SWAN-community/owid#7) says a verifier sends the OWID's own date every time. public_key_fetch asks /owid/api/v{n}/public-key?date={minutes}&format=pkcs on the domain the OWID carries, at the version the OWID carries, through urllib with a ten second timeout. Keys are held per request URL up to 1024, a domain that is not a host name or a scheme that is not HTTP is refused before anything is opened, and a key that cannot be had or read answers KEY_UNAVAILABLE or INVALID_KEY rather than a forgery. A caller may pass a transport of its own. PublicKeySchedule and DatedPublicKey choose the key in force at a date out of a published schedule, being the latest start at or before the date, with current() the key in force now and last() documented as not that. endpoints.public_key_response_at answers the date parameter for a creator that rotates its key as the specification requires. io.minutes_since_base shares write_date's arithmetic. The core keeps no network access of its own: the fetch is imported only by a caller that asks for it, and a contract test scans the package for any other route to the network. The tests drive the real fetch against a stand in for the end point on the loopback address serving the published 51d.es schedule, and check the choice of key against a genuine identifier the 51Degrees cloud issued on 4 September 2026, the fixtures the Java port uses. 161 tests pass, README examples included. --- README.md | 126 +++++++- owid/__init__.py | 9 +- owid/endpoints.py | 67 +++++ owid/error.py | 34 ++- owid/io.py | 13 + owid/public_key_fetch.py | 309 +++++++++++++++++++ owid/public_key_schedule.py | 201 +++++++++++++ tests/data/identifier.txt | 6 + tests/data/public-key-schedule.txt | 42 +++ tests/key_end_point.py | 163 ++++++++++ tests/key_fixtures.py | 118 ++++++++ tests/test_network_contract.py | 117 ++++++++ tests/test_public_key_fetch.py | 424 +++++++++++++++++++++++++++ tests/test_public_key_response_at.py | 163 ++++++++++ tests/test_public_key_schedule.py | 283 ++++++++++++++++++ 15 files changed, 2068 insertions(+), 7 deletions(-) create mode 100644 owid/public_key_fetch.py create mode 100644 owid/public_key_schedule.py create mode 100644 tests/data/identifier.txt create mode 100644 tests/data/public-key-schedule.txt create mode 100644 tests/key_end_point.py create mode 100644 tests/key_fixtures.py create mode 100644 tests/test_network_contract.py create mode 100644 tests/test_public_key_fetch.py create mode 100644 tests/test_public_key_response_at.py create mode 100644 tests/test_public_key_schedule.py diff --git a/README.md b/README.md index 772c0b9..bfa4b00 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 @@ -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 @@ -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 diff --git a/owid/__init__.py b/owid/__init__.py index 3892e7b..44d7fd2 100644 --- a/owid/__init__.py +++ b/owid/__init__.py @@ -30,10 +30,11 @@ 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 @@ -41,18 +42,24 @@ "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" diff --git a/owid/endpoints.py b/owid/endpoints.py index 813a107..8b1d3a9 100644 --- a/owid/endpoints.py +++ b/owid/endpoints.py @@ -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 @@ -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 diff --git a/owid/error.py b/owid/error.py index 9db5667..007cbde 100644 --- a/owid/error.py +++ b/owid/error.py @@ -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 @@ -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 diff --git a/owid/io.py b/owid/io.py index 7916653..287cc3c 100644 --- a/owid/io.py +++ b/owid/io.py @@ -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) diff --git a/owid/public_key_fetch.py b/owid/public_key_fetch.py new file mode 100644 index 0000000..aea6a8b --- /dev/null +++ b/owid/public_key_fetch.py @@ -0,0 +1,309 @@ +# **************************************************************************** +# Copyright 2026 51 Degrees Mobile Experts Limited (51degrees.com) +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# **************************************************************************** +"""Fetches the signing public key of a creator from the well known end point +on the domain the OWID carries, asking for the key that was in force on the +date the OWID carries. + +The end point is /owid/api/v{n}/public-key?date={minutes}&format=pkcs, where +the version in the path is the version byte of the OWID being checked rather +than a constant, and the minutes are counted from 2020-01-01 in the same way +the OWID stores the date. Creators rotate weekly, so without the date only +identifiers signed since the most recent rotation can be verified and every +older one reads as not matching. 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. + +Only the standard library is used, through urllib, so the package keeps its +promise of no dependency beyond cryptography. This module is the one place in +the package that reaches the network. It is imported only when a caller asks +for it, and every function takes a transport of the caller's own for an +environment where urllib is not the right client. + +The Java port answers the same question with PublicKeyFetch, the Rust port +with Owid::verify_status and the Go port with SignatureStatusFromDomain. +""" + +from __future__ import annotations + +import http.client +import threading +import urllib.error +import urllib.parse +import urllib.request +from typing import Callable, Dict, Optional, Sequence, Tuple + +from . import endpoints, io +from .error import OwidError, PublicKeyFetchError +from .owid import Owid +from .status import SignatureStatus + +#: How long to wait for the connection and then for the response, in seconds. +TIMEOUT_SECONDS = 10.0 + +#: The most keys held before the cache is emptied and filled again. A bound is +#: needed because a verifier sees identifiers from many domains and many +#: weeks, and an unbounded store would grow for as long as the process runs. +MAXIMUM_CACHED_KEYS = 1024 + +#: The most bytes accepted from a response. A public key PEM is a few hundred +#: bytes, so a body beyond this is not a key and is not held or decoded. +MAXIMUM_RESPONSE_BYTES = 65536 + +#: A transport takes the URL and the timeout in seconds and returns the +#: response code and the body. It raises OSError where no response could be +#: obtained at all, which is what urllib raises for a refused connection, a +#: name that does not resolve and a timeout. Supply one where urllib is not +#: the right client, for example behind a proxy that needs its own set up. +Transport = Callable[[str, float], Tuple[int, bytes]] + +#: The schemes that make an HTTP request. A caller chooses the scheme, and one +#: that reads something other than a creator, such as file, is refused rather +#: than opened. +_ACCEPTED_SCHEMES = ("http", "https") + +#: Keys already fetched, held against the URL they were fetched from. +#: +#: The specification asks implementations to cache so that verifying many +#: identifiers does not mean repeating requests to another processor. Holding +#: the key against the whole URL is safe because the URL names the domain, +#: the version and the minute, and the key a creator published for a minute +#: in the past does not change. +_cache: Dict[str, str] = {} +_lock = threading.Lock() + + +def public_key_url(owid: Owid, scheme: str) -> str: + """Returns the URL of the public key end point for the OWID, using the + scheme provided, which is normally https. + + The date the OWID carries is sent as the date parameter, counted in whole + minutes from 2020-01-01, so that a creator which rotates its key returns + the key that was in force when this OWID was signed. The parameter is left + out where the date cannot be counted, which no OWID this package reads can + be, because the wire format cannot hold such a date. + + Raises OwidError if the OWID or the scheme is missing, or the domain the + OWID carries is not a domain name this package will put in a URL. + """ + if owid is None: + raise OwidError("the OWID is missing") + if scheme is None or not scheme.strip(): + raise OwidError("the scheme is missing") + domain = owid.domain + _check_domain(domain) + minutes = io.minutes_since_base(owid.date) + if minutes >= 0: + query = "date={0}&format=pkcs".format(minutes) + else: + query = "format=pkcs" + return "{0}://{1}{2}?{3}".format( + scheme, domain, endpoints.public_key_path(owid.version), query + ) + + +def public_key_pem( + owid: Owid, scheme: str, transport: Optional[Transport] = None +) -> str: + """Returns the public key PEM of the creator of the OWID, for the date the + OWID carries. + + Raises PublicKeyFetchError if the key could not be obtained, carrying the + status to report for the identifier, and OwidError if the OWID, the scheme + or the domain is not usable. + """ + return _public_key_pem_at_url( + public_key_url(owid, scheme), owid.domain, transport + ) + + +def signature_status( + owid: Owid, + scheme: str, + others: Optional[Sequence[Owid]] = None, + transport: Optional[Transport] = None, +) -> SignatureStatus: + """Says whether the signature on the OWID is genuine, fetching the key + that was in force when the OWID was signed from the creator domain. + + A key that cannot be fetched is SignatureStatus.KEY_UNAVAILABLE and one + that arrives in a form this package cannot read is + SignatureStatus.INVALID_KEY. Neither is SignatureStatus.SIGNATURE_INVALID, + because an outage or a badly served key leaves the signature unjudged, and + reporting either as invalid would read as an attack. + + Pass the other OWIDs that were signed together with this one, in the same + order as when signed, or nothing when it was signed on its own. + """ + try: + url = public_key_url(owid, scheme) + except OwidError: + return SignatureStatus.KEY_UNAVAILABLE + return _signature_status_at_url(owid, url, others, transport) + + +def verify( + owid: Owid, + scheme: str, + others: Optional[Sequence[Owid]] = None, + transport: Optional[Transport] = None, +) -> bool: + """Returns True only when the signature verifies under the key the + creator served for the date the OWID carries. Every other outcome, a + signature that does not match included, is False, so ask + signature_status where the difference changes what the caller does.""" + status = signature_status(owid, scheme, others, transport) + return status is SignatureStatus.SIGNATURE_VALID + + +def clear_cache() -> None: + """Empties the cache of keys already fetched. Provided so that a long + running process can release the memory, and so that a test can start from + a known state.""" + with _lock: + _cache.clear() + + +def _signature_status_at_url( + owid: Owid, + url: str, + others: Optional[Sequence[Owid]] = None, + transport: Optional[Transport] = None, +) -> SignatureStatus: + """The work signature_status does once the URL is known, kept apart so + that the tests drive the real fetch against a key end point the tests can + stand up locally rather than against a near copy of the fetch.""" + try: + pem = _public_key_pem_at_url(url, owid.domain, transport) + except PublicKeyFetchError as failed: + return failed.status + except OwidError: + return SignatureStatus.KEY_UNAVAILABLE + return owid.signature_status(pem, others) + + +def _public_key_pem_at_url( + url: str, domain: str, transport: Optional[Transport] = None +) -> str: + """Fetches the PEM at the URL, answering from the cache where the same URL + has already been fetched.""" + with _lock: + cached = _cache.get(url) + if cached is not None: + return cached + pem = _read(url, domain, transport) + with _lock: + if len(_cache) >= MAXIMUM_CACHED_KEYS: + _cache.clear() + _cache[url] = pem + return pem + + +def _read(url: str, domain: str, transport: Optional[Transport]) -> str: + """Performs the request and returns the body as text.""" + scheme = urllib.parse.urlsplit(url).scheme.lower() + if scheme not in _ACCEPTED_SCHEMES: + # A scheme the caller chose that does not make an HTTP request, such + # as file. Refused before anything is opened, because every route + # into this module promises to ask a creator and nothing else. + raise PublicKeyFetchError( + "the scheme used for domain {0} does not make an HTTP " + "request".format(_quoted(domain)), + SignatureStatus.KEY_UNAVAILABLE, + domain, + ) + try: + code, body = (transport or _urllib_transport)(url, TIMEOUT_SECONDS) + except (OSError, ValueError, http.client.HTTPException) as failed: + # A refused connection, a name that does not resolve and a timeout + # all arrive here, and all of them mean the signature was never + # examined. + raise PublicKeyFetchError( + "the public key could not be fetched from domain {0}".format( + _quoted(domain) + ), + SignatureStatus.KEY_UNAVAILABLE, + domain, + ) from failed + if code != 200: + raise PublicKeyFetchError( + "domain {0} returned code '{1}' for the public key".format( + _quoted(domain), code + ), + SignatureStatus.KEY_UNAVAILABLE, + domain, + code, + ) + if len(body) > MAXIMUM_RESPONSE_BYTES: + raise PublicKeyFetchError( + "domain {0} returned more than a key for the public key".format( + _quoted(domain) + ), + SignatureStatus.KEY_UNAVAILABLE, + domain, + code, + ) + return body.decode("utf-8", errors="replace") + + +def _urllib_transport(url: str, timeout: float) -> Tuple[int, bytes]: + """The transport used unless the caller supplies one. A refusal carrying + a response code is returned as that code, and only the failure to obtain + any response at all is raised.""" + request = urllib.request.Request( + url, headers={"Accept": "text/plain"}, method="GET" + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + return response.status, response.read(MAXIMUM_RESPONSE_BYTES + 1) + except urllib.error.HTTPError as refused: + # A response arrived, so the code is the answer. The body of a + # refusal is not a key and is not read. + refused.close() + return refused.code, b"" + + +def _check_domain(domain: str) -> None: + """Refuses a domain that would change the shape of the URL rather than + name a host in it. + + The domain arrives inside an OWID, which came from outside, so the text is + not the package's own. Letters, digits, dots and hyphens are all a domain + name needs, and anything else could add a query, a fragment, a port, + credentials or a path and send the request somewhere other than the + creator. + """ + if not domain: + raise OwidError("the OWID carries no domain") + for character in domain: + allowed = ( + "a" <= character <= "z" + or "A" <= character <= "Z" + or "0" <= character <= "9" + or character in ".-" + ) + if not allowed: + # The domain is not repeated back, because the text arrived from + # outside and a refusal is often logged. + raise OwidError( + "the domain in the OWID is not a domain name this package " + "will request a key from" + ) + + +def _quoted(value: str) -> str: + """The value in single quotes, for a message.""" + return "'" + value + "'" diff --git a/owid/public_key_schedule.py b/owid/public_key_schedule.py new file mode 100644 index 0000000..db73c19 --- /dev/null +++ b/owid/public_key_schedule.py @@ -0,0 +1,201 @@ +# **************************************************************************** +# Copyright 2026 51 Degrees Mobile Experts Limited (51degrees.com) +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# **************************************************************************** +"""The signing public keys a creator has published, held so that the key +which was in force at any date can be found. + +Creators rotate weekly, 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 choosing the +right key out of the schedule, and this module holds the rule for that choice +in one place so every caller makes the same choice. + +The rule is the one the cloud itself applies, being the latest key whose +start is at or before the date asked about. Keys are generated in batches, +often many weeks ahead of the weeks the keys cover, so the moment key +material was generated says nothing about which key signed anything and is +not held here at all. Selecting on a generation moment picks a key that has +not started yet and reports a genuine identifier as not matching, which is +what the .NET port did before that port was fixed. + +A date the schedule does not reach, being one earlier than the first start, +has no key. That answer is reported as SignatureStatus.KEY_UNAVAILABLE rather +than as a signature that does not match, because with no key the signature +was never examined. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Iterable, Optional, Sequence, Tuple + +from .error import OwidError +from .owid import Owid +from .status import SignatureStatus + + +def _aware(moment: datetime) -> datetime: + """A naive datetime is read as UTC, which is the only zone the wire format + knows, so a caller who builds starts from calendar dates without naming a + zone gets the comparison the dates were meant to have.""" + if moment.tzinfo is None: + return moment.replace(tzinfo=timezone.utc) + return moment + + +class DatedPublicKey: + """One signing public key together with the moment the key came into + force. The key stays in force until the next key in the schedule starts. + Both values are read only.""" + + __slots__ = ("_starts_at", "_public_key_pem") + + def __init__(self, starts_at: datetime, public_key_pem: str) -> None: + """Raises OwidError if the start is missing or the PEM is empty.""" + if starts_at is None: + raise OwidError("the start of the key is missing") + if not isinstance(starts_at, datetime): + raise OwidError("the start of the key must be a datetime") + if public_key_pem is None or not public_key_pem.strip(): + raise OwidError("public key PEM is empty") + self._starts_at = _aware(starts_at) + self._public_key_pem = public_key_pem + + @property + def starts_at(self) -> datetime: + """The moment from which this key signs, in UTC.""" + return self._starts_at + + @property + def public_key_pem(self) -> str: + """The public key in Subject Public Key Info PEM form, as the public + key end point serves the key.""" + return self._public_key_pem + + def __repr__(self) -> str: + return "DatedPublicKey(starts_at={0!r})".format( + self._starts_at.isoformat() + ) + + +class PublicKeySchedule: + """The keys a creator has published, oldest start first, and the rule + that picks the key in force at a date.""" + + __slots__ = ("_keys",) + + def __init__(self, keys: Iterable[DatedPublicKey]) -> None: + """Holds the keys provided, which may arrive in any order. + + Where two keys share a start, the one supplied first wins, which is + how the 51Degrees cloud and the .NET port settle it. A creator does + not publish two keys for one start, so the case is settled rather + than left to chance. + + Raises OwidError if the collection is missing or holds a missing key. + """ + if keys is None: + raise OwidError("the collection of keys is missing") + ordered = list(keys) + for key in ordered: + if key is None or not isinstance(key, DatedPublicKey): + raise OwidError("a key in the schedule is missing") + # The sort is stable, so keys sharing a start keep the order they + # were supplied in and the first supplied stays first. + ordered.sort(key=lambda key: key.starts_at) + self._keys: Tuple[DatedPublicKey, ...] = tuple(ordered) + + @property + def keys(self) -> Tuple[DatedPublicKey, ...]: + """The keys held, oldest start first. The tuple cannot be changed.""" + return self._keys + + def __len__(self) -> int: + return len(self._keys) + + def key_in_force(self, date: Optional[datetime]) -> Optional[DatedPublicKey]: + """Returns the key that was in force at the date given, being the + latest key whose start is at or before that date, or None where the + schedule begins after the date. A naive datetime is read as UTC.""" + if date is None: + return None + moment = _aware(date) + index = len(self._keys) - 1 + while index >= 0: + key = self._keys[index] + if key.starts_at <= moment: + # Keys sharing a start sit in the order supplied, and the + # first supplied is the answer. + while ( + index > 0 + and self._keys[index - 1].starts_at == key.starts_at + ): + index -= 1 + key = self._keys[index] + return key + index -= 1 + return None + + def last(self) -> Optional[DatedPublicKey]: + """Returns the key with the latest start, or None where the schedule + holds no keys. + + This is not the key in force now. A creator publishes its schedule + ahead of time, so the last key by start is usually one whose period + has not begun and which has signed nothing yet. The key in force now + is current(). Serving the last key where the current one was meant is + the same fault as selecting by the generation moment, being a key + from a period that has not started, and it is the fault the .NET port + carried in its answer to a request that named no date. + """ + if not self._keys: + return None + return self._keys[-1] + + def current(self) -> Optional[DatedPublicKey]: + """Returns the key in force now, being the latest key whose start is + at or before the current moment, or None where no key has started. + This is what a creator serves for a request that names no date.""" + return self.key_in_force(datetime.now(timezone.utc)) + + def key_for(self, owid: Optional[Owid]) -> Optional[DatedPublicKey]: + """Returns the key that signed the OWID, being the key in force at the + date the OWID carries, or None where the schedule does not reach back + to that date.""" + if owid is None: + return None + return self.key_in_force(owid.date) + + def signature_status( + self, owid: Optional[Owid], others: Optional[Sequence[Owid]] = None + ) -> SignatureStatus: + """Says whether the signature on the OWID is genuine, using the key + that was in force when the OWID was signed. The answer is + SignatureStatus.KEY_UNAVAILABLE where the schedule holds no key for + the date, because the signature was never examined.""" + if owid is None: + return SignatureStatus.KEY_UNAVAILABLE + key = self.key_for(owid) + if key is None: + return SignatureStatus.KEY_UNAVAILABLE + return owid.signature_status(key.public_key_pem, others) + + def verify( + self, owid: Optional[Owid], others: Optional[Sequence[Owid]] = None + ) -> bool: + """Returns True only when the signature verifies under the key in + force when the OWID was signed.""" + status = self.signature_status(owid, others) + return status is SignatureStatus.SIGNATURE_VALID diff --git a/tests/data/identifier.txt b/tests/data/identifier.txt new file mode 100644 index 0000000..d49215d --- /dev/null +++ b/tests/data/identifier.txt @@ -0,0 +1,6 @@ +# A genuine 51Did creator context identifier, created on +# 2026-09-04 by the 51Degrees cloud for the creator domain +# 51d.es. It is public and carries no secret. +# +# One record, being the identifier as base 64. +AzUxZC5lcwDAkTUAOAAAAAFkcBNhYL0rPISwcMhuxl0ezIe0Nywzr/lhMtzX5QjydfVQA40Aw6iylkIfGYQWecrhzDfF8g11B1KtuOBxa98MEyJZOXw3PB/EBuyOfGxZ/pu6rlt8zpCyIn1Prbu7V300bQBqrqyYfU0nNeZf/PCwLtXJRak76A== diff --git a/tests/data/public-key-schedule.txt b/tests/data/public-key-schedule.txt new file mode 100644 index 0000000..2a8ac61 --- /dev/null +++ b/tests/data/public-key-schedule.txt @@ -0,0 +1,42 @@ +# The published 51d.es signing public key schedule, being thirty +# weekly keys from 11 May to 30 November 2026, as the public key +# end point serves them. Public material, no secret. +# +# One record per key, space separated, being the date the key came +# into force, the moment the key material was generated, and the +# base 64 body of the Subject Public Key Info PEM. +# +# The last thirteen keys share one generation moment while starting +# on thirteen different weeks, because the keys were generated as +# a batch. That is the shape which broke selection by the +# generation moment in the .NET port. +2026-05-11T00:00:00Z 2026-05-13T07:53:00Z MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1S5u0ke8IvXO9tKBBjkNgUTLkMRRHcdbVUCYIDZbhOvvU2Sa7OTVfb/jDwcHwd/KVrl33uHBFHuH2E/UdBXBSw== +2026-05-18T00:00:00Z 2026-05-18T05:31:00Z MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEMaXEnA/hG6e9ZBIBxRnqaOspCS2fMDZ7MRaGMVo2CL0hcqdI4rP+KJll83xUxQq0nwwo11j/C2BwWfCbfcjFbw== +2026-05-25T00:00:00Z 2026-05-25T05:41:00Z MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE7GW67FrL7KfFZpxWt94xM4mgqtXB3jLL1gE/bcLaXVO7Myj5eo38oGTggos5iWAxlpY0bFUuqHSTkffQ+2jxog== +2026-06-01T00:00:00Z 2026-06-01T05:46:00Z MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEOUaE86mCaq/kUcSbfdp6lIckGvPdnbDr++RVrj6WmJbOXpvdKk/nFfPb27TYz1P/mT0/YgGLsNRii/DK41Ag2g== +2026-06-08T00:00:00Z 2026-06-08T05:44:00Z MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE6FK+7t+HkffkPe9Yhqtuy+lt8bgLPGUYty3nFid3mGub6QnIJFkyj9aAPe3VRcPY77Oaux78Xy+2tcdSBb91LA== +2026-06-15T00:00:00Z 2026-06-15T05:57:00Z MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEYU6uwIX2RJ6YtC516iCEITSs0W++RLM/Yo3ZmM2iCYe/HfSgqJwfy7ioVce3784+/YPgkmX8LBNwJTarVy/SmA== +2026-06-22T00:00:00Z 2026-06-22T05:59:00Z MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEJTOx47gMykTRMwsZq6xMrwO0+2nypeIxKfa6gvPWpBvDFFJMbNR9cUbHzrb0iFs1WzlKfEAjSrCtxaSfsNXkoA== +2026-06-29T00:00:00Z 2026-06-29T05:44:00Z MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEoKU47SHVpEjqD9txbXA4/StmOz2O1taTquaTes9lE+0jHB9/A8EUcodt8+oAG/YRRA6pjpe01/FlkgKIKWrhlw== +2026-07-06T00:00:00Z 2026-07-06T05:33:00Z MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEx2jFmRxOqUH74IRCEPgNG0/e7FyNeWstmBx0GVRNORYou5LtA++OlygptcuPRy/XqSy+/etiz6o8SzUiZ2F4qQ== +2026-07-13T00:00:00Z 2026-07-13T05:15:00Z MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEhBOts6uqkfMIjfkNtoyYxNNAFtA1Rvkx7ZdvbvlQDDgze7YadBUmvJwMNzBNeOeyAIshl8FklLB8R2VpyAkIRg== +2026-07-20T00:00:00Z 2026-07-20T05:19:00Z MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAElZGKUk21yUH5DaJj41X3NiqDUoGaiGgq/mZFQcTQkHQ2mr/Y6W6JbcSRiRFfTT21bpYd7BkatRotGtpHxXPuvQ== +2026-07-27T00:00:00Z 2026-07-27T05:22:00Z MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEayA4kh6boraOurRdy4epwDcW5GaytuKdpqdLtcz5yEtH4bpmJxx74ZGbStWuilAWvLutBOWp0mAjpUu5JgPE3A== +2026-08-03T00:00:00Z 2026-08-03T05:18:00Z MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEZMR1ut4GRhbc1dOxwXree/efQTtgDqzK2dicaCRO5Pz4E5ms3rDksBrv382MI0W7BgrSPR5VAtPEA93S/xcmFg== +2026-08-10T00:00:00Z 2026-08-10T04:35:00Z MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2UwwNdW+L68uziCcJUegj49Xi07PY4EGiGgMlESa2xoJfwVHoUc6oqRNqrPBnsrqQYsU2uYlR9QOWIomui5qEw== +2026-08-17T00:00:00Z 2026-08-17T04:18:00Z MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEpcTDCZlVsO1glWnDqfBiTOZysOIzdx2eFdPbrndBTWz7fu588pjZACM2CCs03dslwehiCuOHqJtdmHQv41V6Yw== +2026-08-24T00:00:00Z 2026-08-24T04:19:00Z MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEJ7YEHH6liCiSn6jmcZXXydRRiCARHaZHkyazSq1bsUipbYOmz3kCrk1r3DmqrqiKBWZZS64yrGckLf5UkNsteg== +2026-08-31T00:00:00Z 2026-08-31T04:21:00Z MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEilbjpzqx8zx3oShIDgDoGTcBqivw08FKqFvRtX8rCS4Ftguvu2RwXDFLEwho0IhD/boxM1IwZ6u34hwkhf5AiQ== +2026-09-07T00:00:00Z 2026-09-01T10:54:00Z MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE0KhXTjSD4xyW7Zc9d+kHWPzhx1yZ7c3gAMCF0iFHJokWejq9FrWSVEc9bMBJPv1Br/q/oIiPTfPuhiOJ1xMkFQ== +2026-09-14T00:00:00Z 2026-09-01T10:54:00Z MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEEXKsoKyAZRkK9o1k4heQjAzUjHEPPWFnE0KKA5RI9wZHQffQcrK6rb2hcnObeZsYz7OQ/FneOMWpeiqK03OKHA== +2026-09-21T00:00:00Z 2026-09-01T10:54:00Z MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEFGOV57ItWDb76wzvzx+m2GWsJ+dX5OvtQFdPkAFV9OL4w0ntiVG0iUzZEGDRyCE5Xla82jhvA8YILz0gTdMFHQ== +2026-09-28T00:00:00Z 2026-09-01T10:54:00Z MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEEi85ASgFPo4LerVK/QU3J5mdaW4oCYRzjqNqLzRjXWoiAQTyB7LB6MwDXSv4svsh7SNpjNmOFXcjVJ3CwwoNjA== +2026-10-05T00:00:00Z 2026-09-01T10:54:00Z MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEoRogg/dXpCiV3psJPu9ffND7JvBmFg9gOgRlXrCXFBIIoEWvGb/7q1MsquKoTF1uKkHy7HEJTkr9CUvSd83CcQ== +2026-10-12T00:00:00Z 2026-09-01T10:54:00Z MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEBkAwOOX+Oykv6xHewe0CJuXzSGtspAFEc82QhwzlanYJlY1lHLuP2D1xsEGOqsKXf9BOb/SesRjNHSTmi6ZGpw== +2026-10-19T00:00:00Z 2026-09-01T10:54:00Z MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEkkUuirYrxuFQmWjYZRWGSyvJuYcAOWz4zIP6c33VOKqEEtkvGJDoCV0Qm6EdgGJspDRQ2d7dtbKV2kMPnKW7UA== +2026-10-26T00:00:00Z 2026-09-01T10:54:00Z MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEnqgWDgDUXTqY2ZMOePx7qBFGgD/wepMX7HmpyRWr0XyZSUzeq2duV+VMJzNiZmQ0x34F1auNd+Dcf/3JkvXa/w== +2026-11-02T00:00:00Z 2026-09-01T10:54:00Z MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAENlPq/5aO/kU6VbLMipaZMukRQFreMsE7Tp+8CqIZA7HPi/ikrgCOOeimi5QURTjXin8JoUiVNae3SFxBRwTsYQ== +2026-11-09T00:00:00Z 2026-09-01T10:54:00Z MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAET3UEAs41VZiXt+SmzEzL/4pyJJxVwoY+sD/haSnvyRUxSurp7ju8ACZm/bDKOPtM9xmZtRbmQaU2y221EB+Okw== +2026-11-16T00:00:00Z 2026-09-01T10:54:00Z MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEgEbUOGc6d+rJNVrW6FSozCr3MXYrjDf1ap19zD2pJZaRI0ORxs5A19mAEf11Nl64NT0gPI2ls9RVnMDY1tq8sg== +2026-11-23T00:00:00Z 2026-09-01T10:54:00Z MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEKAazotnWdw0hbDJMRJjdCwa5EaemMfwA2+Aks6g7NelJ4GsU4yLhITmrmcFWexSz3JzCa+wN255rZMqiIFSHIA== +2026-11-30T00:00:00Z 2026-09-01T10:54:00Z MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEhDrkhpx28KTSNqXvs8vEXoUJEmLDqEyaVONFOZxgsnJehJqBtHVOevjptFfY6G1E6T4Opi4hH6m+/elwh8R2aA== diff --git a/tests/key_end_point.py b/tests/key_end_point.py new file mode 100644 index 0000000..235d225 --- /dev/null +++ b/tests/key_end_point.py @@ -0,0 +1,163 @@ +# **************************************************************************** +# Copyright 2026 51 Degrees Mobile Experts Limited (51degrees.com) +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# **************************************************************************** +"""A stand in for the public key end point of a creator, answering the way +the cloud controller does and serving the real published 51d.es schedule. + +The live end point answers 401 without a credential, so the tests stand this +up on the loopback address instead, which is what the Java, Rust and Go ports +do for the same reason. + +A request naming a date is served the key that was in force then, a request +without one is served the key in force at the moment of the request, a date +after that moment is read as that moment, and a date the schedule does not +reach is a 404. That is how the cloud answers, and the moment of the request +is fixed at REQUEST_MOMENT so the tests are repeatable. The date parameter of +every request is recorded, so a test can say what went over the wire rather +than only what the URL builder returned. +""" + +from __future__ import annotations + +import threading +import urllib.parse +from datetime import datetime, timedelta, timezone +from enum import Enum +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import List, Optional + +from owid import Owid, io +from owid.public_key_fetch import public_key_url + +from tests import key_fixtures + +#: The moment the end point treats as now, ten days after the fixture +#: identifier was signed and in the week that followed. Every port's stand in +#: uses this moment. An undated request is therefore served a key other than +#: the one that signed the fixture, exactly as it would be against the live +#: creator in that week. +REQUEST_MOMENT = datetime(2026, 9, 14, tzinfo=timezone.utc) + + +class Answer(Enum): + """What the end point serves.""" + + #: The published schedule, chosen by the date requested. + SCHEDULE = "schedule" + #: Text shaped like a PEM that no key can be read out of. + BROKEN_KEY = "broken-key" + + +class _Server(ThreadingHTTPServer): + allow_reuse_address = True + daemon_threads = True + end_point: "KeyEndPoint" + + +class _Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: # noqa: N802 - the name is the protocol's. + end_point = self.server.end_point # type: ignore[attr-defined] + query = urllib.parse.urlsplit(self.path).query + values = urllib.parse.parse_qs(query, keep_blank_values=True) + date = values.get("date", [None])[0] + end_point.record(date) + try: + body = end_point.body(date) + except ValueError: + # A date that is not a number is refused, as the cloud refuses + # it, rather than failing inside the handler. + self.send_response(400) + self.send_header("Content-Length", "0") + self.end_headers() + return + if body is None: + self.send_response(404) + self.send_header("Content-Length", "0") + self.end_headers() + return + data = body.encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/plain") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def log_message(self, format: str, *args: object) -> None: # noqa: A002 + # The test output is for the assertions, not for the request log. + pass + + +class KeyEndPoint: + """A running stand in, listening on the loopback address.""" + + def __init__(self, answer: Answer = Answer.SCHEDULE) -> None: + self._answer = answer + self._schedule = key_fixtures.schedule() + self._dates: List[Optional[str]] = [] + self._lock = threading.Lock() + self._server = _Server(("127.0.0.1", 0), _Handler) + self._server.end_point = self + self.base = "http://127.0.0.1:{0}".format(self._server.server_address[1]) + self._thread = threading.Thread( + target=self._server.serve_forever, daemon=True + ) + self._thread.start() + + def stop(self) -> None: + """Stops the end point, after which its address refuses connections.""" + self._server.shutdown() + self._server.server_close() + self._thread.join(timeout=5) + + def url_for(self, owid: Owid) -> str: + """The URL a fetch would use, with the creator domain replaced by this + end point. The path and the query are the ones the package builds, so + what is under test is the real URL rather than a copy of it.""" + built = public_key_url(owid, "http") + at = built.index(owid.domain) + return self.base + built[at + len(owid.domain) :] + + def dates(self) -> List[Optional[str]]: + """The date parameter of every request served so far, in order.""" + with self._lock: + return list(self._dates) + + def record(self, date: Optional[str]) -> None: + with self._lock: + self._dates.append(date) + + def body(self, date: Optional[str]) -> Optional[str]: + """The body to serve, or None where the end point has no key. Raises + ValueError where the date is not a count of minutes.""" + if self._answer is Answer.BROKEN_KEY: + # Shaped like a PEM, with a body no key can be read out of. This + # is the 30 August 2026 fault, where the end points served PEM a + # strict parser refused and good identifiers went unverified. + return ( + "-----BEGIN PUBLIC KEY-----\n" + "bm90IGEga2V5\n" + "-----END PUBLIC KEY-----\n" + ) + asked = REQUEST_MOMENT + if date is not None: + minutes = int(date) + if minutes <= io.MAXIMUM_MINUTES: + asked = io.BASE_DATE + timedelta(minutes=minutes) + if asked > REQUEST_MOMENT: + asked = REQUEST_MOMENT + key = self._schedule.key_in_force(asked) + if key is None: + return None + return key.public_key_pem diff --git a/tests/key_fixtures.py b/tests/key_fixtures.py new file mode 100644 index 0000000..9eba750 --- /dev/null +++ b/tests/key_fixtures.py @@ -0,0 +1,118 @@ +# **************************************************************************** +# Copyright 2026 51 Degrees Mobile Experts Limited (51degrees.com) +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# **************************************************************************** +"""A genuine identifier and the published signing key schedule of the creator +that issued it, shared with the other OWID ports so that every port is checked +against the same real data. + +The identifier is a 51Did creator context identifier the 51Degrees cloud +issued on 4 September 2026 for the creator domain 51d.es. The schedule is the +thirty weekly keys the public key end point served for that domain from +11 May to 30 November 2026. Both are public and carry no secret. +""" + +from __future__ import annotations + +import os +from datetime import datetime, timezone +from typing import List, NamedTuple + +from owid import Owid, ParseStatus +from owid.public_key_schedule import DatedPublicKey, PublicKeySchedule + +_DATA = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data") + +#: The date the identifier carries, in whole minutes since 2020-01-01, which +#: is 2026-09-04T00:00:00Z because the cloud dates an identifier to the day, +#: and is the value a fetch names. +IDENTIFIER_MINUTES = 3_510_720 + +#: The creator domain the identifier carries. +IDENTIFIER_DOMAIN = "51d.es" + +#: The start of the key that signed the identifier, being the week that was +#: running on 4 September 2026. +WEEK_OF_THE_IDENTIFIER = datetime(2026, 8, 31, tzinfo=timezone.utc) + + +class ScheduledKey(NamedTuple): + """One record of the published schedule, being the moment the key came + into force, the moment the key material was generated, and the PEM.""" + + starts_at: datetime + created: datetime + pem: str + + +def identifier() -> Owid: + """The genuine identifier, read from the fixture.""" + value = _records("identifier.txt") + assert len(value) == 1, "the fixture holds one identifier" + result = Owid.parse(value[0]) + assert result.status is ParseStatus.PARSED, result.status + assert result.owid is not None + return result.owid + + +def scheduled_keys() -> List[ScheduledKey]: + """Every record of the published schedule, in the order published.""" + keys = [] + for record in _records("public-key-schedule.txt"): + fields = record.split(" ") + assert len(fields) == 3, "a record is a start, a generation moment and a key" + keys.append( + ScheduledKey( + _instant(fields[0]), _instant(fields[1]), pem(fields[2]) + ) + ) + return keys + + +def schedule() -> PublicKeySchedule: + """The published schedule as the package holds it, with only the start of + each key, because the generation moment plays no part in the choice.""" + return PublicKeySchedule( + DatedPublicKey(key.starts_at, key.pem) for key in scheduled_keys() + ) + + +def pem(body: str) -> str: + """Wraps the base 64 body of a Subject Public Key Info into the PEM form + the public key end point serves.""" + lines = ["-----BEGIN PUBLIC KEY-----"] + for start in range(0, len(body), 64): + lines.append(body[start : start + 64]) + lines.append("-----END PUBLIC KEY-----") + return "\n".join(lines) + "\n" + + +def _instant(value: str) -> datetime: + """Reads a moment written the way the schedule writes them, for example + 2026-08-31T00:00:00Z.""" + return datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace( + tzinfo=timezone.utc + ) + + +def _records(name: str) -> List[str]: + """Reads a fixture, dropping the comment lines and the blank ones.""" + path = os.path.join(_DATA, name) + with open(path, "r", encoding="utf-8") as handle: + lines = handle.read().splitlines() + return [ + line.strip() + for line in lines + if line.strip() and not line.lstrip().startswith("#") + ] diff --git a/tests/test_network_contract.py b/tests/test_network_contract.py new file mode 100644 index 0000000..abf76a9 --- /dev/null +++ b/tests/test_network_contract.py @@ -0,0 +1,117 @@ +# **************************************************************************** +# Copyright 2026 51 Degrees Mobile Experts Limited (51degrees.com) +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# **************************************************************************** +"""No read can cause a request. The one module that reaches the network is +public_key_fetch, which a caller reaches only by importing it, and nothing +else in the package imports it. The source is scanned for the ways Python +reaches the network, which is a stronger statement than any single test of +the parse path, and the same contract the PHP port keeps.""" + +from __future__ import annotations + +import os +import subprocess +import sys +import unittest + +_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +_SOURCE = os.path.join(_ROOT, "owid") + +#: The modules through which Python reaches the network. +_NETWORK = ("urllib", "http.client", "socket", "requests", "httpx", "aiohttp") + +#: The module that is allowed to reach it. +_FETCH = "public_key_fetch.py" + +#: The ways a module in the package could import the fetch. +_FETCH_IMPORTS = ( + "from .public_key_fetch import", + "from . import public_key_fetch", + "import owid.public_key_fetch", + "from owid.public_key_fetch import", + "from owid import public_key_fetch", +) + + +def _import_forms(module: str): + """The statements that would bring the module in, as they appear in + source, so that prose naming a module in a comment does not count.""" + return ( + "import {0}".format(module), + "from {0} import".format(module), + "from {0}.".format(module), + ) + + +class NetworkContractTests(unittest.TestCase): + def _sources(self): + for name in sorted(os.listdir(_SOURCE)): + if name.endswith(".py"): + path = os.path.join(_SOURCE, name) + with open(path, "r", encoding="utf-8") as handle: + yield name, handle.read() + + def test_nothing_but_the_fetch_reaches_the_network(self) -> None: + seen = [] + for name, source in self._sources(): + seen.append(name) + if name == _FETCH: + self.assertIn( + "import urllib.request", + source, + "the fetch reaches the network through urllib", + ) + continue + for module in _NETWORK: + for form in _import_forms(module): + self.assertNotIn( + form, source, "{0} must not reach {1}".format(name, module) + ) + for form in _FETCH_IMPORTS: + # The package's own __init__ names the import in a comment + # for the reader, which is prose and not a statement. + lines = [ + line + for line in source.splitlines() + if form in line and not line.lstrip().startswith("#") + ] + self.assertEqual( + [], lines, "{0} must not reach the fetch".format(name) + ) + self.assertIn(_FETCH, seen, "the fetch is part of the package") + + def test_importing_the_package_does_not_load_the_fetch(self) -> None: + """A caller who never asks for the fetch never loads the network + client, which is what lets the core keep its promise of no network + access of its own. Checked in a fresh interpreter, because the one + running these tests has already imported the fetch.""" + script = ( + "import sys; import owid; " + "print('owid.public_key_fetch' in sys.modules); " + "import owid.public_key_fetch; " + "print('owid.public_key_fetch' in sys.modules)" + ) + completed = subprocess.run( + [sys.executable, "-c", script], + cwd=_ROOT, + capture_output=True, + text=True, + check=True, + ) + self.assertEqual(["False", "True"], completed.stdout.split()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_public_key_fetch.py b/tests/test_public_key_fetch.py new file mode 100644 index 0000000..c4f6da5 --- /dev/null +++ b/tests/test_public_key_fetch.py @@ -0,0 +1,424 @@ +# **************************************************************************** +# Copyright 2026 51 Degrees Mobile Experts Limited (51degrees.com) +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# **************************************************************************** +"""Fetching the key that was in force when an identifier was signed, from the +well known end point on the creator domain. + +The live end point answers 401 without a credential, so these tests run +against a stand in on the loopback address which serves the real published +51d.es schedule. The URL under test is the one the package builds, with only +the host replaced, so a fault in the path or the query is caught here. +""" + +from __future__ import annotations + +import os +import pathlib +import unittest +from datetime import datetime, timedelta, timezone +from typing import List, Optional, Tuple +from unittest import mock + +from owid import ( + Creator, + Crypto, + Owid, + OwidError, + PublicKeyFetchError, + SignatureStatus, + Version, + io, + public_key_fetch, +) + +from tests import key_fixtures +from tests.key_end_point import Answer, KeyEndPoint + +#: No other OWIDs were covered by the signature on the fixture. +ALONE: List[Owid] = [] + +#: The date the fixture identifier carries, which the cloud trims to the day. +IDENTIFIER_DATE = datetime(2026, 9, 4, tzinfo=timezone.utc) + + +def crafted(version: Version, domain: str, date: datetime) -> Owid: + """Builds an OWID with the version, domain and date given and a signature + of zeroes, for the cases that are about the URL rather than the + signature. Reading it back is the only way an OWID reaches a caller, so + the bytes are written and then read.""" + buffer = bytearray() + io.write_byte(buffer, version.as_byte()) + io.write_string(buffer, domain) + io.write_date(buffer, date, version) + io.write_byte_array(buffer, b"") + io.write_signature(buffer, bytes(io.SIGNATURE_LENGTH)) + result = Owid.parse_bytes(bytes(buffer)) + assert result.ok, result.status + assert result.owid is not None + return result.owid + + +class PublicKeyFetchTests(unittest.TestCase): + def setUp(self) -> None: + # Keys are held against the URL they were fetched from, and a test + # that counts requests has to start from nothing held. + public_key_fetch.clear_cache() + self.started: List[KeyEndPoint] = [] + + def tearDown(self) -> None: + for end_point in self.started: + end_point.stop() + self.started.clear() + public_key_fetch.clear_cache() + + def end_point(self, answer: Answer = Answer.SCHEDULE) -> KeyEndPoint: + """Starts a stand in end point and stops it when the test ends.""" + end_point = KeyEndPoint(answer) + self.started.append(end_point) + return end_point + + def test_url_names_the_minute_the_identifier_was_created(self) -> None: + """The URL names the minute the identifier was created, which is the + value the end point selects a key by, and it names the well known + path from the specification.""" + self.assertEqual( + "https://51d.es/owid/api/v3/public-key?date={0}&format=pkcs".format( + key_fixtures.IDENTIFIER_MINUTES + ), + public_key_fetch.public_key_url(key_fixtures.identifier(), "https"), + "should ask 51d.es for the key in force on 4 September 2026", + ) + + def test_url_uses_the_version_the_identifier_carries(self) -> None: + """The version in the path comes from the version byte of the + identifier rather than from a constant, so an identifier written by + an earlier version asks the end point that serves that version.""" + version2 = crafted(Version.VERSION2, "example.com", IDENTIFIER_DATE) + self.assertIs(Version.VERSION2, version2.version) + self.assertEqual( + "https://example.com/owid/api/v2/public-key?date={0}&format=pkcs" + .format(key_fixtures.IDENTIFIER_MINUTES), + public_key_fetch.public_key_url(version2, "https"), + "should ask the version 2 end point", + ) + + def test_url_of_a_newly_signed_owid_names_its_own_minute(self) -> None: + creator = Creator("example.com", Crypto.new()) + owid = creator.create_string("payload") + self.assertEqual( + "https://example.com/owid/api/v3/public-key?date={0}&format=pkcs" + .format(io.minutes_since_base(owid.date)), + public_key_fetch.public_key_url(owid, "https"), + "should name the minute the OWID was signed", + ) + + def test_dated_fetch_verifies_an_identifier_from_an_earlier_key_week( + self, + ) -> None: + """The fetch asks for the key in force when the identifier was signed + and verifies it, with the identifier signed in a week earlier than + the one the end point counts as current.""" + owid = key_fixtures.identifier() + end_point = self.end_point() + self.assertIs( + SignatureStatus.SIGNATURE_VALID, + public_key_fetch._signature_status_at_url( + owid, end_point.url_for(owid), ALONE + ), + "should verify against the key in force when it was signed", + ) + self.assertEqual( + [str(key_fixtures.IDENTIFIER_MINUTES)], + end_point.dates(), + "the request should name the minute the identifier was created", + ) + + def test_undated_fetch_leaves_an_earlier_weeks_identifier_unverified( + self, + ) -> None: + """The same identifier against the same end point without the date, + which is the request a port that forgets the date makes. The end + point answers with the key in force at the moment of the request, ten + days after the identifier was signed, the signature does not match + that key, and a genuine identifier reads as a forgery.""" + owid = key_fixtures.identifier() + end_point = self.end_point() + undated = end_point.base + "/owid/api/v3/public-key?format=pkcs" + self.assertIs( + SignatureStatus.SIGNATURE_INVALID, + public_key_fetch._signature_status_at_url(owid, undated, ALONE), + "an undated request gets the key in force at the request, which " + "did not sign it", + ) + self.assertEqual([None], end_point.dates(), "the request carried no date") + + def test_a_key_the_end_point_cannot_serve_is_key_unavailable(self) -> None: + """An end point that cannot serve a key for the date leaves the + signature unjudged rather than reporting a genuine identifier as a + forgery.""" + owid = key_fixtures.identifier() + end_point = self.end_point() + # A fortnight before the schedule begins, which no key in it covers, + # so the end point answers 404 the way the cloud does. + before = key_fixtures.scheduled_keys()[0].starts_at - timedelta(days=14) + url = "{0}/owid/api/v3/public-key?date={1}&format=pkcs".format( + end_point.base, io.minutes_since_base(before) + ) + self.assertIs( + SignatureStatus.KEY_UNAVAILABLE, + public_key_fetch._signature_status_at_url(owid, url, ALONE), + "no key means the signature was never examined", + ) + + def test_a_refused_request_carries_the_status_and_the_code(self) -> None: + """The refusal carries the code and the domain, not only a message.""" + end_point = self.end_point() + url = end_point.base + "/owid/api/v3/public-key?date=0&format=pkcs" + with self.assertRaises(PublicKeyFetchError) as refused: + public_key_fetch._public_key_pem_at_url(url, "51d.es") + self.assertIs(SignatureStatus.KEY_UNAVAILABLE, refused.exception.status) + self.assertEqual(404, refused.exception.status_code) + self.assertEqual("51d.es", refused.exception.domain) + + def test_a_malformed_date_is_refused_by_the_end_point(self) -> None: + """The stand in refuses a date that is not a count of minutes with a + 400, as the cloud does, and the package reports the refusal as a key + that could not be obtained.""" + end_point = self.end_point() + url = end_point.base + "/owid/api/v3/public-key?date=abc&format=pkcs" + with self.assertRaises(PublicKeyFetchError) as refused: + public_key_fetch._public_key_pem_at_url(url, "51d.es") + self.assertIs(SignatureStatus.KEY_UNAVAILABLE, refused.exception.status) + self.assertEqual(400, refused.exception.status_code) + + def test_an_end_point_that_cannot_be_reached_is_key_unavailable( + self, + ) -> None: + """An end point that cannot be reached at all leaves the signature + unjudged. Nothing about the identifier is known, so calling it + invalid would report an outage as an attack.""" + owid = key_fixtures.identifier() + end_point = KeyEndPoint() + url = end_point.url_for(owid) + end_point.stop() + self.assertIs( + SignatureStatus.KEY_UNAVAILABLE, + public_key_fetch._signature_status_at_url(owid, url, ALONE), + "a connection that is refused leaves the signature unjudged", + ) + + def test_a_key_that_cannot_be_read_is_invalid_key(self) -> None: + """Text shaped like a PEM that holds no key is a fault in the key, and + never a signature that does not match.""" + owid = key_fixtures.identifier() + end_point = self.end_point(Answer.BROKEN_KEY) + self.assertIs( + SignatureStatus.INVALID_KEY, + public_key_fetch._signature_status_at_url( + owid, end_point.url_for(owid), ALONE + ), + ) + + def test_keys_are_held_per_request_and_not_per_domain(self) -> None: + """Keys are held against the URL they came from, which names the + minute, so two identifiers from different weeks fetch two different + keys and a key held for one week never answers for another. A store + keyed by domain alone would hand the second identifier the first + one's key.""" + end_point = self.end_point() + earlier = crafted( + Version.VERSION3, + key_fixtures.IDENTIFIER_DOMAIN, + IDENTIFIER_DATE - timedelta(days=14), + ) + later = crafted( + Version.VERSION3, key_fixtures.IDENTIFIER_DOMAIN, IDENTIFIER_DATE + ) + first = public_key_fetch._public_key_pem_at_url( + end_point.url_for(earlier), key_fixtures.IDENTIFIER_DOMAIN + ) + second = public_key_fetch._public_key_pem_at_url( + end_point.url_for(later), key_fixtures.IDENTIFIER_DOMAIN + ) + self.assertNotEqual(first, second, "two weeks, two keys") + self.assertEqual(2, len(end_point.dates()), "one request per week") + again = public_key_fetch._public_key_pem_at_url( + end_point.url_for(earlier), key_fixtures.IDENTIFIER_DOMAIN + ) + self.assertEqual(first, again, "the held key is the one fetched for that week") + self.assertEqual( + 2, len(end_point.dates()), "a week already held is not asked for again" + ) + self.assertIn("BEGIN PUBLIC KEY", first) + + def test_the_cache_is_bounded(self) -> None: + """When the store reaches its bound it is emptied and filled again, so + a long running verifier never holds more than the bound.""" + end_point = self.end_point() + weeks = [ + crafted( + Version.VERSION3, + key_fixtures.IDENTIFIER_DOMAIN, + IDENTIFIER_DATE - timedelta(days=7 * back), + ) + for back in (0, 1, 2) + ] + with mock.patch.object(public_key_fetch, "MAXIMUM_CACHED_KEYS", 2): + for week in weeks: + public_key_fetch._public_key_pem_at_url( + end_point.url_for(week), key_fixtures.IDENTIFIER_DOMAIN + ) + self.assertEqual(3, len(end_point.dates())) + # The third arrival emptied the store, so the first week is + # fetched again rather than answered from what was held. + public_key_fetch._public_key_pem_at_url( + end_point.url_for(weeks[0]), key_fixtures.IDENTIFIER_DOMAIN + ) + self.assertEqual(4, len(end_point.dates())) + + def test_a_domain_that_is_not_a_domain_name_is_refused_before_any_request( + self, + ) -> None: + """The domain arrives inside an OWID, which came from outside, so a + value that would change the shape of the URL rather than name a host + in it is refused before any request is made.""" + owid = crafted(Version.VERSION3, "51d.es/evil?x=", IDENTIFIER_DATE) + with self.assertRaises(OwidError): + public_key_fetch.public_key_url(owid, "https") + + def no_request(url: str, timeout: float) -> Tuple[int, bytes]: + self.fail("no request should be made for a refused domain") + + self.assertIs( + SignatureStatus.KEY_UNAVAILABLE, + public_key_fetch.signature_status(owid, "https", ALONE, no_request), + ) + + def test_a_scheme_that_does_not_make_an_http_request_is_refused( + self, + ) -> None: + """A caller chooses the scheme, and one that reads something other + than a creator, such as a file, is refused rather than opened.""" + owid = key_fixtures.identifier() + fixture = pathlib.Path(key_fixtures._DATA, "identifier.txt").resolve() + url = fixture.as_uri() + self.assertTrue(url.startswith("file:")) + self.assertIs( + SignatureStatus.KEY_UNAVAILABLE, + public_key_fetch._signature_status_at_url(owid, url, ALONE), + ) + with self.assertRaises(PublicKeyFetchError) as refused: + public_key_fetch._public_key_pem_at_url(url, owid.domain) + self.assertIs(SignatureStatus.KEY_UNAVAILABLE, refused.exception.status) + self.assertEqual(0, refused.exception.status_code) + + def test_a_missing_owid_or_scheme_is_refused(self) -> None: + owid = key_fixtures.identifier() + with self.assertRaises(OwidError): + public_key_fetch.public_key_url(None, "https") # type: ignore[arg-type] + with self.assertRaises(OwidError): + public_key_fetch.public_key_url(owid, "") + with self.assertRaises(OwidError): + public_key_fetch.public_key_url(owid, " ") + self.assertIs( + SignatureStatus.KEY_UNAVAILABLE, + public_key_fetch.signature_status(None, "https"), # type: ignore[arg-type] + ) + + def test_a_transport_of_the_callers_own_is_used(self) -> None: + """A caller whose environment needs its own HTTP client supplies a + transport, which is asked for the URL the package builds and whose + answer is held like any other.""" + owid = key_fixtures.identifier() + pem = key_fixtures.schedule().key_for(owid).public_key_pem + calls: List[Tuple[str, float]] = [] + + def transport(url: str, timeout: float) -> Tuple[int, bytes]: + calls.append((url, timeout)) + return 200, pem.encode("utf-8") + + self.assertIs( + SignatureStatus.SIGNATURE_VALID, + public_key_fetch.signature_status(owid, "https", ALONE, transport), + ) + self.assertTrue( + public_key_fetch.verify(owid, "https", ALONE, transport) + ) + self.assertEqual( + [ + ( + public_key_fetch.public_key_url(owid, "https"), + public_key_fetch.TIMEOUT_SECONDS, + ) + ], + calls, + "one request, for the URL the package builds, then the cache", + ) + + def test_verify_answers_true_only_for_a_genuine_signature(self) -> None: + owid = key_fixtures.identifier() + schedule = key_fixtures.schedule() + keys = schedule.keys + signing = schedule.key_for(owid) + following = keys[keys.index(signing) + 1] + + def wrong_week(url: str, timeout: float) -> Tuple[int, bytes]: + return 200, following.public_key_pem.encode("utf-8") + + self.assertFalse( + public_key_fetch.verify(owid, "https", ALONE, wrong_week), + "the following week's key did not sign the identifier", + ) + + def test_a_transport_that_raises_is_key_unavailable(self) -> None: + owid = key_fixtures.identifier() + + def unreachable(url: str, timeout: float) -> Tuple[int, bytes]: + raise OSError("no route") + + def unusable(url: str, timeout: float) -> Tuple[int, bytes]: + raise ValueError("unknown url type") + + self.assertIs( + SignatureStatus.KEY_UNAVAILABLE, + public_key_fetch.signature_status(owid, "https", ALONE, unreachable), + ) + self.assertIs( + SignatureStatus.KEY_UNAVAILABLE, + public_key_fetch.signature_status(owid, "https", ALONE, unusable), + ) + + def test_a_response_larger_than_a_key_is_refused(self) -> None: + """A body beyond the bound is not a key, and is neither held nor + decoded.""" + owid = key_fixtures.identifier() + too_large = b"x" * (public_key_fetch.MAXIMUM_RESPONSE_BYTES + 1) + + def oversized(url: str, timeout: float) -> Tuple[int, bytes]: + return 200, too_large + + with self.assertRaises(PublicKeyFetchError) as refused: + public_key_fetch.public_key_pem(owid, "https", oversized) + self.assertIs(SignatureStatus.KEY_UNAVAILABLE, refused.exception.status) + self.assertEqual(200, refused.exception.status_code) + public_key_fetch.clear_cache() + self.assertIs( + SignatureStatus.KEY_UNAVAILABLE, + public_key_fetch.signature_status(owid, "https", ALONE, oversized), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_public_key_response_at.py b/tests/test_public_key_response_at.py new file mode 100644 index 0000000..6d8e70f --- /dev/null +++ b/tests/test_public_key_response_at.py @@ -0,0 +1,163 @@ +# **************************************************************************** +# Copyright 2026 51 Degrees Mobile Experts Limited (51degrees.com) +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# **************************************************************************** +"""The public key end point of a creator that rotates its key, answering the +date parameter the way the specification requires: the key in force at the +date asked, the key in force now where no date is given or the date is later +than now, 404 where no key is in force, and 400 where the date is not a count +of minutes.""" + +from __future__ import annotations + +import unittest +from datetime import datetime, timedelta, timezone + +from owid import Crypto, DatedPublicKey, OwidError, PublicKeySchedule, endpoints, io + + +def fresh_pem() -> str: + return Crypto.new().public_key_pem() + + +class PublicKeyResponseAtTests(unittest.TestCase): + def setUp(self) -> None: + self.last_week = DatedPublicKey( + datetime(2026, 8, 24, tzinfo=timezone.utc), fresh_pem() + ) + self.this_week = DatedPublicKey( + datetime(2026, 8, 31, tzinfo=timezone.utc), fresh_pem() + ) + self.next_week = DatedPublicKey( + datetime(2026, 9, 7, tzinfo=timezone.utc), fresh_pem() + ) + self.schedule = PublicKeySchedule( + [self.next_week, self.last_week, self.this_week] + ) + # The moment of the request, in the week of 31 August 2026 with the + # following week's key already published. + self.now = datetime(2026, 9, 4, 20, 32, tzinfo=timezone.utc) + + def minutes(self, moment: datetime) -> str: + return str(io.minutes_since_base(moment)) + + def test_a_dated_request_is_served_the_key_in_force_then(self) -> None: + asked = datetime(2026, 8, 26, tzinfo=timezone.utc) + self.assertEqual( + (200, self.last_week.public_key_pem), + endpoints.public_key_response_at( + self.schedule, "pkcs", self.minutes(asked), self.now + ), + ) + self.assertEqual( + (200, self.this_week.public_key_pem), + endpoints.public_key_response_at( + self.schedule, "spki", self.minutes(self.now), self.now + ), + ) + + def test_the_date_may_arrive_as_a_number(self) -> None: + asked = datetime(2026, 8, 26, tzinfo=timezone.utc) + self.assertEqual( + (200, self.last_week.public_key_pem), + endpoints.public_key_response_at( + self.schedule, "pkcs", io.minutes_since_base(asked), self.now + ), + ) + + def test_an_undated_request_is_served_the_key_in_force_now(self) -> None: + """The key in force now is not the last key of the schedule, which is + one whose period has not begun.""" + for absent in (None, ""): + self.assertEqual( + (200, self.this_week.public_key_pem), + endpoints.public_key_response_at( + self.schedule, "pkcs", absent, self.now + ), + ) + + def test_a_future_date_is_read_as_now(self) -> None: + """A caller cannot ask for a key whose period has not begun, because a + key that has signed nothing yet has nothing to verify.""" + future = datetime(2026, 9, 8, tzinfo=timezone.utc) + self.assertEqual( + (200, self.this_week.public_key_pem), + endpoints.public_key_response_at( + self.schedule, "pkcs", self.minutes(future), self.now + ), + ) + + def test_a_date_beyond_the_calendar_is_read_as_now(self) -> None: + """The largest value the field can hold is past the year 9999, and it + is after every key, so the answer is the key in force now rather than + a failure in the date arithmetic.""" + self.assertEqual( + (200, self.this_week.public_key_pem), + endpoints.public_key_response_at( + self.schedule, "pkcs", str(0xFFFFFFFF), self.now + ), + ) + + def test_a_date_before_the_schedule_is_404(self) -> None: + before = datetime(2026, 8, 23, tzinfo=timezone.utc) + self.assertEqual( + (404, ""), + endpoints.public_key_response_at( + self.schedule, "pkcs", self.minutes(before), self.now + ), + ) + self.assertEqual( + (404, ""), + endpoints.public_key_response_at( + PublicKeySchedule([]), "pkcs", None, self.now + ), + ) + + def test_a_date_that_is_not_a_count_of_minutes_is_400(self) -> None: + for malformed in ("abc", "-1", "1.5", " 12", "+5", "4294967296", "²"): + with self.subTest(date=malformed): + self.assertEqual( + (400, ""), + endpoints.public_key_response_at( + self.schedule, "pkcs", malformed, self.now + ), + ) + self.assertEqual( + (400, ""), + endpoints.public_key_response_at(self.schedule, "pkcs", -1, self.now), + ) + self.assertEqual( + (400, ""), + endpoints.public_key_response_at(self.schedule, "pkcs", True, self.now), + ) + + def test_the_format_must_be_spki_or_pkcs(self) -> None: + with self.assertRaises(OwidError): + endpoints.public_key_response_at(self.schedule, "der", None, self.now) + + def test_the_moment_of_the_request_defaults_to_now(self) -> None: + """Without a moment supplied the clock is used, so the answer is the + latest key that has started by the time the test runs.""" + status, body = endpoints.public_key_response_at(self.schedule, "pkcs", None) + self.assertEqual(200, status) + self.assertIn("BEGIN PUBLIC KEY", body) + started = [ + key for key in self.schedule.keys + if key.starts_at <= datetime.now(timezone.utc) + ] + self.assertEqual(started[-1].public_key_pem, body) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_public_key_schedule.py b/tests/test_public_key_schedule.py new file mode 100644 index 0000000..b6df65a --- /dev/null +++ b/tests/test_public_key_schedule.py @@ -0,0 +1,283 @@ +# **************************************************************************** +# Copyright 2026 51 Degrees Mobile Experts Limited (51degrees.com) +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# **************************************************************************** +"""Choosing the key that signed an identifier out of the schedule a creator +has published, checked against a genuine identifier the 51Degrees cloud issued +on 4 September 2026 and the thirty keys the cloud published for that creator. + +The fault this guards against was found on 4 September 2026 in the .NET port, +which selected the key by the moment the key material was generated. The cloud +had generated thirteen weeks of keys in one run on 1 September, so the newest +generated key was one whose period had not begun, and a genuine identifier was +reported as not matching. +""" + +from __future__ import annotations + +import unittest +from datetime import datetime, timedelta, timezone +from typing import List + +from owid import ( + Crypto, + DatedPublicKey, + Owid, + OwidError, + PublicKeySchedule, + SignatureStatus, +) + +from tests import key_fixtures + +#: No other OWIDs were covered by the signature on the fixture. +ALONE: List[Owid] = [] + + +def fresh_pem() -> str: + return Crypto.new().public_key_pem() + + +class PublicKeyScheduleTests(unittest.TestCase): + def test_genuine_identifier_verifies_against_the_key_in_force_on_its_date( + self, + ) -> None: + """The published key for the week of 31 August 2026 verifies the + identifier created on 4 September, checked directly against the + record rather than through the schedule, so the fixture itself is + shown to be sound.""" + owid = key_fixtures.identifier() + week = [ + key + for key in key_fixtures.scheduled_keys() + if key.starts_at == key_fixtures.WEEK_OF_THE_IDENTIFIER + ] + self.assertEqual(1, len(week), "the schedule holds that week once") + self.assertIs( + SignatureStatus.SIGNATURE_VALID, + owid.signature_status(week[0].pem, ALONE), + ) + + def test_schedule_verifies_the_genuine_identifier(self) -> None: + owid = key_fixtures.identifier() + schedule = key_fixtures.schedule() + chosen = schedule.key_for(owid) + self.assertIsNotNone(chosen) + self.assertEqual(key_fixtures.WEEK_OF_THE_IDENTIFIER, chosen.starts_at) + self.assertIs( + SignatureStatus.SIGNATURE_VALID, schedule.signature_status(owid, ALONE) + ) + self.assertTrue(schedule.verify(owid, ALONE)) + + def test_a_later_weeks_key_does_not_verify_an_earlier_weeks_identifier( + self, + ) -> None: + """The key that started after the identifier was signed reports the + signature as not matching, which is the answer a verifier that took + the newest key would have given for a genuine identifier.""" + owid = key_fixtures.identifier() + schedule = key_fixtures.schedule() + keys = schedule.keys + signing = schedule.key_for(owid) + following = keys[keys.index(signing) + 1] + self.assertGreater(following.starts_at, owid.date) + self.assertIs( + SignatureStatus.SIGNATURE_INVALID, + owid.signature_status(following.public_key_pem, ALONE), + ) + + def test_the_last_key_is_not_the_key_in_force(self) -> None: + """A schedule is published ahead of time, so its last key is one whose + period has not begun, and serving that where the current key was + meant would fail every check of an identifier signed today.""" + now = datetime.now(timezone.utc) + schedule = PublicKeySchedule( + [ + DatedPublicKey(now - timedelta(days=7), fresh_pem()), + DatedPublicKey(now + timedelta(days=7), fresh_pem()), + ] + ) + self.assertEqual( + now + timedelta(days=7), + schedule.last().starts_at, + "the last key is the one with the latest start", + ) + self.assertEqual( + now - timedelta(days=7), + schedule.current().starts_at, + "the key in force now is the one that has started", + ) + + def test_selection_ignores_the_moment_the_keys_were_generated(self) -> None: + """The shape that broke the .NET port. Thirteen of the published keys + were generated in one batch on 1 September 2026 and cover the weeks + from 7 September to 30 November, so on 4 September the newest key + that had already been generated was one that had not started yet.""" + owid = key_fixtures.identifier() + published = key_fixtures.scheduled_keys() + generated_before = [ + key for key in published if key.created <= owid.date + ] + self.assertTrue(generated_before, "keys were generated before the date") + newest_generated = max(generated_before, key=lambda key: key.created) + sharing_the_batch = [ + key for key in published if key.created == newest_generated.created + ] + self.assertEqual( + 13, + len(sharing_the_batch), + "thirteen keys share the generation moment of 1 September", + ) + self.assertGreater( + newest_generated.starts_at, + owid.date, + "the newest generated key had not started when the identifier " + "was signed", + ) + self.assertIs( + SignatureStatus.SIGNATURE_INVALID, + owid.signature_status(newest_generated.pem, ALONE), + "selecting on the generation moment reports a genuine identifier " + "as not matching", + ) + chosen = key_fixtures.schedule().key_for(owid) + self.assertIsNotNone(chosen, "the schedule covers the date") + self.assertEqual( + key_fixtures.WEEK_OF_THE_IDENTIFIER, + chosen.starts_at, + "selecting on the start picks the week that was running", + ) + self.assertIs( + SignatureStatus.SIGNATURE_VALID, + owid.signature_status(chosen.public_key_pem, ALONE), + "selecting on the start verifies the genuine identifier", + ) + + def test_a_key_is_in_force_from_its_start_until_the_next_start(self) -> None: + schedule = key_fixtures.schedule() + keys = schedule.keys + for index in range(1, len(keys)): + previous = keys[index - 1] + key = keys[index] + start = key.starts_at + self.assertEqual( + key.starts_at, + schedule.key_in_force(start).starts_at, + "the start belongs to the key that is starting", + ) + self.assertEqual( + previous.starts_at, + schedule.key_in_force(start - timedelta(minutes=1)).starts_at, + "the minute before the start belongs to the key before", + ) + self.assertEqual( + key.starts_at, + schedule.key_in_force(start + timedelta(days=6)).starts_at, + "the rest of the week belongs to the key that started", + ) + + def test_keys_may_arrive_in_any_order(self) -> None: + forwards = key_fixtures.schedule().keys + backwards = PublicKeySchedule(reversed(forwards)).keys + self.assertEqual( + [key.starts_at for key in forwards], + [key.starts_at for key in backwards], + "the schedule is held oldest start first whatever the order given", + ) + self.assertEqual(len(forwards), len(PublicKeySchedule(reversed(forwards)))) + + def test_a_date_before_the_schedule_has_no_key(self) -> None: + schedule = key_fixtures.schedule() + before = schedule.keys[0].starts_at - timedelta(minutes=1) + self.assertIsNone(schedule.key_in_force(before)) + self.assertIsNone(schedule.key_in_force(None)) + owid = key_fixtures.identifier() + late_start = PublicKeySchedule( + [DatedPublicKey(owid.date + timedelta(days=1), fresh_pem())] + ) + self.assertIsNone(late_start.key_for(owid)) + self.assertIs( + SignatureStatus.KEY_UNAVAILABLE, late_start.signature_status(owid) + ) + self.assertFalse(late_start.verify(owid)) + + def test_an_empty_schedule_has_no_key(self) -> None: + schedule = PublicKeySchedule([]) + self.assertEqual(0, len(schedule)) + self.assertIsNone(schedule.last()) + self.assertIsNone(schedule.current()) + self.assertIsNone(schedule.key_in_force(datetime.now(timezone.utc))) + self.assertIs( + SignatureStatus.KEY_UNAVAILABLE, + schedule.signature_status(key_fixtures.identifier()), + ) + + def test_a_missing_owid_is_key_unavailable(self) -> None: + schedule = key_fixtures.schedule() + self.assertIsNone(schedule.key_for(None)) + self.assertIs( + SignatureStatus.KEY_UNAVAILABLE, schedule.signature_status(None) + ) + self.assertFalse(schedule.verify(None)) + + def test_two_keys_sharing_a_start_are_settled_in_favour_of_the_first_supplied( + self, + ) -> None: + """A creator does not publish two keys for one start, so the case is + settled the way the cloud settles it rather than left to chance.""" + start = datetime(2026, 8, 31, tzinfo=timezone.utc) + first = DatedPublicKey(start, fresh_pem()) + second = DatedPublicKey(start, fresh_pem()) + schedule = PublicKeySchedule([second, first]) + self.assertIs(second, schedule.key_in_force(start)) + self.assertIs(second, schedule.key_in_force(start + timedelta(days=3))) + reordered = PublicKeySchedule([first, second]) + self.assertIs(first, reordered.key_in_force(start)) + + def test_naive_datetimes_are_read_as_utc(self) -> None: + aware = datetime(2026, 8, 31, tzinfo=timezone.utc) + key = DatedPublicKey(datetime(2026, 8, 31), fresh_pem()) + self.assertEqual(aware, key.starts_at) + schedule = PublicKeySchedule([key]) + self.assertIs(key, schedule.key_in_force(datetime(2026, 9, 4))) + self.assertIsNone(schedule.key_in_force(datetime(2026, 8, 30, 23, 59))) + + def test_missing_values_are_refused(self) -> None: + with self.assertRaises(OwidError): + PublicKeySchedule(None) # type: ignore[arg-type] + with self.assertRaises(OwidError): + PublicKeySchedule([None]) # type: ignore[list-item] + with self.assertRaises(OwidError): + DatedPublicKey(None, fresh_pem()) # type: ignore[arg-type] + with self.assertRaises(OwidError): + DatedPublicKey("2026-08-31", fresh_pem()) # type: ignore[arg-type] + with self.assertRaises(OwidError): + DatedPublicKey(datetime.now(timezone.utc), "") + with self.assertRaises(OwidError): + DatedPublicKey(datetime.now(timezone.utc), " ") + + def test_the_keys_handed_out_cannot_be_changed(self) -> None: + schedule = key_fixtures.schedule() + keys = schedule.keys + with self.assertRaises(TypeError): + keys[0] = DatedPublicKey( # type: ignore[index] + datetime.now(timezone.utc), fresh_pem() + ) + with self.assertRaises(AttributeError): + keys[0].starts_at = datetime.now(timezone.utc) # type: ignore[misc] + self.assertEqual(30, len(schedule), "the published schedule holds thirty keys") + + +if __name__ == "__main__": + unittest.main()