# Evidence Snapshot — bundle & verification spec (v1)

This document is the contract shared by the web PWA and the Flask batch tool.
Any re-implementation must produce byte-identical `evidence_digest` values from
the same inputs.

---

## 1. `evidence_digest`

The single value that gets timestamped. It binds the target URL, the declared
capture time, the third-party archive links, and every page-capture hash together.

### 1.1 Canonical input object

```jsonc
{
  "archives": [ { "service": "archive.today", "url": "https://archive.ph/abc12" } ],
  "artifacts": [ { "filename": "01-weibo.png", "sha256": "<hex>" } ],
  "declared_capture_time_utc": "2026-09-02T14:25:00Z",   // or null
  "target_url": "https://weibo.com/1234567890/AbCdEf"
}
```

Rules:

- **Keys sorted** ascending at every level (`archives`, `artifacts`,
  `declared_capture_time_utc`, `target_url`; and within each element
  `filename` < `sha256`, `service` < `url`).
- **`artifacts` array sorted by `filename`** ascending (byte order of the UTF-8
  string). `archives` kept in the order the user entered them.
- Only `filename` + `sha256` of each artifact enter the digest. The manifest's
  `kind` / `source` fields (§4) are descriptive metadata about a capture, **not**
  part of `evidence_digest` — the `sha256` already binds the bytes, and keeping
  the canonical input minimal keeps cross-implementation agreement simple.
- **No whitespace** — `JSON.stringify` with no `space` argument, equivalent
  `json.dumps(obj, separators=(",", ":"), sort_keys=True, ensure_ascii=False)`.
- `sha256` values are **lower-case hex**, 64 chars.
- `declared_capture_time_utc` is an ISO-8601 UTC instant ending in `Z`, second
  precision, or JSON `null` if the user did not supply one.
- `target_url` is stored exactly as the user entered it (no normalisation) —
  the archive link is the tamper-evident copy, this field is only a pointer.

### 1.2 Digest

```
canonical_bytes = UTF-8( canonical_json )
evidence_digest = SHA-256( canonical_bytes )        // 32 bytes, lower-case hex in the manifest
```

`manifest.json` stores both `canonical_string` (the exact JSON text) and
`evidence_digest` (hex) so a verifier can re-derive without rebuilding the object.

---

## 2. Timestamps

Both mechanisms assert over the **raw 32 bytes** of `evidence_digest` (not over a
further hash of it).

### 2.1 OpenTimestamps (`timestamp.ots`)

- Library: `@lacrypta/typescript-opentimestamps` (UMD global `opentimestamps`,
  ~30 KB, zero runtime deps). Vendored at `vendor/opentimestamps.min.js`.
- Stamped with `opentimestamps.submit("sha256", evidenceDigestBytes)` — the
  library treats the 32-byte value as a SHA-256 output, so the `.ots` proof
  commits directly to `evidence_digest`. Serialised with `opentimestamps.write`,
  re-read with `opentimestamps.read`, upgraded with `opentimestamps.upgrade`,
  verified with `opentimestamps.verify(ts, opentimestamps.verifiers)`.
- Submitted to the library's default calendar pool.
- Immediately after stamping the proof is **pending** (calendar attestation
  only). After ~1–24 h it can be **upgraded** to carry a Bitcoin block
  attestation, at which point it is self-verifying against the Bitcoin
  blockchain forever.
- CLI verify: `ots verify -d <evidence_digest hex> timestamp.ots`

### 2.2 RFC-3161 (`timestamp.tsr`, optional)

- `messageImprint` = `{ hashAlgorithm: sha256, hashedMessage: evidenceDigestBytes }`.
  The imprint value **is** `evidence_digest` — we do not hash it again.
- `certReq` = TRUE so the TSA response embeds its signing cert chain; the chain
  is also written to `tsa/tsa-cert-chain.pem` for offline verification.
- TSA is tried in order: `https://freetsa.org/tsr`,
  `https://zeitstempel.dfn.de`, `https://timestamp.sectigo.com`. The first
  `application/timestamp-reply` with `status == granted` wins. `manifest.json`
  records which TSA answered.
