NORAEarly Access

Appendices · Chapter 39

Appendix B — A Worked Attestation, Every Byte Explained {.unnumbered}

Appendix B — A Worked Attestation, Every Byte Explained

This appendix presents two worked examples. Section B.0–B.8 cover the v0.1.x attestation format (the 01_valid.json fixture from Lab 25) and remain the reference for the seven-step verifier labs. Section B.9–B.13 cover the v0.2.0 DSSE format produced by emit_dsse(), with a full walk-through of the three-step DSSE verification procedure and PAE byte computation. The cross-reference tables in B.8 and B.13 map every field to its governing requirement. ## For non-technical readers If you are a lawyer, judge, or paralegal evaluating a Canon attestation, this appendix explains every field. The fields most relevant to legal evaluation are: - findings.claims[*].statement — the actual factual assertion the attestation makes. Read these first. They are the only field with natural-language content. - findings.claims[*].inference_type — whether each claim is observed (a direct recording of source content) or some form of inference. An observed claim is the most reliable; any other type means a model drew a conclusion. - findings.claims[*].gaps — what the system could not determine. Non-empty gaps indicate limits on the claim. - refutation.coverage — how many challenges were applied and how many were declined. A coverage with no attempted challenges is a red flag. - seal.public_key_fingerprint — the identifier of the key that signed this document. Cross-reference to seal.public_key_url to confirm the key is on record. The detailed byte-level explanation of every field follows in sections B.1–B.8 for engineers and expert witnesses. --- ## The Complete Attestation (v0.1.x) Reproduced exactly from labs/ch25_verifier/fixtures/01_valid.json. Do not modify it.

{
  "canon_version": "0.1.1",
  "attestation_id": "01TESTLAB25FIXTURE000000000",
  "kind": "enrichment",
  "issued_at": "2026-05-01T12:00:00.000000Z",
  "issuer": "acme-corp-2026",
  "matter_id": null,
  "subject": "Lab 25 fixture",
  "witness": [
    {
      "observation_id": "obs-LAB25-FIX-01",
      "source": "fixture://lab25/sample",
      "received_at": "2026-05-01T11:59:00.000000Z",
      "custody_chain": [
        {
          "custodian": "lab25",
          "received_at": "2026-05-01T11:59:00.000000Z"
        }
      ],
      "content_hash": "sha256:07a82ec1d73d6a598628939dac3da3bf735fe2ac0854c800ca2ae1d71d3653e5",
      "content_inline": "c2FtcGxlIG9ic2VydmF0aW9uIGJ5dGVzIGZvciBMYWIgMjU="
    }
  ],
  "findings": {
    "method": "lab25_fixture_generator v0.1",
    "claims": [
      {
        "claim_id": "claim-LAB25-01",
        "statement": "The fixture content is 'sample observation bytes for Lab 25'.",
        "supports": [
          "obs-LAB25-FIX-01"
        ],
        "inference_type": "observation",
        "gaps": []
      }
    ]
  },
  "refutation": {
    "challenges": [
      {
        "challenge_id": "chal-LAB25-01",
        "type": "replay",
        "targets": [
          "claim-LAB25-01"
        ],
        "input": "re-run the observation",
        "outcome": "survived"
      }
    ],
    "coverage": {
      "applied": [
        "replay"
      ],
      "declined": [
        {
          "type": "adversarial_prompt",
          "reason": "observation_claim_does_not_admit_adversarial_challenge"
        },
        {
          "type": "consistency_check",
          "reason": "single_fixture_no_corpus_to_check_against"
        },
        {
          "type": "coverage_audit",
          "reason": "applies_at_batch_level_not_per_attestation"
        },
        {
          "type": "counter_evidence",
          "reason": "observation_over_inline_content_admits_no_counter_evidence_search"
        }
      ]
    }
  },
  "seal": {
    "chain_hash": "sha256:873e889343ea2d9e3ce3b37bd0ff818898d0ef9a9321b7a5481bff8e6d8f0f73",
    "canonicalization": "rfc8785",
    "signature_algorithm": "ed25519",
    "signature": "BL9sT4uoeRkPa2u5uZ80h/ZHB1FXHLpQzrKTOPXDi0BIR/zuHuC5HWj+SwmA8mYPG4LJHXOyT0/Mf6VqlrIsBQ==",
    "public_key_fingerprint": "sha256:c0214e934521b7b91a004cee09208ce6892ffd1f6aee88337706af6fe6541781",
    "public_key_url": "file:///Users/pat/litigation-db/docs/textbook/labs/ch25_verifier/fixtures/keypair.pem"
  }
}

