diff --git a/dist/cli/index.d.ts b/dist/cli/index.d.ts index d5255765..91edda8a 100644 --- a/dist/cli/index.d.ts +++ b/dist/cli/index.d.ts @@ -53,7 +53,7 @@ export declare const EXIT: { /** The owner policy (~/.trustshell/policy.json) denied the action. Nothing ran. */ readonly REFUSED: 5; }; -export type Command = 'verify' | 'evaluate' | 'repid' | 'proof' | 'badge' | 'check' | 'receipt' | 'verify-receipt' | 'inspect' | 'init' | 'report' | 'profile' | 'status' | 'remember' | 'recall' | 'redact' | 'quarantine' | 'bind-status' | 'traps' | 'ground' | 'erc8004' | 'learn' | 'bundle' | 'guard' | 'run' | 'sandbox' | 'help' | 'version'; +export type Command = 'verify' | 'evaluate' | 'repid' | 'proof' | 'badge' | 'check' | 'receipt' | 'verify-receipt' | 'sign-receipt' | 'demo' | 'inspect' | 'init' | 'report' | 'profile' | 'status' | 'remember' | 'recall' | 'redact' | 'quarantine' | 'bind-status' | 'traps' | 'ground' | 'erc8004' | 'learn' | 'bundle' | 'guard' | 'run' | 'sandbox' | 'help' | 'version'; /** Result of parsing argv (everything after `node cli.js`). Pure + testable. */ export interface ParsedArgs { command: Command; diff --git a/dist/cli/index.js b/dist/cli/index.js index 7cc08621..c7551c61 100644 --- a/dist/cli/index.js +++ b/dist/cli/index.js @@ -89,6 +89,8 @@ const guard_1 = require("./guard"); const run_1 = require("../sandbox/run"); const guardrails_1 = require("../lib/guardrails"); const api_url_1 = require("../lib/api-url"); +const install_key_1 = require("../lib/install-key"); +const owner_receipt_1 = require("../lib/owner-receipt"); /** Exit codes — a small, stable contract so CI scripts can branch on them. */ exports.EXIT = { /** HAL PASS (or soft FLAG) — safe to proceed. */ @@ -207,6 +209,20 @@ COMMANDS [--envelope ] Ed25519 keys), the signature; reads the other claims from the envelope, fail-closed to NOT_CHECKED. EXIT keyed to integrity: 0 VERIFIED, 1 FAILED (tampered/forged), 2 NOT_CHECKED. + sign-receipt Seal a kernel-receipt body with THIS install's OWN key + (/bundle-signing-key.pem), OFFLINE, + no engine. Prints the sealed kernel-receipt/2 to stdout (provenance + 'owner-self-signed') and, on stderr, the 0x key a verifier pins: + trustshell sign-receipt body.json > r.json + trustshell verify-receipt r.json --root 0x + An owner self-signature proves AUTHORSHIP + wholeness only — never that + the engine permitted the action, and it earns no production RepID. Use + - for stdin. EXIT 0 sealed, 2 bad body, 3 could not seal. + demo The whole offline loop in one command: seal a sample receipt with THIS + install's key, then verify it HERE under that key and print the six-claim + verdict and the exact verify-receipt --root line to reproduce it. No + network, no account. Receipts, not promises. EXIT 0 the seal verified, + 2 it did not, 3 could not seal. inspect [] Verify an append-only tool-call log. Reads a local file and computes hashes; opens NO socket. INTACT (0) / BROKEN (1) / @@ -582,6 +598,14 @@ function parseArgs(argv) { return { command: 'help', json, verify, error: 'verify-receipt requires (a receipt, or an engine receipt_response)' }; return { command: 'verify-receipt', operand, json, verify, root: values['root'], asOf: values['as-of'], envelope: values['envelope'] }; } + case 'sign-receipt': { + const operand = rest[0]; + if (!operand) + return { command: 'help', json, verify, error: 'sign-receipt requires (a kernel-receipt body to seal) or - for stdin' }; + return { command: 'sign-receipt', operand, json, verify }; + } + case 'demo': + return { command: 'demo', json, verify }; case 'status': return { command: 'status', json, verify }; case 'remember': { @@ -1398,6 +1422,94 @@ async function run(args, client, io = realIO) { (code === exports.EXIT.VETO ? io.err : io.out)(formatOfflineVerify(result)); return code; } + case 'sign-receipt': { + const file = args.operand; + let raw; + try { + raw = (0, node_fs_1.readFileSync)(file === '-' ? 0 : file, 'utf8'); // fd 0 = stdin + } + catch (e) { + io.err(`sign-receipt: cannot read ${file === '-' ? 'stdin' : file}: ${e.message}`); + return exports.EXIT.USAGE; + } + if (raw.length > action_evidence_1.MAX_CLAIMS_FILE_BYTES) { + io.err(`sign-receipt: input is too large (> ${action_evidence_1.MAX_CLAIMS_FILE_BYTES} bytes)`); + return exports.EXIT.USAGE; + } + let body; + try { + body = JSON.parse(raw); + } + catch { + io.err(`sign-receipt: input is not valid JSON`); + return exports.EXIT.USAGE; + } + if (!body || typeof body !== 'object' || typeof body.header !== 'object' || body.header === null) { + io.err('sign-receipt: the body must be a JSON object with a "header" object'); + return exports.EXIT.USAGE; + } + try { + const dir = (0, guardrails_1.trustshellHome)(process.env); + const { key, created, path } = (0, install_key_1.signingKey)(dir); + const receipt = await (0, owner_receipt_1.signOwnerReceipt)(body, key); + // The receipt to stdout stays machine-clean (pipe it, or `> r.json`); the human-facing pin + // and the warning that this is a self-attestation go to stderr so they never corrupt the JSON. + io.out(JSON.stringify(receipt, null, 2)); + if (created) + io.err(`sign-receipt: created a new install key at ${path}`); + io.err(`sign-receipt: sealed as owner-self-signed. A verifier confirms the SEAL with:`); + io.err(` trustshell verify-receipt --root ${(0, owner_receipt_1.installKeyRootHex)(key)}`); + io.err(`This proves authorship and that the bytes are whole — NOT that the engine permitted the action, and it earns no production RepID.`); + return exports.EXIT.OK; + } + catch (e) { + io.err(`sign-receipt: could not seal the receipt: ${e instanceof optional_ethers_1.MissingDependencyError ? e.message : e.message}`); + return exports.EXIT.RUNTIME; + } + } + case 'demo': { + // The whole offline loop in one command: seal a sample receipt with this install's key, then verify + // it HERE under that key. No network, no account, no file written. "Receipts, not promises" in ~2 s. + try { + const dir = (0, guardrails_1.trustshellHome)(process.env); + const { key, created, path: keyPath } = (0, install_key_1.signingKey)(dir); + const body = { + header: { receipt_id: `demo-${Date.now().toString(36)}`, prev_receipt_id: null, prev_hash: null, created_at: new Date().toISOString(), env: { network: 'base-sepolia', chain_id: 84532, is_simulated: true } }, + subject: { agent: { agent_id: 'demo-agent', agent_name: 'trustshell demo' }, owner: { wallet: null, assurance: null, status: 'none' } }, + request: { tool: { name: 'read_file', class: 'read' }, origin: 'Cli', args_hash: `0x${'de'.repeat(32)}`, amount_raw: null, payee: null, asset: null, chain_id: null }, + decision: { verdict: 'ALLOW', blocking: false, executed: true, reasons: ['sample action for the demo'], checks: [{ name: 'local', outcome: 'PASS', source: 'owner' }], does_not_attest: [], policy: { policy_hash: null, signer: null, signature: 'NOT_CHECKED', expires_at: null, revocation: 'NOT_CHECKED' }, limits: {}, laya: { lane: null, mode: 'shadow', consulted: false } }, + payment: { status: 'none' }, + verification: { status: 'none' }, + }; + const receipt = await (0, owner_receipt_1.signOwnerReceipt)(body, key); + const pin = (0, owner_receipt_1.installKeyRootHex)(key); + const result = await (0, verify_receipt_1.verifyReceiptOffline)(receipt, { trustedRoots: [pin] }); + const code = verifyReceiptExitCode(result); + if (args.json) { + io.out((0, action_evidence_1.terminalSafeJson)({ receipt, verify: result, root: pin })); + return code; + } + if (created) + io.err(`demo: created a new install key at ${keyPath}`); + io.out('trustshell demo — the offline receipt loop, start to finish:'); + io.out(''); + io.out(` 1. SEALED a sample receipt with this install's own key (provenance: ${receipt.header.provenance ?? 'owner-self-signed'}).`); + io.out(' 2. VERIFIED it here, offline, under that key:'); + io.out(''); + io.out(formatOfflineVerify(result)); + io.out(''); + io.out('Do the same with your own receipt body — still offline, no account:'); + io.out(' trustshell sign-receipt your-body.json > r.json'); + io.out(` trustshell verify-receipt r.json --root ${pin}`); + io.err(''); + io.err('An owner self-signature proves AUTHORSHIP + wholeness only — not that the engine permitted the action, and it earns no production RepID. Offline, a non-owner key reaches at most ISSUER_ATTESTED (attested under a named key, not a finding of independence); independence and RepID are the engine\'s call.'); + return code; + } + catch (e) { + io.err(`demo: could not seal the sample receipt: ${e instanceof optional_ethers_1.MissingDependencyError ? e.message : e.message}`); + return exports.EXIT.RUNTIME; + } + } case 'proof': { try { const p = await client.presentProof(args.operand, { verify: args.verify }); diff --git a/dist/lib/owner-receipt.d.ts b/dist/lib/owner-receipt.d.ts new file mode 100644 index 00000000..a8ef0dd6 --- /dev/null +++ b/dist/lib/owner-receipt.d.ts @@ -0,0 +1,85 @@ +/** + * owner-receipt — seal a kernel receipt with THIS install's own Ed25519 key, offline, with no engine. + * + * The engine (repid-engine) signs kernel-receipt/2 with its production key; this SDK has only ever + * VERIFIED those. This is the owner's side: an agent's accountable owner can seal a receipt with their + * own install key (`bundle-signing-key.pem`, src/lib/install-key.ts) so a stranger can reproduce its + * integrity offline. It emits the SAME wire format the engine does, so {@link verifyReceiptOffline} + * checks it UNCHANGED — the Ed25519 signature over `KERNEL_RECEIPT_SIGNATURE_DOMAIN + canonical bytes`, + * the keccak256 receipt_hash, the 0x-hex signer and signature. One format, one verifier. + * + * THREE EVIDENCE LEVELS, NOT ONE. An owner-self-signed receipt proves exactly two things and NOTHING + * more: the bytes are whole, and the owner's own key sealed them (authorship). It does NOT carry the + * engine's six-claim attestation, so a verifier leaves authenticity/observation/correctness/ + * authorization/containment at NOT_CHECKED — the owner cannot self-certify that the kernel permitted + * the action or that an outcome was observed. To keep that legible and un-launderable: + * + * - every owner receipt carries `header.provenance = 'owner-self-signed'`, covered by the signature + * AND the hash (flip it and both break), so no reader mistakes it for an engine receipt; + * - {@link evidenceLevel} never reports ISSUER_ATTESTED for one, and never reports a receipt as + * RepID-eligible — self-attestation earns authorship, never production reputation. An owner receipt + * that is whole and sealed stays OWNER_SELF_SIGNED. ISSUER_ATTESTED is the most an offline verdict + * ever reaches for a non-owner key, and it does NOT assert the issuer is independent of the owner + * (the SDK has no controller graph). Independence and RepID are the ENGINE's call against its OWN key. + * + * A missing trust root does NOT silently pass: with no pin the verifier reports the bytes whole but + * UNSEALED (sealer.trusted=false), which {@link evidenceLevel} surfaces as WHOLE_BUT_UNSEALED, never + * OWNER_SELF_SIGNED. "I can't check who sealed it" is never "the owner sealed it". + */ +import { type KeyObject } from 'node:crypto'; +import { type KernelReceiptSigner, type KernelReceiptV2 } from './kernel-client'; +import type { OfflineVerifyResult } from './verify-receipt'; +/** The provenance stamp that marks a receipt as sealed by an owner's own install key, not the engine. */ +export declare const OWNER_SELF_SIGNED: "owner-self-signed"; +/** + * This install key's public identity in the engine's wire encoding: public_key and fingerprint are + * lower-case `0x` + hex (the install key's own {@link import('./install-key').signerOf} gives base64url + * / un-prefixed sha256; kernel-receipt/2 uses 0x-hex, so convert once, here). + */ +export declare function installKeySigner(key: KeyObject): KernelReceiptSigner; +/** The `trustedRoots` pin a verifier passes to confirm THIS key sealed a receipt (0x + 64 hex). */ +export declare function installKeyRootHex(key: KeyObject): string; +/** A receipt body to seal: anything with a `header` object, minus the sealing fields this adds. */ +export interface SealableReceipt { + header: Record; + [k: string]: unknown; +} +/** + * Seal `body` as an owner-self-signed kernel-receipt/2. Reproduces the engine's order exactly: + * 1. header gets schema=kernel-receipt/2, version=2, provenance=owner-self-signed, signer — but NO + * receipt_hash and NO signature yet; + * 2. signature = Ed25519 over `DOMAIN + canonical(receipt-without-receipt_hash-and-signature)`, 0x+128 hex; + * 3. receipt_hash = keccak256 over the canonical receipt WITH the signature but WITHOUT receipt_hash. + * The caller supplies the body; any `schema`/`version`/`signer`/`signature`/`receipt_hash` already on the + * header are overwritten (you cannot pre-stamp the sealing fields). Needs keccak (ethers peer or the + * @noble path); never makes a network call. + */ +export declare function signOwnerReceipt(body: SealableReceipt, key: KeyObject): Promise; +/** The provenance stamp on a receipt, or null when it has none (an engine receipt carries none). */ +export declare function ownerReceiptProvenance(receipt: unknown): string | null; +export type EvidenceLevel = +/** integrity is not VERIFIED: tampered, or the hash could not be recomputed. Trust nothing. */ +'NOT_ESTABLISHED' +/** the bytes are whole, but no pinned key was confirmed to have sealed them (no/absent trust root). */ + | 'WHOLE_BUT_UNSEALED' +/** whole AND sealed by the owner's own install key (provenance=owner-self-signed). Authorship only. */ + | 'OWNER_SELF_SIGNED' +/** + * whole, sealed by a pinned issuer key that is NOT an owner self-signature, with ≥1 engine claim + * VERIFIED under it. Attestation UNDER A NAMED ISSUER — NOT a finding that the issuer is independent + * of the owner's beneficial control. Offline, the SDK only knows the caller chose to pin this key; it + * cannot derive the controller graph. Whether the issuer is truly independent (and whether the receipt + * earns RepID) is the engine's call against its own key, never an offline verdict. + */ + | 'ISSUER_ATTESTED'; +export interface EvidenceVerdict { + level: EvidenceLevel; + /** The SDK never says "yes": self-attestation is 'no'; everything else defers to the engine. */ + repid_eligible: 'no' | 'engine-decides'; + why: string; +} +/** + * Map an offline verdict (+ the receipt it came from) to one of the three evidence levels D1 preserves. + * This is the guard that keeps owner-signed from ever reading as independently-verified or RepID-bearing. + */ +export declare function evidenceLevel(verdict: OfflineVerifyResult, receipt: unknown): EvidenceVerdict; diff --git a/dist/lib/owner-receipt.js b/dist/lib/owner-receipt.js new file mode 100644 index 00000000..6646735f --- /dev/null +++ b/dist/lib/owner-receipt.js @@ -0,0 +1,122 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.OWNER_SELF_SIGNED = void 0; +exports.installKeySigner = installKeySigner; +exports.installKeyRootHex = installKeyRootHex; +exports.signOwnerReceipt = signOwnerReceipt; +exports.ownerReceiptProvenance = ownerReceiptProvenance; +exports.evidenceLevel = evidenceLevel; +/** + * owner-receipt — seal a kernel receipt with THIS install's own Ed25519 key, offline, with no engine. + * + * The engine (repid-engine) signs kernel-receipt/2 with its production key; this SDK has only ever + * VERIFIED those. This is the owner's side: an agent's accountable owner can seal a receipt with their + * own install key (`bundle-signing-key.pem`, src/lib/install-key.ts) so a stranger can reproduce its + * integrity offline. It emits the SAME wire format the engine does, so {@link verifyReceiptOffline} + * checks it UNCHANGED — the Ed25519 signature over `KERNEL_RECEIPT_SIGNATURE_DOMAIN + canonical bytes`, + * the keccak256 receipt_hash, the 0x-hex signer and signature. One format, one verifier. + * + * THREE EVIDENCE LEVELS, NOT ONE. An owner-self-signed receipt proves exactly two things and NOTHING + * more: the bytes are whole, and the owner's own key sealed them (authorship). It does NOT carry the + * engine's six-claim attestation, so a verifier leaves authenticity/observation/correctness/ + * authorization/containment at NOT_CHECKED — the owner cannot self-certify that the kernel permitted + * the action or that an outcome was observed. To keep that legible and un-launderable: + * + * - every owner receipt carries `header.provenance = 'owner-self-signed'`, covered by the signature + * AND the hash (flip it and both break), so no reader mistakes it for an engine receipt; + * - {@link evidenceLevel} never reports ISSUER_ATTESTED for one, and never reports a receipt as + * RepID-eligible — self-attestation earns authorship, never production reputation. An owner receipt + * that is whole and sealed stays OWNER_SELF_SIGNED. ISSUER_ATTESTED is the most an offline verdict + * ever reaches for a non-owner key, and it does NOT assert the issuer is independent of the owner + * (the SDK has no controller graph). Independence and RepID are the ENGINE's call against its OWN key. + * + * A missing trust root does NOT silently pass: with no pin the verifier reports the bytes whole but + * UNSEALED (sealer.trusted=false), which {@link evidenceLevel} surfaces as WHOLE_BUT_UNSEALED, never + * OWNER_SELF_SIGNED. "I can't check who sealed it" is never "the owner sealed it". + */ +const node_crypto_1 = require("node:crypto"); +const kernel_client_1 = require("./kernel-client"); +/** The provenance stamp that marks a receipt as sealed by an owner's own install key, not the engine. */ +exports.OWNER_SELF_SIGNED = 'owner-self-signed'; +const HEX32 = /^0x[0-9a-f]{64}$/; +/** + * This install key's public identity in the engine's wire encoding: public_key and fingerprint are + * lower-case `0x` + hex (the install key's own {@link import('./install-key').signerOf} gives base64url + * / un-prefixed sha256; kernel-receipt/2 uses 0x-hex, so convert once, here). + */ +function installKeySigner(key) { + const x = String((0, node_crypto_1.createPublicKey)(key).export({ format: 'jwk' }).x); + const raw = Buffer.from(x, 'base64url'); + if (raw.length !== 32) + throw new Error('not an Ed25519 public key (expected 32 bytes)'); + return { + alg: 'ed25519', + public_key: `0x${raw.toString('hex')}`, + fingerprint: `0x${(0, node_crypto_1.createHash)('sha256').update(raw).digest('hex')}`, + }; +} +/** The `trustedRoots` pin a verifier passes to confirm THIS key sealed a receipt (0x + 64 hex). */ +function installKeyRootHex(key) { + return installKeySigner(key).public_key; +} +/** + * Seal `body` as an owner-self-signed kernel-receipt/2. Reproduces the engine's order exactly: + * 1. header gets schema=kernel-receipt/2, version=2, provenance=owner-self-signed, signer — but NO + * receipt_hash and NO signature yet; + * 2. signature = Ed25519 over `DOMAIN + canonical(receipt-without-receipt_hash-and-signature)`, 0x+128 hex; + * 3. receipt_hash = keccak256 over the canonical receipt WITH the signature but WITHOUT receipt_hash. + * The caller supplies the body; any `schema`/`version`/`signer`/`signature`/`receipt_hash` already on the + * header are overwritten (you cannot pre-stamp the sealing fields). Needs keccak (ethers peer or the + * @noble path); never makes a network call. + */ +async function signOwnerReceipt(body, key) { + if (!body || typeof body !== 'object' || typeof body.header !== 'object' || body.header === null) { + throw new Error('signOwnerReceipt: body must be an object with a header object'); + } + const signer = installKeySigner(key); + const { receipt_hash: _h, signature: _s, ...rest } = body.header; + const header = { ...rest, schema: kernel_client_1.KERNEL_RECEIPT_SCHEMA_SIGNED, version: 2, provenance: exports.OWNER_SELF_SIGNED, signer }; + const unsigned = { ...body, header }; + const signature = `0x${(0, node_crypto_1.sign)(null, Buffer.from(kernel_client_1.KERNEL_RECEIPT_SIGNATURE_DOMAIN + (0, kernel_client_1.kernelCanonicalJson)(unsigned), 'utf8'), key).toString('hex')}`; + const signed = { ...body, header: { ...header, signature } }; + const receipt_hash = await (0, kernel_client_1.kernelReceiptHash)(signed); + return { ...body, header: { ...header, signature, receipt_hash } }; +} +/** The provenance stamp on a receipt, or null when it has none (an engine receipt carries none). */ +function ownerReceiptProvenance(receipt) { + if (!receipt || typeof receipt !== 'object') + return null; + const header = receipt.header; + if (!header || typeof header !== 'object') + return null; + const p = header.provenance; + return typeof p === 'string' ? p : null; +} +/** + * Map an offline verdict (+ the receipt it came from) to one of the three evidence levels D1 preserves. + * This is the guard that keeps owner-signed from ever reading as independently-verified or RepID-bearing. + */ +function evidenceLevel(verdict, receipt) { + const integrity = verdict.claims?.integrity?.status; + if (integrity !== 'VERIFIED') { + return { level: 'NOT_ESTABLISHED', repid_eligible: 'engine-decides', why: `integrity is ${integrity ?? 'absent'}, not VERIFIED` }; + } + if (!verdict.sealer?.trusted) { + return { level: 'WHOLE_BUT_UNSEALED', repid_eligible: 'engine-decides', why: 'the bytes are whole but no pinned key was confirmed to have sealed them' }; + } + if (ownerReceiptProvenance(receipt) === exports.OWNER_SELF_SIGNED) { + return { level: 'OWNER_SELF_SIGNED', repid_eligible: 'no', why: `sealed by the owner's own install key (${verdict.sealer.fingerprint ?? 'pinned key'}); self-attestation earns authorship, never production RepID` }; + } + const anyEngineClaimVerified = kernel_client_1.SIX_CLAIM_KEYS.some((k) => k !== 'integrity' && verdict.claims?.[k]?.status === 'VERIFIED'); + if (anyEngineClaimVerified) { + return { + level: 'ISSUER_ATTESTED', + repid_eligible: 'engine-decides', + why: 'sealed by a pinned issuer key (not an owner self-signature) and at least one engine attestation verified under it; this does NOT establish that the issuer is independent of the owner — independence and RepID are the engine’s call', + }; + } + // Whole + sealed by a pinned key that is not a self-signature, but no engine claim reached VERIFIED + // (e.g. the envelope was not supplied or not authenticated). It is sealed, but it is not independently + // verified — do not promote it. + return { level: 'WHOLE_BUT_UNSEALED', repid_eligible: 'engine-decides', why: 'sealed by a pinned key, but no engine attestation reached VERIFIED, so no independent claim stands' }; +} diff --git a/dist/verify/index.d.ts b/dist/verify/index.d.ts index e3fd820c..2c82a440 100644 --- a/dist/verify/index.d.ts +++ b/dist/verify/index.d.ts @@ -16,3 +16,12 @@ export type { OfflineVerifyOptions, OfflineVerifyResult, ClaimSource, OfflineVer /** The verdict taxonomy and the pure primitives, so a consumer can reproduce canonical bytes themselves. */ export { SIX_CLAIM_KEYS, SIX_CLAIMS_PREDICATE_TYPE, KERNEL_RECEIPT_SCHEMA, KERNEL_RECEIPT_SCHEMA_SIGNED, KERNEL_RECEIPT_SIGNATURE_DOMAIN, KERNEL_TRUSTED_SIGNERS_ENV, kernelCanonicalJson, kernelReceiptHash, kernelReceiptAuthenticity, kernelTrustedSigners, } from '../lib/kernel-client'; export type { SixClaim, SixClaimKey, SixClaimStatus, SixClaimStatement, KernelReceipt, KernelReceiptV1, KernelReceiptV2, KernelReceiptSigner, KernelReceiptAuthenticity, } from '../lib/kernel-client'; +/** + * The owner's side of the same offline surface: seal a receipt with THIS install's own Ed25519 key so + * a stranger reproduces its integrity with the verifier above — one wire format, one verifier. An + * owner self-signature proves authorship and wholeness only; `evidenceLevel` keeps it at + * OWNER_SELF_SIGNED, never issuer-attested or RepID-bearing (a non-owner pinned key reaches at most + * ISSUER_ATTESTED, which is not a finding of independence). node:crypto only, no network (same import boundary). + */ +export { signOwnerReceipt, installKeySigner, installKeyRootHex, ownerReceiptProvenance, evidenceLevel, OWNER_SELF_SIGNED, } from '../lib/owner-receipt'; +export type { SealableReceipt, EvidenceLevel, EvidenceVerdict } from '../lib/owner-receipt'; diff --git a/dist/verify/index.js b/dist/verify/index.js index 4e5a3442..a9fb8bd5 100644 --- a/dist/verify/index.js +++ b/dist/verify/index.js @@ -1,6 +1,6 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -exports.kernelTrustedSigners = exports.kernelReceiptAuthenticity = exports.kernelReceiptHash = exports.kernelCanonicalJson = exports.KERNEL_TRUSTED_SIGNERS_ENV = exports.KERNEL_RECEIPT_SIGNATURE_DOMAIN = exports.KERNEL_RECEIPT_SCHEMA_SIGNED = exports.KERNEL_RECEIPT_SCHEMA = exports.SIX_CLAIMS_PREDICATE_TYPE = exports.SIX_CLAIM_KEYS = exports.TRUSTLATTICE_VERDICT_SPEC = exports.verifyReceiptChain = exports.verifyReceiptOffline = void 0; +exports.OWNER_SELF_SIGNED = exports.evidenceLevel = exports.ownerReceiptProvenance = exports.installKeyRootHex = exports.installKeySigner = exports.signOwnerReceipt = exports.kernelTrustedSigners = exports.kernelReceiptAuthenticity = exports.kernelReceiptHash = exports.kernelCanonicalJson = exports.KERNEL_TRUSTED_SIGNERS_ENV = exports.KERNEL_RECEIPT_SIGNATURE_DOMAIN = exports.KERNEL_RECEIPT_SCHEMA_SIGNED = exports.KERNEL_RECEIPT_SCHEMA = exports.SIX_CLAIMS_PREDICATE_TYPE = exports.SIX_CLAIM_KEYS = exports.TRUSTLATTICE_VERDICT_SPEC = exports.verifyReceiptChain = exports.verifyReceiptOffline = void 0; /** * `@hyperdag/trustshell/verify` — the offline receipt-verification surface, on its own subpath. * @@ -30,3 +30,17 @@ Object.defineProperty(exports, "kernelCanonicalJson", { enumerable: true, get: f Object.defineProperty(exports, "kernelReceiptHash", { enumerable: true, get: function () { return kernel_client_1.kernelReceiptHash; } }); Object.defineProperty(exports, "kernelReceiptAuthenticity", { enumerable: true, get: function () { return kernel_client_1.kernelReceiptAuthenticity; } }); Object.defineProperty(exports, "kernelTrustedSigners", { enumerable: true, get: function () { return kernel_client_1.kernelTrustedSigners; } }); +/** + * The owner's side of the same offline surface: seal a receipt with THIS install's own Ed25519 key so + * a stranger reproduces its integrity with the verifier above — one wire format, one verifier. An + * owner self-signature proves authorship and wholeness only; `evidenceLevel` keeps it at + * OWNER_SELF_SIGNED, never issuer-attested or RepID-bearing (a non-owner pinned key reaches at most + * ISSUER_ATTESTED, which is not a finding of independence). node:crypto only, no network (same import boundary). + */ +var owner_receipt_1 = require("../lib/owner-receipt"); +Object.defineProperty(exports, "signOwnerReceipt", { enumerable: true, get: function () { return owner_receipt_1.signOwnerReceipt; } }); +Object.defineProperty(exports, "installKeySigner", { enumerable: true, get: function () { return owner_receipt_1.installKeySigner; } }); +Object.defineProperty(exports, "installKeyRootHex", { enumerable: true, get: function () { return owner_receipt_1.installKeyRootHex; } }); +Object.defineProperty(exports, "ownerReceiptProvenance", { enumerable: true, get: function () { return owner_receipt_1.ownerReceiptProvenance; } }); +Object.defineProperty(exports, "evidenceLevel", { enumerable: true, get: function () { return owner_receipt_1.evidenceLevel; } }); +Object.defineProperty(exports, "OWNER_SELF_SIGNED", { enumerable: true, get: function () { return owner_receipt_1.OWNER_SELF_SIGNED; } }); diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md new file mode 100644 index 00000000..0e635bf7 --- /dev/null +++ b/docs/QUICKSTART.md @@ -0,0 +1,84 @@ +# Quickstart — verify it yourself in 10 steps + +No account, no key, no signup. Every step is a command you run and an output you can check. Where a step leaves the machine it says so; the rest are offline. Node 22 or newer. + +The one rule this project holds itself to: **receipts, not promises.** Each step below is a receipt you can reproduce, or an honest "not checked". + +--- + +**1. Run it once, cold.** From an empty directory, no clone, no install: + +``` +npx @hyperdag/trustshell@latest demo +``` + +You get a real `kernel-receipt/2`, sealed with a key this install just made, and **verified in front of you**: Integrity `VERIFIED`, the other five claims `NOT_CHECKED`. That gap is the point — read step 10. + +**2. See the whole loop in one command.** `trustshell demo` sealed a receipt and verified it under its own key, offline. Nothing was written to disk, nothing left the machine. Add `--json` to get `{ receipt, verify, root }` back as data. + +**3. Do it yourself with your own receipt.** Seal a body, then verify the file — the two commands the demo ran for you: + +``` +trustshell sign-receipt your-body.json > r.json +trustshell verify-receipt r.json --root 0x # the pin sign-receipt printed on stderr +``` + +`integrity VERIFIED, sealer trusted → exit 0`. + +**4. Break it and watch it fail.** Change one character of `r.json` (flip a digit in `decision`), then verify again: + +``` +trustshell verify-receipt r.json --root 0x # integrity FAILED → exit 1 +``` + +The hash no longer matches the bytes. A tampered receipt cannot pass. + +**5. No pin is not a pass.** Verify the untouched receipt with **no** `--root`: + +``` +trustshell verify-receipt r.json # NOT_CHECKED → exit 2, never 0 +``` + +The bytes are whole, but no accountable key was confirmed to have sealed them. An unaccountable receipt is `NOT_CHECKED`, never success — so a CI gate written as `verify-receipt … || exit 1` can never read one as a pass. + +**6. Check a real claim about a real CI run.** This one uses the network (`api.github.com` only): + +``` +trustshell check --claims-file claims.txt --out ./out +``` + +A true claim about the run comes back `SUPPORTED` (exit 0); a false one `CONTRADICTED` (exit 1). The record it writes under `./out` is a receipt you can re-read. + +**7. Inspect an append-only tool-call log.** Offline, no socket: + +``` +trustshell inspect .trustshell/session.jsonl # INTACT (0) / BROKEN (1) / UNCHAINED (3) +``` + +`INTACT` means nothing was edited or dropped — but see step 10 for what it does *not* prove. + +**8. Reproduce the headline claims with one command.** From a clone (needs the internet; installs into a temp dir and deletes it): + +``` +npm run proof-pack +``` + +It packs this checkout, installs the tarball **and** the published `@latest` into empty dirs, runs the documented commands from both, and prints `PASS` / `FAIL` / `NOT_CHECKED` per step with a tamper control that must fail. Its dated report lands in `docs/proof-pack/`. + +**9. Attack the receipt seam and watch every forgery bounce.** Offline: + +``` +npm run skeptic +``` + +It seals a receipt, then tries to forge, tamper, transplant a signature, pin the wrong key, expire the authorization, and launder a self-signature into RepID — each **passes only when the verifier refuses it**. The report names the commit, the version and every input/output. + +**10. Read what a pass does NOT prove.** This is the honest part, and it is load-bearing: + +- **Three evidence levels, never collapsed.** An owner self-signature (steps 1–5) proves **authorship and wholeness only**. It is *not* independent verification and it earns *no* production RepID. Whether a receipt earns RepID is the **engine's** call against its own key — never an offline verdict. +- **`NOT_CHECKED` is never a pass.** A missing observation stays not-checked; it does not quietly become "fine". +- **`inspect INTACT` has no trusted anchor.** Its hashes are unkeyed — anyone who can write the file can produce a valid-looking chain. It catches edits and deletions, not a log rewritten end to end. +- **Offline verify is stateless.** It does not catch a verbatim replay of one receipt; replay is prevented by the engine's `receipt_id`/nullifier uniqueness, not the offline verifier. +- **The ERC-8004 reads in the proof pack** show two contracts exist on Base Sepolia. They do not show that any agent holds a token or earned a reputation. + +If any of steps 1–9 does something other than what it says here, that is a bug — open an issue with the output. The commands and their exit codes are the contract. diff --git a/docs/api-reference.md b/docs/api-reference.md index dda7a83e..47910e9b 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -364,6 +364,68 @@ separately; `--json` prints the structured `OfflineVerifyResult`. | integrity **FAILED**: the hash does not match, the signature is from a key you did not pin, or an unsigned receipt under a pin. Treat it as tampered/forged | `1` | | integrity VERIFIED but **no `--root` pinned** (sealer unconfirmed), or integrity NOT_CHECKED; also usage errors (missing/unreadable file, non-JSON, too large, unreadable `--as-of`). **Not a pass** | `2` | +### `trustshell sign-receipt ` + +> **Offline. No network, no engine.** The owner's side of `verify-receipt`: seal a receipt with +> **this install's own key** so a stranger can reproduce its integrity. Also exported as +> `@hyperdag/trustshell/verify` (`signOwnerReceipt`, `installKeyRootHex`, `evidenceLevel`). + +Reads a `kernel-receipt` **body** from `` (a JSON object with a `header`; use `-` for stdin), +seals it with the Ed25519 install key at `/bundle-signing-key.pem` +(created `0600` on first use), and prints the sealed **`kernel-receipt/2`** to **stdout**. The seal +is the engine's exact wire format — Ed25519 over the domain-tagged canonical bytes, a keccak256 +`receipt_hash`, a `0x`-hex signer and signature — so `verify-receipt` checks it **unchanged**. Every +owner receipt carries `header.provenance = "owner-self-signed"`, covered by both the signature and +the hash. On **stderr** it prints the `0x` key to pin and the plain caveat below. The whole loop: + +``` +trustshell sign-receipt body.json > r.json +trustshell verify-receipt r.json --root 0x # integrity VERIFIED, sealer trusted → exit 0 +trustshell verify-receipt r.json # no pin → NOT_CHECKED, never exit 0 +``` + +**Three evidence levels, never collapsed.** An owner self-signature proves **authorship and that the +bytes are whole — nothing more.** It does **not** carry the engine's six-claim attestation, so a +verifier leaves Authenticity/Observation/Correctness/Authorization/Containment at `NOT_CHECKED`, and +`evidenceLevel()` reports `OWNER_SELF_SIGNED` — never `ISSUER_ATTESTED`, and never RepID-eligible. +For a *non-owner* pinned key, the most an offline verdict ever reaches is `ISSUER_ATTESTED`: +**attested under a named issuer key, NOT a finding that the issuer is independent of the owner** — the +SDK has no controller graph. Whether a receipt earns production RepID, and whether an issuer is truly +independent, is the **engine's** call against its own pinned key, never an offline verdict. A missing or +foreign trust root is `WHOLE_BUT_UNSEALED` / `FAILED`, never a silent pass. The engine-signed path is untouched. + +| Outcome | Exit | +|---|---| +| sealed; the receipt is on stdout and the pin on stderr | `0` | +| the body is missing, not JSON, too large, or not an object with a `header` | `2` | +| the receipt could not be sealed (e.g. keccak unavailable) | `3` | + +### `trustshell demo [--json]` + +> **Offline. No network, no account.** The whole receipt loop in one command — "receipts, not +> promises" in about two seconds. + +Seals a built-in sample `kernel-receipt/2` with **this install's own key** (created on first use, +exactly as `sign-receipt` does), then **verifies it right here** under that key and prints the +six-claim TrustLattice verdict. Nothing is written to disk and nothing leaves the machine. With +`--json` it emits `{ receipt, verify, root }` — the sealed receipt, the offline verdict, and the +`0x` key to pin — so a skeptic can reproduce the verify step independently: + +``` +trustshell sign-receipt your-body.json > r.json +trustshell verify-receipt r.json --root 0x +``` + +The sample receipt is owner-self-signed, so Integrity is `VERIFIED` under the pinned key and the +other five claims stay `NOT_CHECKED` — a self-signature proves authorship and wholeness only, and +earns no production RepID (see `sign-receipt`). + +| Outcome | Exit | +|---|---| +| sealed and verified here — Integrity `VERIFIED`, sealer trusted | `0` | +| the seal did not verify (should never happen on a good build) | `2` | +| the sample could not be sealed (e.g. keccak unavailable) | `3` | + ### `trustshell inspect [] [--from ]` Verify an append-only tool-call log and report what it can and cannot establish. Reads a local diff --git a/docs/proof-pack/README.md b/docs/proof-pack/README.md index 7f8822b8..6eb47318 100644 --- a/docs/proof-pack/README.md +++ b/docs/proof-pack/README.md @@ -47,3 +47,15 @@ For step 5 on a devnet, set `PROOF_PACK_DEVNET_MODULE` (a `devnet.mjs` exporting - The packed tarball is built from the committed `dist/`; the pack does not rebuild it. Dated runs sit beside this file as `proof-pack-.json` and `.md`. Each lists, under "limits", what the pack observed about itself. + +## Skeptic's receipt challenge + +A second, narrower artifact for one question: can a skeptic forge an **owner-self-signed** receipt that the shipped verifier accepts? It seals a receipt with a throwaway install key and then runs the attack matrix against the real `trustshell sign-receipt` and `trustshell verify-receipt` — offline, no account. + +```bash +npm run skeptic # from a clone; Node 22 or newer; a build (dist/); no key, no account, no network +``` + +Each attack PASSES only when the verifier **refuses** it: a missing root (never exit 0), a wrong pinned key, a flipped decision field, a swapped signature, a signature lifted onto another body, a policy expired before `--as-of`, and a self-signature dressed up with an authenticated six-claim envelope (still owner-self-signed, `repid_eligible: 'no'`). A positive control must verify first, or every refusal is vacuous. + +Recorded limitation: offline verify is stateless, so it does not catch a verbatim replay of one receipt — replay is the engine's `receipt_id`/nullifier, not the offline verifier. Exit codes and the dated `skeptic-receipt-.{json,md}` beside this file follow the same convention as the pack. The same guards are also unit-tested in `tests/owner-receipt.test.ts`. diff --git a/docs/proof-pack/skeptic-receipt-2026-11-07.json b/docs/proof-pack/skeptic-receipt-2026-11-07.json new file mode 100644 index 00000000..66586547 --- /dev/null +++ b/docs/proof-pack/skeptic-receipt-2026-11-07.json @@ -0,0 +1,198 @@ +{ + "challenge": "skeptic-receipt", + "decision": "D5", + "rollup": "PASS", + "generated_at": "2026-11-07T17:16:09.898Z", + "commit": "253ed5a6d69449fa5dc3e591cd5a777c1f74762c", + "version": "1.9.0-dev.0", + "node": "v22.17.0", + "platform": "win32 10.0.26200", + "ruler_sha256": "13fcf3d3c79a28d9abf9b62cb6aee7e2fcf2411ffcf663cbc44446c428568a3d", + "self_sha256": "923787643243bf3d4f11e0fee7bdfcc39a97065516332f4d1d11f9b2c53a2dd1", + "inputs": { + "cli": "dist\\cli\\index.js", + "body": { + "header": { + "receipt_id": "skeptic-0001", + "prev_receipt_id": null, + "prev_hash": null, + "created_at": "2026-11-07T00:00:00Z", + "env": { + "network": "base-sepolia", + "chain_id": 84532, + "is_simulated": true + } + }, + "subject": { + "agent": { + "agent_id": "agent-1", + "agent_name": "demo" + }, + "owner": { + "wallet": null, + "assurance": null, + "status": "none" + } + }, + "request": { + "tool": { + "name": "echo", + "class": "read" + }, + "origin": "Cli", + "args_hash": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "amount_raw": null, + "payee": null, + "asset": null, + "chain_id": null + }, + "decision": { + "verdict": "ALLOW", + "blocking": false, + "executed": false, + "reasons": [], + "checks": [ + { + "name": "local", + "outcome": "PASS", + "source": "owner" + } + ], + "does_not_attest": [], + "policy": { + "policy_hash": null, + "signer": null, + "signature": "NOT_CHECKED", + "expires_at": null, + "revocation": "NOT_CHECKED" + }, + "limits": { + "per_tx_raw": "0", + "daily_raw": "0", + "total_raw": "0", + "effective_per_tx_raw": "0", + "binding_term": "none" + }, + "laya": { + "lane": null, + "mode": "shadow", + "consulted": false + } + }, + "payment": { + "status": "none" + }, + "verification": { + "status": "none" + } + }, + "attacks": [ + "0-setup", + "positive-control", + "missing-root", + "wrong-key", + "tampered-evidence", + "forged-signature", + "transplanted-sig", + "expired-authz", + "no-self-promotion" + ] + }, + "steps": [ + { + "id": "0-setup", + "label": "seal a base receipt with this install key (throwaway TRUSTSHELL_HOME)", + "source": "[cmd] trustshell sign-receipt", + "verdict": "PASS", + "detail": "provenance owner-self-signed, pin 0x8e8c76026aaa…", + "data": { + "provenance": "owner-self-signed" + } + }, + { + "id": "positive-control", + "label": "a real seal verifies under its own pinned key", + "source": "[cmd] verify-receipt --root ", + "verdict": "PASS", + "detail": "exit 0, integrity VERIFIED, sealer trusted", + "data": { + "exit": 0 + } + }, + { + "id": "missing-root", + "label": "no --root pinned -> never exit 0 (unaccountable signer is not a pass)", + "source": "[cmd] verify-receipt (no --root)", + "verdict": "PASS", + "detail": "exit 2 (NOT_CHECKED), sealer not trusted, integrity VERIFIED", + "data": { + "exit": 2 + } + }, + { + "id": "wrong-key", + "label": "pinned to a DIFFERENT key -> integrity FAILED", + "source": "[cmd] verify-receipt --root ", + "verdict": "PASS", + "detail": "exit 1, integrity FAILED, sealer not trusted", + "data": { + "exit": 1 + } + }, + { + "id": "tampered-evidence", + "label": "flip decision.verdict after sealing -> integrity FAILED", + "source": "[cmd] verify-receipt --root ", + "verdict": "PASS", + "detail": "exit 1, integrity FAILED (hash no longer matches)", + "data": { + "exit": 1 + } + }, + { + "id": "forged-signature", + "label": "swap the signature -> Ed25519 verify FAILED", + "source": "[cmd] verify-receipt --root ", + "verdict": "PASS", + "detail": "exit 1, integrity FAILED", + "data": { + "exit": 1 + } + }, + { + "id": "transplanted-sig", + "label": "lift one receipt's signature onto a different body (same key) -> FAILED", + "source": "[cmd] verify-receipt --root ", + "verdict": "PASS", + "detail": "exit 1, integrity FAILED (a signature covers its own bytes only)", + "data": { + "exit": 1 + } + }, + { + "id": "expired-authz", + "label": "a policy expired before --as-of -> authorization claim FAILED", + "source": "[cmd] verify-receipt --root --as-of ", + "verdict": "PASS", + "detail": "authorization FAILED at as-of 2033 (integrity still VERIFIED, exit 0)", + "data": { + "exit": 0 + } + }, + { + "id": "no-self-promotion", + "label": "self-signature + authenticated envelope is STILL owner-self-signed, no RepID", + "source": "[shipped fn] evidenceLevel()", + "verdict": "PASS", + "detail": "level OWNER_SELF_SIGNED, repid_eligible no" + } + ], + "limitations": [ + { + "id": "offline-verify-is-stateless", + "observed": true, + "evidence": "the same sealed receipt verified twice, both exit 0/0", + "meaning": "offline verify does NOT catch a verbatim replay of one receipt. Replay is prevented by the engine (receipt_id / nullifier uniqueness), not by the offline verifier. A relying party that must not accept a receipt twice checks the id against what it has already seen." + } + ] +} diff --git a/docs/proof-pack/skeptic-receipt-2026-11-07.md b/docs/proof-pack/skeptic-receipt-2026-11-07.md new file mode 100644 index 00000000..43773877 --- /dev/null +++ b/docs/proof-pack/skeptic-receipt-2026-11-07.md @@ -0,0 +1,24 @@ +# Skeptic's receipt challenge — PASS + +> Can a skeptic forge an owner-self-signed receipt the shipped verifier accepts? Each attack below PASSES when the verifier **refuses** it. Reproduce: `npm run skeptic`. + +- **commit** `253ed5a6d69449fa5dc3e591cd5a777c1f74762c` · **version** `1.9.0-dev.0` · **node** `v22.17.0` · **platform** `win32 10.0.26200` +- **ruler** (the body under attack) `sha256:13fcf3d3c79a28d9…` · **this script** `sha256:923787643243bf3d…` +- **commands** `trustshell sign-receipt` and `trustshell verify-receipt` — offline, throwaway install key, no network + +| attack | verdict | what it proves | source | detail | +|---|---|---|---|---| +| 0-setup | PASS | seal a base receipt with this install key (throwaway TRUSTSHELL_HOME) | [cmd] trustshell sign-receipt | provenance owner-self-signed, pin 0x8e8c76026aaa… | +| positive-control | PASS | a real seal verifies under its own pinned key | [cmd] verify-receipt --root | exit 0, integrity VERIFIED, sealer trusted | +| missing-root | PASS | no --root pinned -> never exit 0 (unaccountable signer is not a pass) | [cmd] verify-receipt (no --root) | exit 2 (NOT_CHECKED), sealer not trusted, integrity VERIFIED | +| wrong-key | PASS | pinned to a DIFFERENT key -> integrity FAILED | [cmd] verify-receipt --root | exit 1, integrity FAILED, sealer not trusted | +| tampered-evidence | PASS | flip decision.verdict after sealing -> integrity FAILED | [cmd] verify-receipt --root | exit 1, integrity FAILED (hash no longer matches) | +| forged-signature | PASS | swap the signature -> Ed25519 verify FAILED | [cmd] verify-receipt --root | exit 1, integrity FAILED | +| transplanted-sig | PASS | lift one receipt's signature onto a different body (same key) -> FAILED | [cmd] verify-receipt --root | exit 1, integrity FAILED (a signature covers its own bytes only) | +| expired-authz | PASS | a policy expired before --as-of -> authorization claim FAILED | [cmd] verify-receipt --root --as-of | authorization FAILED at as-of 2033 (integrity still VERIFIED, exit 0) | +| no-self-promotion | PASS | self-signature + authenticated envelope is STILL owner-self-signed, no RepID | [shipped fn] evidenceLevel() | level OWNER_SELF_SIGNED, repid_eligible no | + +## Limitations (recorded, not passes) +- **offline-verify-is-stateless** — offline verify does NOT catch a verbatim replay of one receipt. Replay is prevented by the engine (receipt_id / nullifier uniqueness), not by the offline verifier. A relying party that must not accept a receipt twice checks the id against what it has already seen. _(the same sealed receipt verified twice, both exit 0/0)_ + +_Generated 2026-11-07T17:16:09.898Z. PASS = the verifier refused the attack. A FAIL here is a real defect in the seam._ diff --git a/package.json b/package.json index 77a7de2d..68ba85c0 100644 --- a/package.json +++ b/package.json @@ -106,6 +106,7 @@ "test:pack-x402": "node tests/pack-x402-guards.mjs", "test:trustmesh-live": "node scripts/trustmesh-live-e2e.mjs", "proof-pack": "node scripts/proof-pack.mjs", + "skeptic": "node scripts/skeptic-receipt.mjs", "test:proof-pack-controls": "node tests/e2e/proof-pack-controls.mjs", "test:proof-pack-mutants": "node tests/e2e/proof-pack-mutants.mjs", "search:index": "zg index . --embedding local/potion-retrieval-32m --ignore-file .zvec-grep-ignore" diff --git a/scripts/skeptic-receipt.mjs b/scripts/skeptic-receipt.mjs new file mode 100644 index 00000000..7a89b0b7 --- /dev/null +++ b/scripts/skeptic-receipt.mjs @@ -0,0 +1,282 @@ +#!/usr/bin/env node +/** + * SKEPTIC'S RECEIPT CHALLENGE (DECISION 5): can a skeptic forge an owner-self-signed receipt that the + * shipped verifier accepts? Run the attack matrix against the REAL built CLI and see each one REFUSED, + * with the receipt in hand and a report that names the commit, the version, the input and the output. + * + * npm run skeptic (or: node scripts/skeptic-receipt.mjs) + * + * Needs Node >= 22 and a build (dist/). NO account, NO key, NO network — every check is offline. The + * install key is a throwaway in a temp TRUSTSHELL_HOME, so your real key is untouched. The commands are + * exactly the two a skeptic runs: `trustshell sign-receipt` and `trustshell verify-receipt`. + * + * POSITIVE CONTROL a real seal verifies under its own pinned key (exit 0). If this fails, every REFUSE + * below is vacuous, so the whole run fails. + * missing root verify with no --root -> NOT_CHECKED, never exit 0 (an unaccountable signer is not a pass) + * wrong key verify under a different pinned key -> integrity FAILED (exit 1) + * tampered evidence flip one decision field after sealing -> integrity FAILED (exit 1) + * forged signature swap the signature for another -> integrity FAILED (exit 1) + * transplanted sig lift one receipt's signature onto a different body (same key) -> FAILED (exit 1) + * expired authz a policy expired before as-of -> the authorization claim is FAILED + * no self-promotion a self-signature + an authenticated six-claim envelope is STILL owner-self-signed, + * repid_eligible 'no' — validating your own receipt never earns production RepID + * + * LIMITATION (recorded, not a pass): offline verify is stateless, so it does not catch a verbatim replay + * of one receipt. Replay is the engine's job (receipt_id / nullifier uniqueness), not the offline verifier. + * + * Verdicts: PASS | FAIL | NOT_CHECKED. NOT_CHECKED is a result, never a pass. A REFUSE attack PASSES when + * the verifier refuses; it FAILS when the verifier accepts the attack (that would be a real defect). + * Exit: 0 every check PASS | 1 any FAIL | 2 nothing FAILED but something NOT_CHECKED | 3 usage/setup. + * + * --out-dir where skeptic-receipt-.json/.md go (default docs/proof-pack) + * --stem file stem (default skeptic-receipt-) + * --keep keep the temp dirs + */ +import { spawnSync, execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const SELF = fileURLToPath(import.meta.url); +const argv = process.argv.slice(2); +const opt = (n, d) => { const i = argv.indexOf(n); return i >= 0 && argv[i + 1] !== undefined && !argv[i + 1].startsWith('--') ? argv[i + 1] : d; }; +const has = (n) => argv.includes(n); +if (has('--help') || has('-h')) { console.log(fs.readFileSync(SELF, 'utf8').split('*/')[0].replace(/^\/\*\*?\n?/, '').replace(/^ \* ?/gm, '')); process.exit(0); } +const UTC_DATE = new Date().toISOString().slice(0, 10); +const OUT_DIR = path.resolve(ROOT, opt('--out-dir', 'docs/proof-pack')); +const STEM = opt('--stem', `skeptic-receipt-${UTC_DATE}`); +const KEEP = has('--keep'); + +const sha256 = (s) => createHash('sha256').update(s).digest('hex'); +const flipHex = (h) => h.slice(0, -1) + (h.endsWith('0') ? '1' : '0'); // valid-shaped but wrong (last nibble) +const CLI = path.join(ROOT, 'dist', 'cli', 'index.js'); +const err = (m) => process.stderr.write(m + '\n'); +const mdEscape = (s) => String(s).replace(/[\\|]/g, '\\$&'); + +// ---- the body under attack: byte-for-byte the one tests/owner-receipt.test.ts seals (the ruler) -------------------- +function base() { + return { + header: { receipt_id: 'skeptic-0001', prev_receipt_id: null, prev_hash: null, created_at: '2026-11-07T00:00:00Z', env: { network: 'base-sepolia', chain_id: 84532, is_simulated: true } }, + subject: { agent: { agent_id: 'agent-1', agent_name: 'demo' }, owner: { wallet: null, assurance: null, status: 'none' } }, + request: { tool: { name: 'echo', class: 'read' }, origin: 'Cli', args_hash: `0x${'a'.repeat(64)}`, amount_raw: null, payee: null, asset: null, chain_id: null }, + decision: { + verdict: 'ALLOW', blocking: false, executed: false, reasons: [], checks: [{ name: 'local', outcome: 'PASS', source: 'owner' }], does_not_attest: [], + policy: { policy_hash: null, signer: null, signature: 'NOT_CHECKED', expires_at: null, revocation: 'NOT_CHECKED' }, + limits: { per_tx_raw: '0', daily_raw: '0', total_raw: '0', effective_per_tx_raw: '0', binding_term: 'none' }, + laya: { lane: null, mode: 'shadow', consulted: false }, + }, + payment: { status: 'none' }, + verification: { status: 'none' }, + }; +} +const RULER_SHA = sha256(JSON.stringify(base())); +const SELF_SHA = sha256(fs.readFileSync(SELF)); + +// ---- plumbing: run the REAL built CLI with a throwaway install key, offline ---------------------------------------- +const HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'skeptic-home-')); +const WORK = fs.mkdtempSync(path.join(os.tmpdir(), 'skeptic-work-')); +const cleanup = () => { if (KEEP) return; for (const d of [HOME, WORK]) try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* best effort */ } }; +process.on('exit', cleanup); +function cli(args, input) { + const r = spawnSync(process.execPath, [CLI, ...args], { + cwd: WORK, input, encoding: 'utf8', windowsHide: true, timeout: 60000, + env: { ...process.env, TRUSTSHELL_HOME: HOME, NO_COLOR: '1' }, + }); + return { code: r.status, stdout: r.stdout || '', stderr: r.stderr || '', err: r.error }; +} +const json = (s) => { try { return JSON.parse(s); } catch { return null; } }; +const writeJson = (name, obj) => { const p = path.join(WORK, name); fs.writeFileSync(p, JSON.stringify(obj)); return p; }; + +// ---- results ------------------------------------------------------------------------------------------------------- +const steps = []; +const limits = []; +let dir = null; // the deep-imported built lib (for the one claim the CLI exit code does not express) +const P = (detail, data) => ({ verdict: 'PASS', detail, data }); +const F = (detail, data) => ({ verdict: 'FAIL', detail, data }); +const N = (detail, cause, data) => ({ verdict: 'NOT_CHECKED', detail, cause, data }); +function record(id, label, source, r) { + const st = { id, label, source, verdict: r.verdict, detail: r.detail ?? '', ...(r.cause ? { cause: r.cause } : {}), ...(r.data ? { data: r.data } : {}) }; + steps.push(st); + err(` ${r.verdict.padEnd(11)} ${id.padEnd(18)} ${label}${st.detail ? ' | ' + String(st.detail).slice(0, 140) : ''}${r.cause ? ` [${r.cause}]` : ''}`); + return st; +} + +// ---- the challenge ------------------------------------------------------------------------------------------------- +let PIN = null; +let sealed = null; + +function setup() { + if (!fs.existsSync(CLI)) { record('0-setup', 'build present (dist/cli/index.js)', '[fs]', N('no dist/cli/index.js — run `npm run sdk:build` first', 'no-build')); return false; } + const help = cli(['--help']); + if (!/sign-receipt/.test(help.stdout + help.stderr) || !/verify-receipt/.test(help.stdout + help.stderr)) { + record('0-setup', 'this build ships sign-receipt + verify-receipt', '[cmd] trustshell --help', N('the owner-signed seam is not in this build (it lands with PR #681 / branch claude/run-signed-receipt)', 'not-on-this-build')); + return false; + } + const bodyPath = writeJson('body.json', base()); + const s = cli(['sign-receipt', bodyPath]); + sealed = json(s.stdout); + PIN = (s.stderr.match(/--root (0x[0-9a-f]{64})/) || [])[1] || null; + if (s.code !== 0 || !sealed || !PIN) { record('0-setup', 'seal a base receipt with this install key', '[cmd] trustshell sign-receipt', F(`sign-receipt failed (exit ${s.code})`, { stderr: s.stderr.slice(0, 200) })); return false; } + record('0-setup', 'seal a base receipt with this install key (throwaway TRUSTSHELL_HOME)', '[cmd] trustshell sign-receipt', P(`provenance ${sealed.header.provenance}, pin ${PIN.slice(0, 14)}…`, { provenance: sealed.header.provenance })); + // deep-import the BUILT lib for the one guard the CLI exit code cannot express (RepID eligibility) + try { const req = createRequire(path.join(ROOT, 'x.js')); dir = { owner: req(path.join(ROOT, 'dist/lib/owner-receipt.js')), verify: req(path.join(ROOT, 'dist/lib/verify-receipt.js')), kernel: req(path.join(ROOT, 'dist/lib/kernel-client.js')) }; } catch (e) { dir = null; limits.push({ id: 'lib-import', meaning: `could not deep-import the built lib (${e.message}); the no-self-promotion check falls back to the provenance stamp only` }); } + return true; +} + +/** A verify-receipt call, returned with its exit code and parsed --json result. */ +function verify(file, extra = []) { const r = cli(['verify-receipt', file, '--json', ...extra]); return { code: r.code, v: json(r.stdout), raw: r }; } + +function runChallenge() { + // POSITIVE CONTROL — the real seal must verify, or every refusal below means nothing. + { + const sealedPath = writeJson('sealed.json', sealed); + const { code, v } = verify(sealedPath, ['--root', PIN]); + const ok = code === 0 && v?.claims?.integrity?.status === 'VERIFIED' && v?.sealer?.trusted === true; + record('positive-control', 'a real seal verifies under its own pinned key', '[cmd] verify-receipt --root ', ok ? P(`exit 0, integrity VERIFIED, sealer trusted`, { exit: code }) : F(`expected exit 0 + VERIFIED + trusted; got exit ${code}, integrity ${v?.claims?.integrity?.status}, trusted ${v?.sealer?.trusted}`, { exit: code })); + if (!ok) return; // vacuous to continue + } + + // missing root — whole, but UNSEALED: NOT_CHECKED, never a pass. + { + const sealedPath = writeJson('sealed.json', sealed); + const { code, v } = verify(sealedPath); + const refused = code !== 0 && v?.sealer?.trusted === false; + record('missing-root', 'no --root pinned -> never exit 0 (unaccountable signer is not a pass)', '[cmd] verify-receipt (no --root)', refused ? P(`exit ${code} (NOT_CHECKED), sealer not trusted, integrity ${v?.claims?.integrity?.status}`, { exit: code }) : F(`a receipt with no pinned signer read as a pass (exit ${code})`, { exit: code })); + } + + // wrong key — a different pinned root: integrity FAILED. + { + const sealedPath = writeJson('sealed.json', sealed); + const { code, v } = verify(sealedPath, ['--root', flipHex(PIN)]); + const refused = code === 1 && v?.claims?.integrity?.status === 'FAILED'; + record('wrong-key', 'pinned to a DIFFERENT key -> integrity FAILED', '[cmd] verify-receipt --root ', refused ? P(`exit 1, integrity FAILED, sealer not trusted`, { exit: code }) : F(`a foreign pin did not fail (exit ${code}, integrity ${v?.claims?.integrity?.status})`, { exit: code })); + } + + // tampered evidence — flip a decision field after sealing. + { + const t = JSON.parse(JSON.stringify(sealed)); t.decision.verdict = 'DENY'; + const p = writeJson('tampered.json', t); + const { code, v } = verify(p, ['--root', PIN]); + const refused = code === 1 && v?.claims?.integrity?.status === 'FAILED'; + record('tampered-evidence', 'flip decision.verdict after sealing -> integrity FAILED', '[cmd] verify-receipt --root ', refused ? P(`exit 1, integrity FAILED (hash no longer matches)`, { exit: code }) : F(`a tampered receipt verified (exit ${code}, integrity ${v?.claims?.integrity?.status})`, { exit: code })); + } + + // forged signature — swap the signature for a different (valid-shaped) one. + { + const f = JSON.parse(JSON.stringify(sealed)); f.header.signature = flipHex(sealed.header.signature); + const p = writeJson('forged-sig.json', f); + const { code, v } = verify(p, ['--root', PIN]); + const refused = code === 1 && v?.claims?.integrity?.status === 'FAILED'; + record('forged-signature', 'swap the signature -> Ed25519 verify FAILED', '[cmd] verify-receipt --root ', refused ? P(`exit 1, integrity FAILED`, { exit: code }) : F(`a forged signature verified (exit ${code}, integrity ${v?.claims?.integrity?.status})`, { exit: code })); + } + + // transplanted signature — lift this key's signature from receipt A onto a DIFFERENT body B. + { + const bodyB = base(); bodyB.header.receipt_id = 'skeptic-0002'; + const sB = cli(['sign-receipt', writeJson('bodyB.json', bodyB)]); + const sealedB = json(sB.stdout); + if (sB.code === 0 && sealedB) { + const transplant = JSON.parse(JSON.stringify(sealedB)); transplant.header.signature = sealed.header.signature; // A's sig on B's bytes + const p = writeJson('transplant.json', transplant); + const { code, v } = verify(p, ['--root', PIN]); + const refused = code === 1 && v?.claims?.integrity?.status === 'FAILED'; + record('transplanted-sig', "lift one receipt's signature onto a different body (same key) -> FAILED", '[cmd] verify-receipt --root ', refused ? P(`exit 1, integrity FAILED (a signature covers its own bytes only)`, { exit: code }) : F(`a transplanted signature verified (exit ${code}, integrity ${v?.claims?.integrity?.status})`, { exit: code })); + } else record('transplanted-sig', 'seal a second body to transplant from', '[cmd] trustshell sign-receipt', N('could not seal the second body', 'setup')); + } + + // expired authorization — a policy expired before the as-of anchor; the authorization claim must fail. + { + const b = base(); b.decision.policy.expires_at = 1_000_000_000; // 2001 + const sE = cli(['sign-receipt', writeJson('bodyE.json', b)]); + const sealedE = json(sE.stdout); + if (sE.code === 0 && sealedE) { + const p = writeJson('expired.json', sealedE); + const { code, v } = verify(p, ['--root', PIN, '--as-of', '2000000000']); // 2033 + const refused = v?.claims?.authorization?.status === 'FAILED'; + record('expired-authz', 'a policy expired before --as-of -> authorization claim FAILED', '[cmd] verify-receipt --root --as-of ', refused ? P(`authorization FAILED at as-of 2033 (integrity still VERIFIED, exit ${code})`, { exit: code }) : F(`an expired policy did not fail the authorization claim (${v?.claims?.authorization?.status})`, { exit: code })); + } else record('expired-authz', 'seal a receipt with an expired policy', '[cmd] trustshell sign-receipt', N('could not seal the expired-policy body', 'setup')); + } + + // no self-promotion — a self-signature, even with an AUTHENTICATED six-claim envelope, is still + // owner-self-signed and repid_eligible 'no'. This is the CLI exit code's blind spot, so it is read + // from the shipped evidenceLevel() directly (deep import), with the provenance stamp as the fallback. + { + if (dir) { + const { SIX_CLAIM_KEYS, SIX_CLAIMS_PREDICATE_TYPE } = dir.kernel; + const claims = {}; for (const k of SIX_CLAIM_KEYS) claims[k] = { status: 'VERIFIED', statement: `claim ${k}`, supports: [], gaps: [] }; + const envelope = { _type: 'https://in-toto.io/Statement/v1', predicateType: SIX_CLAIMS_PREDICATE_TYPE, subject: [{ name: `kernel-receipt:${sealed.header.receipt_id}`, digest: { keccak256: sealed.header.receipt_hash.replace(/^0x/, '') } }], predicate: { claims, what_this_does_not_establish: [] } }; + dir.verify.verifyReceiptOffline(sealed, { trustedRoots: [PIN], engineEnvelope: envelope, envelopeAuthenticated: true }).then((v) => { + const lvl = dir.owner.evidenceLevel(v, sealed); + const refused = lvl.level === 'OWNER_SELF_SIGNED' && lvl.repid_eligible === 'no'; + record('no-self-promotion', 'self-signature + authenticated envelope is STILL owner-self-signed, no RepID', '[shipped fn] evidenceLevel()', refused ? P(`level ${lvl.level}, repid_eligible ${lvl.repid_eligible}`) : F(`a self-signature was promoted: level ${lvl.level}, repid_eligible ${lvl.repid_eligible}`)); + finish(); + }).catch((e) => { record('no-self-promotion', 'evidenceLevel() on a self-signature', '[shipped fn]', F(`threw: ${e.message}`)); finish(); }); + return; // finish() is called in the promise + } + const refused = sealed.header.provenance === 'owner-self-signed'; + record('no-self-promotion', 'the seal is stamped owner-self-signed (RepID gate proven in owner-receipt.test.ts)', '[cmd] provenance stamp', refused ? P('provenance owner-self-signed') : F(`provenance is ${sealed.header.provenance}`)); + } + finish(); +} + +// ---- limitation probe + report ------------------------------------------------------------------------------------ +function replayLimitation() { + const sealedPath = writeJson('sealed.json', sealed); + const a = verify(sealedPath, ['--root', PIN]); + const b = verify(sealedPath, ['--root', PIN]); + limits.push({ id: 'offline-verify-is-stateless', observed: a.code === 0 && b.code === 0, evidence: `the same sealed receipt verified twice, both exit ${a.code}/${b.code}`, meaning: 'offline verify does NOT catch a verbatim replay of one receipt. Replay is prevented by the engine (receipt_id / nullifier uniqueness), not by the offline verifier. A relying party that must not accept a receipt twice checks the id against what it has already seen.' }); +} + +function finish() { + if (sealed && PIN) replayLimitation(); + const roll = steps.some((s) => s.verdict === 'FAIL') ? 'FAIL' : steps.some((s) => s.verdict === 'NOT_CHECKED') ? 'NOT_CHECKED' : 'PASS'; + let commit = null; try { commit = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: ROOT, encoding: 'utf8' }).trim(); } catch { /* not a git checkout */ } + const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')); + const report = { + challenge: 'skeptic-receipt', decision: 'D5', rollup: roll, generated_at: new Date().toISOString(), + commit, version: pkg.version, node: process.version, platform: `${process.platform} ${os.release()}`, + ruler_sha256: RULER_SHA, self_sha256: SELF_SHA, + inputs: { cli: path.relative(ROOT, CLI), body: base(), attacks: steps.map((s) => s.id) }, + steps, limitations: limits, + }; + fs.mkdirSync(OUT_DIR, { recursive: true }); + const jsonPath = path.join(OUT_DIR, `${STEM}.json`); + fs.writeFileSync(jsonPath, JSON.stringify(report, null, 2) + '\n'); + fs.writeFileSync(path.join(OUT_DIR, `${STEM}.md`), markdown(report)); + err(''); + err(`SKEPTIC CHALLENGE: ${roll} (commit ${commit?.slice(0, 12) ?? '?'}, v${pkg.version})`); + err(`report: ${path.relative(ROOT, jsonPath)} and ${STEM}.md`); + process.exitCode = roll === 'FAIL' ? 1 : roll === 'NOT_CHECKED' ? 2 : 0; +} + +function markdown(r) { + const rows = r.steps.map((s) => `| ${s.id} | ${s.verdict} | ${mdEscape(s.label)} | ${mdEscape(s.source)} | ${mdEscape(s.detail)} |`).join('\n'); + const lims = r.limitations.map((l) => `- **${l.id}** — ${mdEscape(l.meaning)}${l.evidence ? ` _(${mdEscape(l.evidence)})_` : ''}`).join('\n'); + return [ + `# Skeptic's receipt challenge — ${r.rollup}`, + '', + `> Can a skeptic forge an owner-self-signed receipt the shipped verifier accepts? Each attack below PASSES when the verifier **refuses** it. Reproduce: \`npm run skeptic\`.`, + '', + `- **commit** \`${r.commit ?? '?'}\` · **version** \`${r.version}\` · **node** \`${r.node}\` · **platform** \`${r.platform}\``, + `- **ruler** (the body under attack) \`sha256:${r.ruler_sha256.slice(0, 16)}…\` · **this script** \`sha256:${r.self_sha256.slice(0, 16)}…\``, + `- **commands** \`trustshell sign-receipt\` and \`trustshell verify-receipt\` — offline, throwaway install key, no network`, + '', + '| attack | verdict | what it proves | source | detail |', + '|---|---|---|---|---|', + rows, + '', + '## Limitations (recorded, not passes)', + lims || '- none', + '', + `_Generated ${r.generated_at}. PASS = the verifier refused the attack. A FAIL here is a real defect in the seam._`, + '', + ].join('\n'); +} + +// ---- go ------------------------------------------------------------------------------------------------------------ +err(`skeptic's receipt challenge — attacking the owner-self-signed seam with the real CLI`); +if (!setup()) { finish(); } else { runChallenge(); } diff --git a/src/cli/index.ts b/src/cli/index.ts index 8e3bf28d..afc46654 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -75,8 +75,10 @@ import { runLearn } from '../learn/cli'; import { runBundle } from './bundle'; import { EXIT_REFUSED, runGuard, runGuarded, runRun } from './guard'; import { sandboxCli } from '../sandbox/run'; -import { GUARD_RECORD_SCHEMA } from '../lib/guardrails'; +import { GUARD_RECORD_SCHEMA, trustshellHome } from '../lib/guardrails'; import { ApiUrlError } from '../lib/api-url'; +import { signingKey } from '../lib/install-key'; +import { signOwnerReceipt, installKeyRootHex, type SealableReceipt } from '../lib/owner-receipt'; /** Exit codes — a small, stable contract so CI scripts can branch on them. */ export const EXIT = { @@ -109,6 +111,8 @@ export type Command = | 'check' | 'receipt' | 'verify-receipt' + | 'sign-receipt' + | 'demo' | 'inspect' | 'init' | 'report' @@ -321,6 +325,20 @@ COMMANDS [--envelope ] Ed25519 keys), the signature; reads the other claims from the envelope, fail-closed to NOT_CHECKED. EXIT keyed to integrity: 0 VERIFIED, 1 FAILED (tampered/forged), 2 NOT_CHECKED. + sign-receipt Seal a kernel-receipt body with THIS install's OWN key + (/bundle-signing-key.pem), OFFLINE, + no engine. Prints the sealed kernel-receipt/2 to stdout (provenance + 'owner-self-signed') and, on stderr, the 0x key a verifier pins: + trustshell sign-receipt body.json > r.json + trustshell verify-receipt r.json --root 0x + An owner self-signature proves AUTHORSHIP + wholeness only — never that + the engine permitted the action, and it earns no production RepID. Use + - for stdin. EXIT 0 sealed, 2 bad body, 3 could not seal. + demo The whole offline loop in one command: seal a sample receipt with THIS + install's key, then verify it HERE under that key and print the six-claim + verdict and the exact verify-receipt --root line to reproduce it. No + network, no account. Receipts, not promises. EXIT 0 the seal verified, + 2 it did not, 3 could not seal. inspect [] Verify an append-only tool-call log. Reads a local file and computes hashes; opens NO socket. INTACT (0) / BROKEN (1) / @@ -677,6 +695,13 @@ export function parseArgs(argv: string[]): ParsedArgs { if (!operand) return { command: 'help', json, verify, error: 'verify-receipt requires (a receipt, or an engine receipt_response)' }; return { command: 'verify-receipt', operand, json, verify, root: values['root'], asOf: values['as-of'], envelope: values['envelope'] }; } + case 'sign-receipt': { + const operand = rest[0]; + if (!operand) return { command: 'help', json, verify, error: 'sign-receipt requires (a kernel-receipt body to seal) or - for stdin' }; + return { command: 'sign-receipt', operand, json, verify }; + } + case 'demo': + return { command: 'demo', json, verify }; case 'status': return { command: 'status', json, verify }; case 'remember': { @@ -1473,6 +1498,90 @@ export async function run( return code; } + case 'sign-receipt': { + const file = args.operand as string; + let raw: string; + try { + raw = readTextFile(file === '-' ? 0 : file, 'utf8'); // fd 0 = stdin + } catch (e) { + io.err(`sign-receipt: cannot read ${file === '-' ? 'stdin' : file}: ${(e as Error).message}`); + return EXIT.USAGE; + } + if (raw.length > MAX_CLAIMS_FILE_BYTES) { + io.err(`sign-receipt: input is too large (> ${MAX_CLAIMS_FILE_BYTES} bytes)`); + return EXIT.USAGE; + } + let body: unknown; + try { + body = JSON.parse(raw); + } catch { + io.err(`sign-receipt: input is not valid JSON`); + return EXIT.USAGE; + } + if (!body || typeof body !== 'object' || typeof (body as { header?: unknown }).header !== 'object' || (body as { header?: unknown }).header === null) { + io.err('sign-receipt: the body must be a JSON object with a "header" object'); + return EXIT.USAGE; + } + try { + const dir = trustshellHome(process.env); + const { key, created, path } = signingKey(dir); + const receipt = await signOwnerReceipt(body as SealableReceipt, key); + // The receipt to stdout stays machine-clean (pipe it, or `> r.json`); the human-facing pin + // and the warning that this is a self-attestation go to stderr so they never corrupt the JSON. + io.out(JSON.stringify(receipt, null, 2)); + if (created) io.err(`sign-receipt: created a new install key at ${path}`); + io.err(`sign-receipt: sealed as owner-self-signed. A verifier confirms the SEAL with:`); + io.err(` trustshell verify-receipt --root ${installKeyRootHex(key)}`); + io.err(`This proves authorship and that the bytes are whole — NOT that the engine permitted the action, and it earns no production RepID.`); + return EXIT.OK; + } catch (e) { + io.err(`sign-receipt: could not seal the receipt: ${e instanceof MissingDependencyError ? e.message : (e as Error).message}`); + return EXIT.RUNTIME; + } + } + + case 'demo': { + // The whole offline loop in one command: seal a sample receipt with this install's key, then verify + // it HERE under that key. No network, no account, no file written. "Receipts, not promises" in ~2 s. + try { + const dir = trustshellHome(process.env); + const { key, created, path: keyPath } = signingKey(dir); + const body: SealableReceipt = { + header: { receipt_id: `demo-${Date.now().toString(36)}`, prev_receipt_id: null, prev_hash: null, created_at: new Date().toISOString(), env: { network: 'base-sepolia', chain_id: 84532, is_simulated: true } }, + subject: { agent: { agent_id: 'demo-agent', agent_name: 'trustshell demo' }, owner: { wallet: null, assurance: null, status: 'none' } }, + request: { tool: { name: 'read_file', class: 'read' }, origin: 'Cli', args_hash: `0x${'de'.repeat(32)}`, amount_raw: null, payee: null, asset: null, chain_id: null }, + decision: { verdict: 'ALLOW', blocking: false, executed: true, reasons: ['sample action for the demo'], checks: [{ name: 'local', outcome: 'PASS', source: 'owner' }], does_not_attest: [], policy: { policy_hash: null, signer: null, signature: 'NOT_CHECKED', expires_at: null, revocation: 'NOT_CHECKED' }, limits: {}, laya: { lane: null, mode: 'shadow', consulted: false } }, + payment: { status: 'none' }, + verification: { status: 'none' }, + }; + const receipt = await signOwnerReceipt(body, key); + const pin = installKeyRootHex(key); + const result = await verifyReceiptOffline(receipt, { trustedRoots: [pin] }); + const code = verifyReceiptExitCode(result); + if (args.json) { + io.out(terminalSafeJson({ receipt, verify: result, root: pin })); + return code; + } + if (created) io.err(`demo: created a new install key at ${keyPath}`); + io.out('trustshell demo — the offline receipt loop, start to finish:'); + io.out(''); + io.out(` 1. SEALED a sample receipt with this install's own key (provenance: ${(receipt.header as { provenance?: string }).provenance ?? 'owner-self-signed'}).`); + io.out(' 2. VERIFIED it here, offline, under that key:'); + io.out(''); + io.out(formatOfflineVerify(result)); + io.out(''); + io.out('Do the same with your own receipt body — still offline, no account:'); + io.out(' trustshell sign-receipt your-body.json > r.json'); + io.out(` trustshell verify-receipt r.json --root ${pin}`); + io.err(''); + io.err('An owner self-signature proves AUTHORSHIP + wholeness only — not that the engine permitted the action, and it earns no production RepID. Offline, a non-owner key reaches at most ISSUER_ATTESTED (attested under a named key, not a finding of independence); independence and RepID are the engine\'s call.'); + return code; + } catch (e) { + io.err(`demo: could not seal the sample receipt: ${e instanceof MissingDependencyError ? e.message : (e as Error).message}`); + return EXIT.RUNTIME; + } + } + case 'proof': { try { const p = await client.presentProof(args.operand as string, { verify: args.verify }); diff --git a/src/lib/owner-receipt.ts b/src/lib/owner-receipt.ts new file mode 100644 index 00000000..1aa98005 --- /dev/null +++ b/src/lib/owner-receipt.ts @@ -0,0 +1,156 @@ +/** + * owner-receipt — seal a kernel receipt with THIS install's own Ed25519 key, offline, with no engine. + * + * The engine (repid-engine) signs kernel-receipt/2 with its production key; this SDK has only ever + * VERIFIED those. This is the owner's side: an agent's accountable owner can seal a receipt with their + * own install key (`bundle-signing-key.pem`, src/lib/install-key.ts) so a stranger can reproduce its + * integrity offline. It emits the SAME wire format the engine does, so {@link verifyReceiptOffline} + * checks it UNCHANGED — the Ed25519 signature over `KERNEL_RECEIPT_SIGNATURE_DOMAIN + canonical bytes`, + * the keccak256 receipt_hash, the 0x-hex signer and signature. One format, one verifier. + * + * THREE EVIDENCE LEVELS, NOT ONE. An owner-self-signed receipt proves exactly two things and NOTHING + * more: the bytes are whole, and the owner's own key sealed them (authorship). It does NOT carry the + * engine's six-claim attestation, so a verifier leaves authenticity/observation/correctness/ + * authorization/containment at NOT_CHECKED — the owner cannot self-certify that the kernel permitted + * the action or that an outcome was observed. To keep that legible and un-launderable: + * + * - every owner receipt carries `header.provenance = 'owner-self-signed'`, covered by the signature + * AND the hash (flip it and both break), so no reader mistakes it for an engine receipt; + * - {@link evidenceLevel} never reports ISSUER_ATTESTED for one, and never reports a receipt as + * RepID-eligible — self-attestation earns authorship, never production reputation. An owner receipt + * that is whole and sealed stays OWNER_SELF_SIGNED. ISSUER_ATTESTED is the most an offline verdict + * ever reaches for a non-owner key, and it does NOT assert the issuer is independent of the owner + * (the SDK has no controller graph). Independence and RepID are the ENGINE's call against its OWN key. + * + * A missing trust root does NOT silently pass: with no pin the verifier reports the bytes whole but + * UNSEALED (sealer.trusted=false), which {@link evidenceLevel} surfaces as WHOLE_BUT_UNSEALED, never + * OWNER_SELF_SIGNED. "I can't check who sealed it" is never "the owner sealed it". + */ +import { createHash, createPublicKey, sign, type KeyObject } from 'node:crypto'; +import { + KERNEL_RECEIPT_SCHEMA_SIGNED, + KERNEL_RECEIPT_SIGNATURE_DOMAIN, + SIX_CLAIM_KEYS, + kernelCanonicalJson, + kernelReceiptHash, + type KernelReceiptSigner, + type KernelReceiptV2, +} from './kernel-client'; +import type { OfflineVerifyResult } from './verify-receipt'; + +/** The provenance stamp that marks a receipt as sealed by an owner's own install key, not the engine. */ +export const OWNER_SELF_SIGNED = 'owner-self-signed' as const; + +const HEX32 = /^0x[0-9a-f]{64}$/; + +/** + * This install key's public identity in the engine's wire encoding: public_key and fingerprint are + * lower-case `0x` + hex (the install key's own {@link import('./install-key').signerOf} gives base64url + * / un-prefixed sha256; kernel-receipt/2 uses 0x-hex, so convert once, here). + */ +export function installKeySigner(key: KeyObject): KernelReceiptSigner { + const x = String((createPublicKey(key).export({ format: 'jwk' }) as { x?: string }).x); + const raw = Buffer.from(x, 'base64url'); + if (raw.length !== 32) throw new Error('not an Ed25519 public key (expected 32 bytes)'); + return { + alg: 'ed25519', + public_key: `0x${raw.toString('hex')}`, + fingerprint: `0x${createHash('sha256').update(raw).digest('hex')}`, + }; +} + +/** The `trustedRoots` pin a verifier passes to confirm THIS key sealed a receipt (0x + 64 hex). */ +export function installKeyRootHex(key: KeyObject): string { + return installKeySigner(key).public_key; +} + +/** A receipt body to seal: anything with a `header` object, minus the sealing fields this adds. */ +export interface SealableReceipt { + header: Record; + [k: string]: unknown; +} + +/** + * Seal `body` as an owner-self-signed kernel-receipt/2. Reproduces the engine's order exactly: + * 1. header gets schema=kernel-receipt/2, version=2, provenance=owner-self-signed, signer — but NO + * receipt_hash and NO signature yet; + * 2. signature = Ed25519 over `DOMAIN + canonical(receipt-without-receipt_hash-and-signature)`, 0x+128 hex; + * 3. receipt_hash = keccak256 over the canonical receipt WITH the signature but WITHOUT receipt_hash. + * The caller supplies the body; any `schema`/`version`/`signer`/`signature`/`receipt_hash` already on the + * header are overwritten (you cannot pre-stamp the sealing fields). Needs keccak (ethers peer or the + * @noble path); never makes a network call. + */ +export async function signOwnerReceipt(body: SealableReceipt, key: KeyObject): Promise { + if (!body || typeof body !== 'object' || typeof body.header !== 'object' || body.header === null) { + throw new Error('signOwnerReceipt: body must be an object with a header object'); + } + const signer = installKeySigner(key); + const { receipt_hash: _h, signature: _s, ...rest } = body.header as Record; + const header = { ...rest, schema: KERNEL_RECEIPT_SCHEMA_SIGNED, version: 2 as const, provenance: OWNER_SELF_SIGNED, signer }; + const unsigned = { ...body, header }; + const signature = `0x${sign(null, Buffer.from(KERNEL_RECEIPT_SIGNATURE_DOMAIN + kernelCanonicalJson(unsigned), 'utf8'), key).toString('hex')}`; + const signed = { ...body, header: { ...header, signature } }; + const receipt_hash = await kernelReceiptHash(signed as unknown as Parameters[0]); + return { ...body, header: { ...header, signature, receipt_hash } } as unknown as KernelReceiptV2; +} + +/** The provenance stamp on a receipt, or null when it has none (an engine receipt carries none). */ +export function ownerReceiptProvenance(receipt: unknown): string | null { + if (!receipt || typeof receipt !== 'object') return null; + const header = (receipt as { header?: unknown }).header; + if (!header || typeof header !== 'object') return null; + const p = (header as { provenance?: unknown }).provenance; + return typeof p === 'string' ? p : null; +} + +export type EvidenceLevel = + /** integrity is not VERIFIED: tampered, or the hash could not be recomputed. Trust nothing. */ + | 'NOT_ESTABLISHED' + /** the bytes are whole, but no pinned key was confirmed to have sealed them (no/absent trust root). */ + | 'WHOLE_BUT_UNSEALED' + /** whole AND sealed by the owner's own install key (provenance=owner-self-signed). Authorship only. */ + | 'OWNER_SELF_SIGNED' + /** + * whole, sealed by a pinned issuer key that is NOT an owner self-signature, with ≥1 engine claim + * VERIFIED under it. Attestation UNDER A NAMED ISSUER — NOT a finding that the issuer is independent + * of the owner's beneficial control. Offline, the SDK only knows the caller chose to pin this key; it + * cannot derive the controller graph. Whether the issuer is truly independent (and whether the receipt + * earns RepID) is the engine's call against its own key, never an offline verdict. + */ + | 'ISSUER_ATTESTED'; + +export interface EvidenceVerdict { + level: EvidenceLevel; + /** The SDK never says "yes": self-attestation is 'no'; everything else defers to the engine. */ + repid_eligible: 'no' | 'engine-decides'; + why: string; +} + +/** + * Map an offline verdict (+ the receipt it came from) to one of the three evidence levels D1 preserves. + * This is the guard that keeps owner-signed from ever reading as independently-verified or RepID-bearing. + */ +export function evidenceLevel(verdict: OfflineVerifyResult, receipt: unknown): EvidenceVerdict { + const integrity = verdict.claims?.integrity?.status; + if (integrity !== 'VERIFIED') { + return { level: 'NOT_ESTABLISHED', repid_eligible: 'engine-decides', why: `integrity is ${integrity ?? 'absent'}, not VERIFIED` }; + } + if (!verdict.sealer?.trusted) { + return { level: 'WHOLE_BUT_UNSEALED', repid_eligible: 'engine-decides', why: 'the bytes are whole but no pinned key was confirmed to have sealed them' }; + } + if (ownerReceiptProvenance(receipt) === OWNER_SELF_SIGNED) { + return { level: 'OWNER_SELF_SIGNED', repid_eligible: 'no', why: `sealed by the owner's own install key (${verdict.sealer.fingerprint ?? 'pinned key'}); self-attestation earns authorship, never production RepID` }; + } + const anyEngineClaimVerified = SIX_CLAIM_KEYS.some((k) => k !== 'integrity' && verdict.claims?.[k]?.status === 'VERIFIED'); + if (anyEngineClaimVerified) { + return { + level: 'ISSUER_ATTESTED', + repid_eligible: 'engine-decides', + why: 'sealed by a pinned issuer key (not an owner self-signature) and at least one engine attestation verified under it; this does NOT establish that the issuer is independent of the owner — independence and RepID are the engine’s call', + }; + } + // Whole + sealed by a pinned key that is not a self-signature, but no engine claim reached VERIFIED + // (e.g. the envelope was not supplied or not authenticated). It is sealed, but it is not independently + // verified — do not promote it. + return { level: 'WHOLE_BUT_UNSEALED', repid_eligible: 'engine-decides', why: 'sealed by a pinned key, but no engine attestation reached VERIFIED, so no independent claim stands' }; +} diff --git a/src/verify/index.ts b/src/verify/index.ts index 4ce28ba4..b05d965d 100644 --- a/src/verify/index.ts +++ b/src/verify/index.ts @@ -50,3 +50,20 @@ export type { KernelReceiptSigner, KernelReceiptAuthenticity, } from '../lib/kernel-client'; + +/** + * The owner's side of the same offline surface: seal a receipt with THIS install's own Ed25519 key so + * a stranger reproduces its integrity with the verifier above — one wire format, one verifier. An + * owner self-signature proves authorship and wholeness only; `evidenceLevel` keeps it at + * OWNER_SELF_SIGNED, never issuer-attested or RepID-bearing (a non-owner pinned key reaches at most + * ISSUER_ATTESTED, which is not a finding of independence). node:crypto only, no network (same import boundary). + */ +export { + signOwnerReceipt, + installKeySigner, + installKeyRootHex, + ownerReceiptProvenance, + evidenceLevel, + OWNER_SELF_SIGNED, +} from '../lib/owner-receipt'; +export type { SealableReceipt, EvidenceLevel, EvidenceVerdict } from '../lib/owner-receipt'; diff --git a/tests/cli-demo.test.ts b/tests/cli-demo.test.ts new file mode 100644 index 00000000..5fbd69a4 --- /dev/null +++ b/tests/cli-demo.test.ts @@ -0,0 +1,68 @@ +/** + * `trustshell demo` (DECISION 4): the whole offline receipt loop in one command — seal a sample + * receipt with THIS install's key, verify it HERE under that key, print the six-claim verdict and the + * line to reproduce it. No network, no account. TRUSTSHELL_HOME is an ephemeral temp dir so the real + * install key is untouched. + */ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { parseArgs, run, EXIT, type CliIO } from '../src/cli'; +import type { TrustShell } from '../src/lib/trustshell'; + +const NO_CLIENT = {} as TrustShell; // demo never touches the HAL client + +function captureIO(): { io: CliIO; out: string[]; err: string[] } { + const out: string[] = []; + const err: string[] = []; + return { io: { out: (s) => out.push(s), err: (s) => err.push(s) }, out, err }; +} + +describe('trustshell demo (DECISION 4 — one-command offline loop)', () => { + let home = ''; + let prevHome: string | undefined; + + beforeEach(() => { + prevHome = process.env.TRUSTSHELL_HOME; + home = mkdtempSync(join(tmpdir(), 'tl-demo-home-')); + process.env.TRUSTSHELL_HOME = home; + }); + afterEach(() => { + if (prevHome === undefined) delete process.env.TRUSTSHELL_HOME; + else process.env.TRUSTSHELL_HOME = prevHome; + rmSync(home, { recursive: true, force: true }); + }); + + it('parses with no operand', () => { + expect(parseArgs(['demo'])).toMatchObject({ command: 'demo' }); + }); + + it('seals a sample receipt and verifies it here under its own key (exit 0)', async () => { + const s = captureIO(); + expect(await run(parseArgs(['demo']), NO_CLIENT, s.io)).toBe(EXIT.OK); + const out = s.out.join('\n'); + expect(out).toMatch(/Integrity\s+VERIFIED/i); + expect(out).toContain('owner-self-signed'); + // the five engine claims stay NOT_CHECKED — a self-signature is not independent verification + expect(out).toMatch(/Authorization\s+NOT_CHECKED/); + // the reproduce line carries a real 0x pin + expect(out).toMatch(/verify-receipt r\.json --root 0x[0-9a-f]{64}/); + // the self-attestation caveat goes to stderr so stdout stays the human report + expect(s.err.join('\n')).toMatch(/earns no production RepID/i); + }); + + it('--json emits the sealed receipt + the offline verdict a stranger reproduces', async () => { + const s = captureIO(); + expect(await run(parseArgs(['demo', '--json']), NO_CLIENT, s.io)).toBe(EXIT.OK); + const d = JSON.parse(s.out.join('\n')) as { + receipt: { header: { schema: string; provenance: string } }; + verify: { claims: { integrity: { status: string } }; sealer: { trusted: boolean } }; + root: string; + }; + expect(d.receipt.header.schema).toBe('kernel-receipt/2'); + expect(d.receipt.header.provenance).toBe('owner-self-signed'); + expect(d.verify.claims.integrity.status).toBe('VERIFIED'); + expect(d.verify.sealer.trusted).toBe(true); + expect(d.root).toMatch(/^0x[0-9a-f]{64}$/); + }); +}); diff --git a/tests/cli-sign-receipt.test.ts b/tests/cli-sign-receipt.test.ts new file mode 100644 index 00000000..029e67b5 --- /dev/null +++ b/tests/cli-sign-receipt.test.ts @@ -0,0 +1,103 @@ +/** + * `trustshell sign-receipt `: seal a kernel-receipt body with this install's OWN key, OFFLINE, + * then hand the output to `trustshell verify-receipt` — the end-to-end "receipts, not promises" loop a + * skeptic runs. No network. TRUSTSHELL_HOME is an ephemeral temp dir so the real install key is untouched. + */ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { parseArgs, run, EXIT, type CliIO } from '../src/cli'; +import type { TrustShell } from '../src/lib/trustshell'; + +const NO_CLIENT = {} as TrustShell; // sign/verify-receipt never touch the HAL client + +function captureIO(): { io: CliIO; out: string[]; err: string[] } { + const out: string[] = []; + const err: string[] = []; + return { io: { out: (s) => out.push(s), err: (s) => err.push(s) }, out, err }; +} + +function body(): unknown { + return { + header: { receipt_id: 'cli-sign-0001', prev_receipt_id: null, prev_hash: null, created_at: '2026-11-07T00:00:00Z', env: { network: 'base-sepolia', chain_id: 84532, is_simulated: true } }, + subject: { agent: { agent_id: 'agent-1', agent_name: 'demo' }, owner: { wallet: null, assurance: null, status: 'none' } }, + request: { tool: { name: 'echo', class: 'read' }, origin: 'Cli', args_hash: `0x${'a'.repeat(64)}`, amount_raw: null, payee: null, asset: null, chain_id: null }, + decision: { verdict: 'ALLOW', blocking: false, executed: false, reasons: [], checks: [{ name: 'local', outcome: 'PASS', source: 'owner' }], does_not_attest: [], policy: { policy_hash: null, signer: null, signature: 'NOT_CHECKED', expires_at: null, revocation: 'NOT_CHECKED' }, limits: {}, laya: { lane: null, mode: 'shadow', consulted: false } }, + payment: { status: 'none' }, + verification: { status: 'none' }, + }; +} + +describe('trustshell sign-receipt → verify-receipt (DECISION 1 E2E)', () => { + let home = ''; + let prevHome: string | undefined; + let dir = ''; + + beforeEach(() => { + prevHome = process.env.TRUSTSHELL_HOME; + home = mkdtempSync(join(tmpdir(), 'tl-sign-home-')); + process.env.TRUSTSHELL_HOME = home; + dir = mkdtempSync(join(tmpdir(), 'tl-sign-io-')); + }); + afterEach(() => { + if (prevHome === undefined) delete process.env.TRUSTSHELL_HOME; + else process.env.TRUSTSHELL_HOME = prevHome; + rmSync(home, { recursive: true, force: true }); + rmSync(dir, { recursive: true, force: true }); + }); + + it('needs a file operand', () => { + expect(parseArgs(['sign-receipt']).error).toMatch(/requires /); + expect(parseArgs(['sign-receipt', 'body.json'])).toMatchObject({ command: 'sign-receipt', operand: 'body.json' }); + }); + + it('seals a body as owner-self-signed, and the printed receipt verifies under the printed root (exit 0)', async () => { + const bodyPath = join(dir, 'body.json'); + writeFileSync(bodyPath, JSON.stringify(body())); + + const s = captureIO(); + expect(await run(parseArgs(['sign-receipt', bodyPath]), NO_CLIENT, s.io)).toBe(EXIT.OK); + + // stdout is the sealed receipt; stderr carries the pin line and the self-attestation caveat. + const receipt = JSON.parse(s.out.join('\n')); + expect(receipt.header.schema).toBe('kernel-receipt/2'); + expect(receipt.header.provenance).toBe('owner-self-signed'); + const errText = s.err.join('\n'); + expect(errText).toMatch(/earns no production RepID/i); + const root = errText.match(/--root (0x[0-9a-f]{64})/)?.[1]; + expect(root).toBeDefined(); + + // the stranger pins exactly what sign-receipt told them to, and the seal verifies + const receiptPath = join(dir, 'r.json'); + writeFileSync(receiptPath, JSON.stringify(receipt)); + const v = captureIO(); + expect(await run(parseArgs(['verify-receipt', receiptPath, '--root', root!]), NO_CLIENT, v.io)).toBe(EXIT.OK); + expect(v.out.join('\n')).toMatch(/integrity\s+VERIFIED/i); + + // without pinning the owner key it is NOT a pass — whole, but unsealed (NOT_CHECKED, never 0) + const u = captureIO(); + expect(await run(parseArgs(['verify-receipt', receiptPath]), NO_CLIENT, u.io)).toBe(EXIT.NOT_CHECKED); + }); + + it('refuses a body that is not an object with a header (exit 2)', async () => { + const p = join(dir, 'bad.json'); + writeFileSync(p, JSON.stringify({ not: 'a receipt' })); + const s = captureIO(); + expect(await run(parseArgs(['sign-receipt', p]), NO_CLIENT, s.io)).toBe(EXIT.USAGE); + expect(s.err.join('\n')).toMatch(/must be a JSON object with a "header"/); + }); + + it('tampering with the sealed receipt breaks verification (exit 1)', async () => { + const bodyPath = join(dir, 'body.json'); + writeFileSync(bodyPath, JSON.stringify(body())); + const s = captureIO(); + await run(parseArgs(['sign-receipt', bodyPath]), NO_CLIENT, s.io); + const receipt = JSON.parse(s.out.join('\n')); + const root = s.err.join('\n').match(/--root (0x[0-9a-f]{64})/)![1]; + receipt.decision.verdict = 'DENY'; // flip after sealing + const p = join(dir, 'tampered.json'); + writeFileSync(p, JSON.stringify(receipt)); + const v = captureIO(); + expect(await run(parseArgs(['verify-receipt', p, '--root', root]), NO_CLIENT, v.io)).toBe(EXIT.VETO); + }); +}); diff --git a/tests/e2e-stranger.test.ts b/tests/e2e-stranger.test.ts index 45d76501..4d5984ae 100644 --- a/tests/e2e-stranger.test.ts +++ b/tests/e2e-stranger.test.ts @@ -2,7 +2,7 @@ * Stranger path: the four commands shipped in @hyperdag/trustshell@1.4.0, * plus every command in this repo's CLI. Engine routes are mocked. */ -import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'; +import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { homedir, tmpdir } from 'node:os'; import { join } from 'node:path'; import { parseArgs, run, type CliIO } from '../src/cli'; @@ -209,6 +209,46 @@ describe('stranger e2e on current main', () => { expect(verifyReceipt.code).toBe(0); expect(verifyReceipt.out).toMatch(/Integrity\s+VERIFIED/); + // sign-receipt: seal a minimal body with THIS install's OWN key (ephemeral home so the real install + // key is untouched), then the same verify-receipt re-derives its integrity under the printed root — + // the offline emit→verify loop a skeptic runs, exercised end to end on main. No network. + const signDir = mkdtempSync(join(tmpdir(), 'ts-stranger-sign-')); + const bodyPath = join(signDir, 'body.json'); + writeFileSync( + bodyPath, + JSON.stringify({ + header: { receipt_id: 'stranger-sign-1', prev_receipt_id: null, prev_hash: null, created_at: '2026-11-07T00:00:00Z', env: { network: 'base-sepolia', chain_id: 84532, is_simulated: true } }, + subject: { agent: { agent_id: 'agent-1', agent_name: 'demo' }, owner: { wallet: null, assurance: null, status: 'none' } }, + request: { tool: { name: 'echo', class: 'read' }, origin: 'Cli', args_hash: `0x${'a'.repeat(64)}`, amount_raw: null, payee: null, asset: null, chain_id: null }, + decision: { verdict: 'ALLOW', blocking: false, executed: false, reasons: [], checks: [{ name: 'local', outcome: 'PASS', source: 'owner' }], does_not_attest: [], policy: { policy_hash: null, signer: null, signature: 'NOT_CHECKED', expires_at: null, revocation: 'NOT_CHECKED' }, limits: {}, laya: { lane: null, mode: 'shadow', consulted: false } }, + payment: { status: 'none' }, + verification: { status: 'none' }, + }), + ); + const prevSignHome = process.env.TRUSTSHELL_HOME; + process.env.TRUSTSHELL_HOME = mkdtempSync(join(tmpdir(), 'ts-stranger-home-')); + try { + const signed = await go(['sign-receipt', bodyPath]); + expect(signed.code).toBe(0); + const sealed = JSON.parse(signed.out) as { header: { provenance: string } }; + expect(sealed.header.provenance).toBe('owner-self-signed'); + const root = signed.err.match(/--root (0x[0-9a-f]{64})/)![1]; + const sealedPath = join(signDir, 'sealed.json'); + writeFileSync(sealedPath, JSON.stringify(sealed)); + const reverify = await go(['verify-receipt', sealedPath, '--root', root]); + expect(reverify.code).toBe(0); + expect(reverify.out).toMatch(/Integrity\s+VERIFIED/); + + // demo: the whole loop in one command, offline, under the same throwaway home. + const demo = await go(['demo']); + expect(demo.code).toBe(0); + expect(demo.out).toMatch(/Integrity\s+VERIFIED/); + expect(demo.out).toMatch(/verify-receipt r\.json --root 0x[0-9a-f]{64}/); + } finally { + if (prevSignHome === undefined) delete process.env.TRUSTSHELL_HOME; + else process.env.TRUSTSHELL_HOME = prevSignHome; + } + const dir = mkdtempSync(join(tmpdir(), 'ts-stranger-')); const missing = join(dir, 'missing.jsonl'); const inspect = await go(['inspect', missing]); diff --git a/tests/owner-receipt.test.ts b/tests/owner-receipt.test.ts new file mode 100644 index 00000000..4cb8f32e --- /dev/null +++ b/tests/owner-receipt.test.ts @@ -0,0 +1,311 @@ +/** + * The owner-self-signed receipt seam (DECISION 1): an owner seals a kernel-receipt/2 with their own + * install key, offline, and a STRANGER reproduces its integrity with the SAME verifier the engine's + * receipts use. These tests are the contract — they prove the guards bite, not just that the happy + * path runs: a tampered byte, a foreign key, a missing domain tag, a transplanted signature, a missing + * trust root, a rotated key, a smuggled self-attestation, and an expired policy each fail CLOSED, and + * an owner self-signature can never read as independently-verified or RepID-bearing. Cross-verifier tests + * prove key isolation (a receipt is trusted only under the verifier that pins its own sealing key, in both + * directions), and a verbatim-replay test states plainly that offline verification does NOT reject replay — + * that is the engine's receipt_id/nullifier, not a claim a stranger's stateless check can make. + */ +import { generateKeyPairSync, sign, type KeyObject } from 'node:crypto'; +import { + KERNEL_RECEIPT_SIGNATURE_DOMAIN, + SIX_CLAIMS_PREDICATE_TYPE, + SIX_CLAIM_KEYS, + kernelCanonicalJson, + kernelReceiptHash, + type SixClaimKey, + type SixClaimStatus, +} from '../src/lib/kernel-client'; +import { verifyReceiptOffline } from '../src/lib/verify-receipt'; +import { + OWNER_SELF_SIGNED, + evidenceLevel, + installKeyRootHex, + installKeySigner, + ownerReceiptProvenance, + signOwnerReceipt, + type SealableReceipt, +} from '../src/lib/owner-receipt'; + +const ownerKey = (): KeyObject => generateKeyPairSync('ed25519').privateKey; +const ENGINE_CLAIMS: SixClaimKey[] = ['authenticity', 'observation', 'correctness', 'authorization', 'containment']; + +/** A fresh, minimal-but-complete kernel-receipt body to seal. New object each call (tests mutate clones). */ +function base(): SealableReceipt { + return { + header: { receipt_id: 'owner-test-0001', prev_receipt_id: null, prev_hash: null, created_at: '2026-11-07T00:00:00Z', env: { network: 'base-sepolia', chain_id: 84532, is_simulated: true } }, + subject: { agent: { agent_id: 'agent-1', agent_name: 'demo' }, owner: { wallet: null, assurance: null, status: 'none' } }, + request: { tool: { name: 'echo', class: 'read' }, origin: 'Cli', args_hash: `0x${'a'.repeat(64)}`, amount_raw: null, payee: null, asset: null, chain_id: null }, + decision: { + verdict: 'ALLOW', blocking: false, executed: false, reasons: [], checks: [{ name: 'local', outcome: 'PASS', source: 'owner' }], does_not_attest: [], + policy: { policy_hash: null, signer: null, signature: 'NOT_CHECKED', expires_at: null, revocation: 'NOT_CHECKED' }, + limits: { per_tx_raw: '0', daily_raw: '0', total_raw: '0', effective_per_tx_raw: '0', binding_term: 'none' }, + laya: { lane: null, mode: 'shadow', consulted: false }, + }, + payment: { status: 'none' }, + verification: { status: 'none' }, + }; +} + +const sixClaims = (status: SixClaimStatus): Record => { + const out = {} as Record; + for (const k of SIX_CLAIM_KEYS) out[k] = { status, statement: `claim ${k}`, supports: [], gaps: [] }; + return out; +}; + +/** + * An ENGINE-style receipt: sealed over the domain-prefixed canonical bytes by `key`, with NO owner provenance + * stamp (so `ownerReceiptProvenance` is null), plus the matching authenticated six-claim envelope. This is the + * only shape that can reach ISSUER_ATTESTED — used by the cross-verifier tests to prove key isolation. + */ +async function signEngineReceipt(key: KeyObject, receiptId = 'engine-test-0001') { + const b = base(); + (b.header as Record).receipt_id = receiptId; + const header = { ...b.header, schema: 'kernel-receipt/2', version: 2, signer: installKeySigner(key) }; // NO provenance stamp + const unsigned = { ...b, header }; + const signature = `0x${sign(null, Buffer.from(KERNEL_RECEIPT_SIGNATURE_DOMAIN + kernelCanonicalJson(unsigned), 'utf8'), key).toString('hex')}`; + const signed = { ...b, header: { ...header, signature } }; + const receipt_hash = await kernelReceiptHash(signed as unknown as Parameters[0]); + const r = { ...b, header: { ...header, signature, receipt_hash } }; + const envelope = { + _type: 'https://in-toto.io/Statement/v1', + predicateType: SIX_CLAIMS_PREDICATE_TYPE, + subject: [{ name: `kernel-receipt:${receiptId}`, digest: { keccak256: receipt_hash.replace(/^0x/, '') } }], + predicate: { claims: sixClaims('VERIFIED'), what_this_does_not_establish: [] }, + }; + return { r, envelope }; +} + +describe('owner-self-signed receipt seam (DECISION 1)', () => { + it('emits an owner-stamped kernel-receipt/2 a stranger verifies under the owner-pinned key; the 5 engine claims stay NOT_CHECKED', async () => { + const key = ownerKey(); + const r = await signOwnerReceipt(base(), key); + + expect(r.header.schema).toBe('kernel-receipt/2'); + expect((r.header as unknown as { provenance: string }).provenance).toBe(OWNER_SELF_SIGNED); + expect(ownerReceiptProvenance(r)).toBe(OWNER_SELF_SIGNED); + expect(r.header.signature).toMatch(/^0x[0-9a-f]{128}$/); + expect(r.header.receipt_hash).toMatch(/^0x[0-9a-f]{64}$/); + + const v = await verifyReceiptOffline(r, { trustedRoots: [installKeyRootHex(key)] }); + expect(v.claims.integrity.status).toBe('VERIFIED'); + expect(v.sealer.trusted).toBe(true); + for (const k of ENGINE_CLAIMS) expect(v.claims[k].status).toBe('NOT_CHECKED'); + + expect(evidenceLevel(v, r)).toMatchObject({ level: 'OWNER_SELF_SIGNED', repid_eligible: 'no' }); + }); + + it('absent trust root: the bytes are whole but UNSEALED — never silently owner-signed', async () => { + const key = ownerKey(); + const r = await signOwnerReceipt(base(), key); + const v = await verifyReceiptOffline(r, { trustedRoots: [] }); // no pin + expect(v.claims.integrity.status).toBe('VERIFIED'); // hash recomputed and matched + expect(v.sealer.trusted).toBe(false); // but no key was confirmed to have sealed it + expect(evidenceLevel(v, r).level).toBe('WHOLE_BUT_UNSEALED'); + }); + + it('a foreign pinned key: integrity FAILS (untrusted signer), evidence NOT_ESTABLISHED', async () => { + const key = ownerKey(); + const r = await signOwnerReceipt(base(), key); + const v = await verifyReceiptOffline(r, { trustedRoots: [installKeyRootHex(ownerKey())] }); // someone else's key + expect(v.claims.integrity.status).toBe('FAILED'); + expect(v.sealer.trusted).toBe(false); + expect(evidenceLevel(v, r).level).toBe('NOT_ESTABLISHED'); + }); + + it('tampering after sealing: a flipped decision breaks the hash, integrity FAILS', async () => { + const key = ownerKey(); + const r = await signOwnerReceipt(base(), key); + const tampered = JSON.parse(JSON.stringify(r)); + tampered.decision.verdict = 'DENY'; + const v = await verifyReceiptOffline(tampered, { trustedRoots: [installKeyRootHex(key)] }); + expect(v.claims.integrity.status).toBe('FAILED'); + }); + + it('domain separation: a signature made WITHOUT the domain tag does not verify (the tag is load-bearing)', async () => { + const key = ownerKey(); + const b = base(); + const header = { ...b.header, schema: 'kernel-receipt/2', version: 2, provenance: OWNER_SELF_SIGNED, signer: installKeySigner(key) }; + const unsigned = { ...b, header }; + const badSig = `0x${sign(null, Buffer.from(kernelCanonicalJson(unsigned), 'utf8'), key).toString('hex')}`; // NO domain prefix + const signed = { ...b, header: { ...header, signature: badSig } }; + const receipt_hash = await kernelReceiptHash(signed as unknown as Parameters[0]); + const forged = { ...b, header: { ...header, signature: badSig, receipt_hash } }; + + const v = await verifyReceiptOffline(forged, { trustedRoots: [installKeyRootHex(key)] }); + // hash matches the forged bytes, but the signature is over the un-prefixed message, so it fails under the domain. + expect(v.claims.integrity.status).toBe('FAILED'); + expect(v.sealer.trusted).toBe(false); + // sanity: with the domain tag it WOULD have verified + expect((await verifyReceiptOffline(await signOwnerReceipt(base(), key), { trustedRoots: [installKeyRootHex(key)] })).sealer.trusted).toBe(true); + }); + + it('canonical serialization: re-ordering keys does not change the hash or the verdict', async () => { + const key = ownerKey(); + const r = await signOwnerReceipt(base(), key); + // rebuild the same receipt with top-level AND header keys inserted in reverse order + const reverse = (o: Record) => Object.fromEntries(Object.entries(o).reverse()); + const shuffled = { ...reverse(r as unknown as Record), header: reverse(r.header as unknown as Record) }; + const v = await verifyReceiptOffline(shuffled, { trustedRoots: [installKeyRootHex(key)] }); + expect(v.claims.integrity.status).toBe('VERIFIED'); + expect(v.receipt_hash).toBe(r.header.receipt_hash); + }); + + // NOTE: signature TRANSPLANT and verbatim REPLAY are different attacks; this test is the former only. + it('signature transplant: one receipt’s signature cannot be lifted onto a different body (offline integrity FAILS)', async () => { + const key = ownerKey(); + const r = await signOwnerReceipt(base(), key); + const other = base(); + (other.header as Record).receipt_id = 'owner-test-0002'; + const transplant = { ...other, header: { ...other.header, schema: 'kernel-receipt/2', version: 2, provenance: OWNER_SELF_SIGNED, signer: r.header.signer, signature: r.header.signature } }; + const receipt_hash = await kernelReceiptHash(transplant as unknown as Parameters[0]); + const forged = { ...transplant, header: { ...transplant.header, receipt_hash } }; + const v = await verifyReceiptOffline(forged, { trustedRoots: [installKeyRootHex(key)] }); + expect(v.claims.integrity.status).toBe('FAILED'); // r's signature does not cover other's bytes + }); + + it('verbatim replay is NOT something offline verify rejects: the identical receipt verifies again — freshness/one-time use is the engine’s receipt_id/nullifier, not stateless offline verification', async () => { + const key = ownerKey(); + const r = await signOwnerReceipt(base(), key); + // A replay is the UNCHANGED receipt, re-submitted. Offline verification is stateless: it proves the bytes are + // whole and sealed by a pinned key — never that they are fresh or used once. So both pass, by design. Rejecting + // a re-submitted receipt is one-time settlement in the engine (receipt_id / nullifier), which a stranger's + // offline check cannot see and must not claim. Overstating this would be exactly the false "replay-proof" claim. + const roots = { trustedRoots: [installKeyRootHex(key)] }; + const v1 = await verifyReceiptOffline(r, roots); + const v2 = await verifyReceiptOffline(JSON.parse(JSON.stringify(r)) as typeof r, roots); // same bytes, submitted again + expect(v1.claims.integrity.status).toBe('VERIFIED'); + expect(v2.claims.integrity.status).toBe('VERIFIED'); // identical bytes still verify — this is correct, not a hole + expect(v2.sealer.trusted).toBe(true); + expect(v2.receipt_hash).toBe(v1.receipt_hash); // nothing in the offline verdict distinguishes a replay + }); + + it('key rotation: an old receipt verifies under the old pin, is rejected under the new, and still holds when both are pinned', async () => { + const oldKey = ownerKey(); + const newKey = ownerKey(); + const rOld = await signOwnerReceipt(base(), oldKey); + expect((await verifyReceiptOffline(rOld, { trustedRoots: [installKeyRootHex(newKey)] })).claims.integrity.status).toBe('FAILED'); + expect((await verifyReceiptOffline(rOld, { trustedRoots: [installKeyRootHex(oldKey)] })).claims.integrity.status).toBe('VERIFIED'); + const both = await verifyReceiptOffline(rOld, { trustedRoots: [installKeyRootHex(newKey), installKeyRootHex(oldKey)] }); + expect(both.sealer.trusted).toBe(true); + }); + + it('mismatched execution evidence: a smuggled six-claim envelope cannot make an owner receipt independently-verified', async () => { + const key = ownerKey(); + const r = await signOwnerReceipt(base(), key); + const envelope = { + _type: 'https://in-toto.io/Statement/v1', + predicateType: SIX_CLAIMS_PREDICATE_TYPE, + subject: [{ name: `kernel-receipt:${r.header.receipt_id}`, digest: { keccak256: r.header.receipt_hash.replace(/^0x/, '') } }], + predicate: { claims: sixClaims('VERIFIED'), what_this_does_not_establish: [] }, + }; + + // (a) read from a file (envelope NOT declared authenticated): claims are read, not verified → NOT_CHECKED + const vFile = await verifyReceiptOffline(r, { trustedRoots: [installKeyRootHex(key)], engineEnvelope: envelope }); + expect(vFile.claims.observation.status).toBe('NOT_CHECKED'); + expect(evidenceLevel(vFile, r)).toMatchObject({ level: 'OWNER_SELF_SIGNED', repid_eligible: 'no' }); + + // (b) even if a caller wrongly declares the owner's own envelope authenticated, evidenceLevel still + // caps it at OWNER_SELF_SIGNED on the provenance stamp — self-attestation never earns RepID. + const vAuthed = await verifyReceiptOffline(r, { trustedRoots: [installKeyRootHex(key)], engineEnvelope: envelope, envelopeAuthenticated: true }); + expect(evidenceLevel(vAuthed, r)).toMatchObject({ level: 'OWNER_SELF_SIGNED', repid_eligible: 'no' }); + }); + + it('a pinned NON-owner issuer + a verified engine claim reads as ISSUER_ATTESTED — attestation under a named issuer, NOT a finding of independence', async () => { + // The ONLY branch that is not owner-self-signed: an engine-style receipt (no owner stamp), sealed by + // an issuer key the verifier chose to pin, with an AUTHENTICATED six-claim envelope. The SDK cannot + // know whether that issuer is independent of the owner's beneficial control — so the level must say + // "attested under a named issuer", never "independently verified". + const issuer = ownerKey(); // a stand-in issuer key; offline, the SDK cannot know who controls it + const b = base(); + const header = { ...b.header, schema: 'kernel-receipt/2', version: 2, signer: installKeySigner(issuer) }; // NO provenance stamp + const unsigned = { ...b, header }; + const signature = `0x${sign(null, Buffer.from(KERNEL_RECEIPT_SIGNATURE_DOMAIN + kernelCanonicalJson(unsigned), 'utf8'), issuer).toString('hex')}`; + const signed = { ...b, header: { ...header, signature } }; + const receipt_hash = await kernelReceiptHash(signed as unknown as Parameters[0]); + const r = { ...b, header: { ...header, signature, receipt_hash } }; + const receiptId = String((b.header as Record).receipt_id); + + const envelope = { + _type: 'https://in-toto.io/Statement/v1', + predicateType: SIX_CLAIMS_PREDICATE_TYPE, + subject: [{ name: `kernel-receipt:${receiptId}`, digest: { keccak256: receipt_hash.replace(/^0x/, '') } }], + predicate: { claims: sixClaims('VERIFIED'), what_this_does_not_establish: [] }, + }; + const v = await verifyReceiptOffline(r, { trustedRoots: [installKeyRootHex(issuer)], engineEnvelope: envelope, envelopeAuthenticated: true }); + + expect(v.claims.integrity.status).toBe('VERIFIED'); + expect(v.sealer.trusted).toBe(true); + expect(ownerReceiptProvenance(r)).toBeNull(); // not an owner self-signature + expect(v.claims.observation.status).toBe('VERIFIED'); // an engine attestation verified under the pin + + const ev = evidenceLevel(v, r); + expect(ev.level).toBe('ISSUER_ATTESTED'); + expect(ev.repid_eligible).toBe('engine-decides'); // the SDK never says 'yes' + // the honest caveat must be stated: a pinned key is not a controller-independence finding + expect(ev.why).toMatch(/not establish|independen/i); + }); + + it('cross-verifier: a receipt sealed by one key is trusted ONLY by a verifier pinning that key; the other key fails it closed, and the level never promotes', async () => { + const ownerK = ownerKey(); + const engineK = ownerKey(); // a distinct issuer key + const rOwner = await signOwnerReceipt(base(), ownerK); + const { r: rEngine, envelope } = await signEngineReceipt(engineK); + + // (positive control) each verifies — and is trusted — ONLY under its own key. + expect((await verifyReceiptOffline(rOwner, { trustedRoots: [installKeyRootHex(ownerK)] })).sealer.trusted).toBe(true); + const engineOwnKey = await verifyReceiptOffline(rEngine, { trustedRoots: [installKeyRootHex(engineK)], engineEnvelope: envelope, envelopeAuthenticated: true }); + expect(engineOwnKey.sealer.trusted).toBe(true); + expect(evidenceLevel(engineOwnKey, rEngine).level).toBe('ISSUER_ATTESTED'); + + // the OWNER receipt under an ENGINE-ONLY verifier: foreign key → integrity FAILS, untrusted, NOT_ESTABLISHED. + // It can never read as engine-attested just because a different pinned key is handed in. + const ownerUnderEngine = await verifyReceiptOffline(rOwner, { trustedRoots: [installKeyRootHex(engineK)], engineEnvelope: envelope, envelopeAuthenticated: true }); + expect(ownerUnderEngine.sealer.trusted).toBe(false); + expect(ownerUnderEngine.claims.integrity.status).toBe('FAILED'); + expect(evidenceLevel(ownerUnderEngine, rOwner).level).toBe('NOT_ESTABLISHED'); + + // the ENGINE receipt under an OWNER-ONLY verifier: symmetric — foreign key, fails closed, never promotes. + const engineUnderOwner = await verifyReceiptOffline(rEngine, { trustedRoots: [installKeyRootHex(ownerK)], engineEnvelope: envelope, envelopeAuthenticated: true }); + expect(engineUnderOwner.sealer.trusted).toBe(false); + expect(engineUnderOwner.claims.integrity.status).toBe('FAILED'); + expect(evidenceLevel(engineUnderOwner, rEngine).level).toBe('NOT_ESTABLISHED'); + }); + + it('cross-verifier: an engine-style receipt whose sealing key is NOT pinned cannot reach ISSUER_ATTESTED even with an authenticated envelope — an unsealed body stays WHOLE_BUT_UNSEALED', async () => { + // No trusted root pinned at all: the bytes recompute (integrity VERIFIED) but no key is confirmed to have + // sealed them, so the five engine claims are gated off (verify-receipt.ts: sealerTrusted && envelopeAuthenticated), + // and an authenticated envelope cannot lift an unsealed receipt to issuer-attested. + const engineK = ownerKey(); + const { r, envelope } = await signEngineReceipt(engineK, 'engine-test-0002'); + const v = await verifyReceiptOffline(r, { trustedRoots: [], engineEnvelope: envelope, envelopeAuthenticated: true }); + expect(v.claims.integrity.status).toBe('VERIFIED'); + expect(v.sealer.trusted).toBe(false); + expect(v.claims.observation.status).toBe('NOT_CHECKED'); // not VERIFIED: no trusted sealer + expect(evidenceLevel(v, r).level).toBe('WHOLE_BUT_UNSEALED'); + }); + + it('expired authorization: a policy expired at/before as_of fails the authorization claim', async () => { + const key = ownerKey(); + const b = base(); + (b.decision as { policy: Record }).policy.expires_at = 1_000_000_000; // 2001 + const r = await signOwnerReceipt(b, key); + const v = await verifyReceiptOffline(r, { trustedRoots: [installKeyRootHex(key)], asOf: 2_000_000_000 }); // 2033 + expect(v.claims.authorization.status).toBe('FAILED'); + }); + + it('installKeySigner encodes the key the engine way: 0x + 64 hex public key and sha256 fingerprint', () => { + const s = installKeySigner(ownerKey()); + expect(s.alg).toBe('ed25519'); + expect(s.public_key).toMatch(/^0x[0-9a-f]{64}$/); + expect(s.fingerprint).toMatch(/^0x[0-9a-f]{64}$/); + expect(installKeyRootHex).toBeDefined(); + }); + + it('the domain constant is the engine’s (so owner and engine receipts share one verifier)', () => { + expect(KERNEL_RECEIPT_SIGNATURE_DOMAIN).toBe('hyperdag.kernel-receipt.signature.v1\n'); + }); +});