# Verifying an Audrie-Signed Document Without Audrie

**Covers:** `protocol/canonical/**`, `protocol/verify/**`, `server/api/cert/types.go`, `server/api/cert/usersig.go`, `server/api/cert/serverseal_verify.go`, `server/api/cert/seal_record.go`, `server/api/serverkeys/**`, `client/webapp/src/lib/audriePdf/**`, `client/webapp/src/lib/audrieVerify/**` — a change to the seal, the signing digest or the anchor wire format MUST be reflected here. `server/api/cert/spec_vectors_test.go` enforces that automatically for **the signing digest and the seal canonical form only**; the `audrie-hcs/v1` anchor payload of §3.4 is **not** pinned by it, and a change there must be reflected here by hand.

**Status:** Normative — public cryptographic verification specification ("rescueDoc")
**Canonical URL:** `https://audrie.io/rescue`
**Normative vectors:** [`verify-without-audrie-vectors.json`](verify-without-audrie-vectors.json)
**Wire schema:** `audrie-cert/v1` (sealed payload) · `audrie-hcs/v1` (ledger anchor)
**Last updated:** 2026-09-12
**Audience:** Independent verifiers, auditors, archivists, and reference-implementation authors. No Audrie account, API key, or network access to Audrie is required.

> **This document is versioned by the payload, not by its URL.** The URL is a permanent
> identifier, sealed immutably into every certificate. Which rules apply to the document in your
> hand is told to you by that document itself: read `schema` and `v` from its payload (§1) and
> follow the section for that schema. This specification covers `audrie-cert/v1`. If you hold a
> certificate declaring a schema not described here, this document is older than your certificate.

---

## 0. Purpose and guarantees

Audrie certifies that a specific signer, at a specific moment, cryptographically attested to a
specific document. This document — the **rescueDoc** — specifies everything an independent third
party needs to verify that attestation, using only:

- Stock primitives available in any modern crypto library (SHA-256, RFC 8785 JSON
  canonicalization, base64url, hex).
- A FIPS 204 (ML-DSA-65) implementation.
- Optionally, a public Hedera mirror node, for the ledger timestamp check. **This step is not
  required**: verification of the document itself is fully offline.

**Audrie does not need to exist for this verification to succeed** — but that is a claim with a
condition attached, and the condition is load-bearing. Every cryptographic input is carried in the
document itself (PDF variant) or alongside it (detached variant), except one: **Audrie's seal
public key**, which is the trust root of the offline verdict. Obtaining it from a live fetch of an
`audrie.io` URL and nothing else roots your verification in Audrie's DNS and TLS, which is the
dependency this document exists to remove. §4, Check 2, step 5 says what to do instead, and what is
mandatory there.

### 0.1 What this specification does *not* claim

Be precise about what a passing verification proves, because it is narrower than "this document
is genuine":

- It proves the file is **byte-for-byte unaltered** since signing, that the **signer's key**
  signed this exact document in this exact context, and that **Audrie bound** that metadata
  together and issued it.
- With the ledger check, it additionally proves the record **existed no later than** a consensus
  timestamp Audrie cannot rewrite.
- It does **not** prove the person named signed willingly, that the document says what you think
  it says, or that the signer's key was uncompromised at the time.

**Nor is this document itself independently verifiable.** It is not content-addressed: there is no
anchored hash, no mirror and no signature over the text you are reading, so its authenticity rests
entirely on the channel you fetched it over. That was considered and declined — content-addressing
only helps a reader who already holds an independent anchor point, and Audrie has no channel to
give you one that an attacker controlling `audrie.io` would not also control. What it would take to
change that is an anchor identifier obtained out of band, which is worth building when this
specification is cited as evidence — by an auditor, a contract, or an eIDAS assessment — and not
before.

The practical consequence is bounded, and worth being precise about: a doctored copy of this text
could lead you to build a **weaker verifier**. It cannot make a forged document verify, because
every guarantee above lives in the payload and the ledger, not in this prose.

### 0.2 History — PAdES/CMS is not used

Earlier designs explored PAdES/CMS packaging (`/Sig` dictionary, `/ByteRange`, `/Contents`,
`/AcroForm`). **That approach was dropped.** A signed PDF has **no** `/Sig`, **no** CMS
`SignedData`, **no** `/AcroForm`, **no** `/ByteRange` and **no** `/Contents`. Consequently **Adobe
Reader shows no signature panel** — that is expected, not a defect. Tamper evidence is carried by
the whole-file hash inside the signer's own ML-DSA signature (§4, Check 1), not by a `/ByteRange`
gap. For why this still satisfies eIDAS AES, see §7.

---