B.1 — Top-Level Envelope Fields

These are the fields that wrap everything else. Together they identify the attestation, place it in time, and declare the protocol version.

canon_version"0.1.1" A semantic version string. Canon v0.1.1 accepts "0.1.0" and "0.1.1". The Pydantic validator in meridian/canon/schema.py rejects any other value at construction time (R1). A verifier receiving an attestation with an unrecognized version should fail immediately: it does not know what invariants the rest of the document is supposed to satisfy. attestation_id"01TESTLAB25FIXTURE000000000" A ULID: Universally Unique Lexicographically Sortable Identifier. A real ULID is 26 characters of Crockford base-32. The first 10 characters encode a millisecond timestamp; the last 16 are random. This means attestations can be sorted by creation time without a separate timestamp column, and ULIDs from any two sources interleave correctly if you combine them. The fixture uses a human-readable stand-in (01TESTLAB25FIXTURE000000000) to make it easy to spot in log output; a production attestation would have an opaque 26-character ULID such as 01HV7JBXR4CBJN2F8S4YNZT3MG. kind"enrichment" One of the four attestation kinds defined in Chapter 20: observation, enrichment, search, or brief. The kind determines what the findings block is expected to contain. An enrichment attestation holds claims derived from prior observations — it is the most common output of the L4 extraction pipeline. issued_at"2026-05-01T12:00:00.000000Z" An ISO 8601 UTC timestamp. The Z suffix is required; local time offsets are not accepted. This field is inside the signed seal boundary: it is included in the RFC 8785 canonical form that becomes the chain_hash input. Any attempt to backdate or forward-date the attestation after signing breaks the chain_hash, which causes the verifier to fail at step 3. issuer"acme-corp-2026" The identity of the software or agent that produced this attestation. In production systems, the issuer is an identifier that maps to the key registered in the NORA key registry. The verifier does not check this field against an allowlist (that is a policy decision left to the recipient) but it is inside the signed boundary. matter_idnull An optional UUID linking this attestation to a legal matter in the procedural substrate. When present, the Postgres schema (schema/A0_attestations.sql) uses this as a foreign key into the matters table. The fixture sets it to null because it is not connected to a live matter. subject"Lab 25 fixture" A free-text description of what this attestation is about. The verifier does not parse this field; it is for human readers and for display in interfaces. --- ## B.2 — The Witness Block The witness array is Canon's ground truth layer. Every claim in the findings block must trace back to at least one entry here. This traceability is enforced at construction time (R3) by the _supports_closure validator in schema.py. observation_id"obs-LAB25-FIX-01" Identifies this specific observation within the attestation. The pattern enforced by Pydantic is ^obs-[A-Za-z0-9_-]+$. This is the string that appears in supports lists inside the findings block. source"fixture://lab25/sample" A URI identifying where the bytes came from. In production, this would be gmail://messageId, s3://bucket/key, or a similar scheme. The verifier does not fetch this URI; it is provenance metadata for the human record. received_at"2026-05-01T11:59:00.000000Z" When the system received these bytes. Note this is one minute before issued_at on the envelope: the observation precedes the attestation that wraps it, as expected. custody_chain — array of CustodyEvent Each entry records a custodian identifier and a timestamp, building an immutable chain of transfers. The fixture has one entry (custodian: "lab25"). A real email might have three: the original Gmail API retrieval, the local hash-before-cloud primitive, and the ingestion worker that wrote it to Postgres. content_hash"sha256:07a82ec1d73d6a598628939dac3da3bf735fe2ac0854c800ca2ae1d71d3653e5" The SHA-256 hash of the actual content bytes, prefixed with "sha256:". This is the central integrity claim about the observation. Step 4 of the seven-step verifier recomputes this hash from either content_inline or the bytes fetched from content_ref and checks that it matches. To verify manually: base64-decode content_inline, SHA-256 the raw bytes, hex-encode, prepend "sha256:". The result must be this string exactly. content_inline"c2FtcGxlIG9ic2VydmF0aW9uIGJ5dGVzIGZvciBMYWIgMjU=" Standard base64 encoding of the content bytes. Decoding this gives the ASCII string sample observation bytes for Lab 25. The WitnessEntry model requires either content_inline or content_ref to be present (R2); both may not be absent. > ◆ Going Deeper — Why base64 inline instead of a URL? > > A URL reference (content_ref) requires the verifier to make a network > request. For court exhibits, that creates a custody gap: the bytes at the > URL could change between when the attestation was issued and when the > verifier fetches them. Inline content eliminates that gap entirely — the > bytes are in the signed document. The cost is size. Canon's guidance is: > use content_inline for anything under 64 KB; use content_ref with a > content-addressed URL (e.g., an IPFS CID) for anything larger. --- ## B.3 — The Findings Block The findings block holds the claims that the system has derived from the witness observations. method"lab25_fixture_generator v0.1" Free text naming the process that produced the claims. In production, this might be "bge-large-en-v1.5 extraction pipeline v2.3.1". The method string is not parsed by the verifier but it is inside the signed boundary, making it tamper-evident. Claims array — claim_id"claim-LAB25-01" Each claim in the claims array has a unique identifier matching the pattern ^claim-[A-Za-z0-9_-]+$. This identifier is what refutation targets lists reference. statement"The fixture content is 'sample observation bytes for Lab 25'." The actual claim as a natural-language assertion. Canon imposes no format on this string, but the Epistemic Neutrality Masking (ENM) guidance from Chapter 16 recommends passive voice, no hedging language that is not quantified, and no first-person perspective. supports["obs-LAB25-FIX-01"] The list of observation IDs (or earlier claim IDs) that provide evidence for this claim. Every entry must resolve within the same attestation — no external references. Step 5 of the verifier checks this. The Pydantic _supports_closure validator also checks it at construction time (R3), so a claim with a dangling support never reaches the signing stage. inference_type"observation" One of five closed-vocabulary types: observation, deduction, induction, abduction, or compound. An observation claim is a direct reading of the witness bytes — no inference. Critically, observation is the only type that is allowed to have an empty gaps list. All other inference types must declare at least one gap (R5, enforced by the _gap_disclosure validator). gaps[] Empty here because this is an observation claim. A deduction claim might declare: ["The content hash was accepted as authentic; chain of custody between receipt and hashing is not independently verified."] --- ## B.4 — The Refutation Block R6 mandates at least one challenge and a coverage inventory naming which of the five challenge types ran and which were declined, with reasons. challenges — array of Challenge challenge_id"chal-LAB25-01" Unique identifier per challenge, matching ^chal-[A-Za-z0-9_-]+$. type"replay" One of the five challenge types from Chapter 19: | Type | What it does | |---|---| | replay | Re-runs the observation to see if it produces the same result | | adversarial_prompt | Prompts the LLM to argue against the claim | | consistency_check | Checks the claim against a corpus of prior attestations | | coverage_audit | Audits for systematic gaps across a batch | | counter_evidence | Searches for evidence that contradicts the claim | targets["claim-LAB25-01"] The claim IDs this challenge was directed at. Step 6 of the verifier checks that every target resolves to a real claim_id in the findings block. input"re-run the observation" Free text describing what was submitted to the challenge process. outcome"survived" One of four outcomes: survived, failed, revised, or contested. survived means the challenge did not overturn the claim. contested is reserved for Tri-Model Consensus cases where all three models disagree. coverage.applied["replay"] The challenge types that were actually run. The verifier checks this against the challenges array: every type in applied must correspond to at least one entry in challenges. coverage.declined — array of DeclinedChallenge The four challenge types not applied, each with a machine-readable reason. R6 requires every challenge type to appear in either applied or declined. The reasons here are honest about the fixture's constraints: - adversarial_prompt is inapplicable to a direct observation claim. - consistency_check requires a corpus; this is an isolated fixture. - coverage_audit operates at batch level, not per attestation. - counter_evidence search is not meaningful over inline bytes. > ▼ Why It Matters — The coverage list is not bureaucratic overhead. > > A recipient reading this attestation — a lawyer, a judge's law clerk, an > opposing forensic expert — can look at the coverage block and immediately > know what adversarial testing was not done. If a challenge type is missing > from the declined list, the attestation fails R6 and the verifier rejects > it. The coverage block is the mechanism that prevents selective omission of > inconvenient challenges: you must name what you did not run. --- ## B.5 — The Seal Block The seal is the cryptographic binding. It is populated last, after the rest of the attestation is complete. A missing or malformed seal is an immediate verifier failure (step 0). public_key_url"file:///Users/pat/litigation-db/docs/textbook/labs/ch25_verifier/fixtures/keypair.pem" Where to fetch the issuer's Ed25519 public key in PEM format (RFC 8410). In production, this is a stable HTTPS URL at a domain the issuer controls. Step 1 of the verifier fetches this URL and computes its SHA-256 fingerprint. public_key_fingerprint"sha256:c0214e934521b7b91a004cee09208ce6892ffd1f6aee88337706af6fe6541781" "sha256:" + hex digest of the PEM file bytes. Step 1 of the verifier fetches the PEM at public_key_url, hashes the bytes, and checks this value. This prevents a key-substitution attack: an adversary who can control the URL cannot swap in a different key, because the expected fingerprint is sealed inside the signed document. chain_hash"sha256:873e889343ea2d9e3ce3b37bd0ff818898d0ef9a9321b7a5481bff8e6d8f0f73" "sha256:" + hex digest of the RFC 8785 canonical form of the attestation with the "seal" key removed. Step 3 of the verifier recomputes this. The computation is described in detail in section B.6 below. signature_algorithm"ed25519" Declares what algorithm was used to sign. Because this field is inside the seal block and the seal block is inside the signed boundary (the chain_hash covers everything except the seal object itself — see B.6 for the exact exclusion), an attacker cannot swap the algorithm declaration without breaking the chain_hash. Wait — if the seal block is excluded when computing the chain_hash, how is signature_algorithm protected? Answer: it is protected by the signature itself. The signature covers the chain_hash, and the chain_hash covers every field except the seal. The seal's own fields (signature_algorithm, signature, public_key_fingerprint, chain_hash) are checked by the verifier's structural steps (0, 1, 2, 3) rather than being folded into the chain_hash. This is the intended architecture: the chain_hash commits to the content; the seal fields authenticate the chain_hash. signature"BL9sT4uoeRkPa2u5uZ80h/ZHB1FXHLpQzrKTOPXDi0BIR/zuHuC5HWj+SwmA8mYPG4LJHXOyT0/Mf6VqlrIsBQ==" Base64-encoded Ed25519 signature. Step 2 decodes this, fetches the public key from public_key_url, and calls Ed25519PublicKey.verify(signature_bytes, message). The message is described in section B.7. --- ## B.6 — The Chain Hash Computation The chain_hash is the document commitment. Every field in the attestation except the seal block is covered by it. Here is the exact procedure: Step 1: Take the attestation as a Python dict (or equivalent). Remove the "seal" key entirely. The result is a dict containing exactly: canon_version, attestation_id, kind, issued_at, issuer, matter_id, subject, witness, findings, refutation. Step 2: Apply RFC 8785 canonicalization to this dict. RFC 8785 specifies: - Sort object keys by their UTF-16 code-unit sequence, not by Unicode code point. For ASCII-only keys (the common case) this is the same as lexicographic sort. - Serialize strings per RFC 8259: escape only the characters JSON requires to be escaped (", \, and the C0 control characters). Do not escape non-ASCII code points. - Format numbers using ECMAScript's Number.toString algorithm. 1.0 becomes 1; 1e20 stays 1e+20 only if that is the shortest representation. - Emit no whitespace (no spaces, no newlines). - The output is a sequence of bytes, not a Unicode string. It is UTF-8 encoded. Step 3: SHA-256 the canonical bytes. You get 32 raw bytes. Step 4: Hex-encode the 32 bytes (lowercase). Prepend "sha256:". The result is the value that must match seal.chain_hash. > ◆ Going Deeper — Why exclude the seal block? > > The seal contains the signature, which in turn covers the chain_hash. If the > seal were included in the chain_hash computation, you would have a circular > dependency: you cannot compute the chain_hash without the signature, but > you cannot compute the signature without the chain_hash. Excluding the seal > block breaks the cycle. This is a standard construction; TLS certificate > signatures use the same pattern (the TBSCertificate structure excludes the > signature field). > ✻ Try This — Verify the chain_hash by hand. > > 1. Copy the attestation JSON above into a text editor. > 2. Remove the entire "seal": { ... } block and its preceding comma. > 3. Paste the result into an RFC 8785 canonicalization tool (the > jcs npm package or python -c "from meridian.canon.canonicalize > import canonicalize; ..." from the repo). > 4. SHA-256 the canonical bytes. Prepend "sha256:". > 5. Compare to chain_hash above. They must match. --- ## B.7 — The Signature Computation The message signed by Ed25519 is the UTF-8 byte encoding of the chain_hash string — including the "sha256:" prefix.

