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. 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 diff --git a/README.md b/README.md index 9690e03..37c15e1 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. --- @@ -69,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`. --- @@ -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 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) diff --git a/tests/test_draft04_conformance.py b/tests/test_draft04_conformance.py new file mode 100644 index 0000000..68a922e --- /dev/null +++ b/tests/test_draft04_conformance.py @@ -0,0 +1,280 @@ +# 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"]["created"] = "2026-10-08T00:58:50Z" + 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-08T00: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-08T00: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 = 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"} + 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 + + 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") diff --git a/tests/test_resolver.py b/tests/test_resolver.py index 6d483dd..9485177 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 @@ -90,28 +92,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 = [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 == [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") == "OLDKEY" + assert resolver.resolve_dns_pubkey("example.com") == "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" def test_validate_domain_accepts_document_signed_under_second_rotated_key(monkeypatch): 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/) 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 }); }, };