$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 { $parts = [ (string) $e['seq'], $e['prev_hash'], $e['server_ts'], $e['payload_hash'], $e['actor_sub'], $e['counterparty_sub'] ?? '', $e['actor_sig'], ]; // Beacon-bound entries append one line (drand round committed into entry_hash); absent → the // exact legacy 7-field preimage, so unbound entries and every pre-existing anchor verify unchanged. // A present-but-malformed server_beacon fails closed identically across php/js/python. $b = $e['server_beacon'] ?? null; if ($b !== null) { if (!\is_array($b) || !isset($b['chain'], $b['round'], $b['randomness'])) { return 'malformed-server_beacon'; // never equals a real entry_hash → reject } $parts[] = 'beacon:'.$b['chain'].':'.$b['round'].':'.$b['randomness']; } return hash('sha256', implode("\n", $parts)); } 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 claims Bitcoin block {$btcHeight} (server-asserted; this offline check does not confirm the .ots commits to that block) — fork tie-breaker once verified: of conflicting heads the lowest VERIFIED block wins. Confirm with `ots verify`, or gossip_check.py + ots_verify.py.", false); } } catch (\Throwable $ex) { $cpAdd(false, 'malformed checkpoint — rejected', true); } $prevCp = $cp; } // Committed beacon policy, read from the hash-committed genesis body: once a recorder declares // beacon_from_seq=N, every entry at seq>=N MUST carry a beacon, so a missing one is a contradiction // (a signer can no longer selectively omit the line to keep a soft clock). Absent → no enforcement. $beaconFromSeq = null; foreach ($entries as $ge) { if (($ge['seq'] ?? null) === 0 || ($ge['event_type'] ?? '') === 'genesis') { $gb = !empty($ge['body_enc']) ? (json_decode((string) $ge['body_enc'], true) ?: []) : []; $bc = $gb['beacon_commitment'] ?? null; if (\is_array($bc) && isset($bc['from_seq']) && \is_int($bc['from_seq'])) { $beaconFromSeq = $bc['from_seq']; } break; } } 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); // 1b. beacon commitment: an entry at seq >= from_seq must be beacon-bound. A malformed beacon // already fails integrity above; this catches the ABSENT one (a valid legacy hash where the // committed policy required a beacon) — the selective-omission a soft-clock signer would try. if ($beaconFromSeq !== null && $e['seq'] >= $beaconFromSeq) { $sb = $e['server_beacon'] ?? null; $bound = \is_array($sb) && isset($sb['chain'], $sb['round'], $sb['randomness']); $add($bound, 'beacon commitment honored (seq >= '.$beaconFromSeq.' is beacon-bound)', 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 confirm the block exists' : ''; $add(true, "OTS → Bitcoin block {$blob['bitcoin_height']} CLAIMED ({$a['external_ts']}{$corr}) — not confirmed against mainnet here; verify the downloadable .ots with `ots verify`, which recomputes the committed root against the real block", 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, ]; } // ── Column provenance: grade each column by k, computed from its evidence ──────────────────────── // Port of public/columns-verify.py `evaluate()` / `verifier.js` `_evalColumn` — the three must never // disagree (verifier/columns_php_parity.py runs them on the same fixture and mutations in CI). // k = 0 DERIVABLE value == alg(input) recomputed here; uncollusible. // k ≥ 2 WITNESSED 1 + DISTINCT-CONTROL co-signers over jcs({proposition, value, challenge}); // witnesses sharing a key or a verified controller binding collapse to one. // k = 1 TESTIMONY the attester's word; shown, never counted as verified (ok = null). // Over-claiming (declaring more strength than earned; k=0 ranks strongest) and a derivation that does // not reproduce fail closed. The receipt's strength is its cheapest falsehood path: min k over k ≥ 1. /** @return bool|null true = recomputes, false = offered but fails, null = no derivation */ function column_recompute(array $col): ?bool { $d = $col['derivation'] ?? null; if (!\is_array($d)) { return null; } $alg = $d['alg'] ?? null; $inp = $d['input'] ?? null; if ($alg === 'sha256') { return \is_string($inp) && ($col['value'] ?? null) === hash('sha256', $inp); } if ($alg === 'jcs-sha256') { return ($col['value'] ?? null) === hash('sha256', jcs($inp)); } return false; // unknown alg → cannot recompute → fail closed } /** A witness's CONTROL identity: a verified controller binding if carried, else its own signing key. */ function column_control_key(array $w): string { $ctrl = $w['controller'] ?? null; if (\is_array($ctrl) && !empty($ctrl['pubkey']) && !empty($ctrl['sig']) && ed25519_verify((string) $ctrl['pubkey'], "touchstone-control:v1\n".(string) ($w['pubkey'] ?? ''), (string) $ctrl['sig'])) { return 'ctrl:'.(string) $ctrl['pubkey']; } return 'key:'.(string) ($w['pubkey'] ?? ''); } /** Number of distinct-control clusters among valid co-signatures over the proposition (an UPPER bound). */ function column_control_clusters(array $col): int { $witnesses = $col['witnesses'] ?? null; if ($witnesses === null && \is_array($col['witness'] ?? null)) { $witnesses = [$col['witness']]; } $clusters = []; foreach (\is_array($witnesses) ? $witnesses : [] as $w) { if (!\is_array($w) || empty($w['pubkey']) || empty($w['sig'])) { continue; } $msg = jcs(['proposition' => $col['proposition'] ?? null, 'value' => $col['value'] ?? null, 'challenge' => $w['challenge'] ?? null]); if (ed25519_verify((string) $w['pubkey'], $msg, (string) $w['sig'])) { $clusters[column_control_key($w)] = true; } } return \count($clusters); } /** @return array{name:mixed, ok:bool|null, status:string, k:int|null, k_indep:int|null, note:string, proposition?:mixed} */ function evaluate_column(array $col): array { $name = $col['name'] ?? null; $declared = $col['k'] ?? null; $rec = column_recompute($col); if ($rec === true) { [$k, $kIndep, $status] = [0, 0, 'DERIVABLE']; } elseif ($rec === false) { return ['name' => $name, 'ok' => false, 'status' => 'RECOMPUTE-FAILS', 'k' => null, 'k_indep' => null, 'note' => 'declares a derivation but it does NOT reproduce the value']; } else { $clusters = column_control_clusters($col); if ($clusters > 0) { [$k, $kIndep, $status] = [1 + $clusters, 1 + $clusters, 'WITNESSED']; } else { [$k, $kIndep, $status] = [1, 1, 'TESTIMONY']; } } $rank = static fn (int $x): float => $x === 0 ? INF : (float) $x; if (\is_int($declared) && $rank($declared) > $rank($k)) { return ['name' => $name, 'ok' => false, 'status' => 'OVER-CLAIMED', 'k' => $k, 'k_indep' => $kIndep, 'note' => sprintf('declares k=%d but the evidence only supports k=%d — cannot launder strength', $declared, $k)]; } $note = match ($status) { 'DERIVABLE' => 'recomputed from published input — no coalition can forge it', 'WITNESSED' => sprintf('≤ %d distinct-control witness(es) co-signed the proposition (collapsed on shared key/controller); hidden shared control can only lower it', $k - 1), default => "attester's word alone — believed, not checked", }; return ['name' => $name, 'ok' => $status === 'TESTIMONY' ? null : true, 'status' => $status, 'k' => $k, 'k_indep' => $kIndep, 'note' => $note, 'proposition' => $col['proposition'] ?? null]; } /** @return array{columns: list|null, columns_sha256: ?string} the block at .columns / .payload.columns / .document.columns */ function find_columns(array $doc): array { foreach ([$doc['columns'] ?? null, $doc['payload']['columns'] ?? null, $doc['document']['columns'] ?? null] as $c) { if (\is_array($c) && array_is_list($c)) { // An absent or EMPTY published digest means "none published" (as in columns-verify.py / verifier.js). $pub = $doc['columns_sha256'] ?? null; return ['columns' => $c, 'columns_sha256' => \is_string($pub) && $pub !== '' ? $pub : null]; } } return ['columns' => null, 'columns_sha256' => null]; } /** @return array{coherent:bool, min_k:?int, n_fail:int, digest_ok:bool, computed_digest:string, columns:list>} */ function grade_columns(array $columns, ?string $columnsSha256 = null): array { $out = []; $nFail = 0; $minK = null; foreach ($columns as $col) { $r = evaluate_column(\is_array($col) ? $col : []); $out[] = $r; if ($r['ok'] === false) { ++$nFail; continue; } if ($r['k'] !== null && $r['k'] >= 1) { $minK = $minK === null ? $r['k'] : min($minK, $r['k']); } } $computed = hash('sha256', jcs($columns)); $digestOk = $columnsSha256 === null || hash_equals($computed, $columnsSha256); return ['coherent' => $nFail === 0 && $digestOk, 'min_k' => $minK, 'n_fail' => $nFail, 'digest_ok' => $digestOk, 'computed_digest' => $computed, 'columns' => $out]; } // ── CLI: only when run directly, never when require()d as a library ─────── if (!defined('TOUCHSTONE_VERIFY_AS_LIB')) { // `php verify.php --columns receipt.json` (or `--columns-json` for machine-readable output): grade a // /columns receipt — or any document carrying a `columns` block — on its own. Exit 0 coherent, // 1 incoherent, 2 no columns block. The anchor walk is the receipt's `anchor` links (verify them with // the disclosure mode); this mode grades the columns and says so. if (\in_array($argv[1] ?? '', ['--columns', '--columns-json'], true)) { $src = $argv[2] ?? ''; $raw = $src !== '' ? file_get_contents($src) : file_get_contents('php://stdin'); $doc = json_decode((string) $raw, true); if (!\is_array($doc)) { fwrite(STDERR, "invalid JSON\n"); exit(2); } $found = find_columns($doc); if ($found['columns'] === null) { fwrite(STDERR, "no columns block found (.columns / .payload.columns / .document.columns)\n"); exit(2); } $g = grade_columns($found['columns'], $found['columns_sha256']); if (($argv[1] ?? '') === '--columns-json') { echo json_encode($g, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), "\n"; exit($g['coherent'] ? 0 : 1); } echo "Touchstone columns — graded by k, computed from the evidence (not the declared grade)\n"; foreach ($g['columns'] as $c) { $mark = $c['ok'] === true ? "\033[32m✓\033[0m" : ($c['ok'] === false ? "\033[31m✗\033[0m" : "\033[2m·\033[0m"); printf(" [%s] %-22s %-16s %s %s\n", $mark, (string) ($c['name'] ?? '?'), $c['status'], $c['k'] === null ? 'k=?' : 'k='.$c['k'], $c['note']); } if ($found['columns_sha256'] !== null) { printf(" [%s] columns_sha256 %s\n", $g['digest_ok'] ? "\033[32m✓\033[0m" : "\033[31m✗\033[0m", $g['digest_ok'] ? 'binds the set' : 'does NOT match sha256(JCS(columns))'); } echo $g['coherent'] ? "\033[32mRESULT: RECEIPT COHERENT ✓\033[0m — every column carries the strength it claims; ".($g['min_k'] === null ? 'no coalition can forge a load-bearing column' : 'cheapest falsehood path k='.$g['min_k']).".\n" : "\033[31mRESULT: RECEIPT INCOHERENT ✗\033[0m — {$g['n_fail']} column(s) fail or the set digest does not bind.\n"; echo "Anchoring is not checked in this mode: follow the receipt's anchor links (entry proof → checkpoint → Bitcoin).\n"; exit($g['coherent'] ? 0 : 1); } $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); }