In Python:

message = chain_hash_string.encode("utf-8")
# e.g. b"sha256:873e889343ea2d9e3ce3b37bd0ff818898d0ef9a9321b7a5481bff8e6d8f0f73"

This is 71 bytes: 7 for "sha256:" and 64 for the hex digest. The signature is then Ed25519PrivateKey.sign(message). Verification is Ed25519PublicKey.verify(signature_bytes, message). A common implementation error: stripping the "sha256:" prefix before signing or verifying. A verifier that does this computes Ed25519PublicKey.verify(sig, b"873e889343ea...") instead of Ed25519PublicKey.verify(sig, b"sha256:873e889343ea..."). The 64-byte message differs from the 71-byte message; signature verification fails. The error is silent on the verifier side — it reports step2_signature_verify: fail with no further hint. This is one of the deliberate corruption fixtures in Lab 25: fixture 02_bad_sig.json triggers exactly this failure mode at step 2. > ☉ In the Wild — The prefix matters. > > The "sha256:" prefix convention is borrowed from container image digests > (OCI Distribution Spec §6.3) and IPFS CIDs. Its purpose is algorithm > agility: if a future Canon version uses SHA-3, the prefix "sha3-256:" > makes the algorithm explicit in the wire format without a separate field. > Signing the full prefix string rather than the raw bytes means an > attestation signed with SHA-256 cannot be confused with one signed with > SHA-3-256, even if both produce identical-length digests. --- ## B.8 — Cross-Reference Table Every field, its governing requirement, and the verifier step that checks it: | Field | Location | Requirement | Verifier Step | |---|---|---|---| | canon_version | envelope | R1 | step0_canon_version | | seal (present) | envelope | R4 | step0_seal_present | | public_key_fingerprint | seal | R4 | step1_public_key_fetch | | signature | seal | R4 | step2_signature_verify | | chain_hash | seal | R4 | step3_chain_hash_recompute | | content_hash | witness entry | R2 | step4_witness_content_hashes | | content_inline / content_ref | witness entry | R2 | step4_witness_content_hashes | | supports (each entry resolves) | claim | R3 | step5_supports_resolution | | targets (each entry resolves) | challenge | R6 | step6_refutation_targets | | challenges (at least one) | refutation | R6 | step6_refutation_targets | | coverage.declined (all types) | refutation | R6 | step6_refutation_targets | | signature_algorithm | seal | R4 | structural (step 0) | | canonicalization | seal | R4 | structural (step 0) | | inference_type | claim | R4 | schema validation | | gaps (non-observation claims) | claim | R5 | schema validation | | attestation_id | envelope | R1 | schema validation | | issued_at | envelope | inside signed boundary | step3_chain_hash_recompute | | issuer | envelope | inside signed boundary | step3_chain_hash_recompute | The seven verifier steps are step0 through step6. Steps 0–3 check the cryptographic envelope. Steps 4–6 check the semantic content. An attestation that passes all seven steps is valid in the sense of Chapter 25's definition: it is structurally complete, cryptographically authentic, and internally consistent. Whether its claims are true is a separate question — one the refutation block addresses probabilistically, not with finality. --- ## B.9 — The v0.2.0 DSSE Envelope Canon v0.2.0 replaces the bare Ed25519-over-JCS seal block with a DSSE envelope produced by emit_dsse(). The envelope wraps the same attestation content but uses Pre-Authentication Encoding (PAE) as the signed message, providing domain separation.