## 1. The sealed payload (schema `audrie-cert/v1`)

A signed PDF carries exactly one `/AudrieMeta` dictionary object, added by incremental update and
referenced from the document Catalog (`/Root`). Its `/Data` entry is a JSON object — the **sealed
payload**. The detached variant (§6) carries the identical object as a standalone file.

**`/Data` MUST be a plain, uncompressed PDF literal string, not inside an object stream and not
hex-encoded.** This is a normative requirement, not an implementation detail: §3.1 hashes the file
with signature values overwritten **in the raw bytes**, which is only possible if those bytes occur
verbatim. A compressed or object-stream-encoded `/AudrieMeta` is not a valid `audrie-cert/v1`
artifact.

Field order is irrelevant: canonicalization (§2) sorts keys.

| Field | Type | Description |
|---|---|---|
| `v` | integer | Schema version. MUST be `1`. |
| `schema` | string | MUST be `"audrie-cert/v1"`. |
| `kind` | string | `"pdf_embedded"` or `"record_only"`. Selects the document mode, and determines whether `signedDocHash` enters the signing digest (§3.2). |
| `originalDocHash` | string | `"sha256:<64 lowercase hex>"` of the **original** document bytes, before any Audrie content was appended. |
| `userId` | string | Pseudonymous signer identifier. Carries no PII by itself. Always present; `""` is itself a signed assertion that there is no human signer. |
| `orgId` | string | **Present only** for an organisation-signed record. Absent means no organisation binding. |
| `signerType` | string | **Present only** for an organisation-signed record, with value `"org"`. **Absence means a human signer** — this is the wire contract, not an inference. See §1.3. |
| `signers` | object | Algorithm-keyed map of signer entries. See §1.1. |
| `issuedAt` | string | RFC 3339 timestamp of issuance. |
| `nonce` | string | 32 lowercase hex characters (16 random bytes). |
| `audrie` | object | Ledger and convenience metadata. See §1.2. |
| `serverSeals` | object | Algorithm-keyed map of Audrie's seal entries. See §1.1. |

Every field above is always present **except `orgId` and `signerType`**, which are the only
omissible fields. That is deliberate and load-bearing: a human-signed payload's canonical bytes
are identical to one produced before the organisation feature existed, which is what keeps every
previously issued signature verifying.

### 1.1 `signers` and `serverSeals` — algorithm-keyed maps

```jsonc
"signers": {
  "ml-dsa-65": {
    "pub": "<base64url, unpadded — raw 1952-byte ML-DSA-65 public key>",
    "sig": "<6618 lowercase hex — raw 3309-byte ML-DSA-65 signature>"
  }
},
"serverSeals": {
  "ml-dsa-65": {
    "sig": "<6618 lowercase hex — raw 3309-byte ML-DSA-65 signature>",
    "keyVersion": "v1"
  }
}
```

**The algorithm identifier is the map key, and this is a security property, not a layout choice.**
Because it is a key, it is serialised into the canonical bytes and therefore **covered by the
seal**. An `{alg, sig}` blob excluded from the signed form would let an attacker relabel a
signature as a weaker algorithm; this shape closes that.

**Encodings are not uniform, and a verifier must not assume they are:**

| Field | Encoding |
|---|---|
| `signers[alg].pub` | base64url, **unpadded** (RFC 4648 §5) |
| `signers[alg].sig` | lowercase **hex** |
| `serverSeals[alg].sig` | lowercase **hex** |

For `ml-dsa-65` (FIPS 204): public key 1952 bytes; signature 3309 bytes → **6618 hex characters**.
The public key is the raw `MarshalBinary` form — **not** SPKI/DER, **not** PEM.

**`ml-dsa-65` MUST be present in both maps and MUST verify.** This is the downgrade guard: a
document offering only some other algorithm fails, even if that algorithm verifies. Everything
below rests on that requirement holding first.

**An entry whose algorithm you do not implement MUST be reported as unverified — never silently
skipped, and never treated as a pass.** But provided the required `ml-dsa-65` entry is present and
verifies, an additional entry you cannot evaluate does **not** fail the check. The distinction
matters in both directions:

- Silently ignoring an unrecognised entry would let an unverifiable document look valid.
- Failing outright on one would break agility the day Audrie adds a second algorithm: every already
  deployed verifier would start reporting valid documents as not verified. These maps exist to make
  that transition possible.

This is safe because the algorithm identifier is the **map key** and is therefore covered by the
seal: an attacker cannot add an entry without breaking Check 2. An entry you cannot evaluate means
Audrie added an algorithm, not that someone injected one.

### 1.2 The `audrie` sub-object

