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()