A v0.2.0 sealed output looks like this (small example, short payload for readability):

{
  "payload_type": "application/vnd.nora.canon.attestation+json; version=0.2.0",
  "payload": "eyJjYW5vbl92ZXJzaW9uIjoiMC4yLjAiLCJhdHRlc3RhdGlvbl9pZCI6IjAxQUNNRUNPUlAyMDI2MDAwMDAwMDAwMDAwMCIsImtpbmQiOiJlbnJpY2htZW50IiwiaXNzdWVkX2F0IjoiMjAyNi0wNS0wMVQxMjowMDowMC4wMDAwMDBaIiwiaXNzdWVyIjoiYWNtZS1jb3JwLTIwMjYifQ",
  "signatures": [
    {
      "keyid": "sha256:c0214e934521b7b91a004cee09208ce6892ffd1f6aee88337706af6fe6541781",
      "sig": "BASE64URL_ENCODED_ED25519_SIGNATURE"
    }
  ],
  "chain_hash": "sha256:HASH_OF_JCS_BYTES"
}

The payload field is the base64url encoding (no padding) of the JCS bytes of the attestation. The signatures[0].keyid is the SHA-256 fingerprint of the public key PEM — the same value as seal.public_key_fingerprint in v0.1.x. The chain_hash is SHA-256(base64url_decode(payload)), stored as a convenience field.