| Field | Type | Description |
|---|---|---|
| `platformVersion` | string | Issuing platform version. |
| `topicId` | string | Hedera Consensus Service topic ID, e.g. `"0.0.123456"`. |
| `recordId` | string | Audrie's record identifier (UUID). An input to the signing digest (§3.2) and to the ledger payload. |
| `transactionId` | string | Hedera transaction ID, e.g. `"0.0.123456@1750000000.000000000"`. |
| `hashscanUrl` | string | Human-readable explorer URL. Convenience only. |
| `verifyUrl` | string | Audrie's own verification UI. **Convenience only — never required.** |
| `mirrorNodeUrl` | string | A public Hedera mirror node URL for the transaction. Convenience only; **substitute any mirror node you prefer, or run your own** (§4, Check 3). |
| `rescueDoc` | string | Audrie's canonical location for **this** specification at the time the certificate was issued. A **provenance stamp, not a functional pointer**: no check in this document reads it, and verification is unaffected if it is stale, unreachable, or serving something else. Treat it as a hint about where to look, never as authority. |

Only `topicId`, `recordId` and `transactionId` are load-bearing. Every URL in this sub-object is a
convenience, and a verifier that distrusts Audrie should treat them as hints and resolve the
ledger independently.

### 1.3 `signerType` — why absence is the contract

`signerType` is emitted **only** for an organisation signature. Its absence means a human signer,
and a verifier MUST treat it that way rather than inferring from any other field.

This is what makes an organisation-issued signature unpresentable as a human sole-control one: the
discriminator is **inside the signed bytes** (§3.2), so a verifier deriving the human form of the
digest for an organisation signature gets a **mismatch**, not a false accept.

**`userId == ""` occurs only together with `signerType == "org"`.** A payload carrying an empty
`userId` with `signerType` absent satisfies §1's "no human signer" sentence and §1.3's
"absence means a human signer" rule at the same time, which is a contradiction, not a shape. A
verifier encountering it MUST **reject the payload** — never report it as a human signature with an
empty identifier, because §7's sole-control claim attaches to exactly that form.

`signerType` and `kind` are **orthogonal axes** and MUST NOT be collapsed. `kind` is the document
mode and decides whether `signedDocHash` enters the digest; `signerType` is who signed. An
organisation-signed `pdf_embedded` record is `signerType: "org"` **and** `kind: "pdf_embedded"`.

---

## 2. Canonicalization

All digests in this specification are computed over canonical JSON. The rules are RFC 8785 as
implemented by `protocol/canonical`:

1. **Object keys are sorted** by Unicode code point, **recursively at every level**.
2. **No whitespace** between tokens.
3. **Minimal string escaping.** Only `"`, `\` and control characters (U+0000–U+001F) are escaped.
   Forward slash, `<`, `>`, `&` and all non-ASCII characters are emitted **raw as UTF-8** — never
   `\u`-escaped. A canonicalizer that escapes non-ASCII (many defaults do) produces different
   bytes and every digest will be wrong.
4. **Arrays preserve insertion order.** Only objects are sorted.
5. **Numbers are integers.** A whole-valued float (`2.0`) is emitted as `2`. Non-integer numbers
   are not representable — such values are carried as strings.
6. `null`, `true`, `false` encode literally.
7. Output is UTF-8, with no byte-order mark and no trailing newline.

### 2.1 Worked example

Input (any key order):

```json
{ "b": 1, "a": { "d": "x/y", "c": "é" } }
```

Canonical output — 32 bytes (31 characters; `é` is two bytes), note the raw `/` and the raw UTF-8 `é`:

```
{"a":{"c":"é","d":"x/y"},"b":1}
```

---

## 3. Digests

### 3.1 `signedDocHash` — the whole-file hash (PDF variant only)

`signedDocHash` is SHA-256 over the **entire signed PDF**, computed in a specific intermediate
state:

> Take the final signed PDF bytes. For **each** entry in `signers`, locate its `sig` value in the
> raw bytes and overwrite every character of that value with ASCII `'0'`. Leave every
> `serverSeals[*].sig` **at its real value**. Hash the result.

```
signedDocHash = sha256( signedPdfBytes with each signers[*].sig overwritten by '0' × len )
```

It is expressed as **64 lowercase hex characters**, and enters both §3.2 and §3.4 prefixed with
`sha256:`. Uppercase hex or a `0x` prefix produces different canonical bytes and a confident,
wrong "invalid" verdict.

Three points a reference implementation must get right:

- **Only the signer signatures are zeroed.** The server seal is left real — it already existed
  when the file was assembled, which is what makes it possible to locate the signer's placeholder
  unambiguously at splice time.
- **Locate each signature by its value, not by field name.** Searching for the first `"sig":"`
  matches whichever entry appears first in the file and may be the server seal. Search for
  `"sig":"<the exact value>"`.
