# Offline Verification Spec

Status: **DOCUMENTED**; reference verifier is delivered as
`scripts/verify-offline.mjs`. Zero network dependency, zero dependency on
`signatrust.net` remaining reachable.

## Goal

If Signatrust as a service disappears, receipts issued during its lifetime
must remain verifiable by any party holding:

- the receipt JSON,
- the correct signing-key trust bundle for that issuer at that time, and
- optionally, the referenced sector schema and any pinned policy
  documents.

Nothing else. No network. No proprietary binary.

## What the offline verifier checks

For a single receipt:

| Check | Verifies | Failure mode |
|-------|----------|--------------|
| Receipt recognised | JSON parses; expected top-level fields present | Malformed receipt |
| Body integrity | `sha256(canonical(body)) == receipt_hash` | Body tampered |
| Ed25519 signature | `signature.value` verifies against `receipt_hash` under `signature.public_key` | Forged / re-signed |
| Issuer key / trust anchor | `signature.public_key` matches one of the keys in the supplied trust bundle for the receipt's `timestamp` window | Untrusted or expired key |
| Chain linkage | `previous_hash` matches predecessor when the caller supplies the tail | Chain break |
| Hashing profile present | `decision.input_profile` / `decision.output_profile` present when the caller requests content re-verification | Reported as `content_reverification_impossible` — not a failure of the receipt itself |
| Content re-verification | For each supplied original content: profile is applied → recomputed hash matches recorded hash | Content mismatch |
| Sector schema pin | Supplied schema file hashes to `scope_declaration.sector_schema_hash` | Schema mismatch |
| DBD arithmetic | `coverage_classification` reproduces from `domains_evaluated` + `domains_excluded` against `sector_schema` | DBD reclassification |
| Policy bindings | Each supplied policy document hashes to its `policy_hash`; unbound policies reported as UNKNOWN | Policy mismatch |
| Attestations | Each attestation signature verifies under its declared `attestor_id` public key from the trust bundle | Attestation mismatch |

The verifier prints a structured report and exits non-zero if any check
fails with a hard failure. UNKNOWN checks (missing profile, missing
supplied policy, missing supplied content) are reported but do **not**
cause a non-zero exit — the receipt is still cryptographically valid.

## Trust bundle format

An issuer trust bundle is a single JSON file with a stable schema.
Backward compatible additions are allowed; incompatible changes bump
`format`.

```jsonc
{
  "format": "signatrust-trust-bundle-v1",
  "issuer": {
    "id": "signatrust-node-mainnet-1",
    "display_name": "Signatrust — reference node",
    "genesis_hash": "0000…"
  },
  "keys": [
    {
      "key_id": "sig-2026-01",
      "algorithm": "ed25519",
      "public_key": "MCowBQYDK2VwAyEA…",
      "fingerprint": "sha256:…",
      "valid_from": "2026-01-01T00:00:00Z",
      "valid_to": "2027-12-31T23:59:59Z",
      "status": "active",
      "rotation_notes": "Rotated after 2 years, no compromise."
    },
    {
      "key_id": "sig-2028-01",
      "algorithm": "ed25519",
      "public_key": "MCowBQYDK2VwAyEA…",
      "fingerprint": "sha256:…",
      "valid_from": "2028-01-01T00:00:00Z",
      "valid_to": null,
      "status": "active"
    }
  ],
  "revocations": [],
  "attestor_keys": [ /* same shape, for external attestation verifiers */ ],
  "generated_at": "2026-08-27T00:00:00Z"
}
```

### Semantics

- Each key entry describes exactly one Ed25519 public key with an issuance
  window.
- A receipt is trusted iff its `signature.public_key` matches an entry
  whose `valid_from ≤ receipt.timestamp ≤ (valid_to or ∞)` **and** whose
  `status ∈ {"active", "retired"}`. A `"revoked"` status is a hard fail
  for receipts whose `timestamp` is inside the revocation window; the
  bundle must record the effective revocation time.
- **Historical revocation semantics** are recorded but do **not**
  automatically invalidate earlier receipts. A retroactive revocation is
  a policy decision the verifier surfaces to the caller — the trust
  bundle carries a `revocations[]` array with `effective_from` and
  `reason` fields; the verifier reports "receipt signed with a key later
  revoked" as an advisory, not a hard fail, unless the caller specifies
  `--strict-revocation`.

Rationale: pretending that a revocation retroactively invalidates every
past signature is misleading — the signature was valid at the moment it
was produced. Whether the historical evidence is still considered
trustworthy is a policy call for the reader.

## Long-term key discovery

The running node's discovery document only exposes the **currently**
active key. It is not sufficient for archival verification. Deployments
that expect their receipts to survive the service must therefore:

1. Publish a signed trust bundle (`signatrust-trust-bundle-v1`) at
   regular intervals or on every key rotation, whichever is sooner.
2. Distribute the bundle to customers as part of the Offline
   Verification Bundle (below).
3. Keep an internal archive of every historical key with its issuance
   metadata.

The reference node exposes an endpoint (`GET /.well-known/signatrust/trust-bundle.json`,
DOCUMENTED, wired in a follow-up) that serves the current bundle. When
the service disappears, customers rely on the bundle copies they
downloaded during the operational period.

## Offline Verification Bundle

A single `.tar` or `.zip` archive that ships alongside a receipt for
hand-off to an auditor:

```
verification-bundle-STR-…/
  receipt.json
  trust-bundle.json          # signatrust-trust-bundle-v1
  hashing-profiles.json      # profile ids used, with references
  schemas/
    hospital-triage-protocol.v1.json    # if scope_declaration references it
  policies/
    hospital-triage-protocol_2026.04.pdf
    hospital-triage-protocol_2026.04.manifest.json  # policy_hash, source
  attestations/
    reviewer-attestation.json           # if applicable
  content/                              # optional; only if the customer
                                        # chooses to include original
                                        # content for content-level
                                        # re-verification
  README.txt                            # verifier command + explanation
```

Verifier invocation:

```
node scripts/verify-offline.mjs \
     --bundle ./verification-bundle-STR-… \
     [--content ./content/original.pdf] \
     [--strict-revocation]
```

or in package form once published:

```
npx @signatrust/verify-offline --bundle ./verification-bundle-STR-…
```

Exit code:

- `0`: every check that could be evaluated is PASS or UNKNOWN.
- `1`: any hard failure (body / signature / chain / trust anchor / content
  hash / schema hash / policy hash / attestation signature).
- `2`: bundle malformed.

## What this does NOT prove

- The signing-key holder identity beyond the trust bundle you were given.
- That the original content was correct, complete, or lawful.
- That the pinned policy was itself lawful or ethical.
- That any human review was independent, competent, or actually
  performed — unless a corresponding attestation receipt is included and
  its own signature verifies.
- That the timestamp reflects real wall-clock time (see
  `LONG_TERM_VERIFICATION_AND_CRYPTO_AGILITY.md` §Trusted Time).

## Mandatory tests

- `verify_known_good_bundle_passes`
- `verify_tampered_body_fails`
- `verify_forged_signature_fails`
- `verify_wrong_key_fails`
- `verify_policy_hash_mismatch_fails`
- `verify_schema_hash_mismatch_fails`
- `verify_missing_content_reports_unknown_not_fail`
- `verify_missing_profile_reports_unknown_not_fail`
- `verify_revoked_key_advisory_by_default_hard_fail_when_strict`
- `verify_chain_break_flagged_as_chain_check_only`
