# Tradexx Electronic Signing Evidence Protocol — TESP/1.0

This specification lets a third party, with no access to Tradexx systems or source code, recompute every
hash in an evidence package and check the blockchain corroboration. It is normative for `protocol_version:
"TESP/1.0"`.

## 1. Primitives

* **Hash**: SHA-256 (FIPS 180-4). Rendered as 64 lowercase hex characters. On-chain it is the raw 32 bytes
  (`bytes32`).
* **Canonical JSON**: RFC 8785 (JSON Canonicalization Scheme). Object keys sorted by UTF-16 code units,
  no insignificant whitespace, strings escaped per RFC 8785, numbers per ECMAScript `Number.toString`.
  TESP additionally **forbids non-integer numbers**: every quantity that could have a fractional or
  locale-dependent representation (timestamps, amounts, gas) is a string. Integers must fit in 53 bits;
  anything larger (block numbers are fine, wei values are not) is a decimal string.
* **Timestamps**: RFC 3339 in UTC with millisecond precision, `Z` suffix, e.g. `2026-09-09T22:14:03.117Z`.
* **Signature**: Ed25519 (RFC 8032) over the raw 32 bytes of `evidenceHash`. Public keys are published at
  `https://sign.tradexx.us/.well-known/tesp-keys.json` (`{ keys: [{ key_id, alg: "Ed25519", public_key_hex, valid_from, valid_to }] }`).

## 2. Artifacts of a completed signing

| File | Content | Hash |
|---|---|---|
| `contract.pdf` | The **final** PDF exactly as stored: frozen contract pages + Tradexx certificate page (QR, verification id, abbreviated hashes) | `documentHash = SHA-256(bytes)` |
| `evidence.json` | Canonical JSON (RFC 8785) of the evidence record, stored byte-for-byte | `evidenceHash = SHA-256(bytes)` |
| `evidence.sig.json` | Detached signature: `{ protocol_version, evidence_sha256, alg, key_id, signature_hex }` | — |
| `evidence.pdf` | Human-readable rendering of `evidence.json`; **informative, not authoritative** | listed inside `evidence.json` only if generated before sealing (it is not; see §6) |

The original, unsigned contract bytes are also retained (`document_versions`), and their hash appears in
`evidence.json` as `document.source.sha256`, so a party holding the original file can prove what the
signer was shown before the certificate page was appended.

## 3. `documentHash`

`documentHash` is the SHA-256 of the complete final PDF file. The final PDF is produced once:
1. Load the frozen document version bytes (`document.source.sha256`).
2. Append the certificate page (deterministic content: verification id, request title, signer roles,
   signing timestamps, `document.source.sha256`, QR to `https://sign.tradexx.us/v/<verification_token>`).
   PDF metadata is fixed (`Producer`, `Creator`, `CreationDate`/`ModDate` = signing completion instant,
   `Keywords` = `TESP/1.0 VID:<verification_token>`), and the PDF trailer ID is derived from
   SHA-256 of the inputs, so regenerating from identical inputs yields identical bytes.
3. Write the bytes to storage, compute SHA-256, record both. The bytes are never modified afterwards.

Any byte change — including re-saving in a PDF editor — changes `documentHash` and verification fails.

## 4. `evidence.json` schema (v1.0)