- **The length does not change.** Zeroing is in place, so no byte offsets shift and the hash is
  reproducible by anyone holding the file.

This is why a signer's signature cannot cover itself, and why whole-file tamper evidence lives in
the signer's signature rather than in the server seal — the seal is computed at issue time, before
the signed file exists.

### 3.2 `UserSigningDigest` — what the signer's key signed

A **domain-separated** object, so a signature minted in one context cannot be replayed in another:

```
UserSigningDigest = sha256( Canonicalize({
  "domain":          "audrie-cert/v1",
  "kind":            <"pdf_embedded" | "record_only">,
  "originalDocHash": "sha256:<hex>",
  "recordId":        "<uuid>",
  "signedDocHash":   "sha256:<hex>",   // present ONLY when kind == "pdf_embedded"
  "signerType":      "org"             // present ONLY for an organisation signature
}) )
```

Both conditional keys follow the same rule: **present or wholly absent — never empty, never
null.**

- `signedDocHash` is included **iff** `kind == "pdf_embedded"`.
- `signerType` is included **iff** the record is organisation-signed (§1.3).

The four resulting shapes and their digests are pinned in
[`verify-without-audrie-vectors.json`](verify-without-audrie-vectors.json). Verify your
canonicalizer against those before going further — a canonicalization mistake is the single most
likely cause of a correct document appearing invalid.


### 3.3 The seal canonical form — what Audrie's key signed

The server seal is computed over the sealed payload with **every** `signers[*].sig` **and every**
`serverSeals[*].sig` set to the **empty string** `""` — not a zero-width placeholder, not a hex
string of zeros — then canonicalized per §2.

```
sealCanonical = Canonicalize( payload with all signers[*].sig = "" and all serverSeals[*].sig = "" )
```

Note the asymmetry with §3.1, which is deliberate and a common implementation error: the
whole-file hash zeroes signature **characters in the raw file bytes** to preserve length; the seal
canonical form replaces signature **values in the parsed object** with the empty string. They are
different operations on different inputs.

Using `""` rather than a fixed-width placeholder makes the form algorithm-agnostic: a future
signature algorithm of a different length needs no change here.

The seal canonical bytes for a worked payload, and their SHA-256, are in the vectors file.

The `sealCanonical` vector's `pub` is a short placeholder, not a well-formed 1952-byte ML-DSA-65
key: it exercises canonicalization only. Do not calibrate the public-key length validation §1.1
requires against it — a correct verifier rejects that key, and rightly.

### 3.4 The ledger payload (`audrie-hcs/v1`)

The message anchored to Hedera is pseudonymous and carries no identity. In the default *compact*
mode it anchors SHA-256 **commitments** to key and signature material rather than the material
itself:

```jsonc
{
  "schema": "audrie-hcs/v1",
  "event": "SIGN",
  "anchorMode": "compact",
  "originalDocHash": "sha256:<hex>",
  "signedDocHash": "sha256:<hex>",     // pdf_embedded only
  "recordId": "<uuid>",
  "signerType": "org",                  // org records only — same absence rule as §1.3
  "ts": "<RFC3339>",
  "keyCommitments": { "ml-dsa-65": "<sha256 hex of the raw public key>" },
  "sigCommitments": { "ml-dsa-65": "<sha256 hex of the raw signature>" }
}
```

A verifier uses this to cross-check `recordId` and `signedDocHash` against the file (§4, Check 3).
Because compact mode anchors commitments, the on-chain message alone does not let a third party
re-verify the signature — that is what the document in your hand is for. The commitments do let
you confirm the key and signature you hold are the ones that were anchored.

**`anchorMode` values.** `"compact"` is the only mode emitted for `audrie-cert/v1`. A verifier
encountering any other `anchorMode` MUST report Check 3 as **unresolved**, never as failed — Check 3
does not gate the verdict (§4), so an unknown anchor mode must not turn a valid document invalid.

The shape above is the complete **verifier-facing** view of `audrie-hcs/v1`: any field not listed
here is not needed for Check 3 and may be ignored.

---

## 4. Verification procedure (PDF variant)

### Step 0 — Locate `/AudrieMeta`, and reject ambiguity

Parse the PDF and resolve the Catalog (`/Root`). Find the `/AudrieMeta` entry and read its `/Data`
string.

**More than one `/AudrieMeta` object ⇒ reject the file.**

The companion rule — that no signature value may appear twice in the raw bytes — is **Step 2**, not
here, because you cannot search for a signature value before parsing the payload that carries it.