B.10 — DSSE Three-Step Verification

Verifying a v0.2.0 DSSE envelope is a three-step procedure, replacing the seven-step procedure for the cryptographic layers (the semantic steps 4–6 are unchanged):

Step 1 — Decode and check the payload.

import base64, hashlib

canonical_bytes = base64.urlsafe_b64decode(envelope["payload"] + "==")
# The "==" pads to a multiple of 4 if needed.

Verify that len(canonical_bytes) is what you expect (non-zero, plausible for an attestation JSON object). This step catches truncated or corrupted payloads before the signature check. Step 2 — Verify chain_hash.

computed_hash = "sha256:" + hashlib.sha256(canonical_bytes).hexdigest()
assert computed_hash == envelope["chain_hash"]

A mismatch here means either the payload bytes were altered after sealing or the chain_hash field was incorrectly computed. This check is fast (one SHA-256) and catches data corruption before the more expensive signature verification.

Step 3 — Verify the Ed25519 signature over the PAE.

from meridian.canon.dsse import pae

message = pae(envelope["payload_type"], canonical_bytes)
public_key.verify(base64.urlsafe_b64decode(sig["sig"] + "=="), message)

The pae() function constructs the Pre-Authentication Encoding byte sequence. If verification succeeds, the attestation content is cryptographically bound to the declared payload_type by the key identified in signatures[0].keyid. --- ## B.11 — PAE Byte Computation The PAE function produces the message that Ed25519 signs. Its structure is defined by the DSSE specification and must be computed identically in every language. For a payload_type string T (UTF-8 bytes) and a payload byte sequence P:

