/// Litzki Systems LLC — Draft Product Spec
SovereignAttestation — Format & Verification Procedure
Status: draft product spec, Litzki Systems LLC. Not an IETF document, not part of draft-litzki-sovp-04. It reuses that draft's cryptographic primitives (Ed25519, RFC 8785 JCS, DNS-anchored keys) but defines a separate document type for a separate trust claim.
A German translation of this specification is available. This English page is the normative one: it is the target of @context and spec_url in every issued document, and it governs where the two differ.
1. What this is (and is not)
Two documents exist for a domain that has gone through SOVP validation:
| Document | Location | Signed by | Claim |
|---|---|---|---|
sovp-identity.json |
subject domain's own /.well-known/ |
subject domain's own key (_sovp.<subject-domain>, possibly custody-managed) |
“I am this domain” — self-asserted identity |
sovp-attestation.json |
subject domain's own /.well-known/ |
Litzki Systems LLC issuer key (_sovp-attestation.litzki-systems.com) |
“Litzki certifies this verdict about this domain” — third-party attestation |
These are independent trust claims from independent signers, verified against independent DNS-anchored keys. A verifier who cares about the verdict trusts the attestation, signed by the issuer. The identity document's own scan block (if present) is explicitly out-of-scope for trust decisions per draft-litzki-sovp-04 §5 — it is not, and must not be read as, a substitute for this attestation.
Why this is a separate document. draft-litzki-sovp-04 §8.3 (Layer Boundaries) states the requirement this document type exists to meet: assessment results such as verdicts or scores require a separate signed document, issued under a key that the verifier resolves independently (the draft points at its own §8.2 for that key), and the draft puts the format of such a document expressly out of its own scope. This page is that format. §8.2 (Anchor Authorization) constrains the key: the authorization path must be external to the SOVP document and must not be derived from any identifier the attested subject controls — control of a key is not authorization of that key. That is why the issuer key sits under _sovp-attestation.litzki-systems.com, a label in the issuer's own zone, and never under the subject domain's _sovp label. The two-document split on this page is therefore not a product decision layered on top of the draft; it is what the draft requires of anyone who wants to publish a signed verdict.
Self-issuance notice. For a customer domain, issuer and subject are different parties and the attestation is a third-party claim. For litzki-systems.com they are the same party: the attestation published at /.well-known/sovp-attestation.json on this domain is issued by Litzki Systems LLC about its own domain, and its sovereignScore of 100 is self-reported. It exists to demonstrate the document format and the verification procedure in section 5 against a live document. It is not third-party assurance about this website.
draft-litzki-sovp-04 §8.2 permits this arrangement explicitly — issuer and subject may be the same organization — because the authorization constraint it imposes is on the key path, not on the parties: the key must be resolved from a label the subject does not control, which holds here even though both roles are Litzki Systems LLC. Permitted is not the same as independent, and a verifier that needs independence needs a different issuer, not a different reading of this document.
2. Document structure
{
"@context": "https://litzki-systems.com/protocol/sovp-attestation-v1",
"@type": "SovereignAttestation",
"subject": {
"domain": "example.com",
"canonical_url": "https://example.com"
},
"attestation": {
"verdict": "CERTIFIED",
"sovereignScore": 87.4,
"contentQuality": 91,
"aiReadiness": 88,
"infrastructureScore": 79,
"authorityScore": 62,
"anchorScore": 70,
"vault_payload_digest": "<sha256 hex, optional>",
"scanned_at": "2026-07-22T10:00:00.000Z",
"valid_until": "2026-10-20T10:00:00.000Z",
"spec_url": "https://litzki-systems.com/protocol/sovp-attestation-v1"
},
"issuer": {
"uid": "urn:sovp:litzki-systems-llc",
"canonical_url": "https://litzki-systems.com"
},
"integrity_proof": {
"signature": "<base64>",
"created": "2026-07-22T10:00:03.000Z",
"public_key_ref": "dns:txt:_sovp-attestation.litzki-systems.com",
"nonce": "<uuid4>"
}
}
spec_url points at this specification, not at the IETF identity draft — the attestation is a Litzki product artifact, and pointing at the draft would misrepresent it as an IETF-specified object.
attestation.vault_payload_digest is optional: a SHA-256 hex digest of the JCS-canonicalized Sovereign Vault record this attestation was issued for. Because it sits inside the signed scope, it binds the vault record's grading threshold and scores to this signature — the vault record can no longer be edited after issuance without invalidating the attestation. Attestations issued before this field existed, and verifiers that don't check it, remain valid.
The example above holds no parameterCount. The field was removed from this specification on 2026-10-09: it was optional, it had no defined value range, no attestation ever carried it, and the number of parameters is a property of the scanning engine at scan time, not of this document format. A verifier re-canonicalizes and verifies whatever fields are present, so an older document that carries the field still verifies.
Timestamp format. Timestamps in this document type are RFC 3339, UTC, with a literal Z suffix, and they may carry fractional seconds: the signer emits millisecond precision, and the live document reads "scanned_at": "2026-09-08T23:17:55.646Z". draft-litzki-sovp-04 §5 requires identity-document timestamps to carry no fractional seconds — that rule binds the SovereignIdentity format, and this is a deliberately separate document type, so the difference is permitted rather than a defect. It has one consequence a verifier must respect: scanned_at and valid_until are signed string values, so re-serializing them (dropping the milliseconds, rewriting Z as +00:00) changes the canonical bytes and breaks the signature. Parse them as instants for the expiry comparison in section 7, but verify the signature over the bytes as received. The example above uses the three-digit form the signer actually emits.
attestation.anchorScore carries the name the engine uses since 2026-08-05. Documents issued before that date, if any exist, carry the same value under the former name coherenceScore. A verifier re-canonicalizes and verifies whatever fields are present, so both shapes verify.
2.1 attestation.verdict value set and issuance
verdict is one of "CERTIFIED", "NOT_CONFORMANT", "INCOMPLETE". The engine's verdict module defines the value set in VERDICT, and the engine's scoring module emits it. VALID_VERDICTS in the verdict module additionally contains FAILED, for reads only.
"FAILED" is a legacy value. The engine renamed it to NOT_CONFORMANT on 2026-09-03 and writes it nowhere any more. It stays readable: a stored FAILED is mapped to NOT_CONFORMANT on read, so existing database rows, vault records and archived documents remain valid. Signed documents are never rewritten; a signed FAILED stays FAILED inside its signature. A verifier built against the original CERTIFIED/FAILED value set MUST treat an unrecognized value as “not certified”, never as “certified by default”.
INCOMPLETE means the scan could not reach enough of the subject domain to produce a measurement, for example because a firewall or a bot-protection system blocked it. It is a statement about the measurement, not about the subject. NOT_CONFORMANT states that the domain has not published the SOVP identity artifacts. A domain that was never scanned has no document at all.
How an attestation is issued. Issuance is a manual step, run on the server. An operator runs the standalone signer against an input file and places the resulting document at the subject's /.well-known/sovp-attestation.json. The signer is a standalone script. It is not reachable over HTTP, it is not part of any request handler, and no route in the engine issues an attestation. The scan route signs the separate SovereignIdentity document and never writes an attestation. The decision of which verdict gets an attestation is therefore an operator decision at issuance time, enforced by the operator, not by code.
The issuer key is separate from every identity key. It is published under _sovp-attestation.litzki-systems.com and is distinct from _sovp.litzki-systems.com. Both keys sit on the same machine: the key file belongs to root with mode 600 on the server that also runs the engine, and no request handler reads it. The separation is one of key material and DNS label, not one of machines.
State on 2026-10-10: exactly one attestation has been issued, for litzki-systems.com, with verdict: "CERTIFIED", scanned 2026-09-08 and valid until 2026-12-07. No attestation with NOT_CONFORMANT or INCOMPLETE exists. Attestations issued before 2026-08-01 carry a verdict from the former CERTIFIED/FAILED set and are not reissued or reclassified: a signed document reflects the verdict computed at scan time.
3. Signed scope
The Ed25519 signature covers every top-level field except integrity_proof — @context, @type, subject, attestation, issuer. This differs from the SovereignIdentity document, where vendor extensions (like scan) sit outside the signed scope; here, the verdict and scores are the entire point of the document, so they are inside the signed scope by construction.
integrity_proof.created and integrity_proof.nonce lie outside the signed scope and carry no cryptographic assurance. The signed time fields are attestation.scanned_at and attestation.valid_until. Both unsigned fields can be rewritten to any value without invalidating the signature, and this is demonstrated rather than inferred: against the live document, rewriting integrity_proof.created to 2029-01-01T00:00:00.000Z and integrity_proof.nonce to a different UUID still returns Signature Verified Successfully, while rewriting the signed attestation.scanned_at returns Signature Verification Failure with exit code 1. A verifier therefore MUST NOT use created or nonce as a freshness signal, as an anti-replay signal, or as evidence of when the document was issued; the only expiry rule this document type defines is the valid_until comparison in section 7.
This is the same structural weakness that draft-litzki-sovp-04 §9.4 (Replay Protection) closes for the identity document, by requiring freshness.created and freshness.expiresAt to be present inside the signed scope — the change that distinguishes identity schema v2.0 from v1.4, where those two fields sat in the unsigned integrity_proof exactly as they still do here. This document type has not had that treatment. Closing the gap means moving both fields into a signed freshness object, which changes the wire format and the canonical bytes of every document, so it requires a new @context and a version 2 of this specification rather than an edit to this page. Until that exists, the limitation above is the normative statement for verifiers: treat created and nonce as issuer annotations, not as proof.
4. Canonicalization
Identical rule to draft-litzki-sovp-04 §4: the payload (all signed fields) MUST be serialized using an RFC 8785-compliant JSON Canonicalization Scheme (JCS) implementation before signing or verifying. Non-compliant canonicalizers (ad hoc JSON.stringify, handwritten sorters that don't recurse into arrays, etc.) MUST NOT be used — they silently break cross-implementation verification even when the signature math is otherwise correct. Reference implementation here uses the canonicalize npm package (RFC 8785 conformant); a Python verifier should use the jcs PyPI package for the same reason.
5. Signature algorithm
Ed25519 in pure mode (RFC 8032 §5.1) over JCS(payload) directly — no external pre-hash is applied before the sign/verify call (Ed25519 already applies SHA-512 internally). This is directly verifiable with standard tools, e.g.:
openssl pkeyutl -verify -rawin \
-pubin -inkey issuer_pubkey.pem \
-sigfile signature.bin \
-in canonical_payload.json
where canonical_payload.json is the JCS-canonicalized payload bytes and signature.bin is the base64-decoded integrity_proof.signature.
Building the issuer PEM files from the DNS records. The TXT record carries the raw 32-byte Ed25519 key, while openssl expects a SubjectPublicKeyInfo structure. Prepend the fixed 12-byte Ed25519 SPKI prefix 302a300506032b6570032100 to the raw key, then convert. Collect the keys into a list: the label may carry up to four v=SOVP1 records during a key rotation (section 6), and it may also carry unrelated TXT records.
# Every published v=SOVP1 key, one per line. Never collapse this to one value:
# `sed 's/.*k=//'` over a multi-record response concatenates the keys and
# produces an oversized, unusable spki.bin.
dig +short TXT _sovp-attestation.litzki-systems.com \
| sed 's/" "//g; s/^"//; s/"$//' \
| grep '^v=SOVP1;' \
| sed 's/.*k=//; s/[[:space:]]*$//' > keys.txt
# One PEM per published key.
n=0
while read -r KEY; do
n=$((n + 1))
printf '302a300506032b6570032100' | xxd -r -p > spki.bin
printf '%s' "$KEY" | base64 -d >> spki.bin
openssl pkey -pubin -inform DER -in spki.bin -out "issuer_pubkey-$n.pem"
done < keys.txt
The prefix is constant for every Ed25519 key: 302a SEQUENCE of 42 bytes, 300506032b6570 AlgorithmIdentifier id-Ed25519 (OID 1.3.101.112), 032100 BIT STRING of 33 bytes with 0 unused bits. Nothing in it depends on the key.
Full worked example, executed against the live document on 2026-10-10:
curl -s https://litzki-systems.com/.well-known/sovp-attestation.json -o att.json
python3 - <<'EOF'
import json, base64, jcs
d = json.load(open('att.json'))
payload = {k: v for k, v in d.items() if k != 'integrity_proof'}
open('canonical_payload.json', 'wb').write(jcs.canonicalize(payload))
open('signature.bin', 'wb').write(base64.b64decode(d['integrity_proof']['signature']))
EOF
ok=0; n=0
while read -r KEY; do
n=$((n + 1))
if openssl pkeyutl -verify -rawin -pubin -inkey "issuer_pubkey-$n.pem" \
-sigfile signature.bin -in canonical_payload.json >/dev/null 2>&1; then
ok=$n; break
fi
done < keys.txt
[ "$ok" -gt 0 ] \
&& echo "Signature Verified Successfully (key $ok of $(wc -l < keys.txt))" \
|| echo "Signature Verification Failure: no published issuer key verifies this document"
Result: Signature Verified Successfully (key 1 of 1). keys.txt held one line, the DNS record resolved to v=SOVP1; k=fuBpokpFXyvjpsumM3Q0AZZYKMRilsbjhBZBhWEGfLo= (TTL 43200), the resulting PEM to MCowBQYDK2VwAyEAfuBpokpFXyvjpsumM3Q0AZZYKMRilsbjhBZBhWEGfLo=, and the canonical payload to 551 bytes. Changing a single byte of canonical_payload.json turns the result into the failure branch, with openssl exiting 1, which is the control that proves the check is live.
The loop is what makes the procedure survive a key rotation: it tries each published key and succeeds if any one of them verifies, which is the behaviour section 6 requires. Verified against a simulated rotation as well — with the matching key published second and an unrelated SPF record in the same response, the loop still reports success, and with only a non-matching key published it reports the failure branch rather than a false positive.
This procedure uses dig, openssl, python3 and the jcs package. Install the one dependency first — it is not in the Python standard library:
python3 -m venv venv && ./venv/bin/pip install jcs
# then run the python3 step above as ./venv/bin/python
It needs no Litzki Systems code and no access to Litzki Systems infrastructure.
6. Key location (trust anchor)
The issuer's Ed25519 public key is published as a DNS TXT record:
_sovp-attestation.litzki-systems.com IN TXT "v=SOVP1; k=<base64-raw-32-byte-key>"
Format identical to draft-litzki-sovp-04 §7 (DNS TXT Record Format and Resolution). This label is deliberately distinct from _sovp.litzki-systems.com (the SovereignIdentity key for litzki-systems.com itself) and from any _sovp.<subject-domain> label (the subject's own identity key). A verifier resolves the issuer key from DNS independently. Per draft-litzki-sovp-04 §9.7 (Public Key Source Authority) it must not take the key from any source other than that DNS TXT record — in particular not from an HTTP header field and not from any value carried in the document, because a verifier that did so would learn only that the document is consistent with itself. It never trusts a key embedded in the document itself, and never trusts a key found under the subject domain's own _sovp label for this purpose.
More than one key may be published. §7 permits a zone to publish several v=SOVP1 records under the same label at once, to support key rotation: a verifier must consider at least 4 keys and may consider more, a zone should not publish more than 4 matching records, and the keys form an unordered set — the order in which a resolver returns records has no meaning. A verifier MUST therefore try every published key and accept the document if any one of them verifies the signature. It MUST NOT stop at the first record, MUST NOT assume the first record is the current key, and MUST ignore records whose value does not begin with v=SOVP1, since an unrelated TXT record such as an SPF entry can share the label's response. The key-collection step in section 5 is written for this case: it produces a list, never a single value. As of 2026-10-10, exactly one key is published under this label.
Recommended TTL: operationally dependent, not a fixed value. Short (300 seconds, the value recommended in draft-litzki-sovp-04 §9.3, Key Revocation and DNS TTL) while the issuer key rotates frequently; longer (3600 seconds or more) once the issuer key is stable — a longer TTL reduces resolver load and lookup latency for verifiers with no revocation-speed cost as long as no rotation is imminent. Lower the TTL back to 300 seconds ahead of a planned key rotation, so the old key falls out of caches quickly once the new one is published. Same revocation mechanism either way (update/remove the TXT record).
7. Expiry semantics
attestation.valid_until is either an ISO-8601 timestamp or the literal string "permanent".
Verification produces two independent results, not one collapsed boolean:
signature_valid(bool) — purely cryptographic. Never affected by dates. A tampered document issignature_valid: falseregardless of whethervalid_untilhas passed.status("valid"|"expired") — purely a comparison ofvalid_untilagainst the current time. Independent ofsignature_valid.
A genuine attestation past its valid_until is {signature_valid: true, status: "expired"} — it was never forged, it lapsed. Consumers MUST NOT report an expired-but-genuine attestation as “invalid” or “tampered”; those are different failure modes with different remediation (re-scan vs. security incident).
8. Verification procedure (informal)
- Fetch
https://<subject-domain>/.well-known/sovp-attestation.json. - Resolve the
_sovp-attestation.litzki-systems.comTXT records. Keep every record whose value begins withv=SOVP1, discard the rest, and parsek=<base64>from each. There may be up to four during a key rotation, in no meaningful order (see §6). For a command-line check, convert each raw key to PEM as shown in section 5. - Strip
integrity_prooffrom the document; JCS-canonicalize the rest. - Ed25519-verify
integrity_proof.signatureagainst the canonical bytes, trying each DNS-resolved public key in turn. The document issignature_validif any published key verifies it; report failure only after every key has been tried. - Compare
attestation.valid_untilagainst current time. - Report
signature_validandstatusseparately (see §7).
Two things a verifier must not do at any step: derive the key from the document instead of from DNS (§6), and read integrity_proof.created or integrity_proof.nonce as a freshness or anti-replay signal — both sit outside the signed scope (§3). Timestamps are compared as instants but verified as the bytes received, never re-serialized (§2).
Reference implementation: verify-attestation.mjs (offline reference verifier, Node.js + canonicalize).
Change history
- 2026-10-10 — Section 3 now states that
integrity_proof.createdandintegrity_proof.noncelie outside the signed scope and carry no assurance, with the demonstrated control pair and the relationship to draft-litzki-sovp-04 §9.4; closing that gap is deferred to a version 2 because it changes the wire format. Section 1 anchors the two-document split in draft-litzki-sovp-04 §8.3 and the issuer-key separation in §8.2, which also permits the self-issued case. Section 6 documents that up to fourv=SOVP1records may be published during a rotation, as an unordered set, and that a verifier must try every key; the key-extraction command in section 5 was replaced, because the previousdig | sedform concatenated multiple records into an unusable key, and the worked example now loops over all published keys. Section 2 documents the timestamp format, including the fractional seconds this document type carries and the identity document does not, and the example timestamps were made internally consistent. Section 9.7 cited for the key-source rule. No wire-format change:@context,spec_urland the signed scope are unchanged, and every previously issued document still verifies. - 2026-10-09 —
coherenceScorerenamed toanchorScore, matching the engine since 2026-08-05. Verdict value set stated as the three values the engine emits (CERTIFIED,NOT_CONFORMANT,INCOMPLETE), withFAILEDas the legacy value that stays readable. The optionalparameterCountfield was removed: it had no defined value range and no attestation ever carried it. Section 5 gained the DNS-to-openssl key conversion and the dependency install step. Draft section numbers corrected to §7 (DNS TXT record format) and §9.3 (300-second TTL recommendation); §4 and §5 were already correct. Wire format,@contextandspec_urlunchanged. - 2026-08-05 — optional
attestation.vault_payload_digestfield added. - 2026-08-01 — verdict value set extended beyond the original pair.
- 2026-07-22 — first publication.