$x) { $o[$k] = $sort($x); } if (!$isList) { ksort($o, SORT_STRING); } return $o; } return $v; }; return json_encode($sort($v), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); } function b64d(string $s): string|false { return base64_decode(strtr($s, '-_', '+/'), true); } function ed25519_verify(string $pubkeyB64, string $msg, string $sigB64): bool { $pk = b64d($pubkeyB64); $sig = b64d($sigB64); if ($pk === false || $sig === false) return false; if (strlen($pk) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES || strlen($sig) !== SODIUM_CRYPTO_SIGN_BYTES) return false; return sodium_crypto_sign_verify_detached($sig, $msg, $pk); } function entry_hash(array $e): string { return hash('sha256', implode("\n", [ (string) $e['seq'], $e['prev_hash'], $e['server_ts'], $e['payload_hash'], $e['actor_sub'], $e['counterparty_sub'] ?? '', $e['actor_sig'], ])); } function signed_content(string $recorderId, array $e): string { return jcs([ 'v' => 1, 'recorder_id' => $recorderId, 'event_type' => $e['event_type'], 'actor_sub' => $e['actor_sub'], 'counterparty_sub' => $e['counterparty_sub'] ?? null, 'payload_hash' => $e['payload_hash'], 'client_ts' => $e['client_ts'] ?? null, ]); } function mleaf(string $hex): string { return hash('sha256', "\x00".hex2bin($hex)); } function mnode(string $l, string $r): string { return hash('sha256', "\x01".hex2bin($l).hex2bin($r)); } function merkle_from_proof(string $entryHashHex, array $proof): string { $cur = mleaf($entryHashHex); foreach ($proof as $step) { $cur = ($step['side'] === 'right') ? mnode($cur, $step['hash']) : mnode($step['hash'], $cur); } return $cur; } /** * Verify a decoded disclosure bundle. Pure: no I/O, no printing, no exit — so it * is reusable as a library (the Touchstone MCP verify tool require()s this file * with TOUCHSTONE_VERIFY_AS_LIB defined and calls this). Each check carries * `fatal` = whether it contributes to the overall verdict (anchor-presence lines * are informational and never fail an otherwise-sound entry). * * @param array $b * @return array{ok:bool,format_ok:bool,error?:string,recorder?:string,subject?:string,tier?:string,entries?:array}>} */ function verify_disclosure(array $b): array { if (($b['format'] ?? '') !== 'touchstone-disclosure/1') { return ['ok' => false, 'format_ok' => false, 'error' => 'not a touchstone-disclosure/1 bundle']; } // structural guard: a malformed top-level shape rejects gracefully (never crashes). // A disclosure must carry a recorder object and a non-empty entries list (genesis at least); // checkpoints are optional, and a non-list value means "none" (matches verifier.js / py). $rec = $b['recorder'] ?? null; $entries = $b['entries'] ?? null; $checkpoints = (isset($b['checkpoints']) && \is_array($b['checkpoints'])) ? $b['checkpoints'] : []; if (!\is_array($rec) || !\is_array($entries) || \count($entries) === 0) { return ['ok' => false, 'format_ok' => false, 'error' => 'malformed bundle structure']; } foreach ($entries as $e) { if (!\is_array($e)) { return ['ok' => false, 'format_ok' => false, 'error' => 'malformed bundle structure']; } } foreach ($checkpoints as $cp) { if (!\is_array($cp)) { return ['ok' => false, 'format_ok' => false, 'error' => 'malformed bundle structure']; } } $pubkey = (string) ($rec['signing_pubkey'] ?? ''); $recorderId = (string) ($rec['public_id'] ?? ''); $serverPub = (isset($b['server_pubkey']) && \is_string($b['server_pubkey']) && $b['server_pubkey'] !== '') ? $b['server_pubkey'] : null; // Signing-key epochs (key rotation): pick the key active at a given seq. Only well-formed // epochs participate; a malformed one is dropped (its rotated key is then untrusted → // fail-closed), matching verifier.js / touchstone-verify. $epochs = $rec['signing_keys'] ?? []; $epochs = \is_array($epochs) ? array_values(array_filter($epochs, static fn ($k) => \is_array($k) && \is_int($k['from_seq'] ?? null) && \is_string($k['pubkey'] ?? null))) : []; usort($epochs, static fn ($x, $y) => (int) $x['from_seq'] <=> (int) $y['from_seq']); $keyForSeq = static function (int $seq) use ($epochs, $pubkey): string { $k = $pubkey; foreach ($epochs as $ep) { if ((int) ($ep['from_seq'] ?? 0) <= $seq) { $k = (string) ($ep['pubkey'] ?? ''); } else { break; } } return $k; }; $cpById = []; foreach ($checkpoints as $cp) { $cpById[$cp['id'] ?? ''] = $cp; } $bySeq = []; foreach ($entries as $e) { $sq = $e['seq'] ?? null; if (\is_int($sq) || \is_string($sq)) { $bySeq[$sq] = $e; } } $ok = true; $entriesOut = []; // ── Checkpoint verification: server signature + append-only chain ────── $cpChecks = []; $cpAdd = function (bool $pass, string $msg, bool $fatal) use (&$cpChecks, &$ok): void { $cpChecks[] = ['ok' => $pass, 'fatal' => $fatal, 'msg' => $msg]; if ($fatal) { $ok = $ok && $pass; } }; // Key-epoch list must be backed by the chain: each epoch past genesis must be // introduced by the key_rotation entry at its from_seq-1 (when that entry is in the // bundle), so an attacker can't inject {from_seq, attacker_pubkey} and forge entries // from there. (Genesis is bound by the seq-0 proof-of-possession, checked per-entry.) foreach ($epochs as $ep) { try { if ((int) ($ep['from_seq'] ?? 0) === 0) { continue; } $rot = $bySeq[(int) $ep['from_seq'] - 1] ?? null; if ($rot === null) { $cpAdd(true, "key epoch from seq {$ep['from_seq']} relies on a key_rotation entry not in this disclosure — verify the full chain or rely on a server-signed checkpoint", false); continue; } $rb = !empty($rot['body_enc']) ? (json_decode((string) $rot['body_enc'], true) ?: []) : []; $cpAdd(($rot['event_type'] ?? '') === 'key_rotation' && ($rb['new_signing_pubkey'] ?? null) === $ep['pubkey'], "key epoch from seq {$ep['from_seq']} introduced by its key_rotation entry", true); } catch (\Throwable $ex) { $cpAdd(false, 'malformed key epoch — rejected', true); } } // Honest binding note: the genesis PoP proves the KEY-HOLDER claims subject_sub; it does // NOT prove the key is that subject's. Without a Touchstone-server-signed checkpoint, the // subject→key binding is the bundle's own claim — confirm it independently. $cpAdd(true, 'attribution binds to the genesis key, which self-claims subject "'.$rec['subject_sub'].'"; confirm that key at /.well-known/touchstone/pubkeys/'.$rec['subject_sub'].' (or rely on a server-signed checkpoint) — this bundle alone does not prove the subject→key binding', false); $cps = array_values($cpById); usort($cps, static fn ($x, $y) => ($x['seq_start'] ?? 0) <=> ($y['seq_start'] ?? 0)); $prevCp = null; foreach ($cps as $cp) { try { // strict checkpoint field types — reject type-confused checkpoints uniformly // (matches verifier.js / touchstone-verify). if (!\is_int($cp['id'] ?? null) || !\is_int($cp['seq_start'] ?? null) || !\is_int($cp['seq_end'] ?? null) || !\is_string($cp['merkle_root'] ?? null) || !\is_string($cp['head_hash'] ?? null)) { $cpAdd(false, 'malformed checkpoint field types — rejected', true); $prevCp = $cp; continue; } // The server (recorder) signature commits the Merkle root. if ($serverPub) { $cpAdd(ed25519_verify($serverPub, $cp['merkle_root'], $cp['recorder_sig'] ?? ''), "checkpoint #{$cp['id']} root signed by Touchstone server", true); } else { $cpAdd(true, "checkpoint #{$cp['id']} server signature unverifiable (no server_pubkey in bundle)", false); } // Append-only: adjacent checkpoints must be contiguous + hash-linked. A // partial disclosure may skip intermediate checkpoints, in which case the // linkage isn't checkable from the bundle — verify the full chain via the feed. if ($prevCp !== null) { if ((int) $cp['seq_start'] > (int) $prevCp['seq_end'] + 1) { $cpAdd(true, "checkpoint #{$cp['id']} follows #{$prevCp['id']} (intermediate checkpoints not in this disclosure — verify the full chain via the feed)", false); } else { $contig = (int) $cp['seq_start'] === (int) $prevCp['seq_end'] + 1; $linked = !empty($cp['prev_checkpoint_hash']) && hash_equals((string) $cp['prev_checkpoint_hash'], (string) $prevCp['head_hash']); $cpAdd($contig && $linked, "checkpoint #{$cp['id']} extends #{$prevCp['id']} append-only (contiguous + hash-linked)", true); } } // Independent witnesses (split-view resistance). foreach (($cp['witnesses'] ?? []) as $w) { $msg = "touchstone-cp-witness:v1:{$recorderId}:{$cp['id']}:{$cp['merkle_root']}:{$cp['head_hash']}"; $cpAdd(ed25519_verify($w['witness_pubkey'], $msg, $w['witness_sig']), "checkpoint #{$cp['id']} witnessed by {$w['witness_sub']} ({$w['grade']})", true); } // External Nostr mirror — fetch the event from relays and compare (split-view resistance). if (!empty($cp['nostr_event_id']) && \is_array($b['nostr'] ?? null)) { $cpAdd(true, "checkpoint #{$cp['id']} mirrored to Nostr (event ".substr($cp['nostr_event_id'], 0, 12).'…, kind '.$b['nostr']['kind'].'); fetch from a relay and compare', false); } // External ordering: the OTS→Bitcoin commitment is the *fork tie-breaker*. Detection // (Nostr mirror) shows two heads can't both hide; this shows which one wins — of // conflicting heads at one seq, the canonical one is committed to Bitcoin at the // lowest block, an ordering no party here can forge. (See gossip_check.py to resolve.) $btcHeight = null; foreach (($cp['anchors'] ?? []) as $a) { if (!\is_array($a)) { continue; } if ($a['method'] === 'ots' && $a['status'] === 'confirmed') { $blob = \is_string($a['token_blob'] ?? null) ? (json_decode($a['token_blob'], true) ?: []) : []; if (!empty($blob['bitcoin_height'])) { $btcHeight = (int) $blob['bitcoin_height']; break; } } } if ($btcHeight !== null) { $cpAdd(true, "checkpoint #{$cp['id']} head committed to Bitcoin at block {$btcHeight} — fork tie-breaker: of conflicting heads at this seq, the lowest block wins", false); } } catch (\Throwable $ex) { $cpAdd(false, 'malformed checkpoint — rejected', true); } $prevCp = $cp; } foreach ($entries as $e) { $checks = []; $add = function (bool $pass, string $msg, bool $fatal) use (&$checks, &$ok): void { $checks[] = ['ok' => $pass, 'fatal' => $fatal, 'msg' => $msg]; if ($fatal) { $ok = $ok && $pass; } }; // 0. strict field types — reject type-confused entries uniformly (string seq, // numeric subs, etc.) instead of coercing them differently per language. $typesOk = \is_int($e['seq'] ?? null); foreach (['prev_hash', 'entry_hash', 'payload_hash', 'actor_sub', 'actor_sig'] as $sf) { $typesOk = $typesOk && \is_string($e[$sf] ?? null); } if (\array_key_exists('counterparty_sub', $e) && $e['counterparty_sub'] !== null && !\is_string($e['counterparty_sub'])) { $typesOk = false; } if (!$typesOk) { $add(false, 'malformed entry field types — rejected', true); $entriesOut[] = ['seq' => \is_int($e['seq'] ?? null) ? $e['seq'] : -1, 'type' => (string) ($e['event_type'] ?? ''), 'redacted' => false, 'checks' => $checks]; continue; } try { // 1. integrity: entry_hash recomputes $add(hash_equals($e['entry_hash'], entry_hash($e)), 'entry_hash integrity', true); // 2. attribution: signature against the key active at this seq (epoch-aware) $seqKey = $keyForSeq((int) $e['seq']); if ((int) $e['seq'] === 0) { $challenge = 'touchstone-pop:v1:'.$rec['subject_sub'].':'.$seqKey; $add(ed25519_verify($seqKey, $challenge, $e['actor_sig']), 'genesis proof-of-possession', true); } else { $add(ed25519_verify($seqKey, signed_content($recorderId, $e), $e['actor_sig']), 'actor signature (subject key)', true); } // key rotation: the new key must prove possession (authorized by the current key above) if (($e['event_type'] ?? '') === 'key_rotation' && !empty($e['body_enc'])) { $rb = json_decode((string) $e['body_enc'], true) ?: []; $np = (string) ($rb['new_signing_pubkey'] ?? ''); $rmsg = "touchstone-rotate:v1:{$recorderId}:{$np}"; $add(ed25519_verify($np, $rmsg, (string) ($rb['new_key_pop'] ?? '')), 'key rotation → new key proof-of-possession', true); } // counterparty co-signature (non-repudiation), if present if (!empty($e['counterparty_sig']) && !empty($e['counterparty_pubkey'])) { $cpass = ed25519_verify($e['counterparty_pubkey'], signed_content($recorderId, $e), $e['counterparty_sig']); $grade = $e['counterparty_grade'] ?? 'claimed'; $add($cpass, "counterparty co-signature ({$e['counterparty_sub']}, {$grade})", true); } elseif (!empty($e['counterparty_sub'])) { $add(true, "names counterparty {$e['counterparty_sub']} (awaiting co-signature)", false); } // 3. chain linkage (if predecessor is in the bundle) if (isset($bySeq[$e['seq'] - 1])) { $add(hash_equals($e['prev_hash'], $bySeq[$e['seq'] - 1]['entry_hash']), 'chain linkage to seq '.($e['seq'] - 1), true); } // 4. merkle inclusion + anchor. merkle_proof is an array (possibly empty: a single-entry // checkpoint's proof IS empty, root == leaf) — run whenever present, matching the JS/Py verifiers. if (\is_int($e['checkpoint_id'] ?? null) && \is_array($e['merkle_proof'] ?? null) && \array_is_list($e['merkle_proof']) && isset($cpById[$e['checkpoint_id']])) { $cp = $cpById[$e['checkpoint_id']]; $root = merkle_from_proof($e['entry_hash'], $e['merkle_proof']); $add(hash_equals($cp['merkle_root'], $root), "merkle inclusion in checkpoint #{$cp['id']}", true); $anyConfirmed = false; foreach (($cp['anchors'] ?? []) as $a) { if (!\is_array($a)) { continue; } $tb = \is_string($a['token_blob'] ?? null) ? $a['token_blob'] : ''; $blob = json_decode($tb, true) ?: []; if ($a['method'] === 'self' && $a['status'] === 'confirmed') { // Verify the server signature over the head — don't trust the status label. $selfOk = $serverPub ? ed25519_verify($serverPub, $cp['head_hash'], $tb) : null; if ($selfOk === false) { $add(false, "self anchor signature INVALID (checkpoint #{$cp['id']})", true); } else { $add(true, "anchored (self, {$a['external_ts']})".($selfOk ? ' — server signature verified' : ''), false); $anyConfirmed = true; } } elseif ($a['method'] === 'ots' && $a['status'] === 'confirmed' && !empty($blob['bitcoin_height'])) { $corr = !empty($blob['corroborated_by']) ? ', '.count($blob['corroborated_by']).' explorers agree' : ''; $add(true, "OTS → Bitcoin block {$blob['bitcoin_height']} ({$a['external_ts']}{$corr}); cryptographic check: ots verify", false); $anyConfirmed = true; } elseif ($a['method'] === 'ots') { $host = $blob['calendar'] ?? 'calendar'; $add(true, "OTS submitted ($host), Bitcoin-pending (re-check with ots upgrade/verify)", false); } elseif ($a['method'] === 'tsa' && $a['status'] === 'confirmed') { $host = parse_url($blob['tsa'] ?? '', PHP_URL_HOST) ?: 'TSA'; $add(true, "RFC 3161 timestamp ($host, {$a['external_ts']}); cryptographic check: openssl ts -verify", false); $anyConfirmed = true; } } if (!$anyConfirmed) { $add(false, 'no confirmed external anchor yet', false); } } else { $add(false, 'not yet checkpointed/anchored (integrity+signature only)', false); } // Selective disclosure: each revealed field must prove inclusion in payload_hash // (the Merkle root the subject signature already covers). Withheld fields stay hidden. if (!empty($e['sd'])) { $revealed = (isset($e['sd_revealed']) && \is_array($e['sd_revealed'])) ? $e['sd_revealed'] : []; // non-list → nothing revealed (matches verifier.js / py) // Committed key-set: proves the FULL set of fields (so a discloser can't silently // drop one); makes "N committed, M revealed, these withheld" checkable, not asserted. $committed = null; if (\is_array($e['sd_keyset'] ?? null) && !empty($e['sd_keyset'])) { // non-array (e.g. "x") → no key-set commitment (matches verifier.js / py) $keys = \is_array($e['sd_keyset']['keys'] ?? null) ? $e['sd_keyset']['keys'] : []; sort($keys, SORT_STRING); $ksLeaf = hash('sha256', "tsd:keyset:v1\n".jcs(array_values($keys))); $add(hash_equals($e['payload_hash'], merkle_from_proof($ksLeaf, $e['sd_keyset']['proof'] ?? [])), 'selective-disclosure key-set committed ('.\count($keys).' fields)', true); $committed = $keys; } foreach ($revealed as $f) { $leaf = hash('sha256', "tsd:field:v1\n".jcs([$f['k'], $f['v'], $f['s']])); $add(hash_equals($e['payload_hash'], merkle_from_proof($leaf, $f['proof'] ?? [])), "selective field \"{$f['k']}\" proven in payload_hash", true); if ($committed !== null) { $add(\in_array($f['k'], $committed, true), "revealed field \"{$f['k']}\" in committed key-set", true); } } $shown = \count($revealed); if ($committed !== null) { $withheldKeys = array_values(array_diff($committed, array_column($revealed, 'k'))); $add(true, "selective disclosure: {$shown} of ".\count($committed).' committed field(s) revealed; withheld (provably sealed): '.(implode(', ', $withheldKeys) ?: 'none'), false); } else { $count = (int) ($e['sd_field_count'] ?? 0); $add(true, "selective disclosure: {$shown} of {$count} field(s) revealed (key-set not committed — count is server-asserted)", false); } } } catch (\Throwable $ex) { $add(false, 'malformed entry — rejected', true); } $entriesOut[] = [ 'seq' => (int) $e['seq'], 'type' => (string) ($e['event_type'] ?? ''), 'redacted' => !empty($e['redacted']), 'checks' => $checks, ]; } return [ 'ok' => $ok, 'format_ok' => true, 'recorder' => $recorderId, 'subject' => $rec['subject_sub'] ?? null, 'tier' => $rec['trust_tier'] ?? null, 'entries' => $entriesOut, 'checkpoints' => $cpChecks, ]; } // ── CLI: only when run directly, never when require()d as a library ─────── if (!defined('TOUCHSTONE_VERIFY_AS_LIB')) { $raw = ($argv[1] ?? '') !== '' ? file_get_contents($argv[1]) : file_get_contents('php://stdin'); $b = json_decode((string) $raw, true); if (!is_array($b)) { fwrite(STDERR, "invalid JSON\n"); exit(1); } $r = verify_disclosure($b); if (!$r['format_ok']) { fwrite(STDERR, ($r['error'] ?? 'bad bundle')."\n"); exit(1); } echo "Touchstone disclosure — recorder {$r['recorder']} (tier: {$r['tier']})\n"; echo "subject_sub: {$r['subject']}\n\n"; if (!empty($r['checkpoints'])) { echo "Checkpoint chain:\n"; foreach ($r['checkpoints'] as $c) { printf(" [%s] %s\n", $c['ok'] ? "\033[32m✓\033[0m" : "\033[31m✗\033[0m", $c['msg']); } echo "\n"; } foreach ($r['entries'] as $e) { echo "Entry seq {$e['seq']} ({$e['type']}):\n"; foreach ($e['checks'] as $c) { printf(" [%s] %s\n", $c['ok'] ? "\033[32m✓\033[0m" : "\033[31m✗\033[0m", $c['msg']); } echo "\n"; } echo $r['ok'] ? "\033[32mPASS\033[0m — every checked property holds.\n" : "\033[31mFAIL\033[0m — one or more checks failed.\n"; echo "Note: this proves what was logged was not altered and was signed by the\n"; echo "subject key. It does NOT prove the log is complete (omission is possible\n"; echo "below the 'evidence'/'inline' tiers).\n"; exit($r['ok'] ? 0 : 1); }