PAE(T, P) =
  "DSSEv1" + "\n"
  + uint64_le8(len(T)) + "\n"
  + T + "\n"
  + uint64_le8(len(P)) + "\n"
  + P

where uint64_le8(n) is the 8-byte little-endian encoding of n as an unsigned 64-bit integer. Concrete example. Let payload_type = "text/plain" (10 bytes) and payload = b"hello" (5 bytes):

DSSEv1\n                                    → 7 bytes: 44 53 53 45 76 31 0a
\x0a\x00\x00\x00\x00\x00\x00\x00\n         → 9 bytes: 0a 00 00 00 00 00 00 00 0a
text/plain\n                                → 11 bytes: 74 65 78 74 2f 70 6c 61 69 6e 0a
\x05\x00\x00\x00\x00\x00\x00\x00\n         → 9 bytes: 05 00 00 00 00 00 00 00 0a
hello                                       → 5 bytes: 68 65 6c 6c 6f

Total PAE: 41 bytes. Every conformant implementation — Python, Go, Rust, TypeScript — must produce this exact byte sequence for these inputs.

In Python:

import struct

def pae(payload_type: str, payload: bytes) -> bytes:
    t = payload_type.encode("utf-8")
    return (
        b"DSSEv1\n"
        + struct.pack("<Q", len(t)) + b"\n"
        + t + b"\n"
        + struct.pack("<Q", len(payload)) + b"\n"
        + payload
    )