### Step 1 — Parse and check the envelope

JSON-parse `/Data`, and apply **all five** of these before any cryptography:

1. Reject unless `schema == "audrie-cert/v1"` **and** `v == 1`. A document declaring anything else
   is not covered by this specification; do not attempt to verify it under these rules.
2. Reject unless `kind == "pdf_embedded"`. A `record_only` payload carried inside a PDF is not a
   valid artifact, and §6.2 — not this procedure — is what applies to `record_only`.
3. **Reject any JSON object in the payload that contains a duplicate key**, at any nesting level.
4. **Reject any key not defined in §1 or §1.2, and any key inside a `signers`/`serverSeals` entry
   not defined in §1.1.** This rule does **not** apply to the **map keys** of `signers` and
   `serverSeals` — those are algorithm identifiers, deliberately open-ended, and are handled
   per-entry under §1.1. A payload carrying a second algorithm alongside a verifying `ml-dsa-65`
   is valid.
5. **For each `signers` and `serverSeals` entry whose algorithm you implement**: reject a `sig`
   that is empty or not the expected width for that algorithm, and a `pub` whose decoded length is
   wrong. An entry whose algorithm you do not implement is set aside here and reported as
   unverified per §1.1 — it is not a rejection.

   These checks belong here, before any raw-byte search over signature values (Step 2), not inside
   the checks themselves. An empty `sig` would make that search match everywhere and reject the
   file for a reason that has nothing to do with its actual defect, while telling its holder so.

Rules 3 and 4 are security rules and are easy to omit by accident, because the natural way to write
a verifier hides them. Most JSON libraries parse into a typed structure that **silently drops
unknown keys** and takes **last-wins** on duplicates. An attacker who edits `/Data` on a genuine
certificate to add a key — an invented `"legalName"`, or a second `"verifyUrl"` — produces a file
where such a verifier rebuilds the canonical form *without* the injected key, the seal verifies,
the verdict is PASS, and any layer that displays the payload as received shows the attacker's
value. The signature never covered it, and nothing in the cryptography notices.

So a conforming verifier MUST parse into a structure that preserves what it received — and §3.3's
canonical form MUST be built from the payload **as received**, not from a struct that discarded
what it did not recognise.

### Step 2 — Reject duplicate signature values in the raw bytes

Now that the payload is parsed and each signature has passed Step 1's width checks, search the raw
file bytes for each `signers[*].sig` and `serverSeals[*].sig` value.

**A value that appears more than once ⇒ reject the file. A value that appears zero times ⇒ reject
the file.**

Both close the same hole as Step 0's rule. Field-zeroing (§3.1) works by locating a signature value
in the raw bytes: if it appears twice, an attacker controls which occurrence a verifier zeroes and
can construct a file that hashes two different ways; if it appears not at all — which is what a
compressed or object-stream `/AudrieMeta` produces, against §1's requirement — a verifier hashes
the file with the real signature still in place and reports a valid certificate as invalid.
Rejecting is the only safe response to either; "use the first one" is not.

### Check 1 — ML-DSA-65 signer signature (document integrity + attestation)

1. Recompute `signedDocHash` per §3.1.
2. Rebuild the digest per §3.2, with `kind = "pdf_embedded"`, the payload's `originalDocHash`, the
   `audrie.recordId`, `"sha256:" + signedDocHash`, and `signerType` **iff** the payload carries it.
3. Confirm `signers` contains `ml-dsa-65`. If not, **fail** (downgrade guard).
4. For **each** entry in `signers` whose algorithm you implement: decode `pub` as base64url and
   `sig` as hex, and verify the signature over the digest. Report an entry you cannot evaluate as
   unverified (§1.1); it does not fail this check, because step 3 already required `ml-dsa-65`.

**Pass ⇒** the file is byte-for-byte intact **and** the signer's key signed this exact document in
this exact context.

### Check 2 — ML-DSA-65 server seal (Audrie issued this)

1. Build the seal canonical form per §3.3, from the payload **as received**.
2. Confirm `serverSeals` contains `ml-dsa-65`. If not, **fail** (downgrade guard).
3. For **each** entry in `serverSeals` whose algorithm you implement: read its `keyVersion` and
   resolve the matching public key from the discovery document (§5). If **no** entry matches,
   report Check 2 as **unresolved** — not as a bad signature; the two mean different things to
   whoever reads the result. Report an entry whose algorithm you cannot evaluate as unverified
   (§1.1).
4. **Cross-check that the resolved key's `algorithm` equals the map key.** A mismatch is a fail,
   not a warning. This is what stops an attacker presenting a signature under one algorithm label
   while the key resolved for it is another.
