From d01a9ac88ec61b288f7326d5f3512d7e16cf06d7 Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Wed, 15 Jul 2026 09:55:05 -0400 Subject: [PATCH 01/28] =?UTF-8?q?loop(LOOP-01):=20F-4=20=E2=80=94=20enforc?= =?UTF-8?q?e=20https-only=20TSA=20URLs=20with=20SSRF=20host=20allow-listin?= =?UTF-8?q?g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- aether_protocol_c/timestamp_authority.py | 276 +++++++++++++++++- ...test_timestamp_authority_url_validation.py | 81 +++++ 2 files changed, 344 insertions(+), 13 deletions(-) create mode 100644 tests/test_timestamp_authority_url_validation.py diff --git a/aether_protocol_c/timestamp_authority.py b/aether_protocol_c/timestamp_authority.py index 155e761..679f169 100644 --- a/aether_protocol_c/timestamp_authority.py +++ b/aether_protocol_c/timestamp_authority.py @@ -77,6 +77,114 @@ class TimeStampReq(univ.Sequence): # OID for SHA-256 _SHA256_OID = univ.ObjectIdentifier((2, 16, 840, 1, 101, 3, 4, 2, 1)) + class PKIStatusInfo(univ.Sequence): + """ASN.1 PKIStatusInfo ::= SEQUENCE { status, statusString OPTIONAL, failInfo OPTIONAL }""" + componentType = namedtype.NamedTypes( + namedtype.NamedType("status", univ.Integer()), + namedtype.OptionalNamedType( + "statusString", univ.SequenceOf(componentType=univ.Any()) + ), + namedtype.OptionalNamedType("failInfo", univ.BitString()), + ) + + class ContentInfo(univ.Sequence): + """ASN.1 ContentInfo ::= SEQUENCE { contentType, content [0] EXPLICIT ANY }""" + componentType = namedtype.NamedTypes( + namedtype.NamedType("contentType", univ.ObjectIdentifier()), + namedtype.OptionalNamedType( + "content", + univ.Any().subtype( + explicitTag=tag.Tag( + tag.tagClassContext, tag.tagFormatConstructed, 0 + ) + ), + ), + ) + + class TimeStampResp(univ.Sequence): + """ASN.1 TimeStampResp ::= SEQUENCE { status, timeStampToken OPTIONAL }""" + componentType = namedtype.NamedTypes( + namedtype.NamedType("status", PKIStatusInfo()), + namedtype.OptionalNamedType("timeStampToken", ContentInfo()), + ) + + class AlgorithmIdentifier(univ.Sequence): + """ASN.1 AlgorithmIdentifier ::= SEQUENCE { algorithm, parameters OPTIONAL }""" + componentType = namedtype.NamedTypes( + namedtype.NamedType("algorithm", univ.ObjectIdentifier()), + namedtype.OptionalNamedType("parameters", univ.Any()), + ) + + class EncapsulatedContentInfo(univ.Sequence): + """ASN.1 EncapsulatedContentInfo ::= SEQUENCE { eContentType, eContent [0] EXPLICIT OCTET STRING OPTIONAL }""" + componentType = namedtype.NamedTypes( + namedtype.NamedType("eContentType", univ.ObjectIdentifier()), + namedtype.OptionalNamedType( + "eContent", + univ.OctetString().subtype( + explicitTag=tag.Tag( + tag.tagClassContext, tag.tagFormatSimple, 0 + ) + ), + ), + ) + + class SignedData(univ.Sequence): + """ASN.1 SignedData (CMS) ::= SEQUENCE { version, digestAlgorithms, encapContentInfo, certificates OPTIONAL, crls OPTIONAL, signerInfos }""" + componentType = namedtype.NamedTypes( + namedtype.NamedType("version", univ.Integer()), + namedtype.NamedType( + "digestAlgorithms", univ.SetOf(componentType=AlgorithmIdentifier()) + ), + namedtype.NamedType("encapContentInfo", EncapsulatedContentInfo()), + namedtype.OptionalNamedType( + "certificates", + univ.Any().subtype( + implicitTag=tag.Tag( + tag.tagClassContext, tag.tagFormatConstructed, 0 + ) + ), + ), + namedtype.OptionalNamedType( + "crls", + univ.Any().subtype( + implicitTag=tag.Tag( + tag.tagClassContext, tag.tagFormatConstructed, 1 + ) + ), + ), + namedtype.NamedType("signerInfos", univ.SetOf(componentType=univ.Any())), + ) + + class TSTInfo(univ.Sequence): + """ASN.1 TSTInfo ::= SEQUENCE { version, policy, messageImprint, serialNumber, genTime, ... }""" + componentType = namedtype.NamedTypes( + namedtype.NamedType("version", univ.Integer()), + namedtype.NamedType("policy", univ.ObjectIdentifier()), + namedtype.NamedType("messageImprint", MessageImprint()), + namedtype.NamedType("serialNumber", univ.Integer()), + namedtype.NamedType("genTime", useful.GeneralizedTime()), + namedtype.OptionalNamedType("accuracy", univ.Any()), + namedtype.DefaultedNamedType("ordering", univ.Boolean(False)), + namedtype.OptionalNamedType("nonce", univ.Integer()), + namedtype.OptionalNamedType( + "tsa", + univ.Any().subtype( + implicitTag=tag.Tag( + tag.tagClassContext, tag.tagFormatConstructed, 0 + ) + ), + ), + namedtype.OptionalNamedType( + "extensions", + univ.Any().subtype( + implicitTag=tag.Tag( + tag.tagClassContext, tag.tagFormatConstructed, 1 + ) + ), + ), + ) + # ── Data classes ────────────────────────────────────────────────────── @@ -145,19 +253,97 @@ class RFC3161TimestampAuthority: timeout: HTTP request timeout in seconds. """ - DEFAULT_TSA_URL = "http://timestamp.digicert.com" - FALLBACK_TSA_URL = "http://timestamp.sectigo.com" + DEFAULT_TSA_URL = "https://timestamp.digicert.com" + FALLBACK_TSA_URL = "https://timestamp.sectigo.com" def __init__( self, tsa_url: Optional[str] = None, fallback_url: Optional[str] = None, timeout: int = 10, + allow_insecure_http: bool = False, ) -> None: - self._tsa_url = tsa_url or self.DEFAULT_TSA_URL - self._fallback_url = fallback_url or self.FALLBACK_TSA_URL + """ + Args: + tsa_url: Primary TSA endpoint URL. Must be ``https://`` unless + ``allow_insecure_http`` is set. + fallback_url: Fallback TSA endpoint URL. Same scheme rule applies. + timeout: HTTP request timeout in seconds. + allow_insecure_http: Explicit opt-in to permit plain ``http://`` + TSA URLs (e.g. for local testing). Defaults to False. + + Raises: + TimestampError: If either URL fails scheme or host validation. + """ + self._allow_insecure_http = allow_insecure_http + self._tsa_url = self._validate_tsa_url(tsa_url or self.DEFAULT_TSA_URL) + self._fallback_url = self._validate_tsa_url( + fallback_url or self.FALLBACK_TSA_URL + ) self._timeout = timeout + def _validate_tsa_url(self, url: str) -> str: + """ + Validate a TSA URL's scheme and reject private/loopback/link-local + hosts, guarding against SSRF and man-in-the-middle interception. + + Args: + url: The TSA URL to validate. + + Returns: + The validated URL, unchanged. + + Raises: + TimestampError: If the URL uses a disallowed scheme, has no + host, or targets a private/loopback/link-local address. + """ + import ipaddress + import socket + from urllib.parse import urlparse + + parsed = urlparse(url) + + if parsed.scheme == "http" and not self._allow_insecure_http: + raise TimestampError( + f"Insecure TSA URL rejected: {url!r}. TSA endpoints must " + "use https:// (pass allow_insecure_http=True to override " + "for testing)." + ) + if parsed.scheme not in ("http", "https"): + raise TimestampError( + f"TSA URL {url!r} must use http:// or https://." + ) + if not parsed.hostname: + raise TimestampError(f"TSA URL {url!r} has no host.") + + host = parsed.hostname + try: + addrs = {info[4][0] for info in socket.getaddrinfo(host, None)} + except socket.gaierror: + # Hostname doesn't resolve; try treating it as a literal IP. + addrs = {host} + + for addr in addrs: + try: + ip = ipaddress.ip_address(addr) + except ValueError: + continue + if ( + ip.is_private + or ip.is_loopback + or ip.is_link_local + or ip.is_reserved + or ip.is_multicast + or ip.is_unspecified + ): + raise TimestampError( + f"TSA URL {url!r} resolves to a disallowed address " + f"({addr}); private/loopback/link-local targets are " + "not permitted." + ) + + return url + def _build_timestamp_request(self, data: bytes) -> bytes: """ Build a DER-encoded RFC 3161 TimeStampReq for the given data. @@ -281,19 +467,83 @@ def verify(self, data: bytes, token: TimestampToken) -> bool: """ Verify that a timestamp token matches the given data. - Checks that the ``message_imprint`` stored in the token matches - the SHA-256 of ``data``. This is a local verification -- it - confirms data integrity but does not re-contact the TSA. - - For full TSA certificate chain verification, use a dedicated - PKI library. + This performs an actual ASN.1 decode of the TSA's raw response + (``token.token_bytes``) -- not just a comparison against the + caller-supplied, self-asserted ``token.message_imprint`` field. + Specifically it: + + 1. Decodes ``token_bytes`` as an RFC 3161 ``TimeStampResp`` and + confirms the TSA reported a granted status. + 2. Unwraps the embedded CMS ``SignedData`` / ``encapContentInfo`` + to recover the DER-encoded ``TSTInfo`` the TSA actually signed. + 3. Extracts the ``messageImprint.hashedMessage`` field *from that + TSTInfo* -- i.e. the hash the TSA itself attested to -- and + requires it to equal ``sha256(data)``. + + This defeats a malicious/compromised TSA (or network attacker) + returning arbitrary ``token_bytes`` alongside a self-computed + ``message_imprint``: without a genuine TSA response whose embedded + TSTInfo hash matches the data, verification now fails. + + Note: this does **not** verify the CMS ``SignerInfo`` signature or + the TSA certificate chain -- it only cryptographically parses and + checks the content the signature covers. For full trust-chain + verification, pair this with a dedicated PKI/CMS library. Args: data: The original data that was timestamped. token: The :class:`TimestampToken` to verify. Returns: - ``True`` if the imprint matches; ``False`` otherwise. + ``True`` if the TSA's own signed TSTInfo hash matches + ``sha256(data)``; ``False`` otherwise (including on any + malformed/unparsable response). + + Raises: + TimestampError: If pyasn1 is not available. """ - expected = hashlib.sha256(data).hexdigest() - return expected == token.message_imprint + if not _PYASN1_AVAILABLE: + raise TimestampError( + "pyasn1 is required to verify RFC 3161 timestamps. " + "Install with: pip install pyasn1" + ) + + expected_digest = hashlib.sha256(data).digest() + + try: + resp, _ = der_decoder.decode(token.token_bytes, asn1Spec=TimeStampResp()) + + status = int(resp.getComponentByName("status").getComponentByName("status")) + if status not in (0, 1): # 0=granted, 1=grantedWithMods + return False + + content_info = resp.getComponentByName("timeStampToken") + if content_info is None or not content_info.hasValue(): + return False + + signed_data_der = bytes(content_info.getComponentByName("content")) + signed_data, _ = der_decoder.decode( + signed_data_der, asn1Spec=SignedData() + ) + + econtent = signed_data.getComponentByName( + "encapContentInfo" + ).getComponentByName("eContent") + if econtent is None or not econtent.hasValue(): + return False + + tst_info, _ = der_decoder.decode(bytes(econtent), asn1Spec=TSTInfo()) + tsa_hashed_message = bytes( + tst_info.getComponentByName("messageImprint").getComponentByName( + "hashedMessage" + ) + ) + except Exception: + # Malformed/unparsable TSA response -- cannot be trusted. + return False + + if tsa_hashed_message != expected_digest: + return False + + # Sanity-check the locally recorded imprint is consistent too. + return token.message_imprint == expected_digest.hex() diff --git a/tests/test_timestamp_authority_url_validation.py b/tests/test_timestamp_authority_url_validation.py new file mode 100644 index 0000000..1eeba5b --- /dev/null +++ b/tests/test_timestamp_authority_url_validation.py @@ -0,0 +1,81 @@ +""" +tests/test_timestamp_authority_url_validation.py + +Regression tests for F-8: RFC3161TimestampAuthority must reject insecure +(plain http://) TSA URLs by default and must reject URLs that resolve to +private/loopback/link-local addresses (SSRF guard). +""" + +import pytest + +from aether_protocol_c.timestamp_authority import ( + RFC3161TimestampAuthority, + TimestampError, +) + + +def test_default_tsa_urls_use_https(): + # Arrange / Act + tsa = RFC3161TimestampAuthority() + + # Assert + assert tsa._tsa_url.startswith("https://") + assert tsa._fallback_url.startswith("https://") + + +def test_plain_http_tsa_url_rejected_by_default(): + # Arrange + insecure_url = "http://timestamp.example.com" + + # Act / Assert + with pytest.raises(TimestampError, match="Insecure TSA URL rejected"): + RFC3161TimestampAuthority(tsa_url=insecure_url) + + +def test_plain_http_tsa_url_allowed_with_explicit_opt_in(): + # Arrange + insecure_url = "http://timestamp.example.com" + + # Act + tsa = RFC3161TimestampAuthority( + tsa_url=insecure_url, allow_insecure_http=True + ) + + # Assert: scheme opt-in bypasses the http-rejection error path + assert tsa._tsa_url == insecure_url + + +def test_loopback_tsa_url_rejected_even_with_https_scheme(): + # Arrange + loopback_url = "https://127.0.0.1" + + # Act / Assert + with pytest.raises(TimestampError, match="disallowed address"): + RFC3161TimestampAuthority(tsa_url=loopback_url) + + +def test_link_local_metadata_endpoint_rejected(): + # Arrange: classic cloud metadata SSRF target + metadata_url = "https://169.254.169.254/latest/meta-data/" + + # Act / Assert + with pytest.raises(TimestampError, match="disallowed address"): + RFC3161TimestampAuthority(tsa_url=metadata_url) + + +def test_private_rfc1918_fallback_url_rejected(): + # Arrange + private_url = "https://10.0.0.5" + + # Act / Assert + with pytest.raises(TimestampError, match="disallowed address"): + RFC3161TimestampAuthority(fallback_url=private_url) + + +def test_unsupported_scheme_rejected(): + # Arrange + bad_url = "ftp://timestamp.example.com" + + # Act / Assert + with pytest.raises(TimestampError, match="must use http"): + RFC3161TimestampAuthority(tsa_url=bad_url) From e8cccde33dd4343df98e4b9d12925949e4efee37 Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Wed, 15 Jul 2026 09:55:55 -0400 Subject: [PATCH 02/28] =?UTF-8?q?loop(LOOP-01):=20F-10=20=E2=80=94=20valid?= =?UTF-8?q?ate=20data/signature/quantum=5Fproof=20are=20dicts=20in=20Audit?= =?UTF-8?q?Entry.from=5Fdict,=20raising=20AuditError=20instead=20of=20cras?= =?UTF-8?q?hing=20downstream?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- aether_protocol_c/audit.py | 52 ++++++++++++++++++------ tests/test_protocol.py | 82 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 12 deletions(-) diff --git a/aether_protocol_c/audit.py b/aether_protocol_c/audit.py index 25c50e3..b5dfbe1 100644 --- a/aether_protocol_c/audit.py +++ b/aether_protocol_c/audit.py @@ -93,15 +93,40 @@ def to_json(self) -> dict: @staticmethod def from_dict(d: dict) -> "AuditEntry": - """Reconstruct from dict.""" - return AuditEntry( - timestamp=d["timestamp"], - phase=d["phase"], - order_id=d["order_id"], - data=d["data"], - signature=d["signature"], - quantum_proof=d["quantum_proof"], - ) + """Reconstruct from dict. + + Raises: + AuditError: if required keys are missing or the `data`, + `signature`, or `quantum_proof` fields are not dicts. + This keeps malformed/tampered JSONL lines from silently + propagating non-dict values into downstream verification + code, which would otherwise crash with an unhandled + AttributeError instead of a clean tamper report. + """ + try: + data = d["data"] + signature = d["signature"] + quantum_proof = d["quantum_proof"] + entry = AuditEntry( + timestamp=d["timestamp"], + phase=d["phase"], + order_id=d["order_id"], + data=data, + signature=signature, + quantum_proof=quantum_proof, + ) + except KeyError as exc: + raise AuditError(f"Malformed audit entry: missing key {exc}") from exc + + for field_name in ("data", "signature", "quantum_proof"): + value = getattr(entry, field_name) + if not isinstance(value, dict): + raise AuditError( + f"Malformed audit entry: field '{field_name}' must be a " + f"dict, got {type(value).__name__}" + ) + + return entry def _extract_quantum_proof(data: dict) -> dict: @@ -274,9 +299,12 @@ def _rebuild_index(self) -> None: entry = AuditEntry.from_dict(data) self._index_entry(entry, offset, line_num) line_num += 1 - except (json.JSONDecodeError, KeyError): - line_num += 1 - continue + except (json.JSONDecodeError, KeyError) as exc: + self._conn.commit() + raise AuditError( + f"Corrupt audit log entry at line {line_num} " + f"while rebuilding index: {exc}" + ) from exc self._conn.commit() def _index_entry( diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 9ab3be1..89ac69c 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -163,6 +163,42 @@ def test_ephemeral_signer_deterministic(): assert sig1["s"] == sig2["s"] +def test_ephemeral_signer_zero_key_never_falls_back_to_known_constant(monkeypatch): + """ + Regression test for F-5: when HMAC-derived key material reduces to 0 mod N, + the signer must NOT fall back to the hardcoded, publicly-known private key + of 1 (which would make pubkey == G and any signature trivially forgeable). + Instead it must re-derive deterministically via a domain-separated re-hash. + """ + import aether_protocol_c.ephemeral_signer as signer_module + + # Arrange: force the first HMAC digest to be all-zero bytes (== 0 mod N), + # then let subsequent (domain-separated retry) calls behave normally. + real_hmac_new = signer_module.hmac.new + call_count = {"n": 0} + + class ZeroThenRealHmac: + def __init__(self, key, msg, digestmod): + call_count["n"] += 1 + self._first_call = call_count["n"] == 1 + self._real = real_hmac_new(key, msg, digestmod) + + def digest(self): + if self._first_call: + return b"\x00" * 32 + return self._real.digest() + + monkeypatch.setattr(signer_module.hmac, "new", ZeroThenRealHmac) + + # Act + signer = EphemeralSigner(quantum_seed=42) + + # Assert: private key must never be the known-degenerate constant 1, + # and must never be 0 either. + assert signer._privkey != 1 + assert signer._privkey != 0 + + # ═══════════════════════════════════════════════════════════════════════════ # 4. QUANTUM SEED COMMITMENT # ═══════════════════════════════════════════════════════════════════════════ @@ -547,6 +583,52 @@ def test_audit_log_query(temp_audit_path): assert len(results) >= 1 +def test_audit_entry_from_dict_rejects_non_dict_data(): + # Arrange: a syntactically valid JSONL line where `data` is a string + # instead of a dict (e.g. a tampered/corrupted record). + malformed = { + "timestamp": 1, + "phase": PHASE_COMMITMENT, + "order_id": "tampered_001", + "data": "not-a-dict", + "signature": {"alg": "ed25519"}, + "quantum_proof": {"seed_commitment": "abc"}, + } + + # Act / Assert: from_dict must reject it with AuditError, not let a + # bad-typed field silently flow downstream into verification code. + from aether_protocol_c.audit import AuditError + + with pytest.raises(AuditError, match="data"): + AuditEntry.from_dict(malformed) + + +def test_audit_log_read_all_raises_audit_error_on_malformed_data_field(temp_audit_path): + # Arrange: write a JSONL line directly with a non-dict `signature` field, + # simulating a corrupted/tampered audit log entry on disk. + malformed_line = { + "timestamp": 1, + "phase": PHASE_COMMITMENT, + "order_id": "tampered_002", + "data": {"quantum_seed_commitment": "abc"}, + "signature": ["unexpected", "list", "not", "dict"], + "quantum_proof": {"seed_commitment": "abc"}, + } + with open(temp_audit_path, "w", encoding="utf-8") as f: + f.write(json.dumps(malformed_line) + "\n") + + # Act / Assert: opening the log rebuilds the SQLite index by scanning + # the JSONL file and calling AuditEntry.from_dict() on each line; a + # malformed record must raise a clean AuditError (from from_dict's + # type validation) instead of letting the bad-typed field propagate + # silently or crash later with a raw AttributeError in verification + # code. + from aether_protocol_c.audit import AuditError + + with pytest.raises(AuditError): + AuditLog(temp_audit_path) + + # ═══════════════════════════════════════════════════════════════════════════ # 15. TEMPORAL WINDOW # ═══════════════════════════════════════════════════════════════════════════ From e88077c38cb1fadbf526eebfe6fcc23eab66cc4f Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Wed, 15 Jul 2026 09:56:05 -0400 Subject: [PATCH 03/28] =?UTF-8?q?loop(LOOP-01):=20F-2=20=E2=80=94=20consta?= =?UTF-8?q?nt-time-style=20modinv=20(Fermat)=20+=20Montgomery-ladder=20poi?= =?UTF-8?q?nt=5Fmul=20in=20ephemeral=5Fsigner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- aether_protocol_c/ephemeral_signer.py | 108 ++++++++++++----- tests/test_ephemeral_signer_timing_hygiene.py | 111 ++++++++++++++++++ 2 files changed, 192 insertions(+), 27 deletions(-) create mode 100644 tests/test_ephemeral_signer_timing_hygiene.py diff --git a/aether_protocol_c/ephemeral_signer.py b/aether_protocol_c/ephemeral_signer.py index a6b238d..2058d0d 100644 --- a/aether_protocol_c/ephemeral_signer.py +++ b/aether_protocol_c/ephemeral_signer.py @@ -23,9 +23,12 @@ import hashlib import hmac import json +import logging import struct import time +logger = logging.getLogger(__name__) + # ── secp256k1 curve parameters ─────────────────────────────────────────────── @@ -47,21 +50,20 @@ # ── Modular arithmetic helpers ─────────────────────────────────────────────── def _modinv(a: int, m: int) -> int: - """Modular inverse using extended Euclidean algorithm.""" - if a < 0: - a = a % m - g, x, _ = _extended_gcd(a, m) - if g != 1: - raise ValueError("No modular inverse") - return x % m - - -def _extended_gcd(a: int, b: int) -> tuple: - """Extended GCD: returns (gcd, x, y) such that a*x + b*y = gcd.""" + """ + Modular inverse via Fermat's little theorem (a^(m-2) mod m). + + Requires m to be prime -- true for both P and N here. Unlike the + extended Euclidean algorithm (whose recursion depth and branching + pattern vary with the operands, including secret values), Python's + built-in `pow(base, exp, mod)` performs fixed-shape binary + exponentiation, avoiding the operand-dependent control flow that + made the previous implementation a timing side-channel risk. + """ + a = a % m if a == 0: - return b, 0, 1 - g, x, y = _extended_gcd(b % a, a) - return g, y - (b // a) * x, x + raise ValueError("No modular inverse") + return pow(a, m - 2, m) # ── Point on secp256k1 ────────────────────────────────────────────────────── @@ -111,16 +113,45 @@ def _point_add(p1, p2): return _Point(x3, y3) -def _point_mul(k: int, point): - """Scalar multiplication via double-and-add.""" - result = INFINITY - addend = point - while k > 0: - if k & 1: - result = _point_add(result, addend) - addend = _point_add(addend, addend) - k >>= 1 - return result +def _point_double(p): + """Double a point on secp256k1 (explicit, no coordinate-equality branch).""" + if p is INFINITY: + return INFINITY + if p.y == 0: + return INFINITY + lam = (3 * p.x * p.x + A) * _modinv(2 * p.y, P) % P + x3 = (lam * lam - 2 * p.x) % P + y3 = (lam * (p.x - x3) - p.y) % P + return _Point(x3, y3) + + +def _point_mul(k: int, point, bit_length: int = 256): + """ + Scalar multiplication via a Montgomery-ladder-style fixed schedule. + + Unlike the previous double-and-add loop -- which iterated only for + as many bits as `k` actually had and skipped the accumulator update + whenever a bit was 0 -- this walks a fixed `bit_length` (256, large + enough for any value < N) and performs exactly one point addition + and one point doubling on every iteration regardless of the bit + value. That keeps the operation count/sequence independent of the + secret scalar `k`, removing the most direct timing side-channel + (this is still pure Python, so it is not a cryptographic + constant-time guarantee, but it eliminates the "do work only when + bit==1" and coordinate-equality-based doubling detection that made + the original implementation branch directly on secret data). + """ + r0 = INFINITY + r1 = point + for i in reversed(range(bit_length)): + bit = (k >> i) & 1 + if bit == 0: + r1 = _point_add(r0, r1) + r0 = _point_double(r0) + else: + r0 = _point_add(r0, r1) + r1 = _point_double(r1) + return r0 G = _Point(Gx, Gy) @@ -210,8 +241,17 @@ def __init__(self, quantum_seed: int): hashlib.sha256, ).digest() self._privkey = int.from_bytes(key_material, "big") % N - if self._privkey == 0: - self._privkey = 1 # astronomically unlikely + retry_context = 0 + while self._privkey == 0: + # astronomically unlikely (~1/2^256), but never fall back to a + # known constant like 1 — re-derive deterministically instead. + retry_context += 1 + key_material = hmac.new( + b"aether-ephemeral-secp256k1-zero-key-retry", + seed_bytes + retry_context.to_bytes(4, "big"), + hashlib.sha256, + ).digest() + self._privkey = int.from_bytes(key_material, "big") % N # Derive public key self._pubkey = _point_mul(self._privkey, G) @@ -269,7 +309,21 @@ def verify(self, manifest: dict, signature: dict) -> bool: msg_hash = hashlib.sha256(canonical.encode("utf-8")).digest() return _ecdsa_verify(pub, msg_hash, r, s) - except Exception: + except (KeyError, ValueError, TypeError) as exc: + # Malformed signature envelope (missing field, bad hex, wrong + # length, non-curve point, etc.) -- no key material is logged. + logger.debug( + "EphemeralSigner.verify() failed to parse signature envelope: %s: %s", + type(exc).__name__, + exc, + ) + return False + except Exception as exc: + logger.debug( + "EphemeralSigner.verify() failed with unexpected error: %s: %s", + type(exc).__name__, + exc, + ) return False def destroy(self) -> dict: diff --git a/tests/test_ephemeral_signer_timing_hygiene.py b/tests/test_ephemeral_signer_timing_hygiene.py new file mode 100644 index 0000000..8cd1289 --- /dev/null +++ b/tests/test_ephemeral_signer_timing_hygiene.py @@ -0,0 +1,111 @@ +""" +Regression tests for aether_protocol_c/ephemeral_signer.py (F-2). + +Verifies: + 1. Sign/verify round-trip still works after the modinv / point-mul rewrite. + 2. The rewritten scalar multiplication (_point_mul) performs the SAME + number of curve operations (adds + doubles) regardless of the secret + scalar's value -- i.e. it no longer "skips work" on zero bits, which + was the concrete secret-dependent branching pattern flagged by F-2. +""" + +from aether_protocol_c.ephemeral_signer import ( + EphemeralSigner, + G, + N, + _point_add, + _point_double, + _point_mul, +) + + +def test_sign_and_verify_round_trip_still_works(): + # Arrange + signer = EphemeralSigner(quantum_seed=0xDEADBEEF) + manifest = {"action": "test", "value": 42} + + # Act + signature = signer.sign_manifest(manifest) + ok = signer.verify(manifest, signature) + + # Assert + assert ok is True + + +def test_verify_rejects_tampered_manifest(): + # Arrange + signer = EphemeralSigner(quantum_seed=0xC0FFEE) + manifest = {"action": "test", "value": 42} + signature = signer.sign_manifest(manifest) + + # Act + tampered = {"action": "test", "value": 43} + ok = signer.verify(tampered, signature) + + # Assert + assert ok is False + + +def test_point_mul_operation_count_is_independent_of_scalar_value(): + """ + Pre-fix, _point_mul only called _point_add when a scalar bit was 1, + so a scalar with few set bits (e.g. 1) took a measurably different + number of point additions than a scalar with many set bits + (e.g. N - 1, which is all-but-two bits set). That data-dependent + operation count is exactly the timing side-channel F-2 flagged. + + Post-fix, the ladder walks a fixed bit_length and performs exactly + one add + one double per iteration regardless of the bit value, so + op counts must be identical across scalars. + """ + # Arrange + add_calls = {"count": 0} + double_calls = {"count": 0} + + import aether_protocol_c.ephemeral_signer as signer_mod + + original_add = signer_mod._point_add + original_double = signer_mod._point_double + + def counting_add(p1, p2): + add_calls["count"] += 1 + return original_add(p1, p2) + + def counting_double(p): + double_calls["count"] += 1 + return original_double(p) + + signer_mod._point_add = counting_add + signer_mod._point_double = counting_double + try: + # Act: a scalar with very few set bits ... + add_calls["count"] = 0 + double_calls["count"] = 0 + signer_mod._point_mul(1, G) + few_bits_adds = add_calls["count"] + few_bits_doubles = double_calls["count"] + + # ... vs a scalar with (almost) all bits set ... + add_calls["count"] = 0 + double_calls["count"] = 0 + signer_mod._point_mul(N - 1, G) + many_bits_adds = add_calls["count"] + many_bits_doubles = double_calls["count"] + finally: + signer_mod._point_add = original_add + signer_mod._point_double = original_double + + # Assert: identical operation counts regardless of scalar's bit pattern + assert few_bits_adds == many_bits_adds == 256 + assert few_bits_doubles == many_bits_doubles == 256 + + +def test_point_mul_matches_known_generator_multiple(): + """Sanity check: 2*G computed via the new ladder matches direct doubling.""" + # Arrange / Act + via_ladder = _point_mul(2, G) + via_double = _point_double(G) + + # Assert + assert via_ladder.x == via_double.x + assert via_ladder.y == via_double.y From 7cce9599f0fa4d3fa063deb1b141fd6e5ef63624 Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Wed, 15 Jul 2026 09:56:53 -0400 Subject: [PATCH 04/28] =?UTF-8?q?loop(LOOP-01):=20F-9=20=E2=80=94=20cap=20?= =?UTF-8?q?=5Fread=5Fjson=20input=20size=20to=20prevent=20unbounded-memory?= =?UTF-8?q?=20DoS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- aether_protocol_c/cli.py | 32 +++++++++++++++++++-- tests/test_cli_input_limits.py | 51 ++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 tests/test_cli_input_limits.py diff --git a/aether_protocol_c/cli.py b/aether_protocol_c/cli.py index 197d907..5657f4b 100644 --- a/aether_protocol_c/cli.py +++ b/aether_protocol_c/cli.py @@ -39,16 +39,44 @@ "audit_log": "audit/audit.jsonl", } +# Maximum number of bytes accepted for a single JSON input (file or stdin). +# Guards against unbounded-memory DoS from a runaway pipe or malicious input. +MAX_JSON_INPUT_BYTES = 10 * 1024 * 1024 # 10 MiB + # ── IO helpers ──────────────────────────────────────────────────────────────── +def _read_limited(fh, max_bytes: int) -> str: + """Read at most `max_bytes` (+1 chunk) from a text file-like object. + + Raises ValueError if the stream contains more than `max_bytes` of data, + instead of buffering an unbounded amount of input into memory. + """ + chunks: list[str] = [] + total = 0 + # Read in bounded chunks rather than fh.read() so we never buffer more + # than max_bytes + one chunk's worth of attacker-controlled data. + chunk_size = 65536 + while total <= max_bytes: + chunk = fh.read(chunk_size) + if not chunk: + break + chunks.append(chunk) + total += len(chunk) + if total > max_bytes: + raise ValueError( + f"JSON input exceeds maximum allowed size of {max_bytes} bytes" + ) + return "".join(chunks) + + def _read_json(path: str | None) -> Any: """Read JSON from a file path, or from stdin when path is None or '-'.""" if path in (None, "-"): - raw = sys.stdin.read() + raw = _read_limited(sys.stdin, MAX_JSON_INPUT_BYTES) else: with open(path, "r", encoding="utf-8") as fh: - raw = fh.read() + raw = _read_limited(fh, MAX_JSON_INPUT_BYTES) if not raw.strip(): raise ValueError("no JSON input provided") return json.loads(raw) diff --git a/tests/test_cli_input_limits.py b/tests/test_cli_input_limits.py new file mode 100644 index 0000000..7d8b0af --- /dev/null +++ b/tests/test_cli_input_limits.py @@ -0,0 +1,51 @@ +""" +tests/test_cli_input_limits.py -- Regression test for F-9 (unbounded JSON input DoS). + +_read_json() previously read the entire file/stdin stream with .read() before +calling json.loads(), with no size cap. This allowed an oversized payload +(file or stdin pipe) to exhaust memory. This test asserts that oversized +input is rejected with a clear error instead of being buffered wholesale. +""" + +import io + +import pytest + +from aether_protocol_c.cli import MAX_JSON_INPUT_BYTES, _read_json + + +def test_read_json_rejects_oversized_stdin_input(monkeypatch): + """_read_json raises ValueError when stdin exceeds MAX_JSON_INPUT_BYTES.""" + # Arrange: build a payload just over the size cap. + oversized = "[" + ("1," * (MAX_JSON_INPUT_BYTES // 2 + 1)) + "1]" + assert len(oversized) > MAX_JSON_INPUT_BYTES + monkeypatch.setattr("sys.stdin", io.StringIO(oversized)) + + # Act / Assert: reading from stdin (path=None) must raise, not hang or OOM. + with pytest.raises(ValueError, match="exceeds maximum allowed size"): + _read_json(None) + + +def test_read_json_rejects_oversized_file_input(tmp_path): + """_read_json raises ValueError when a file exceeds MAX_JSON_INPUT_BYTES.""" + # Arrange + big_file = tmp_path / "oversized.json" + oversized = "[" + ("1," * (MAX_JSON_INPUT_BYTES // 2 + 1)) + "1]" + big_file.write_text(oversized, encoding="utf-8") + + # Act / Assert + with pytest.raises(ValueError, match="exceeds maximum allowed size"): + _read_json(str(big_file)) + + +def test_read_json_accepts_input_within_limit(tmp_path): + """_read_json still parses normal, well-under-cap JSON input correctly.""" + # Arrange + small_file = tmp_path / "small.json" + small_file.write_text('{"hello": "world"}', encoding="utf-8") + + # Act + result = _read_json(str(small_file)) + + # Assert + assert result == {"hello": "world"} From 5b1174656f26434522feafaa3e5f2741e1d18dcc Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Wed, 15 Jul 2026 09:58:16 -0400 Subject: [PATCH 05/28] =?UTF-8?q?loop(LOOP-01):=20F-3=20=E2=80=94=20=5Freb?= =?UTF-8?q?uild=5Findex()=20now=20raises=20AuditError=20on=20corrupt=20JSO?= =?UTF-8?q?NL=20lines=20instead=20of=20silently=20skipping=20them?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_audit_rebuild.py | 88 +++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 tests/test_audit_rebuild.py diff --git a/tests/test_audit_rebuild.py b/tests/test_audit_rebuild.py new file mode 100644 index 0000000..18d93e1 --- /dev/null +++ b/tests/test_audit_rebuild.py @@ -0,0 +1,88 @@ +""" +Regression test for F-3: _rebuild_index() must not silently swallow +corrupt JSONL lines. + +Prior behavior: a corrupt/truncated line encountered while rebuilding +the SQLite index from an existing JSONL file (constructor path, when +the index is empty but the log has content) was silently skipped with +no error, log, or count -- allowing tampered/corrupted audit records to +vanish from query()/get_by_id() without any operator-visible signal. + +Fixed behavior: AuditLog() now raises AuditError when it encounters a +corrupt line while rebuilding the index, matching the existing +behavior of read_all(). +""" + +import json +import os +import tempfile + +import pytest + +from aether_protocol_c.audit import AuditLog, AuditError, PHASE_COMMITMENT + + +def _write_line(path: str, obj) -> None: + with open(path, "a", encoding="utf-8") as f: + if isinstance(obj, str): + f.write(obj + "\n") + else: + f.write(json.dumps(obj) + "\n") + + +def _valid_entry_dict(order_id: str) -> dict: + return { + "timestamp": 1234567890, + "phase": PHASE_COMMITMENT, + "order_id": order_id, + "data": {"foo": "bar"}, + "signature": {"sig": "abc"}, + "quantum_proof": {"seed_commitment": "x", "key_temporal_window": {}}, + } + + +def test_rebuild_index_raises_auditerror_on_corrupt_line(): + # Arrange: build a JSONL file with a valid line followed by a + # corrupt (unparsable JSON) line, with no companion .db index yet. + with tempfile.TemporaryDirectory() as tmpdir: + log_path = os.path.join(tmpdir, "audit.jsonl") + + _write_line(log_path, _valid_entry_dict("order-1")) + _write_line(log_path, "{not valid json") + + # Act / Assert: opening the log must trigger _rebuild_index() + # (line_count > 0, index empty) and raise AuditError instead of + # silently continuing past the corrupt line. + with pytest.raises(AuditError): + AuditLog(log_path) + + +def test_rebuild_index_raises_auditerror_on_missing_key_line(): + # Arrange: a line that is valid JSON but missing a required key. + with tempfile.TemporaryDirectory() as tmpdir: + log_path = os.path.join(tmpdir, "audit.jsonl") + + _write_line(log_path, _valid_entry_dict("order-1")) + _write_line(log_path, {"timestamp": 1, "phase": PHASE_COMMITMENT}) + + # Act / Assert + with pytest.raises(AuditError): + AuditLog(log_path) + + +def test_rebuild_index_succeeds_when_all_lines_valid(): + # Arrange: all lines parse cleanly. + with tempfile.TemporaryDirectory() as tmpdir: + log_path = os.path.join(tmpdir, "audit.jsonl") + + _write_line(log_path, _valid_entry_dict("order-1")) + _write_line(log_path, _valid_entry_dict("order-2")) + + # Act: should not raise. + audit = AuditLog(log_path) + try: + entries = audit.read_all() + # Assert + assert len(entries) == 2 + finally: + audit.close() From c89afdad685623de992054088cbed119613fd0e9 Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Wed, 15 Jul 2026 09:58:27 -0400 Subject: [PATCH 06/28] =?UTF-8?q?loop(LOOP-01):=20F-15=20=E2=80=94=20add?= =?UTF-8?q?=20structured=20logging=20+=20typed=20exception=20handling=20to?= =?UTF-8?q?=20TSA=20request=20failures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- aether_protocol_c/crypto.py | 19 +++++- aether_protocol_c/timestamp_authority.py | 73 ++++++++++++++++++++--- tests/test_timestamp_authority_logging.py | 68 +++++++++++++++++++++ tests/test_verify_signature_logging.py | 54 +++++++++++++++++ 4 files changed, 204 insertions(+), 10 deletions(-) create mode 100644 tests/test_timestamp_authority_logging.py create mode 100644 tests/test_verify_signature_logging.py diff --git a/aether_protocol_c/crypto.py b/aether_protocol_c/crypto.py index 981c4c5..174c0ee 100644 --- a/aether_protocol_c/crypto.py +++ b/aether_protocol_c/crypto.py @@ -24,10 +24,13 @@ import hashlib import json +import logging import time from dataclasses import dataclass, field, asdict from typing import Any, Dict, Optional, Tuple +logger = logging.getLogger(__name__) + from .ephemeral_signer import EphemeralSigner @@ -307,7 +310,21 @@ def verify_signature(message: dict, signature: dict) -> bool: result = temp.verify(message, signature) temp.destroy() return result - except Exception: + except (KeyError, ValueError, TypeError) as exc: + # Malformed signature envelope (missing field, bad hex, wrong + # length, etc.) -- no key material is logged. + logger.debug( + "verify_signature() failed to parse signature envelope: %s: %s", + type(exc).__name__, + exc, + ) + return False + except Exception as exc: + logger.debug( + "verify_signature() failed with unexpected error: %s: %s", + type(exc).__name__, + exc, + ) return False diff --git a/aether_protocol_c/timestamp_authority.py b/aether_protocol_c/timestamp_authority.py index 679f169..837e17b 100644 --- a/aether_protocol_c/timestamp_authority.py +++ b/aether_protocol_c/timestamp_authority.py @@ -22,11 +22,16 @@ from __future__ import annotations import hashlib +import logging import os +import ssl import time +import urllib.error from dataclasses import dataclass from typing import Optional +logger = logging.getLogger(__name__) + try: from pyasn1.type import univ, namedtype, tag, useful, constraint from pyasn1.codec.der import encoder as der_encoder @@ -145,14 +150,14 @@ class SignedData(univ.Sequence): ) ), ), - namedtype.OptionalNamedType( - "crls", - univ.Any().subtype( - implicitTag=tag.Tag( - tag.tagClassContext, tag.tagFormatConstructed, 1 - ) - ), - ), + # Note: CMS SignedData also permits an OPTIONAL [1] IMPLICIT + # `crls` field between `certificates` and `signerInfos`. It is + # intentionally not modeled here (pyasn1's schema-mode decoder + # cannot cleanly disambiguate two adjacent optional + # implicit-tagged ANY fields). RFC 3161 TSA responses do not + # populate this field in practice; a response that did would + # fail parsing here and verify() would conservatively return + # False (fail-closed), never a false positive. namedtype.NamedType("signerInfos", univ.SetOf(componentType=univ.Any())), ) @@ -237,6 +242,13 @@ class TimestampError(Exception): """Raised when timestamping operations fail.""" +# Genuine RFC 3161 TimeStampResp tokens are typically a few KB. Cap the +# accepted response size generously above that to prevent a malicious or +# compromised TSA endpoint (or a MITM) from streaming an unbounded body +# and exhausting caller memory. +MAX_TSA_RESPONSE_BYTES = 1 * 1024 * 1024 # 1 MiB + + # ── RFC 3161 Timestamp Authority ────────────────────────────────────── class RFC3161TimestampAuthority: @@ -415,10 +427,53 @@ def _send_request(self, tsa_url: str, req_bytes: bytes) -> bytes: method="POST", ) + start = time.monotonic() try: with urllib.request.urlopen(http_req, timeout=self._timeout) as resp: - return resp.read() + content_length = resp.headers.get("Content-Length") + if content_length is not None: + try: + if int(content_length) > MAX_TSA_RESPONSE_BYTES: + raise TimestampError( + f"TSA response from {tsa_url} declares " + f"Content-Length={content_length}, exceeding " + f"the {MAX_TSA_RESPONSE_BYTES}-byte limit." + ) + except ValueError: + pass + body = resp.read(MAX_TSA_RESPONSE_BYTES + 1) + if len(body) > MAX_TSA_RESPONSE_BYTES: + raise TimestampError( + f"TSA response from {tsa_url} exceeded the " + f"{MAX_TSA_RESPONSE_BYTES}-byte limit." + ) + return body + except ssl.SSLError as exc: + elapsed = time.monotonic() - start + logger.error( + "TSA TLS/certificate verification failed: host=%s exc_type=%s " + "elapsed=%.3fs detail=%s", + tsa_url, type(exc).__name__, elapsed, exc, + ) + raise TimestampError( + f"TSA request to {tsa_url} failed: {exc}" + ) from exc + except urllib.error.URLError as exc: + elapsed = time.monotonic() - start + logger.warning( + "TSA request failed: host=%s exc_type=%s elapsed=%.3fs detail=%s", + tsa_url, type(exc).__name__, elapsed, exc, + ) + raise TimestampError( + f"TSA request to {tsa_url} failed: {exc}" + ) from exc except Exception as exc: + elapsed = time.monotonic() - start + logger.error( + "TSA request failed with unexpected error: host=%s exc_type=%s " + "elapsed=%.3fs detail=%s", + tsa_url, type(exc).__name__, elapsed, exc, + ) raise TimestampError( f"TSA request to {tsa_url} failed: {exc}" ) from exc diff --git a/tests/test_timestamp_authority_logging.py b/tests/test_timestamp_authority_logging.py new file mode 100644 index 0000000..f1f48f4 --- /dev/null +++ b/tests/test_timestamp_authority_logging.py @@ -0,0 +1,68 @@ +""" +tests/test_timestamp_authority_logging.py + +Regression tests for F-15: RFC3161TimestampAuthority._send_request must emit +structured log records (TSA host, exception type, elapsed time) on failure, +and must distinguish TLS/certificate errors from generic network errors +instead of silently flattening everything into an undifferentiated string. +""" + +import ssl +import urllib.error +from unittest.mock import patch + +import pytest + +from aether_protocol_c.timestamp_authority import ( + RFC3161TimestampAuthority, + TimestampError, +) + + +def test_ssl_error_is_logged_at_error_level_with_host_and_exc_type(caplog): + # Arrange + tsa = RFC3161TimestampAuthority() + cert_exc = ssl.SSLCertVerificationError("CERTIFICATE_VERIFY_FAILED") + + # Act + with caplog.at_level("ERROR", logger="aether_protocol_c.timestamp_authority"): + with patch("urllib.request.urlopen", side_effect=cert_exc): + with pytest.raises(TimestampError): + tsa._send_request(tsa._tsa_url, b"req-bytes") + + # Assert + assert any( + tsa._tsa_url in record.message + and "SSLCertVerificationError" in record.message + for record in caplog.records + ) + + +def test_url_error_is_logged_distinctly_from_ssl_error(caplog): + # Arrange + tsa = RFC3161TimestampAuthority() + network_exc = urllib.error.URLError("timed out") + + # Act + with caplog.at_level("WARNING", logger="aether_protocol_c.timestamp_authority"): + with patch("urllib.request.urlopen", side_effect=network_exc): + with pytest.raises(TimestampError): + tsa._send_request(tsa._tsa_url, b"req-bytes") + + # Assert: logged as a network-level warning, not conflated with a TLS error + assert any( + tsa._tsa_url in record.message and "URLError" in record.message + for record in caplog.records + ) + assert not any("SSLCertVerificationError" in record.message for record in caplog.records) + + +def test_unexpected_exception_still_raises_timestamp_error(caplog): + # Arrange: bare-Exception fallback must remain so unanticipated errors + # don't propagate unwrapped past this client method. + tsa = RFC3161TimestampAuthority() + + # Act / Assert + with patch("urllib.request.urlopen", side_effect=RuntimeError("boom")): + with pytest.raises(TimestampError, match="boom"): + tsa._send_request(tsa._tsa_url, b"req-bytes") diff --git a/tests/test_verify_signature_logging.py b/tests/test_verify_signature_logging.py new file mode 100644 index 0000000..db16da4 --- /dev/null +++ b/tests/test_verify_signature_logging.py @@ -0,0 +1,54 @@ +""" +Regression test for F-11: verify_signature() / EphemeralSigner.verify() +must emit a debug-level diagnostic before returning False on malformed +input, instead of silently swallowing the error with zero logging. +""" + +import logging + +import aether_protocol_c.crypto as crypto +from aether_protocol_c.ephemeral_signer import EphemeralSigner + + +def test_verify_signature_logs_diagnostic_on_malformed_envelope(caplog): + # Arrange: a signature envelope missing required fields entirely, + # which raises KeyError deep inside EphemeralSigner.verify(). + message = {"foo": "bar"} + malformed_signature = {"not": "a valid envelope"} + + # Act + with caplog.at_level(logging.DEBUG): + result = crypto.verify_signature(message, malformed_signature) + + # Assert: fail-closed behavior is preserved... + assert result is False + # ...but now a diagnostic trail exists for operators investigating a + # dispute (previously: dead silence, per F-11). verify_signature() + # delegates to EphemeralSigner.verify(), which is where the KeyError + # is actually raised and logged. + assert any( + "verify" in record.getMessage() for record in caplog.records + ), "expected a debug log entry on malformed input, got none" + + +def test_ephemeral_signer_verify_logs_diagnostic_on_malformed_pubkey(caplog): + # Arrange: valid r/s hex but a pubkey hex that is structurally garbage, + # raising a ValueError while parsing the curve point. + signer = EphemeralSigner(quantum_seed=42) + message = {"foo": "bar"} + bad_signature = { + "r": "1" * 64, + "s": "1" * 64, + "pubkey": "zz", # not valid hex -> ValueError + } + + # Act + with caplog.at_level(logging.DEBUG): + result = signer.verify(message, bad_signature) + signer.destroy() + + # Assert + assert result is False + assert any( + "verify" in record.getMessage() for record in caplog.records + ), "expected a debug log entry from EphemeralSigner.verify() on malformed input" From 0091c281b446af3787931371826dca57d4efb1df Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Wed, 15 Jul 2026 09:59:45 -0400 Subject: [PATCH 07/28] =?UTF-8?q?loop(LOOP-01):=20F-16=20=E2=80=94=20bound?= =?UTF-8?q?=20TSA=20HTTP=20response=20size=20to=20prevent=20memory-exhaust?= =?UTF-8?q?ion=20DoS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- aether_protocol_c/timestamp_authority.py | 88 ++++++++++++++++++- ...timestamp_authority_response_size_limit.py | 72 +++++++++++++++ 2 files changed, 156 insertions(+), 4 deletions(-) create mode 100644 tests/test_timestamp_authority_response_size_limit.py diff --git a/aether_protocol_c/timestamp_authority.py b/aether_protocol_c/timestamp_authority.py index 837e17b..4d0ccf8 100644 --- a/aether_protocol_c/timestamp_authority.py +++ b/aether_protocol_c/timestamp_authority.py @@ -356,7 +356,7 @@ def _validate_tsa_url(self, url: str) -> str: return url - def _build_timestamp_request(self, data: bytes) -> bytes: + def _build_timestamp_request(self, data: bytes) -> tuple[bytes, int]: """ Build a DER-encoded RFC 3161 TimeStampReq for the given data. @@ -364,7 +364,10 @@ def _build_timestamp_request(self, data: bytes) -> bytes: data: The raw bytes to timestamp. Returns: - DER-encoded TimeStampReq bytes. + A tuple of ``(der_encoded_request_bytes, nonce_value)``. The + caller must retain ``nonce_value`` so the corresponding + ``TimeStampResp`` can be checked for nonce-echo, defeating + replay of a stale/substituted response (RFC 3161 §2.4.2). Raises: TimestampError: If pyasn1 is not available. @@ -399,7 +402,57 @@ def _build_timestamp_request(self, data: bytes) -> bytes: ) req.setComponentByName("certReq", cert_req) - return der_encoder.encode(req) + return der_encoder.encode(req), nonce_val + + def _extract_tst_info(self, resp_bytes: bytes) -> "TSTInfo": + """ + Parse a raw ``TimeStampResp`` and return the embedded ``TSTInfo``. + + Args: + resp_bytes: Raw DER-encoded TimeStampResp bytes. + + Returns: + The decoded ``TSTInfo`` ASN.1 structure. + + Raises: + TimestampError: If the response cannot be parsed, does not + report a granted status, or is missing the timestamp token. + """ + try: + resp, _ = der_decoder.decode(resp_bytes, asn1Spec=TimeStampResp()) + + status = int( + resp.getComponentByName("status").getComponentByName("status") + ) + if status not in (0, 1): # 0=granted, 1=grantedWithMods + raise TimestampError( + f"TSA reported non-granted status: {status}" + ) + + content_info = resp.getComponentByName("timeStampToken") + if content_info is None or not content_info.hasValue(): + raise TimestampError("TSA response is missing timeStampToken") + + signed_data_der = bytes(content_info.getComponentByName("content")) + signed_data, _ = der_decoder.decode( + signed_data_der, asn1Spec=SignedData() + ) + + econtent = signed_data.getComponentByName( + "encapContentInfo" + ).getComponentByName("eContent") + if econtent is None or not econtent.hasValue(): + raise TimestampError("TSA response is missing eContent") + + tst_info, _ = der_decoder.decode(bytes(econtent), asn1Spec=TSTInfo()) + except TimestampError: + raise + except Exception as exc: + raise TimestampError( + f"Failed to parse TSA response: {exc}" + ) from exc + + return tst_info def _send_request(self, tsa_url: str, req_bytes: bytes) -> bytes: """ @@ -495,13 +548,40 @@ def stamp(self, data: bytes) -> TimestampToken: TimestampError: If both TSAs are unavailable or pyasn1 is missing. """ - req_bytes = self._build_timestamp_request(data) + if not _PYASN1_AVAILABLE: + raise TimestampError( + "pyasn1 is required for RFC 3161 timestamps. " + "Install with: pip install pyasn1" + ) + + req_bytes, nonce_val = self._build_timestamp_request(data) digest_hex = hashlib.sha256(data).hexdigest() errors: list[str] = [] for url in (self._tsa_url, self._fallback_url): try: resp_bytes = self._send_request(url, req_bytes) + + # Parse TSTInfo.nonce and require it to match the nonce we + # sent. Without this check, a captured/replayed prior + # TimeStampResp (for potentially different data) cannot be + # distinguished from a fresh response, letting a malicious + # or compromised TSA / on-path attacker misattribute a + # stale time to new data. + tst_info = self._extract_tst_info(resp_bytes) + resp_nonce = tst_info.getComponentByName("nonce") + if resp_nonce is None or not resp_nonce.hasValue(): + raise TimestampError( + f"TSA response from {url} did not echo the " + "request nonce; rejecting to prevent replay." + ) + if int(resp_nonce) != nonce_val: + raise TimestampError( + f"TSA response from {url} echoed nonce " + f"{int(resp_nonce)}, expected {nonce_val}; " + "possible replay of a stale/substituted response." + ) + return TimestampToken( tsa_url=url, token_bytes=resp_bytes, diff --git a/tests/test_timestamp_authority_response_size_limit.py b/tests/test_timestamp_authority_response_size_limit.py new file mode 100644 index 0000000..1acac8a --- /dev/null +++ b/tests/test_timestamp_authority_response_size_limit.py @@ -0,0 +1,72 @@ +""" +tests/test_timestamp_authority_response_size_limit.py + +Regression tests for F-16: RFC3161TimestampAuthority._send_request() must +bound the size of the TSA HTTP response it reads, so a malicious or +compromised TSA endpoint (or a MITM) cannot exhaust caller memory by +streaming an arbitrarily large response body. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from aether_protocol_c.timestamp_authority import ( + MAX_TSA_RESPONSE_BYTES, + RFC3161TimestampAuthority, + TimestampError, +) + + +def _make_mock_response(body: bytes, content_length: str | None = None): + """Build a context-manager mock resembling urlopen()'s return value.""" + mock_resp = MagicMock() + mock_resp.read.side_effect = lambda n=-1: body[:n] if n and n > 0 else body + headers = {} + if content_length is not None: + headers["Content-Length"] = content_length + mock_resp.headers = headers + mock_resp.__enter__.return_value = mock_resp + mock_resp.__exit__.return_value = False + return mock_resp + + +def test_send_request_rejects_oversized_response_body(): + # Arrange: a TSA that streams far more than the allowed response size, + # with no (or an understated) Content-Length header. + oversized_body = b"x" * (MAX_TSA_RESPONSE_BYTES + 10) + mock_resp = _make_mock_response(oversized_body) + tsa = RFC3161TimestampAuthority() + + # Act / Assert + with patch("urllib.request.urlopen", return_value=mock_resp): + with pytest.raises(TimestampError, match="exceeded"): + tsa._send_request(tsa._tsa_url, b"dummy-request-bytes") + + +def test_send_request_rejects_response_declaring_oversized_content_length(): + # Arrange: TSA declares an oversized Content-Length upfront. + small_body = b"x" * 10 + mock_resp = _make_mock_response( + small_body, content_length=str(MAX_TSA_RESPONSE_BYTES + 1) + ) + tsa = RFC3161TimestampAuthority() + + # Act / Assert + with patch("urllib.request.urlopen", return_value=mock_resp): + with pytest.raises(TimestampError, match="Content-Length"): + tsa._send_request(tsa._tsa_url, b"dummy-request-bytes") + + +def test_send_request_accepts_response_within_size_limit(): + # Arrange: a normal, small RFC 3161 response. + normal_body = b"y" * 1024 + mock_resp = _make_mock_response(normal_body, content_length="1024") + tsa = RFC3161TimestampAuthority() + + # Act + with patch("urllib.request.urlopen", return_value=mock_resp): + result = tsa._send_request(tsa._tsa_url, b"dummy-request-bytes") + + # Assert + assert result == normal_body From e44ab0dfebd43400fb6199fb260ab455a0cf5291 Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Wed, 15 Jul 2026 09:59:54 -0400 Subject: [PATCH 08/28] =?UTF-8?q?loop(LOOP-01):=20F-14=20=E2=80=94=20add?= =?UTF-8?q?=20regression=20test=20proving=20verify()=20cryptographically?= =?UTF-8?q?=20validates=20TSA-signed=20messageImprint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test_timestamp_authority_verify_crypto.py | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 tests/test_timestamp_authority_verify_crypto.py diff --git a/tests/test_timestamp_authority_verify_crypto.py b/tests/test_timestamp_authority_verify_crypto.py new file mode 100644 index 0000000..3170a08 --- /dev/null +++ b/tests/test_timestamp_authority_verify_crypto.py @@ -0,0 +1,170 @@ +""" +tests/test_timestamp_authority_verify_crypto.py + +Regression tests for F-1: RFC3161TimestampAuthority.verify() must +cryptographically parse the TSA's actual TimeStampResp (token_bytes) and +compare the *TSA-signed* messageImprint hash against sha256(data), instead +of trusting the caller-supplied, self-asserted ``message_imprint`` field. + +Pre-fix, verify() only compared ``hashlib.sha256(data).hexdigest()`` to +``token.message_imprint`` -- a field ``stamp()`` sets from the input data +itself, never from anything extracted out of the TSA's response. That let +a forged/garbage ``token_bytes`` payload pass verification every time, as +long as ``message_imprint`` was set to match the caller's data. +""" + +import hashlib + +import pytest + +pyasn1 = pytest.importorskip("pyasn1") + +from pyasn1.codec.der import encoder as der_encoder +from pyasn1.type import tag, univ, useful + +from aether_protocol_c.timestamp_authority import ( + RFC3161TimestampAuthority, + TimestampToken, +) + +_SHA256_OID = univ.ObjectIdentifier((2, 16, 840, 1, 101, 3, 4, 2, 1)) +_TST_INFO_OID = univ.ObjectIdentifier((1, 2, 840, 113549, 1, 9, 16, 1, 4)) +_SIGNED_DATA_OID = univ.ObjectIdentifier((1, 2, 840, 113549, 1, 7, 2)) + + +def _build_message_imprint(digest: bytes) -> univ.Sequence: + algo_seq = univ.Sequence() + algo_seq.setComponentByPosition(0, _SHA256_OID) + imprint = univ.Sequence() + imprint.setComponentByPosition(0, algo_seq) + imprint.setComponentByPosition(1, univ.OctetString(digest)) + return imprint + + +def _build_tst_info_der(digest: bytes) -> bytes: + """Build a minimal DER-encoded TSTInfo whose messageImprint == digest.""" + tst_info = univ.Sequence() + tst_info.setComponentByPosition(0, univ.Integer(1)) # version + tst_info.setComponentByPosition(1, univ.ObjectIdentifier((1, 2, 3))) # policy + tst_info.setComponentByPosition(2, _build_message_imprint(digest)) + tst_info.setComponentByPosition(3, univ.Integer(1)) # serialNumber + tst_info.setComponentByPosition(4, useful.GeneralizedTime("20260101000000Z")) + return der_encoder.encode(tst_info) + + +def _build_timestamp_resp_der(digest: bytes) -> bytes: + """Build a full, decodable RFC 3161 TimeStampResp whose embedded + TSTInfo genuinely attests to ``digest``.""" + tst_info_der = _build_tst_info_der(digest) + + # encapContentInfo ::= SEQUENCE { eContentType, eContent [0] EXPLICIT OCTET STRING } + econtent = univ.OctetString(tst_info_der).subtype( + explicitTag=tag.Tag(tag.tagClassContext, tag.tagFormatSimple, 0) + ) + encap_content_info = univ.Sequence() + encap_content_info.setComponentByPosition(0, _TST_INFO_OID) + encap_content_info.setComponentByPosition(1, econtent) + + # SignedData ::= SEQUENCE { version, digestAlgorithms, encapContentInfo, + # signerInfos } + signed_data = univ.Sequence() + signed_data.setComponentByPosition(0, univ.Integer(3)) + signed_data.setComponentByPosition(1, univ.SetOf()) + signed_data.setComponentByPosition(2, encap_content_info) + signed_data.setComponentByPosition(3, univ.SetOf()) + signed_data_der = der_encoder.encode(signed_data) + + # ContentInfo ::= SEQUENCE { contentType, content [0] EXPLICIT ANY } + content = univ.Any(signed_data_der).subtype( + explicitTag=tag.Tag(tag.tagClassContext, tag.tagFormatConstructed, 0) + ) + content_info = univ.Sequence() + content_info.setComponentByPosition(0, _SIGNED_DATA_OID) + content_info.setComponentByPosition(1, content) + + # PKIStatusInfo ::= SEQUENCE { status } + status_info = univ.Sequence() + status_info.setComponentByPosition(0, univ.Integer(0)) # granted + + # TimeStampResp ::= SEQUENCE { status, timeStampToken } + resp = univ.Sequence() + resp.setComponentByPosition(0, status_info) + resp.setComponentByPosition(1, content_info) + + return der_encoder.encode(resp) + + +def test_verify_accepts_genuine_tsa_response_matching_data(): + # Arrange + data = b"legitimate commitment payload" + digest = hashlib.sha256(data).digest() + resp_der = _build_timestamp_resp_der(digest) + tsa = RFC3161TimestampAuthority() + token = TimestampToken( + tsa_url=tsa._tsa_url, + token_bytes=resp_der, + token_hex=resp_der.hex(), + stamped_at=0, + hash_algorithm="sha-256", + message_imprint=digest.hex(), + ) + + # Act + result = tsa.verify(data, token) + + # Assert + assert result is True + + +def test_verify_rejects_forged_token_bytes_with_matching_self_asserted_imprint(): + """ + CRITICAL regression: a network attacker or malicious TSA can return + arbitrary garbage as token_bytes. Pre-fix, verify() ignored + token_bytes entirely and only checked the caller-supplied + message_imprint against sha256(data) -- so this forged token, whose + token_bytes attest to nothing, passed verification. Post-fix it must + fail because there is no genuine, parsable TSA attestation. + """ + # Arrange + data = b"legitimate commitment payload" + digest = hashlib.sha256(data).digest() + forged_token_bytes = b"\x00\x01\x02not a real TimeStampResp at all" + tsa = RFC3161TimestampAuthority() + forged_token = TimestampToken( + tsa_url=tsa._tsa_url, + token_bytes=forged_token_bytes, + token_hex=forged_token_bytes.hex(), + stamped_at=0, + hash_algorithm="sha-256", + # Self-asserted by the attacker/caller to match the data -- + # this is exactly what stamp() would have produced too. + message_imprint=digest.hex(), + ) + + # Act + result = tsa.verify(data, forged_token) + + # Assert + assert result is False + + +def test_verify_rejects_genuine_response_whose_signed_imprint_is_for_different_data(): + # Arrange: TSA genuinely attested to *other* data, not `data`. + data = b"legitimate commitment payload" + other_digest = hashlib.sha256(b"different data entirely").digest() + resp_der = _build_timestamp_resp_der(other_digest) + tsa = RFC3161TimestampAuthority() + token = TimestampToken( + tsa_url=tsa._tsa_url, + token_bytes=resp_der, + token_hex=resp_der.hex(), + stamped_at=0, + hash_algorithm="sha-256", + message_imprint=hashlib.sha256(data).hexdigest(), + ) + + # Act + result = tsa.verify(data, token) + + # Assert + assert result is False From 3a2f99d23c6d0c8e1975a4326f4af9088977b03f Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Wed, 15 Jul 2026 10:02:27 -0400 Subject: [PATCH 09/28] =?UTF-8?q?loop(LOOP-01):=20F-21=20=E2=80=94=20destr?= =?UTF-8?q?oy()=20now=20zeroes=20private=20key=20via=20mutable=20bytearray?= =?UTF-8?q?=20in=20place?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- aether_protocol_c/ephemeral_signer.py | 107 ++++++++++++++---- ...est_ephemeral_signer_destroy_zeroes_key.py | 50 ++++++++ 2 files changed, 136 insertions(+), 21 deletions(-) create mode 100644 tests/test_ephemeral_signer_destroy_zeroes_key.py diff --git a/aether_protocol_c/ephemeral_signer.py b/aether_protocol_c/ephemeral_signer.py index 2058d0d..f1509de 100644 --- a/aether_protocol_c/ephemeral_signer.py +++ b/aether_protocol_c/ephemeral_signer.py @@ -11,12 +11,15 @@ signer = EphemeralSigner(quantum_seed=...) sig = signer.sign_manifest(manifest_dict) ok = signer.verify(manifest_dict, sig) - signer.destroy() # zeroes private key from memory + signer.destroy() # best-effort zeroing of private key buffer Security properties: - Private key derived from quantum entropy via HMAC-SHA256 - Key NEVER written to disk - - destroy() zeroes key material in-place + - destroy() overwrites the private key's backing bytearray in-place + (best-effort only -- CPython's GC/refcounting may still leave + copies elsewhere in the process image; no guarantee against a + memory dump taken before destroy() runs) - Ephemeral: one key per session, discarded at end """ @@ -157,6 +160,21 @@ def _point_mul(k: int, point, bit_length: int = 256): G = _Point(Gx, Gy) +def _zero_bytearray(buf: bytearray) -> None: + """ + Overwrite a mutable buffer's bytes in place. + + Best-effort only: in a managed-memory runtime like CPython this does + not guarantee the value never existed elsewhere in the process image + (the garbage collector, refcounting, or interpreter internals may + still have made copies), but it does eliminate the *known* long-lived + copy this module controls directly, which a bare `= 0` rebind on an + immutable int/bytes object never touches. + """ + for i in range(len(buf)): + buf[i] = 0 + + # ── RFC 6979 deterministic k ──────────────────────────────────────────────── def _rfc6979_k(privkey: int, msg_hash: bytes) -> int: @@ -233,29 +251,52 @@ def __init__(self, quantum_seed: int): self._destroyed = False self._sign_count = 0 - # Derive private key from quantum seed via HMAC-SHA256 - seed_bytes = quantum_seed.to_bytes(32, "big") - key_material = hmac.new( - b"aether-ephemeral-secp256k1", - seed_bytes, - hashlib.sha256, - ).digest() - self._privkey = int.from_bytes(key_material, "big") % N + # Derive private key from quantum seed via HMAC-SHA256. + # Held in a mutable bytearray (not a bare int/bytes object) so + # destroy() can overwrite the actual backing buffer in place -- + # Python ints and bytes are immutable and can't be zeroed after + # the fact, only rebound to a new object, which leaves the + # original value sitting on the heap. + seed_bytes = bytearray(quantum_seed.to_bytes(32, "big")) + key_material = bytearray( + hmac.new( + b"aether-ephemeral-secp256k1", + bytes(seed_bytes), + hashlib.sha256, + ).digest() + ) + privkey_int = int.from_bytes(key_material, "big") % N retry_context = 0 - while self._privkey == 0: + while privkey_int == 0: # astronomically unlikely (~1/2^256), but never fall back to a # known constant like 1 — re-derive deterministically instead. retry_context += 1 - key_material = hmac.new( - b"aether-ephemeral-secp256k1-zero-key-retry", - seed_bytes + retry_context.to_bytes(4, "big"), - hashlib.sha256, - ).digest() - self._privkey = int.from_bytes(key_material, "big") % N + _zero_bytearray(key_material) + key_material = bytearray( + hmac.new( + b"aether-ephemeral-secp256k1-zero-key-retry", + bytes(seed_bytes) + retry_context.to_bytes(4, "big"), + hashlib.sha256, + ).digest() + ) + privkey_int = int.from_bytes(key_material, "big") % N + + self._privkey_buf = bytearray(privkey_int.to_bytes(32, "big")) # Derive public key self._pubkey = _point_mul(self._privkey, G) + # The seed and HMAC digest are intermediate copies of key material + # that are no longer needed once the private key buffer above is + # populated -- wipe them immediately rather than leaving them on + # the heap for the lifetime of the object. + _zero_bytearray(seed_bytes) + _zero_bytearray(key_material) + + @property + def _privkey(self) -> int: + return int.from_bytes(self._privkey_buf, "big") + @property def public_key_hex(self) -> str: """Compressed public key (33 bytes hex).""" @@ -288,6 +329,20 @@ def sign_manifest(self, manifest: dict) -> dict: def verify(self, manifest: dict, signature: dict) -> bool: """Verify a signature envelope against a manifest dict.""" + return EphemeralSigner.verify_static(manifest, signature) + + @staticmethod + def verify_static(manifest: dict, signature: dict) -> bool: + """ + Verify a signature envelope against a manifest dict. + + This is a pure function of the signature envelope's embedded public + key -- it never derives or holds any private key material, so + callers that only need to verify (no signing) should use this + instead of instantiating an EphemeralSigner, which would otherwise + pointlessly derive a throwaway private key and perform an EC point + multiplication on every call. + """ try: r = int(signature["r"], 16) s = int(signature["s"], 16) @@ -313,27 +368,37 @@ def verify(self, manifest: dict, signature: dict) -> bool: # Malformed signature envelope (missing field, bad hex, wrong # length, non-curve point, etc.) -- no key material is logged. logger.debug( - "EphemeralSigner.verify() failed to parse signature envelope: %s: %s", + "EphemeralSigner.verify_static() failed to parse signature envelope: %s: %s", type(exc).__name__, exc, ) return False except Exception as exc: logger.debug( - "EphemeralSigner.verify() failed with unexpected error: %s: %s", + "EphemeralSigner.verify_static() failed with unexpected error: %s: %s", type(exc).__name__, exc, ) return False def destroy(self) -> dict: - """Zero private key material. Returns destruction receipt.""" + """ + Zero private key material in-place (best-effort). + + Overwrites the mutable bytearray backing the private key so the + actual scalar bytes are gone from this buffer, not merely + rebound to a new object. This is still a best-effort operation + in a managed-memory language: it does not guarantee against + copies made elsewhere by the GC, refcounting, or interpreter + internals, and offers no protection against a memory dump taken + before destroy() runs. + """ receipt = { "destroyed": True, "sign_count": self._sign_count, "lifetime_seconds": round(time.time() - self._created_at, 2), } - self._privkey = 0 + _zero_bytearray(self._privkey_buf) self._destroyed = True return receipt diff --git a/tests/test_ephemeral_signer_destroy_zeroes_key.py b/tests/test_ephemeral_signer_destroy_zeroes_key.py new file mode 100644 index 0000000..43480df --- /dev/null +++ b/tests/test_ephemeral_signer_destroy_zeroes_key.py @@ -0,0 +1,50 @@ +""" +Regression tests for aether_protocol_c/ephemeral_signer.py (F-21). + +destroy() is documented as "zeroing" private key material. Prior to the +fix, it only did `self._privkey = 0`, which -- because Python ints are +immutable -- merely rebinds the attribute to a new int object and leaves +the original private-key bytes untouched on the heap. These tests verify +that destroy() now overwrites the actual backing buffer in place. +""" + +from aether_protocol_c.ephemeral_signer import EphemeralSigner + + +def test_destroy_zeroes_the_private_key_backing_buffer(): + # Arrange + signer = EphemeralSigner(quantum_seed=0xABCDEF) + privkey_buf_before = signer._privkey_buf + assert any(b != 0 for b in privkey_buf_before), ( + "sanity check: private key buffer should be non-zero before destroy()" + ) + + # Act + signer.destroy() + + # Assert: the SAME buffer object (not a new one) is now all zero bytes -- + # this is the property a bare `self._privkey = 0` rebind could never + # satisfy, since it never touches the original object's memory at all. + assert privkey_buf_before is signer._privkey_buf + assert all(b == 0 for b in signer._privkey_buf) + + +def test_destroy_makes_privkey_property_read_as_zero(): + # Arrange + signer = EphemeralSigner(quantum_seed=0x123456) + + # Act + signer.destroy() + + # Assert + assert signer._privkey == 0 + + +def test_init_wipes_intermediate_seed_and_key_material_copies(): + # Arrange / Act + signer = EphemeralSigner(quantum_seed=0x999999) + + # Assert: __init__ must not leave live, non-zeroed references to the + # intermediate seed_bytes/key_material buffers hanging off the instance. + assert not hasattr(signer, "_seed_bytes") + assert not hasattr(signer, "_key_material") From 79836445fad164ca987454798ee3fda988021798 Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Wed, 15 Jul 2026 10:03:12 -0400 Subject: [PATCH 10/28] =?UTF-8?q?loop(LOOP-01):=20F-27=20=E2=80=94=20narro?= =?UTF-8?q?w=20verify=5Fsignature()=20bare=20except=20and=20log=20unexpect?= =?UTF-8?q?ed=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- aether_protocol_c/crypto.py | 18 ++++----- ...rify_signature_unexpected_error_logging.py | 40 +++++++++++++++++++ 2 files changed, 48 insertions(+), 10 deletions(-) create mode 100644 tests/test_verify_signature_unexpected_error_logging.py diff --git a/aether_protocol_c/crypto.py b/aether_protocol_c/crypto.py index 174c0ee..b49d083 100644 --- a/aether_protocol_c/crypto.py +++ b/aether_protocol_c/crypto.py @@ -266,12 +266,10 @@ def verify(self, message: dict, signature: dict) -> bool: Returns: True if the signature is valid. """ - # EphemeralSigner.verify uses the pubkey from the signature envelope, - # not the private key, so we can use a temporary signer for verification. - temp = EphemeralSigner(quantum_seed=1) # seed irrelevant for verify - result = temp.verify(message, signature) - temp.destroy() - return result + # Delegates to the module-level verify_signature() (which itself + # delegates to EphemeralSigner.verify_static()) so there is a single + # implementation of signature verification in this package. + return verify_signature(message, signature) # ── Helper functions ────────────────────────────────────────────────────────── @@ -306,10 +304,10 @@ def verify_signature(message: dict, signature: dict) -> bool: True if the signature is valid. """ try: - temp = EphemeralSigner(quantum_seed=1) - result = temp.verify(message, signature) - temp.destroy() - return result + # verify_static() only parses the pubkey embedded in the signature + # envelope -- no private key is derived, unlike constructing a + # throwaway EphemeralSigner just to call its instance verify(). + return EphemeralSigner.verify_static(message, signature) except (KeyError, ValueError, TypeError) as exc: # Malformed signature envelope (missing field, bad hex, wrong # length, etc.) -- no key material is logged. diff --git a/tests/test_verify_signature_unexpected_error_logging.py b/tests/test_verify_signature_unexpected_error_logging.py new file mode 100644 index 0000000..45569a8 --- /dev/null +++ b/tests/test_verify_signature_unexpected_error_logging.py @@ -0,0 +1,40 @@ +""" +Regression test for F-27: verify_signature() must not silently swallow an +*unexpected* (non-parsing) exception with a bare `except Exception: return +False` and zero logging. Narrow parsing errors (KeyError/ValueError/TypeError) +were already covered by F-11; this test targets the separate catch-all branch +that guards truly unexpected failures (e.g. a bug unrelated to malformed +input), which must still be logged so operators can distinguish "our bug" +from "genuine tampering". +""" + +import logging + +import aether_protocol_c.crypto as crypto + + +def test_verify_signature_logs_diagnostic_on_unexpected_error(caplog, monkeypatch): + # Arrange: force EphemeralSigner.verify_static to raise an exception type + # that is NOT one of the narrowly-handled parsing errors, simulating an + # unrelated bug surfacing during verification. + def _boom(message, signature): + raise RuntimeError("unexpected internal failure") + + monkeypatch.setattr( + crypto.EphemeralSigner, "verify_static", staticmethod(_boom) + ) + + message = {"foo": "bar"} + signature = {"r": "1" * 64, "s": "1" * 64, "pubkey": "02" + "1" * 64} + + # Act + with caplog.at_level(logging.DEBUG): + result = crypto.verify_signature(message, signature) + + # Assert: still fails closed... + assert result is False + # ...but the unexpected error is logged, not silently mapped to "invalid + # signature" indistinguishable from real tampering (F-27). + assert any( + "unexpected" in record.getMessage() for record in caplog.records + ), "expected a debug log entry for the unexpected-error branch, got none" From c085fcd87f281c4dc3a4db687cb0af13ebf22840 Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Wed, 15 Jul 2026 10:03:13 -0400 Subject: [PATCH 11/28] =?UTF-8?q?loop(LOOP-01):=20F-28=20=E2=80=94=20add?= =?UTF-8?q?=20regression=20test=20locking=20=5Fmodinv=20to=20fixed-shape?= =?UTF-8?q?=20pow=20(no=20branchy=20extended-Euclidean)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ..._ephemeral_signer_modinv_constant_shape.py | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 tests/test_ephemeral_signer_modinv_constant_shape.py diff --git a/tests/test_ephemeral_signer_modinv_constant_shape.py b/tests/test_ephemeral_signer_modinv_constant_shape.py new file mode 100644 index 0000000..b69096e --- /dev/null +++ b/tests/test_ephemeral_signer_modinv_constant_shape.py @@ -0,0 +1,69 @@ +""" +Regression test for aether_protocol_c/ephemeral_signer.py (F-28). + +F-28 flagged that _point_mul (secret-scalar EC multiplication) and the +_modinv it relies on are hand-rolled, variable-time pure-Python code: +before the F-2 fix, _modinv used the extended Euclidean algorithm, whose +recursion depth/branch pattern varies with the operand (including secret +scalars/nonces), and _point_mul skipped work on zero bits. Both were +folded into a fixed-shape rewrite (see test_ephemeral_signer_timing_hygiene.py +for the _point_mul side). + +This test locks in the _modinv side of that fix: it must be implemented +via Python's built-in fixed-shape modular exponentiation (`pow(a, m-2, m)`) +rather than a hand-rolled branchy extended-Euclidean loop, and it must +still compute correct modular inverses. +""" + +import inspect + +from aether_protocol_c.ephemeral_signer import N, P, _modinv + + +def test_modinv_computes_correct_inverse_mod_p(): + # Arrange + a = 12345678901234567890 + + # Act + inv = _modinv(a, P) + + # Assert + assert (a * inv) % P == 1 + + +def test_modinv_computes_correct_inverse_mod_n(): + # Arrange + a = 98765432109876543210 + + # Act + inv = _modinv(a, N) + + # Assert + assert (a * inv) % N == 1 + + +def test_modinv_rejects_zero_with_no_inverse(): + # Arrange / Act / Assert + try: + _modinv(0, P) + assert False, "expected ValueError for a value with no modular inverse" + except ValueError: + pass + + +def test_modinv_uses_fixed_shape_pow_not_branchy_extended_euclidean(): + """ + Source-shape guard: _modinv must delegate to the built-in `pow()` + (fixed-shape binary exponentiation, no operand-dependent branching) + rather than a hand-rolled extended Euclidean algorithm, whose + recursion/branch pattern is exactly the timing side-channel F-28 + flagged for scalars derived from secret key/nonce material. + """ + # Arrange + source = inspect.getsource(_modinv) + + # Act / Assert + assert "pow(" in source + # No manual recursive/iterative gcd-style branching left in the body. + assert "while" not in source + assert "def gcd" not in source From c6d8752eab51032e08699f27fc112c4e6d809e43 Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Wed, 15 Jul 2026 10:04:25 -0400 Subject: [PATCH 12/28] =?UTF-8?q?loop(LOOP-01):=20F-26=20=E2=80=94=20dedup?= =?UTF-8?q?e=20verify()=20via=20EphemeralSigner.verify=5Fstatic,=20no=20th?= =?UTF-8?q?rowaway=20privkey=20derivation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...test_verify_no_duplicate_key_derivation.py | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 tests/test_verify_no_duplicate_key_derivation.py diff --git a/tests/test_verify_no_duplicate_key_derivation.py b/tests/test_verify_no_duplicate_key_derivation.py new file mode 100644 index 0000000..ec15668 --- /dev/null +++ b/tests/test_verify_no_duplicate_key_derivation.py @@ -0,0 +1,88 @@ +""" +Regression test for F-26: QuantumEphemeralKey.verify() and the module-level +crypto.verify_signature() must not each construct their own throwaway +EphemeralSigner(quantum_seed=1) instance just to call .verify() -- that +needlessly derives a private key and performs an EC point multiplication. + +Both should delegate to a single, lightweight verification path +(EphemeralSigner.verify_static) that only parses the pubkey embedded in the +signature envelope, with no private-key derivation. +""" + +from unittest.mock import patch + +import aether_protocol_c.crypto as crypto +from aether_protocol_c.crypto import QuantumEphemeralKey, verify_signature +from aether_protocol_c.ephemeral_signer import EphemeralSigner + + +def _make_valid_signature(): + # Arrange: sign a message with a real ephemeral key so we have a + # well-formed signature envelope to verify against. + key = QuantumEphemeralKey(quantum_seed=12345, method="CSPRNG") + message = {"amount": 100, "asset": "BTC"} + signature = key.sign(message) + return message, signature + + +def test_verify_signature_does_not_instantiate_ephemeral_signer(): + # Arrange + message, signature = _make_valid_signature() + + # Act / Assert: verify_signature() must not construct a throwaway + # EphemeralSigner (which would derive a private key) -- it should only + # call the lightweight static verifier. + with patch.object( + EphemeralSigner, "__init__", side_effect=AssertionError( + "verify_signature() must not instantiate EphemeralSigner" + ), + ): + result = verify_signature(message, signature) + + assert result is True + + +def test_quantum_ephemeral_key_verify_does_not_instantiate_ephemeral_signer(): + # Arrange: construct the verifier key *before* patching, since key + # construction legitimately derives its own signing key -- only the + # subsequent .verify() call is under test. + message, signature = _make_valid_signature() + verifier_key = QuantumEphemeralKey(quantum_seed=1) + + # Act / Assert: QuantumEphemeralKey.verify() must delegate to the same + # lightweight path rather than duplicating its own throwaway signer. + with patch.object( + EphemeralSigner, "__init__", side_effect=AssertionError( + "QuantumEphemeralKey.verify() must not instantiate EphemeralSigner" + ), + ): + result = verifier_key.verify(message, signature) + + assert result is True + + +def test_verify_static_is_callable_without_any_instance(): + # Arrange + message, signature = _make_valid_signature() + + # Act: EphemeralSigner.verify_static() should be usable directly as a + # staticmethod, with no instance / private key required at all. + result = EphemeralSigner.verify_static(message, signature) + + # Assert + assert result is True + + +def test_quantum_ephemeral_key_verify_delegates_to_module_verify_signature(): + # Arrange + message, signature = _make_valid_signature() + + # Act: QuantumEphemeralKey.verify() should produce the same result as + # the module-level verify_signature() it delegates to (no duplicated + # divergent implementation). + instance_result = QuantumEphemeralKey(quantum_seed=1).verify(message, signature) + module_result = crypto.verify_signature(message, signature) + + # Assert + assert instance_result is True + assert module_result is True From 6896a645199f44c168e5b2cf85a342aa81db4cd3 Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Wed, 15 Jul 2026 10:04:31 -0400 Subject: [PATCH 13/28] =?UTF-8?q?loop(LOOP-01):=20F-23=20=E2=80=94=20get?= =?UTF-8?q?=5Ftrade=5Fflow()=20now=20uses=20SQLite=20index=20(get=5Fby=5Fi?= =?UTF-8?q?d)=20instead=20of=20full-file=20scan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- aether_protocol_c/audit.py | 32 ++++---- tests/test_audit_get_trade_flow_indexed.py | 88 ++++++++++++++++++++++ 2 files changed, 105 insertions(+), 15 deletions(-) create mode 100644 tests/test_audit_get_trade_flow_indexed.py diff --git a/aether_protocol_c/audit.py b/aether_protocol_c/audit.py index b5dfbe1..214a1f0 100644 --- a/aether_protocol_c/audit.py +++ b/aether_protocol_c/audit.py @@ -554,8 +554,6 @@ def get_trade_flow(self, order_id: str) -> dict: Returns: Dict with data and signature for each phase. """ - entries = self.read_by_order_id(order_id) - flow: Dict[str, Any] = { "order_id": order_id, "commitment": None, @@ -569,19 +567,23 @@ def get_trade_flow(self, order_id: str) -> dict: "settlement_quantum_proof": None, } - for entry in entries: - if entry.phase == PHASE_COMMITMENT: - flow["commitment"] = entry.data - flow["commitment_sig"] = entry.signature - flow["commitment_quantum_proof"] = entry.quantum_proof - elif entry.phase == PHASE_EXECUTION: - flow["execution"] = entry.data - flow["execution_sig"] = entry.signature - flow["execution_quantum_proof"] = entry.quantum_proof - elif entry.phase == PHASE_SETTLEMENT: - flow["settlement"] = entry.data - flow["settlement_sig"] = entry.signature - flow["settlement_quantum_proof"] = entry.quantum_proof + phase_to_keys = { + PHASE_COMMITMENT: ("commitment", "commitment_sig", "commitment_quantum_proof"), + PHASE_EXECUTION: ("execution", "execution_sig", "execution_quantum_proof"), + PHASE_SETTLEMENT: ("settlement", "settlement_sig", "settlement_quantum_proof"), + } + + for phase, (data_key, sig_key, proof_key) in phase_to_keys.items(): + record = self.get_by_id(f"{order_id}_{phase}") + if record is None: + continue + try: + entry = AuditEntry.from_dict(record) + except (KeyError, TypeError) as exc: + raise AuditError(f"Corrupt audit log entry: {exc}") from exc + flow[data_key] = entry.data + flow[sig_key] = entry.signature + flow[proof_key] = entry.quantum_proof return flow diff --git a/tests/test_audit_get_trade_flow_indexed.py b/tests/test_audit_get_trade_flow_indexed.py new file mode 100644 index 0000000..4f4bbd3 --- /dev/null +++ b/tests/test_audit_get_trade_flow_indexed.py @@ -0,0 +1,88 @@ +""" +Regression test for F-23: get_trade_flow() must use the SQLite index +(get_by_id) instead of a full linear scan/parse of the JSONL file +(read_all()/read_by_order_id()). + +Prior behavior: get_trade_flow() called read_by_order_id(), which calls +read_all(), performing a full-file scan+parse for every single order +lookup -- even though an O(1) SQLite index already exists. + +Fixed behavior: get_trade_flow() looks up each of the three phases via +get_by_id() (indexed seek), never calling read_all()/read_by_order_id(). +""" + +import os +import tempfile + +import pytest + +from aether_protocol_c.audit import AuditLog + + +def _make_signature() -> dict: + return {"sig": "abc", "public_key": "pub"} + + +def test_get_trade_flow_does_not_call_read_all(): + # Arrange: a fresh audit log with a full commitment/execution/ + # settlement trio for one order, plus noise from another order. + with tempfile.TemporaryDirectory() as tmpdir: + log_path = os.path.join(tmpdir, "audit.jsonl") + audit = AuditLog(log_path) + try: + audit.append_commitment( + {"order_id": "order-1", "quantum_seed_commitment": "seed"}, + _make_signature(), + ) + audit.append_execution( + {"execution_result": {"order_id": "order-1", "status": "filled"}}, + _make_signature(), + ) + audit.append_settlement( + {"order_id": "order-1", "status": "settled"}, + _make_signature(), + ) + # Noise: another order that should not appear in the flow. + audit.append_commitment( + {"order_id": "order-2", "quantum_seed_commitment": "seed2"}, + _make_signature(), + ) + + # Act: force read_all()/read_by_order_id() to fail loudly if + # get_trade_flow() ever falls back to a full-file scan. + def _boom(*args, **kwargs): + raise AssertionError( + "get_trade_flow() must not call read_all()/" + "read_by_order_id() -- it should use the SQLite index" + ) + + audit.read_all = _boom + audit.read_by_order_id = _boom + + flow = audit.get_trade_flow("order-1") + + # Assert: indexed lookups still return the correct trade flow. + assert flow["order_id"] == "order-1" + assert flow["commitment"]["quantum_seed_commitment"] == "seed" + assert flow["execution"]["execution_result"]["status"] == "filled" + assert flow["settlement"]["status"] == "settled" + finally: + audit.close() + + +def test_get_trade_flow_returns_none_fields_for_unknown_order(): + # Arrange: an audit log with no records at all. + with tempfile.TemporaryDirectory() as tmpdir: + log_path = os.path.join(tmpdir, "audit.jsonl") + audit = AuditLog(log_path) + try: + # Act + flow = audit.get_trade_flow("nonexistent-order") + + # Assert + assert flow["order_id"] == "nonexistent-order" + assert flow["commitment"] is None + assert flow["execution"] is None + assert flow["settlement"] is None + finally: + audit.close() From 413e0745ada30a83b327a5d43927540ed024348d Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Wed, 15 Jul 2026 10:06:54 -0400 Subject: [PATCH 14/28] =?UTF-8?q?loop(LOOP-01):=20F-19=20=E2=80=94=20stamp?= =?UTF-8?q?()=20now=20parses=20TSTInfo.nonce=20and=20rejects=20TSA=20respo?= =?UTF-8?q?nses=20whose=20echoed=20nonce=20doesn't=20match=20the=20request?= =?UTF-8?q?,=20closing=20a=20replay=20gap=20(also=20includes=20concurrentl?= =?UTF-8?q?y-landed=20CMS=20signature=20verification=20in=20verify()/findi?= =?UTF-8?q?ng=20F-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- aether_protocol_c/timestamp_authority.py | 268 ++++++++++++++++-- .../test_timestamp_authority_nonce_replay.py | 174 ++++++++++++ 2 files changed, 422 insertions(+), 20 deletions(-) create mode 100644 tests/test_timestamp_authority_nonce_replay.py diff --git a/aether_protocol_c/timestamp_authority.py b/aether_protocol_c/timestamp_authority.py index 4d0ccf8..ce8eb70 100644 --- a/aether_protocol_c/timestamp_authority.py +++ b/aether_protocol_c/timestamp_authority.py @@ -40,6 +40,24 @@ except ImportError: _PYASN1_AVAILABLE = False +try: + from pyasn1_modules import rfc3161, rfc5652 + from cryptography import x509 + from cryptography.exceptions import InvalidSignature + from cryptography.hazmat.primitives import hashes + from cryptography.hazmat.primitives.asymmetric import ec, padding, rsa + _CMS_VERIFY_AVAILABLE = True +except ImportError: + _CMS_VERIFY_AVAILABLE = False + +# OIDs for digest algorithms that may appear in a TSA's CMS SignerInfo. +_DIGEST_OID_TO_HASH_ALGO = { + "1.3.14.3.2.26": "sha1", + "2.16.840.1.101.3.4.2.1": "sha256", + "2.16.840.1.101.3.4.2.2": "sha384", + "2.16.840.1.101.3.4.2.3": "sha512", +} + # ── ASN.1 structures for RFC 3161 ──────────────────────────────────── @@ -404,15 +422,31 @@ def _build_timestamp_request(self, data: bytes) -> tuple[bytes, int]: return der_encoder.encode(req), nonce_val - def _extract_tst_info(self, resp_bytes: bytes) -> "TSTInfo": + def _extract_tst_info_nonce(self, resp_bytes: bytes) -> Optional[int]: """ - Parse a raw ``TimeStampResp`` and return the embedded ``TSTInfo``. + Parse a raw ``TimeStampResp`` and return the ``TSTInfo.nonce`` + value, if present. + + The embedded ``TSTInfo`` is decoded *schemaless* (without + ``asn1Spec=TSTInfo()``) deliberately: ``TSTInfo``'s optional + ``accuracy`` field is modelled as ``univ.Any()`` (see the class + docstring), and pyasn1's schema-mode decoder cannot determine + whether a following untagged optional/default field (``accuracy``, + ``ordering``) is present when trailing bytes exist -- it raises + rather than silently mis-parsing. A schemaless decode sidesteps + this because each universally-tagged primitive (``INTEGER``, + ``BOOLEAN``, ``SEQUENCE``, ...) is resolved directly from its own + DER tag, with no ambiguity to resolve. ``nonce`` is the only + top-level ``INTEGER``-tagged field in ``TSTInfo`` after + ``serialNumber``/``genTime``, so it can be found unambiguously by + tag once the mandatory prefix is skipped. Args: resp_bytes: Raw DER-encoded TimeStampResp bytes. Returns: - The decoded ``TSTInfo`` ASN.1 structure. + The nonce value echoed by the TSA, or ``None`` if the + TSTInfo has no nonce field. Raises: TimestampError: If the response cannot be parsed, does not @@ -444,7 +478,17 @@ def _extract_tst_info(self, resp_bytes: bytes) -> "TSTInfo": if econtent is None or not econtent.hasValue(): raise TimestampError("TSA response is missing eContent") - tst_info, _ = der_decoder.decode(bytes(econtent), asn1Spec=TSTInfo()) + # Schemaless decode -- see docstring above. + tst_info, _ = der_decoder.decode(bytes(econtent)) + + # Mandatory prefix: version, policy, messageImprint, + # serialNumber, genTime -- always exactly 5 components. + nonce_val: Optional[int] = None + for i in range(5, len(tst_info)): + component = tst_info.getComponentByPosition(i) + if isinstance(component, univ.Integer): + nonce_val = int(component) + break except TimestampError: raise except Exception as exc: @@ -452,7 +496,158 @@ def _extract_tst_info(self, resp_bytes: bytes) -> "TSTInfo": f"Failed to parse TSA response: {exc}" ) from exc - return tst_info + return nonce_val + + def _verify_cms_signature(self, content_info_content: bytes) -> bool: + """ + Verify the CMS ``SignedData`` signature covering a TSA's + ``TimeStampToken``, proving the response was produced by whoever + holds the private key for the embedded signer certificate -- + rather than trusting any bytes an HTTP endpoint chooses to return. + + Args: + content_info_content: The DER bytes of the ``SignedData`` + (i.e. ``ContentInfo.content``) extracted from the TSA's + ``TimeStampResp``. + + Returns: + ``True`` if a signerInfo's signature verifies against the + embedded signer certificate's public key; ``False`` on any + parsing failure, missing signer/certificate, digest mismatch, + or signature mismatch (fail-closed). + """ + if not _CMS_VERIFY_AVAILABLE: + return False + + try: + signed_data, _ = der_decoder.decode( + content_info_content, asn1Spec=rfc5652.SignedData() + ) + + econtent = bytes( + signed_data["encapContentInfo"]["eContent"] + ) + + signer_infos = signed_data["signerInfos"] + if len(signer_infos) < 1: + return False + + # Collect embedded certificates (CertificateChoices -> Certificate). + certs = [] + if signed_data["certificates"].isValue: + for choice in signed_data["certificates"]: + if choice.getName() == "certificate": + cert_der = der_encoder.encode(choice["certificate"]) + certs.append(x509.load_der_x509_certificate(cert_der)) + + for signer_info in signer_infos: + if self._verify_single_signer(signer_info, econtent, certs): + return True + + return False + except Exception: + # Any parsing/verification failure means the signature cannot + # be trusted -- fail closed. + return False + + def _verify_single_signer( + self, signer_info, econtent: bytes, certs: list + ) -> bool: + """ + Verify one CMS ``SignerInfo`` against a candidate signer + certificate's public key. + + Args: + signer_info: A parsed ``rfc5652.SignerInfo``. + econtent: The raw ``eContent`` (DER-encoded ``TSTInfo``) bytes. + certs: Candidate signer certificates from ``SignedData``. + + Returns: + ``True`` if the signature verifies; ``False`` otherwise. + """ + if not certs: + return False + + # Select the certificate identified by issuerAndSerialNumber when + # present; otherwise fall back to the sole/first embedded cert. + signer_cert = certs[0] + sid = signer_info["sid"] + if sid.getName() == "issuerAndSerialNumber": + iasn = sid["issuerAndSerialNumber"] + serial = int(iasn["serialNumber"]) + issuer_der = der_encoder.encode(iasn["issuer"]) + for cert in certs: + if ( + cert.serial_number == serial + and cert.issuer.public_bytes() == issuer_der + ): + signer_cert = cert + break + else: + return False + + digest_oid = str(signer_info["digestAlgorithm"]["algorithm"]) + hash_name = _DIGEST_OID_TO_HASH_ALGO.get(digest_oid) + if hash_name is None: + return False + hash_cls = { + "sha1": hashes.SHA1, + "sha256": hashes.SHA256, + "sha384": hashes.SHA384, + "sha512": hashes.SHA512, + }[hash_name] + + signed_attrs = signer_info["signedAttrs"] + if signed_attrs.isValue: + # signedAttrs must contain a messageDigest attribute equal to + # the digest of eContent -- otherwise the signature could + # cover attributes disconnected from the actual TSTInfo. + message_digest = None + for attr in signed_attrs: + if str(attr["attrType"]) == str(rfc5652.id_messageDigest): + values = attr["attrValues"] + if len(values) != 1: + return False + inner, _ = der_decoder.decode(bytes(values[0])) + message_digest = bytes(inner) + break + if message_digest is None: + return False + + digest = hashlib.new(hash_name, econtent).digest() + if message_digest != digest: + return False + + # Re-tag signedAttrs from its IMPLICIT [0] context tag to the + # universal SET tag it must have for signature purposes + # (RFC 5652 §5.4): the signature covers a DER SET OF Attribute, + # not the [0]-tagged field as it appears in SignerInfo. + reencoded_attrs = signed_attrs.clone( + tagSet=rfc5652.SignedAttributes().tagSet, + cloneValueFlag=True, + ) + signed_bytes = der_encoder.encode(reencoded_attrs) + else: + signed_bytes = econtent + + signature = bytes(signer_info["signature"]) + public_key = signer_cert.public_key() + + try: + if isinstance(public_key, rsa.RSAPublicKey): + public_key.verify( + signature, signed_bytes, padding.PKCS1v15(), hash_cls() + ) + elif isinstance(public_key, ec.EllipticCurvePublicKey): + public_key.verify( + signature, signed_bytes, ec.ECDSA(hash_cls()) + ) + else: + return False + except InvalidSignature: + return False + + return True def _send_request(self, tsa_url: str, req_bytes: bytes) -> bytes: """ @@ -568,21 +763,20 @@ def stamp(self, data: bytes) -> TimestampToken: # distinguished from a fresh response, letting a malicious # or compromised TSA / on-path attacker misattribute a # stale time to new data. - tst_info = self._extract_tst_info(resp_bytes) - resp_nonce = tst_info.getComponentByName("nonce") - if resp_nonce is None or not resp_nonce.hasValue(): + resp_nonce = self._extract_tst_info_nonce(resp_bytes) + if resp_nonce is None: raise TimestampError( f"TSA response from {url} did not echo the " "request nonce; rejecting to prevent replay." ) - if int(resp_nonce) != nonce_val: + if resp_nonce != nonce_val: raise TimestampError( f"TSA response from {url} echoed nonce " - f"{int(resp_nonce)}, expected {nonce_val}; " + f"{resp_nonce}, expected {nonce_val}; " "possible replay of a stale/substituted response." ) - return TimestampToken( + candidate = TimestampToken( tsa_url=url, token_bytes=resp_bytes, token_hex=resp_bytes.hex(), @@ -590,6 +784,19 @@ def stamp(self, data: bytes) -> TimestampToken: hash_algorithm="sha-256", message_imprint=digest_hex, ) + + # Reject the response outright unless it also passes full + # verification (status/hash/CMS signature) -- otherwise + # stamp() would happily persist a token that verify() + # itself would later reject. + if not self.verify(data, candidate): + raise TimestampError( + f"TSA response from {url} failed verification " + "(bad status, hash mismatch, or invalid CMS " + "signature); rejecting." + ) + + return candidate except TimestampError as exc: errors.append(str(exc)) continue @@ -615,15 +822,22 @@ def verify(self, data: bytes, token: TimestampToken) -> bool: TSTInfo* -- i.e. the hash the TSA itself attested to -- and requires it to equal ``sha256(data)``. + 4. Verifies the CMS ``SignerInfo`` signature over that ``TSTInfo`` + against the signer certificate embedded in the response, + proving the response was produced by whoever holds the + corresponding private key (``_verify_cms_signature``). + This defeats a malicious/compromised TSA (or network attacker) returning arbitrary ``token_bytes`` alongside a self-computed - ``message_imprint``: without a genuine TSA response whose embedded - TSTInfo hash matches the data, verification now fails. + ``message_imprint``: without a genuine, correctly-signed TSA + response whose embedded TSTInfo hash matches the data, verification + now fails. - Note: this does **not** verify the CMS ``SignerInfo`` signature or - the TSA certificate chain -- it only cryptographically parses and - checks the content the signature covers. For full trust-chain - verification, pair this with a dedicated PKI/CMS library. + Note: this verifies the CMS signature against the certificate + embedded in the response itself; it does **not** build/validate a + full certificate chain to a trusted root store. Pair this with a + dedicated PKI library (or pin the expected TSA certificate) if a + full chain-of-trust guarantee is required. Args: data: The original data that was timestamped. @@ -631,8 +845,9 @@ def verify(self, data: bytes, token: TimestampToken) -> bool: Returns: ``True`` if the TSA's own signed TSTInfo hash matches - ``sha256(data)``; ``False`` otherwise (including on any - malformed/unparsable response). + ``sha256(data)`` *and* the CMS signature over that TSTInfo + verifies against the embedded signer certificate; ``False`` + otherwise (including on any malformed/unparsable response). Raises: TimestampError: If pyasn1 is not available. @@ -642,6 +857,12 @@ def verify(self, data: bytes, token: TimestampToken) -> bool: "pyasn1 is required to verify RFC 3161 timestamps. " "Install with: pip install pyasn1" ) + if not _CMS_VERIFY_AVAILABLE: + raise TimestampError( + "pyasn1_modules and cryptography are required to verify " + "RFC 3161 timestamp signatures. Install with: " + "pip install pyasn1_modules cryptography" + ) expected_digest = hashlib.sha256(data).digest() @@ -681,4 +902,11 @@ def verify(self, data: bytes, token: TimestampToken) -> bool: return False # Sanity-check the locally recorded imprint is consistent too. - return token.message_imprint == expected_digest.hex() + if token.message_imprint != expected_digest.hex(): + return False + + # Finally, verify the CMS signature covering that TSTInfo against + # the embedded signer certificate. Without this, an attacker could + # forge a well-formed but unsigned/self-authored TimeStampResp with + # the correct hash and status, and it would pass every check above. + return self._verify_cms_signature(signed_data_der) diff --git a/tests/test_timestamp_authority_nonce_replay.py b/tests/test_timestamp_authority_nonce_replay.py new file mode 100644 index 0000000..1ee2943 --- /dev/null +++ b/tests/test_timestamp_authority_nonce_replay.py @@ -0,0 +1,174 @@ +""" +tests/test_timestamp_authority_nonce_replay.py + +Regression tests for F-19: RFC3161TimestampAuthority.stamp() must parse the +TSA's TimeStampResp and verify the echoed nonce matches the one sent in the +request, rejecting stale/substituted/replayed responses. + +Pre-fix, stamp() never decoded resp_bytes at all -- it built a +TimestampToken using a locally-computed digest_hex, so a captured or +replayed TimeStampResp (with a matching or attacker-chosen message imprint) +for unrelated data could not be distinguished from a fresh response. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +pyasn1 = pytest.importorskip("pyasn1") + +from pyasn1.codec.der import encoder as der_encoder +from pyasn1.type import tag, univ, useful + +from aether_protocol_c.timestamp_authority import ( + RFC3161TimestampAuthority, + TimestampError, +) + +_SHA256_OID = univ.ObjectIdentifier((2, 16, 840, 1, 101, 3, 4, 2, 1)) +_TST_INFO_OID = univ.ObjectIdentifier((1, 2, 840, 113549, 1, 9, 16, 1, 4)) +_SIGNED_DATA_OID = univ.ObjectIdentifier((1, 2, 840, 113549, 1, 7, 2)) + + +def _build_message_imprint(digest: bytes) -> univ.Sequence: + algo_seq = univ.Sequence() + algo_seq.setComponentByPosition(0, _SHA256_OID) + imprint = univ.Sequence() + imprint.setComponentByPosition(0, algo_seq) + imprint.setComponentByPosition(1, univ.OctetString(digest)) + return imprint + + +def _build_tst_info_der(digest: bytes, nonce: int | None) -> bytes: + """Build a DER-encoded TSTInfo, optionally echoing a nonce.""" + from aether_protocol_c.timestamp_authority import TSTInfo + + tst_info = TSTInfo() + tst_info.setComponentByName("version", univ.Integer(1)) + tst_info.setComponentByName("policy", univ.ObjectIdentifier((1, 2, 3))) + tst_info.setComponentByName("messageImprint", _build_message_imprint(digest)) + tst_info.setComponentByName("serialNumber", univ.Integer(1)) + tst_info.setComponentByName( + "genTime", useful.GeneralizedTime("20260101000000Z") + ) + tst_info.setComponentByName("ordering", univ.Boolean(False)) + if nonce is not None: + tst_info.setComponentByName("nonce", univ.Integer(nonce)) + return der_encoder.encode(tst_info) + + +def _build_timestamp_resp_der(digest: bytes, nonce: int | None) -> bytes: + """Build a full, decodable RFC 3161 TimeStampResp whose embedded + TSTInfo attests to ``digest`` and (optionally) echoes ``nonce``.""" + tst_info_der = _build_tst_info_der(digest, nonce) + + econtent = univ.OctetString(tst_info_der).subtype( + explicitTag=tag.Tag(tag.tagClassContext, tag.tagFormatSimple, 0) + ) + encap_content_info = univ.Sequence() + encap_content_info.setComponentByPosition(0, _TST_INFO_OID) + encap_content_info.setComponentByPosition(1, econtent) + + signed_data = univ.Sequence() + signed_data.setComponentByPosition(0, univ.Integer(3)) + signed_data.setComponentByPosition(1, univ.SetOf()) + signed_data.setComponentByPosition(2, encap_content_info) + signed_data.setComponentByPosition(3, univ.SetOf()) + signed_data_der = der_encoder.encode(signed_data) + + content = univ.Any(signed_data_der).subtype( + explicitTag=tag.Tag(tag.tagClassContext, tag.tagFormatConstructed, 0) + ) + content_info = univ.Sequence() + content_info.setComponentByPosition(0, _SIGNED_DATA_OID) + content_info.setComponentByPosition(1, content) + + status_info = univ.Sequence() + status_info.setComponentByPosition(0, univ.Integer(0)) # granted + + resp = univ.Sequence() + resp.setComponentByPosition(0, status_info) + resp.setComponentByPosition(1, content_info) + + return der_encoder.encode(resp) + + +def _make_mock_response(body: bytes): + mock_resp = MagicMock() + mock_resp.read.side_effect = lambda n=-1: body[:n] if n and n > 0 else body + mock_resp.headers = {} + mock_resp.__enter__.return_value = mock_resp + mock_resp.__exit__.return_value = False + return mock_resp + + +def test_stamp_rejects_response_with_mismatched_nonce_replay(): + """ + CRITICAL regression: a replayed/stale TimeStampResp for the current + data's digest but a *different* (stale) nonce must be rejected, since + it proves the response was not generated for this specific request. + """ + # Arrange + data = b"commitment payload" + tsa = RFC3161TimestampAuthority() + import hashlib + + digest = hashlib.sha256(data).digest() + # A stale response with a nonce that can never match the freshly + # generated random nonce sent by stamp(). + stale_nonce = 0xDEADBEEF + resp_der = _build_timestamp_resp_der(digest, stale_nonce) + mock_resp = _make_mock_response(resp_der) + + # Act / Assert: both primary and fallback TSA return the stale + # replayed response, so stamp() must exhaust retries and raise. + with patch("urllib.request.urlopen", return_value=mock_resp): + with pytest.raises(TimestampError, match="nonce"): + tsa.stamp(data) + + +def test_stamp_rejects_response_missing_nonce_entirely(): + # Arrange + data = b"commitment payload" + tsa = RFC3161TimestampAuthority() + import hashlib + + digest = hashlib.sha256(data).digest() + resp_der = _build_timestamp_resp_der(digest, nonce=None) + mock_resp = _make_mock_response(resp_der) + + # Act / Assert + with patch("urllib.request.urlopen", return_value=mock_resp): + with pytest.raises(TimestampError, match="nonce"): + tsa.stamp(data) + + +def test_stamp_accepts_response_with_matching_echoed_nonce(): + # Arrange + data = b"commitment payload" + tsa = RFC3161TimestampAuthority() + import hashlib + + digest = hashlib.sha256(data).digest() + captured_nonce = {} + + original_build = tsa._build_timestamp_request + + def _capture_build(d): + req_bytes, nonce_val = original_build(d) + captured_nonce["value"] = nonce_val + return req_bytes, nonce_val + + def _urlopen_side_effect(*args, **kwargs): + # Build the response only after the nonce has been captured, so + # the mocked TSA can genuinely echo it back. + resp_der = _build_timestamp_resp_der(digest, captured_nonce["value"]) + return _make_mock_response(resp_der) + + # Act + with patch.object(tsa, "_build_timestamp_request", side_effect=_capture_build): + with patch("urllib.request.urlopen", side_effect=_urlopen_side_effect): + token = tsa.stamp(data) + + # Assert + assert token.message_imprint == digest.hex() From b316b801f174b7980a069b9362273cb5cba21042 Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Wed, 15 Jul 2026 10:27:17 -0400 Subject: [PATCH 15/28] loop(LOOP-01): F-7 -- verify() now cryptographically checks the CMS SignerInfo signature over the TSA TimeStampToken, not just the hash RFC3161TimestampAuthority.verify() (and stamp(), via a self-check) previously accepted any TimeStampResp whose granted-status PKIStatusInfo and TSTInfo messageImprint hash matched the data, with no check that anyone actually signed the response. A malicious/compromised TSA or on-path attacker with no private key could forge such bytes and have them accepted as a genuine third-party attestation. verify() now decodes the CMS SignedData, resolves the embedded signer certificate (matching issuerAndSerialNumber when present), and verifies the SignerInfo's RSA/ECDSA signature over the (re-tagged) signedAttrs -- checking the signedAttrs messageDigest attribute against the actual eContent digest first. stamp() rejects any TSA response that fails this full verification instead of persisting it. Also fixes a latent bug this change surfaced: verify()'s TSTInfo decode used a strict asn1Spec, which pyasn1 cannot reliably parse when a `nonce` field is present without the earlier optional `accuracy`/`ordering` fields (a case already worked around elsewhere via schemaless decoding) -- switched to the same schemaless positional extraction so genuine nonce-echoing TSA responses verify correctly. Adds pyasn1-modules/cryptography to the `timestamp` extra, a shared DER/CMS test-fixture builder (tests/_rfc3161_test_support.py), and regression tests covering: genuine signed response accepted; correct-hash-but-unsigned rejected; corrupted-signature rejected; signature/cert keypair mismatch rejected. --- aether_protocol_c/timestamp_authority.py | 19 +- pyproject.toml | 2 +- tests/_rfc3161_test_support.py | 234 ++++++++++++++++++ .../test_timestamp_authority_cms_signature.py | 131 ++++++++++ .../test_timestamp_authority_nonce_replay.py | 82 ++---- .../test_timestamp_authority_verify_crypto.py | 84 ++----- 6 files changed, 409 insertions(+), 143 deletions(-) create mode 100644 tests/_rfc3161_test_support.py create mode 100644 tests/test_timestamp_authority_cms_signature.py diff --git a/aether_protocol_c/timestamp_authority.py b/aether_protocol_c/timestamp_authority.py index ce8eb70..d043a70 100644 --- a/aether_protocol_c/timestamp_authority.py +++ b/aether_protocol_c/timestamp_authority.py @@ -41,7 +41,7 @@ _PYASN1_AVAILABLE = False try: - from pyasn1_modules import rfc3161, rfc5652 + from pyasn1_modules import rfc5652 from cryptography import x509 from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives import hashes @@ -888,12 +888,17 @@ def verify(self, data: bytes, token: TimestampToken) -> bool: if econtent is None or not econtent.hasValue(): return False - tst_info, _ = der_decoder.decode(bytes(econtent), asn1Spec=TSTInfo()) - tsa_hashed_message = bytes( - tst_info.getComponentByName("messageImprint").getComponentByName( - "hashedMessage" - ) - ) + # Schemaless decode -- see `_extract_tst_info_nonce`'s docstring: + # pyasn1's schema-mode TSTInfo() decoder cannot reliably + # disambiguate later optional/default fields (accuracy, + # ordering) from an included `nonce` when earlier optional + # fields are DER-omitted, and raises rather than risk a wrong + # parse. `messageImprint` is always the mandatory 3rd component + # (position 2), so it can be read positionally without + # depending on which trailing optional fields are present. + tst_info, _ = der_decoder.decode(bytes(econtent)) + message_imprint = tst_info.getComponentByPosition(2) + tsa_hashed_message = bytes(message_imprint.getComponentByPosition(1)) except Exception: # Malformed/unparsable TSA response -- cannot be trusted. return False diff --git a/pyproject.toml b/pyproject.toml index a39f6d0..e41582e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ classifiers = [ dependencies = [] [project.optional-dependencies] -timestamp = ["pyasn1>=0.5"] +timestamp = ["pyasn1>=0.5", "pyasn1-modules>=0.4", "cryptography>=41"] dev = ["pytest>=8.0"] [project.scripts] diff --git a/tests/_rfc3161_test_support.py b/tests/_rfc3161_test_support.py new file mode 100644 index 0000000..09e3517 --- /dev/null +++ b/tests/_rfc3161_test_support.py @@ -0,0 +1,234 @@ +""" +tests/_rfc3161_test_support.py + +Shared test-only helpers for building real, decodable RFC 3161 +``TimeStampResp`` / CMS ``SignedData`` fixtures, optionally with a genuine +CMS signature over a self-signed TSA certificate. + +Not a test module itself -- imported by the ``test_timestamp_authority_*`` +files. Requires ``cryptography`` in addition to ``pyasn1``. +""" + +from __future__ import annotations + +import datetime +import hashlib + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import padding, rsa +from cryptography.x509.oid import NameOID +from pyasn1.codec.der import encoder as der_encoder +from pyasn1.type import tag, univ, useful + +_SHA256_OID = univ.ObjectIdentifier((2, 16, 840, 1, 101, 3, 4, 2, 1)) +_TST_INFO_OID = univ.ObjectIdentifier((1, 2, 840, 113549, 1, 9, 16, 1, 4)) +_RSA_OID = univ.ObjectIdentifier((1, 2, 840, 113549, 1, 1, 1)) +_SIGNED_DATA_OID = univ.ObjectIdentifier((1, 2, 840, 113549, 1, 7, 2)) +_CONTENT_TYPE_OID = univ.ObjectIdentifier((1, 2, 840, 113549, 1, 9, 3)) +_MESSAGE_DIGEST_OID = univ.ObjectIdentifier((1, 2, 840, 113549, 1, 9, 4)) + + +def generate_self_signed_tsa_cert(): + """Generate a throwaway RSA key + self-signed certificate for a fake TSA.""" + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Test TSA")]) + cert = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(12345) + .not_valid_before(datetime.datetime(2020, 1, 1)) + .not_valid_after(datetime.datetime(2030, 1, 1)) + .sign(key, hashes.SHA256()) + ) + return key, cert + + +def build_tst_info_der(digest: bytes, nonce=None) -> bytes: + """Build a minimal DER-encoded TSTInfo whose messageImprint == digest, + optionally echoing ``nonce`` (as a genuine TSA response would).""" + algo_seq = univ.Sequence() + algo_seq.setComponentByPosition(0, _SHA256_OID) + imprint = univ.Sequence() + imprint.setComponentByPosition(0, algo_seq) + imprint.setComponentByPosition(1, univ.OctetString(digest)) + + tst_info = univ.Sequence() + tst_info.setComponentByPosition(0, univ.Integer(1)) # version + tst_info.setComponentByPosition(1, univ.ObjectIdentifier((1, 2, 3))) # policy + tst_info.setComponentByPosition(2, imprint) + tst_info.setComponentByPosition(3, univ.Integer(1)) # serialNumber + tst_info.setComponentByPosition(4, useful.GeneralizedTime("20260101000000Z")) + if nonce is not None: + # Per DER, a component with its DEFAULT value ("ordering" defaults + # to FALSE) must be omitted from the encoding, matching how a + # real, DER-compliant TSA would encode this. `accuracy` is also + # OPTIONAL and omitted, so `nonce` is set at position 5 (not 6). + tst_info.setComponentByPosition(5, univ.Integer(nonce)) + return der_encoder.encode(tst_info) + + +def _build_attribute(oid: univ.ObjectIdentifier, value_der: bytes) -> univ.Sequence: + attr = univ.Sequence() + attr.setComponentByPosition(0, oid) + values = univ.SetOf(componentType=univ.Any()) + values.setComponentByPosition(0, univ.Any(value_der)) + attr.setComponentByPosition(1, values) + return attr + + +def build_signed_timestamp_resp_der( + digest: bytes, + key, + cert, + *, + tst_info_der=None, + corrupt_signature: bool = False, +) -> bytes: + """ + Build a full, decodable RFC 3161 ``TimeStampResp`` whose embedded + ``TSTInfo`` genuinely attests to ``digest``, wrapped in a real CMS + ``SignedData`` signed (RSA/PKCS#1v1.5/SHA-256) with ``key`` over a + ``signedAttrs`` set, alongside the self-signed ``cert``. + + Args: + digest: The SHA-256 digest the TSTInfo's messageImprint attests to. + key: RSA private key used to sign the CMS ``signedAttrs``. + cert: Self-signed certificate embedded in the CMS ``SignedData`` + (must correspond to ``key``). + tst_info_der: Pre-built TSTInfo DER to use instead of building one + from ``digest`` (e.g. to test a digest/TSTInfo mismatch). + corrupt_signature: If True, flip a bit in the CMS signature bytes + so the response has correct hash/status but an invalid + signature. + + Returns: + DER-encoded ``TimeStampResp`` bytes. + """ + if tst_info_der is None: + tst_info_der = build_tst_info_der(digest) + econtent_digest = hashlib.sha256(tst_info_der).digest() + + attr_content_type = _build_attribute( + _CONTENT_TYPE_OID, der_encoder.encode(_TST_INFO_OID) + ) + attr_message_digest = _build_attribute( + _MESSAGE_DIGEST_OID, der_encoder.encode(univ.OctetString(econtent_digest)) + ) + + signed_attrs_set = univ.SetOf(componentType=univ.Sequence()) + signed_attrs_set.setComponentByPosition(0, attr_content_type) + signed_attrs_set.setComponentByPosition(1, attr_message_digest) + signed_attrs_der_for_signing = der_encoder.encode(signed_attrs_set) + signature = key.sign( + signed_attrs_der_for_signing, padding.PKCS1v15(), hashes.SHA256() + ) + if corrupt_signature: + signature = bytes(signature[:-1]) + bytes([signature[-1] ^ 0xFF]) + + issuer_name_der = cert.issuer.public_bytes() + issuer_and_serial = univ.Sequence() + issuer_and_serial.setComponentByPosition(0, univ.Any(issuer_name_der)) + issuer_and_serial.setComponentByPosition(1, univ.Integer(cert.serial_number)) + + digest_algo = univ.Sequence() + digest_algo.setComponentByPosition(0, _SHA256_OID) + + signer_info = univ.Sequence() + signer_info.setComponentByPosition(0, univ.Integer(1)) # version + signer_info.setComponentByPosition(1, issuer_and_serial) # sid + signer_info.setComponentByPosition(2, digest_algo) + signed_attrs_implicit = signed_attrs_set.subtype( + implicitTag=tag.Tag(tag.tagClassContext, tag.tagFormatConstructed, 0), + cloneValueFlag=True, + ) + signer_info.setComponentByPosition(3, signed_attrs_implicit) + sig_algo = univ.Sequence() + sig_algo.setComponentByPosition(0, _RSA_OID) + signer_info.setComponentByPosition(4, sig_algo) + signer_info.setComponentByPosition(5, univ.OctetString(signature)) + + signer_infos = univ.SetOf(componentType=univ.Any()) + signer_infos.setComponentByPosition(0, univ.Any(der_encoder.encode(signer_info))) + + digest_algos = univ.SetOf(componentType=univ.Sequence()) + digest_algos.setComponentByPosition(0, digest_algo) + + econtent_val = univ.OctetString(tst_info_der).subtype( + explicitTag=tag.Tag(tag.tagClassContext, tag.tagFormatConstructed, 0) + ) + encap_content_info = univ.Sequence() + encap_content_info.setComponentByPosition(0, _TST_INFO_OID) + encap_content_info.setComponentByPosition(1, econtent_val) + + cert_der = cert.public_bytes(serialization.Encoding.DER) + certs_set = univ.SetOf(componentType=univ.Any()) + certs_set.setComponentByPosition(0, univ.Any(cert_der)) + certs_implicit = certs_set.subtype( + implicitTag=tag.Tag(tag.tagClassContext, tag.tagFormatConstructed, 0), + cloneValueFlag=True, + ) + + signed_data = univ.Sequence() + signed_data.setComponentByPosition(0, univ.Integer(3)) + signed_data.setComponentByPosition(1, digest_algos) + signed_data.setComponentByPosition(2, encap_content_info) + signed_data.setComponentByPosition(3, certs_implicit) + signed_data.setComponentByPosition(4, signer_infos) + signed_data_der = der_encoder.encode(signed_data) + + return _wrap_signed_data_in_resp(signed_data_der) + + +def build_unsigned_timestamp_resp_der(digest: bytes) -> bytes: + """ + Build a well-formed ``TimeStampResp`` with a granted status and a + ``TSTInfo`` whose messageImprint genuinely equals ``digest``, but with + **no** CMS signerInfos/certificates at all. + + This is the core F-7 regression fixture: pre-fix, this passed + ``verify()`` because only the hash was checked; post-fix it must be + rejected because there is no cryptographic signature over it. + """ + tst_info_der = build_tst_info_der(digest) + + econtent_val = univ.OctetString(tst_info_der).subtype( + explicitTag=tag.Tag(tag.tagClassContext, tag.tagFormatConstructed, 0) + ) + encap_content_info = univ.Sequence() + encap_content_info.setComponentByPosition(0, _TST_INFO_OID) + encap_content_info.setComponentByPosition(1, econtent_val) + + empty_certs = univ.SetOf(componentType=univ.Any()).subtype( + implicitTag=tag.Tag(tag.tagClassContext, tag.tagFormatConstructed, 0) + ) + + signed_data = univ.Sequence() + signed_data.setComponentByPosition(0, univ.Integer(3)) + signed_data.setComponentByPosition(1, univ.SetOf(componentType=univ.Sequence())) + signed_data.setComponentByPosition(2, encap_content_info) + signed_data.setComponentByPosition(3, empty_certs) + signed_data.setComponentByPosition(4, univ.SetOf(componentType=univ.Any())) + signed_data_der = der_encoder.encode(signed_data) + + return _wrap_signed_data_in_resp(signed_data_der) + + +def _wrap_signed_data_in_resp(signed_data_der: bytes) -> bytes: + content_wrapped = univ.Any(signed_data_der).subtype( + explicitTag=tag.Tag(tag.tagClassContext, tag.tagFormatConstructed, 0) + ) + content_info = univ.Sequence() + content_info.setComponentByPosition(0, _SIGNED_DATA_OID) + content_info.setComponentByPosition(1, content_wrapped) + + status_info = univ.Sequence() + status_info.setComponentByPosition(0, univ.Integer(0)) # granted + + resp = univ.Sequence() + resp.setComponentByPosition(0, status_info) + resp.setComponentByPosition(1, content_info) + + return der_encoder.encode(resp) diff --git a/tests/test_timestamp_authority_cms_signature.py b/tests/test_timestamp_authority_cms_signature.py new file mode 100644 index 0000000..e21c2bb --- /dev/null +++ b/tests/test_timestamp_authority_cms_signature.py @@ -0,0 +1,131 @@ +""" +tests/test_timestamp_authority_cms_signature.py + +Regression tests for F-7: RFC3161TimestampAuthority.verify() must +cryptographically verify the CMS SignerInfo signature over the TSA's +TimeStampToken, not just recompute/compare the messageImprint hash. + +Pre-fix, verify() (and stamp()) accepted any well-formed TimeStampResp whose +TSTInfo.messageImprint happened to equal sha256(data) and whose PKIStatus +was granted -- with no check that the response was actually signed by +anyone. That let a malicious/compromised TSA, or an on-path attacker who +can forge DER bytes (no private key required), produce a "valid" token for +arbitrary data. Post-fix, verify() additionally requires the CMS +SignerInfo's signature to verify against the embedded signer certificate's +public key. +""" + +import hashlib + +import pytest + +pyasn1 = pytest.importorskip("pyasn1") +pytest.importorskip("pyasn1_modules") +pytest.importorskip("cryptography") + +from tests._rfc3161_test_support import ( + build_signed_timestamp_resp_der, + build_unsigned_timestamp_resp_der, + generate_self_signed_tsa_cert, +) + +from aether_protocol_c.timestamp_authority import ( + RFC3161TimestampAuthority, + TimestampToken, +) + + +def _make_token(tsa: RFC3161TimestampAuthority, resp_der: bytes, digest_hex: str) -> TimestampToken: + return TimestampToken( + tsa_url=tsa._tsa_url, + token_bytes=resp_der, + token_hex=resp_der.hex(), + stamped_at=0, + hash_algorithm="sha-256", + message_imprint=digest_hex, + ) + + +def test_verify_accepts_genuinely_signed_tsa_response(): + # Arrange + data = b"legitimate commitment payload" + digest = hashlib.sha256(data).digest() + key, cert = generate_self_signed_tsa_cert() + resp_der = build_signed_timestamp_resp_der(digest, key, cert) + tsa = RFC3161TimestampAuthority() + token = _make_token(tsa, resp_der, digest.hex()) + + # Act + result = tsa.verify(data, token) + + # Assert + assert result is True + + +def test_verify_rejects_response_with_correct_hash_but_no_cms_signature(): + """ + CRITICAL regression (F-7 core case): an attacker who can forge/replay + DER bytes -- but does not hold the TSA's private key -- builds a + TimeStampResp with a granted status and a TSTInfo whose messageImprint + genuinely equals sha256(data), but with no CMS signerInfos at all. + + Pre-fix, verify() only checked the messageImprint hash and passed this. + Post-fix it must fail because there is no signature to verify. + """ + # Arrange + data = b"legitimate commitment payload" + digest = hashlib.sha256(data).digest() + resp_der = build_unsigned_timestamp_resp_der(digest) + tsa = RFC3161TimestampAuthority() + token = _make_token(tsa, resp_der, digest.hex()) + + # Act + result = tsa.verify(data, token) + + # Assert + assert result is False + + +def test_verify_rejects_response_with_correct_hash_but_corrupted_signature(): + """ + A response with the right hash/status/certificate but a tampered + signature (e.g. flipped by a MITM, or forged without the private key) + must fail signature verification. + """ + # Arrange + data = b"legitimate commitment payload" + digest = hashlib.sha256(data).digest() + key, cert = generate_self_signed_tsa_cert() + resp_der = build_signed_timestamp_resp_der(digest, key, cert, corrupt_signature=True) + tsa = RFC3161TimestampAuthority() + token = _make_token(tsa, resp_der, digest.hex()) + + # Act + result = tsa.verify(data, token) + + # Assert + assert result is False + + +def test_verify_rejects_signature_from_a_different_keypair_than_embedded_cert(): + """ + A signature produced by an attacker's own key, paired with a legitimate + -looking (but mismatched) certificate, must not verify: the public key + in the embedded certificate is what's actually used to check the + signature, so a signature/cert mismatch is caught. + """ + # Arrange + data = b"legitimate commitment payload" + digest = hashlib.sha256(data).digest() + _real_key, real_cert = generate_self_signed_tsa_cert() + attacker_key, _attacker_cert = generate_self_signed_tsa_cert() + # Sign with the attacker's key, but embed the *real* (unrelated) cert. + resp_der = build_signed_timestamp_resp_der(digest, attacker_key, real_cert) + tsa = RFC3161TimestampAuthority() + token = _make_token(tsa, resp_der, digest.hex()) + + # Act + result = tsa.verify(data, token) + + # Assert + assert result is False diff --git a/tests/test_timestamp_authority_nonce_replay.py b/tests/test_timestamp_authority_nonce_replay.py index 1ee2943..9de664d 100644 --- a/tests/test_timestamp_authority_nonce_replay.py +++ b/tests/test_timestamp_authority_nonce_replay.py @@ -16,81 +16,33 @@ import pytest pyasn1 = pytest.importorskip("pyasn1") +pytest.importorskip("pyasn1_modules") +pytest.importorskip("cryptography") -from pyasn1.codec.der import encoder as der_encoder -from pyasn1.type import tag, univ, useful +from tests._rfc3161_test_support import ( + build_signed_timestamp_resp_der, + build_tst_info_der, + generate_self_signed_tsa_cert, +) from aether_protocol_c.timestamp_authority import ( RFC3161TimestampAuthority, TimestampError, ) -_SHA256_OID = univ.ObjectIdentifier((2, 16, 840, 1, 101, 3, 4, 2, 1)) -_TST_INFO_OID = univ.ObjectIdentifier((1, 2, 840, 113549, 1, 9, 16, 1, 4)) -_SIGNED_DATA_OID = univ.ObjectIdentifier((1, 2, 840, 113549, 1, 7, 2)) - - -def _build_message_imprint(digest: bytes) -> univ.Sequence: - algo_seq = univ.Sequence() - algo_seq.setComponentByPosition(0, _SHA256_OID) - imprint = univ.Sequence() - imprint.setComponentByPosition(0, algo_seq) - imprint.setComponentByPosition(1, univ.OctetString(digest)) - return imprint - - -def _build_tst_info_der(digest: bytes, nonce: int | None) -> bytes: - """Build a DER-encoded TSTInfo, optionally echoing a nonce.""" - from aether_protocol_c.timestamp_authority import TSTInfo - - tst_info = TSTInfo() - tst_info.setComponentByName("version", univ.Integer(1)) - tst_info.setComponentByName("policy", univ.ObjectIdentifier((1, 2, 3))) - tst_info.setComponentByName("messageImprint", _build_message_imprint(digest)) - tst_info.setComponentByName("serialNumber", univ.Integer(1)) - tst_info.setComponentByName( - "genTime", useful.GeneralizedTime("20260101000000Z") - ) - tst_info.setComponentByName("ordering", univ.Boolean(False)) - if nonce is not None: - tst_info.setComponentByName("nonce", univ.Integer(nonce)) - return der_encoder.encode(tst_info) - +_TSA_KEY, _TSA_CERT = generate_self_signed_tsa_cert() -def _build_timestamp_resp_der(digest: bytes, nonce: int | None) -> bytes: - """Build a full, decodable RFC 3161 TimeStampResp whose embedded - TSTInfo attests to ``digest`` and (optionally) echoes ``nonce``.""" - tst_info_der = _build_tst_info_der(digest, nonce) - econtent = univ.OctetString(tst_info_der).subtype( - explicitTag=tag.Tag(tag.tagClassContext, tag.tagFormatSimple, 0) - ) - encap_content_info = univ.Sequence() - encap_content_info.setComponentByPosition(0, _TST_INFO_OID) - encap_content_info.setComponentByPosition(1, econtent) - - signed_data = univ.Sequence() - signed_data.setComponentByPosition(0, univ.Integer(3)) - signed_data.setComponentByPosition(1, univ.SetOf()) - signed_data.setComponentByPosition(2, encap_content_info) - signed_data.setComponentByPosition(3, univ.SetOf()) - signed_data_der = der_encoder.encode(signed_data) - - content = univ.Any(signed_data_der).subtype( - explicitTag=tag.Tag(tag.tagClassContext, tag.tagFormatConstructed, 0) +def _build_timestamp_resp_der(digest: bytes, nonce) -> bytes: + """Build a full, decodable, genuinely CMS-signed RFC 3161 TimeStampResp + whose embedded TSTInfo attests to ``digest`` and (optionally) echoes + ``nonce``. stamp() now runs the response through verify() (which + requires a valid CMS signature -- see F-7), so nonce-replay fixtures + must be genuinely signed too, not just hash/nonce-matching.""" + tst_info_der = build_tst_info_der(digest, nonce=nonce) + return build_signed_timestamp_resp_der( + digest, _TSA_KEY, _TSA_CERT, tst_info_der=tst_info_der ) - content_info = univ.Sequence() - content_info.setComponentByPosition(0, _SIGNED_DATA_OID) - content_info.setComponentByPosition(1, content) - - status_info = univ.Sequence() - status_info.setComponentByPosition(0, univ.Integer(0)) # granted - - resp = univ.Sequence() - resp.setComponentByPosition(0, status_info) - resp.setComponentByPosition(1, content_info) - - return der_encoder.encode(resp) def _make_mock_response(body: bytes): diff --git a/tests/test_timestamp_authority_verify_crypto.py b/tests/test_timestamp_authority_verify_crypto.py index 3170a08..2d50bb9 100644 --- a/tests/test_timestamp_authority_verify_crypto.py +++ b/tests/test_timestamp_authority_verify_crypto.py @@ -11,6 +11,10 @@ itself, never from anything extracted out of the TSA's response. That let a forged/garbage ``token_bytes`` payload pass verification every time, as long as ``message_imprint`` was set to match the caller's data. + +Note: verify() also now requires the CMS signature over the response to +verify (see F-7 / test_timestamp_authority_cms_signature.py), so the +"genuine" fixture below is genuinely signed, not just hash-matching. """ import hashlib @@ -18,87 +22,26 @@ import pytest pyasn1 = pytest.importorskip("pyasn1") +pytest.importorskip("pyasn1_modules") +pytest.importorskip("cryptography") -from pyasn1.codec.der import encoder as der_encoder -from pyasn1.type import tag, univ, useful +from tests._rfc3161_test_support import ( + build_signed_timestamp_resp_der, + generate_self_signed_tsa_cert, +) from aether_protocol_c.timestamp_authority import ( RFC3161TimestampAuthority, TimestampToken, ) -_SHA256_OID = univ.ObjectIdentifier((2, 16, 840, 1, 101, 3, 4, 2, 1)) -_TST_INFO_OID = univ.ObjectIdentifier((1, 2, 840, 113549, 1, 9, 16, 1, 4)) -_SIGNED_DATA_OID = univ.ObjectIdentifier((1, 2, 840, 113549, 1, 7, 2)) - - -def _build_message_imprint(digest: bytes) -> univ.Sequence: - algo_seq = univ.Sequence() - algo_seq.setComponentByPosition(0, _SHA256_OID) - imprint = univ.Sequence() - imprint.setComponentByPosition(0, algo_seq) - imprint.setComponentByPosition(1, univ.OctetString(digest)) - return imprint - - -def _build_tst_info_der(digest: bytes) -> bytes: - """Build a minimal DER-encoded TSTInfo whose messageImprint == digest.""" - tst_info = univ.Sequence() - tst_info.setComponentByPosition(0, univ.Integer(1)) # version - tst_info.setComponentByPosition(1, univ.ObjectIdentifier((1, 2, 3))) # policy - tst_info.setComponentByPosition(2, _build_message_imprint(digest)) - tst_info.setComponentByPosition(3, univ.Integer(1)) # serialNumber - tst_info.setComponentByPosition(4, useful.GeneralizedTime("20260101000000Z")) - return der_encoder.encode(tst_info) - - -def _build_timestamp_resp_der(digest: bytes) -> bytes: - """Build a full, decodable RFC 3161 TimeStampResp whose embedded - TSTInfo genuinely attests to ``digest``.""" - tst_info_der = _build_tst_info_der(digest) - - # encapContentInfo ::= SEQUENCE { eContentType, eContent [0] EXPLICIT OCTET STRING } - econtent = univ.OctetString(tst_info_der).subtype( - explicitTag=tag.Tag(tag.tagClassContext, tag.tagFormatSimple, 0) - ) - encap_content_info = univ.Sequence() - encap_content_info.setComponentByPosition(0, _TST_INFO_OID) - encap_content_info.setComponentByPosition(1, econtent) - - # SignedData ::= SEQUENCE { version, digestAlgorithms, encapContentInfo, - # signerInfos } - signed_data = univ.Sequence() - signed_data.setComponentByPosition(0, univ.Integer(3)) - signed_data.setComponentByPosition(1, univ.SetOf()) - signed_data.setComponentByPosition(2, encap_content_info) - signed_data.setComponentByPosition(3, univ.SetOf()) - signed_data_der = der_encoder.encode(signed_data) - - # ContentInfo ::= SEQUENCE { contentType, content [0] EXPLICIT ANY } - content = univ.Any(signed_data_der).subtype( - explicitTag=tag.Tag(tag.tagClassContext, tag.tagFormatConstructed, 0) - ) - content_info = univ.Sequence() - content_info.setComponentByPosition(0, _SIGNED_DATA_OID) - content_info.setComponentByPosition(1, content) - - # PKIStatusInfo ::= SEQUENCE { status } - status_info = univ.Sequence() - status_info.setComponentByPosition(0, univ.Integer(0)) # granted - - # TimeStampResp ::= SEQUENCE { status, timeStampToken } - resp = univ.Sequence() - resp.setComponentByPosition(0, status_info) - resp.setComponentByPosition(1, content_info) - - return der_encoder.encode(resp) - def test_verify_accepts_genuine_tsa_response_matching_data(): # Arrange data = b"legitimate commitment payload" digest = hashlib.sha256(data).digest() - resp_der = _build_timestamp_resp_der(digest) + key, cert = generate_self_signed_tsa_cert() + resp_der = build_signed_timestamp_resp_der(digest, key, cert) tsa = RFC3161TimestampAuthority() token = TimestampToken( tsa_url=tsa._tsa_url, @@ -152,7 +95,8 @@ def test_verify_rejects_genuine_response_whose_signed_imprint_is_for_different_d # Arrange: TSA genuinely attested to *other* data, not `data`. data = b"legitimate commitment payload" other_digest = hashlib.sha256(b"different data entirely").digest() - resp_der = _build_timestamp_resp_der(other_digest) + key, cert = generate_self_signed_tsa_cert() + resp_der = build_signed_timestamp_resp_der(other_digest, key, cert) tsa = RFC3161TimestampAuthority() token = TimestampToken( tsa_url=tsa._tsa_url, From 46da2b888e4f9ecbd4673fd8b1affb714c7b1dcf Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Wed, 15 Jul 2026 10:36:14 -0400 Subject: [PATCH 16/28] =?UTF-8?q?loop(LOOP-17):=20round=201=20=E2=80=94=20?= =?UTF-8?q?vacuous-truth=20verification=20bypass=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verify_trade_flow computed chain_valid via all(v is True for v in [commitment_valid, execution_valid, settlement_valid] if v is not None). When an order_id has zero phases recorded (bogus/never-created order_id), all three locals stay None, the filtered list is empty, and Python's all([]) vacuously returns True — making quantum_safe=True for a trade flow with no cryptographic evidence whatsoever. Fix: require at least one phase to actually be present before chain_valid can be True. Empty flows now fail closed (chain_valid=False, quantum_safe=False). Adds regression test reproducing the exact scenario: a nonexistent order_id against an empty AuditLog. --- aether_protocol_c/verify.py | 11 +++++++---- tests/test_protocol.py | 25 +++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/aether_protocol_c/verify.py b/aether_protocol_c/verify.py index 2753ecb..2f1f661 100644 --- a/aether_protocol_c/verify.py +++ b/aether_protocol_c/verify.py @@ -165,13 +165,16 @@ def verify_trade_flow(self, order_id: str, audit_log: AuditLog) -> dict: details.append("Settlement phase: MISSING") # ── Overall assessment ─────────────────────────────────────── - chain_valid = all( - v is True - for v in [commitment_valid, execution_valid, settlement_valid] - if v is not None + phase_results = [commitment_valid, execution_valid, settlement_valid] + has_any_phase = any(v is not None for v in phase_results) + chain_valid = has_any_phase and all( + v is True for v in phase_results if v is not None ) # Quantum safety summary + # A flow with zero recorded phases has no cryptographic evidence at + # all, so it must never be reported as quantum-safe (vacuous truth + # over an empty all() would otherwise make chain_valid=True here). quantum_safe = chain_valid # If all checks pass, the flow is quantum-safe return { diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 89ac69c..29783c0 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -654,3 +654,28 @@ def test_verify_signature_standalone(): def test_verify_signature_invalid(): assert not verify_signature({"any": "msg"}, {"r": "00" * 32, "s": "00" * 32, "pubkey": "02" + "00" * 32}) + + +# ═══════════════════════════════════════════════════════════════════════════ +# 17. AUDIT VERIFIER — VACUOUS TRUTH REGRESSION (LOOP-17) +# ═══════════════════════════════════════════════════════════════════════════ + +def test_verify_trade_flow_empty_flow_is_not_quantum_safe(temp_audit_path): + """ + A bogus/never-created order_id has no commitment, execution, or + settlement records at all, so AuditLog.get_trade_flow() returns all + three phases as None. Previously, verify_trade_flow() computed + chain_valid via all(v is True for v in [...] if v is not None), which + is vacuously True over an empty list — falsely marking a flow with + ZERO cryptographic evidence as quantum_safe=True. This must fail closed. + """ + audit = AuditLog(temp_audit_path) + verifier = AuditVerifier() + + result = verifier.verify_trade_flow("nonexistent-order-id", audit) + + assert result["commitment_valid"] is None + assert result["execution_valid"] is None + assert result["settlement_valid"] is None + assert result["chain_valid"] is False + assert result["quantum_safe"] is False From 32ac004f2253db04c7e5bb8c805e9221e9363a26 Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Wed, 15 Jul 2026 10:38:45 -0400 Subject: [PATCH 17/28] =?UTF-8?q?loop(LOOP-17):=20round=202=20=E2=80=94=20?= =?UTF-8?q?partial-completeness=20vacuous-truth=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chain_valid previously filtered None phase results out of the all() check, so a flow missing e.g. the commitment phase could still be certified quantum_safe=True as long as the phases that were present (execution, settlement) were internally self-consistent. Now requires all three phases to be explicitly True. --- aether_protocol_c/verify.py | 11 +++--- tests/test_protocol.py | 67 +++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/aether_protocol_c/verify.py b/aether_protocol_c/verify.py index 2f1f661..d52fe21 100644 --- a/aether_protocol_c/verify.py +++ b/aether_protocol_c/verify.py @@ -165,11 +165,14 @@ def verify_trade_flow(self, order_id: str, audit_log: AuditLog) -> dict: details.append("Settlement phase: MISSING") # ── Overall assessment ─────────────────────────────────────── + # All three phases (commitment, execution, settlement) must be + # PRESENT and individually valid. A missing phase yields None for + # that phase's *_valid, which must never be silently filtered out + # of the aggregate check -- otherwise a flow missing e.g. the + # commitment record could still be certified chain_valid/quantum_safe + # as long as the phases that do exist are self-consistent. phase_results = [commitment_valid, execution_valid, settlement_valid] - has_any_phase = any(v is not None for v in phase_results) - chain_valid = has_any_phase and all( - v is True for v in phase_results if v is not None - ) + chain_valid = all(v is True for v in phase_results) # Quantum safety summary # A flow with zero recorded phases has no cryptographic evidence at diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 29783c0..21a155e 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -679,3 +679,70 @@ def test_verify_trade_flow_empty_flow_is_not_quantum_safe(temp_audit_path): assert result["settlement_valid"] is None assert result["chain_valid"] is False assert result["quantum_safe"] is False + + +def test_verify_trade_flow_missing_commitment_is_not_quantum_safe(temp_audit_path): + """ + Round-2 finding: a flow with valid, self-consistent execution and + settlement records but NO commitment record at all (dropped, never + logged, or a race where commitment write fails silently) must not + be certified quantum_safe. Previously chain_valid was computed via + all(v is True for v in phase_results if v is not None), which drops + the None commitment_valid from the check entirely -- so a settlement + lacking any commitment-phase evidence could still be reported + chain_valid=True / quantum_safe=True. This must fail closed. + """ + order_id = "missing_commit_001" + seed1 = get_seed() + seed2 = get_seed() + seed3 = get_seed() + snap = AccountSnapshot.from_dict(ACCOUNT_STATE) + + # Commitment is created only to derive valid downstream references -- + # it is deliberately NEVER appended to the audit log, simulating a + # dropped/pruned/never-written commitment record. + c_dict, c_sig, _ = QuantumDecisionCommitment.create_and_sign( + order_id=order_id, + trade_details=TRADE_DETAILS, + account_state=snap, + quantum_seed=seed1.seed_int, + measurement_method=seed1.method, + ) + + er = ExecutionResult(order_id=order_id, filled_qty=1, fill_price=50_000) + snap_after = AccountSnapshot.from_dict({**ACCOUNT_STATE, "nonce": 2}) + + att_dict, att_sig, _ = QuantumExecutionAttestation.create_and_sign( + commitment_sig=c_sig, + commitment_seed_hash=c_dict["quantum_seed_commitment"], + execution_result=er, + new_account_state=snap_after, + quantum_seed=seed2.seed_int, + measurement_method=seed2.method, + ) + + s_dict, s_sig, _ = QuantumSettlementRecord.create_and_sign( + order_id=order_id, + commitment_sig=c_sig, + commitment_seed_hash=c_dict["quantum_seed_commitment"], + commitment_window=c_dict["key_temporal_window"], + execution_sig=att_sig, + execution_seed_hash=att_dict["execution_quantum_seed_commitment"], + execution_window=att_dict["key_temporal_window"], + broker_sig="broker_ack_missing_commit", + quantum_seed=seed3.seed_int, + measurement_method=seed3.method, + ) + + audit = AuditLog(temp_audit_path) + audit.append_execution(att_dict, att_sig) + audit.append_settlement(s_dict, s_sig) + + verifier = AuditVerifier() + result = verifier.verify_trade_flow(order_id, audit) + + assert result["commitment_valid"] is None + assert result["execution_valid"] is True + assert result["settlement_valid"] is True + assert result["chain_valid"] is False + assert result["quantum_safe"] is False From 69baba27215483d42cc32e12c1368bb9ef078f1d Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Wed, 15 Jul 2026 10:47:11 -0400 Subject: [PATCH 18/28] =?UTF-8?q?loop(LOOP-17):=20round=203=20=E2=80=94=20?= =?UTF-8?q?missing-identity-binding=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verify_static()/verify_signature() and every *_valid check in verify.py only validated a signature against the pubkey embedded in that same envelope, never against a known, authorised identity. Anyone could mint a fresh keypair, self-sign a fabricated commitment/execution/settlement flow for any order_id, and have AuditVerifier.verify_trade_flow report quantum_safe=True / chain_valid=True — a tautology, not authorization. Add AccountKeyRegistry (aether_protocol_c/identity.py): an out-of-band registry of authorised account pubkeys, populated at onboarding, never derived from the audit log itself. AuditVerifier.verify_trade_flow and detect_tampering now accept a registry and gate each phase's *_valid (and therefore chain_valid/quantum_safe) on the embedded pubkey being a registered signer for the account/order scope; omitting the registry fails closed rather than silently certifying an unauthenticated flow. Regression tests reproduce the breaker's exact attacker scenario (self-signed forged flow reported quantum_safe without a registry, and rejected once a registry is populated with only the legitimate key). --- aether_protocol_c/identity.py | 107 ++++++++++++++++++++ aether_protocol_c/verify.py | 94 +++++++++++++++++- tests/test_protocol.py | 178 +++++++++++++++++++++++++++++++++- 3 files changed, 373 insertions(+), 6 deletions(-) create mode 100644 aether_protocol_c/identity.py diff --git a/aether_protocol_c/identity.py b/aether_protocol_c/identity.py new file mode 100644 index 0000000..0c86d50 --- /dev/null +++ b/aether_protocol_c/identity.py @@ -0,0 +1,107 @@ +""" +aether_protocol_c/identity.py + +Out-of-band account identity binding. + +The signature envelopes produced by EphemeralSigner / QuantumEphemeralKey +prove only that *a* valid ECDSA signature was produced by *some* key +embedded in that same envelope ("pubkey" field) -- they say nothing +about who controls that key. Anyone can mint a fresh secp256k1 +keypair, self-sign a fabricated commitment/execution/settlement flow +for any order_id, and every check in commitment.py / execution.py / +settlement.py / verify.py will report it as valid, because none of +them ever compare the embedded pubkey against a known, authorised +identity. + +AccountKeyRegistry closes that gap: it lets an operator register, out- +of-band (e.g. at account onboarding, via a trusted channel -- NEVER +derived from the audit log or from a signature envelope itself), the +public key(s) that are actually authorised to sign on behalf of a +given account/order identity scope. AuditVerifier.verify_trade_flow() +requires every phase's embedded pubkey to match a registered key +before that phase -- and the overall flow -- is treated as +authorised, so quantum_safe/chain_valid can no longer be satisfied by +a purely self-referential signature. +""" + +from __future__ import annotations + +import re +from typing import Dict, FrozenSet, Set + +_PUBKEY_RE = re.compile(r"^(02|03)[0-9a-f]{64}$") + + +class IdentityError(Exception): + """Raised when identity registry operations fail.""" + + +class AccountKeyRegistry: + """ + Maps an account/order identity scope to the set of public keys + authorised to sign on its behalf. + + This registry must be populated out-of-band (account onboarding, + key-rotation ceremony, etc.). It must NEVER be populated from data + read out of the audit log or out of a signature envelope itself -- + doing so would recreate the exact self-referential tautology this + class exists to prevent. + """ + + def __init__(self) -> None: + self._authorized: Dict[str, Set[str]] = {} + + def register(self, account_id: str, pubkey_hex: str) -> None: + """ + Register a public key as authorised to sign for ``account_id``. + + Args: + account_id: The account/order identity scope. + pubkey_hex: Compressed secp256k1 public key -- 33-byte hex + (66 chars, "02"/"03" prefix), as produced by + ``EphemeralSigner.public_key_hex``. + + Raises: + IdentityError: If account_id is empty or pubkey_hex is not + a well-formed compressed pubkey. + """ + if not account_id: + raise IdentityError("account_id must be non-empty") + pubkey_hex = pubkey_hex.lower() + if not _PUBKEY_RE.match(pubkey_hex): + raise IdentityError(f"Malformed compressed pubkey: {pubkey_hex!r}") + self._authorized.setdefault(account_id, set()).add(pubkey_hex) + + def revoke(self, account_id: str, pubkey_hex: str) -> None: + """Remove a previously registered key for ``account_id``.""" + self._authorized.get(account_id, set()).discard(pubkey_hex.lower()) + + def is_authorized(self, account_id: str, pubkey_hex: str) -> bool: + """ + Check whether ``pubkey_hex`` is a registered, authorised signer + for ``account_id``. + + Fails closed: an account with no registered keys at all, or a + key that was never registered (or was revoked), is NOT + authorised. + + Args: + account_id: The account/order identity scope. + pubkey_hex: Compressed pubkey hex extracted from a + signature envelope. + + Returns: + True only if ``pubkey_hex`` is a currently registered key + for ``account_id``. + """ + if not account_id or not pubkey_hex: + return False + return pubkey_hex.lower() in self._authorized.get(account_id, set()) + + def is_registered(self, account_id: str) -> bool: + """Whether ``account_id`` has any registered keys at all.""" + return bool(self._authorized.get(account_id)) + + def get_authorized_pubkeys(self, account_id: str) -> FrozenSet[str]: + """Return the frozen set of pubkeys authorised for ``account_id``.""" + return frozenset(self._authorized.get(account_id, set())) diff --git a/aether_protocol_c/verify.py b/aether_protocol_c/verify.py index d52fe21..b5edfa8 100644 --- a/aether_protocol_c/verify.py +++ b/aether_protocol_c/verify.py @@ -25,6 +25,7 @@ from .commitment import QuantumCommitmentVerifier from .execution import QuantumExecutionVerifier from .crypto import verify_signature, SHOR_EARLIEST_ATTACK_SECONDS +from .identity import AccountKeyRegistry from .settlement import QuantumSettlementVerifier, compute_flow_merkle @@ -40,7 +41,13 @@ class AuditVerifier: independence, and chain linkage. """ - def verify_trade_flow(self, order_id: str, audit_log: AuditLog) -> dict: + def verify_trade_flow( + self, + order_id: str, + audit_log: AuditLog, + registry: Optional[AccountKeyRegistry] = None, + account_id: Optional[str] = None, + ) -> dict: """ Verify the complete trade flow for an order. @@ -50,16 +57,53 @@ def verify_trade_flow(self, order_id: str, audit_log: AuditLog) -> dict: 3. All temporal windows prove safety against Shor's 4. All seeds are independent (P4: PFS) 5. Chain linkage is correct + 6. Every phase's embedded pubkey is a registered, authorised + signer for the account (identity binding) + + A valid ECDSA signature over an envelope only proves that + *some* key -- possibly one an attacker just generated -- signed + that envelope; it does not by itself prove the account holder + authorised the trade. ``registry`` supplies the out-of-band + ground truth (registered at account onboarding, never derived + from the audit log itself) needed to close that gap. Every + phase is required to pass identity binding for the flow to be + reported quantum_safe/chain_valid -- if ``registry`` is not + supplied, the flow fails closed (identity_bound=False, + quantum_safe=False), because there is no way to prove any + signature actually belongs to the account holder. Args: order_id: The order to verify. audit_log: The audit log to read from. + registry: Out-of-band registry of authorised account + pubkeys. Required for the flow to be certified + quantum_safe/chain_valid. + account_id: Identity scope to check pubkeys against. + Defaults to ``order_id`` when omitted (this codebase + has no separate account identifier today; callers with + a real account/order distinction should pass it + explicitly). Returns: Comprehensive verification result dict. """ flow = audit_log.get_trade_flow(order_id) details: List[str] = [] + scope = account_id or order_id + + def _identity_ok(signature: Optional[dict]) -> bool: + if registry is None: + return False + if not signature: + return False + pubkey_hex = signature.get("pubkey", "") + return registry.is_authorized(scope, pubkey_hex) + + if registry is None: + details.append( + "Identity registry: NOT PROVIDED -- no phase can be certified " + "as authorised by the account holder (fail closed)" + ) # ── Commitment verification ────────────────────────────────── commitment_valid: Optional[bool] = None @@ -70,13 +114,17 @@ def verify_trade_flow(self, order_id: str, audit_log: AuditLog) -> dict: state_ok = QuantumCommitmentVerifier.verify_state_binding(flow["commitment"]) quantum_ok = QuantumCommitmentVerifier.verify_quantum_binding(flow["commitment"]) temporal_ok = QuantumCommitmentVerifier.verify_temporal_safety(flow["commitment"]) + identity_ok = _identity_ok(flow["commitment_sig"]) - commitment_valid = sig_ok and state_ok and quantum_ok and temporal_ok + commitment_valid = ( + sig_ok and state_ok and quantum_ok and temporal_ok and identity_ok + ) details.append(f"Commitment signature valid: {sig_ok}") details.append(f"Commitment state binding: {state_ok}") details.append(f"Commitment quantum binding: {quantum_ok}") details.append(f"Commitment temporal safety: {temporal_ok}") + details.append(f"Commitment identity bound (authorised signer): {identity_ok}") else: details.append("Commitment phase: MISSING") @@ -88,12 +136,14 @@ def verify_trade_flow(self, order_id: str, audit_log: AuditLog) -> dict: ) quantum_ok = QuantumExecutionVerifier.verify_quantum_binding(flow["execution"]) temporal_ok = QuantumExecutionVerifier.verify_temporal_safety(flow["execution"]) + identity_ok = _identity_ok(flow["execution_sig"]) - execution_valid = sig_ok and quantum_ok and temporal_ok + execution_valid = sig_ok and quantum_ok and temporal_ok and identity_ok details.append(f"Execution signature valid: {sig_ok}") details.append(f"Execution quantum binding: {quantum_ok}") details.append(f"Execution temporal safety: {temporal_ok}") + details.append(f"Execution identity bound (authorised signer): {identity_ok}") # Check commitment reference if flow["commitment_sig"] is not None: @@ -133,9 +183,11 @@ def verify_trade_flow(self, order_id: str, audit_log: AuditLog) -> dict: sig_ok = QuantumSettlementVerifier.verify_signature( flow["settlement"], flow["settlement_sig"] ) + identity_ok = _identity_ok(flow["settlement_sig"]) details.append(f"Settlement signature valid: {sig_ok}") + details.append(f"Settlement identity bound (authorised signer): {identity_ok}") - settlement_valid = sig_ok + settlement_valid = sig_ok and identity_ok # Chain linkage if flow["commitment_sig"] is not None and flow["execution_sig"] is not None: @@ -187,10 +239,17 @@ def verify_trade_flow(self, order_id: str, audit_log: AuditLog) -> dict: "commitment_valid": commitment_valid, "execution_valid": execution_valid, "settlement_valid": settlement_valid, + "identity_bound": registry is not None, "details": details, } - def detect_tampering(self, order_id: str, audit_log: AuditLog) -> dict: + def detect_tampering( + self, + order_id: str, + audit_log: AuditLog, + registry: Optional[AccountKeyRegistry] = None, + account_id: Optional[str] = None, + ) -> dict: """ Detect tampering in a trade flow. @@ -199,12 +258,34 @@ def detect_tampering(self, order_id: str, audit_log: AuditLog) -> dict: Args: order_id: The order to check. audit_log: The audit log to read from. + registry: Out-of-band registry of authorised account + pubkeys. Without it, an attacker-fabricated flow signed + with a fresh, self-consistent keypair reports as + untampered -- so its absence is itself flagged as an + issue. + account_id: Identity scope to check pubkeys against. + Defaults to ``order_id`` when omitted. Returns: Dict with order_id, tampered (bool), issues (list of strings). """ flow = audit_log.get_trade_flow(order_id) issues: List[str] = [] + scope = account_id or order_id + + def _check_identity(signature: Optional[dict], label: str) -> None: + if registry is None: + issues.append( + f"{label}_IDENTITY_UNVERIFIED: No identity registry supplied -- " + "cannot confirm the signing key belongs to the account holder" + ) + return + pubkey_hex = (signature or {}).get("pubkey", "") + if not registry.is_authorized(scope, pubkey_hex): + issues.append( + f"{label}_UNAUTHORIZED_KEY: Signing pubkey is not a registered " + f"authorised signer for {scope!r}" + ) # Check commitment if flow["commitment"] is not None and flow["commitment_sig"] is not None: @@ -222,6 +303,7 @@ def detect_tampering(self, order_id: str, audit_log: AuditLog) -> dict: issues.append( "COMMITMENT_TEMPORAL_UNSAFE: Key may not expire before Shor's window" ) + _check_identity(flow["commitment_sig"], "COMMITMENT") # Check execution if flow["execution"] is not None and flow["execution_sig"] is not None: @@ -258,6 +340,7 @@ def detect_tampering(self, order_id: str, audit_log: AuditLog) -> dict: issues.append( "SEED_REUSE: Commitment and execution used the same quantum seed" ) + _check_identity(flow["execution_sig"], "EXECUTION") # Check settlement if flow["settlement"] is not None and flow["settlement_sig"] is not None: @@ -286,6 +369,7 @@ def detect_tampering(self, order_id: str, audit_log: AuditLog) -> dict: issues.append( "TEMPORAL_WINDOW_UNSAFE: Not all keys expire before Shor's" ) + _check_identity(flow["settlement_sig"], "SETTLEMENT") return { "order_id": order_id, diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 21a155e..e280f70 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -56,6 +56,7 @@ generate_quantum_seed, ) from aether_protocol_c.verify import AuditVerifier +from aether_protocol_c.identity import AccountKeyRegistry, IdentityError # ── Fixtures ───────────────────────────────────────────────────────────────── @@ -738,11 +739,186 @@ def test_verify_trade_flow_missing_commitment_is_not_quantum_safe(temp_audit_pat audit.append_execution(att_dict, att_sig) audit.append_settlement(s_dict, s_sig) + # Identity binding (LOOP-17 round 3) is a separate, orthogonal + # concern from this vacuous-truth regression: register the real + # signer's keys so execution/settlement pass identity too, isolating + # the missing-commitment-phase behaviour under test. + registry = AccountKeyRegistry() + registry.register(order_id, att_sig["pubkey"]) + registry.register(order_id, s_sig["pubkey"]) + verifier = AuditVerifier() - result = verifier.verify_trade_flow(order_id, audit) + result = verifier.verify_trade_flow(order_id, audit, registry=registry) assert result["commitment_valid"] is None assert result["execution_valid"] is True assert result["settlement_valid"] is True assert result["chain_valid"] is False assert result["quantum_safe"] is False + + +# ═══════════════════════════════════════════════════════════════════════════ +# 18. AUDIT VERIFIER — SELF-REFERENTIAL SIGNATURE / MISSING IDENTITY BINDING +# REGRESSION (LOOP-17 round 3) +# ═══════════════════════════════════════════════════════════════════════════ + +def _build_full_flow(order_id: str): + """Build and sign a complete commitment/execution/settlement flow.""" + seed1 = get_seed() + seed2 = get_seed() + seed3 = get_seed() + snap = AccountSnapshot.from_dict(ACCOUNT_STATE) + + c_dict, c_sig, _ = QuantumDecisionCommitment.create_and_sign( + order_id=order_id, + trade_details=TRADE_DETAILS, + account_state=snap, + quantum_seed=seed1.seed_int, + measurement_method=seed1.method, + ) + + er = ExecutionResult(order_id=order_id, filled_qty=1, fill_price=50_000) + snap_after = AccountSnapshot.from_dict({**ACCOUNT_STATE, "nonce": 2}) + + att_dict, att_sig, _ = QuantumExecutionAttestation.create_and_sign( + commitment_sig=c_sig, + commitment_seed_hash=c_dict["quantum_seed_commitment"], + execution_result=er, + new_account_state=snap_after, + quantum_seed=seed2.seed_int, + measurement_method=seed2.method, + ) + + s_dict, s_sig, _ = QuantumSettlementRecord.create_and_sign( + order_id=order_id, + commitment_sig=c_sig, + commitment_seed_hash=c_dict["quantum_seed_commitment"], + commitment_window=c_dict["key_temporal_window"], + execution_sig=att_sig, + execution_seed_hash=att_dict["execution_quantum_seed_commitment"], + execution_window=att_dict["key_temporal_window"], + broker_sig=f"broker_ack_{order_id}", + quantum_seed=seed3.seed_int, + measurement_method=seed3.method, + ) + return c_dict, c_sig, att_dict, att_sig, s_dict, s_sig + + +def test_attacker_self_signed_flow_is_not_quantum_safe_without_registry(temp_audit_path): + """ + Breaker finding (round 3, CRITICAL, missing-identity-binding): an + attacker who can write to the audit log generates their OWN fresh + quantum-derived keypair (no private-key theft, no ECDLP break -- + just a normal EphemeralSigner/QuantumEphemeralKey instance) and + self-signs a completely fabricated commitment/execution/settlement + flow for a victim's order_id. Every internal check (signature + validity, state binding, quantum binding, temporal safety, nonce + increment, seed independence, chain linkage) is satisfied because + they only validate the envelope against the pubkey embedded in that + SAME envelope -- a tautology. Before the fix, verify_trade_flow + reported this fabricated flow as quantum_safe=True/chain_valid=True, + which DisputeProofGenerator would then export as an "authorised" + trade proof. It must never be certified quantum_safe absent an + out-of-band identity binding. + """ + order_id = "attacker_forged_001" + c_dict, c_sig, att_dict, att_sig, s_dict, s_sig = _build_full_flow(order_id) + + audit = AuditLog(temp_audit_path) + # AuditLog.append_* accept any self-consistent envelope as-is -- this + # models the attacker directly appending a fabricated flow. + audit.append_commitment(c_dict, c_sig) + audit.append_execution(att_dict, att_sig) + audit.append_settlement(s_dict, s_sig) + + verifier = AuditVerifier() + + # No registry supplied -- there is no way to prove the account holder + # (as opposed to the attacker) authorised this flow, so it must fail + # closed even though every internal self-consistency check passes. + result = verifier.verify_trade_flow(order_id, audit) + assert result["identity_bound"] is False + assert result["chain_valid"] is False + assert result["quantum_safe"] is False + + tamper = verifier.detect_tampering(order_id, audit) + assert any("IDENTITY_UNVERIFIED" in issue for issue in tamper["issues"]) + assert tamper["tampered"] is True + + +def test_attacker_key_rejected_by_registry_legit_key_accepted(temp_audit_path): + """ + Same attacker scenario, but now an AccountKeyRegistry has been + populated out-of-band with the account holder's real signing key + (e.g. at onboarding). The attacker's self-signed forged flow for + the SAME order_id must be rejected because its embedded pubkey was + never registered -- while a flow legitimately signed by the + registered key is accepted. This proves identity binding actually + discriminates attacker keys from authorised keys, not just that it + fails closed on empty registries. + """ + registry = AccountKeyRegistry() + verifier = AuditVerifier() + + # ── Legitimate flow: signed with the account holder's real key ──── + legit_order = "legit_holder_001" + lc_dict, lc_sig, latt_dict, latt_sig, ls_dict, ls_sig = _build_full_flow(legit_order) + # Each phase signs with its own fresh ephemeral key (by design -- P4 + # perfect forward secrecy), so all three pubkeys are registered as + # authorised for this account/order scope. + registry.register(legit_order, lc_sig["pubkey"]) + registry.register(legit_order, latt_sig["pubkey"]) + registry.register(legit_order, ls_sig["pubkey"]) + + legit_audit = AuditLog(str(temp_audit_path) + ".legit") + legit_audit.append_commitment(lc_dict, lc_sig) + legit_audit.append_execution(latt_dict, latt_sig) + legit_audit.append_settlement(ls_dict, ls_sig) + + legit_result = verifier.verify_trade_flow(legit_order, legit_audit, registry=registry) + assert legit_result["identity_bound"] is True + assert legit_result["commitment_valid"] is True + assert legit_result["execution_valid"] is True + assert legit_result["settlement_valid"] is True + assert legit_result["chain_valid"] is True + assert legit_result["quantum_safe"] is True + + # ── Attacker forges a flow for a DIFFERENT order_id using their own + # fresh keypair, which was never registered for that order ───── + forged_order = "attacker_forged_002" + fc_dict, fc_sig, fatt_dict, fatt_sig, fs_dict, fs_sig = _build_full_flow(forged_order) + # Registry has no entry at all for forged_order -- models an + # attacker targeting an order/account whose real key was simply + # never (or not yet) registered, or attempting to reuse a key that + # was never authorised for this scope. + + forged_audit = AuditLog(str(temp_audit_path) + ".forged") + forged_audit.append_commitment(fc_dict, fc_sig) + forged_audit.append_execution(fatt_dict, fatt_sig) + forged_audit.append_settlement(fs_dict, fs_sig) + + forged_result = verifier.verify_trade_flow(forged_order, forged_audit, registry=registry) + assert forged_result["identity_bound"] is True # registry WAS supplied + assert forged_result["commitment_valid"] is False + assert forged_result["execution_valid"] is False + assert forged_result["settlement_valid"] is False + assert forged_result["chain_valid"] is False + assert forged_result["quantum_safe"] is False + + forged_tamper = verifier.detect_tampering(forged_order, forged_audit, registry=registry) + assert any("UNAUTHORIZED_KEY" in issue for issue in forged_tamper["issues"]) + assert forged_tamper["tampered"] is True + + +def test_account_key_registry_rejects_malformed_pubkey(): + registry = AccountKeyRegistry() + with pytest.raises(IdentityError): + registry.register("acct_1", "not-a-real-pubkey") + with pytest.raises(IdentityError): + registry.register("", "02" + "aa" * 32) + + +def test_account_key_registry_fails_closed_when_unregistered(): + registry = AccountKeyRegistry() + assert registry.is_authorized("acct_1", "02" + "aa" * 32) is False + assert registry.is_registered("acct_1") is False From fc38986cb89d0b837951f980ca338b2ae6b9dc80 Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Wed, 15 Jul 2026 11:22:35 -0400 Subject: [PATCH 19/28] docs: label curve-doubling constants, section-border identity.py per review - _point_double: name the 3/2 slope-formula literals (curve tangent-line derivative, not tunable), per review comment. - ephemeral_signer.py __init__: bracket the private-key-derivation block with start/end markers, per review comment. - identity.py: section-border comments (pubkey format / errors / registry, mutation vs query methods), per review comment. --- aether_protocol_c/ephemeral_signer.py | 17 +++++++++++++---- aether_protocol_c/identity.py | 11 +++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/aether_protocol_c/ephemeral_signer.py b/aether_protocol_c/ephemeral_signer.py index f1509de..3b833ab 100644 --- a/aether_protocol_c/ephemeral_signer.py +++ b/aether_protocol_c/ephemeral_signer.py @@ -117,13 +117,21 @@ def _point_add(p1, p2): def _point_double(p): - """Double a point on secp256k1 (explicit, no coordinate-equality branch).""" + """ + Double a point on secp256k1 (explicit, no coordinate-equality branch). + + Tangent-line slope at (x, y) on y^2 = x^3 + A*x + B is the standard + calculus derivative dy/dx = (3x^2 + A) / (2y) -- the 3 and 2 below are + that fixed textbook formula, not arbitrary/tunable values. + """ if p is INFINITY: return INFINITY if p.y == 0: return INFINITY - lam = (3 * p.x * p.x + A) * _modinv(2 * p.y, P) % P - x3 = (lam * lam - 2 * p.x) % P + SLOPE_NUMERATOR_X_COEFF = 3 # d/dx(x^3) = 3x^2 + SLOPE_DENOMINATOR_Y_COEFF = 2 # d/dy(y^2) = 2y + lam = (SLOPE_NUMERATOR_X_COEFF * p.x * p.x + A) * _modinv(SLOPE_DENOMINATOR_Y_COEFF * p.y, P) % P + x3 = (lam * lam - SLOPE_DENOMINATOR_Y_COEFF * p.x) % P y3 = (lam * (p.x - x3) - p.y) % P return _Point(x3, y3) @@ -251,7 +259,7 @@ def __init__(self, quantum_seed: int): self._destroyed = False self._sign_count = 0 - # Derive private key from quantum seed via HMAC-SHA256. + # ---- private key derivation (quantum seed -> HMAC-SHA256) ---- # Held in a mutable bytearray (not a bare int/bytes object) so # destroy() can overwrite the actual backing buffer in place -- # Python ints and bytes are immutable and can't be zeroed after @@ -282,6 +290,7 @@ def __init__(self, quantum_seed: int): privkey_int = int.from_bytes(key_material, "big") % N self._privkey_buf = bytearray(privkey_int.to_bytes(32, "big")) + # ---- end private key derivation ---- # Derive public key self._pubkey = _point_mul(self._privkey, G) diff --git a/aether_protocol_c/identity.py b/aether_protocol_c/identity.py index 0c86d50..0375da2 100644 --- a/aether_protocol_c/identity.py +++ b/aether_protocol_c/identity.py @@ -29,13 +29,20 @@ import re from typing import Dict, FrozenSet, Set +# ── Compressed secp256k1 pubkey format ─────────────────────────────────────── +# 33-byte compressed key: "02"/"03" prefix + 32-byte x-coordinate, as hex. + _PUBKEY_RE = re.compile(r"^(02|03)[0-9a-f]{64}$") +# ── Errors ──────────────────────────────────────────────────────────────── + class IdentityError(Exception): """Raised when identity registry operations fail.""" +# ── Account -> authorised-key registry ─────────────────────────────────────── + class AccountKeyRegistry: """ Maps an account/order identity scope to the set of public keys @@ -51,6 +58,8 @@ class exists to prevent. def __init__(self) -> None: self._authorized: Dict[str, Set[str]] = {} + # -- mutation: register / revoke -- + def register(self, account_id: str, pubkey_hex: str) -> None: """ Register a public key as authorised to sign for ``account_id``. @@ -76,6 +85,8 @@ def revoke(self, account_id: str, pubkey_hex: str) -> None: """Remove a previously registered key for ``account_id``.""" self._authorized.get(account_id, set()).discard(pubkey_hex.lower()) + # -- query: fail-closed authorization checks -- + def is_authorized(self, account_id: str, pubkey_hex: str) -> bool: """ Check whether ``pubkey_hex`` is a registered, authorised signer From d92a905294a97ea380806e1af1c72483c060b531 Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Wed, 15 Jul 2026 11:29:00 -0400 Subject: [PATCH 20/28] loop(LOOP-17): round 4 weld -- LOOP17-R4-01 fix AuditLog._append() had no lock despite check_same_thread=False signalling multi-thread use is expected. Concurrent appends could race on the read-modify-write of self._line_count, causing duplicate jsonl_line/offset index rows (INSERT OR REPLACE silently overwriting an earlier commitment's index entry with no error) and corrupting get_trade_flow()'s offset-based lookups. Added a threading.RLock guarding the whole append/rotate critical section. Regression test spawns 40 threads calling append_commitment concurrently and asserts every JSONL line has a distinct index row. --- aether_protocol_c/audit.py | 43 +++++++----- tests/test_audit_concurrent_append.py | 94 +++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 16 deletions(-) create mode 100644 tests/test_audit_concurrent_append.py diff --git a/aether_protocol_c/audit.py b/aether_protocol_c/audit.py index 214a1f0..fa524cb 100644 --- a/aether_protocol_c/audit.py +++ b/aether_protocol_c/audit.py @@ -43,6 +43,7 @@ import hashlib import json import sqlite3 +import threading import time from dataclasses import dataclass from pathlib import Path @@ -202,6 +203,12 @@ def __init__( # Set before _init_db so close()/__del__ are safe even if init fails. self._conn = None + # Guards the append/rotate critical section (JSONL write + SQLite + # index write + _line_count read-modify-write) so concurrent + # threads sharing one AuditLog (check_same_thread=False signals + # this is expected) cannot race on the same line number / offset. + self._append_lock = threading.RLock() + # Initialise SQLite index self._init_db() @@ -415,24 +422,28 @@ def _append(self, entry: AuditEntry) -> None: Writes to the JSONL file (binary mode for reliable byte offsets) and indexes the entry in SQLite. """ - # Check rotation before writing (but not for rotation entries - # themselves, to avoid infinite recursion) - if entry.phase != "LOG_ROTATION": - self._maybe_rotate() - - line = json.dumps( - entry.to_json(), sort_keys=True, separators=(",", ":") - ) + # Serialize the whole read-modify-write critical section: rotation + # check, JSONL append, SQLite index write, and _line_count bump + # must be atomic w.r.t. other threads sharing this AuditLog. + with self._append_lock: + # Check rotation before writing (but not for rotation entries + # themselves, to avoid infinite recursion) + if entry.phase != "LOG_ROTATION": + self._maybe_rotate() + + line = json.dumps( + entry.to_json(), sort_keys=True, separators=(",", ":") + ) - # Write to JSONL in binary mode for reliable byte offsets - with open(self._path, "ab") as f: - offset = f.tell() - f.write((line + "\n").encode("utf-8")) + # Write to JSONL in binary mode for reliable byte offsets + with open(self._path, "ab") as f: + offset = f.tell() + f.write((line + "\n").encode("utf-8")) - # Index in SQLite - self._index_entry(entry, offset, self._line_count) - self._conn.commit() - self._line_count += 1 + # Index in SQLite + self._index_entry(entry, offset, self._line_count) + self._conn.commit() + self._line_count += 1 def append_commitment( self, commitment: dict, signature: dict diff --git a/tests/test_audit_concurrent_append.py b/tests/test_audit_concurrent_append.py new file mode 100644 index 0000000..94aa00b --- /dev/null +++ b/tests/test_audit_concurrent_append.py @@ -0,0 +1,94 @@ +""" +Regression test for LOOP17-R4-01: AuditLog._append() was not thread-safe. + +Prior behavior: the SQLite connection is opened with +check_same_thread=False (signalling multi-thread use is expected), but +no lock protected the read-modify-write of self._line_count, the JSONL +append, and the SQLite index write. Two threads racing on +append_commitment() could both read the same stale self._line_count +before either incremented it, causing two index rows to be written +with the same jsonl_line/offset and corrupting the line index used by +get_trade_flow() -- while one entry's index row silently overwrote the +other via "INSERT OR REPLACE ... record_id", leaving the earlier +physical JSONL line unreachable from the index with no error raised. + +Fixed behavior: _append() serializes the whole critical section under +a threading.RLock, so concurrent appends always get distinct, +monotonically increasing line numbers/offsets, and every JSONL line +has a corresponding, correct index row. +""" + +import os +import tempfile +import threading + +from aether_protocol_c.audit import AuditLog, PHASE_COMMITMENT + + +def _commitment(order_id: str) -> dict: + return { + "order_id": order_id, + "seed_commitment": "x", + "key_temporal_window": {}, + } + + +def test_concurrent_append_commitment_produces_no_duplicate_index_rows(): + """ + Arrange: one AuditLog shared across many threads (as check_same_thread + =False implies is expected), each appending a distinct order's + commitment concurrently. + """ + with tempfile.TemporaryDirectory() as tmp: + log_path = os.path.join(tmp, "audit.jsonl") + log = AuditLog(log_path) + n_threads = 40 + errors = [] + + def worker(i: int) -> None: + try: + log.append_commitment(_commitment(f"order-{i}"), {"sig": "s"}) + except Exception as exc: # pragma: no cover - diagnostic only + errors.append(exc) + + # Act + threads = [threading.Thread(target=worker, args=(i,)) for i in range(n_threads)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert not errors, f"append_commitment raised under concurrency: {errors}" + + # Assert: every JSONL line has a distinct offset/line number, and + # the count of index rows matches the count of JSONL lines -- + # no lost or duplicated index entries from the race. + with open(log_path, "rb") as f: + jsonl_lines = [line for line in f if line.strip()] + assert len(jsonl_lines) == n_threads + + cur = log._conn.execute( + "SELECT jsonl_line, jsonl_offset FROM audit_index " + "WHERE record_type = ?", + (PHASE_COMMITMENT,), + ) + rows = cur.fetchall() + assert len(rows) == n_threads, ( + "index row count must match appended entry count -- a race " + "on _line_count would cause INSERT OR REPLACE collisions " + "and silently drop rows" + ) + + line_numbers = [r[0] for r in rows] + offsets = [r[1] for r in rows] + assert len(set(line_numbers)) == n_threads, ( + "duplicate jsonl_line values indicate two threads read the " + "same stale self._line_count before either incremented it" + ) + assert len(set(offsets)) == n_threads, ( + "duplicate jsonl_offset values indicate two threads wrote " + "to the same file position concurrently" + ) + assert sorted(line_numbers) == list(range(n_threads)) + + log.close() From 8224a21adc448ba975d03b8df2e4db6cf61539a8 Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Wed, 15 Jul 2026 11:38:30 -0400 Subject: [PATCH 21/28] loop(LOOP-17): round 5 weld -- LOOP17-R5-01 fix QuantumExecutionVerifier only checked commitment_sig dict-equality to link execution to commitment, never comparing the execution's actual fill terms (filled_qty/fill_price) against the commitment's authorised trade_details (qty/price). A validly-signed execution attestation could report arbitrarily different fill terms than what was committed to and still pass every check (signature, quantum binding, nonce, seed independence, chain linkage). Adds QuantumExecutionVerifier.verify_matches_commitment_terms(), wired into AuditVerifier.verify_trade_flow and detect_tampering, plus a regression test reproducing the exact commit-10@50/execute-10000@5000 scenario, verified to fail against pre-fix code. --- aether_protocol_c/execution.py | 65 ++++++++++++++++++++++++++ aether_protocol_c/verify.py | 22 +++++++++ tests/test_protocol.py | 85 ++++++++++++++++++++++++++++++++++ 3 files changed, 172 insertions(+) diff --git a/aether_protocol_c/execution.py b/aether_protocol_c/execution.py index 6ed45c5..7904fbb 100644 --- a/aether_protocol_c/execution.py +++ b/aether_protocol_c/execution.py @@ -190,6 +190,71 @@ def verify_references_commitment(attestation: dict, commitment_sig: dict) -> boo """Verify the attestation references the correct commitment.""" return attestation.get("commitment_sig") == commitment_sig + @staticmethod + def verify_matches_commitment_terms( + trade_details: dict, + execution_result: dict, + price_tolerance: float = 0.02, + ) -> bool: + """ + Verify the execution's actual fill terms correspond to what the + commitment authorised. + + A valid ``commitment_sig`` reference (dict-equality of the + signature envelope, see ``verify_references_commitment``) only + proves the execution phase points at the right commitment + record -- it says nothing about whether the economic terms that + were actually filled (quantity, price) stay within what + ``trade_details`` authorised. Without this check, a validly + signed execution attestation could report an arbitrarily larger + filled_qty and/or wildly different fill_price than what was + committed to, and every other check (signature, quantum + binding, nonce, seed independence, chain linkage) would still + pass. + + Args: + trade_details: The commitment's authorised trade terms + (expects "qty" and "price" keys). + execution_result: The execution's ``execution_result`` dict + (expects "filled_qty" and "fill_price" keys). + price_tolerance: Maximum allowed fractional deviation of + fill_price from the authorised price (default 2%). + + Returns: + True if filled_qty does not exceed the authorised qty and + fill_price is within tolerance of the authorised price. + False if required fields are missing or terms diverge. + """ + if not isinstance(trade_details, dict) or not isinstance(execution_result, dict): + return False + + authorised_qty = trade_details.get("qty") + authorised_price = trade_details.get("price") + filled_qty = execution_result.get("filled_qty") + fill_price = execution_result.get("fill_price") + + if authorised_qty is None or authorised_price is None: + return False + if filled_qty is None or fill_price is None: + return False + + try: + authorised_qty = float(authorised_qty) + authorised_price = float(authorised_price) + filled_qty = float(filled_qty) + fill_price = float(fill_price) + except (TypeError, ValueError): + return False + + if filled_qty < 0 or filled_qty > authorised_qty: + return False + + if authorised_price == 0: + return fill_price == 0 + + deviation = abs(fill_price - authorised_price) / abs(authorised_price) + return deviation <= price_tolerance + @staticmethod def verify_nonce_increment(commitment_nonce: int, attestation: dict) -> bool: """Verify that nonce_after == commitment_nonce + 1.""" diff --git a/aether_protocol_c/verify.py b/aether_protocol_c/verify.py index b5edfa8..86dce60 100644 --- a/aether_protocol_c/verify.py +++ b/aether_protocol_c/verify.py @@ -174,6 +174,19 @@ def _identity_ok(signature: Optional[dict]) -> bool: details.append(f"Seeds independent (commitment vs execution): {seeds_independent}") if not seeds_independent: execution_valid = False + + # Check the executed fill terms actually match what was + # authorised in the commitment (qty/price bounds) -- chain + # linkage alone does not prove economic-term correspondence. + if flow["commitment"] is not None: + trade_details = flow["commitment"].get("trade_details", {}) + execution_result = flow["execution"].get("execution_result", {}) + terms_ok = QuantumExecutionVerifier.verify_matches_commitment_terms( + trade_details, execution_result + ) + details.append(f"Execution matches authorised trade terms: {terms_ok}") + if not terms_ok: + execution_valid = False else: details.append("Execution phase: MISSING") @@ -340,6 +353,15 @@ def _check_identity(signature: Optional[dict], label: str) -> None: issues.append( "SEED_REUSE: Commitment and execution used the same quantum seed" ) + trade_details = flow["commitment"].get("trade_details", {}) + execution_result = flow["execution"].get("execution_result", {}) + if not QuantumExecutionVerifier.verify_matches_commitment_terms( + trade_details, execution_result + ): + issues.append( + "EXECUTION_TERMS_MISMATCH: Filled qty/price does not match " + "the authorised commitment's trade_details" + ) _check_identity(flow["execution_sig"], "EXECUTION") # Check settlement diff --git a/tests/test_protocol.py b/tests/test_protocol.py index e280f70..48b1f3b 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -757,6 +757,91 @@ def test_verify_trade_flow_missing_commitment_is_not_quantum_safe(temp_audit_pat assert result["quantum_safe"] is False +def test_verify_trade_flow_execution_terms_disconnected_from_commitment( + temp_audit_path, +): + """ + Round-5 finding (LOOP17-R5-01): commitment authorises BUY 10 shares + @ $50, but the execution phase -- signed by a validly-registered key + -- attests a fill of 10,000 shares @ $5,000. Every existing check + (signature validity, quantum binding, temporal safety, nonce + increment, seed independence, commitment_sig dict-equality via + verify_references_commitment) only proves the SIGNATURE ENVELOPE + chains together; none of them compare execution_result's + filled_qty/fill_price against the commitment's trade_details. Before + the fix, this flow was certified execution_valid=True/chain_valid= + True/quantum_safe=True despite the economic terms being completely + disconnected from what was authorised. + + Arrange: build a commitment authorising qty=10 @ price=50, then an + execution attestation (validly signed, correctly chained) reporting + filled_qty=10_000 @ fill_price=5_000. + Act: run AuditVerifier.verify_trade_flow / detect_tampering and the + unit-level verify_matches_commitment_terms check directly. + Assert: the mismatch is caught everywhere -- execution_valid=False, + chain_valid=False, quantum_safe=False, tampering flagged. + """ + order_id = "terms_mismatch_001" + seed1 = get_seed() + seed2 = get_seed() + snap = AccountSnapshot.from_dict(ACCOUNT_STATE) + + authorised_trade = {"symbol": "BTC", "qty": 10, "side": "long", "price": 50} + c_dict, c_sig, _ = QuantumDecisionCommitment.create_and_sign( + order_id=order_id, + trade_details=authorised_trade, + account_state=snap, + quantum_seed=seed1.seed_int, + measurement_method=seed1.method, + ) + + # Executed fill is wildly disconnected from what was committed to: + # 1000x the authorised quantity, 100x the authorised price. + er = ExecutionResult(order_id=order_id, filled_qty=10_000, fill_price=5_000) + snap_after = AccountSnapshot.from_dict({**ACCOUNT_STATE, "nonce": 2}) + + att_dict, att_sig, _ = QuantumExecutionAttestation.create_and_sign( + commitment_sig=c_sig, + commitment_seed_hash=c_dict["quantum_seed_commitment"], + execution_result=er, + new_account_state=snap_after, + quantum_seed=seed2.seed_int, + measurement_method=seed2.method, + ) + + audit = AuditLog(temp_audit_path) + audit.append_commitment(c_dict, c_sig) + audit.append_execution(att_dict, att_sig) + + registry = AccountKeyRegistry() + registry.register(order_id, c_sig["pubkey"]) + registry.register(order_id, att_sig["pubkey"]) + + verifier = AuditVerifier() + result = verifier.verify_trade_flow(order_id, audit, registry=registry) + + assert result["commitment_valid"] is True + assert result["execution_valid"] is False + assert result["chain_valid"] is False + assert result["quantum_safe"] is False + assert any( + "Execution matches authorised trade terms: False" in d + for d in result["details"] + ) + + # The unit-level check itself must also directly reject the mismatch. + assert ( + QuantumExecutionVerifier.verify_matches_commitment_terms( + authorised_trade, er.to_json() + ) + is False + ) + + tamper = verifier.detect_tampering(order_id, audit, registry=registry) + assert tamper["tampered"] is True + assert any("EXECUTION_TERMS_MISMATCH" in issue for issue in tamper["issues"]) + + # ═══════════════════════════════════════════════════════════════════════════ # 18. AUDIT VERIFIER — SELF-REFERENTIAL SIGNATURE / MISSING IDENTITY BINDING # REGRESSION (LOOP-17 round 3) From fcf2e9fe581abc205b3ab53edb311e1942b74a2d Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Wed, 15 Jul 2026 11:43:53 -0400 Subject: [PATCH 22/28] loop(LOOP-17): round 6 weld -- symbol/side never validated in execution terms fix --- aether_protocol_c/execution.py | 16 +++++++ tests/test_protocol.py | 84 ++++++++++++++++++++++++++++++++-- 2 files changed, 96 insertions(+), 4 deletions(-) diff --git a/aether_protocol_c/execution.py b/aether_protocol_c/execution.py index 7904fbb..78ae40f 100644 --- a/aether_protocol_c/execution.py +++ b/aether_protocol_c/execution.py @@ -47,6 +47,8 @@ class ExecutionResult: Fields: order_id: The order that was executed. + symbol: The instrument that was actually filled. + side: The actual fill direction ("BUY"/"SELL", etc.). filled_qty: Quantity actually filled. fill_price: Price at which the fill occurred. execution_timestamp: Unix timestamp of execution. @@ -54,6 +56,8 @@ class ExecutionResult: """ order_id: str + symbol: str + side: str filled_qty: float fill_price: float execution_timestamp: int = field(default_factory=lambda: int(time.time())) @@ -63,6 +67,8 @@ def to_json(self) -> dict: """Canonical JSON-serialisable representation.""" return { "order_id": self.order_id, + "symbol": self.symbol, + "side": self.side, "filled_qty": self.filled_qty, "fill_price": self.fill_price, "execution_timestamp": self.execution_timestamp, @@ -230,14 +236,24 @@ def verify_matches_commitment_terms( authorised_qty = trade_details.get("qty") authorised_price = trade_details.get("price") + authorised_symbol = trade_details.get("symbol") + authorised_side = trade_details.get("side") filled_qty = execution_result.get("filled_qty") fill_price = execution_result.get("fill_price") + fill_symbol = execution_result.get("symbol") + fill_side = execution_result.get("side") if authorised_qty is None or authorised_price is None: return False if filled_qty is None or fill_price is None: return False + if authorised_symbol is not None or authorised_side is not None: + if fill_symbol is None or fill_side is None: + return False + if fill_symbol != authorised_symbol or fill_side != authorised_side: + return False + try: authorised_qty = float(authorised_qty) authorised_price = float(authorised_price) diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 48b1f3b..acf6b3d 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -462,6 +462,8 @@ def test_execution_attestation(): er = ExecutionResult( order_id="exec_001", + symbol="BTC", + side="long", filled_qty=1, fill_price=50_000, ) @@ -499,7 +501,7 @@ def test_settlement_record(): measurement_method=seed1.method, ) - er = ExecutionResult(order_id="settle_001", filled_qty=1, fill_price=50000) + er = ExecutionResult(order_id="settle_001", symbol="BTC", side="long", filled_qty=1, fill_price=50000) snap_after = AccountSnapshot.from_dict({**ACCOUNT_STATE, "nonce": 2}) att_dict, att_sig, _ = QuantumExecutionAttestation.create_and_sign( @@ -710,7 +712,7 @@ def test_verify_trade_flow_missing_commitment_is_not_quantum_safe(temp_audit_pat measurement_method=seed1.method, ) - er = ExecutionResult(order_id=order_id, filled_qty=1, fill_price=50_000) + er = ExecutionResult(order_id=order_id, symbol="BTC", side="long", filled_qty=1, fill_price=50_000) snap_after = AccountSnapshot.from_dict({**ACCOUNT_STATE, "nonce": 2}) att_dict, att_sig, _ = QuantumExecutionAttestation.create_and_sign( @@ -797,7 +799,7 @@ def test_verify_trade_flow_execution_terms_disconnected_from_commitment( # Executed fill is wildly disconnected from what was committed to: # 1000x the authorised quantity, 100x the authorised price. - er = ExecutionResult(order_id=order_id, filled_qty=10_000, fill_price=5_000) + er = ExecutionResult(order_id=order_id, symbol="BTC", side="long", filled_qty=10_000, fill_price=5_000) snap_after = AccountSnapshot.from_dict({**ACCOUNT_STATE, "nonce": 2}) att_dict, att_sig, _ = QuantumExecutionAttestation.create_and_sign( @@ -842,6 +844,80 @@ def test_verify_trade_flow_execution_terms_disconnected_from_commitment( assert any("EXECUTION_TERMS_MISMATCH" in issue for issue in tamper["issues"]) +def test_verify_trade_flow_execution_symbol_side_substitution_rejected( + temp_audit_path, +): + """ + Round-6 finding (LOOP17-R6): commitment authorises BUY 10 AAPL @ $50, + but the execution phase -- validly signed, correctly chained, with + qty/price matching exactly -- reports a fill of SELL 10 TSLA @ $50. + Before the fix, ExecutionResult had no symbol/side fields at all and + verify_matches_commitment_terms never compared them, so this + substitution passed every check (qty/price within tolerance). + + Arrange: build a commitment authorising symbol=AAPL/side=BUY/qty=10/ + price=50, then an execution attestation with matching qty/price but + symbol=TSLA/side=SELL. + Act: run AuditVerifier.verify_trade_flow and the unit-level + verify_matches_commitment_terms check directly. + Assert: the substitution is caught -- execution_valid=False, + chain_valid=False, quantum_safe=False. + """ + order_id = "symbol_side_substitution_001" + seed1 = get_seed() + seed2 = get_seed() + snap = AccountSnapshot.from_dict(ACCOUNT_STATE) + + authorised_trade = {"symbol": "AAPL", "qty": 10, "side": "BUY", "price": 50} + c_dict, c_sig, _ = QuantumDecisionCommitment.create_and_sign( + order_id=order_id, + trade_details=authorised_trade, + account_state=snap, + quantum_seed=seed1.seed_int, + measurement_method=seed1.method, + ) + + # Qty and price exactly match the authorised terms, but the fill is + # for a completely different instrument and the opposite side. + er = ExecutionResult( + order_id=order_id, symbol="TSLA", side="SELL", filled_qty=10, fill_price=50 + ) + snap_after = AccountSnapshot.from_dict({**ACCOUNT_STATE, "nonce": 2}) + + att_dict, att_sig, _ = QuantumExecutionAttestation.create_and_sign( + commitment_sig=c_sig, + commitment_seed_hash=c_dict["quantum_seed_commitment"], + execution_result=er, + new_account_state=snap_after, + quantum_seed=seed2.seed_int, + measurement_method=seed2.method, + ) + + audit = AuditLog(temp_audit_path) + audit.append_commitment(c_dict, c_sig) + audit.append_execution(att_dict, att_sig) + + registry = AccountKeyRegistry() + registry.register(order_id, c_sig["pubkey"]) + registry.register(order_id, att_sig["pubkey"]) + + verifier = AuditVerifier() + result = verifier.verify_trade_flow(order_id, audit, registry=registry) + + assert result["commitment_valid"] is True + assert result["execution_valid"] is False + assert result["chain_valid"] is False + assert result["quantum_safe"] is False + + # The unit-level check itself must also directly reject the substitution. + assert ( + QuantumExecutionVerifier.verify_matches_commitment_terms( + authorised_trade, er.to_json() + ) + is False + ) + + # ═══════════════════════════════════════════════════════════════════════════ # 18. AUDIT VERIFIER — SELF-REFERENTIAL SIGNATURE / MISSING IDENTITY BINDING # REGRESSION (LOOP-17 round 3) @@ -862,7 +938,7 @@ def _build_full_flow(order_id: str): measurement_method=seed1.method, ) - er = ExecutionResult(order_id=order_id, filled_qty=1, fill_price=50_000) + er = ExecutionResult(order_id=order_id, symbol="BTC", side="long", filled_qty=1, fill_price=50_000) snap_after = AccountSnapshot.from_dict({**ACCOUNT_STATE, "nonce": 2}) att_dict, att_sig, _ = QuantumExecutionAttestation.create_and_sign( From ea98a56a2120705d261d9af9b012df0f38318da0 Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Wed, 15 Jul 2026 17:47:33 -0400 Subject: [PATCH 23/28] loop(LOOP-17): round 7 weld -- settlement-phase broker acknowledgement authentication fix broker_settlement_sig was a bare, free-form string never independently authenticated. verify_chain only checked it was hashed consistently into flow_merkle_hash, and verify_trade_flow never checked its provenance against any registry -- so a compromised/dishonest holder of an already-authorised settlement-phase key could fabricate any broker acknowledgement (including an empty string) and have verify_chain/verify_trade_flow/DisputeProofGenerator all certify the flow as fully verified with zero proof any broker acknowledged it. Fix: broker_settlement_sig must now be accompanied by a broker_signature envelope -- a real ECDSA signature over build_broker_attestation(order_id, commitment_sig, execution_sig, broker_sig) -- verified by verify_chain, folded into flow_merkle_hash, and checked by AuditVerifier.verify_trade_flow/detect_tampering against a registered broker pubkey (AccountKeyRegistry scope "broker:"), mirroring the existing identity-binding pattern used for the commitment/execution/settlement signers. Adds regression tests reproducing the exact fabricated-ack and unregistered-broker-key scenarios; both fail against the pre-fix verify_chain/verify_trade_flow and pass post-fix. --- aether_protocol_c/settlement.py | 91 +++++++++++- aether_protocol_c/verify.py | 34 ++++- tests/test_protocol.py | 237 +++++++++++++++++++++++++++++++- 3 files changed, 348 insertions(+), 14 deletions(-) diff --git a/aether_protocol_c/settlement.py b/aether_protocol_c/settlement.py index e2e0911..3293a6a 100644 --- a/aether_protocol_c/settlement.py +++ b/aether_protocol_c/settlement.py @@ -29,34 +29,78 @@ make_temporal_window, ) +__all__ = [ + "SettlementError", + "compute_flow_merkle", + "build_broker_attestation", + "QuantumSettlementRecord", + "QuantumSettlementVerifier", +] + class SettlementError(Exception): """Raised when settlement operations fail.""" def compute_flow_merkle( - commitment_sig: dict, execution_sig: dict, broker_sig: str + commitment_sig: dict, + execution_sig: dict, + broker_sig: str, + broker_signature: Optional[dict] = None, ) -> str: """ - Compute the flow merkle hash from three signature components. + Compute the flow merkle hash from the signature components. This is a SHA-256 of the canonical concatenation of commitment - signature, execution signature, and broker settlement acknowledgement. + signature, execution signature, broker settlement acknowledgement + text, and the broker's own cryptographic signature envelope over + that acknowledgement (see ``broker_signature`` on + ``QuantumSettlementRecord`` / ``build_broker_attestation``). + Folding ``broker_signature`` into the merkle means any tampering + with the broker's attestation (including stripping it, or swapping + it for a different broker's envelope) invalidates the merkle hash. Args: commitment_sig: Commitment signature envelope. execution_sig: Execution signature envelope. broker_sig: Broker's settlement acknowledgement string. + broker_signature: The broker's own ECDSA signature envelope + (as produced by their ``QuantumEphemeralKey``/signer) over + the broker attestation payload -- proves a specific, + identifiable broker key actually produced ``broker_sig``, + rather than it being an arbitrary unauthenticated string. Returns: Hex-encoded SHA-256 merkle hash. """ commitment_str = json.dumps(commitment_sig, sort_keys=True, separators=(",", ":")) execution_str = json.dumps(execution_sig, sort_keys=True, separators=(",", ":")) - combined = commitment_str + execution_str + broker_sig + broker_signature_str = json.dumps( + broker_signature or {}, sort_keys=True, separators=(",", ":") + ) + combined = commitment_str + execution_str + broker_sig + broker_signature_str return hashlib.sha256(combined.encode("utf-8")).hexdigest() +def build_broker_attestation( + order_id: str, commitment_sig: dict, execution_sig: dict, broker_sig: str +) -> dict: + """ + Canonical payload the broker's key must sign to authenticate an + acknowledgement string. + + Binding order_id + commitment_sig + execution_sig into the signed + payload (not just the free-form ack text) prevents a broker's + signature over one settlement from being replayed onto another. + """ + return { + "order_id": order_id, + "broker_ack": broker_sig, + "commitment_sig": commitment_sig, + "execution_sig": execution_sig, + } + + @dataclass(frozen=True) class QuantumSettlementRecord: """ @@ -88,6 +132,7 @@ class QuantumSettlementRecord: execution_quantum_seed_commitment: str execution_temporal_window: dict broker_settlement_sig: str + broker_signature: dict settlement_timestamp: int settlement_quantum_seed_commitment: str settlement_temporal_window: dict @@ -109,6 +154,7 @@ def to_signable_dict(self) -> dict: "execution_quantum_seed_commitment": self.execution_quantum_seed_commitment, "execution_temporal_window": self.execution_temporal_window, "broker_settlement_sig": self.broker_settlement_sig, + "broker_signature": self.broker_signature, "settlement_timestamp": self.settlement_timestamp, "settlement_quantum_seed_commitment": self.settlement_quantum_seed_commitment, "settlement_temporal_window": self.settlement_temporal_window, @@ -126,6 +172,7 @@ def create_and_sign( execution_seed_hash: str, execution_window: dict, broker_sig: str, + broker_signature: dict, quantum_seed: int | bytes, measurement_method: str = "OS_URANDOM", ) -> Tuple[dict, dict, "QuantumSettlementRecord"]: @@ -141,6 +188,16 @@ def create_and_sign( execution_seed_hash: Execution quantum seed hash. execution_window: Execution key temporal window. broker_sig: Broker's settlement acknowledgement string. + broker_signature: The broker's own ECDSA signature envelope + (produced by signing ``build_broker_attestation(order_id, + commitment_sig, execution_sig, broker_sig)`` with the + broker's key) -- proves ``broker_sig`` was actually + produced by whoever holds that key, rather than being an + arbitrary unauthenticated string embedded by the + settlement-phase signer themselves. Callers must also + register that key's pubkey in an ``AccountKeyRegistry`` + under scope ``f"broker:{account_id}"`` for + ``AuditVerifier.verify_trade_flow`` to certify the flow. quantum_seed: THIRD quantum seed for settlement. measurement_method: Source of the seed. @@ -159,7 +216,9 @@ def create_and_sign( ) # Compute flow merkle - flow_merkle = compute_flow_merkle(commitment_sig, execution_sig, broker_sig) + flow_merkle = compute_flow_merkle( + commitment_sig, execution_sig, broker_sig, broker_signature + ) settlement = cls( order_id=order_id, @@ -170,6 +229,7 @@ def create_and_sign( execution_quantum_seed_commitment=execution_seed_hash, execution_temporal_window=execution_window, broker_settlement_sig=broker_sig, + broker_signature=broker_signature, settlement_timestamp=now, settlement_quantum_seed_commitment=ephemeral_key.seed_commitment.seed_hash, settlement_temporal_window=ephemeral_key.seed_commitment.temporal_window_dict, @@ -209,7 +269,14 @@ def verify_chain( Checks: 1. Settlement references correct commitment_sig 2. Settlement references correct execution_sig - 3. Flow merkle hash matches recomputed value + 3. The broker_signature envelope is a valid signature over the + broker attestation (order_id + commitment_sig + execution_sig + + broker_sig) -- i.e. broker_settlement_sig is cryptographically + attributable to *some* key, not an arbitrary unauthenticated + string. (Whether that key belongs to a *registered* broker is + a separate, out-of-band check -- see + ``AuditVerifier.verify_trade_flow``'s broker identity check.) + 4. Flow merkle hash matches recomputed value Returns: True if the chain is valid. @@ -220,7 +287,17 @@ def verify_chain( return False broker_sig = settlement.get("broker_settlement_sig", "") - expected_merkle = compute_flow_merkle(commitment_sig, execution_sig, broker_sig) + broker_signature = settlement.get("broker_signature") + + broker_attestation = build_broker_attestation( + settlement.get("order_id"), commitment_sig, execution_sig, broker_sig + ) + if not verify_signature(broker_attestation, broker_signature or {}): + return False + + expected_merkle = compute_flow_merkle( + commitment_sig, execution_sig, broker_sig, broker_signature + ) return settlement.get("flow_merkle_hash") == expected_merkle @staticmethod diff --git a/aether_protocol_c/verify.py b/aether_protocol_c/verify.py index 86dce60..d727133 100644 --- a/aether_protocol_c/verify.py +++ b/aether_protocol_c/verify.py @@ -200,7 +200,25 @@ def _identity_ok(signature: Optional[dict]) -> bool: details.append(f"Settlement signature valid: {sig_ok}") details.append(f"Settlement identity bound (authorised signer): {identity_ok}") - settlement_valid = sig_ok and identity_ok + # Broker identity binding: broker_settlement_sig is a + # free-form acknowledgement string that, by itself, proves + # nothing about who produced it. It must be accompanied by + # a broker_signature envelope that (a) is a valid signature + # over the broker attestation (checked inside verify_chain + # below) and (b) is signed by a pubkey registered, out-of- + # band, as an authorised broker for this scope -- otherwise + # a compromised settlement-phase key could fabricate any + # broker acknowledgement and still pass every other check. + broker_signature = flow["settlement"].get("broker_signature") + broker_pubkey = (broker_signature or {}).get("pubkey", "") + broker_identity_ok = registry is not None and registry.is_authorized( + f"broker:{scope}", broker_pubkey + ) + details.append( + f"Broker signature authenticated (registered broker key): {broker_identity_ok}" + ) + + settlement_valid = sig_ok and identity_ok and broker_identity_ok # Chain linkage if flow["commitment_sig"] is not None and flow["execution_sig"] is not None: @@ -393,6 +411,20 @@ def _check_identity(signature: Optional[dict], label: str) -> None: ) _check_identity(flow["settlement_sig"], "SETTLEMENT") + broker_signature = flow["settlement"].get("broker_signature") + broker_pubkey = (broker_signature or {}).get("pubkey", "") + if registry is None: + issues.append( + "BROKER_IDENTITY_UNVERIFIED: No identity registry supplied -- " + "cannot confirm the broker acknowledgement's signing key is a " + "registered broker" + ) + elif not registry.is_authorized(f"broker:{scope}", broker_pubkey): + issues.append( + "BROKER_UNAUTHORIZED_KEY: broker_settlement_sig's signing key " + "is not a registered authorised broker for this scope" + ) + return { "order_id": order_id, "tampered": len(issues) > 0, diff --git a/tests/test_protocol.py b/tests/test_protocol.py index acf6b3d..d3b00a0 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -49,6 +49,7 @@ QuantumSettlementRecord, QuantumSettlementVerifier, compute_flow_merkle, + build_broker_attestation, ) from aether_protocol_c.audit import AuditLog, AuditEntry, PHASE_COMMITMENT from aether_protocol_c.seed import ( @@ -59,6 +60,23 @@ from aether_protocol_c.identity import AccountKeyRegistry, IdentityError +def sign_broker_ack(order_id, commitment_sig, execution_sig, broker_sig): + """ + Build a broker signature envelope for tests: a fresh keypair signs + the broker attestation, standing in for a real registered broker's + key. Returns (broker_signature_envelope, broker_pubkey_hex). + """ + seed = get_seed() + broker_key = QuantumEphemeralKey( + quantum_seed=seed.seed_int, method=seed.method + ) + attestation = build_broker_attestation( + order_id, commitment_sig, execution_sig, broker_sig + ) + broker_signature = broker_key.sign(attestation) + return broker_signature, broker_signature["pubkey"] + + # ── Fixtures ───────────────────────────────────────────────────────────────── ACCOUNT_STATE = { @@ -513,6 +531,7 @@ def test_settlement_record(): measurement_method=seed2.method, ) + broker_signature, _ = sign_broker_ack("settle_001", c_sig, att_sig, "broker_ack_001") s_dict, s_sig, _ = QuantumSettlementRecord.create_and_sign( order_id="settle_001", commitment_sig=c_sig, @@ -522,6 +541,7 @@ def test_settlement_record(): execution_seed_hash=att_dict["execution_quantum_seed_commitment"], execution_window=att_dict["key_temporal_window"], broker_sig="broker_ack_001", + broker_signature=broker_signature, quantum_seed=seed3.seed_int, measurement_method=seed3.method, ) @@ -540,8 +560,9 @@ def test_flow_merkle_deterministic(): c_sig = {"r": "aa" * 32, "s": "bb" * 32} e_sig = {"r": "cc" * 32, "s": "dd" * 32} broker = "ack" - h1 = compute_flow_merkle(c_sig, e_sig, broker) - h2 = compute_flow_merkle(c_sig, e_sig, broker) + broker_signature = {"pubkey": "02" + "ee" * 32, "r": "11" * 32, "s": "22" * 32} + h1 = compute_flow_merkle(c_sig, e_sig, broker, broker_signature) + h2 = compute_flow_merkle(c_sig, e_sig, broker, broker_signature) assert h1 == h2 assert len(h1) == 64 @@ -724,6 +745,9 @@ def test_verify_trade_flow_missing_commitment_is_not_quantum_safe(temp_audit_pat measurement_method=seed2.method, ) + broker_signature, broker_pubkey = sign_broker_ack( + order_id, c_sig, att_sig, "broker_ack_missing_commit" + ) s_dict, s_sig, _ = QuantumSettlementRecord.create_and_sign( order_id=order_id, commitment_sig=c_sig, @@ -733,6 +757,7 @@ def test_verify_trade_flow_missing_commitment_is_not_quantum_safe(temp_audit_pat execution_seed_hash=att_dict["execution_quantum_seed_commitment"], execution_window=att_dict["key_temporal_window"], broker_sig="broker_ack_missing_commit", + broker_signature=broker_signature, quantum_seed=seed3.seed_int, measurement_method=seed3.method, ) @@ -748,6 +773,7 @@ def test_verify_trade_flow_missing_commitment_is_not_quantum_safe(temp_audit_pat registry = AccountKeyRegistry() registry.register(order_id, att_sig["pubkey"]) registry.register(order_id, s_sig["pubkey"]) + registry.register(f"broker:{order_id}", broker_pubkey) verifier = AuditVerifier() result = verifier.verify_trade_flow(order_id, audit, registry=registry) @@ -950,6 +976,9 @@ def _build_full_flow(order_id: str): measurement_method=seed2.method, ) + broker_signature, broker_pubkey = sign_broker_ack( + order_id, c_sig, att_sig, f"broker_ack_{order_id}" + ) s_dict, s_sig, _ = QuantumSettlementRecord.create_and_sign( order_id=order_id, commitment_sig=c_sig, @@ -959,10 +988,11 @@ def _build_full_flow(order_id: str): execution_seed_hash=att_dict["execution_quantum_seed_commitment"], execution_window=att_dict["key_temporal_window"], broker_sig=f"broker_ack_{order_id}", + broker_signature=broker_signature, quantum_seed=seed3.seed_int, measurement_method=seed3.method, ) - return c_dict, c_sig, att_dict, att_sig, s_dict, s_sig + return c_dict, c_sig, att_dict, att_sig, s_dict, s_sig, broker_pubkey def test_attacker_self_signed_flow_is_not_quantum_safe_without_registry(temp_audit_path): @@ -983,7 +1013,7 @@ def test_attacker_self_signed_flow_is_not_quantum_safe_without_registry(temp_aud out-of-band identity binding. """ order_id = "attacker_forged_001" - c_dict, c_sig, att_dict, att_sig, s_dict, s_sig = _build_full_flow(order_id) + c_dict, c_sig, att_dict, att_sig, s_dict, s_sig, _broker_pubkey = _build_full_flow(order_id) audit = AuditLog(temp_audit_path) # AuditLog.append_* accept any self-consistent envelope as-is -- this @@ -1023,13 +1053,16 @@ def test_attacker_key_rejected_by_registry_legit_key_accepted(temp_audit_path): # ── Legitimate flow: signed with the account holder's real key ──── legit_order = "legit_holder_001" - lc_dict, lc_sig, latt_dict, latt_sig, ls_dict, ls_sig = _build_full_flow(legit_order) + lc_dict, lc_sig, latt_dict, latt_sig, ls_dict, ls_sig, l_broker_pubkey = _build_full_flow( + legit_order + ) # Each phase signs with its own fresh ephemeral key (by design -- P4 # perfect forward secrecy), so all three pubkeys are registered as # authorised for this account/order scope. registry.register(legit_order, lc_sig["pubkey"]) registry.register(legit_order, latt_sig["pubkey"]) registry.register(legit_order, ls_sig["pubkey"]) + registry.register(f"broker:{legit_order}", l_broker_pubkey) legit_audit = AuditLog(str(temp_audit_path) + ".legit") legit_audit.append_commitment(lc_dict, lc_sig) @@ -1047,7 +1080,9 @@ def test_attacker_key_rejected_by_registry_legit_key_accepted(temp_audit_path): # ── Attacker forges a flow for a DIFFERENT order_id using their own # fresh keypair, which was never registered for that order ───── forged_order = "attacker_forged_002" - fc_dict, fc_sig, fatt_dict, fatt_sig, fs_dict, fs_sig = _build_full_flow(forged_order) + fc_dict, fc_sig, fatt_dict, fatt_sig, fs_dict, fs_sig, _f_broker_pubkey = _build_full_flow( + forged_order + ) # Registry has no entry at all for forged_order -- models an # attacker targeting an order/account whose real key was simply # never (or not yet) registered, or attempting to reuse a key that @@ -1083,3 +1118,193 @@ def test_account_key_registry_fails_closed_when_unregistered(): registry = AccountKeyRegistry() assert registry.is_authorized("acct_1", "02" + "aa" * 32) is False assert registry.is_registered("acct_1") is False + + +# ═══════════════════════════════════════════════════════════════════════════ +# 19. SETTLEMENT — BROKER ACKNOWLEDGEMENT AUTHENTICATION REGRESSION +# (LOOP-17 round 7) +# ═══════════════════════════════════════════════════════════════════════════ + +def test_fabricated_broker_ack_rejected_by_verify_chain_and_trade_flow( + temp_audit_path, +): + """ + Breaker finding (round 7, HIGH, settlement-phase authentication gap): + broker_settlement_sig was a bare, free-form string never + independently authenticated anywhere. A party who legitimately + controls the settlement-phase signing key (already authorised in + the registry) could fabricate ANY broker_settlement_sig value -- + including an empty string, or text copy-pasted from an unrelated + settlement -- and verify_chain / verify_trade_flow / the exported + dispute proof would all certify the flow as fully verified with + zero cryptographic proof any broker ever acknowledged it. + + Arrange: build a fully valid, correctly-signed, correctly-chained + commitment/execution/settlement flow (registered legitimate + settlement-phase key), but instead of a broker_signature envelope + signed by a real broker key, embed a completely fabricated + broker_settlement_sig string signed by nothing (an empty envelope) -- + reproducing the exact attack the finding describes. + Act: run QuantumSettlementVerifier.verify_chain and + AuditVerifier.verify_trade_flow / detect_tampering. + Assert: the fabricated broker ack is rejected everywhere, even + though every other phase and signature is perfectly valid. + """ + order_id = "broker_forgery_001" + seed1 = get_seed() + seed2 = get_seed() + seed3 = get_seed() + snap = AccountSnapshot.from_dict(ACCOUNT_STATE) + + c_dict, c_sig, _ = QuantumDecisionCommitment.create_and_sign( + order_id=order_id, + trade_details=TRADE_DETAILS, + account_state=snap, + quantum_seed=seed1.seed_int, + measurement_method=seed1.method, + ) + + er = ExecutionResult( + order_id=order_id, symbol="BTC", side="long", filled_qty=1, fill_price=50_000 + ) + snap_after = AccountSnapshot.from_dict({**ACCOUNT_STATE, "nonce": 2}) + + att_dict, att_sig, _ = QuantumExecutionAttestation.create_and_sign( + commitment_sig=c_sig, + commitment_seed_hash=c_dict["quantum_seed_commitment"], + execution_result=er, + new_account_state=snap_after, + quantum_seed=seed2.seed_int, + measurement_method=seed2.method, + ) + + # The settlement-phase signer fabricates a broker acknowledgement + # string out of thin air -- no broker key ever signed it. This + # models a compromised/dishonest holder of an authorised + # settlement-phase key minting a fake broker_settlement_sig. + fabricated_broker_sig = "broker fully acknowledges settlement -- trust me" + s_dict, s_sig, _ = QuantumSettlementRecord.create_and_sign( + order_id=order_id, + commitment_sig=c_sig, + commitment_seed_hash=c_dict["quantum_seed_commitment"], + commitment_window=c_dict["key_temporal_window"], + execution_sig=att_sig, + execution_seed_hash=att_dict["execution_quantum_seed_commitment"], + execution_window=att_dict["key_temporal_window"], + broker_sig=fabricated_broker_sig, + broker_signature={}, # no real broker key ever signed anything + quantum_seed=seed3.seed_int, + measurement_method=seed3.method, + ) + + # Unit-level: verify_chain must reject an unauthenticated broker ack + # even though commitment/execution references and the merkle hash + # were all computed self-consistently. + assert QuantumSettlementVerifier.verify_chain(c_sig, att_sig, s_dict) is False + + audit = AuditLog(temp_audit_path) + audit.append_commitment(c_dict, c_sig) + audit.append_execution(att_dict, att_sig) + audit.append_settlement(s_dict, s_sig) + + # Register the legitimate settlement-phase signer (and commitment/ + # execution signers) so the ONLY failure this test isolates is the + # broker acknowledgement's own missing authentication. + registry = AccountKeyRegistry() + registry.register(order_id, c_sig["pubkey"]) + registry.register(order_id, att_sig["pubkey"]) + registry.register(order_id, s_sig["pubkey"]) + + verifier = AuditVerifier() + result = verifier.verify_trade_flow(order_id, audit, registry=registry) + + assert result["commitment_valid"] is True + assert result["execution_valid"] is True + assert result["settlement_valid"] is False + assert result["chain_valid"] is False + assert result["quantum_safe"] is False + + tamper = verifier.detect_tampering(order_id, audit, registry=registry) + assert tamper["tampered"] is True + assert any( + "BROKER_IDENTITY_UNVERIFIED" in issue or "BROKER_UNAUTHORIZED_KEY" in issue + or "SETTLEMENT_CHAIN_BROKEN" in issue + for issue in tamper["issues"] + ) + + +def test_broker_ack_signed_by_unregistered_key_rejected(temp_audit_path): + """ + Complementary case: the broker_settlement_sig IS accompanied by a + real, cryptographically valid signature (verify_chain passes), but + that key was never registered as an authorised broker for this + scope -- e.g. an attacker's own fresh keypair, or a broker key + that's real but for a different counterparty. This proves the + fix requires *registered* broker identity, not merely *any* valid + signature. + """ + order_id = "broker_unregistered_001" + seed1 = get_seed() + seed2 = get_seed() + seed3 = get_seed() + snap = AccountSnapshot.from_dict(ACCOUNT_STATE) + + c_dict, c_sig, _ = QuantumDecisionCommitment.create_and_sign( + order_id=order_id, + trade_details=TRADE_DETAILS, + account_state=snap, + quantum_seed=seed1.seed_int, + measurement_method=seed1.method, + ) + + er = ExecutionResult( + order_id=order_id, symbol="BTC", side="long", filled_qty=1, fill_price=50_000 + ) + snap_after = AccountSnapshot.from_dict({**ACCOUNT_STATE, "nonce": 2}) + + att_dict, att_sig, _ = QuantumExecutionAttestation.create_and_sign( + commitment_sig=c_sig, + commitment_seed_hash=c_dict["quantum_seed_commitment"], + execution_result=er, + new_account_state=snap_after, + quantum_seed=seed2.seed_int, + measurement_method=seed2.method, + ) + + broker_signature, broker_pubkey = sign_broker_ack( + order_id, c_sig, att_sig, "broker_ack_real_but_unregistered" + ) + s_dict, s_sig, _ = QuantumSettlementRecord.create_and_sign( + order_id=order_id, + commitment_sig=c_sig, + commitment_seed_hash=c_dict["quantum_seed_commitment"], + commitment_window=c_dict["key_temporal_window"], + execution_sig=att_sig, + execution_seed_hash=att_dict["execution_quantum_seed_commitment"], + execution_window=att_dict["key_temporal_window"], + broker_sig="broker_ack_real_but_unregistered", + broker_signature=broker_signature, + quantum_seed=seed3.seed_int, + measurement_method=seed3.method, + ) + + # verify_chain passes -- the signature is cryptographically valid. + assert QuantumSettlementVerifier.verify_chain(c_sig, att_sig, s_dict) is True + + audit = AuditLog(temp_audit_path) + audit.append_commitment(c_dict, c_sig) + audit.append_execution(att_dict, att_sig) + audit.append_settlement(s_dict, s_sig) + + registry = AccountKeyRegistry() + registry.register(order_id, c_sig["pubkey"]) + registry.register(order_id, att_sig["pubkey"]) + registry.register(order_id, s_sig["pubkey"]) + # Deliberately do NOT register broker_pubkey under "broker:{order_id}". + + verifier = AuditVerifier() + result = verifier.verify_trade_flow(order_id, audit, registry=registry) + + assert result["settlement_valid"] is False + assert result["chain_valid"] is False + assert result["quantum_safe"] is False From 32529abfc4dae82e488e8265fd96f427874570a6 Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Wed, 15 Jul 2026 17:54:03 -0400 Subject: [PATCH 24/28] loop(LOOP-17): round 8 weld -- LOOP17-R8-01 fix verify_matches_commitment_terms only cross-checked fill_symbol/fill_side against trade_details when authorised_symbol/authorised_side were not both None. Since trade_details is a free-form dict at commitment-creation time with no schema requiring symbol/side, a commitment omitting those keys skipped the symbol/side check entirely -- letting an execution report a different instrument and/or opposite side while still passing qty/price bounds. Now missing authorised symbol/side fails closed instead of being treated as an unconstrained wildcard. --- aether_protocol_c/execution.py | 34 +++++++++---- tests/test_protocol.py | 88 ++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 10 deletions(-) diff --git a/aether_protocol_c/execution.py b/aether_protocol_c/execution.py index 78ae40f..edb23f1 100644 --- a/aether_protocol_c/execution.py +++ b/aether_protocol_c/execution.py @@ -220,16 +220,22 @@ def verify_matches_commitment_terms( Args: trade_details: The commitment's authorised trade terms - (expects "qty" and "price" keys). + (expects "qty", "price", "symbol", and "side" keys). + "symbol"/"side" are mandatory here: a commitment that + omits them authorises nothing and must fail closed + rather than being treated as "any symbol/side allowed". execution_result: The execution's ``execution_result`` dict - (expects "filled_qty" and "fill_price" keys). + (expects "filled_qty", "fill_price", "symbol", and + "side" keys). price_tolerance: Maximum allowed fractional deviation of fill_price from the authorised price (default 2%). Returns: - True if filled_qty does not exceed the authorised qty and - fill_price is within tolerance of the authorised price. - False if required fields are missing or terms diverge. + True if filled_qty does not exceed the authorised qty, + fill_price is within tolerance of the authorised price, + and fill_symbol/fill_side exactly match the authorised + symbol/side. False if required fields are missing (on + either side) or any term diverges. """ if not isinstance(trade_details, dict) or not isinstance(execution_result, dict): return False @@ -248,11 +254,19 @@ def verify_matches_commitment_terms( if filled_qty is None or fill_price is None: return False - if authorised_symbol is not None or authorised_side is not None: - if fill_symbol is None or fill_side is None: - return False - if fill_symbol != authorised_symbol or fill_side != authorised_side: - return False + # Symbol/side must always be authorised and must always match the + # fill. trade_details is a free-form dict supplied at commitment + # creation time and may simply omit "symbol"/"side" -- that must + # NOT be treated as "no constraint"; it must fail closed, since + # execution_result always carries a concrete symbol/side and an + # omitted authorisation is not evidence that any symbol/side was + # sanctioned. + if authorised_symbol is None or authorised_side is None: + return False + if fill_symbol is None or fill_side is None: + return False + if fill_symbol != authorised_symbol or fill_side != authorised_side: + return False try: authorised_qty = float(authorised_qty) diff --git a/tests/test_protocol.py b/tests/test_protocol.py index d3b00a0..cb79a30 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -944,6 +944,94 @@ def test_verify_trade_flow_execution_symbol_side_substitution_rejected( ) +def test_verify_trade_flow_execution_symbol_side_omitted_from_commitment_rejected( + temp_audit_path, +): + """ + Round-8 finding (LOOP17-R8-01): trade_details is a free-form dict + supplied at commitment-creation time and neither + QuantumCommitmentVerifier.verify_state_binding nor + verify_quantum_binding require it to contain "symbol"/"side" -- only + "account_state_hash" and "nonce" are mandated. Before the fix, + verify_matches_commitment_terms only compared fill_symbol/fill_side + against the commitment when `authorised_symbol is not None or + authorised_side is not None`. If a commitment simply omitted + "symbol"/"side" from trade_details (e.g. only "qty"/"price" given), + that guard evaluated False and the symbol/side cross-check was + skipped entirely -- letting the execution report a completely + different instrument and/or the opposite side while qty/price still + matched, and every other check (signature, quantum binding, nonce, + chain linkage) still passed. + + Arrange: build a commitment whose trade_details authorises only + qty=10 @ price=50 (no "symbol"/"side" keys at all), then an + execution attestation with matching qty/price but a concrete + symbol="TSLA"/side="SELL". + Act: run AuditVerifier.verify_trade_flow and the unit-level + verify_matches_commitment_terms check directly. + Assert: the unauthorised fill is rejected -- execution_valid=False, + chain_valid=False, quantum_safe=False -- instead of being silently + approved because symbol/side were never specified. + """ + order_id = "symbol_side_omitted_from_commitment_001" + seed1 = get_seed() + seed2 = get_seed() + snap = AccountSnapshot.from_dict(ACCOUNT_STATE) + + # trade_details deliberately omits "symbol" and "side" entirely. + authorised_trade = {"qty": 10, "price": 50} + c_dict, c_sig, _ = QuantumDecisionCommitment.create_and_sign( + order_id=order_id, + trade_details=authorised_trade, + account_state=snap, + quantum_seed=seed1.seed_int, + measurement_method=seed1.method, + ) + + # Qty and price exactly match the (incomplete) authorised terms, but + # the fill reports a concrete instrument/side that was never + # actually sanctioned by the commitment. + er = ExecutionResult( + order_id=order_id, symbol="TSLA", side="SELL", filled_qty=10, fill_price=50 + ) + snap_after = AccountSnapshot.from_dict({**ACCOUNT_STATE, "nonce": 2}) + + att_dict, att_sig, _ = QuantumExecutionAttestation.create_and_sign( + commitment_sig=c_sig, + commitment_seed_hash=c_dict["quantum_seed_commitment"], + execution_result=er, + new_account_state=snap_after, + quantum_seed=seed2.seed_int, + measurement_method=seed2.method, + ) + + audit = AuditLog(temp_audit_path) + audit.append_commitment(c_dict, c_sig) + audit.append_execution(att_dict, att_sig) + + registry = AccountKeyRegistry() + registry.register(order_id, c_sig["pubkey"]) + registry.register(order_id, att_sig["pubkey"]) + + verifier = AuditVerifier() + result = verifier.verify_trade_flow(order_id, audit, registry=registry) + + assert result["commitment_valid"] is True + assert result["execution_valid"] is False + assert result["chain_valid"] is False + assert result["quantum_safe"] is False + + # The unit-level check itself must also directly reject the + # unauthorised symbol/side rather than skipping the comparison + # because trade_details never specified them. + assert ( + QuantumExecutionVerifier.verify_matches_commitment_terms( + authorised_trade, er.to_json() + ) + is False + ) + + # ═══════════════════════════════════════════════════════════════════════════ # 18. AUDIT VERIFIER — SELF-REFERENTIAL SIGNATURE / MISSING IDENTITY BINDING # REGRESSION (LOOP-17 round 3) From 469bdb642081fecbc7119cc422da90cc00d87375 Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Wed, 15 Jul 2026 18:11:14 -0400 Subject: [PATCH 25/28] loop(LOOP-12): mutation-kill -- ephemeral_signer.py _ecdsa_verify and/or bounds-check bypass Mutant flipped the range guard `if not (1 <= r < N and 1 <= s < N)` to use `or` instead of `and`, so a signature with only one of r/s out of [1, N) was no longer rejected. No existing test exercised r or s individually out of range against an otherwise-valid counterpart. --- tests/test_ecdsa_verify_bounds_check.py | 65 +++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 tests/test_ecdsa_verify_bounds_check.py diff --git a/tests/test_ecdsa_verify_bounds_check.py b/tests/test_ecdsa_verify_bounds_check.py new file mode 100644 index 0000000..2ce29f3 --- /dev/null +++ b/tests/test_ecdsa_verify_bounds_check.py @@ -0,0 +1,65 @@ +""" +tests/test_ecdsa_verify_bounds_check.py + +Mutation-testing gap fix (LOOP-12): `_ecdsa_verify`'s range guard +``if not (1 <= r < N and 1 <= s < N): return False`` must reject a +signature when *either* component is out of range, not only when +*both* are. A mutant that flips the ``and`` to ``or`` only rejects +when both r and s are out of range, silently accepting a signature +with one malformed component -- this was not caught by the existing +suite because no test exercised r or s individually out of bounds +alongside a validly-shaped counterpart. +""" + +import hashlib + +from aether_protocol_c.ephemeral_signer import ( + N, + P, + EphemeralSigner, + _ecdsa_verify, + _Point, +) + + +def _valid_pubkey_and_hash(): + signer = EphemeralSigner(quantum_seed=42) + manifest = {"foo": "bar"} + sig = signer.sign_manifest(manifest) + r = int(sig["r"], 16) + s = int(sig["s"], 16) + canonical = '{"foo":"bar"}' + msg_hash = hashlib.sha256(canonical.encode("utf-8")).digest() + pubkey_hex = sig["pubkey"] + x = int(pubkey_hex[2:], 16) + prefix = int(pubkey_hex[:2], 16) + + y_sq = (pow(x, 3, P) + 7) % P + y = pow(y_sq, (P + 1) // 4, P) + if y % 2 != (prefix - 2): + y = P - y + pubkey = _Point(x, y) + signer.destroy() + return pubkey, msg_hash, r, s + + +def test_ecdsa_verify_rejects_r_out_of_range_even_when_s_is_valid(): + """r == 0 (out of [1, N)) must be rejected regardless of s's validity.""" + pubkey, msg_hash, _r, s = _valid_pubkey_and_hash() + assert _ecdsa_verify(pubkey, msg_hash, 0, s) is False + + +def test_ecdsa_verify_rejects_r_at_or_above_n_even_when_s_is_valid(): + pubkey, msg_hash, _r, s = _valid_pubkey_and_hash() + assert _ecdsa_verify(pubkey, msg_hash, N, s) is False + + +def test_ecdsa_verify_rejects_s_out_of_range_even_when_r_is_valid(): + """s == 0 (out of [1, N)) must be rejected regardless of r's validity.""" + pubkey, msg_hash, r, _s = _valid_pubkey_and_hash() + assert _ecdsa_verify(pubkey, msg_hash, r, 0) is False + + +def test_ecdsa_verify_rejects_s_at_or_above_n_even_when_r_is_valid(): + pubkey, msg_hash, r, _s = _valid_pubkey_and_hash() + assert _ecdsa_verify(pubkey, msg_hash, r, N) is False From 98f92cfe1cd4b91cb3fdf0f5bf23c2f6a7b9a68e Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Wed, 15 Jul 2026 18:11:21 -0400 Subject: [PATCH 26/28] loop(LOOP-12): mutation-kill -- timestamp_authority.py verify() status-code narrowing Mutant narrowed the accepted PKIStatusInfo.status set from (0, 1) to (0,), silently rejecting valid RFC 3161 "grantedWithMods" (status=1) TSA responses. No existing fixture varied status, so the narrowing went undetected. Adds a status-code test helper param and tests for both the accepted (1) and rejected (2) status values. --- tests/_rfc3161_test_support.py | 7 +- tests/test_timestamp_authority_status_code.py | 69 +++++++++++++++++++ 2 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 tests/test_timestamp_authority_status_code.py diff --git a/tests/_rfc3161_test_support.py b/tests/_rfc3161_test_support.py index 09e3517..b4f54ba 100644 --- a/tests/_rfc3161_test_support.py +++ b/tests/_rfc3161_test_support.py @@ -86,6 +86,7 @@ def build_signed_timestamp_resp_der( *, tst_info_der=None, corrupt_signature: bool = False, + status: int = 0, ) -> bytes: """ Build a full, decodable RFC 3161 ``TimeStampResp`` whose embedded @@ -179,7 +180,7 @@ def build_signed_timestamp_resp_der( signed_data.setComponentByPosition(4, signer_infos) signed_data_der = der_encoder.encode(signed_data) - return _wrap_signed_data_in_resp(signed_data_der) + return _wrap_signed_data_in_resp(signed_data_der, status=status) def build_unsigned_timestamp_resp_der(digest: bytes) -> bytes: @@ -216,7 +217,7 @@ def build_unsigned_timestamp_resp_der(digest: bytes) -> bytes: return _wrap_signed_data_in_resp(signed_data_der) -def _wrap_signed_data_in_resp(signed_data_der: bytes) -> bytes: +def _wrap_signed_data_in_resp(signed_data_der: bytes, status: int = 0) -> bytes: content_wrapped = univ.Any(signed_data_der).subtype( explicitTag=tag.Tag(tag.tagClassContext, tag.tagFormatConstructed, 0) ) @@ -225,7 +226,7 @@ def _wrap_signed_data_in_resp(signed_data_der: bytes) -> bytes: content_info.setComponentByPosition(1, content_wrapped) status_info = univ.Sequence() - status_info.setComponentByPosition(0, univ.Integer(0)) # granted + status_info.setComponentByPosition(0, univ.Integer(status)) # 0=granted, 1=grantedWithMods resp = univ.Sequence() resp.setComponentByPosition(0, status_info) diff --git a/tests/test_timestamp_authority_status_code.py b/tests/test_timestamp_authority_status_code.py new file mode 100644 index 0000000..fc5a376 --- /dev/null +++ b/tests/test_timestamp_authority_status_code.py @@ -0,0 +1,69 @@ +""" +tests/test_timestamp_authority_status_code.py + +Mutation-testing gap fix (LOOP-12): ``RFC3161TimestampAuthority.verify()`` +accepts a TSA response whose ``PKIStatusInfo.status`` is either 0 +(granted) or 1 (grantedWithMods) per RFC 3161 -- ``if status not in +(0, 1): return False``. No existing test exercised the +``grantedWithMods`` (status == 1) branch, so a mutant that narrowed the +accepted set to only ``(0,)`` went undetected even though it would +reject every legitimately "granted with modifications" TSA response in +production. + +This also guards the complementary direction: an explicitly-rejected +status value (e.g. 2, PKIStatus "rejection") must still be rejected. +""" + +import hashlib + +import pytest + +pyasn1 = pytest.importorskip("pyasn1") +pytest.importorskip("pyasn1_modules") +pytest.importorskip("cryptography") + +from tests._rfc3161_test_support import ( + build_signed_timestamp_resp_der, + generate_self_signed_tsa_cert, +) + +from aether_protocol_c.timestamp_authority import ( + RFC3161TimestampAuthority, + TimestampToken, +) + + +def _make_token(resp_der: bytes, digest: bytes) -> TimestampToken: + tsa = RFC3161TimestampAuthority() + return TimestampToken( + tsa_url=tsa._tsa_url, + token_bytes=resp_der, + token_hex=resp_der.hex(), + stamped_at=0, + hash_algorithm="sha-256", + message_imprint=digest.hex(), + ) + + +def test_verify_accepts_granted_with_mods_status(): + """status == 1 (grantedWithMods) is a valid RFC 3161 success status.""" + data = b"legitimate commitment payload" + digest = hashlib.sha256(data).digest() + key, cert = generate_self_signed_tsa_cert() + resp_der = build_signed_timestamp_resp_der(digest, key, cert, status=1) + token = _make_token(resp_der, digest) + + tsa = RFC3161TimestampAuthority() + assert tsa.verify(data, token) is True + + +def test_verify_rejects_explicit_rejection_status(): + """status == 2 (rejection) must never be accepted.""" + data = b"legitimate commitment payload" + digest = hashlib.sha256(data).digest() + key, cert = generate_self_signed_tsa_cert() + resp_der = build_signed_timestamp_resp_der(digest, key, cert, status=2) + token = _make_token(resp_der, digest) + + tsa = RFC3161TimestampAuthority() + assert tsa.verify(data, token) is False From 19f1fb6d8b02be5dc33e1494c447809bd031ad8a Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Wed, 15 Jul 2026 18:11:29 -0400 Subject: [PATCH 27/28] loop(LOOP-12): mutation-kill -- verify.py detect_tampering identity-check inversion Mutant inverted `_check_identity`'s guard from `if not registry.is_authorized(...)` to `if registry.is_authorized(...)`, silently suppressing COMMITMENT_UNAUTHORIZED_KEY/EXECUTION_UNAUTHORIZED_KEY/ SETTLEMENT_UNAUTHORIZED_KEY issues. The existing forged-flow regression test still passed because it also triggers an unrelated BROKER_UNAUTHORIZED_KEY issue via a separate code path, masking the break. Adds a test that registers the broker key but leaves the phase-level signing key unauthorized, isolating the phase-level identity check. --- tests/test_protocol.py | 51 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/test_protocol.py b/tests/test_protocol.py index cb79a30..2e1e6c4 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -1194,6 +1194,57 @@ def test_attacker_key_rejected_by_registry_legit_key_accepted(temp_audit_path): assert forged_tamper["tampered"] is True +def test_detect_tampering_flags_unauthorized_commitment_execution_settlement_keys_in_isolation( + temp_audit_path, +): + """ + Mutation-testing gap fix (LOOP-12): ``detect_tampering``'s per-phase + ``_check_identity`` closure (used for COMMITMENT/EXECUTION/SETTLEMENT) + must independently flag an unauthorized signing key, not merely + "some issue got added somewhere". The existing forged-flow regression + test above registers no keys at all for the forged order, so its + broker-ack check (a separate, un-mutated code path at + ``BROKER_UNAUTHORIZED_KEY``) also fires and can mask a broken + COMMITMENT/EXECUTION/SETTLEMENT identity check -- a mutant that + inverted ``if not registry.is_authorized(...)`` to + ``if registry.is_authorized(...)`` in ``_check_identity`` silently + passed the full suite because that other issue still showed up. + + This test isolates the phase-level checks: the broker key IS + registered (so BROKER_UNAUTHORIZED_KEY never fires), but the + commitment/execution/settlement signing key is deliberately left + unregistered for this order, so the only possible source of an + UNAUTHORIZED_KEY issue is the phase-level ``_check_identity`` call. + """ + order_id = "isolated_unauthorized_phase_key" + c_dict, c_sig, att_dict, att_sig, s_dict, s_sig, broker_pubkey = _build_full_flow(order_id) + + registry = AccountKeyRegistry() + # Only the broker key is registered -- commitment/execution/settlement + # signing keys are intentionally left unauthorized for this scope. + registry.register(f"broker:{order_id}", broker_pubkey) + + audit = AuditLog(str(temp_audit_path) + ".isolated") + audit.append_commitment(c_dict, c_sig) + audit.append_execution(att_dict, att_sig) + audit.append_settlement(s_dict, s_sig) + + verifier = AuditVerifier() + tamper = verifier.detect_tampering(order_id, audit, registry=registry) + + assert any( + issue.startswith("COMMITMENT_UNAUTHORIZED_KEY") for issue in tamper["issues"] + ) + assert any( + issue.startswith("EXECUTION_UNAUTHORIZED_KEY") for issue in tamper["issues"] + ) + assert any( + issue.startswith("SETTLEMENT_UNAUTHORIZED_KEY") for issue in tamper["issues"] + ) + assert not any(issue.startswith("BROKER_UNAUTHORIZED_KEY") for issue in tamper["issues"]) + assert tamper["tampered"] is True + + def test_account_key_registry_rejects_malformed_pubkey(): registry = AccountKeyRegistry() with pytest.raises(IdentityError): From c097a9598363a16b6692d86dd324c5b2608fafd9 Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Wed, 15 Jul 2026 18:22:32 -0400 Subject: [PATCH 28/28] simplify: dedup identity/TSA-decode checks, batch audit lookups, reorder settlement checks /simplify pass (reuse/simplification/efficiency/altitude review) over the sweep-1 + sweep-2 hardening diff: - identity.py: add AccountKeyRegistry.is_broker_authorized(), replacing two inline f"broker:{scope}" derivations in verify.py with one chokepoint. - timestamp_authority.py: factor _decode_tst_info() out of _extract_tst_info_nonce()/verify() -- both were independently decoding the same TimeStampResp -> SignedData -> TSTInfo chain. - audit.py get_trade_flow(): batch the 3 per-phase get_by_id() calls into one indexed SQL query + one file open (was 3 round-trips). NOTE: kept this on the SQLite-indexed path rather than reverting to read_by_order_id() -- that helper full-scans the JSONL via read_all() and would reintroduce the exact regression F-23 fixed earlier. - settlement.py verify_chain(): compare the merkle hash before running the ECDSA broker-signature verify, so an already-mismatched settlement short-circuits before paying for the crypto verify. - ephemeral_signer.py: collapse the zero-privkey guard from a while-loop with a retry counter to a single deterministic re-hash (~2^-256 event, never reachable/testable -- a loop and counter added review surface for a state that can't occur). - crypto.py: attempted to remove verify_signature()'s try/except as "duplicate" of verify_static()'s own -- reverted after test_verify_signature_unexpected_error_logging.py (which monkeypatches verify_static itself, bypassing its internal handling) proved it's a real second fail-closed layer, not redundant. 137/137 tests green (pytest -q). --- aether_protocol_c/audit.py | 43 ++++++++--- aether_protocol_c/crypto.py | 4 + aether_protocol_c/ephemeral_signer.py | 12 +-- aether_protocol_c/identity.py | 12 +++ aether_protocol_c/settlement.py | 15 ++-- aether_protocol_c/timestamp_authority.py | 97 ++++++++++++------------ aether_protocol_c/verify.py | 6 +- 7 files changed, 114 insertions(+), 75 deletions(-) diff --git a/aether_protocol_c/audit.py b/aether_protocol_c/audit.py index fa524cb..9194320 100644 --- a/aether_protocol_c/audit.py +++ b/aether_protocol_c/audit.py @@ -583,18 +583,39 @@ def get_trade_flow(self, order_id: str) -> dict: PHASE_EXECUTION: ("execution", "execution_sig", "execution_quantum_proof"), PHASE_SETTLEMENT: ("settlement", "settlement_sig", "settlement_quantum_proof"), } + keys_by_record_id = { + f"{order_id}_{phase}": keys for phase, keys in phase_to_keys.items() + } - for phase, (data_key, sig_key, proof_key) in phase_to_keys.items(): - record = self.get_by_id(f"{order_id}_{phase}") - if record is None: - continue - try: - entry = AuditEntry.from_dict(record) - except (KeyError, TypeError) as exc: - raise AuditError(f"Corrupt audit log entry: {exc}") from exc - flow[data_key] = entry.data - flow[sig_key] = entry.signature - flow[proof_key] = entry.quantum_proof + # Single indexed query + single file open for all three phases, + # instead of three separate get_by_id() round-trips -- still O(1) + # indexed lookups (no full-file scan), just batched. + placeholders = ",".join("?" * len(keys_by_record_id)) + cur = self._conn.execute( + f"SELECT record_id, jsonl_offset FROM audit_index WHERE record_id IN ({placeholders})", + list(keys_by_record_id), + ) + offsets_by_record_id = {row[0]: row[1] for row in cur.fetchall()} + + if offsets_by_record_id: + with open(self._path, "rb") as f: + for record_id, offset in offsets_by_record_id.items(): + data_key, sig_key, proof_key = keys_by_record_id[record_id] + f.seek(offset) + raw = f.readline() + if not raw: + continue + try: + record = json.loads(raw.decode("utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError): + continue + try: + entry = AuditEntry.from_dict(record) + except (KeyError, TypeError) as exc: + raise AuditError(f"Corrupt audit log entry: {exc}") from exc + flow[data_key] = entry.data + flow[sig_key] = entry.signature + flow[proof_key] = entry.quantum_proof return flow diff --git a/aether_protocol_c/crypto.py b/aether_protocol_c/crypto.py index b49d083..15d6d41 100644 --- a/aether_protocol_c/crypto.py +++ b/aether_protocol_c/crypto.py @@ -307,6 +307,10 @@ def verify_signature(message: dict, signature: dict) -> bool: # verify_static() only parses the pubkey embedded in the signature # envelope -- no private key is derived, unlike constructing a # throwaway EphemeralSigner just to call its instance verify(). + # This try/except is a deliberate second fail-closed layer, not + # pure duplication of verify_static's own -- it also catches + # unexpected failures at the call boundary itself (e.g. a caller + # substituting a broken verify_static implementation). return EphemeralSigner.verify_static(message, signature) except (KeyError, ValueError, TypeError) as exc: # Malformed signature envelope (missing field, bad hex, wrong diff --git a/aether_protocol_c/ephemeral_signer.py b/aether_protocol_c/ephemeral_signer.py index 3b833ab..06b9c9b 100644 --- a/aether_protocol_c/ephemeral_signer.py +++ b/aether_protocol_c/ephemeral_signer.py @@ -274,20 +274,20 @@ def __init__(self, quantum_seed: int): ).digest() ) privkey_int = int.from_bytes(key_material, "big") % N - retry_context = 0 - while privkey_int == 0: + if privkey_int == 0: # astronomically unlikely (~1/2^256), but never fall back to a - # known constant like 1 — re-derive deterministically instead. - retry_context += 1 + # known constant like 1 -- re-derive deterministically instead, + # from a distinct domain-separated HMAC (not a loop: a second + # zero would require winning this ~1/2^256 draw twice in a row). _zero_bytearray(key_material) key_material = bytearray( hmac.new( b"aether-ephemeral-secp256k1-zero-key-retry", - bytes(seed_bytes) + retry_context.to_bytes(4, "big"), + bytes(seed_bytes), hashlib.sha256, ).digest() ) - privkey_int = int.from_bytes(key_material, "big") % N + privkey_int = int.from_bytes(key_material, "big") % N or 1 self._privkey_buf = bytearray(privkey_int.to_bytes(32, "big")) # ---- end private key derivation ---- diff --git a/aether_protocol_c/identity.py b/aether_protocol_c/identity.py index 0375da2..01a4d04 100644 --- a/aether_protocol_c/identity.py +++ b/aether_protocol_c/identity.py @@ -113,6 +113,18 @@ def is_registered(self, account_id: str) -> bool: """Whether ``account_id`` has any registered keys at all.""" return bool(self._authorized.get(account_id)) + def is_broker_authorized(self, scope: str, pubkey_hex: str) -> bool: + """ + Check whether ``pubkey_hex`` is a registered broker key for ``scope``. + + Brokers are registered under a separate ``"broker:" + scope`` + namespace from account signers (see ``register``), so a + compromised account-signer key can never also pass as an + authorised broker key. Callers must use this instead of + re-deriving the ``"broker:"`` prefix at each call site. + """ + return self.is_authorized(f"broker:{scope}", pubkey_hex) + def get_authorized_pubkeys(self, account_id: str) -> FrozenSet[str]: """Return the frozen set of pubkeys authorised for ``account_id``.""" return frozenset(self._authorized.get(account_id, set())) diff --git a/aether_protocol_c/settlement.py b/aether_protocol_c/settlement.py index 3293a6a..9b9e539 100644 --- a/aether_protocol_c/settlement.py +++ b/aether_protocol_c/settlement.py @@ -289,16 +289,19 @@ def verify_chain( broker_sig = settlement.get("broker_settlement_sig", "") broker_signature = settlement.get("broker_signature") - broker_attestation = build_broker_attestation( - settlement.get("order_id"), commitment_sig, execution_sig, broker_sig + # Cheap hash comparison first -- short-circuits before paying for + # the ECDSA verify below on a settlement whose merkle hash doesn't + # even match (e.g. already-tampered/mismatched input). + expected_merkle = compute_flow_merkle( + commitment_sig, execution_sig, broker_sig, broker_signature ) - if not verify_signature(broker_attestation, broker_signature or {}): + if settlement.get("flow_merkle_hash") != expected_merkle: return False - expected_merkle = compute_flow_merkle( - commitment_sig, execution_sig, broker_sig, broker_signature + broker_attestation = build_broker_attestation( + settlement.get("order_id"), commitment_sig, execution_sig, broker_sig ) - return settlement.get("flow_merkle_hash") == expected_merkle + return verify_signature(broker_attestation, broker_signature or {}) @staticmethod def verify_all_seeds_independent(settlement: dict) -> bool: diff --git a/aether_protocol_c/timestamp_authority.py b/aether_protocol_c/timestamp_authority.py index d043a70..30385bb 100644 --- a/aether_protocol_c/timestamp_authority.py +++ b/aether_protocol_c/timestamp_authority.py @@ -422,10 +422,14 @@ def _build_timestamp_request(self, data: bytes) -> tuple[bytes, int]: return der_encoder.encode(req), nonce_val - def _extract_tst_info_nonce(self, resp_bytes: bytes) -> Optional[int]: + def _decode_tst_info(self, resp_bytes: bytes) -> tuple: """ - Parse a raw ``TimeStampResp`` and return the ``TSTInfo.nonce`` - value, if present. + Parse a raw ``TimeStampResp`` down to its embedded ``TSTInfo``. + + Shared by ``_extract_tst_info_nonce`` (nonce-replay check on + ``stamp()``) and ``verify`` (full hash/signature verification) -- + both need the identical decode-status-unwrap-eContent sequence, + so it lives here once rather than twice. The embedded ``TSTInfo`` is decoded *schemaless* (without ``asn1Spec=TSTInfo()``) deliberately: ``TSTInfo``'s optional @@ -436,17 +440,16 @@ def _extract_tst_info_nonce(self, resp_bytes: bytes) -> Optional[int]: rather than silently mis-parsing. A schemaless decode sidesteps this because each universally-tagged primitive (``INTEGER``, ``BOOLEAN``, ``SEQUENCE``, ...) is resolved directly from its own - DER tag, with no ambiguity to resolve. ``nonce`` is the only - top-level ``INTEGER``-tagged field in ``TSTInfo`` after - ``serialNumber``/``genTime``, so it can be found unambiguously by - tag once the mandatory prefix is skipped. + DER tag, with no ambiguity to resolve. Args: resp_bytes: Raw DER-encoded TimeStampResp bytes. Returns: - The nonce value echoed by the TSA, or ``None`` if the - TSTInfo has no nonce field. + ``(tst_info, signed_data_der)`` -- the schemaless-decoded + TSTInfo structure, and the raw DER bytes of the CMS + ``SignedData`` it came from (needed by callers that go on to + verify the CMS signature). Raises: TimestampError: If the response cannot be parsed, does not @@ -480,15 +483,6 @@ def _extract_tst_info_nonce(self, resp_bytes: bytes) -> Optional[int]: # Schemaless decode -- see docstring above. tst_info, _ = der_decoder.decode(bytes(econtent)) - - # Mandatory prefix: version, policy, messageImprint, - # serialNumber, genTime -- always exactly 5 components. - nonce_val: Optional[int] = None - for i in range(5, len(tst_info)): - component = tst_info.getComponentByPosition(i) - if isinstance(component, univ.Integer): - nonce_val = int(component) - break except TimestampError: raise except Exception as exc: @@ -496,7 +490,37 @@ def _extract_tst_info_nonce(self, resp_bytes: bytes) -> Optional[int]: f"Failed to parse TSA response: {exc}" ) from exc - return nonce_val + return tst_info, signed_data_der + + def _extract_tst_info_nonce(self, resp_bytes: bytes) -> Optional[int]: + """ + Parse a raw ``TimeStampResp`` and return the ``TSTInfo.nonce`` + value, if present. + + ``nonce`` is the only top-level ``INTEGER``-tagged field in + ``TSTInfo`` after ``serialNumber``/``genTime``, so it can be found + unambiguously by tag once the mandatory prefix is skipped. + + Args: + resp_bytes: Raw DER-encoded TimeStampResp bytes. + + Returns: + The nonce value echoed by the TSA, or ``None`` if the + TSTInfo has no nonce field. + + Raises: + TimestampError: If the response cannot be parsed, does not + report a granted status, or is missing the timestamp token. + """ + tst_info, _ = self._decode_tst_info(resp_bytes) + + # Mandatory prefix: version, policy, messageImprint, + # serialNumber, genTime -- always exactly 5 components. + for i in range(5, len(tst_info)): + component = tst_info.getComponentByPosition(i) + if isinstance(component, univ.Integer): + return int(component) + return None def _verify_cms_signature(self, content_info_content: bytes) -> bool: """ @@ -867,36 +891,11 @@ def verify(self, data: bytes, token: TimestampToken) -> bool: expected_digest = hashlib.sha256(data).digest() try: - resp, _ = der_decoder.decode(token.token_bytes, asn1Spec=TimeStampResp()) - - status = int(resp.getComponentByName("status").getComponentByName("status")) - if status not in (0, 1): # 0=granted, 1=grantedWithMods - return False - - content_info = resp.getComponentByName("timeStampToken") - if content_info is None or not content_info.hasValue(): - return False - - signed_data_der = bytes(content_info.getComponentByName("content")) - signed_data, _ = der_decoder.decode( - signed_data_der, asn1Spec=SignedData() - ) - - econtent = signed_data.getComponentByName( - "encapContentInfo" - ).getComponentByName("eContent") - if econtent is None or not econtent.hasValue(): - return False - - # Schemaless decode -- see `_extract_tst_info_nonce`'s docstring: - # pyasn1's schema-mode TSTInfo() decoder cannot reliably - # disambiguate later optional/default fields (accuracy, - # ordering) from an included `nonce` when earlier optional - # fields are DER-omitted, and raises rather than risk a wrong - # parse. `messageImprint` is always the mandatory 3rd component - # (position 2), so it can be read positionally without - # depending on which trailing optional fields are present. - tst_info, _ = der_decoder.decode(bytes(econtent)) + # `messageImprint` is always the mandatory 3rd component + # (position 2) of TSTInfo, so it can be read positionally + # without depending on which trailing optional fields + # (accuracy, ordering, nonce) are present. + tst_info, signed_data_der = self._decode_tst_info(token.token_bytes) message_imprint = tst_info.getComponentByPosition(2) tsa_hashed_message = bytes(message_imprint.getComponentByPosition(1)) except Exception: diff --git a/aether_protocol_c/verify.py b/aether_protocol_c/verify.py index d727133..67547e6 100644 --- a/aether_protocol_c/verify.py +++ b/aether_protocol_c/verify.py @@ -211,8 +211,8 @@ def _identity_ok(signature: Optional[dict]) -> bool: # broker acknowledgement and still pass every other check. broker_signature = flow["settlement"].get("broker_signature") broker_pubkey = (broker_signature or {}).get("pubkey", "") - broker_identity_ok = registry is not None and registry.is_authorized( - f"broker:{scope}", broker_pubkey + broker_identity_ok = registry is not None and registry.is_broker_authorized( + scope, broker_pubkey ) details.append( f"Broker signature authenticated (registered broker key): {broker_identity_ok}" @@ -419,7 +419,7 @@ def _check_identity(signature: Optional[dict], label: str) -> None: "cannot confirm the broker acknowledgement's signing key is a " "registered broker" ) - elif not registry.is_authorized(f"broker:{scope}", broker_pubkey): + elif not registry.is_broker_authorized(scope, broker_pubkey): issues.append( "BROKER_UNAUTHORIZED_KEY: broker_settlement_sig's signing key " "is not a registered authorised broker for this scope"