Going Deeper — Why PAE prevents cross-protocol attacks.

Bare Ed25519 over JCS bytes (v0.1.x) signs the same message regardless of what the bytes represent. If two Canon-like protocols both use bare Ed25519 over JCS, a signature from one protocol's key could theoretically verify against the other protocol's message, depending on the key. PAE prevents this: the payload_type string is mixed into the signed message. > An Ed25519 signature over PAE("application/vnd.nora.canon.attestation+json; > version=0.2.0", payload) cannot verify against a message with a different > payload_type, even with the same key and identical payload bytes. > This is why DSSE is now the CNCF standard for signing in SLSA, in-toto, > and TUF. --- ## B.12 — Version Detection A verifier receiving an attestation-shaped JSON object must determine which verification path to use: | Condition | Path | |---|---| | Top-level dsse_envelope key present | DSSE verification (B.10) | | Top-level seal key present, no dsse_envelope | Legacy seven-step (B.5–B.7) | | Neither present | Invalid; reject | A v0.2.0-capable verifier must handle both paths without error. It must not reject a v0.1.x attestation solely because it lacks a DSSE envelope. This is the version-detection conformance requirement from Chapter 29. --- ## B.13 — v0.2.0 Cross-Reference Table Every v0.2.0 DSSE envelope field and its verification role: | Field | Location | Requirement | Verification step | |---|---|---|---| | payload_type | DSSEEnvelope | DSSE spec | PAE construction (Step 3) | | payload | DSSEEnvelope | DSSE spec | base64url decode (Step 1) | | signatures[*].keyid | DSSEEnvelope | Canon v0.2.0 | Key identity check | | signatures[*].sig | DSSEEnvelope | DSSE spec | Ed25519 verify (Step 3) | | chain_hash | DSSEEnvelope | Canon extension | SHA-256 check (Step 2) | | canon_version | attestation (in payload) | R1 | schema validation | | attestation_id | attestation | R1 | schema validation | | issued_at | attestation | signed boundary | payload integrity | | issuer | attestation | signed boundary | payload integrity | | witness[*].content_hash | attestation | R2 | semantic step 4 | | supports (resolves) | claim | R3 | semantic step 5 | | coverage.declined | refutation | R6 | semantic step 6 |

Semantic steps 4–6 are identical to the v0.1.x procedure: they check the attestation content after the cryptographic envelope has been verified.