5. **Corroborate the key — see below, including what is mandatory and what is not.**
6. Verify `sig` (hex) over the canonical bytes.
7. Read the resolved key's `revoked`, `validFrom` and `validUntil`, and **report that status
   alongside the cryptographic result.** A seal that verifies under a key marked `revoked` is
   never a silent pass.

**Pass ⇒** the metadata — record id, document hash, topic, transaction id, issuance time, the
signer's public key, and the algorithm identifiers — was bound together by Audrie and not
fabricated.

#### Step 5, and why the whole document depends on it

Be clear about what this check rests on. `overall` does not include the ledger check, so **the
public key you resolve in step 3 is the sole trust root of the entire verdict.** If you take it
from a live fetch of `https://audrie.io/.well-known/...` and nothing else, you have rooted your
verification in Audrie's DNS and TLS — which is exactly the dependency this document exists to
remove. Anyone able to serve that URL could publish their own `ml-dsa-65` key as `v1`, mint a
signer keypair, and issue a payload binding any identifier to any document hash.

**A verifier MUST therefore record how the key was obtained, and MUST NOT present an uncorroborated
seal as equivalent to a corroborated one.** Corroborate by either:

- **The on-chain `KEY_PUBLISH` commitment.** Each seal key version is anchored to Hedera as a
  message of the form below, on Audrie's shared platform topic. Compute `sha256` over the raw
  public-key bytes you resolved and confirm it equals `pubKeyHash` for your `keyVersion` and `alg`.
  This needs nothing from Audrie but a mirror node, and is the option to prefer.

  ```jsonc
  { "schema": "audrie-hcs/v1", "event": "KEY_PUBLISH",
    "alg": "ml-dsa-65", "keyVersion": "v1",
    "pubKeyHash": "<sha256 hex of the raw public key>", "ts": "<RFC3339>" }
  ```

- **A copy of the discovery document obtained before this certificate reached you** — pinned,
  cached, or mirrored. The comparison is **byte-identical raw public-key bytes for the same
  `keyVersion`**; merely *possessing* an older copy corroborates nothing. Any difference is a
  **fail**. That `keyVersion` being absent from the earlier copy is **not** corroboration — it is
  no evidence either way.

> **Known gap, stated rather than papered over.** Locating the `KEY_PUBLISH` message requires
> Audrie's shared platform topic id, and that id is **not currently carried in the discovery
> document** (§5) or anywhere else a third party can read. So today the on-chain path is available
> only to a verifier who already knows that topic, and the pinned-copy comparison is the one most
> readers can actually perform. Until that is fixed,
> **corroboration is a SHOULD, not a MUST** —
> making it a MUST would only mean specifying something a conforming verifier cannot do.
>
> What does **not** relax is the reporting obligation: a verifier MUST state which corroboration it
> performed, or that it performed none. The difference between "Audrie issued this" and "whoever
> controls audrie.io today says Audrie issued this" must reach whoever reads the result.

### Check 3 — Hedera anchor (public-ledger timestamp, optional)

Entirely independent of Audrie. Using `audrie.topicId` and `audrie.transactionId`, against **any**
public Hedera mirror node:

1. Fetch the transaction: `GET /api/v1/transactions/{transactionId}` (the id in dashed mirror
   form, `0.0.N-sec-nanos`). Confirm `result == SUCCESS` and that `entity_id` equals `topicId`.
2. Take `sequence_number` from that response and fetch the message:
   `GET /api/v1/topics/{topicId}/messages/{sequenceNumber}`. **Base64-decode the `message`
   field**, then parse it as the `audrie-hcs/v1` payload (§3.4). The transaction endpoint does not
   return the message body; this second call is what gets it.
3. Branch on `anchorMode` (§3.4). For `"compact"`, cross-check **every field the anchor carries
   that you can independently derive** — at minimum `recordId` **and** `originalDocHash`, plus
   `signedDocHash` when `kind == "pdf_embedded"`. Where `keyCommitments` and `sigCommitments` are
   present, also confirm that `sha256(raw public key)` and `sha256(raw signature)` for each
   algorithm you verified match them.
4. Record the consensus timestamp.

**Cross-check all of them, not just `recordId`.** Checking one field does not establish Check 3's
stated guarantee, and for `record_only` there is no `signedDocHash` on chain — so a verifier that
compares only `recordId` is comparing an opaque identifier and nothing else. Concretely: a holder
of a compromised seal key can mint a `record_only` payload that reuses a genuine record's
`recordId`, `topicId` and `transactionId`, substitutes their own `originalDocHash` and their own
signer keypair, and back-dates `issuedAt`. Check 1 passes on their keypair, Check 2 on the stolen
seal key, and a `recordId`-only Check 3 passes against the real anchor. Comparing
`originalDocHash` — which the anchor always carries — breaks that.

