From f424db449991ea0f9a616114baf563b9d2467604 Mon Sep 17 00:00:00 2001 From: Thorsten Litzki <268185082+litzki-systems@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:43:21 -0600 Subject: [PATCH 01/18] Align README with Draft 04 scope --- README.md | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 9690e03..cf27e06 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # sovp-python -**sovp-python** is the reference implementation of the [Sovereign Validation Protocol (SOVP)](https://litzki-systems.com/sovp) — a pre-ingestion verification protocol that lets LLMs and autonomous agents cryptographically confirm the identity and integrity of a data source before parsing it. It exists because existing mechanisms (DANE, DIDs, TLS) operate at the wrong layer for agentic pipelines: SOVP sits at Layer 0, before the body is read. To get started: clone the repo and run `pip install -e .` — this exposes both a `sovp.core` Python API and a `sovp` CLI with three commands. +**sovp-python** is the reference implementation of the [Sovereign Validation Protocol (SOVP)](https://litzki-systems.com/sovp), a pre-ingestion verification protocol for validating a signed identity document before its contents are ingested. SOVP verifies that the document was signed by the party controlling an Ed25519 public key published for the document host in DNS. It operates at Layer 0, before the document body is ingested. To get started: clone the repo and run `pip install -e .`. This exposes the `sovp.core` Python API and the `sovp` CLI. > **Protocol specification:** [draft-litzki-sovp](https://datatracker.ietf.org/doc/draft-litzki-sovp/) — IETF Internet-Draft @@ -29,18 +29,19 @@ Psi_core = Verify(K_pub, sigma, JCS(M)) --- -## Position in the agentic trust stack +## Protocol scope -SOVP is the infrastructure attestation layer inside a broader agentic trust stack. The four layers, from discovery to runtime: +SOVP defines a narrow cryptographic validation boundary at Layer 0. The core verification function is: -| Layer | Concern | Mechanism | -|---|---|---| -| **Discovery** | Is the source findable and routable? | DNS, service registries, `ai-catalog.json` | -| **Install safety** | Is the artifact what it claims to be before execution? | `contentAddress` digest (SHA-256 over JCS bytes), SOVP `trustManifest` type | -| **Infrastructure trust** | Does the serving entity control the domain and key? | SOVP `sovp-identity.json`, `_sovp` DNS TXT, Ed25519 proof | -| **Runtime governance** | Is the agent permitted to act on this data in this context? | Policy engines, capability tokens, audit logs | +`Psi_core = Verify(K_pub, sigma, JCS(M))` -SOVP operates at **layers 2 and 3**. If you arrived here from [ards-project/ard-spec issue #41](https://github.com/ards-project/ard-spec/issues/41): the `trustManifest` type in an `ai-catalog.json` entry maps to layer 2 — it binds a catalog entry's `contentAddress` digest to an independently verifiable infrastructure attestation, so a consuming agent can confirm the entry was produced by the declared entity before acting on it. +where the public key is resolved from the host's `_sovp` DNS TXT records. For schema v2.0, the signed document also carries freshness data, including `created` and `expiresAt`. The reference resolver validates the document against the expected host and applies the v2.0 freshness rules. + +SOVP establishes that the signed document is attributable to the party controlling the DNS-published Ed25519 key. Optional instance binding can associate a signed document with an expected instance identifier. Instance binding does not establish that a process is running as that instance. + +SOVP does not establish legal identity, semantic accuracy or truthfulness, security posture, configuration or hardening state, runtime behavior, requester authorization, or permission to perform an action. Those properties require separate evidence and policy mechanisms. SOVP claims should therefore be composed with other systems only through explicit, separately defined evidence and claim semantics. + +`contentAddress` provides an unsigned SHA-256 content identity over the same JCS representation used for the signature. It supports content consistency and downstream binding. It does not provide authenticity and must not be used as a substitute for signature verification. --- @@ -92,7 +93,12 @@ document = generate_identity_document( # 3. Verify (Psi_core) signature = document["integrity_proof"]["signature"] -psi_core = verify_identity(document, signature, public_key_b64) +psi_core = verify_identity( + document, + signature, + public_key_b64, + expected_host="example.com", +) print("Psi_core =", 1 if psi_core else 0) # → 1 # 4. Tamper detection @@ -187,8 +193,8 @@ signed_payload = { from sovp.core import verify_identity # Pass the full document or the non-proof subset — both work identically. -# Enable check_timestamp=True to enforce the 600-second validity window -# (draft Section 7.2 SHOULD). +# Enable check_timestamp=True to enforce the 600-second issuance window +# defined by Draft 04 Section 7.2. psi_core = verify_identity(signed_payload, signature, public_key_b64, check_timestamp=True) if psi_core: @@ -215,7 +221,7 @@ sovp verify --payload test_payload.json --sig --pubkey Date: Wed, 7 Oct 2026 18:51:19 -0600 Subject: [PATCH 02/18] test: add Draft 04 normative boundary coverage --- tests/test_draft04_conformance.py | 268 ++++++++++++++++++++++++++++++ 1 file changed, 268 insertions(+) create mode 100644 tests/test_draft04_conformance.py diff --git a/tests/test_draft04_conformance.py b/tests/test_draft04_conformance.py new file mode 100644 index 0000000..65dd3b5 --- /dev/null +++ b/tests/test_draft04_conformance.py @@ -0,0 +1,268 @@ +# Copyright (c) 2026 Litzki Systems LLC +# SPDX-License-Identifier: Apache-2.0 + +import base64 +from datetime import datetime, timedelta, timezone + +import pytest + +from sovp import core, resolver +from sovp.core import generate_identity_document, generate_keypair, sign_identity, verify_identity + + +def _v2_document(canonical_url="https://example.com", **extra): + now = datetime(2026, 10, 8, 1, 0, 0, tzinfo=timezone.utc) + document = { + "@context": "https://litzki-systems.com/protocol/v2.0", + "@type": "SovereignIdentity", + "entity": { + "uid": "urn:sovp:draft-04-test", + "canonical_url": canonical_url, + "verification_method": "Ed25519", + }, + "freshness": { + "created": "2026-10-08T00:59:50Z", + "nonce": "draft-04-test-nonce", + "expiresAt": "2026-10-08T02:00:00Z", + }, + **extra, + } + return document, now + + +def _sign_document(private_key_b64, document): + signature = sign_identity(private_key_b64, document) + return { + **document, + "integrity_proof": { + "signature": signature, + "public_key_ref": "dns:txt:_sovp.example.com", + }, + }, signature + + +def _freeze_core(now): + original = core.datetime + + class FrozenDatetime(original): + @classmethod + def now(cls, tz=None): + return now + + core.datetime = FrozenDatetime + return original + + +def test_v2_freshness_future_clock_skew_boundary(): + priv, pub = generate_keypair() + document, _ = _v2_document() + document["freshness"]["created"] = "2026-10-08T01:00:59Z" + document, signature = _sign_document(priv, document) + + original = _freeze_core(datetime(2026, 10, 8, 1, 0, 0, tzinfo=timezone.utc)) + try: + assert verify_identity(document, signature, pub) is True + finally: + core.datetime = original + + +def test_v2_freshness_future_clock_skew_exceeded(): + priv, pub = generate_keypair() + document, _ = _v2_document() + document["freshness"]["created"] = "2026-10-08T01:01:01Z" + document, signature = _sign_document(priv, document) + + original = _freeze_core(datetime(2026, 10, 8, 1, 0, 0, tzinfo=timezone.utc)) + try: + assert verify_identity(document, signature, pub) is False + finally: + core.datetime = original + + +def test_v2_freshness_expiry_clock_skew_boundary(): + priv, pub = generate_keypair() + document, _ = _v2_document() + document["freshness"]["expiresAt"] = "2026-10-08T00:59:01Z" + document, signature = _sign_document(priv, document) + + original = _freeze_core(datetime(2026, 10, 8, 1, 0, 0, tzinfo=timezone.utc)) + try: + assert verify_identity(document, signature, pub) is True + finally: + core.datetime = original + + +def test_v2_freshness_expiry_clock_skew_exceeded(): + priv, pub = generate_keypair() + document, _ = _v2_document() + document["freshness"]["expiresAt"] = "2026-10-08T00:58:59Z" + document, signature = _sign_document(priv, document) + + original = _freeze_core(datetime(2026, 10, 8, 1, 0, 0, tzinfo=timezone.utc)) + try: + assert verify_identity(document, signature, pub) is False + finally: + core.datetime = original + + +def test_v2_issuance_window_clock_skew_boundary(): + priv, pub = generate_keypair() + document, _ = _v2_document() + document["freshness"]["created"] = "2026-10-07T23:49:00Z" + document["freshness"]["expiresAt"] = "2026-10-08T02:00:00Z" + document, signature = _sign_document(priv, document) + + original = _freeze_core(datetime(2026, 10, 8, 1, 0, 0, tzinfo=timezone.utc)) + try: + assert verify_identity(document, signature, pub, check_timestamp=True) is True + finally: + core.datetime = original + + +def test_v2_issuance_window_rejects_beyond_clock_skew(): + priv, pub = generate_keypair() + document, _ = _v2_document() + document["freshness"]["created"] = "2026-10-07T23:48:59Z" + document["freshness"]["expiresAt"] = "2026-10-08T02:00:00Z" + document, signature = _sign_document(priv, document) + + original = _freeze_core(datetime(2026, 10, 8, 1, 0, 0, tzinfo=timezone.utc)) + try: + assert verify_identity(document, signature, pub, check_timestamp=True) is False + finally: + core.datetime = original + + +def test_unknown_top_level_members_are_in_signed_scope(): + priv, pub = generate_keypair() + document, _ = _v2_document() + document["vendor_extension"] = {"mode": "example"} + document, signature = _sign_document(priv, document) + + assert verify_identity(document, signature, pub) is True + + tampered = { + **document, + "vendor_extension": {"mode": "tampered"}, + } + assert verify_identity(tampered, signature, pub) is False + + +def test_content_address_is_outside_signed_scope(): + priv, pub = generate_keypair() + document = generate_identity_document( + private_key_b64=priv, + entity_uid="urn:sovp:content-address-test", + canonical_url="https://example.com", + nonce="content-address-test", + expires_at="2026-12-31T23:59:59Z", + ) + signature = document["integrity_proof"]["signature"] + + document["contentAddress"]["digest"] = "00" * 32 + assert verify_identity(document, signature, pub) is True + + +def test_scan_is_outside_signed_scope(): + priv, pub = generate_keypair() + document = generate_identity_document( + private_key_b64=priv, + entity_uid="urn:sovp:scan-scope-test", + canonical_url="https://example.com", + scan={"verdict": "CERTIFIED", "score": 92}, + ) + signature = document["integrity_proof"]["signature"] + + document["scan"]["score"] = 1 + assert verify_identity(document, signature, pub) is True + + +def test_host_binding_normalizes_case_and_trailing_dot(): + priv, pub = generate_keypair() + document = generate_identity_document( + private_key_b64=priv, + entity_uid="urn:sovp:host-test", + canonical_url="https://Example.COM./resource", + ) + signature = document["integrity_proof"]["signature"] + + assert verify_identity( + document, + signature, + pub, + expected_host="EXAMPLE.com.", + ) is True + + +def test_host_binding_rejects_different_host(): + priv, pub = generate_keypair() + document = generate_identity_document( + private_key_b64=priv, + entity_uid="urn:sovp:host-test", + canonical_url="https://example.com", + ) + signature = document["integrity_proof"]["signature"] + + assert verify_identity( + document, + signature, + pub, + expected_host="attacker.example", + ) is False + + +def test_public_key_reference_uses_dns_hostname_without_port(): + priv, _ = generate_keypair() + document = generate_identity_document( + private_key_b64=priv, + entity_uid="urn:sovp:port-test", + canonical_url="https://example.com:8443/path", + ) + + assert document["integrity_proof"]["public_key_ref"] == "dns:txt:_sovp.example.com" + + +def test_dns_parser_requires_exact_sovp1_syntax(monkeypatch): + class FakeRdata: + def __init__(self, value): + self.strings = [value.encode("utf-8")] + + records = [ + FakeRdata("v=SOVP10; k=" + base64.b64encode(b"a" * 32).decode()), + FakeRdata("v=SOVP1; k=" + base64.b64encode(b"b" * 32).decode() + "; extra=x"), + FakeRdata("v=SOVP1; k=" + base64.b64encode(b"c" * 32).decode()), + ] + monkeypatch.setattr( + resolver.dns.resolver, + "resolve", + lambda name, rtype: records, + ) + + keys = resolver.resolve_dns_pubkeys("example.com") + assert keys == [base64.b64encode(b"c" * 32).decode()] + + +@pytest.mark.parametrize( + "record", + [ + "v=SOVP10; k=" + base64.b64encode(b"a" * 32).decode(), + "v=SOVP1", + "v=SOVP1; k=", + "v=SOVP1; k=invalid", + "v=SOVP1; k=" + base64.b64encode(b"a" * 31).decode(), + "v=SOVP1; k=" + base64.b64encode(b"a" * 32).decode() + "; k=" + base64.b64encode(b"b" * 32).decode(), + ], +) +def test_dns_parser_rejects_invalid_sovp_records(monkeypatch, record): + class FakeRdata: + def __init__(self, value): + self.strings = [value.encode("utf-8")] + + monkeypatch.setattr( + resolver.dns.resolver, + "resolve", + lambda name, rtype: [FakeRdata(record)], + ) + + with pytest.raises(resolver.SOVPResolverError): + resolver.resolve_dns_pubkeys("example.com") From 20db402aca294450ca3d738d709b14ae50e4ebb3 Mon Sep 17 00:00:00 2001 From: Thorsten Litzki <268185082+litzki-systems@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:51:27 -0600 Subject: [PATCH 03/18] fix: enforce Draft 04 DNS TXT syntax --- sovp/resolver.py | 92 ++++++++++++++++++++++++++++++------------------ 1 file changed, 57 insertions(+), 35 deletions(-) diff --git a/sovp/resolver.py b/sovp/resolver.py index a1ef881..ad7f7db 100644 --- a/sovp/resolver.py +++ b/sovp/resolver.py @@ -3,12 +3,18 @@ from __future__ import annotations -import requests -import dns.resolver +import base64 +import binascii + import dns.exception +import dns.resolver +import requests from .core import verify_identity -from .document_safety import parse_unverified_sovp_document, UnverifiedDocumentLimitError +from .document_safety import ( + UnverifiedDocumentLimitError, + parse_unverified_sovp_document, +) class SOVPResolverError(Exception): @@ -24,7 +30,7 @@ def fetch_identity_document(domain: str, timeout: int = 10) -> dict: """ primary_url = f"https://{domain}/.well-known/sovp-identity.json" fallback_url = f"https://{domain}/sovp-identity.json" - headers = {"User-Agent": "sovp-resolver/1.0.1"} + headers = {"User-Agent": "sovp-resolver/1.1.0"} try: resp = requests.get(primary_url, timeout=timeout, headers=headers) @@ -40,7 +46,9 @@ def fetch_identity_document(domain: str, timeout: int = 10) -> dict: raise SOVPResolverError(f"failed to retrieve {fallback_url}: {exc}") from exc if resp.status_code != 200: - raise SOVPResolverError(f"identity document returned HTTP {resp.status_code} at {url}") + raise SOVPResolverError( + f"identity document returned HTTP {resp.status_code} at {url}" + ) try: return parse_unverified_sovp_document(resp.text) @@ -50,15 +58,42 @@ def fetch_identity_document(domain: str, timeout: int = 10) -> dict: ) from exc +def _parse_sovp_txt_record(record: str) -> str | None: + """ + Parse the exact Draft 04 DNS TXT syntax. + + A matching record consists of exactly: + v=SOVP1; k= + """ + parts = [part.strip() for part in record.split(";")] + if len(parts) != 2 or parts[0] != "v=SOVP1" or not parts[1].startswith("k="): + return None + + key_b64 = parts[1][2:] + if not key_b64: + return None + + try: + key_bytes = base64.b64decode(key_b64, validate=True) + except (binascii.Error, ValueError): + return None + + if len(key_bytes) != 32: + return None + + return key_b64 + def resolve_dns_pubkeys(domain: str, max_keys: int = 4) -> list[str]: """ - Resolve _sovp.{domain} TXT records, returning every matching - v=SOVP1; k= key as an unordered set (draft-litzki-sovp-04 - "Multiple _sovp TXT records", key-rotation support), capped at - max_keys. Raises SOVPResolverError if no record matches or DNS - resolution fails. + Resolve _sovp.{domain} TXT records. + + All matching v=SOVP1 records form an unordered key set for rotation. + At most max_keys candidates are returned. """ + if max_keys < 1: + raise ValueError("max_keys must be at least 1") + txt_name = f"_sovp.{domain}" try: @@ -78,20 +113,14 @@ def resolve_dns_pubkeys(domain: str, max_keys: int = 4) -> list[str]: keys: list[str] = [] for rdata in answers: - # RFC 1035 allows a single TXT RDATA to hold multiple - # chunks; per draft Section "DNS TXT Record - # Format and Resolution", they MUST be concatenated before parsing. record = "".join( chunk.decode("utf-8") if isinstance(chunk, bytes) else chunk for chunk in rdata.strings ) - if not record.startswith("v=SOVP1"): + key = _parse_sovp_txt_record(record) + if key is None: continue - for part in record.split(";"): - part = part.strip() - if part.startswith("k="): - keys.append(part[2:].strip()) - break + keys.append(key) if len(keys) >= max_keys: break @@ -105,28 +134,21 @@ def resolve_dns_pubkeys(domain: str, max_keys: int = 4) -> list[str]: def resolve_dns_pubkey(domain: str) -> str: """ - Resolve _sovp.{domain} TXT record and return a single public key. - Kept for callers that only want one display/reference key, not a - verification decision; see resolve_dns_pubkeys() for the full, - rotation-aware set. Returns the first matching key. + Resolve _sovp.{domain} TXT record and return one public key. + + This helper is retained for callers that need one display/reference key. + Validation uses resolve_dns_pubkeys() so key rotation remains supported. """ return resolve_dns_pubkeys(domain, max_keys=1)[0] def validate_domain(domain: str, timeout: int = 10) -> dict: """ - Full pipeline: - 1. fetch_identity_document(domain) - 2. resolve_dns_pubkeys(domain). every published v=SOVP1 key - 3. verify_identity(document, signature, pubkey) against each, in order, - accepting on the first that verifies (draft-litzki-sovp-04 "Multiple - _sovp TXT records", key-rotation support) - Returns { - "domain": domain, - "psi_core": 1 or 0, - "document": dict, - "public_key_ref": "dns:txt:_sovp.{domain}" - } + Execute the reference validation pipeline. + + The document is retrieved first, the DNS key set is resolved, and each + published candidate key is tested until one verifies the signed document. + Host binding and v2.0 freshness are enforced by verify_identity(). """ document = fetch_identity_document(domain, timeout=timeout) candidate_keys = resolve_dns_pubkeys(domain) From dfca0a3625832a0fca53b7be6f02211b9ec9fa5e Mon Sep 17 00:00:00 2001 From: Thorsten Litzki <268185082+litzki-systems@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:51:33 -0600 Subject: [PATCH 04/18] fix: align signed-scope comments and DNS host reference --- sovp/core.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/sovp/core.py b/sovp/core.py index 194ac45..7b9517f 100644 --- a/sovp/core.py +++ b/sovp/core.py @@ -235,8 +235,10 @@ def generate_identity_document( Returns: dict: The complete signed sovp-identity.json document. """ - domain = urlparse(canonical_url).netloc - public_key_ref = f"dns:txt:_sovp.{domain}" + hostname = urlparse(canonical_url).hostname + if not hostname: + raise ValueError("canonical_url must contain a valid host") + public_key_ref = f"dns:txt:_sovp.{hostname.rstrip('.').lower()}" created = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") resolved_nonce = nonce if nonce is not None else str(uuid.uuid4()) resolved_expires_at = expires_at From 4ab1dbfef3a10a9813e4291320176216b4d0166a Mon Sep 17 00:00:00 2001 From: Thorsten Litzki <268185082+litzki-systems@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:51:38 -0600 Subject: [PATCH 05/18] docs: update live validation example for Draft 04 --- examples/validate_live.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/validate_live.py b/examples/validate_live.py index 9d6c7de..b9308e3 100644 --- a/examples/validate_live.py +++ b/examples/validate_live.py @@ -1,9 +1,9 @@ """ SOVP Live Validation Example -Demonstrates full pipeline: DNS TXT resolution + HTTP fetch + Ed25519 verify -against a production SOVP-certified domain. +Demonstrates the Draft 04 pipeline: DNS TXT resolution, HTTP retrieval, +host binding, freshness validation, and Ed25519 verification. -Protocol specification: draft-litzki-sovp-03 +Protocol specification: draft-litzki-sovp-04 https://datatracker.ietf.org/doc/draft-litzki-sovp/ """ From 5d7e3d695ae914977132dd5fc4b586b1cebdd4ec Mon Sep 17 00:00:00 2001 From: Thorsten Litzki <268185082+litzki-systems@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:51:45 -0600 Subject: [PATCH 06/18] docs: demonstrate Draft 04 host binding --- examples/end_to_end.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/end_to_end.py b/examples/end_to_end.py index 738ef32..09bf30b 100644 --- a/examples/end_to_end.py +++ b/examples/end_to_end.py @@ -40,7 +40,7 @@ # ── Step 3: Verify — Psi_core resonance check ───────────────────────────────── signature = document["integrity_proof"]["signature"] -psi_core = verify_identity(document, signature, public_key_b64) +psi_core = verify_identity(document, signature, public_key_b64, expected_host="example.com") print(f"Psi_core = {'1 ✓ Verified — ingestion may proceed.' if psi_core else '0 ✗ Blocked.'}") assert psi_core, "Verification should pass for an unmodified document." @@ -48,7 +48,7 @@ tampered = json.loads(json.dumps(document)) tampered["entity"]["canonical_url"] = "https://attacker.com" -psi_tampered = verify_identity(tampered, signature, public_key_b64) +psi_tampered = verify_identity(tampered, signature, public_key_b64, expected_host="example.com") print(f"Psi_core = {'1' if psi_tampered else '0 ✗ Tamper detected — ingestion blocked.'}") assert not psi_tampered, "Verification should fail after tampering." From d36d0be59338d7011941b17d9d1735151c8c50c8 Mon Sep 17 00:00:00 2001 From: Thorsten Litzki <268185082+litzki-systems@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:51:47 -0600 Subject: [PATCH 07/18] docs: update ARD example for Draft 04 --- examples/ard_trust_manifest.py | 78 ++++++++++++++-------------------- 1 file changed, 32 insertions(+), 46 deletions(-) diff --git a/examples/ard_trust_manifest.py b/examples/ard_trust_manifest.py index bb85836..aca33bc 100644 --- a/examples/ard_trust_manifest.py +++ b/examples/ard_trust_manifest.py @@ -2,29 +2,25 @@ # Copyright (c) 2026 Litzki Systems LLC # SPDX-License-Identifier: Apache-2.0 # -# Shows how a SOVP v1 entry looks inside an ai-catalog.json trustManifest. -# This is the pattern being proposed in ards-project/ard-spec issue #41. +# Shows how a SOVP v2.0 identity document can be referenced from an +# ai-catalog.json trustManifest entry. # # Run: python examples/ard_trust_manifest.py import hashlib import json -import sys import os +import sys sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) -from sovp.core import generate_keypair, generate_identity_document, verify_identity +import jcs -try: - from canonicaljson import encode_canonical_json -except ImportError: - print("Install canonicaljson: pip install canonicaljson", file=sys.stderr) - sys.exit(1) +from sovp.core import generate_keypair, generate_identity_document, verify_identity print("=== SOVP ARD trustManifest Example ===\n") -# ── Step 1: Produce a signed sovp-identity.json ──────────────────────────────── +# Step 1: Produce a signed schema v2.0 sovp-identity.json document. private_key_b64, public_key_b64 = generate_keypair() document = generate_identity_document( @@ -33,66 +29,56 @@ canonical_url="https://publisher.example", ) -# ── Step 2: Compute contentAddress over the non-proof, non-contentAddress fields -# -# The digest covers the same byte range that the Ed25519 signature covers: -# JCS(document minus integrity_proof minus contentAddress). -# This lets a catalog consumer verify content identity without re-running Ed25519. -signed_fields = {k: v for k, v in document.items() - if k not in ("integrity_proof", "contentAddress")} -canonical_bytes = encode_canonical_json(signed_fields) -digest_hex = hashlib.sha256(canonical_bytes).hexdigest() - -document["contentAddress"] = {"digest": f"sha256:{digest_hex}"} +digest_hex = document["contentAddress"]["digest"] -print("sovp-identity.json (with contentAddress):") +print("sovp-identity.json:") print(json.dumps(document, indent=2)) print() -# ── Step 3: Build the ai-catalog.json entry (trustManifest type) ─────────────── -# -# This is the structure an ARD-compliant catalog producer emits. -# The `contentAddress` here matches the one inside the SOVP document, -# giving downstream agents a single hash to pin before fetching the full -# identity document. +# Step 2: Build an ai-catalog.json reference. catalog_entry = { "schemaVersion": "ard/1.0", "type": "trustManifest", "uri": "https://publisher.example/.well-known/sovp-identity.json", "contentAddress": { - "digest": f"sha256:{digest_hex}" + "alg": "sha256", + "digest": digest_hex, }, "sovp": { - "draftVersion": "draft-litzki-sovp-03", + "draftVersion": "draft-litzki-sovp-04", "entityUid": document["entity"]["uid"], "canonicalUrl": document["entity"]["canonical_url"], - "publicKeyRef": document["integrity_proof"]["public_key_ref"] - } + "publicKeyRef": document["integrity_proof"]["public_key_ref"], + }, } print("ai-catalog.json entry (trustManifest):") print(json.dumps(catalog_entry, indent=2)) print() -# ── Step 4: Consuming agent verifies the chain ───────────────────────────────── -# -# 1. Fetch the SOVP document from catalog_entry["uri"]. -# 2. Recompute the digest and compare to catalog_entry["contentAddress"]["digest"]. -# 3. Run SOVP Psi_core check to confirm infrastructure trust. - -fetched_doc = document # in production: HTTP GET catalog_entry["uri"] +# Step 3: The consuming agent recomputes the unsigned content identity. +fetched_doc = document -recomputed_fields = {k: v for k, v in fetched_doc.items() - if k not in ("integrity_proof", "contentAddress")} -recomputed_bytes = encode_canonical_json(recomputed_fields) -recomputed_digest = "sha256:" + hashlib.sha256(recomputed_bytes).hexdigest() +recomputed_fields = { + key: value + for key, value in fetched_doc.items() + if key not in ("integrity_proof", "contentAddress", "scan") +} +recomputed_bytes = jcs.canonicalize(recomputed_fields) +recomputed_digest = hashlib.sha256(recomputed_bytes).hexdigest() digest_ok = recomputed_digest == catalog_entry["contentAddress"]["digest"] print(f"contentAddress match : {'PASS' if digest_ok else 'FAIL'}") +# Step 4: Verify the signed document and its host binding. signature = fetched_doc["integrity_proof"]["signature"] -psi_core = verify_identity(fetched_doc, signature, public_key_b64) +psi_core = verify_identity( + fetched_doc, + signature, + public_key_b64, + expected_host="publisher.example", +) print(f"Psi_core : {'1 VERIFIED' if psi_core else '0 BLOCKED'}") -assert digest_ok and psi_core, "Chain verification failed." -print("\n=== Done — full trustManifest chain verified ===") +assert digest_ok and psi_core, "Draft 04 verification chain failed." +print("\n=== Done — trustManifest reference verified ===") From 40c718b6ba3a95b807418503fcd960c64c2128fe Mon Sep 17 00:00:00 2001 From: Thorsten Litzki <268185082+litzki-systems@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:52:08 -0600 Subject: [PATCH 08/18] fix: make identity worker serve Draft 04 v2.0 documents --- workers/sovp-identity/index.mjs | 124 +++++++++++++++++++++----------- 1 file changed, 84 insertions(+), 40 deletions(-) diff --git a/workers/sovp-identity/index.mjs b/workers/sovp-identity/index.mjs index 9827b57..946b8f1 100644 --- a/workers/sovp-identity/index.mjs +++ b/workers/sovp-identity/index.mjs @@ -1,50 +1,94 @@ -const CERT = { - "@context": "https://litzki-systems.com/protocol/v1.4", - "@type": "SovereignIdentity", - "entity": { - "uid": "urn:sovp:litzki-systems.com", - "canonical_url": "https://litzki-systems.com", - "verification_method": "Ed25519" - }, - "integrity_proof": { - "signature": "ZHce8cCUSVdxCJW/s15ZUdvFgr1vSh0zSP3KK2hc4NcZA97Z0MtaZw0qx7tI6kAbER0NZdpeZy9gVSaYfo64AQ==", - "created": "2026-06-09T16:20:11.632Z", - "public_key_ref": "dns:txt:_sovp.litzki-systems.com", - "nonce": "aef6ead6-a5c3-412b-8d50-00bfe3685420" - }, - "scan": { - "verdict": "CERTIFIED", - "tScore": 92, - "readiness": 93, - "parameterCount": 268, - "integrityStatus": "VERIFIED", - "contentQuality": 86, - "aiReadiness": 28, - "infrastructureScore": 75, - "crawlerAccess": 1, - "machineDeclaration": 0.333, - "nodesScanned": 500, - "spec_url": "https://datatracker.ietf.org/doc/draft-litzki-sovp/", - "issuedAt": "2026-06-09T16:20:11.632Z", - "expiresAt": "2026-09-07T16:20:11.632Z" +const REQUIRED_CONTEXT = "https://litzki-systems.com/protocol/v2.0"; + +function loadIdentityDocument(env) { + if (!env.SOVP_IDENTITY_JSON) { + throw new Error("SOVP_IDENTITY_JSON secret is not configured"); } -}; + + let document; + try { + document = JSON.parse(env.SOVP_IDENTITY_JSON); + } catch { + throw new Error("SOVP_IDENTITY_JSON is not valid JSON"); + } + + if (document["@context"] !== REQUIRED_CONTEXT) { + throw new Error("identity document must use the Draft 04 v2.0 context"); + } + if (document["@type"] !== "SovereignIdentity") { + throw new Error("identity document must have @type SovereignIdentity"); + } + + const entity = document.entity; + if (!entity || typeof entity !== "object" || typeof entity.canonical_url !== "string") { + throw new Error("identity document requires entity.canonical_url"); + } + + const freshness = document.freshness; + if (!freshness || typeof freshness !== "object" || + typeof freshness.created !== "string" || typeof freshness.expiresAt !== "string") { + throw new Error("v2.0 identity document requires signed freshness"); + } + + const created = Date.parse(freshness.created); + const expiresAt = Date.parse(freshness.expiresAt); + if (!Number.isFinite(created) || !Number.isFinite(expiresAt) || expiresAt <= created) { + throw new Error("identity document contains invalid freshness timestamps"); + } + + const proof = document.integrity_proof; + if (!proof || typeof proof !== "object" || + typeof proof.signature !== "string" || typeof proof.public_key_ref !== "string") { + throw new Error("identity document requires an integrity_proof"); + } + + return document; +} + +function hostFromUrl(value) { + try { + return new URL(value).hostname.replace(/\.$/, "").toLowerCase(); + } catch { + return null; + } +} export default { - async fetch(request) { + async fetch(request, env) { const url = new URL(request.url); - if ( - url.pathname === '/sovp-identity.json' || - url.pathname === '/.well-known/sovp-identity.json' - ) { - return new Response(JSON.stringify(CERT, null, 2), { + + if (url.pathname !== "/sovp-identity.json" && + url.pathname !== "/.well-known/sovp-identity.json") { + return new Response("Not found", { status: 404 }); + } + + try { + const document = loadIdentityDocument(env); + const documentHost = hostFromUrl(document.entity.canonical_url); + const requestHost = url.hostname.replace(/\.$/, "").toLowerCase(); + + if (!documentHost || documentHost !== requestHost) { + throw new Error("identity document host does not match serving host"); + } + + return new Response(JSON.stringify(document, null, 2), { + headers: { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + "Cache-Control": "public, max-age=300", + }, + }); + } catch (error) { + return new Response(JSON.stringify({ + error: "SOVP identity endpoint is not configured with a valid Draft 04 document", + detail: error instanceof Error ? error.message : String(error), + }), { + status: 500, headers: { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*', - 'Cache-Control': 'public, max-age=3600', + "Content-Type": "application/json", + "Cache-Control": "no-store", }, }); } - return new Response('Not found', { status: 404 }); }, }; From 0cf41dfc7d86b7c62b3013be835853a3d2cb5a48 Mon Sep 17 00:00:00 2001 From: Thorsten Litzki <268185082+litzki-systems@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:52:10 -0600 Subject: [PATCH 09/18] docs: align identity worker with Draft 04 --- workers/sovp-identity/README.md | 69 +++++++++++++++++++++++++++------ 1 file changed, 58 insertions(+), 11 deletions(-) diff --git a/workers/sovp-identity/README.md b/workers/sovp-identity/README.md index c3c175f..606cc70 100644 --- a/workers/sovp-identity/README.md +++ b/workers/sovp-identity/README.md @@ -1,40 +1,87 @@ # SOVP Identity Worker -Cloudflare Worker reference deployment for a spec-compliant SOVP identity endpoint. +Cloudflare Worker reference deployment for a Draft 04 SOVP identity endpoint. -Serves `/.well-known/sovp-identity.json` and `/sovp-identity.json` with the correct -headers and a `SovereignIdentity` document signed per draft Section 4. +The worker serves a pre-signed schema v2.0 `sovp-identity.json` document at +`/.well-known/sovp-identity.json` and `/sovp-identity.json`. The signed document +is supplied through the Cloudflare secret `SOVP_IDENTITY_JSON`, so the repository +contains no private signing key and no stale static signature. ## What this demonstrates -- How to serve a spec-compliant `sovp-identity.json` at the well-known URI -- Ed25519 signature over JCS-canonicalized non-proof fields (RFC 8785) -- DNS TXT record reference format: `v=SOVP1; k=` +* Schema v2.0 with signed `freshness.created` and `freshness.expiresAt` +* Ed25519 signature over the JCS canonicalized signed scope +* `contentAddress` as an optional unsigned content identity +* DNS TXT reference format: `v=SOVP1; k=` +* Serving host validation against `entity.canonical_url` +* The well-known endpoint defined by Draft 04 -## Signing +The worker does not generate or verify the Ed25519 signature. Signature generation +belongs to the publisher. Generate the document with the reference implementation, +publish its public key in DNS, and provide the complete signed JSON document to the +worker as a secret. -The document embedded in `index.mjs` was signed using `sovp.core.sign_identity()`: +## Prepare a Draft 04 document + +Use `sovp-python` to generate a fresh v2.0 document: ```python from sovp.core import generate_keypair, generate_identity_document +import json + private_key_b64, public_key_b64 = generate_keypair() + document = generate_identity_document( private_key_b64=private_key_b64, entity_uid="urn:sovp:yourdomain.com", canonical_url="https://yourdomain.com", ) + +print(json.dumps(document, indent=2)) +print("Publish this public key in DNS:", public_key_b64) ``` -Publish `public_key_b64` in your DNS TXT record: +Publish the returned public key under: + +``` _sovp.yourdomain.com IN TXT "v=SOVP1; k=" +``` + +The DNS key and the document signature must come from the same keypair. + +## Configure the Worker -## Deploy +Store the complete generated JSON document as a Cloudflare Worker secret: + +```bash +wrangler secret put SOVP_IDENTITY_JSON +``` + +Then deploy: ```bash -npm install -g wrangler wrangler deploy ``` +Generate a fresh document before its signed freshness window expires and update +the secret as part of the deployment process. + +## Verification + +The repository reference resolver verifies: + +1. the well-known document retrieval, +2. the DNS-published Ed25519 key, +3. the serving-host binding, +4. the v2.0 freshness fields, +5. the Ed25519 signature over the JCS signed scope. + +Run the live validation example with: + +```bash +python examples/validate_live.py +``` + ## Protocol specification [draft-litzki-sovp](https://datatracker.ietf.org/doc/draft-litzki-sovp/) From 813234cc3847342a1d2347bb7e3733f8aec96bb2 Mon Sep 17 00:00:00 2001 From: Thorsten Litzki <268185082+litzki-systems@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:52:20 -0600 Subject: [PATCH 10/18] docs: align README.md with Draft 04 --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index cf27e06..37c15e1 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ cd sovp-python pip install -e . ``` -This installs the `sovp` CLI and the `sovp.core` library. Dependencies (`cryptography`, `canonicaljson`) are declared in `pyproject.toml`. +This installs the `sovp` CLI and the `sovp.core` library. Dependencies (`cryptography`, `jcs`, `dnspython`, `requests`) are declared in `pyproject.toml`. --- @@ -316,7 +316,7 @@ Result: VERIFIED — identity and integrity confirmed. | RFC conformance test vectors | Implemented — see `tests/test_vectors.py` | | Live validation (`validate_live.py`) | Implemented | | AgenTrust Marketplace integration | Implemented — see [sovp-agentrust-bridge](https://github.com/litzki-systems/sovp-agentrust-bridge) | -| Reference identity-endpoint deployment (Cloudflare Worker) | Implemented — see `workers/sovp-identity`, live at litzki-systems.com | +| Reference identity-endpoint deployment (Cloudflare Worker) | Implemented — see `workers/sovp-identity`; deployment requires a fresh Draft 04 document and matching DNS key | | Replay protection — nonce deduplication | Planned | | `SOVPIdentity` / `SOVPSigner` / `SOVPValidator` class API | Planned | | IETF Internet-Draft | [draft-litzki-sovp](https://datatracker.ietf.org/doc/draft-litzki-sovp/) — active | From 2fa6e1c650e50ac90f562469b7ab89f58c86a512 Mon Sep 17 00:00:00 2001 From: Thorsten Litzki <268185082+litzki-systems@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:52:22 -0600 Subject: [PATCH 11/18] docs: align SECURITY.md with Draft 04 --- SECURITY.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index ab716cd..448d7dd 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,7 +4,7 @@ | Version | Supported | |---|---| -| 1.0.x | Yes | +| 1.1.x | Yes | ## Reporting a Vulnerability @@ -23,7 +23,7 @@ the reporter. This policy covers: - The `sovp` Python package (`sovp.core`, `sovp.resolver`, `sovp.cli`) -- The SOVP protocol specification (`draft-litzki-sovp-03`) +- The SOVP protocol specification (`draft-litzki-sovp-04`) This policy does not cover third-party dependencies. Please report vulnerabilities in `cryptography`, `dnspython`, or `requests` to their From 7eff07df66ccd3486ce676f6b102711d6157d8c3 Mon Sep 17 00:00:00 2001 From: Thorsten Litzki <268185082+litzki-systems@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:52:26 -0600 Subject: [PATCH 12/18] docs: align CONTRIBUTING.md with Draft 04 --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cda5143..a262f7d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,7 +9,7 @@ This is the reference implementation of an open protocol. Contributions that imp - **Roadmap items** — features listed in the README Roadmap table - **Documentation** — clarifications, typo fixes, example corrections -If you want to propose a change to the protocol itself (not just this implementation), the right venue is the IETF draft: [draft-litzki-sovp-03](https://litzki-systems.com/sovp). +If you want to propose a change to the protocol itself (not just this implementation), the right venue is the IETF draft: [draft-litzki-sovp-04](https://litzki-systems.com/sovp). ## How to submit a change From 37e074cdd5764dd76563b978e0c68b5c68916672 Mon Sep 17 00:00:00 2001 From: Thorsten Litzki <268185082+litzki-systems@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:52:28 -0600 Subject: [PATCH 13/18] docs: complete Draft 04 changelog --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39ec43f..a89f429 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ Protocol specification: [draft-litzki-sovp-04](https://datatracker.ietf.org/doc/ ## [Unreleased] +### Changed +- `sovp/core.py` — Draft 04 host binding is enforced through `expected_host`; v2.0 freshness remains signed and expiry is always validated. Generated DNS key references use the URL hostname, excluding ports and normalizing trailing dots and case. +- `sovp/resolver.py` — DNS TXT parsing now accepts only the exact `v=SOVP1; k=` record shape and requires a valid 32-byte Ed25519 public key. Legacy endpoint fallback remains restricted to HTTP 404. +- `examples/` — live validation, end-to-end verification, and the ARD trustManifest example now identify Draft 04 explicitly and demonstrate v2.0 host binding and the current `contentAddress` shape. +- `workers/sovp-identity/` — the reference worker now serves a publisher-supplied, pre-signed Draft 04 v2.0 document from a Cloudflare secret instead of embedding a stale v1.4 artifact. + +### Tests +- Added Draft 04 conformance coverage for freshness clock-skew boundaries, the 600-second issuance window, signed unknown top-level members, unsigned `contentAddress` and `scan` extensions, host normalization, URL ports, and exact DNS TXT syntax. + + ### Added - `sovp/document_safety.py` — `parse_unverified_sovp_document()`, a minimal JSON parser enforcing draft-litzki-sovp-04's "Resource Limits for Unverified Documents": rejects a document larger than 64 KiB, nested deeper than 16 levels, or containing a duplicate member name in any object, before the document is trusted with any further processing. From 8bd9f013bffb797e44d31c9e4b1268020a18fec5 Mon Sep 17 00:00:00 2001 From: Thorsten Litzki <268185082+litzki-systems@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:52:56 -0600 Subject: [PATCH 14/18] test: update DNS rotation fixtures for strict keys --- tests/test_resolver.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/test_resolver.py b/tests/test_resolver.py index 6d483dd..4f5861f 100644 --- a/tests/test_resolver.py +++ b/tests/test_resolver.py @@ -90,28 +90,29 @@ def test_resolve_dns_pubkeys_returns_all_records_during_rotation(monkeypatch): Vorschlag B): a zone MAY publish more than one v=SOVP1 record while rotating keys; a verifier MUST treat them as an unordered set. """ - fake_answers = [_FakeRdata([b"v=SOVP1; k=OLDKEY"]), _FakeRdata([b"v=SOVP1; k=NEWKEY"])] + fake_answers = [_FakeRdata([b"v=SOVP1; k=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="]), _FakeRdata([b"v=SOVP1; k=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="])] monkeypatch.setattr(resolver.dns.resolver, "resolve", lambda name, rtype: fake_answers) keys = resolver.resolve_dns_pubkeys("example.com") - assert keys == ["OLDKEY", "NEWKEY"] + assert keys == ["AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="] def test_resolve_dns_pubkeys_caps_at_four_records(monkeypatch): - fake_answers = [_FakeRdata([f"v=SOVP1; k=K{i}".encode()]) for i in range(6)] + keys = [__import__("base64").b64encode(bytes([i]) * 32).decode() for i in range(6)] + fake_answers = [_FakeRdata([f"v=SOVP1; k={key}".encode()]) for key in keys] monkeypatch.setattr(resolver.dns.resolver, "resolve", lambda name, rtype: fake_answers) keys = resolver.resolve_dns_pubkeys("example.com") - assert keys == ["K0", "K1", "K2", "K3"] + assert keys == [__import__("base64").b64encode(bytes([i]) * 32).decode() for i in range(4)] def test_resolve_dns_pubkey_returns_first_of_several_records(monkeypatch): fake_answers = [_FakeRdata([b"v=SOVP1; k=OLDKEY"]), _FakeRdata([b"v=SOVP1; k=NEWKEY"])] monkeypatch.setattr(resolver.dns.resolver, "resolve", lambda name, rtype: fake_answers) - assert resolver.resolve_dns_pubkey("example.com") == "OLDKEY" + assert resolver.resolve_dns_pubkey("example.com") == "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" def test_validate_domain_accepts_document_signed_under_second_rotated_key(monkeypatch): From 031a90ddb275250725d04833c6b4359357282922 Mon Sep 17 00:00:00 2001 From: Thorsten Litzki <268185082+litzki-systems@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:53:02 -0600 Subject: [PATCH 15/18] test: clean up DNS fixture helpers --- tests/test_resolver.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_resolver.py b/tests/test_resolver.py index 4f5861f..c678aab 100644 --- a/tests/test_resolver.py +++ b/tests/test_resolver.py @@ -1,6 +1,8 @@ # Copyright (c) 2026 Litzki Systems LLC # SPDX-License-Identifier: Apache-2.0 +import base64 + import pytest import sovp.resolver as resolver @@ -99,7 +101,7 @@ def test_resolve_dns_pubkeys_returns_all_records_during_rotation(monkeypatch): def test_resolve_dns_pubkeys_caps_at_four_records(monkeypatch): - keys = [__import__("base64").b64encode(bytes([i]) * 32).decode() for i in range(6)] + keys = [base64.b64encode(bytes([i]) * 32).decode() for i in range(6)] fake_answers = [_FakeRdata([f"v=SOVP1; k={key}".encode()]) for key in keys] monkeypatch.setattr(resolver.dns.resolver, "resolve", lambda name, rtype: fake_answers) From 6b86adf15c9491bb43f372441c86454211af561b Mon Sep 17 00:00:00 2001 From: Thorsten Litzki <268185082+litzki-systems@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:53:19 -0600 Subject: [PATCH 16/18] test: correct Draft 04 boundary fixtures --- tests/test_draft04_conformance.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tests/test_draft04_conformance.py b/tests/test_draft04_conformance.py index 65dd3b5..4d8ff98 100644 --- a/tests/test_draft04_conformance.py +++ b/tests/test_draft04_conformance.py @@ -135,9 +135,20 @@ def test_v2_issuance_window_rejects_beyond_clock_skew(): def test_unknown_top_level_members_are_in_signed_scope(): priv, pub = generate_keypair() - document, _ = _v2_document() + document = generate_identity_document( + private_key_b64=priv, + entity_uid="urn:sovp:signed-extension-test", + canonical_url="https://example.com", + nonce="signed-extension-test", + expires_at="2026-12-31T23:59:59Z", + ) document["vendor_extension"] = {"mode": "example"} - document, signature = _sign_document(priv, document) + signature = sign_identity( + priv, + {key: value for key, value in document.items() + if key not in ("integrity_proof", "contentAddress", "scan")}, + ) + document["integrity_proof"]["signature"] = signature assert verify_identity(document, signature, pub) is True From a6b986dd2ffbcab4913a361d81b91bf43944a6da Mon Sep 17 00:00:00 2001 From: Thorsten Litzki <268185082+litzki-systems@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:53:36 -0600 Subject: [PATCH 17/18] test: fix remaining strict DNS fixtures --- tests/test_resolver.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_resolver.py b/tests/test_resolver.py index c678aab..9485177 100644 --- a/tests/test_resolver.py +++ b/tests/test_resolver.py @@ -107,11 +107,11 @@ def test_resolve_dns_pubkeys_caps_at_four_records(monkeypatch): keys = resolver.resolve_dns_pubkeys("example.com") - assert keys == [__import__("base64").b64encode(bytes([i]) * 32).decode() for i in range(4)] + assert keys == [base64.b64encode(bytes([i]) * 32).decode() for i in range(4)] def test_resolve_dns_pubkey_returns_first_of_several_records(monkeypatch): - fake_answers = [_FakeRdata([b"v=SOVP1; k=OLDKEY"]), _FakeRdata([b"v=SOVP1; k=NEWKEY"])] + fake_answers = [_FakeRdata([b"v=SOVP1; k=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="]), _FakeRdata([b"v=SOVP1; k=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="])] monkeypatch.setattr(resolver.dns.resolver, "resolve", lambda name, rtype: fake_answers) assert resolver.resolve_dns_pubkey("example.com") == "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" From 9846444f2caf4807a2adff10e08bd8778e8d540e Mon Sep 17 00:00:00 2001 From: Thorsten Litzki <268185082+litzki-systems@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:53:42 -0600 Subject: [PATCH 18/18] test: align freshness boundary timestamps --- tests/test_draft04_conformance.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_draft04_conformance.py b/tests/test_draft04_conformance.py index 4d8ff98..68a922e 100644 --- a/tests/test_draft04_conformance.py +++ b/tests/test_draft04_conformance.py @@ -82,6 +82,7 @@ def test_v2_freshness_future_clock_skew_exceeded(): def test_v2_freshness_expiry_clock_skew_boundary(): priv, pub = generate_keypair() document, _ = _v2_document() + document["freshness"]["created"] = "2026-10-08T00:58:50Z" document["freshness"]["expiresAt"] = "2026-10-08T00:59:01Z" document, signature = _sign_document(priv, document) @@ -108,7 +109,7 @@ def test_v2_freshness_expiry_clock_skew_exceeded(): def test_v2_issuance_window_clock_skew_boundary(): priv, pub = generate_keypair() document, _ = _v2_document() - document["freshness"]["created"] = "2026-10-07T23:49:00Z" + document["freshness"]["created"] = "2026-10-08T00:49:00Z" document["freshness"]["expiresAt"] = "2026-10-08T02:00:00Z" document, signature = _sign_document(priv, document) @@ -122,7 +123,7 @@ def test_v2_issuance_window_clock_skew_boundary(): def test_v2_issuance_window_rejects_beyond_clock_skew(): priv, pub = generate_keypair() document, _ = _v2_document() - document["freshness"]["created"] = "2026-10-07T23:48:59Z" + document["freshness"]["created"] = "2026-10-08T00:48:59Z" document["freshness"]["expiresAt"] = "2026-10-08T02:00:00Z" document, signature = _sign_document(priv, document)