```jsonc
{
  "protocol_version": "TESP/1.0",
  "evidence_id": "<uuid>",
  "generated_at": "<rfc3339>",
  "issuer": { "name": "Tradexx", "service": "sign.tradexx.us", "signing_key_id": "<key_id>" },
  "signing_request": {
    "id": "<uuid>", "verification_token": "<token>", "title": "...",
    "signature_level": "electronic|advanced|qualified", "signing_mode": "sequential|parallel",
    "source": { "system": "tablenn", "type": "booking", "id": "..." } | null,
    "created_at": "...", "sent_at": "...", "completed_at": "..."
  },
  "document": {
    "source":  { "sha256": "<hex>", "mime": "application/pdf", "size": 12345, "version": 1, "filename": "..." },
    "final":   { "sha256": "<hex>", "mime": "application/pdf", "size": 23456 },
    "certificate_page": { "appended": true, "verification_url": "https://sign.tradexx.us/v/<token>" }
  },
  "consent": {
    "version": "<consent_version>", "locale": "en", "text_sha256": "<hex>",
    "text": "<exact text shown>"
  },
  "parties": [{
    "party_id": "<uuid>", "role": "customer", "signing_order": 1,
    "identity": {
      "claims": { "name": "…", "email": "…", "phone": "…" },
      "verification": { "provider": "email-otp", "method": "otp", "level": "electronic",
                         "verified_at": "...", "channel": "email", "destination_masked": "j***@e***.com",
                         "challenge_id": "<uuid>", "provider_ref": null }
    },
    "session": { "session_id": "<uuid>", "first_seen_at": "...", "ip_addresses": ["..."], "user_agents": ["..."] },
    "timeline": { "opened_at": "...", "identity_verified_at": "...", "document_viewed_at": "...",
                  "consent_displayed_at": "...", "consent_accepted_at": "...", "signature_started_at": "...", "signed_at": "..." },
    "consent_event": { "consent_version": "...", "text_sha256": "<hex>", "accepted_at": "...", "ip": "...", "user_agent": "...", "session_id": "..." },
    "signature": { "provider": "internal-electronic", "method": "click|typed|drawn", "level": "electronic",
                    "signed_at": "...", "ip": "...", "user_agent": "...", "typed_name": "…", "image_sha256": "<hex>|null",
                    "document_sha256_at_signing": "<hex of source>" },
    "psc": null | { "provider": "...", "certificate": { "subject": "...", "issuer": "...", "serial": "...", "not_before": "...", "not_after": "..." }, "external_ref": "...", "signed_at": "..." }
  }],
  "timestamps": {
    "application": { "signed_at": "...", "finalized_at": "...", "evidence_generated_at": "..." },
    "database":    { "signed_at": "..." },
    "blockchain":  { "block_timestamp": "..." } ,
    "psc":         { "signed_at": "..." } | null,
    "note": "Each source keeps its own clock; the blockchain timestamp is corroboration, not the primary time."
  },
  "audit_trail": {
    "algorithm": "sha256(prev_event_hash_hex || canonical(event))",
    "genesis_prev_hash": "0000…0000",
    "events": [ { "event_id": "...", "seq": 1, "type": "SIGNING_REQUEST_CREATED", "occurred_at": "...", "actor": {"type": "admin", "id": "..."},
                  "ip": "...", "session_id": "...", "correlation_id": "...", "metadata": {...}, "prev_hash": "<hex>", "event_hash": "<hex>" } ],
    "head_hash": "<hex of last event>"
  },
  "blockchain": {
    "status": "pending",
    "chain_id": 8453, "network": "base-mainnet", "contract_address": "0x…",
    "anchored_document_hash": "<hex>", "anchored_evidence_hash": "<see §5>",
    "protocol_version_onchain": 1
  }
}
```

## 5. `evidenceHash` and the anchoring circularity

The blockchain transaction hash cannot be inside the JSON whose hash is anchored. TESP/1.0 resolves this
by sealing the evidence **before** anchoring:

1. `evidence.json` is generated with `blockchain.status = "pending"` and without `tx_hash`, `block_number`,
   `block_timestamp`, `confirmations`. `anchored_evidence_hash` is the literal string `"self"`.
2. `evidenceHash = SHA-256(canonical bytes)`; the bytes are stored and never modified.
3. `anchor(documentHash, evidenceHash)` is sent. The confirmed result (tx hash, block, block timestamp,
   confirmations, chain id, contract) is stored in the database and in `evidence.sig.json`
   (`anchor` object) and on the certificate/verification page.
4. Verification: recompute `SHA-256(evidence.json)`, then read the chain — `anchors(documentHash)` on the
   contract returns `(evidenceHash, timestamp)`; both must match the recomputed values.

`evidence.sig.json` therefore carries: the Ed25519 signature over `evidenceHash`, and the anchor record.
The anchor record is itself verifiable on-chain, so it needs no signature.

## 6. Audit hash chain

For each aggregate (`signing_request`), events are numbered `seq` 1..n. For each event:

```
event_core = { event_id, aggregate_type, aggregate_id, seq, type, occurred_at, actor, ip, session_id, correlation_id, metadata }
event_hash = SHA-256( prev_hash_hex || canonical(event_core) )     // ASCII concatenation, prev_hash as 64 hex chars
prev_hash(seq=1) = "0" × 64
```

A verifier replays the events in `seq` order and must obtain every `event_hash` and the `head_hash`.
Events are append-only in the database (trigger forbids `UPDATE`/`DELETE`); each insert takes a
transaction-level advisory lock on the aggregate so `seq` and `prev_hash` cannot fork.

## 7. Verification procedure (normative)

Given `contract.pdf` and optionally `evidence.json` + `evidence.sig.json`:

1. `documentHash := SHA-256(contract.pdf)`.
2. Query the contract on the declared chain: `anchors(documentHash) → (evidenceHash, timestamp)`.
   If `timestamp == 0` → **NOT ANCHORED** (either not a Tradexx-signed final document, modified, or still pending).
   Optionally read the `DocumentAnchored(documentHash, …)` event for the tx hash/block.
3. If `evidence.json` is available: `h := SHA-256(evidence.json bytes)`; require `h == evidenceHash`
   and `evidence.json.document.final.sha256 == documentHash` → **EVIDENCE VERIFIED**.
4. Verify the Ed25519 signature in `evidence.sig.json` against the published key for `key_id` → **ISSUER VERIFIED**.
5. Replay the audit chain (§6) → **AUDIT TRAIL INTACT**.
6. A document whose PDF metadata carries `VID:<token>` but whose hash is not anchored under that token's
   record is reported as **MODIFIED** (the record exists, the bytes differ).

Steps 1–2 need only the PDF and any RPC endpoint of the chain (`cli/tradexx-sign-verify.js` implements
them with raw JSON-RPC and no Tradexx dependency). Steps 3–5 need the package files. Only the token lookup
of step 6 talks to `sign.tradexx.us`.

## 8. Versioning

`protocol_version` changes when any hash input, canonicalization rule or schema field changes meaning.
Additive optional fields are allowed within 1.x. The on-chain `PROTOCOL_VERSION` (uint16) changes only if
the meaning of the two anchored hashes changes.

## 9. Revision 2 (additive, 2026-09-10) — rendering and storage provenance

Still `TESP/1.0`: the hashed inputs and their meaning are unchanged; the following **optional** fields were
added to `evidence.json` and verifiers must ignore fields they do not know.

```jsonc
"document": {
  "source": { …, "storage": { "provider": "minio", "bucket": "tradexx-sign", "object_key": "originals/<doc>/v1-<sha>.pdf", "version_id": "…", "size": 12345, "sha256": "<hex>", "encrypted_at_rest": true, "immutability": "policy|retention" } },
  "final":  { …, "renderer": { "provider": "tradexx-doc-service/1.0.0", "template_version": "tsx-cert/1", "rendered_at": "<rfc3339>", "render_ms": 812 },
                 "storage": { … same shape as above … } }
}
```

Rules: provenance never contains credentials, endpoints, presigned URLs or anything needed to *reach* the
object — only where the canonical bytes live and their hash. `evidence.sig.json` is unchanged. The CLI
prints provenance when present and needs no storage access: steps 1–5 of §7 verify without it.

The service also records, per completed signing, an `INVARIANT_VERIFIED` audit event with
`H1_renderer` (hash of the renderer output), `H2_stored` (re-read of the stored object), `H3_database`,
`H4_evidence` and `H5_onchain`; a request reaches `completed` only when all five are equal.

## 10. Revision 3 (additive, 2026-09-10) — identity verification evidence

Still `TESP/1.0`. Each party record gains an `identity_verification` object and a richer `timeline`; verifiers that
do not know these fields ignore them. Nothing in this section is biometric: no images, embeddings, templates or
scores — only results, references, consent hashes and server-side timestamps (UTC, ms precision).