- CLI verify:
  `openssl ts -verify -digest <evidence_digest hex> -in timestamp.tsr -CAfile tsa/tsa-cert-chain.pem`

If every TSA fails the bundle is still valid with OpenTimestamps alone; the
manifest `timestamps` array simply omits the `rfc3161` entry.

---

## 3. Bundle layout

```
evidence-<first12 of evidence_digest>-<YYYYMMDD>/
  manifest.json
  evidence.txt            human-readable summary
  VERIFY.txt              step-by-step verification instructions
  captures/               (screenshots/ in v1.0 bundles — verifiers accept both)
    01-<sanitised original name>   PNG screenshot, MHTML, PDF, or HTML
    02-<...>
  timestamp.ots
  timestamp.tsr           (only if an RFC-3161 stamp succeeded)
  tsa/
    tsa-cert-chain.pem     (only with timestamp.tsr)
```

Capture bytes are copied **verbatim** — never re-encoded, cropped, stripped
of metadata, or watermarked.

---

## 4. `manifest.json`

```jsonc
{
  "schema": "evidence-snapshot/manifest",
  "version": 1,
  "tool": { "name": "Evidence Snapshot", "variant": "web|batch", "version": "1.0.0" },
  "created_utc": "2026-09-02T14:30:12Z",

  "capture": {
    "target_url": "https://weibo.com/1234567890/AbCdEf",
    "declared_capture_time_utc": "2026-09-02T14:25:00Z",
    "capture_method": "user-provided page captures | headless full-page (chromium <ver>)",
    "user_note": "free text, may be empty"
  },

  "archives": [
    { "service": "archive.today", "url": "https://archive.ph/abc12",
      "recorded_utc": "2026-09-02T14:28:00Z" }
  ],

  "artifacts": [
    { "filename": "01-weibo.png", "bytes": 483920, "mime": "image/png",
      "kind": "screenshot",              // screenshot | mhtml | pdf | html | other
      "source": "self",                  // self | archive.today | web.archive.org | other
      "sha256": "<hex>" }                // "sha512" MAY also be present; verifiers check it if so
  ],

  "evidence_digest": {
    "algo": "sha256",
    "canonical_string": "{\"archives\":[...],\"artifacts\":[...],\"declared_capture_time_utc\":...,\"target_url\":...}",
    "value": "<hex>"
  },

  "timestamps": [
    { "type": "opentimestamps", "file": "timestamp.ots",
      "target": "evidence_digest", "status": "pending|bitcoin",
      "upgraded_utc": null },
    { "type": "rfc3161", "file": "timestamp.tsr",
      "target": "evidence_digest", "hash_algo": "sha256",
      "tsa_url": "https://freetsa.org/tsr", "ca_file": "tsa/tsa-cert-chain.pem",
      "gen_time_utc": "2026-09-02T14:30:05Z" }
  ]
}
```

---

## 5. Verification algorithm

A bundle **passes** when all of the following hold:

1. **Artifact integrity** — for every entry in `artifacts`, the file under
   `captures/` (or `screenshots/` in v1.0 bundles) hashes to the recorded
   `sha256` (and `sha512` if present).
2. **Digest reproduction** — rebuilding the §1.1 object from
   `capture.target_url`, `capture.declared_capture_time_utc`, `archives`
   (service+url only), and `artifacts` (filename+sha256 only — `kind`/`source`
   are ignored here), then
   canonicalising per §1.1, yields a string equal to
   `evidence_digest.canonical_string`, whose SHA-256 equals
   `evidence_digest.value`.
3. **At least one timestamp verifies:**
   - *OpenTimestamps* — `timestamp.ots` commits to `evidence_digest.value`; if
     status is `bitcoin`, the block attestation is valid and its time is
     returned; if `pending`, report "calendar-only, not yet Bitcoin-anchored".
   - *RFC-3161* — `timestamp.tsr` signature verifies against
     `tsa/tsa-cert-chain.pem`, its `messageImprint` equals
     `evidence_digest.value`, and `genTime` is returned.
4. **Archive reachability** (advisory, needs network) — each `archives[].url`
   resolves to a stored snapshot.

The earliest verified timestamp time is the **"existed no later than"** bound
for the whole bundle.
