# EquaSign — Proof dossier sealing convention (SIGNATURE.md)

**Scope.** How EquaSign seals a signed document's *proof dossier* so that a third
party can verify it **off-server** — from the sealed PDF alone, with no access to
the site that produced it. This is the instance-side counterpart of the EquaSuite
license signing convention (same canonicalization + Ed25519 primitive).

## 1. What is sealed

At sealing time EquaSign freezes an immutable proof dossier (JSON) with these
components:

1. **header** — `{ product, plugin_version, format_version, generated_at }`.
2. **embedded documents** — `documents[]` with `name`, `source_sha256`,
   `signed_sha256`, `signature_mode`, and per-document `checkboxes[]`.
3. **hashes** — the SHA-256 fingerprints above (source render and signed render).
4. **identity** — `signer { name, email, phone }`, plus `ip` and `user_agent`
   captured at sealing.
5. **OTP proof** — `otp_sent`, `otp_validated`, `otp_channel`, `otp_destination`
   (the channel and destination that carried the one-time code, in clear, as an
   evidential element).
6. **journal** — a timestamped event log, **one line per event, IP per line**
   (`type`, `at`, `ip`, `user_agent`, `data`).
7. **signature timestamp** — `sealed_at` (UTC).
8. **seal** — the detached Ed25519 signature block (see §3).

The dossier is stored **encrypted at rest** (AES-256-GCM / XChaCha20-Poly1305 under
`EQUASIGN_KEK`) and a human-readable copy of every field — including the full seal
material — is printed on the proof page bound into the signed PDF package.

## 2. Canonicalization

The bytes that are signed/verified are produced by the shared EquaSuite rule:

1. Take the dossier **body** = the whole dossier **minus its `seal` key**.
2. Recursively sort every object by key (`ksort` at every depth).
3. Encode as UTF-8 JSON with `JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE`.

This is exactly `EquaSign_License_Verifier::canonicalize()`, reused so signing and
verification never drift.

## 3. The seal block

```json
"seal": {
  "algo": "ed25519",
  "kid": 1,
  "public_key": "<base64, 32 bytes>",
  "signature": "<base64 detached Ed25519 signature, 64 bytes>",
  "canonical_sha256": "<hex SHA-256 of the canonical body bytes>",
  "sealed_at": "<UTC ISO-8601>"
}
```

- `signature` is `Ed25519(secret_key, canonical_body_bytes)`, detached.
- `public_key` is embedded so the dossier is self-describing.
- `canonical_sha256` is a convenience anchor for a manual verifier (it must equal
  the SHA-256 of the canonical body you recompute).

## 4. Instance key

- Each install mints **one** Ed25519 keypair, lazily, on first sealing.
- The **secret key** is stored **encrypted at rest** under `EQUASIGN_KEK`
  (`equasign_seal_secret`, base64 of the encrypted blob) — never emitted, logged or
  exported. The **public key** is stored in clear (`equasign_seal_public`).
- Sealing requires the KEK. Without it, signing is already blocked, so no
  unprotected secret is ever written; a dossier produced in that (degraded) state
  carries no `seal` and the proof page says so explicitly.
- `kid` is `1` and only changes on a manual key rotation.

## 5. How to verify off-server

Given a dossier `D` (extractable from the proof page / the API `proof` response):

1. `S = D.seal`; `B = D` without the `seal` key.
2. Canonicalize `B` per §2 → `msg`.
3. Check `SHA-256(msg) == S.canonical_sha256` (optional sanity anchor).
4. `Ed25519_verify(base64_decode(S.signature), msg, base64_decode(S.public_key))`.

Reference implementation (libsodium, PHP):

```php
$B = $D; unset( $B['seal'] );
$sort = function ( &$d ) use ( &$sort ) {
    if ( is_array( $d ) ) { ksort( $d ); foreach ( $d as &$v ) { $sort( $v ); } }
};
$sort( $B );
$msg = json_encode( $B, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
$ok  = sodium_crypto_sign_verify_detached(
    base64_decode( $D['seal']['signature'] ),
    $msg,
    base64_decode( $D['seal']['public_key'] )
);
```

A valid signature proves the dossier has not been altered since sealing and that it
was produced by the holder of the install's secret key. It does **not** by itself
bind the key to a legal identity — that binding is an operational/PKI concern
outside this document.

## 6. Single generator

The signed PDF package (admin download and any future email attachment) is produced
by **one** function at sealing time (`EquaSign_Pdf::assemble_package()` over the
dossier built by `EquaSign_Proof::build()`); there is no second, divergent
generation path. Same input → same hash → same seal.