**Pass ⇒** a record with these contents existed no later than that consensus timestamp, on a
ledger Audrie does not operate and cannot rewrite.

**What it does not establish: that the topic is Audrie's.** `topicId` and `transactionId` come from
the payload, and the payload is what you are trying to verify. Anyone can create a Hedera topic and
submit a self-consistent `audrie-hcs/v1` message to it, so every cross-check in step 3 passes
against an anchor the document's own author chose. Check 3 therefore proves *existence by a
timestamp*, not *issuance by Audrie* — that is Check 2's job, which is why Check 3 does not gate
the verdict and must never be used to upgrade a Check 2 that did not pass.

### Composing the verdict

**Check 2 is not two-valued, so the verdict cannot be either.** It has three outcomes, and a
verifier MUST distinguish them because they mean different things to whoever reads the result:

| Check 2 state | When |
|---|---|
| **pass** | the seal verified under a resolved key |
| **unresolved** | no discovery entry matched the `(keyVersion, algorithm)` pair (Check 2 step 3), the discovery document was rejected (§5, duplicate matching entries), or the seal verified under a key marked `revoked` (§5) |
| **fail** | the signature did not verify, `serverSeals` carried no `ml-dsa-65` entry (step 2, downgrade guard), the resolved key's `algorithm` disagreed with the map key (step 4), or a corroborating earlier copy disagreed byte-for-byte (step 5) |

```
overall = (Check 1 passes) AND (Check 2 == pass)
```

Anything else is **not verified** — `unresolved` is never folded into a pass. Report alongside it:
the corroboration performed (step 5), the resolved key's `revoked` / `validFrom` / `validUntil`
status (step 7), and Check 3's own result.

**Checks 1 and 2 are fully offline and both post-quantum**, and together they constitute valid
verification. Check 3 is reported **separately** and does not gate `overall` — it legitimately
fails when you are offline, or when the transaction is not yet indexed by the mirror node you
chose.

Run each check independently: a failure or an exception in one MUST NOT abort the others. A
verifier that stops at the first failure tells its user less than it knows.

---

## 5. The server-seal key discovery document

`GET https://audrie.io/.well-known/audrie-server-keys.json` — unauthenticated.

```jsonc
{
  "version": 1,
  "currentKeyId": "v1",
  "keys": [
    {
      "keyId": "v1",
      "keyVersion": "v1",
      "algorithm": "ml-dsa-65",
      "publicKey": "<base64url, unpadded — raw 1952-byte ML-DSA-65 public key>",
      "validFrom": "<RFC3339 or null>",
      "validUntil": "<RFC3339 or null>",
      "revoked": false
    }
  ]
}
```

Select the entry whose `keyVersion` equals the `keyVersion` in the `serverSeals` entry you are
verifying **and** whose `algorithm` equals that entry's map key. `keyId` and `keyVersion` carry the
same value; there is no additional indirection.

Select on the **pair**, not on `keyVersion` alone. A second seal algorithm published under the same
version label is the natural shape of an algorithm migration, and selecting on the version alone
would make that an ambiguity — or, with the duplicate rule below, would reject the entire discovery
document and report every previously valid certificate as unverified.

**What the status fields mean to a verdict**, so that two conforming verifiers agree on the same
document after a key rotation:

- **No entry matches** the `keyVersion` you need ⇒ Check 2 is **unresolved**. Report it as such,
  not as a failed signature.
- **More than one entry matches that pair** ⇒ **reject the discovery document**. `keys` is an
  array and nothing structurally prevents two identical `(keyVersion, algorithm)` entries;
  selection is then undefined, and first-match and last-match implementations resolve different
  keys while Check 2 step 4's cross-check passes either way. This is a rejection, not a selection
  problem — the same reasoning as Step 0 and Step 2 apply to the file itself.
- **`validFrom` / `validUntil`** are **informational**. Verification proceeds regardless, and the
  window status is reported alongside the cryptographic result. Do not silently fail a seal for
  falling outside the window.
- **`revoked: true`** ⇒ see the caveat immediately below. It is not the permissive rule it may
  look like.

