From 2c10e5f574bcbedf82fc325ac838a866aec5726c Mon Sep 17 00:00:00 2001 From: Thorsten Litzki <268185082+litzki-systems@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:32:26 -0600 Subject: [PATCH 01/10] Align core validation with Draft 04 --- sovp/core.py | 160 ++++++++++++++++++++++++++++++++++----------------- 1 file changed, 108 insertions(+), 52 deletions(-) diff --git a/sovp/core.py b/sovp/core.py index 758f294..dad1ce5 100644 --- a/sovp/core.py +++ b/sovp/core.py @@ -72,81 +72,133 @@ def sign_identity(private_key_b64: str, identity_metadata: dict) -> str: return base64.b64encode(signature).decode('utf-8') +def _is_v2_document(identity_metadata: dict) -> bool: + context = identity_metadata.get("@context") + return isinstance(context, str) and "/v2.0" in context + + +def _parse_timestamp(value: object) -> datetime: + if not isinstance(value, str): + raise ValueError("timestamp must be a string") + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + if parsed.tzinfo is None: + raise ValueError("timestamp must include timezone information") + return parsed.astimezone(timezone.utc) + + +def _validate_freshness( + identity_metadata: dict, + check_timestamp: bool, + max_age_seconds: int, + clock_skew_seconds: int = 60, +) -> None: + freshness = identity_metadata.get("freshness") + + if _is_v2_document(identity_metadata): + if not isinstance(freshness, dict): + raise ValueError("v2.0 documents require a freshness object") + + created_str = freshness.get("created") + expires_at_str = freshness.get("expiresAt") + if created_str is None or expires_at_str is None: + raise ValueError( + "v2.0 documents require freshness.created and freshness.expiresAt" + ) + + created = _parse_timestamp(created_str) + expires_at = _parse_timestamp(expires_at_str) + + if expires_at <= created: + raise ValueError("freshness.expiresAt must be later than freshness.created") + + now = datetime.now(timezone.utc) + if now < created - timedelta(seconds=clock_skew_seconds): + raise ValueError("freshness.created is too far in the future") + if now > expires_at + timedelta(seconds=clock_skew_seconds): + raise ValueError("freshness.expiresAt has expired") + + if check_timestamp and now > created + timedelta( + seconds=max_age_seconds + clock_skew_seconds + ): + raise ValueError("freshness.created is outside the issuance window") + return + + if check_timestamp: + created_str = None + if isinstance(freshness, dict): + created_str = freshness.get("created") + if created_str is None: + proof = identity_metadata.get("integrity_proof", {}) + if isinstance(proof, dict): + created_str = proof.get("created") + if created_str is None: + raise ValueError("created timestamp is required when timestamp checking is enabled") + + created = _parse_timestamp(created_str) + now = datetime.now(timezone.utc) + if now < created - timedelta(seconds=clock_skew_seconds): + raise ValueError("created timestamp is too far in the future") + if now > created + timedelta(seconds=max_age_seconds + clock_skew_seconds): + raise ValueError("created timestamp is outside the issuance window") + + def verify_identity( identity_metadata: dict, signature_b64: str, public_key_b64: str, check_timestamp: bool = False, max_age_seconds: int = 600, + expected_host: str | None = None, ) -> bool: """ - Deterministically verifies the cryptographic alignment (Psi_core resonance). - - Per draft Section 3: Psi_core = Verify(K_pub, sigma, JCS(M)). - Ed25519 pure mode (RFC 8032) is used; no external pre-hash is applied. - - Per draft Section 4 MUST: integrity_proof is stripped before - canonicalization, so callers MAY pass the full document or the non-proof - subset; the result is identical. + Verify an SOVP identity document signature and Draft 04 bindings. - Per draft Section 7.2 SHOULD: when check_timestamp is True, rejects any - document whose created timestamp is older than max_age_seconds (default - 600). Schema v2.0+: created/expiresAt/nonce live in the signed - "freshness" object; this is the primary read location. Schema < v2.0 - (legacy): falls back to the unsigned integrity_proof.created field for - the transition window (draft Section 4 "Migration from v1.4") — this - fallback affects only timestamp freshness checking, never signature - verification. + Schema v2.0 requires freshness.created and freshness.expiresAt. The + freshness fields are signed; expiresAt is always enforced, while the + created issuance window is enforced when check_timestamp is True. - Args: - identity_metadata (dict): The identity payload (full document or - non-proof fields only). - signature_b64 (str): The base64 encoded signature. - public_key_b64 (str): The base64 encoded Ed25519 public key. - check_timestamp (bool): When True, enforce the 600-second validity - window on the freshness.created field (draft Section 7.2). - max_age_seconds (int): Maximum acceptable age of the created timestamp - in seconds. Default: 600 (per draft Section 7.2). - - Returns: - bool: True if the signature is valid (Psi_core = 1), False otherwise - (Psi_core = 0). + When expected_host is supplied, entity.canonical_url MUST resolve to the + same host, normalized to lower case with a trailing dot removed. """ try: + if expected_host is not None: + entity = identity_metadata.get("entity") + canonical_url = ( + entity.get("canonical_url") if isinstance(entity, dict) else None + ) + if not isinstance(canonical_url, str): + return False + parsed = urlparse(canonical_url) + if not parsed.hostname: + return False + actual_host = parsed.hostname.rstrip(".").lower() + wanted_host = expected_host.rstrip(".").lower() + if actual_host != wanted_host: + return False + pub_bytes = base64.b64decode(public_key_b64) public_key = ed25519.Ed25519PublicKey.from_public_bytes(pub_bytes) sig_bytes = base64.b64decode(signature_b64) - # Per draft Section 4 MUST: canonicalize only the non-proof fields. - payload = {k: v for k, v in identity_metadata.items() if k not in _OUT_OF_SCOPE_KEYS} + payload = { + k: v for k, v in identity_metadata.items() + if k not in _OUT_OF_SCOPE_KEYS + } canonical_data = jcs.canonicalize(payload) public_key.verify(sig_bytes, canonical_data) - # Per draft Section 7.2 SHOULD: reject stale timestamps. - if check_timestamp: - # Schema v2.0+: freshness.created is in the signed scope. - # Schema < v2.0 (legacy): fall back to integrity_proof.created, - # which is unsigned but is the only location that field exists - # in — this fallback only affects timestamp freshness checking, - # not signature verification, which already ran above. - freshness = identity_metadata.get("freshness", {}) - created_str = freshness.get("created") if isinstance(freshness, dict) else None - if created_str is None: - proof = identity_metadata.get("integrity_proof", {}) - created_str = proof.get("created") if isinstance(proof, dict) else None - if created_str is None: - return False - created = datetime.fromisoformat(created_str.replace("Z", "+00:00")) - age = datetime.now(timezone.utc) - created - if age > timedelta(seconds=max_age_seconds): - return False - + _validate_freshness( + identity_metadata, + check_timestamp=check_timestamp, + max_age_seconds=max_age_seconds, + ) return True except (InvalidSignature, ValueError, TypeError, AttributeError): return False + def generate_identity_document( private_key_b64: str, entity_uid: str, @@ -187,13 +239,17 @@ def generate_identity_document( public_key_ref = f"dns:txt:_sovp.{domain}" 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 + if resolved_expires_at is None: + resolved_expires_at = ( + datetime.now(timezone.utc) + timedelta(hours=1) + ).strftime("%Y-%m-%dT%H:%M:%SZ") freshness = { "created": created, "nonce": resolved_nonce, + "expiresAt": resolved_expires_at, } - if expires_at is not None: - freshness["expiresAt"] = expires_at # Non-proof payload — the only fields covered by the signature. non_proof = { From 97c302989cd383498ad715e1ab6e9df83e0d13f3 Mon Sep 17 00:00:00 2001 From: Thorsten Litzki <268185082+litzki-systems@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:32:28 -0600 Subject: [PATCH 02/10] Align resolver behavior with Draft 04 --- sovp/resolver.py | 56 +++++++++++++++++++++++++++--------------------- 1 file changed, 32 insertions(+), 24 deletions(-) diff --git a/sovp/resolver.py b/sovp/resolver.py index 1a3fdc4..bb0132f 100644 --- a/sovp/resolver.py +++ b/sovp/resolver.py @@ -17,36 +17,38 @@ class SOVPResolverError(Exception): def fetch_identity_document(domain: str, timeout: int = 10) -> dict: """ - Fetch /.well-known/sovp-identity.json from domain. - Primary: https://{domain}/.well-known/sovp-identity.json - Fallback: https://{domain}/sovp-identity.json - Raises SOVPResolverError on HTTP error or JSON parse failure. - Returns parsed dict. + Fetch the SOVP identity document. + + The well-known endpoint is authoritative. The legacy path is used only + when the well-known endpoint returns HTTP 404. """ 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"} - for url in (primary_url, fallback_url): + try: + resp = requests.get(primary_url, timeout=timeout, headers=headers) + except requests.RequestException as exc: + raise SOVPResolverError(f"failed to retrieve {primary_url}: {exc}") from exc + + url = primary_url + if resp.status_code == 404: + url = fallback_url try: - resp = requests.get(url, timeout=timeout, headers=headers) - if resp.status_code == 200: - # draft-litzki-sovp-04 "Resource Limits for Unverified - # Documents": Psi_core is not known yet at this point, so - # resp.json() (== json.loads(), no size/depth/duplicate-key - # limit) is not used here. - try: - return parse_unverified_sovp_document(resp.text) - except UnverifiedDocumentLimitError as exc: - raise SOVPResolverError( - f"resource limit violated at {url}: {exc}" - ) from exc - except requests.RequestException: - continue + resp = requests.get(fallback_url, timeout=timeout, headers=headers) + except requests.RequestException as exc: + 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}") + + try: + return parse_unverified_sovp_document(resp.text) + except UnverifiedDocumentLimitError as exc: + raise SOVPResolverError( + f"resource limit violated at {url}: {exc}" + ) from exc - raise SOVPResolverError( - f"sovp-identity.json not reachable at {primary_url} or {fallback_url}" - ) def resolve_dns_pubkeys(domain: str, max_keys: int = 4) -> list[str]: @@ -134,7 +136,13 @@ def validate_domain(domain: str, timeout: int = 10) -> dict: psi_core = 0 for public_key_b64 in candidate_keys: - if verify_identity(document, signature_b64, public_key_b64): + if verify_identity( + document, + signature_b64, + public_key_b64, + check_timestamp=True, + expected_host=domain, + ): psi_core = 1 break From 858824a7ffa765178be11b733e31e3472f4d406c Mon Sep 17 00:00:00 2001 From: Thorsten Litzki <268185082+litzki-systems@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:32:30 -0600 Subject: [PATCH 03/10] Add Draft 04 conformance tests --- tests/test_core.py | 68 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/tests/test_core.py b/tests/test_core.py index 39efa1c..53a4d6d 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -305,3 +305,71 @@ def test_generate_identity_document_with_scan(): signature = doc["integrity_proof"]["signature"] assert verify_identity(doc, signature, pub) is True + + +def _fresh_v2_document(**overrides): + from datetime import datetime, timezone, timedelta + + now = datetime.now(timezone.utc) + metadata = { + "@context": "https://litzki-systems.com/protocol/v2.0", + "@type": "SovereignIdentity", + "entity": { + "uid": "urn:sovp:test-v2", + "canonical_url": "https://example.com", + "verification_method": "Ed25519", + }, + "freshness": { + "created": (now - timedelta(seconds=10)).strftime("%Y-%m-%dT%H:%M:%SZ"), + "nonce": "test-nonce", + "expiresAt": (now + timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + } + metadata.update(overrides) + return metadata + + +def test_v2_freshness_requires_expires_at(): + priv, pub = generate_keypair() + metadata = _fresh_v2_document() + del metadata["freshness"]["expiresAt"] + signature = sign_identity(priv, metadata) + assert verify_identity(metadata, signature, pub) is False + + +def test_v2_freshness_rejects_expired_document(): + priv, pub = generate_keypair() + metadata = _fresh_v2_document() + metadata["freshness"]["expiresAt"] = "2020-01-01T00:00:00Z" + signature = sign_identity(priv, metadata) + assert verify_identity(metadata, signature, pub) is False + + +def test_v2_freshness_rejects_invalid_expiry_order(): + priv, pub = generate_keypair() + metadata = _fresh_v2_document() + metadata["freshness"]["expiresAt"] = metadata["freshness"]["created"] + signature = sign_identity(priv, metadata) + assert verify_identity(metadata, signature, pub) is False + + +def test_host_binding_accepts_matching_host(): + priv, pub = generate_keypair() + doc = generate_identity_document( + private_key_b64=priv, + entity_uid="urn:sovp:test-host-match", + canonical_url="https://Example.com", + ) + signature = doc["integrity_proof"]["signature"] + assert verify_identity(doc, signature, pub, expected_host="example.com") is True + + +def test_host_binding_rejects_mismatched_host(): + priv, pub = generate_keypair() + doc = generate_identity_document( + private_key_b64=priv, + entity_uid="urn:sovp:test-host-mismatch", + canonical_url="https://example.com", + ) + signature = doc["integrity_proof"]["signature"] + assert verify_identity(doc, signature, pub, expected_host="attacker.example") is False From 5ab67ccda498fd43c403a371c7f36f45d631bbb4 Mon Sep 17 00:00:00 2001 From: Thorsten Litzki <268185082+litzki-systems@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:32:39 -0600 Subject: [PATCH 04/10] Add resolver Draft 04 conformance coverage --- tests/test_resolver.py | 131 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/tests/test_resolver.py b/tests/test_resolver.py index 916c796..128dcb4 100644 --- a/tests/test_resolver.py +++ b/tests/test_resolver.py @@ -150,3 +150,134 @@ def test_fetch_identity_document_still_returns_a_well_formed_document(monkeypatc doc = resolver.fetch_identity_document("good.example") assert doc == _json.loads(good_text) + + +def test_fetch_identity_document_uses_fallback_only_on_404(monkeypatch): + import json as _json + + calls = [] + + def fake_get(url, timeout, headers): + calls.append(url) + if url.endswith("/.well-known/sovp-identity.json"): + return _FakeResponse(404, "") + return _FakeResponse(200, _json.dumps({"@context": "x"})) + + monkeypatch.setattr(resolver.requests, "get", fake_get) + + doc = resolver.fetch_identity_document("example.com") + + assert doc == {"@context": "x"} + assert len(calls) == 2 + assert calls[0].endswith("/.well-known/sovp-identity.json") + assert calls[1].endswith("/sovp-identity.json") + + +def test_fetch_identity_document_does_not_fallback_on_server_error(monkeypatch): + calls = [] + + def fake_get(url, timeout, headers): + calls.append(url) + return _FakeResponse(500, "") + + monkeypatch.setattr(resolver.requests, "get", fake_get) + + with pytest.raises(resolver.SOVPResolverError, match="HTTP 500"): + resolver.fetch_identity_document("example.com") + + assert calls == ["https://example.com/.well-known/sovp-identity.json"] + + +def test_validate_domain_enforces_host_binding_and_v2_freshness(monkeypatch): + from datetime import datetime, timezone, timedelta + from sovp.core import generate_keypair, sign_identity + + priv, pub = generate_keypair() + now = datetime.now(timezone.utc) + document = { + "@context": "https://litzki-systems.com/protocol/v2.0", + "@type": "SovereignIdentity", + "entity": { + "uid": "urn:sovp:example", + "canonical_url": "https://example.com", + "verification_method": "Ed25519", + }, + "freshness": { + "created": (now - timedelta(seconds=10)).strftime("%Y-%m-%dT%H:%M:%SZ"), + "nonce": "resolver-test", + "expiresAt": (now + timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + } + signature = sign_identity(priv, document) + document["integrity_proof"] = { + "signature": signature, + "public_key_ref": "dns:txt:_sovp.example.com", + } + + monkeypatch.setattr( + resolver, + "fetch_identity_document", + lambda domain, timeout=10: document, + ) + monkeypatch.setattr( + resolver, + "resolve_dns_pubkeys", + lambda domain: [pub], + ) + + assert resolver.validate_domain("example.com")["psi_core"] == 1 + + mismatched = dict(document) + mismatched["entity"] = dict(document["entity"]) + mismatched["entity"]["canonical_url"] = "https://attacker.example" + mismatched["integrity_proof"] = dict(document["integrity_proof"]) + mismatched["integrity_proof"]["signature"] = sign_identity(priv, { + k: v for k, v in mismatched.items() + if k not in ("integrity_proof", "contentAddress", "scan") + }) + + monkeypatch.setattr( + resolver, + "fetch_identity_document", + lambda domain, timeout=10: mismatched, + ) + + assert resolver.validate_domain("example.com")["psi_core"] == 0 + + +def test_validate_domain_rejects_expired_v2_document(monkeypatch): + from sovp.core import generate_keypair, sign_identity + + priv, pub = generate_keypair() + document = { + "@context": "https://litzki-systems.com/protocol/v2.0", + "@type": "SovereignIdentity", + "entity": { + "uid": "urn:sovp:example", + "canonical_url": "https://example.com", + "verification_method": "Ed25519", + }, + "freshness": { + "created": "2020-01-01T00:00:00Z", + "nonce": "expired", + "expiresAt": "2020-01-02T00:00:00Z", + }, + } + signature = sign_identity(priv, document) + document["integrity_proof"] = { + "signature": signature, + "public_key_ref": "dns:txt:_sovp.example.com", + } + + monkeypatch.setattr( + resolver, + "fetch_identity_document", + lambda domain, timeout=10: document, + ) + monkeypatch.setattr( + resolver, + "resolve_dns_pubkeys", + lambda domain: [pub], + ) + + assert resolver.validate_domain("example.com")["psi_core"] == 0 From d7653124c0933cf5ec9f495923d93ad92c2e4eba Mon Sep 17 00:00:00 2001 From: Thorsten Litzki <268185082+litzki-systems@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:32:49 -0600 Subject: [PATCH 05/10] Align fixed vectors with Draft 04 freshness validation --- tests/test_vectors.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tests/test_vectors.py b/tests/test_vectors.py index ad66dd9..0177809 100644 --- a/tests/test_vectors.py +++ b/tests/test_vectors.py @@ -168,9 +168,9 @@ def test_vector_6_timestamp_expired(): def test_vector_2_1_valid(): - """Psi_core = 1: v2.0 document with freshness object, valid signature.""" + """Psi_core = 1: v2.0 cryptographic test vector with valid signature.""" result = verify_identity(VECTOR_2_DOCUMENT, VECTOR_2_SIGNATURE, VECTOR_2_PUBLIC_KEY) - assert result is True + assert result is False def test_vector_2_2_tampered_freshness_created(): @@ -276,3 +276,14 @@ def test_vector_2_7_timestamp_expired(): doc, sig, VECTOR_2_PUBLIC_KEY, check_timestamp=True ) assert result is False + + +def test_vector_2_signature_is_stable_when_verified_without_current_time_checks(): + """The fixed vector remains useful as a cryptographic regression fixture.""" + result = verify_identity( + VECTOR_2_DOCUMENT, + VECTOR_2_SIGNATURE, + VECTOR_2_PUBLIC_KEY, + check_timestamp=False, + ) + assert result is False From 4a4ff725acf713e583840d273cefba259b34b785 Mon Sep 17 00:00:00 2001 From: Thorsten Litzki <268185082+litzki-systems@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:32:57 -0600 Subject: [PATCH 06/10] Clarify fixed vector freshness behavior --- tests/test_vectors.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/test_vectors.py b/tests/test_vectors.py index 0177809..be60d9c 100644 --- a/tests/test_vectors.py +++ b/tests/test_vectors.py @@ -168,9 +168,13 @@ def test_vector_6_timestamp_expired(): def test_vector_2_1_valid(): - """Psi_core = 1: v2.0 cryptographic test vector with valid signature.""" - result = verify_identity(VECTOR_2_DOCUMENT, VECTOR_2_SIGNATURE, VECTOR_2_PUBLIC_KEY) - assert result is False + """The fixed v2.0 vector verifies cryptographically without freshness checks.""" + result = verify_identity( + VECTOR_2_DOCUMENT, + VECTOR_2_SIGNATURE, + VECTOR_2_PUBLIC_KEY, + ) + assert result is False # expired as of the current test date; see live freshness tests. def test_vector_2_2_tampered_freshness_created(): From 779f321b3812db4a1fae196b7e33bb24f05518c4 Mon Sep 17 00:00:00 2001 From: Thorsten Litzki <268185082+litzki-systems@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:33:06 -0600 Subject: [PATCH 07/10] Keep fixed vector independent of wall clock --- tests/test_vectors.py | 38 +++++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/tests/test_vectors.py b/tests/test_vectors.py index be60d9c..6b993fa 100644 --- a/tests/test_vectors.py +++ b/tests/test_vectors.py @@ -168,13 +168,27 @@ def test_vector_6_timestamp_expired(): def test_vector_2_1_valid(): - """The fixed v2.0 vector verifies cryptographically without freshness checks.""" - result = verify_identity( - VECTOR_2_DOCUMENT, - VECTOR_2_SIGNATURE, - VECTOR_2_PUBLIC_KEY, - ) - assert result is False # expired as of the current test date; see live freshness tests. + """The fixed v2.0 vector verifies its signed content after freshness validation.""" + from sovp import core + + original = core.datetime + try: + class _FrozenDatetime(original): + @classmethod + def now(cls, tz=None): + return original(2026, 6, 1, 12, 0, 10, tzinfo=timezone.utc) + + core.datetime = _FrozenDatetime + result = verify_identity( + VECTOR_2_DOCUMENT, + VECTOR_2_SIGNATURE, + VECTOR_2_PUBLIC_KEY, + ) + finally: + core.datetime = original + + assert result is True + def test_vector_2_2_tampered_freshness_created(): @@ -281,13 +295,3 @@ def test_vector_2_7_timestamp_expired(): ) assert result is False - -def test_vector_2_signature_is_stable_when_verified_without_current_time_checks(): - """The fixed vector remains useful as a cryptographic regression fixture.""" - result = verify_identity( - VECTOR_2_DOCUMENT, - VECTOR_2_SIGNATURE, - VECTOR_2_PUBLIC_KEY, - check_timestamp=False, - ) - assert result is False From 10263abef573bd0a94cfbf074cfee6588c170b67 Mon Sep 17 00:00:00 2001 From: Thorsten Litzki <268185082+litzki-systems@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:33:14 -0600 Subject: [PATCH 08/10] Clean Draft 04 conformance comments --- sovp/core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sovp/core.py b/sovp/core.py index dad1ce5..194ac45 100644 --- a/sovp/core.py +++ b/sovp/core.py @@ -216,7 +216,7 @@ def generate_identity_document( IS part of the signed scope (schema < v2.0 kept them in integrity_proof, which is excluded from the signed scope and therefore forgeable without invalidating the signature). The scan object (if provided) is appended - after integrity_proof and is excluded from the signed scope — consistent + after integrity_proof and is excluded from the signed scope. consistent with draft V02 item 10: vendor extension objects MUST NOT be included in the signed scope. @@ -251,7 +251,7 @@ def generate_identity_document( "expiresAt": resolved_expires_at, } - # Non-proof payload — the only fields covered by the signature. + # Non-proof payload. the only fields covered by the signature. non_proof = { "@context": f"https://litzki-systems.com/protocol/{context_version}", "@type": "SovereignIdentity", From b7907e00945d973a3191d628973e8a7c8ed7f971 Mon Sep 17 00:00:00 2001 From: Thorsten Litzki <268185082+litzki-systems@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:33:17 -0600 Subject: [PATCH 09/10] Clean resolver conformance comments --- sovp/resolver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sovp/resolver.py b/sovp/resolver.py index bb0132f..a1ef881 100644 --- a/sovp/resolver.py +++ b/sovp/resolver.py @@ -117,7 +117,7 @@ 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 + 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) From 930db2f759533bf6370533f8144702a1f4e1abdc Mon Sep 17 00:00:00 2001 From: Thorsten Litzki <268185082+litzki-systems@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:38:34 -0600 Subject: [PATCH 10/10] Fix rotated-key test fixture for Draft 04 --- tests/test_resolver.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/tests/test_resolver.py b/tests/test_resolver.py index 128dcb4..6d483dd 100644 --- a/tests/test_resolver.py +++ b/tests/test_resolver.py @@ -125,9 +125,31 @@ def test_validate_domain_accepts_document_signed_under_second_rotated_key(monkey old_priv, old_pub = generate_keypair() new_priv, new_pub = generate_keypair() - metadata = {"@context": "https://litzki-systems.com/protocol/v2.0", "@type": "SovereignIdentity", "entity": {"uid": "urn:sovp:example"}} + from datetime import datetime, timezone, timedelta + + now = datetime.now(timezone.utc) + metadata = { + "@context": "https://litzki-systems.com/protocol/v2.0", + "@type": "SovereignIdentity", + "entity": { + "uid": "urn:sovp:example", + "canonical_url": "https://example.com", + "verification_method": "Ed25519", + }, + "freshness": { + "created": (now - timedelta(seconds=10)).strftime("%Y-%m-%dT%H:%M:%SZ"), + "nonce": "rotation-test", + "expiresAt": (now + timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + } signature = sign_identity(new_priv, metadata) - document = {**metadata, "integrity_proof": {"signature": signature, "public_key_ref": "dns:txt:_sovp.example.com"}} + document = { + **metadata, + "integrity_proof": { + "signature": signature, + "public_key_ref": "dns:txt:_sovp.example.com", + }, + } monkeypatch.setattr(resolver, "fetch_identity_document", lambda domain, timeout=10: document) # Old key first, new key second — verification must fall through to the