```jsonc
"identity_verification": {
  "level": "standard | enhanced | enhanced_plus",
  "policy": { "otp_required": true, "liveness_required": true, "document_required": false },
  "otp": { "verified": true, "verified_at": "…", "channel": "email", "destination_masked": "a***@e***.com", "challenge_id": "…" },
  "biometric_consent": { "version": "2026-09-bio-v1", "text_sha256": "<hex>", "text": "<exact text shown>", "displayed_at": "…", "accepted_at": "…", "ip": "…", "user_agent": "…", "session_id": "…" },
  "liveness": { "provider": "sumsub", "verification_id": "<provider external id>", "applicant_id": "<provider id>", "result": "passed", "result_code": "GREEN", "attempt": 1,
                "started_at": "…", "completed_at": "…", "valid_until": "…", "max_age_seconds": 600, "receipt_hash": "<sha256 of the provider receipt>", "receipt_storage_key": "identity-evidence/…", "signing_session_id": "…", "bound_to_signature": true },
  "identity_verified_at": "…"
},
"timeline": { "otp_verified_at", "biometric_consent_accepted_at", "liveness_started_at", "liveness_completed_at", "liveness_valid_until", "identity_verified_at",
              "document_viewed_at", "document_review_completed_at", "consent_displayed_at", "signing_consent_accepted_at", "signature_started_at", "signature_completed_at", "liveness_to_signature_seconds": 12 }
```

The `receipt_hash` binds the provider's decision object (stored encrypted by Tradexx, retrievable from the
provider by reference) to the evidence: SHA-256 over `JSON.stringify(receipt, sortedKeys)`. Because the whole
record is canonicalised, hashed and signed, `evidenceHash` — and therefore the blockchain anchor — indirectly
covers the liveness evidence. The chain itself still receives two SHA-256 values only.

## 11. TESP/1.1 (additive minor version, 2026-09-10) — qualified signatures and Enhanced+ identity

`protocol_version` becomes `"TESP/1.1"` and the record carries `compatible_with: ["TESP/1.0"]`. Canonicalisation (RFC 8785), `evidenceHash`, the Ed25519 seal, the audit hash chain and the anchoring rules are **unchanged**: a TESP/1.0 verifier validates a 1.1 package (it ignores the new sections); the CLI verifies both versions. The version was bumped (not a revision of 1.0) because §7 gains an optional verification step.

New fields:

- `signing_request.mixed_signature_levels` (boolean), `signing_request.allowed_document_types` (Enhanced+ policy) and `parties[].signature_level` (effective level per party).
- `document.final.pades` — present when the final PDF carries cryptographic (PAdES) signatures: `{ base_sha256, signatures, validation }` where `base_sha256` is the renderer output (all visible content, certificate page and QR) **before** any cryptographic signature, and `validation` is the server-side report recorded before `completed` (`valid`, `count`, per-signature certificate summary, signing time and its source, timestamp, chain anchor, checks, provider, sandbox/mock flags, trust anchors).
- `parties[].qualified` — for qualified parties: PSC `provider` / `protocol` / `sandbox`, `credential_id`, `auth_mode`, `certificate` (subject, issuer, serial, validity, SHA-256 fingerprint, key type, PSC status at selection), `certificate_chain_pem` (public certificates only), `signature_index`, `field_name`, `base_sha256`, `byte_range_digest_sha256` (the PAdES messageDigest), `hash_to_sign_sha256` (digest of the DER signed attributes that the PSC signed), `signed_document_sha256`, the authorization timeline (`intent_at`, `prepared_at`, `authorization_started_at`, `authorized_at`, `signed_at`, `time_source`), `timestamp` (RFC 3161 token summary), `pades_validation` and `psc_evidence` (provider responses with secrets redacted).
- `parties[].identity_verification.identity_document` and `.face_match` (Enhanced+): document type, issuing country, **masked** number, keyed-hash-based reuse detection is *not* exported, validity/issue dates, capture method, NFC flag, checks, reject labels, receipt hash, binding to the liveness session.
- `parties[].timeline`: `qualified_intent_at`, `psc_authorization_started_at`, `psc_authorized_at`, `psc_signature_completed_at`.
- Audit event types added: `TRADEXX_SIGNING_CONSENT_ACCEPTED`, `QUALIFIED_SIGNATURE_REQUESTED`, `PSC_SESSION_CREATED`, `PSC_CREDENTIALS_LISTED`, `PSC_CREDENTIAL_SELECTED`, `DOCUMENT_RENDERED`, `QUALIFIED_TURN`, `PSC_SIGNATURE_AUTHORIZATION_STARTED`, `PSC_SIGNATURE_AUTHORIZED`, `PSC_SIGNATURE_COMPLETED`, `PSC_SIGNATURE_FAILED`, `PSC_SIGNATURE_REJECTED`, `PSC_SIGNATURE_CANCELLED`, `PSC_ERROR`, `PADES_VALIDATED`, `IDENTITY_DOCUMENT_VERIFIED`, `IDENTITY_DOCUMENT_REJECTED`, `FACE_MATCH_VERIFIED`, `FACE_MATCH_FAILED`.