> **`issuedAt` cannot establish that a document was sealed before a key was revoked.** It lives
> inside the payload that the key itself seals, so under a compromised key it is attacker-chosen: a
> holder of a stolen seal key can mint a payload with any `issuedAt` they like and back-date it
> behind the revocation. The only timestamp an attacker does not choose is the Hedera consensus
> timestamp from Check 3.
>
> **And this document carries no revocation timestamp to compare it against.** The schema above has
> `revoked` as a bare boolean — no `revokedAt` — so even the consensus timestamp cannot be placed
> before or after the revocation. Until such a field exists, the honest rule is the conservative
> one: **a seal under a revoked key is reported `unresolved`, always.** Not valid, not invalid.
> Anything stronger would be a guarantee this procedure cannot establish.

**This endpoint is a transport, not a trust root.** Fetching it is convenient; *believing it on its
own* is the one thing that would quietly undo this document's central claim, because the key you
get from it is what the whole verdict rests on (§4, Check 2, step 5).

What makes a dead or hostile Audrie survivable is that the key can be obtained, or checked, without
trusting this URL — see §4, Check 2, step 5 for how, **including the gap noted there**. The document
is cacheable and mirrorable indefinitely, and a verifier that expects to outlive Audrie should
retain a copy.

---

## 6. Detached variant — `record_only`

For a signer who does not want the original file modified, Audrie issues a **detached**
certificate: the original file, unchanged, plus a companion `audrie-cert.json`.

### 6.1 Structure

`audrie-cert.json` is the **same sealed payload object** described in §1, with `kind` set to
`"record_only"`. There is no `signedDocHash` anywhere in the flow — nothing was embedded, so there
is no signed-file hash to compute.

### 6.2 Verification

1. **Apply §4 Step 1's envelope rules to `audrie-cert.json` first** — rule 1 (`schema`/`v`),
   rule 3 (no duplicate JSON keys), rule 4 (no undefined keys) and rule 5 (per-entry algorithm and
   width checks), with rule 2 inverted: **reject unless `kind == "record_only"`**. Build §3.3's
   canonical form from the file **as received**.

   Do not skip this because the file looks simpler than a PDF. It is a plain JSON file on disk that
   anyone can edit, so the injected-key attack §4 Step 1 describes is **strictly easier** here than
   in the embedded variant.

2. Hash the original file: `sha256(fileBytes)`, and confirm it equals the payload's
   `originalDocHash` (minus the `sha256:` prefix). **This replaces Check 1's whole-file step** and
   is the integrity check for this variant.
3. Rebuild the digest per §3.2 with `kind = "record_only"` — **no `signedDocHash` key** — and
   verify each `signers` entry exactly as in Check 1, steps 3–4.
4. Verify the server seal exactly as in Check 2, corroboration step included.
5. Verify the ledger anchor as in Check 3. There is no `signedDocHash` on chain for this kind, so
   `originalDocHash` carries the binding — cross-check it, or the anchor step compares nothing but
   an opaque `recordId`.

The two files together are the complete artifact; either alone is incomplete.

---

## 7. Why this still satisfies eIDAS AES (Article 26)

Dropping PAdES/CMS packaging does not weaken the Advanced Electronic Signature claim under Article
26 of Regulation (EU) No 910/2014. Article 26 is **technology-neutral**: it states four behavioural
criteria — unique linkage to the signatory, identification of the signatory, sole-control signing
keys, and tamper evidence — without mandating a container format.

The whole-file scheme here satisfies the tamper-evidence criterion at least as strongly as a PAdES
`/ByteRange` gap, and arguably more so: the excluded byte ranges are fixed by **this
specification** rather than declared by an array inside the document, which an attacker can
influence.

For the full normative mapping, see
[`../security/archive/pdf-cert-eidas-aes-mapping.md`](../security/archive/pdf-cert-eidas-aes-mapping.md).

---

## 8. Implementation checklist

A minimal, complete verifier needs:

- [ ] A PDF parser able to resolve the Catalog and read an arbitrary named object's raw bytes —
      and to detect a **duplicate** `/AudrieMeta` (§4, Step 0).
- [ ] A raw-byte search over the file, to locate signature values by value and to confirm each
      appears exactly once.
- [ ] An RFC 8785 canonicalizer matching §2 — in particular **raw UTF-8, not `\u` escapes**.
- [ ] SHA-256.
- [ ] base64url **unpadded** decoding (for public keys) and hex decoding (for signatures). They are
      not interchangeable; see §1.1.
- [ ] A FIPS 204 ML-DSA-65 verifier.
- [ ] An HTTP client for the discovery document (§5) — or a cached copy of it.
- [ ] Optionally, a Hedera mirror-node client for Check 3.

Validate your implementation against [`verify-without-audrie-vectors.json`](verify-without-audrie-vectors.json)
**before** running it against a real document. A canonicalization or encoding mistake produces a
confident, wrong "invalid" verdict on a perfectly good certificate, and that is the failure mode
this specification exists to prevent.