### 11.1 Verification procedure additions (step 6, optional)

When `document.final.pades` is present (or the PDF carries `/Type /Sig` dictionaries), an independent verifier SHOULD, for every signature in file order: (a) check `/ByteRange` = `[0, a, b, len-b]` enclosing exactly the `/Contents` hex string; (b) compute the digest (algorithm from the CMS `digestAlgorithm`) over the two ranges and compare it with the CMS `message-digest` signed attribute; (c) verify the `SignerInfo.signature` over the DER of the signed attributes (re-tagged as SET) with the certificate carried in the CMS whose issuer/serial matches `sid`; (d) check the ESS `signing-certificate-v2` hash against that certificate; (e) build the chain to the PSC/national trust anchors (`--trust roots.pem`); (f) take the signing time from the RFC 3161 `signature-time-stamp` token when present, else from `/M` (marked *claimed*), and check certificate validity at that time; (g) require the **last** signature to cover the whole file. The number of signatures MUST equal `document.final.pades.signatures`. `tradexx-sign-verify` implements this (`PDF cryptographic signature (PAdES)` check).

### 11.3 Identity provider extension (2026-09-10, still TESP/1.1 — additive)

`parties[].identity_verification` gains, for enhanced/enhanced_plus parties (all optional, absent for standard):

- `provider { name, display_name, environment (live|test|sandbox|mock), session_id, report_id, liveness_asserted, semantics, resumed_count, billable_sessions, redacted_at }` — which provider verified the party, in which environment, and its opaque references (Stripe `vs_…` / `vr_…`). `environment != live` marks a non-real verification.
- `document { provider_session_id, verification_report_id, type, issuing_country, result (verified|unverified), verified_at, environment, capture_method }` — the government ID check; never the number (masked in `identity_document.number_masked` only when a sensitive key is configured), never images.
- `face_verification { matching_selfie_required, result (verified|unverified), method, liveness_asserted }` — the selfie ↔ document-portrait comparison. `liveness_asserted=false` (Stripe) means no separate live-presence assertion is claimed.
- `provider_result { result, result_code, completed_at, environment }`.

Verifiers ignore unknown fields; canonicalisation, `evidenceHash`, the seal and the anchor are unchanged. Audit event types added: `LIVENESS_SESSION_RESUMED`, `STRIPE_IDENTITY_SESSION_CREATED`, `STRIPE_IDENTITY_SESSION_RESUMED`, `IDENTITY_DOCUMENT_SUBMITTED`, `IDENTITY_VERIFICATION_PROCESSING`, `IDENTITY_VERIFICATION_REQUIRES_INPUT`, `IDENTITY_VERIFICATION_FAILED`, `IDENTITY_FACE_MATCH_VERIFIED`, `IDENTITY_ENVIRONMENT_REJECTED`, `STRIPE_IDENTITY_REDACTED`, `IDENTITY_REDACTION_REQUESTED`, `LEGAL_HOLD_SET`, `LEGAL_HOLD_CLEARED`.

### 11.2 Hash relationships

`document.final.sha256` = SHA-256 of the complete signed file (what is anchored, H1..H5). `document.final.pades.base_sha256` = SHA-256 of the renderer output before signatures (also the `document_sha256` recorded on qualified `signatures` rows: the bytes the signer authorized). For each qualified signature, `byte_range_digest_sha256` is the PAdES messageDigest and `hash_to_sign_sha256` the value sent to the PSC; nothing else is ever sent.
