diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..cb441f3 --- /dev/null +++ b/.npmignore @@ -0,0 +1,8 @@ +# Private key — NEVER publish to npm +sigil-key.json + +# Sigil generation script — development only +generate-sigil.mjs + +# Test files not needed by end users +test/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5fb75f7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,452 @@ +# Changelog + + +## 0.6.1 - 2026-05-16 + +- Support `--jwks` resolution from `file://` URLs, absolute paths, and bare relative filesystem paths for offline test-vector and CI workflows. +- Preserve HTTP(S) JWKS behavior unchanged. +- Surface the resolved local JWKS path in verifier output. +- Add unit coverage for local JWKS path handling. + +## 0.5.4 — 2026-04-20 (Rekor anchoring + hardware attestation + transparency profiles + watcher + SBOM bundles + AIP-0007) + +Ships the "differentiation roadmap" responding to the Signet / nono +feature-parity analysis. Thirteen new primitives across four strategic +tiers; none of them imitate Signet or nono — they extend the shipping +product to ground that only Veritas Acta holds. + +### New AIP + +- **AIP-0007** (Draft) — Zero-Knowledge Compliance Proofs. Portable + receipt-chain-level proof that every receipt adhered to a declared + policy without revealing receipts. Target release: v0.7.0; the spec + is committed now for public review. + +### New engines + +- **`src/engines/rekor.js`** — Transparency-log anchoring (AIP-0005 T4). + Offline verification of Rekor / Sigstore inclusion proofs. + RFC 6962 Merkle-path recomputation + Signed-Note signature + verification. ISO 8601 duration parsing for `anchored_within`. +- **`src/engines/attestation-quote.js`** — Hardware-attestation quote + validator (AIP-0005 T2). Dispatches to per-platform validators: + ATECC608B (full crypto validator), Apple Secure Enclave (full), + TPM2 / SGX / SEV-SNP / TDX (structural in v0.5.4; full crypto in v0.7). + Enforces that `measured_kid` matches `signature.kid`. +- **`src/engines/watch.js`** — Live receipt watcher + webhook + dispatcher. Rule kinds: `cost_tier_below`, `delegation_expiring_within`, + `chain_break`, `deny_decision`, `scrub_triggered`. Slack / Discord / + generic JSON payloads. +- **`src/engines/sbom.js`** — SBOM-audit bundle builder. Ingests SPDX / + CycloneDX / unknown-format SBOMs; builds a deterministic + `receipts_fingerprint` + canonical manifest; optional signing via + caller-supplied callback. +- **`src/engines/transparency.js`** — Four profiles (private, auditable, + transparent, high-assurance), profile-based anchor decisions, and a + public-facing badge JSON format. + +### New packages + +- **`@veritasacta/cross-verify`** — Arbitrator tool for multi-format + sessions. Extracts canonical `(tool, input_hash, issued_at)` tuples + from Signet / Sigstore / Acta receipts and confirms agreement. + Emits a SHA-256 agreement fingerprint. 19 unit tests. + +### Ecosystem artifacts + +- **`docs/voprf-issuance-for-implementers.md`** — Public pitch for + competitors to route their T1 cost-tier through our commercial API. +- **`docs/framework-author-guide.md`** — Adoption onboarding for + CrewAI / LangChain / Vercel AI / etc. +- **`docs/posts/infrastructure-not-competitor.md`** — Public letter to + peer receipt-format projects articulating the infrastructure stance. +- **`docs/case-study-three-ecosystems.md`** — Microsoft + AWS + + Anthropic contribution ledger. +- **`ecosystem/certify/`** — Weekly cross-implementation conformance + certification program (workflow + runner skeleton). +- **`ecosystem/dashboard/`** — Upgraded with a DAG view for trace_id + grouping. + +### Tests + +- 54 new unit tests: 12 Rekor, 11 attestation-quote, 12 watch, 7 sbom, + 14 transparency, 19 cross-verify (cross-verify is a separate package). +- verify-cli suite: **219 unit+integration + 26 conformance = 245 total**, + all green. + +### Sigil + +- Canonical release: **Open Wind** (`677a8a81`). +- 36 source files monitored (up from 31 in v0.5.3). + +## 0.5.3 — 2026-04-20 (delegation chains + bilateral cosign + trace_id + proxy hardening + dashboard) + +Responds to the Signet / nono comparison: adds the four receipt-shape +primitives that peer implementations have ("who authorized this +agent", "agent + server co-signed", "these receipts all belong to one +workflow", "receipts are visualisable") and the two proxy-layer +security primitives ("scrub secrets from captured args", "cosign the +same decision from two independent keys"). None of this requires new +cryptography. All composes with existing AIPs. + +### New AIP + +- **AIP-0006** (Draft) — Delegation Chains. Defines a `delegation` + receipt type conveying scoped, time-bounded, narrowing-only + authority from a delegator to a delegate. Defines an + `authorization.delegation_chain` payload field on action receipts + referencing delegations by receipt_hash. Verifier walks to a trust + anchor, checks signatures, expiry, scope subset, and max_depth at + each hop. + +### New engines + +- **`src/engines/delegation.js`** — `verifyDelegationChain()` walks the + chain from leaf to root, validating Ed25519 signatures against trust + anchors, scope subset at each hop (narrowing-only), and the action's + tool/target against the leaf's scope. 14 unit tests. +- **`src/engines/cosign.js`** — `verifyCosignatures()` + `attachCosignature()`. + Envelope-level additive signatures. Each cosignature signs the SAME + canonical payload bytes as the primary `signature`. Default semantic: + all cosignatures must be resolved + valid; `requireAllValid: false` + opt-in for M-of-N. 11 unit tests. +- **`src/engines/dashboard.js`** — `startDashboard()` spins up a + loopback-only HTTP server serving `ecosystem/dashboard/` static + a + `/api/receipts` JSON feed for a configured directory. DNS-rebinding + defense (rejects non-loopback Host headers) and path-traversal + defense. 6 smoke tests. + +### New subcommand + CLI surface + +- **`verify dashboard [--port 3847] [--bind 127.0.0.1] [--receipts-dir ]`** — + Start the local dashboard. Opens immediately; no build step. +- **`verify proxy ... --bilateral --server-key `** — Proxy now + attaches a second independent signature (via `cosignatures[]`) to + every receipt. Enables agent + server bilateral evidence without + touching the primary signing path. +- **`verify proxy ... --scrub-secrets`** — Walks incoming tool args for + probable-secret key names (`api_key`, `token`, `password`, + `authorization`, etc.), redacts VALUES in the outgoing call, and + flags the redacted paths on the receipt via `scrub_detected`. Secrets + no longer enter the receipt even as a hash of the real value. +- **`verify proxy ... --trace-id `** — Stamps every receipt with a + workflow `trace_id` so multi-step flows group cleanly. + +### Receipt-format extensions (non-breaking) + +- **`trace_id`** + **`parent_receipt_id`** as optional AIP-0001 payload + fields. `previousReceiptHash` remains the chain pointer; trace_id + groups by workflow; parent_receipt_id expresses non-chain causal + links. `chain explore` surfaces both; `groupByTrace(result)` buckets + nodes by workflow. +- **`cosignatures: [{alg, kid, sig}, ...]`** as an optional + envelope-level array. Old verifiers ignore it; v0.5.3+ verifiers + check each one against caller-supplied trust anchors. +- **`authorization.delegation_chain: [...]`** as an optional + AIP-0001 payload field. AIP-0006 verifiers walk it; others treat as + opaque. + +### Tests + +- 41 new unit tests across this release: 14 delegation, 11 cosign, + 2 chain-explore trace, 8 proxy helpers, 6 dashboard. +- Full suite: **163 unit+integration + 26 conformance = 189 total**, + all green. + +### Sigil + +- 31 source files monitored (up from 28 in v0.5.2). Canonical release: + **Bright Lake** (`ea78b16e`). + +## 0.5.2 — 2026-04-20 (compliance export + DSSE + BRASS v2 scaffold + AIP-0004/0005) + +Ships alongside v0.5.1 as the "governance surface fill" release. Adds +the compliance export subcommand, Sigstore DSSE envelope engine, +BRASS v2 hardening scaffold, and reference implementations for two new +AIPs. + +### New subcommand + +- **`verify compliance --receipts-dir `** — bucket a directory of + receipts into SOC 2 / ISO 42001 / EU AI Act controls and emit an + auditor-ready JSON bundle or self-contained HTML report. Supports + `--framework soc2|iso42001|eu-ai-act|all`, `--start-date`, `--end-date`, + `--org`, `--output`. Zero-evidence controls are surfaced explicitly + so the auditor sees gaps rather than hidden silences. + +### New engines + +- **`src/engines/dsse.js`** — Dead Simple Signing Envelope (DSSE) wrap / + unwrap / verify. Produces and consumes Sigstore-compatible envelopes + with payload types `application/vnd.acta.receipt+json`, + `application/vnd.acta.knowledge-unit+json`, or the standard in-toto + statement type. Signatures bind to the DSSE pre-authentication + encoding (PAE), not the raw payload. +- **`src/util/voprf-crypto-v2.js`** — BRASS v2 scaffold: length-prefixed + hashing (`H_LP`), nullifier derivation bound to issuer public key Y + (`deriveNullifier_v2`), single-variable πC restatement + (`piCVerify_v2`). Not wired into the default path; accessible to + implementers and exercised by unit tests. + +### New AIPs + +- **AIP-0004** (Draft) — Content-Addressed Snapshot and Rollback + Receipts. Defines `snapshot` and `rollback` receipt types with a + Merkle root over file-content hashes. Reference implementation at + `ecosystem/rollback/snapshot.mjs`; schema at + `ecosystem/rollback/snapshot-receipt.schema.json`. +- **AIP-0005** (Draft) — Attestation Weight Profile. Defines a + portable `cost_tier` (T0–T4) over receipts, substantiated by VOPRF + tokens (T1), hardware quotes (T2), multi-party signatures (T3), or + transparency-log anchoring (T4). Reference implementation notes at + `ecosystem/physical-attestation/DESIGN.md` + attestation-quote + schema. + +### Ecosystem additions + +- **`ecosystem/wshobson-plugin/protect-mcp/`** — PR-ready Claude Code + plugin tree for `wshobson/agents` marketplace. Closes issue #471. + Ships agents (`policy-enforcer`, `receipt-verifier`), skill + (`protect-mcp-setup`), slash commands (`/verify-receipt`, + `/audit-chain`), and hooks.json. +- **`ecosystem/dashboard/index.html` + `dashboard.js`** — local-first + in-browser audit dashboard scaffold. JCS + chain-integrity check + over dropped receipts; renders `verify --json` output. No server, + no telemetry. +- **`ecosystem/physical-attestation/DESIGN.md`** — physical-digital + causal chain design for Seal hardware cost_tier T2 receipts. + +### Sigil + tests + +- Sigil commitment expanded to **28 source files** (adds compliance + export, DSSE engine, v2 crypto util). Canonical release: **New Ember** + (`b28f8d60`). +- 41 new unit tests across prompt, chain-explore, snapshot, compliance, + DSSE, and BRASS v2 (11 of 41 new in this release). +- Full suite: **122 unit+integration + 26 conformance** — 148 total, + all green. + +## 0.5.1 — 2026-04-20 (prompt provenance + chain explorer + 5 sandbox profiles) + +### New subcommands + +- **`verify prompt `** — verify the provenance of a prompt/skill/system-instruction file against a Veritas Acta receipt asserting its SHA-256, a Sigstore DSSE bundle with an in-toto subject, or an `--expected-hash`. Closes the supply-chain attack vector where an attacker modifies `CLAUDE.md`, `SKILLS.md`, `AGENTS.md`, or a system prompt between authoring and agent runtime. +- **`verify chain explore `** — walk the `previousReceiptHash` chain back to its root, validating every hash link. Emits a depth-annotated ASCII tree in terminal mode, structured JSON in `--json` mode. `--search-dir ` overrides the ancestor search directory; `--max-depth N` caps the walk. + +### Sigil commitment expansion + +- Sigil v0.5.1 now covers **25 source files** (up from 24 in v0.5.0): adds `src/engines/prompt.js` + `src/engines/chain-explore.js`. Canonical release: **Bright Star** (`1cc829ab`). + +### Ecosystem profiles + +- **`ecosystem/profiles/`** ships pre-built sandboxing profiles for five common agent runtimes: Claude Code, Cursor, Codex, Gemini CLI, OpenClaw. Each ships `profile.yaml` + `policy.cedar` + `nono-capabilities.yaml` + `README.md` with threat-model notes. Composes with `sb-runtime --ring N --policy ./policy.cedar` and `nono run --caps ./nono-capabilities.yaml` for defense-in-depth. + +### Tests + +- 20 new unit tests: 10 for `verifyPrompt` (expected-hash / receipt / Sigstore / missing-source / error paths), 10 for `exploreChain` / `renderChainTree` (3-receipt chain, tamper detection, missing ancestor, maxDepth, searchDir override). +- Full suite now: **81 unit+integration + 26 conformance** — 107 total. + +## 0.5.0 — 2026-04-19 (unified verifier + network-effect mechanics) + +### Network-effect mechanics + +- **`--attest`** produces a canonical verifier attestation: a signed + JSON artifact the user can publish anywhere to demonstrate they ran + the canonical unmodified verifier. Fully offline, user-signed, opt-in. + `--attest-org ` attaches an attributable identifier. + `--attest-key ` overrides the default key location + (`~/.veritasacta-verify/attester.json`). +- **`--emit-verification-receipt`** produces a signed receipt of a + specific verification event — "this receipt verified valid by the + canonical verifier at time T." Composable with Sigstore Rekor. + +### Enterprise features + +- **`--pin-sigil `** enforces that the installed verifier + matches a specific Sigil. Fails fast with exit code 2 and a clear + message on mismatch. Supply-chain pinning for regulated deployments. +- **`--audit-log `** appends every verification event to a local + JSONL file with chain-linked hashes. Tamper-evident local audit trail + for SIEM integration. Fully offline; nothing phoned home. +- **`--fips`** enforces FIPS 140-3 approved algorithms only. Currently + rejects Ed25519 (pending NIST approval) with a clear migration + message pointing at hybrid `ed25519+ml-dsa-65` (v0.6+). +- **`--replay-chain `** bulk-verifies every receipt in a JSONL + chain. Reports total / verified / failed / chain-breaks. Chain + linkage (`previousReceiptHash`) is explicitly validated. +- **`--diff `** structural diff between two receipts. + Surfaces added/removed/changed fields, canonical hash comparison, + and signature equality. Debugging aid for implementers. +- **`--audit-report`** renders a self-contained HTML audit report + suitable for delivery to auditors / compliance teams / counterparties. + Embeds the canonical attestation if `--attest` is also set. + Includes verification summary, per-receipt breakdown, verifier + provenance, and raw JSON result. +- **`--output `** writes HTML reports or attestation JSON to a + file instead of stdout. + +### Sigil commitment expansion + +- Sigil v0.5.0 commits to **21 source files** (up from v0.3.0's single + cli.js): cli.js + 20 engines/outputs/utils/context files. Any + modification invalidates `--self-check`. + +### New subcommands (bootstrap + integration) + +- **`verify init`** — zero-config onboarding wizard. Auto-detects framework across 13 supported agents (Claude Code, Claude Agent SDK, Google ADK, CrewAI, Pydantic AI, AutoGen, Smolagents, LangChain JS/Py, LangGraph JS/Py, OpenAI Agents, Vercel AI). Generates keys, writes `.veritasacta/config.json`, emits next-steps. `--framework ` override, `--force` overwrite. +- **`verify proxy --target ""`** — universal MCP proxy. Wraps any MCP server with signing. No code changes in server or agent; each `tools/call` emits a chain-linked receipt. Signet-parity. +- **`verify daemon`** — sidecar daemon on Unix socket. Language-agnostic signing API (`POST /sign`). One daemon handles receipts for any number of agents in any language. + +### Ecosystem artifacts (`ecosystem/`) + +Shipped (working code): + +- `ecosystem/github-action/` — drop-in CI step (`VeritasActa/verify-action@v1`) +- `ecosystem/claude-code-plugin/` — one-click Claude Code plugin + SKILL.md +- `ecosystem/homebrew-tap/Formula/veritasacta-verify.rb` — `brew install veritasacta-verify` +- `ecosystem/sdk-js/` — `@veritasacta/sdk` tiny signing helper (JS) +- `ecosystem/sdk-py/` — `veritasacta-sdk` tiny signing helper (Python) +- `ecosystem/adapters/langchain/` — LangChain adapter with full `withReceipts()` implementation +- `ecosystem/adapters/{langgraph,crewai,openai-agents,vercel-ai,smolagents,pydantic-ai,autogen}/` — seven additional framework adapter scaffolds +- `ecosystem/registry-worker/` — `registry.veritasacta.com` Cloudflare Worker +- `ecosystem/badge-worker/` — `verify.veritasacta.com/badge/*` shields.io-compatible SVG badges +- `ecosystem/interop-leaderboard/workflow.yml` — weekly cross-implementation interop CI + +Scaffolds (design docs, implementation pending): + +- `ecosystem/cosign-compat/DESIGN.md` — v0.6.0 Sigstore compatibility +- `ecosystem/rollback/DESIGN.md` — filesystem snapshots + undo (nono-style) +- `ecosystem/supervisor/DESIGN.md` — runtime approval flows +- `ecosystem/reputation/DESIGN.md` — issuer reputation (complement to aeoess agent reputation) +- `ecosystem/dashboard/DESIGN.md` — web audit dashboard (Signet-style) +- `ecosystem/browser-extension/DESIGN.md` — Claude.ai / ChatGPT consumer reach +- `ecosystem/ebpf-observer/DESIGN.md` — kernel-level auto-instrumentation (highest novelty) +- `ecosystem/vscode-extension/` — editor integration (v0.5.1) +- `ecosystem/CONFORMANCE-CERTIFICATION.md` — commercial certification service design +- `ecosystem/SIGIL-NAMING.md` + `ecosystem/RELEASE-NAMING.md` — public brand convention + historical Sigil registry + +## 0.5.0 core — 2026-04-19 (unified verifier) + +### Major + +- **Unified verifier.** Single Apache-2.0 binary now handles Ed25519 + signed receipts, VOPRF anonymous-credential tokens (full dual-DLEQ + verification), Knowledge Unit bundles, and selective-disclosure + receipts. Auto-detects input format; `--mode receipt|voprf|ku| + bundle|auto` forces a specific engine. +- **Full VOPRF DLEQ verification.** Both the issuer proof (πI: + log_G(Y) = log_M(Z)) and the client proof (πC: knowledge of the + blinding scalar b such that M = b·P) are verified with Schnorr + DLEQ reconstruction (`A1 = r·g1 + c·h1`, `A2 = r·g2 + c·h2`; check + that the recomputed challenge equals c). The engine is byte- + compatible with the production BRASS issuer at `api.scopeblind.com` + and the production client SDK: tokens issued in production verify + against this engine, and the engine rejects any tampered scalar, + wrong issuer key, scope mismatch, or AAD-bound πC tampering. +- **`--allow-partial-voprf` flag** retained as a no-op for + compatibility with the `_partial` flag that existed during the + port. All VOPRF results in 0.5.0 are full verifications; the flag + is documented as deprecated and scheduled for removal in v0.6.0. +- **Modular architecture.** cli.js is a thin dispatcher; verification + logic lives in `src/engines/*.js`. Each engine is independently + auditable and testable. +- **Conformance tiers (T1-T5).** The verifier reports which tier of + conformance a receipt exercised: T1 basic (Ed25519 + JCS + chain), + T2 disclosure (AIP-0002), T3 attestation (hardware / anchor_uri), + T4 privacy (VOPRF + holder_binding), T5 full (ZK compliance, v1.0+). + Each verification surfaces the tier achieved. +- **Knowledge Unit bundles.** First-class support for + draft-farley-acta-knowledge-units-00 multi-model deliberation + bundles. Reports topic, models, rounds, consensus level, dissent, + and verifies each embedded receipt. +- **AIP-0002 selective disclosure.** `--disclose field:salt:value` + verifies salted SHA-256 commitments on redacted fields without + needing the issuer. Redacted fields are counted and surfaced. +- **Sigil claim 2 — live-context verification (patent #5).** + `--require-context clock:±5s` / `geofence:...` / `sensor:temp<18` + evaluates predicates at verification time. The verifier aggregates + results and fails verification when any required predicate fails. +- **Sigil commits to entire codebase.** Previously Sigil only + committed to cli.js. v0.5.0 extends commitment to all 15 source + files (cli.js + src/engines/* + src/output/* + src/util/* + + src/context/*). Modification of any file invalidates `--self-check`. +- **JSON output.** `--json` emits structured results including + `tier`, `mode`, `algorithm`, `kid`, `key_source`, and + `sigil_fingerprint` for machine consumption. +- **`--capabilities` command.** Lists supported modes, algorithms, + tiers, specs, and wayfinding. For CI integration and compatibility + discovery. +- **Error code registry.** Every emitted error code is stable, + documented in ERRORS.md, and includes a spec section reference + where applicable. + +### Field recognition (surfaced in output, no verification required) + +- `disclosure_mode` enum +- `holder_binding` object (modes: jwk_thumbprint, dpop, + attested_credential; per AIP-0003) +- `annex_hash` (private annex commitment) +- `attestation_mode` (software, hardware:secure_element, hardware:tee, + hardware:hsm) +- `anchor_uri` (transparency log anchor URI) +- Extended decision enum (challenge, payment_required, escalate, + override) +- `nullifier` (VOPRF mode) +- `scope` structure (origin, epoch, sub) +- `compliance_credit_ref` (reserved for v1.0 ZK compliance proofs) +- `transport_hint` (direct, ohttp, tor, custom) +- `verifier_salt_kid` (VOPRF mode) + +### Hybrid post-quantum + +- Verifier detects `algorithm` values of the form `ed25519+ml-dsa-65`, + `ed25519+dilithium3`, etc., and emits a clear `unsupported_algorithm` + error. Full hybrid PQ verification is planned for v0.6+. + +### Security + +- Embedded-key rejection (from 0.4.0) retained. The deprecated + `--allow-embedded-key` escape hatch is still present in 0.5.0 but + will be removed in 0.6.0. +- `--strict` mode disables all deprecated fallbacks. +- Constant-time signature comparison preserved. + +### Spec alignment + +- Targets draft-farley-acta-signed-receipts-03 (Sigil self-check + output now references -03 explicitly). +- References draft-farley-acta-knowledge-units-00 as the KU format. +- References AIP-0001 (receipt format), AIP-0002 (selective + disclosure), AIP-0003 (holder binding). + +### Supply chain + +- Published with `npm publish --provenance` (Sigstore-anchored + supply chain attestation). +- Dependency tree: `@veritasacta/artifacts` only. Transitive surface: + `@noble/curves`, `@noble/hashes`. + +### Documentation + +- THREAT-MODEL.md: formal threat model covering tamper detection, + replay, forgery, canonicalization, and the explicit non-goals. +- SECURITY.md: disclosure policy and supported-version matrix. +- ERRORS.md: complete error-code registry with spec references. +- Expanded README with conformance tiers and usage examples for + every mode. + +## 0.4.0 — 2026-04-19 (embedded-key rejection) + +### Security + +- **Breaking change: embedded keys in receipt payloads are now + rejected by default.** A verification key transported inside the + signed payload does not provide authenticity against tampering + (see draft-farley-acta-signed-receipts-03 Security Considerations). +- **New flag: `--allow-embedded-key`** (deprecated; removed in 0.5 + or 0.6). Restores pre-0.4.0 behaviour for one release cycle. +- Issue surfaced publicly by @desiorac on GetBindu PR #459. + +## 0.3.0 — 2026-04-05 (previous release) + +Offline receipt verification via `@veritasacta/verify` CLI. diff --git a/ERRORS.md b/ERRORS.md new file mode 100644 index 0000000..ada3876 --- /dev/null +++ b/ERRORS.md @@ -0,0 +1,105 @@ +# Error Code Registry + +Every error `@veritasacta/verify` can emit has a stable code, +classification, exit code, and spec reference where applicable. +Consumers should parse the `error` field (from `--json` output) +rather than rely on human-readable text. + +## Classification + +| Class | Meaning | Exit code | +|---|---|---| +| `tampered` | Signature was tested and failed. Proof of modification. | 1 | +| `undecidable` | Signature could not be tested (malformed, missing key, unsupported algorithm). | 2 | + +## Codes + +### Tampered (exit 1) + +#### `invalid_signature` +- **Description:** Cryptographic signature verification failed over the canonical payload. +- **Spec:** `draft-farley-acta-signed-receipts-03 §6.1` +- **Hint:** The receipt has been modified, or was signed by a different key than the one provided. + +#### `chain_break` +- **Description:** `previousReceiptHash` does not match the hash of the preceding receipt. +- **Spec:** `draft-farley-acta-signed-receipts-03 §5.4 Chain Linkage` +- **Hint:** A receipt has been inserted, removed, or reordered in the chain. + +#### `commitment_mismatch` +- **Description:** Selective-disclosure commitment does not match the revealed salt+value. +- **Spec:** `AIP-0002 §Disclosure Package Verification` +- **Hint:** The disclosed value does not correspond to the committed hash. + +#### `dleq_verification_failed` +- **Description:** VOPRF DLEQ proof verification failed (issuer or client proof invalid). +- **Spec:** `draft-farley-acta-signed-receipts-03 §VOPRF Token Verification` +- **Hint:** The VOPRF token was not produced by a valid issuer, or the proof is malformed. + +### Undecidable (exit 2) + +#### `embedded_key_rejected` +- **Description:** Receipt contains a verification key in its payload, which is not trusted by default. +- **Spec:** `draft-farley-acta-signed-receipts-03 §Security Considerations — Key Distribution` +- **Hint:** Provide `--key`, `--jwks`, or `--trust-anchor` externally. The deprecated `--allow-embedded-key` restores pre-0.4.0 behaviour for one release cycle. + +#### `no_public_key` +- **Description:** Verification key could not be resolved from `--key`, `--jwks`, or a bundle verification block. +- **Hint:** Provide `--key `, `--jwks `, or `--trust-anchor `. + +#### `missing_signature` +- **Description:** Input does not contain a `signature` field. + +#### `missing_payload` +- **Description:** Input does not contain a `payload` field. + +#### `unsupported_algorithm` +- **Description:** The declared signature algorithm is not supported by this verifier version. +- **Hint:** Hybrid post-quantum algorithms like `ed25519+ml-dsa-65` require v0.6+ for full PQ verification. + +#### `non_ascii_key` +- **Description:** An object key contains a non-ASCII character, violating AIP-0001. +- **Spec:** `AIP-0001 §JCS Canonicalization` + +#### `malformed_json` +- **Description:** Input could not be parsed as JSON. + +#### `malformed_hex` +- **Description:** A hex-encoded value has odd length or contains invalid characters. + +#### `unknown_format` +- **Description:** Input does not match any recognized receipt, token, or bundle format. +- **Hint:** Valid formats: v1 receipt, v2 receipt, Passport envelope, audit bundle, KU bundle, VOPRF token. + +#### `jwks_fetch_failed` +- **Description:** JWKS endpoint did not return a valid key set. + +#### `context_requirement_unmet` +- **Description:** One or more `--require-context` predicates evaluated false at verification time. +- **Spec:** Patent #5 claim 2 — Live-context verification + +#### `tier_not_achieved` +- **Description:** Verification succeeded but did not achieve the tier required by `--tier`. + +## JSON output format + +When `--json` is used, errors appear as: + +```json +{ + "valid": false, + "error": "embedded_key_rejected", + "errorMeta": { + "code": "embedded_key_rejected", + "description": "Receipt contains a verification key in its payload, which is not trusted by default.", + "class": "undecidable", + "spec": "draft-farley-acta-signed-receipts-03 §Security Considerations — Key Distribution", + "hint": "Provide --key, --jwks, or --trust-anchor externally." + }, + "format": "ed25519-passport", + "kid": "..." +} +``` + +Consumers should branch on `error` code, not on `errorMeta.description` +(which is informative but may evolve). diff --git a/LICENSE b/LICENSE index 11b1de5..de9ae21 100644 --- a/LICENSE +++ b/LICENSE @@ -4,178 +4,7 @@ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to the Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by the Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding any notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - Copyright 2026 ScopeBlind Pty Ltd (ABN 41693027440) + Copyright 2026 Tom Farley / Veritas Acta Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/PATENTS.md b/PATENTS.md new file mode 100644 index 0000000..56a2e46 --- /dev/null +++ b/PATENTS.md @@ -0,0 +1,53 @@ +# Patent Notice + +This software is open source. You are free to use, modify, and distribute it +under its stated license (MIT or Apache-2.0 — see each package's LICENSE file). + +## Patent Holdings + +ScopeBlind Pty Ltd (ABN 41 693 027 440) holds provisional patent applications +covering specific methods used in conjunction with this software: + +1. **VOPRF Metering** (AU Provisional, ~October 2025) — Deterministic + credential derivation using Verifiable Oblivious Pseudorandom Functions + for privacy-preserving rate limiting and metering. + +2. **Verifier Nullifiers** (AU Provisional, ~October 2025) — Issuer-blind + verification scheme where the verifier confirms credential validity + without learning which organization issued it. + +3. **Offline Enforcement** (AU Provisional, ~October 2025) — Self-contained + policy enforcement with cryptographic receipts that are independently + verifiable without contacting the issuer. + +4. **Decision Receipts with Configurable Disclosure** (AU Provisional, + March 2026) — Signed decision artifacts with holder-bound zero-knowledge + compliance proofs, tool-calling gateway integration, agent manifests, + portable identity, and cross-algorithm key binding. 20 claims, 8 figures. + +## Scope + +These patents cover specific server-side issuance methods and the novel +composition of VOPRF credentials with policy enforcement and receipt signing. +They do NOT restrict your use of: + +- The open-source verification code (`@veritasacta/verify`) +- The policy enforcement gateway (`protect-mcp`) +- The Ed25519 signing primitives (`@veritasacta/artifacts`) +- The receipt format specified in the IETF Internet-Draft +- Standard cryptographic operations (Ed25519, JCS, SHA-256) + +## Apache-2.0 Patent Grant + +Packages licensed under Apache-2.0 include an explicit patent grant per +Section 3 of the Apache License, Version 2.0. This grant covers the use +of the software as distributed. The grant terminates only if you initiate +patent litigation alleging that the software constitutes patent infringement +(retaliation clause, Section 3). + +## Contact + +Patent inquiries: patents@scopeblind.com +General: tommy@scopeblind.com +Website: https://scopeblind.com +IETF Draft: https://datatracker.ietf.org/doc/draft-farley-acta-signed-receipts/ diff --git a/README.md b/README.md index 50025dc..34da420 100644 --- a/README.md +++ b/README.md @@ -1,177 +1,322 @@ # @veritasacta/verify -Verify signed receipts offline. No accounts, no API calls, no trust required. - -**Apache-2.0 · Ed25519 · Offline · Zero dependencies beyond `@noble/curves`** - ---- - -> **⚠️ 0.4.0 breaking change (coming this week):** The verifier now **rejects -> keys transported inside the receipt payload** by default. This closes a -> spec gap surfaced via an external review: a verification key embedded in -> the signed body does not provide authenticity against tampering (an -> attacker who can modify the payload can also substitute the key). -> -> **Migration:** Pass the verification key externally via `--key `, -> `--jwks `, or a configured trust anchor. Receipts that rely on -> embedded keys will return exit code 2 (`embedded_key_rejected`). -> -> A deprecated `--allow-embedded-key` flag restores pre-0.4.0 behavior for -> one release cycle. It will be removed in 0.5.0. -> -> Normative language for this change lands in -> [draft-farley-acta-signed-receipts-02](https://datatracker.ietf.org/doc/draft-farley-acta-signed-receipts/) -> Security Considerations (Key Distribution and Trust Anchors). A matching -> negative conformance vector set lands in -> [ScopeBlind/agent-governance-testvectors](https://github.com/ScopeBlind/agent-governance-testvectors). - ---- +**Unified offline verifier for signed machine-decision artifacts. Network-effect mechanics built in.** + +Apache-2.0 · Ed25519 + VOPRF · Offline · Sigil-verified canonical release · Auto-onboarding · MCP proxy · Sidecar daemon + +> **Receipt format:** ScopeBlind emits Veritas Acta receipts. Legacy ScopeBlind +> receipts remain verifiable, but Acta v0.1 is the canonical format going +> forward. Spec: [`@veritasacta/protocol`](https://www.npmjs.com/package/@veritasacta/protocol) +> · IETF: [draft-farley-acta-signed-receipts](https://datatracker.ietf.org/doc/draft-farley-acta-signed-receipts/). ```bash -npm install @veritasacta/verify -``` +# Install +npm install -g @veritasacta/verify +# Or +brew install veritasacta/verify/veritasacta-verify -Part of the [Veritas Acta](https://github.com/VeritasActa) project — open evidence protocol for machine decisions. +# Prove canonical release +npx @veritasacta/verify --self-check -## Self-Test +# Zero-config onboarding (auto-detects framework) +npx @veritasacta/verify init -```bash -npx @veritasacta/verify --self-test +# Verify any receipt format +npx @veritasacta/verify receipt.json --key ``` -``` -✓ Sample receipt: VALID (Ed25519, kid: gateway-001) -✓ Sample bundle: VALID (3/3 receipts verified) -✓ Tampered receipt: REJECTED (signature mismatch) -No ScopeBlind servers were contacted. +Part of the [Veritas Acta](https://veritasacta.com) protocol for machine-decision evidence. + +## What it verifies + +| Mode | Input | Conformance tier | +|---|---|---| +| Ed25519 receipt | Signed decision receipts (v1, v2, Passport envelope) | T1 | +| Ed25519 + AIP-0002 | Selective-disclosure receipts with `_commitments` | T2 | +| Ed25519 + attestation | Receipts with `attestation_mode` or `anchor_uri` | T3 | +| VOPRF token | Anonymous credential tokens (RFC 9497, BRASS wire format). Full Schnorr DLEQ verification for both πI (issuer) and πC (client). | T4 | +| Knowledge Unit | Multi-model deliberation bundles (draft-farley-acta-knowledge-units-00) | varies | +| Audit bundle | Multiple receipts with embedded signing keys | varies | + +## Subcommands + +The CLI is a dispatcher: one binary, eight modes. + +```bash +verify # verify a single file (default) +verify init # zero-config onboarding, auto-detects framework +verify proxy --target "..." # transparent MCP proxy, signs every tool call +verify daemon # unix-socket sidecar, language-agnostic signing API +verify prompt # verify provenance of a CLAUDE.md / SKILL.md / system prompt +verify chain explore # walk a receipt chain to its root, validate every hash link +verify --replay-chain ... # bulk verification with chain-linkage check +verify --self-check # prove this binary is the canonical release +verify --attest # emit a shareable canonical attestation ``` -Exit codes: -- `0` — all signatures valid -- `1` — tampered or invalid signature -- `2` — malformed input +### Prompt provenance -## What It Does +Closes the supply-chain vector where an attacker modifies `CLAUDE.md`, `SKILLS.md`, or a system prompt between authoring time and agent runtime. -Every time an AI agent makes a decision through [protect-mcp](https://www.npmjs.com/package/protect-mcp), that decision is signed with Ed25519. The signature covers: +```bash +# Against a Veritas Acta receipt asserting the prompt hash +verify prompt SKILL.md --prompt-receipt prompt-receipt.json -- **Tool name** — what was called -- **Decision** — allow, deny, rate_limited -- **Policy digest** — which policy version governed the decision -- **Timestamp** — when it happened -- **Payload digest** — SHA-256 of the full input/output +# Against a Sigstore bundle (DSSE + in-toto statement) +verify prompt CLAUDE.md --sigstore-bundle bundle.json -`@veritasacta/verify` checks these signatures. It works offline, requires no accounts, and never contacts any server. +# Fast path: caller knows the expected hash +verify prompt SKILL.md --expected-hash +``` -## Usage +### Chain exploration -### Verify a single receipt +Walks the `previousReceiptHash` chain from a chain tip back to its root, validating every link's SHA-256. ```bash -npx @veritasacta/verify receipt.json +verify chain explore ./receipts/tip.json +# → ASCII tree, depth, links_broken, warnings + +verify chain explore ./receipts/tip.json --search-dir ./audit/ --max-depth 200 --json ``` -### Verify with an explicit public key +### Pre-built sandbox profiles + +`ecosystem/profiles/` ships sandboxing profiles (Cedar policy + nono capabilities + README) for common agent runtimes — Claude Code, Cursor, Codex, Gemini CLI, OpenClaw. Compose with `sb-runtime --ring 3 --policy ./policy.cedar` + `nono run --caps ./nono-capabilities.yaml`. + +## Verification properties + +- **Offline.** No network contacted unless `--jwks ` is explicitly passed. +- **Tamper-evident.** Exit 1 is proven tampering; exit 2 is undecidable (malformed, missing key, unsupported algorithm). +- **No vendor trust.** Only Ed25519 (RFC 8032) and JCS (RFC 8785) in the verification path. +- **Self-verifying.** `--self-check` cryptographically proves the installed verifier (24 source files) matches the canonical release. +- **Algorithm-agile.** Hybrid PQ (`ed25519+ml-dsa-65`) recognized; full PQ in v0.6+. +- **Zero telemetry.** The verifier never phones home. + +## Quick start: frictionless onboarding ```bash -npx @veritasacta/verify receipt.json --key +$ cd my-agent-project +$ npx @veritasacta/verify init + +[Sigil ASCII art] + sigil: 956f2e88 + +✓ Veritas Acta initialized + Directory: ./.veritasacta + Kid: project:956f2e8895fd + Framework: crewai (python) + +Next steps: + Install: pip install veritasacta-crewai + Wrap your agent with the adapter as shown in the adapter README. + +Verify: + npx @veritasacta/verify .veritasacta/receipts/*.json --key 956f2e88... ``` -### Verify an audit bundle +Init auto-detects your framework from `package.json` / `pyproject.toml` / `requirements.txt` across 13 supported frameworks (Claude Code, Claude Agent SDK, Google ADK, CrewAI, Pydantic AI, AutoGen, Smolagents, LangChain JS/Python, LangGraph JS/Python, OpenAI Agents SDK, Vercel AI SDK). + +## Universal MCP proxy — zero code changes ```bash -npx @veritasacta/verify bundle.json +$ verify proxy --target "node my-mcp-server.js" +[veritasacta proxy] rcpt_1 signed (web_search) kid=project:956f2e8895fd +[veritasacta proxy] rcpt_2 signed (read_file) kid=project:956f2e8895fd +... ``` -Bundles are self-contained: they include receipts + the public key used to sign them. +Wraps any MCP server with signing. No changes in the server. No changes in the agent. Every `tools/call` gets a chain-linked Ed25519 receipt. -### Programmatic API +## Sidecar daemon — language-agnostic signing -```javascript -import { verifyReceipt, verifyBundle } from '@veritasacta/verify'; +Run once; any process in the same user context signs receipts by POST. -const result = await verifyReceipt(receiptJson, publicKeyHex); -// { valid: true, algorithm: 'Ed25519', kid: 'gateway-001' } +```bash +$ verify daemon & -const bundleResult = await verifyBundle(bundleJson); -// { valid: true, receipts: 47, verified: 47, failed: 0 } +# Any language, any process: +$ curl --unix-socket /tmp/veritasacta-$UID.sock -X POST http://_/sign \ + -d '{"tool":"web_search","args":{"q":"..."},"decision":"allow"}' + +{ "payload": {...}, "signature": {"alg":"EdDSA","kid":"...","sig":"..."} } ``` -## Receipt Format +One daemon, N agents, zero SDK embedding. + +## Canonical attestation — network-effect mechanics -Receipts follow the [IETF Internet-Draft](https://datatracker.ietf.org/doc/draft-farley-acta-signed-receipts/) `draft-farley-acta-signed-receipts-01`: +Every user who runs `--self-check` can emit a **canonical attestation** — a signed JSON artifact proving they ran the canonical unmodified verifier. Publish wherever (GitHub README, status page, SBOM, Rekor). + +```bash +$ verify --attest --attest-org "Acme Corp" --output attestation.json +``` + +Output: ```json { - "type": "decision", - "version": 2, - "algorithm": "Ed25519", - "kid": "gateway-001", - "timestamp": "2026-04-10T12:00:00Z", "payload": { - "tool": "read_file", - "decision": "allow", - "policy_digest": "sha256:a1b2c3..." + "type": "veritasacta:verifier-attestation", + "sigil_fingerprint": "6391ae72", + "sigil_name": "Quiet Orchard", + "canonical": true, + "attester_org": "Acme Corp", + "issued_at": "2026-04-19T...", + "expires_at": "2026-04-26T...", + "attester_kid": "attester:..." }, - "signature": "base64url-encoded-ed25519-signature" + "signature": { "alg": "EdDSA", ... }, + "verification": { "attester_pubkey": "..." } } ``` -The signature is computed over the [JCS-canonicalized](https://www.rfc-editor.org/rfc/rfc8785) payload, ensuring deterministic verification regardless of JSON key ordering. +Offline. User-signed. Counterfeit forks produce attestations marked `canonical: false` — detectable across the network. -## How Verification Works +## Verification receipts +```bash +$ verify receipt.json --key --emit-verification-receipt ``` -Receipt JSON - ↓ -Extract payload + signature - ↓ -JCS-canonicalize the payload - ↓ -Ed25519.verify(signature, canonical_payload, public_key) - ↓ -exit 0 (valid) or exit 1 (tampered) + +Produces a signed "the canonical verifier checked this receipt and it was valid" artifact. Anchor in Sigstore Rekor, publish in SBOMs, attach to compliance reports. + +## Enterprise features + +| Flag | Purpose | +|---|---| +| `--pin-sigil ` | Require the installed Sigil fingerprint to match (supply-chain enforcement) | +| `--audit-log ` | Append every verification event to a chain-hashed JSONL log | +| `--audit-report` | Render an HTML audit report (self-contained, auditor-ready) | +| `--fips` | Enforce FIPS-approved algorithms only | +| `--strict` | Disable all deprecated fallbacks | +| `--tier N` | Require minimum conformance tier (1-5) | +| `--replay-chain ` | Bulk-verify a JSONL chain with parallel workers | +| `--diff ` | Structural diff between two receipts | + +## Live-context verification (Sigil claim 2) + +```bash +$ verify receipt.json \ + --require-context clock:±5s \ + --require-context sensor:temp<18 ``` -No network calls. No trust assumptions. The only input is the receipt and the public key. +Gates verification on live context (NTP, sensors, feeds). Predicate fails → verification fails. Operationalizes patent #5 claim 2. -## Issuer-Blind Design +## Algorithms supported -The verifier never learns *who* generated the receipt — only that it was signed by a key matching the provided public key. This is a deliberate design property: +- `Ed25519` / `EdDSA` (RFC 8032) +- `voprf-p256-sha256` (RFC 9497, structural; full DLEQ extraction in progress) +- Hybrid PQ recognized: `ed25519+ml-dsa-65`, `ed25519+dilithium3` (v0.6+) -- **Compliance teams** can verify agent behavior without accessing the agent runtime -- **Third-party auditors** can check receipts without org access -- **Cross-organization verification** works without federation or shared infrastructure +## Conformance tiers -## Security +| Tier | Requirements | +|---|---| +| T1 Basic | Ed25519 + JCS + chain linkage | +| T2 Disclosure | T1 + AIP-0002 selective disclosure | +| T3 Attestation | T2 + `attestation_mode` + `anchor_uri` | +| T4 Privacy | T3 + VOPRF + `holder_binding` | +| T5 Full | T4 + ZK compliance proofs (v1.0+) | -- Ed25519 signatures via [`@noble/curves`](https://github.com/paulmillr/noble-curves) (audited by Trail of Bits) -- SHA-256 hashing via [`@noble/hashes`](https://github.com/paulmillr/noble-hashes) (audited) -- JCS canonicalization for deterministic payload serialization -- No eval, no dynamic imports, no network access during verification +Each verification surfaces the tier achieved. Implementations earn tier badges for their READMEs. -## Related Projects +## Framework adapters -| Project | Description | -|---------|-------------| -| [VeritasActa/Acta](https://github.com/VeritasActa/Acta) | Open evidence protocol — charter, spec, types | -| [protect-mcp](https://www.npmjs.com/package/protect-mcp) | Security gateway that produces signed receipts | -| [ScopeBlind/verify-mcp](https://github.com/ScopeBlind/verify-mcp) | MCP server exposing verification as agent tools | -| [VeritasActa/drafts](https://github.com/VeritasActa/drafts) | IETF Internet-Draft source files | -| [@veritasacta/protocol](https://www.npmjs.com/package/@veritasacta/protocol) | Full evidence protocol types (TypeScript) | +| Framework | Package | Language | +|---|---|---| +| Claude Code (MCP hooks) | `protect-mcp` | JS | +| Google ADK | `protect-mcp-adk` | Python | +| LangChain | `@veritasacta/langchain` / `veritasacta-langchain` | JS / Python | +| LangGraph | `@veritasacta/langgraph` / `veritasacta-langgraph` | JS / Python | +| CrewAI | `veritasacta-crewai` | Python | +| Pydantic AI | `veritasacta-pydantic-ai` | Python | +| AutoGen | `veritasacta-autogen` | Python | +| Smolagents | `veritasacta-smolagents` | Python | +| OpenAI Agents SDK | `@veritasacta/openai-agents` | JS / Python | +| Vercel AI SDK | `@veritasacta/vercel-ai` | JS | +| Any MCP server | `verify proxy --target ""` | language-agnostic | +| Anything else | `verify daemon` + HTTP POST | language-agnostic | -## Protocol Specification +## SDK -The receipt format is standardized as an IETF Internet-Draft: +Tiny language-agnostic signing helpers for custom integrations: -**[draft-farley-acta-signed-receipts-01](https://datatracker.ietf.org/doc/draft-farley-acta-signed-receipts/)** — Signed Decision Receipts for Machine-to-Machine Access Control +```bash +npm install @veritasacta/sdk +pip install veritasacta-sdk +``` -## License +```js +import { Signer } from '@veritasacta/sdk'; +const signer = Signer.fromKeyFile('.veritasacta/attester.json'); +const receipt = signer.signDecision({ tool: 'x', args: {}, decision: 'allow' }); +``` + +## Release names (Sigil brand convention) + +Every release gets a unique deterministic name from its cryptographic fingerprint. Current release: **Quiet Orchard** (`6391ae72`). Full registry at [veritasacta.com/sigils](https://veritasacta.com/sigils). See [ecosystem/RELEASE-NAMING.md](./ecosystem/RELEASE-NAMING.md) for the derivation. + +## Ecosystem artifacts + +The `ecosystem/` directory ships: + +- **GitHub Action** (`ecosystem/github-action/`) — drop-in CI step +- **Claude Code plugin** (`ecosystem/claude-code-plugin/`) — one-click Claude Code install +- **Homebrew tap** (`ecosystem/homebrew-tap/`) — `brew install veritasacta-verify` +- **Registry worker** (`ecosystem/registry-worker/`) — public implementations registry (`registry.veritasacta.com`) +- **Badge worker** (`ecosystem/badge-worker/`) — shields.io-compatible badges (`verify.veritasacta.com/badge/*`) +- **Interop leaderboard** (`ecosystem/interop-leaderboard/`) — weekly cross-implementation CI +- **Language SDKs** (`ecosystem/sdk-js/`, `ecosystem/sdk-py/`) — tiny signing helpers +- **Framework adapters** (`ecosystem/adapters/*`) — LangChain, CrewAI, OpenAI Agents, Vercel AI, Smolagents, Pydantic AI, AutoGen, LangGraph +- **Design docs** (`ecosystem/rollback/`, `ecosystem/supervisor/`, `ecosystem/reputation/`, `ecosystem/dashboard/`, `ecosystem/browser-extension/`, `ecosystem/ebpf-observer/`, `ecosystem/cosign-compat/`, `ecosystem/CONFORMANCE-CERTIFICATION.md`) -Apache-2.0 — see [LICENSE](./LICENSE). +See [`ecosystem/README.md`](./ecosystem/README.md) for the full map. -Free to use, modify, and redistribute. The Apache-2.0 license includes a royalty-free patent grant (Section 3) for all constructions implemented in this code. +## Relationship to the Veritas Acta stack + +- **Protocol:** [veritasacta.com](https://veritasacta.com) — open IETF drafts, AIP specs, Apache-2.0. +- **Verifier:** this package. Open, offline, fully user-controlled. +- **Managed issuance (commercial):** [scopeblind.com](https://scopeblind.com) — managed receipt infrastructure + VOPRF issuance API. + +Open verifier + closed issuer. The verifier is always free. The commercial product is the managed service. + +## Supply chain + +v0.5.0 is published with: + +- `npm publish --provenance` — Sigstore-attested supply chain +- Sigil commitment covering 24 source files +- Minimum dependency tree: only `@veritasacta/artifacts` (+ transitively `@noble/curves`, `@noble/hashes`) + +Verify your installation: + +```bash +npm audit signatures # Sigstore attestation +verify --self-check # matches canonical Sigil +verify --pin-sigil # enforce a specific release +``` + +## Specifications + +- [draft-farley-acta-signed-receipts-03](https://datatracker.ietf.org/doc/draft-farley-acta-signed-receipts/) +- [draft-farley-acta-knowledge-units-00](https://datatracker.ietf.org/doc/draft-farley-acta-knowledge-units/) +- AIP-0001 (receipt format + ASCII-only JCS) +- AIP-0002 (selective disclosure) +- AIP-0003 (holder binding) +- RFC 8032, 8785, 9497, 9380, 7517, 7638 + +## Documentation + +- [CHANGELOG.md](./CHANGELOG.md) — release history +- [THREAT-MODEL.md](./THREAT-MODEL.md) — what the verifier protects against and what it doesn't +- [SECURITY.md](./SECURITY.md) — disclosure policy + supported versions +- [ERRORS.md](./ERRORS.md) — complete error-code registry +- [ecosystem/RELEASE-NAMING.md](./ecosystem/RELEASE-NAMING.md) — Sigil naming convention + +## License ---- +Apache-2.0. -> **Looking for the VOPRF/BRASS rate-limiting library?** The anonymous credential primitives have moved to a [separate repository](https://github.com/VeritasActa). This package (`@veritasacta/verify`) is now the offline receipt verification CLI. +Patent-adjacent; covered by the Apache-2.0 patent grant (§3). See [PATENTS.md](./PATENTS.md). diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..37224df --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,113 @@ +# Roadmap + +## v0.5.4 (shipping — 2026-04-20) + +- AIP-0007 ZK compliance proofs (spec draft). +- Rekor / transparency-log anchoring engine (AIP-0005 T4). +- Hardware-attestation quote validator (ATECC608B + Apple SE full crypto; TPM2/SGX/SEV structural). +- Receipt watcher with Slack / Discord / generic webhooks. +- SBOM audit bundle builder (SPDX + CycloneDX). +- Four-profile transparency switch (private / auditable / transparent / high-assurance). +- `@veritasacta/cross-verify` arbitrator package. +- Conformance certification program workflow. +- Canonical release: Open Wind (677a8a81). Sigil covers 36 source files. + +## v0.5.3 (shipping — 2026-04-20) + +- **AIP-0006**: delegation chains with narrowing-only scope, TTL, max_depth, cryptographic chain to trust anchor. +- **Bilateral cosign**: envelope-level `cosignatures[]` field. Proxy supports `--bilateral --server-key`. +- **trace_id + parent_receipt_id**: optional AIP-0001 fields for workflow-level grouping. +- **Proxy --scrub-secrets**: redacts probable-secret values from outgoing tool-call args + flags on receipt. +- **`verify dashboard`**: loopback-only local audit server; DNS-rebinding + path-traversal defenses. +- 41 new unit tests. Sigil covers 31 source files. + +## v0.5.2 (shipping — 2026-04-20) + +- **Compliance export subcommand**: SOC 2 / ISO 42001 / EU AI Act control mapping with HTML auditor report. +- **DSSE envelope engine**: Sigstore-compatible wrap/unwrap/verify for receipts, KUs, and in-toto statements. +- **BRASS v2 crypto scaffold**: length-prefixed hashing, nullifier-bound-to-Y, single-variable πC restatement. +- **AIP-0004** (Draft): content-addressed snapshot and rollback receipts, with reference Merkle helper. +- **AIP-0005** (Draft): attestation weight profile (cost_tier T0–T4) with evidence schemas. +- **Claude Code plugin staging** for wshobson/agents marketplace (`protect-mcp` tree, closes #471). +- **Audit dashboard scaffold**: local-first, in-browser receipt visualiser. +- **Physical attestation design**: Seal cost_tier T2 integration spec. +- Sigil covers 28 source files. Canonical release: **New Ember** (`b28f8d60`). + +## v0.5.1 (shipped — 2026-04-20) + +- **Prompt provenance**: `verify prompt ` verifies a CLAUDE.md / SKILL.md / AGENTS.md / system prompt against a Veritas Acta receipt, a Sigstore DSSE bundle, or an expected SHA-256. +- **Chain explorer**: `verify chain explore ` walks `previousReceiptHash` to root with link-by-link hash validation; ASCII tree and `--json` output. +- **Sandbox profiles**: `ecosystem/profiles/` — Cedar policy + nono capabilities + README for Claude Code, Cursor, Codex, Gemini CLI, OpenClaw. +- Sigil commitment grows to **25 source files** (adds prompt.js + chain-explore.js). Canonical release: **Bright Star** (`1cc829ab`). + +## v0.5.0 (shipped — 2026-04-19) + +- Unified verifier: Ed25519 receipts, VOPRF tokens (full dual-DLEQ), Knowledge Unit bundles, selective-disclosure receipts +- Sigil visual commitment covering 23 source files (since expanded in 0.5.1) +- `--attest` canonical attestations, `--emit-verification-receipt` +- `--pin-sigil`, `--audit-log`, `--audit-report`, `--replay-chain`, `--diff` +- `--fips` enforcement; `--allow-embedded-key` deprecated +- Subcommands: `init`, `proxy`, `daemon`, `--self-check`, `--capabilities` +- Network-effect mechanics (attestations, pinning, receipts-of-receipts) + +## v0.6.0 (planned) + +Scheduled items, each tracked as a separate implementation task. + +### Cryptographic hardening (BRASS protocol) + +| Item | Motivation | Breaking? | +|---|---|---| +| Switch BRASS hashing to length-prefixed (RFC 8785 JCS-aligned) | Eliminates variable-length concat collision surface in `piC` bind (AADr and KID are variable-length inputs). | Yes. Dual-mode verifier accepts both v1 and v2 wire formats during the deprecation window. | +| Bind nullifier derivation to issuer public key `Y` | Prevents cross-issuer nullifier collisions if two issuers ever share a KID string. | Yes, but the dual-mode derivation supports both and migration is operator-paced. | +| Restate πC as a standard single-variable Schnorr proof (wire unchanged) | The production BRASS scheme uses a DLEQ structure with `A2 = G` hardcoded. This is cryptographically equivalent to a single-variable Schnorr but reads as degenerate to external auditors. Restating clarifies without changing the wire. | No. | +| Formal verification of the BRASS scheme via Tamarin or ProVerif | Procurement-audit credibility; catches subtle issues the paper analysis misses. | No. | +| Apache-2.0 reference issuer | Patent conversion often benefits from a complete open specification of the invention, even when the commercial operation is proprietary. | No. Commercial differentiation via managed operations, SLAs, rate limits, key rotation, etc. Algorithm goes public. | + +### Post-quantum hybrid + +- Optional `ed25519+ml-dsa-65` hybrid signature mode on Ed25519 receipts. v0.5.0 recognizes the algorithm identifier and emits a clean `unsupported_algorithm` error. v0.6.0 ships the verifier side via a library like `@noble/post-quantum`. +- Expected scope: ~200 lines of code + spec additions + conformance vectors. + +### VOPRF enhancements + +- Drop the `--allow-partial-voprf` flag (no-op since 0.5.0 shipped full DLEQ). +- `scope` parameter refinement: support richer scope matching than `origin` equality (e.g., origin wildcards, epoch bounds). +- Per-issuer policy cache: avoid re-verifying πI for tokens known to be from a trusted-fingerprint issuer. + +### Reporting and audit + +- `--audit-report` template extensibility: custom CSS, operator logo embedding, export to PDF via headless Chrome. +- SBOM attachment: embed SPDX or CycloneDX alongside the verified receipt chain. + +### Spec tracking + +- Align with draft-farley-acta-signed-receipts-03 as that draft progresses through the IETF. +- Publish BRASS v2 as a separate draft (`draft-farley-brass-anonymous-tokens`). + +## v0.7.0 / v1.0 (exploratory) + +| Item | Notes | +|---|---| +| Sigstore / DSSE wrapping | `ecosystem/cosign-compat/DESIGN.md` outlines the wrap. Rekor anchoring for temporal proof. | +| Filesystem rollback helper | `ecosystem/rollback/DESIGN.md`. Content-addressed snapshots for post-incident recovery. | +| Runtime supervisor | `ecosystem/supervisor/DESIGN.md`. Dynamic permission expansion with approval gates. | +| Issuer reputation | `ecosystem/reputation/DESIGN.md`. Bayesian reputation over receipt issuers, complement to agent reputation. | +| Audit dashboard GUI | `ecosystem/dashboard/DESIGN.md`. Web dashboard for operator audits. | +| Browser extension | `ecosystem/browser-extension/DESIGN.md`. Consumer reach via Claude.ai / ChatGPT injection. | +| eBPF OS observer | `ecosystem/ebpf-observer/DESIGN.md`. Kernel-level auto-instrumentation. | + +## Deprecations + +| Item | Deprecated in | Removed in | +|---|---|---| +| `--allow-embedded-key` | 0.4.0 | 0.6.0 | +| `--allow-partial-voprf` | 0.5.0 | 0.6.0 | +| `voprf-p256-sha256` v1 wire format (plain-concat hash) | 0.6.0 (dual-mode) | 0.7.0 or later | + +## Commitments + +- v0.6.0 targets Q3 2026. +- All breaking changes ship with a dual-mode transition window of at least one minor release. +- Every new wire-format change is accompanied by conformance vectors in [ScopeBlind/agent-governance-testvectors](https://github.com/ScopeBlind/agent-governance-testvectors). +- `--self-check` continues to prove source integrity across releases; Sigils rotate per release with the registry maintained at `ecosystem/RELEASE-NAMING.md`. diff --git a/SECURITY.md b/SECURITY.md index 274f45b..2b924d8 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,53 +1,97 @@ # Security Policy +## Supported Versions + +| Version | Status | Support through | +|---------|----------------|-----------------| +| 0.5.x | Current | Next major | +| 0.4.x | Security only | 2026-10-19 | +| 0.3.x | End of life | ended 2026-04-19| +| < 0.3 | End of life | — | + +Receipts verified with an EOL version should be re-verified with a +current version to confirm continued validity. + ## Reporting a Vulnerability -If you discover a security vulnerability in `@veritasacta/verify`, please report it responsibly. +If you believe you have found a security vulnerability in +`@veritasacta/verify`, please report it privately. + +**Email:** security@scopeblind.com -**Email:** [security@veritasacta.com](mailto:security@veritasacta.com) +**Response time:** +- Acknowledgment within 48 hours +- Initial assessment within 5 business days +- Coordinated disclosure target: 90 days (shorter for actively + exploited issues, longer for complex issues requiring upstream fixes) -Please include: -- Description of the vulnerability -- Steps to reproduce -- Potential impact assessment -- Suggested fix (if any) +**What to include:** +- Version affected +- A clear description of the issue +- Reproduction steps (if applicable) +- Suggested remediation (if you have one) +- Whether you intend to publish (we can coordinate disclosure timing) -We will acknowledge receipt within 48 hours and provide an initial assessment within 7 days. +**What we will do:** +- Acknowledge your report +- Assess severity and impact +- Develop and test a fix +- Coordinate disclosure with you +- Credit you in the release notes unless you prefer otherwise ## Scope -This policy covers: -- The `@veritasacta/verify` npm package -- All code in the [VeritasActa/verify](https://github.com/VeritasActa/verify) repository +### In scope -## Cryptographic Dependencies +- Cryptographic verification correctness bugs in the verifier +- Canonicalization divergence from RFC 8785 / AIP-0001 +- Supply chain risks in the published package +- Side-channel attacks against the verification path (e.g., timing) +- Algorithm downgrade or silent fallback behavior +- Self-check bypass (Sigil commitment verification) -This library delegates all elliptic curve and hashing operations to: -- **[@noble/curves](https://github.com/paulmillr/noble-curves)** — audited by Cure53 (Feb 2024) -- **[@noble/hashes](https://github.com/paulmillr/noble-hashes)** — audited by Cure53 (Feb 2024) +### Out of scope -We do not implement custom cryptographic primitives. If you find a vulnerability in the noble libraries, please report it to their maintainer directly. +- Bugs in dependencies (report to upstream: @noble/curves, @noble/hashes) +- Threat-model non-goals documented in THREAT-MODEL.md (e.g., + compromised signing keys, issuer collusion, policy semantics) +- Usage errors unrelated to verification correctness +- Social engineering against the ScopeBlind team -## Security Properties +## Coordinated Disclosure Examples -The BRASS protocol provides the following security guarantees: +- **Embedded-key acceptance (fixed in 0.4.0):** surfaced publicly by + @desiorac on GetBindu PR #459 before reaching us privately. We + accept that publication of the issue on a third-party project was + legitimate; we responded with 0.4.0 within one week. +- We prefer private disclosure, but we will not penalize researchers + who choose to disclose publicly; our goal is correct verification, + not reporter punishment. -| Property | Guarantee | -|----------|-----------| -| **Issuer blindness** | The issuer cannot determine which scope a token will be redeemed against | -| **Nullifier determinism** | Same token + same scope always produces the same nullifier | -| **Unlinkability** | Different scopes produce unrelated nullifiers from the same token | -| **Proof soundness** | DLEQ proofs are computationally binding under the discrete log assumption on P-256 | -| **Offline verification** | The issuer is never contacted during token redemption | +## Hall of Fame -## Known Limitations +Security researchers who have helped improve @veritasacta/verify: -- **MemoryStore** is not suitable for distributed deployments (no cross-process synchronization) -- **KVStore** (Cloudflare KV) is eventually consistent — overspend is bounded but possible during replication lag -- This library implements the **verifier side** only. It does not issue tokens. +- @desiorac — embedded-key rejection (surfaced on GetBindu #459, + landed in 0.4.0) -## Supported Versions +## Supply Chain + +Each release is published with: + +- `npm publish --provenance` — Sigstore-attested supply chain +- Sigil commitment in `sigil.json` covering all source files +- GPG-signed git tag (when the release workflow runs) + +Verify the integrity of your installation: + +```bash +# Verify npm provenance +npm audit signatures + +# Verify local files match the Sigil +npx @veritasacta/verify --self-check +``` -| Version | Supported | -|---------|-----------| -| 0.1.x | Yes | +Cross-check the expected Sigil fingerprint against the canonical +release published on https://veritasacta.com. diff --git a/THREAT-MODEL.md b/THREAT-MODEL.md new file mode 100644 index 0000000..b2de036 --- /dev/null +++ b/THREAT-MODEL.md @@ -0,0 +1,219 @@ +# Threat Model + +`@veritasacta/verify` is a local, offline verifier for signed +machine-decision artifacts. This document specifies what it protects +against, what it does not, and the trust boundaries a caller must +understand. + +## 1. Goals (in scope) + +The verifier provides, for any artifact it reports as VALID: + +- **Signature integrity**: the signed payload has not been modified since + the signer produced it. An adversary with access to the artifact but + not the signing key cannot produce a VALID result by tampering. +- **Policy binding** (for receipts that include `policy_hash` / + `policy_id`): the decision was made under the identified policy + version, not a later-substituted policy. +- **Chain integrity** (for chained receipts): no receipt in the chain + has been inserted, removed, or reordered; every `previousReceiptHash` + matches the canonical hash of the preceding receipt. +- **Algorithm conformance**: the signature algorithm is explicitly + supported; unrecognized algorithms are reported as `undecidable` + rather than silently accepted. +- **Canonicalization determinism**: input that re-encodes into the same + canonical bytes produces the same verification result byte-for-byte. + Canonicalization follows RFC 8785 with AIP-0001 ASCII-only keys. +- **Selective-disclosure validity** (for receipts with `_commitments`): + a revealed `{field, salt, value}` triple matches the committed hash + if and only if the field content is authentic. +- **VOPRF DLEQ proof verification** (when full extraction lands): + tokens are proven issued by the claimed issuer key. +- **Canonical-release provenance** (`--self-check`): the installed + verifier binary + engine files are the canonical unmodified release + committed by the Sigil. +- **Offline operation**: verification uses no network calls unless the + caller explicitly passes `--jwks`. + +## 2. Non-Goals (out of scope) + +The verifier does NOT protect against: + +- **Compromised signing keys**: if the signer's private key leaks, an + adversary with the key can produce VALID receipts. The verifier + cannot distinguish these from legitimate receipts. +- **Issuer collusion**: if an issuer signs receipts for actions that + never occurred, those receipts are cryptographically valid. Detecting + such fraud requires separate mechanisms (transparency logs, independent + attestation, external audit). +- **Policy semantics**: the verifier confirms a receipt references a + `policy_id` / `policy_hash`, but does NOT interpret the policy or + evaluate whether the decision was correct given the inputs. Policy + interpretation is the caller's responsibility. +- **Replay across contexts**: a receipt valid in one context (e.g., a + specific session) may be replayed in another. Preventing replay is + the application layer's responsibility; the verifier surfaces + `nullifier`, `scope`, and timestamp fields that enable the caller to + implement replay guards. +- **Time authority**: `issued_at` is self-asserted by the signer. The + verifier does not independently time-stamp. Applications requiring a + trusted timestamp must combine signed receipts with transparency-log + inclusion proofs or an external timestamping authority. +- **Holder possession**: when `holder_binding` is present in a receipt, + the verifier surfaces the binding but does NOT prove the presenting + party is the committed holder. Possession proof requires an additional + mechanism (DPoP header at presentation, signed nonce, etc.) at the + caller layer. +- **Receipt availability / censorship**: the verifier only evaluates + artifacts it is given. It cannot detect missing receipts, gaps in a + chain held elsewhere, or receipts that were never surfaced. +- **Input size / resource exhaustion**: the verifier imposes reasonable + defaults on input size, but callers accepting untrusted input at scale + should apply their own limits before invoking the verifier. + +## 3. Trust Boundaries + +For a verification to be trustworthy, the following must hold: + +### 3.1 The caller must authentically obtain the verification key + +The verifier takes a public key as input (via `--key`, `--jwks`, or +`--trust-anchor`). The authenticity of that key is the caller's +responsibility. The verifier does NOT accept keys embedded in the +receipt payload itself (this was the `embedded_key_rejected` issue +fixed in 0.4.0). + +Recommended key distribution mechanisms: + +- **JWKS at a well-known URL** with TLS-authenticated domain ownership +- **DID Document** with method-specific authentication +- **Configured trust anchor** (locally pinned public key) +- **Transparency log entry** with inclusion proof + +### 3.2 The caller must provide an authentic runtime environment + +The verifier executes in Node.js. A compromised Node.js runtime or a +compromised installation of the verifier can produce arbitrary output. +The `--self-check` command is the caller's tool for proving the +installed verifier is the canonical release; however, `--self-check` +itself runs in the same potentially-compromised environment and is +therefore subject to the same caveat. Callers operating in +high-assurance environments should reproduce the verification from +source, in a separate trust domain. + +### 3.3 The caller must authentically obtain the verifier + +The npm package ships with a Sigil commitment that binds the published +release to specific file hashes. `--self-check` confirms installed +files match the Sigil commitment. The authenticity of the Sigil is +anchored in the npm publication (with `--provenance` attestation via +Sigstore). Callers verifying an installation should: + +1. Install via `npm install @veritasacta/verify` (Sigstore-attested) +2. Run `npx @veritasacta/verify --self-check` to confirm local files + match the Sigil +3. For high-assurance use, cross-check the installed Sigil fingerprint + against the fingerprint published on https://veritasacta.com + +## 4. Explicit Attack Classes + +### 4.1 Signature forgery + +Forgery of an Ed25519 signature without possession of the private key +requires breaking Ed25519 (equivalent to ~128-bit security against +classical attackers; reduced under post-quantum attack but still +requires significant work). + +### 4.2 Hash collision + +Forgery via SHA-256 collision requires ~2^128 work; outside practical +threat model. The verifier treats `sha256:` prefixes consistently and +rejects malformed hash strings. + +### 4.3 Canonicalization confusion + +Canonicalization divergence between signer and verifier can allow an +attacker to produce a receipt that verifies under one canonicalization +but fails under another. The verifier uses RFC 8785 JCS with AIP-0001 +ASCII-only keys, consistently. Test vectors in +`specs/conformance/test-vectors/` exercise edge cases. + +Non-ASCII keys are rejected at ingest per AIP-0001, preventing the +most common Unicode-normalization attacks. + +### 4.4 Timing attacks + +Signature byte comparison uses constant-time equality +(`constantTimeEqual` in `src/util/hex.js`). Cache-timing and +side-channel attacks against the @noble/curves Ed25519 implementation +are outside this project's scope; we rely on the auditability of that +library (audited by Trail of Bits, etc.). + +### 4.5 Input-parsing attacks + +The verifier parses JSON via Node.js's built-in parser. Deeply nested +or oversized inputs can exhaust memory. Callers accepting untrusted +input at scale should apply size limits (e.g., 10 MB) before invoking +the verifier. A future release will impose a built-in default limit. + +### 4.6 Algorithm downgrade + +The verifier requires an explicit algorithm identifier in the signature +envelope. Unknown algorithms trigger `unsupported_algorithm`, not +silent fallback to a weaker algorithm. Hybrid post-quantum algorithms +(`ed25519+ml-dsa-65`) are recognized structurally but fully verified +only when full PQ support lands (v0.6+). + +### 4.7 JWKS endpoint compromise + +If a caller uses `--jwks ` and the URL endpoint serves malicious +keys, those keys will be used for verification. The caller is +responsible for ensuring JWKS endpoints are authenticated (TLS with +pinned certificates or trusted PKI) and are controlled by the expected +issuer. + +### 4.8 Process-level interference + +A malicious process running in the same user context can replace the +verifier binary, modify its output, or intercept its inputs. The +verifier does not mitigate this; it is a correct-implementation of +verification in a correct-environment model. OS-level isolation (e.g., +Linux Landlock, macOS sandboxing) or process-level attestation is the +caller's responsibility. + +## 5. Cryptographic Assumptions + +1. **Ed25519 is unforgeable** under known-message attacks (standard + EUF-CMA assumption per RFC 8032). +2. **SHA-256 is collision-resistant** at ~128 bits of work. +3. **JCS canonicalization** produces deterministic output for + semantically equivalent JSON. +4. **The @noble/curves and @noble/hashes libraries** correctly + implement the claimed primitives, verified by third-party audit. + +Any future quantum computer sufficient to break Ed25519 invalidates +signatures created under Ed25519. The verifier's hybrid PQ support +path (`ed25519+ml-dsa-65`) provides forward compatibility. + +## 6. Supply Chain + +The v0.5.0 package is published with: + +- `npm publish --provenance` — Sigstore-attested supply chain +- Sigil commitment in `sigil.json` covering all 15 source files +- `@veritasacta/artifacts` as the single declared dependency +- Transitive dependencies limited to `@noble/curves` and + `@noble/hashes` + +Callers verifying the supply chain should: + +1. Install from npm: `npm install @veritasacta/verify@0.5.0` +2. Verify the npm provenance attestation via `npm audit signatures` + or Sigstore's cosign +3. Run `--self-check` to confirm the installed files match the Sigil +4. Cross-check the Sigil fingerprint against https://veritasacta.com + +## 7. Reporting Issues + +See `SECURITY.md` for the coordinated disclosure policy and contact +address. diff --git a/cli.js b/cli.js new file mode 100644 index 0000000..4836f5d --- /dev/null +++ b/cli.js @@ -0,0 +1,1249 @@ +#!/usr/bin/env node + +/** + * @veritasacta/verify — unified verifier CLI (v0.5.4) + * + * Verifies Ed25519 signed receipts, VOPRF anonymous-credential tokens, + * Knowledge Unit bundles, and selective-disclosure receipts — all offline, + * all under one binary with one Sigil commitment. + * + * Architecture is modular: this file is the entry point + dispatcher. + * Cryptographic verification lives in src/engines/*.js. Output formatting + * lives in src/output/*.js. + * + * Usage: + * npx @veritasacta/verify receipt.json + * npx @veritasacta/verify receipt.json --key + * npx @veritasacta/verify receipt.json --jwks + * npx @veritasacta/verify bundle.json --bundle + * npx @veritasacta/verify ku.json --mode ku + * npx @veritasacta/verify receipt.json --disclose field1,field2:salt:value + * npx @veritasacta/verify receipt.json --require-context sensor:temp<18 + * npx @veritasacta/verify --self-check + * npx @veritasacta/verify --self-test + * npx @veritasacta/verify --capabilities + * + * Exit codes: + * 0 = signature valid (proven authentic) + * 1 = signature invalid (proven tampered) + * 2 = verifier error (malformed input, missing key, unsupported algorithm) + * + * References: + * - draft-farley-acta-signed-receipts-03 + * - draft-farley-acta-knowledge-units-00 + * - AIP-0001, AIP-0002, AIP-0003 + * - Provisional patents #1-5 + * + * @license Apache-2.0 + */ + +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { detectFormat } from './src/detect.js'; +import { verifyReceipt, verifyBundle } from './src/engines/ed25519-receipt.js'; +import { verifyVoprfToken } from './src/engines/voprf-token.js'; +import { verifyKnowledgeUnit } from './src/engines/knowledge-unit.js'; +import { + verifySelectiveDisclosure, + listRedactedFields, +} from './src/engines/selective-disclosure.js'; +import { + verifyCommittedReceipt, + loadDisclosuresFromText, +} from './src/engines/commitment-mode.js'; +import { selfCheck, evaluateLiveContext } from './src/engines/sigil.js'; +import { + buildCanonicalAttestation, + buildVerificationReceipt, +} from './src/engines/attestation.js'; +import { replayChain } from './src/engines/bulk.js'; +import { diffReceipts } from './src/engines/diff.js'; +import { runInit, buildNextSteps } from './src/engines/init.js'; +import { runProxy } from './src/engines/proxy.js'; +import { runDaemon } from './src/engines/daemon.js'; +import { verifyPrompt } from './src/engines/prompt.js'; +import { exploreChain, renderChainTree } from './src/engines/chain-explore.js'; +import { exportCompliance, renderComplianceHTML } from './src/engines/compliance-export.js'; +import { startDashboard } from './src/engines/dashboard.js'; +import { verifyDelegationChain } from './src/engines/delegation.js'; +import { verifyCosignatures } from './src/engines/cosign.js'; +import { parseContextArgs } from './src/context/live-context.js'; +import { detectTier } from './src/conformance.js'; +import { resolveFromJwks } from './src/util/jwks.js'; +import { appendAuditEntry } from './src/util/audit-log.js'; +import { fipsStatus } from './src/util/fips.js'; +import { renderHtmlReport } from './src/output/html-report.js'; +import { getError, exitCodeFor } from './src/errors.js'; + +import { + formatReceiptResult, + formatBundleResult, + formatKuResult, + formatSelfCheckResult, + green, + red, + yellow, + dim, + bold, + teal, + renderTerminalSigil, +} from './src/output/terminal.js'; +import { formatAsJson } from './src/output/json.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PKG = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf-8')); + +const MODE_LABELS = { + 'ed25519-receipt-v1': 'Ed25519 receipt v1 (RFC 8032)', + 'ed25519-receipt-v2': 'Ed25519 receipt v2 (RFC 8032 + draft-farley-acta-signed-receipts)', + 'ed25519-passport': 'Ed25519 Passport envelope (RFC 8032)', + 'ed25519-bundle': 'Ed25519 audit bundle', + 'voprf-token': 'VOPRF token (RFC 9497)', + 'knowledge-unit': 'Knowledge Unit bundle (draft-farley-acta-knowledge-units)', +}; + +// ────────────────────────────────────────────────────────────────── +// CLI argument parsing +// ────────────────────────────────────────────────────────────────── + +function parseArgs() { + const args = process.argv.slice(2); + const opts = { + file: null, + publicKey: null, + jwksUrl: null, + trustAnchor: null, + stdin: false, + mode: 'auto', + bundle: false, + json: false, + help: false, + version: false, + verbose: false, + selfTest: false, + selfCheck: false, + capabilities: false, + allowEmbeddedKey: false, + requireContext: [], + disclose: [], + disclosureFile: null, + tier: null, + strict: false, + noSigil: false, + attest: false, + attestOrg: null, + attestKey: null, + pinSigil: null, + auditLog: null, + replayChain: null, + diff: null, + auditReport: false, + output: null, + emitVerificationReceipt: false, + fips: false, + subcommand: null, + force: false, + proxyTarget: null, + proxyReceiptsDir: null, + daemonSocket: null, + frameworkOverride: null, + }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + const next = () => args[++i]; + switch (arg) { + case '--help': + case '-h': opts.help = true; break; + case '--version': + case '-V': opts.version = true; break; + case '--key': + case '-k': opts.publicKey = next(); break; + case '--jwks': opts.jwksUrl = next(); break; + case '--trust-anchor': opts.trustAnchor = next(); break; + case '--stdin': opts.stdin = true; break; + case '--mode': opts.mode = next(); break; + case '--bundle': opts.bundle = true; break; + case '--json': opts.json = true; break; + case '--verbose': + case '-v': opts.verbose = true; break; + case '--self-test': opts.selfTest = true; break; + case '--self-check': opts.selfCheck = true; break; + case '--capabilities': opts.capabilities = true; break; + case '--allow-embedded-key': opts.allowEmbeddedKey = true; break; + case '--require-context': opts.requireContext.push(next()); break; + case '--disclose': opts.disclose.push(next()); break; + case '--disclosure-file': opts.disclosureFile = next(); break; + case '--tier': opts.tier = Number(next()); break; + case '--strict': opts.strict = true; break; + case '--no-sigil': opts.noSigil = true; break; + case '--attest': opts.attest = true; break; + case '--attest-org': opts.attestOrg = next(); break; + case '--attest-key': opts.attestKey = next(); break; + case '--pin-sigil': opts.pinSigil = next(); break; + case '--audit-log': opts.auditLog = next(); break; + case '--replay-chain': opts.replayChain = next(); break; + case '--diff': opts.diff = next(); break; + case '--audit-report': opts.auditReport = true; break; + case '--output': opts.output = next(); break; + case '--emit-verification-receipt': opts.emitVerificationReceipt = true; break; + case '--fips': opts.fips = true; break; + case '--allow-partial-voprf': opts.allowPartialVoprf = true; break; + case '--force': opts.force = true; break; + case '--target': opts.proxyTarget = next(); break; + case '--framework': opts.frameworkOverride = next(); break; + case '--receipts-dir': opts.proxyReceiptsDir = next(); break; + case '--socket': opts.daemonSocket = next(); break; + case '--prompt-receipt': opts.promptReceipt = next(); break; + case '--sigstore-bundle': opts.sigstoreBundle = next(); break; + case '--expected-hash': opts.expectedHash = next(); break; + case '--search-dir': opts.chainSearchDir = next(); break; + case '--max-depth': opts.chainMaxDepth = Number(next()); break; + case '--profile': opts.profile = next(); break; + case '--start-date': opts.startDate = next(); break; + case '--end-date': opts.endDate = next(); break; + case '--org': opts.organization = next(); break; + case '--port': opts.dashboardPort = Number(next()); break; + case '--bind': opts.dashboardBind = next(); break; + case '--bilateral': opts.proxyBilateral = true; break; + case '--server-key': opts.serverKey = next(); break; + case '--scrub-secrets': opts.scrubSecrets = true; break; + case '--trace-id': opts.traceId = next(); break; + case '--group-by-trace': opts.groupByTrace = true; break; + case 'init': + case 'proxy': + case 'daemon': + case 'prompt': + case 'chain': + case 'compliance': + case 'dashboard': + if (!opts.subcommand) opts.subcommand = arg; + else if (!arg.startsWith('-')) opts.file = arg; + break; + case 'explore': + // `verify chain explore ` → subcommand stays "chain", + // sub-subcommand captured below + if (opts.subcommand === 'chain') opts.chainVerb = arg; + else if (!arg.startsWith('-')) opts.file = arg; + break; + default: + if (!arg.startsWith('-')) opts.file = arg; + // unknown flags silently ignored (forward-compat) + } + } + return opts; +} + +function printHelp() { + console.log(` +${bold('@veritasacta/verify')} ${PKG.version} — unified verifier for signed receipts, VOPRF tokens, and Knowledge Units + +${bold('Usage:')} + npx @veritasacta/verify Auto-detect format, verify + npx @veritasacta/verify --key Provide verification key + npx @veritasacta/verify --jwks Fetch key from JWKS + npx @veritasacta/verify --mode receipt|voprf|ku + npx @veritasacta/verify --bundle Verify audit bundle + cat receipt.json | npx @veritasacta/verify --stdin Read from stdin + npx @veritasacta/verify --json Machine-readable output + +${bold('Selective disclosure (AIP-0002 legacy):')} + --disclose field:salt:value Reveal a field and verify its commitment + +${bold('Commitment-mode disclosure (draft-farley-acta-signed-receipts-01):')} + --disclosure-file Verify Merkle inclusion proofs against committed_fields_root + +${bold('Live-context verification (Sigil claim 2):')} + --require-context clock:±5s + --require-context geofence:inside: + --require-context sensor:temp<18 + +${bold('Subcommands:')} + init Zero-config onboarding wizard (detects framework, generates keys) + proxy --target "" Wrap an MCP server; sign every tools/call transparently + daemon Sidecar daemon: sign receipts over a unix socket (any language) + prompt Verify a prompt/skill/system-instruction file's provenance + [--prompt-receipt | --sigstore-bundle | --expected-hash ] + chain explore Walk a receipt chain to its root; verify every hash link + [--search-dir ] [--max-depth N] [--json] + compliance SOC 2 / ISO 42001 / EU AI Act evidence bundle from a receipt directory + --receipts-dir [--framework soc2|iso42001|eu-ai-act|all] + [--start-date ISO] [--end-date ISO] [--org ""] [--output audit.html|bundle.json] + dashboard Start local audit dashboard server (loopback only, no telemetry) + [--port 3847] [--bind 127.0.0.1] [--receipts-dir ] + proxy --target "" Already-present wrap-and-sign proxy. New flags: + [--bilateral --server-key ] Cosign every receipt with a second key + [--scrub-secrets] Redact api_key/token/etc in outgoing args and flag on receipt + [--trace-id ] Stamp a workflow trace_id on every receipt + +${bold('Options:')} + --key, -k Ed25519 public key (64 hex chars) + --jwks JWKS endpoint to fetch signing key + --trust-anchor Local trust-anchor JSON with public keys + --mode Force mode: receipt|voprf|ku|auto (default: auto) + --bundle Verify as audit bundle + --stdin Read input from stdin + --json Output JSON + --verbose, -v Detailed verification info + --tier N Require minimum conformance tier (1-5) + --pin-sigil Require installed Sigil fingerprint to match + --strict Disable all deprecated fallbacks + --fips Enforce FIPS-approved algorithms only + --audit-log Append verification event to a local JSONL audit log + --self-test Verify bundled sample artifacts + --self-check Prove this verifier is the canonical release + --capabilities List supported modes/algorithms/tiers + --allow-embedded-key DEPRECATED. Accept keys embedded in payloads. + Removed in v0.6.0. + --allow-partial-voprf Treat a partial (structural-only) VOPRF result + as valid. Default fails closed (exit 2). + Full DLEQ verification lands in v0.6.0. + --no-sigil Suppress Sigil art in terminal output + --help, -h + --version, -V + +${bold('Bulk / replay / diff:')} + --replay-chain Verify every receipt in a JSONL chain file + --diff Show structural diff between two receipts + +${bold('Attestation and audit:')} + --attest Emit a canonical verifier attestation (signed) + --attest-org Include this org name in the attestation + --attest-key Override attester key location + (default: ~/.veritasacta-verify/attester.json) + --emit-verification-receipt + Emit a signed verification receipt for the subject + --audit-report Render an HTML audit report + --output Write output to a file (HTML reports, attestations) + +${bold('Supported formats:')} + v1 artifacts, v2 artifacts, Passport envelopes, audit bundles, + VOPRF tokens, Knowledge Unit bundles, selective-disclosure receipts. + +${bold('Exit codes:')} + 0 Valid — proven authentic + 1 Invalid — proven tampered + 2 Undecidable — malformed input, missing key, unsupported algorithm, + or --tier requirement not achieved + +${bold('Standards:')} + RFC 8032 (Ed25519), RFC 8785 (JCS), RFC 9497 (VOPRF), + RFC 7517/7638 (JWK / thumbprint), + draft-farley-acta-signed-receipts-03, + draft-farley-acta-knowledge-units-00, + AIP-0001, AIP-0002, AIP-0003. + +${bold('Protocol:')} https://veritasacta.com (open, Apache-2.0) +${bold('Managed:')} https://scopeblind.com (optional, commercial) +`); +} + +function printCapabilities() { + console.log(JSON.stringify({ + package: PKG.name, + version: PKG.version, + modes: Object.keys(MODE_LABELS), + algorithms: ['ed25519', 'EdDSA', 'voprf-p256-sha256'], + unsupported_but_recognized: ['ed25519+ml-dsa-65', 'ed25519+dilithium3'], + tiers: [1, 2, 3, 4], + max_tier_v0_5_0: 4, + features: [ + 'ed25519-receipt-verification', + 'voprf-token-structural-verification', + 'knowledge-unit-bundle-verification', + 'selective-disclosure-aip-0002', + 'sigil-self-check-claim-1', + 'sigil-live-context-claim-2', + 'conformance-tier-detection', + 'embedded-key-rejection', + ], + specs: [ + 'RFC 8032', 'RFC 8785', 'RFC 9497', 'RFC 7517', 'RFC 7638', + 'draft-farley-acta-signed-receipts-03', + 'draft-farley-acta-knowledge-units-00', + 'AIP-0001', 'AIP-0002', 'AIP-0003', + ], + wayfinding: { + protocol: 'https://veritasacta.com', + managed: 'https://scopeblind.com', + }, + }, null, 2)); +} + +// ────────────────────────────────────────────────────────────────── +// Self-check: prove this binary is the canonical unmodified release +// ────────────────────────────────────────────────────────────────── + +async function runSelfCheck() { + const sigilPath = join(__dirname, 'sigil.json'); + let sigil; + try { + sigil = JSON.parse(readFileSync(sigilPath, 'utf-8')); + } catch { + console.log(`\n ${red('✗')} No sigil.json found — this verifier has no Sigil commitment.`); + console.log(` This may be a development build or a fork.\n`); + process.exit(2); + } + + // Use the shared monitored file list (keeps runSelfCheck and + // selfCheckResult in sync). + const bufs = []; + for (const f of MONITORED_FILES_FOR_SIGIL) { + try { bufs.push(readFileSync(join(__dirname, f))); } catch { /* missing */ } + } + const installedSourceBytes = Buffer.concat(bufs); + + const r = selfCheck({ sigil, installedSourceBytes }); + r.projectPublicKey = sigil.project_public_key; + console.log(bold('@veritasacta/verify — self-check')); + console.log(formatSelfCheckResult(r)); + process.exit(r.canonical ? 0 : 1); +} + +// ────────────────────────────────────────────────────────────────── +// Self-test: verify bundled samples +// ────────────────────────────────────────────────────────────────── + +async function runSelfTest(opts) { + const samplesDir = join(__dirname, 'samples'); + const testKey = 'd75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a'; + let allPassed = true; + + console.log(`\n${bold('@veritasacta/verify — self-test')}\n`); + if (!process.env.CI && !process.env.NO_COLOR) { + console.log(renderTerminalSigil(testKey)); + console.log(''); + } + + try { + const receipt = JSON.parse(readFileSync(join(samplesDir, 'sample-receipt.json'), 'utf-8')); + const detected = detectFormat(receipt); + const r = await verifyReceipt(receipt, detected.mode, { publicKey: testKey }); + if (r.valid) { + console.log(` ${green('✓')} Sample receipt: ${green('VALID')} (${r.type}, kid: ${r.kid || 'n/a'})`); + } else { + console.log(` ${red('✗')} Sample receipt: ${red('INVALID')} (${r.error})`); + allPassed = false; + } + } catch (e) { + console.log(` ${red('✗')} Sample receipt: ${red(e.message)}`); + allPassed = false; + } + + try { + const bundle = JSON.parse(readFileSync(join(samplesDir, 'sample-bundle.json'), 'utf-8')); + const r = await verifyBundle(bundle, { publicKey: testKey }); + if (r.valid) { + console.log(` ${green('✓')} Sample bundle: ${green('VALID')} (${r.passed}/${r.total} receipts)`); + } else { + console.log(` ${red('✗')} Sample bundle: ${red('INVALID')} (${r.failed} failed)`); + allPassed = false; + } + } catch (e) { + console.log(` ${red('✗')} Sample bundle: ${red(e.message)}`); + allPassed = false; + } + + console.log(''); + if (allPassed) { + console.log(` ${green('All self-tests passed.')} The verifier is working correctly.`); + console.log(` ${dim('No ScopeBlind servers were contacted. No accounts required.')}`); + } else { + console.log(` ${red('Some self-tests failed.')} Check the output above.`); + } + console.log(''); + process.exit(allPassed ? 0 : 1); +} + +// ────────────────────────────────────────────────────────────────── +// Dispatch +// ────────────────────────────────────────────────────────────────── + +async function readInput(opts) { + if (opts.stdin) { + const chunks = []; + for await (const chunk of process.stdin) chunks.push(chunk); + return Buffer.concat(chunks).toString('utf-8'); + } + if (opts.file) return readFileSync(opts.file, 'utf-8'); + return null; +} + +async function dispatch(input, opts) { + // Forced-mode overrides + let detected = detectFormat(input); + if (opts.mode && opts.mode !== 'auto') { + const forced = opts.mode.toLowerCase(); + if (forced === 'receipt') detected.mode = 'ed25519-passport'; + else if (forced === 'voprf') detected.mode = 'voprf-token'; + else if (forced === 'ku') detected.mode = 'knowledge-unit'; + else if (forced === 'bundle') detected.mode = 'ed25519-bundle'; + } + if (opts.bundle) detected.mode = 'ed25519-bundle'; + + // Resolve JWKS if needed + let publicKey = opts.publicKey; + let keySource = publicKey ? 'provided' : null; + if (!publicKey && opts.jwksUrl) { + const kid = input.kid || input.signature?.kid; + const resolved = await resolveFromJwks(opts.jwksUrl, kid); + if (resolved.key) { + publicKey = resolved.key; + keySource = resolved.source?.resolved ? `jwks:${resolved.source.resolved}` : 'jwks'; + } else { + const result = { + valid: false, + error: 'jwks_fetch_failed', + format: detected.mode, + detail: resolved.error, + }; + return result; + } + } + + const subOpts = { ...opts, publicKey }; + + // Route to engine + switch (detected.mode) { + case 'ed25519-bundle': { + return await verifyBundle(input, subOpts); + } + case 'knowledge-unit': { + const r = await verifyKnowledgeUnit(input, subOpts); + const tier = detectTier({ mode: 'knowledge-unit', payloadFields: {} }); + return { ...r, tier }; + } + case 'voprf-token': { + const r = await verifyVoprfToken(input, subOpts); + const tier = detectTier({ + mode: 'voprf-token', + payloadFields: { + transport_hint: r.transport_hint, + }, + voprfVerified: r.valid, + }); + return { ...r, tier, modeLabel: MODE_LABELS['voprf-token'] }; + } + case 'ed25519-receipt-v1': + case 'ed25519-receipt-v2': + case 'ed25519-passport': { + const r = await verifyReceipt(input, detected.mode, subOpts); + + // Selective disclosure: attach if commitments present. + // Two formats supported: + // - Legacy AIP-0002 (_commitments map + --disclose field:salt:value) + // - draft-farley-acta-signed-receipts-01 commitment-mode + // (committed_fields_root + --disclosure-file path) + let disclosureResult = null; + const hasCommittedFieldsRoot = detected.signals?.includes('committed_fields_root'); + const hasLegacyCommitments = detected.signals?.includes('_commitments'); + + if (hasCommittedFieldsRoot) { + // draft-01 commitment-mode path + let disclosures = []; + if (opts.disclosureFile) { + try { + const fs = await import('node:fs'); + const text = fs.readFileSync(opts.disclosureFile, 'utf8'); + disclosures = loadDisclosuresFromText(text); + } catch (err) { + r.valid = false; + r.error = `disclosure_file_load_failed: ${err?.message ?? 'unknown'}`; + } + } + if (r.valid !== false) { + disclosureResult = verifyCommittedReceipt(input, disclosures); + r.committedFieldsRoot = input.committed_fields_root; + r.committedFieldNames = input.committed_field_names; + r.disclosedFields = disclosures.map((d) => d.name); + if (!disclosureResult.valid) { + r.valid = false; + r.error = disclosureResult.error || 'commitment_mismatch'; + } + } + } else if (hasLegacyCommitments) { + // Legacy AIP-0002 path + const disclosures = parseDisclosures(opts.disclose); + disclosureResult = verifySelectiveDisclosure(input, disclosures); + r.redactedFields = listRedactedFields(input); + r.disclosedFields = disclosures.map((d) => d.field); + if (!disclosureResult.valid) { + r.valid = false; + r.error = 'commitment_mismatch'; + } + } + + // Live-context predicates + if (opts.requireContext.length > 0) { + const preds = parseContextArgs(opts.requireContext); + const ctx = await evaluateLiveContext(preds); + r.contextChecks = ctx.checks; + if (!ctx.allSatisfied) { + r.valid = false; + r.error = r.error || 'context_requirement_unmet'; + } + } + + // Surface attestation_mode as dedicated output field + if (r.payloadFields?.attestation_mode) { + r.attestationMode = r.payloadFields.attestation_mode; + } + if (r.payloadFields?.scope) r.scope = r.payloadFields.scope; + if (r.payloadFields?.nullifier) r.nullifier = r.payloadFields.nullifier; + if (r.payloadFields?.transport_hint) r.transport_hint = r.payloadFields.transport_hint; + + const tier = detectTier({ + mode: detected.mode, + payloadFields: r.payloadFields, + disclosuresVerified: disclosureResult?.disclosuresVerified || 0, + }); + r.tier = tier; + r.modeLabel = MODE_LABELS[detected.mode]; + r.specVersion = 'draft-farley-acta-signed-receipts-03'; + return r; + } + default: + return { + valid: false, + error: 'unknown_format', + format: 'unknown', + detail: `Could not detect format. Signals: ${detected.signals.join(', ') || 'none'}`, + }; + } +} + +/** + * Parse --disclose arguments into disclosure packages. + * Format: "field.path:salt_hex:json_value" (value is JSON-parsed) + */ +function parseDisclosures(args) { + const packages = []; + for (const a of args) { + const firstColon = a.indexOf(':'); + const secondColon = a.indexOf(':', firstColon + 1); + if (firstColon < 0 || secondColon < 0) continue; + const field = a.slice(0, firstColon); + const salt = a.slice(firstColon + 1, secondColon); + const raw = a.slice(secondColon + 1); + let value; + try { value = JSON.parse(raw); } catch { value = raw; } + packages.push({ field, salt, value }); + } + return packages; +} + +// ────────────────────────────────────────────────────────────────── +// Main +// ────────────────────────────────────────────────────────────────── + +function applyTierGate(result, minTier) { + if (!minTier) return result; + const achieved = result.tier?.tier || 1; + if (achieved < minTier) { + return { + ...result, + valid: false, + error: 'tier_not_achieved', + detail: `Required tier T${minTier}, achieved T${achieved}`, + }; + } + return result; +} + +/** + * Load and parse the Sigil commitment from disk. + * Used by attestation and report generators. + */ +function loadSigil() { + try { + return JSON.parse(readFileSync(join(__dirname, 'sigil.json'), 'utf-8')); + } catch { + return null; + } +} + +/** + * --pin-sigil: require the installed Sigil fingerprint to match a + * specified value. Fails early if mismatch. + */ +function enforcePinSigil(expected) { + const sigil = loadSigil(); + if (!sigil) { + console.error(red('--pin-sigil requires a sigil.json; none found.')); + process.exit(2); + } + if (sigil.fingerprint !== expected) { + console.error(red(`--pin-sigil: mismatch (expected ${expected}, got ${sigil.fingerprint})`)); + console.error(yellow(`Installed verifier is "${sigil.name}" (${sigil.fingerprint}); expected fingerprint ${expected}.`)); + console.error(dim(`Ensure you have installed the expected release: npm install @veritasacta/verify@`)); + process.exit(2); + } +} + +/** + * --replay-chain FILE: verify an entire JSONL chain. + */ +async function runReplayChain(opts) { + const sigil = loadSigil(); + const result = await replayChain(opts.replayChain, opts); + + if (opts.auditReport) { + let attestation = null; + if (opts.attest) { + const r = selfCheckResult(sigil); + attestation = buildCanonicalAttestation({ + sigil, canonical: r.canonical, org: opts.attestOrg, keyPath: opts.attestKey, + }); + } + const html = renderHtmlReport({ result, sigil, attestation, title: 'Veritas Acta chain-replay audit report' }); + if (opts.output) { + writeFileSync(opts.output, html); + console.log(dim(`wrote ${opts.output}`)); + } else { + console.log(html); + } + process.exit(result.valid ? 0 : 1); + } + + if (opts.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(`\n${bold('Chain replay result')}`); + console.log(` Total: ${result.total}`); + console.log(` Verified: ${green(String(result.verified))}`); + console.log(` Failed: ${result.failed > 0 ? red(String(result.failed)) : '0'}`); + console.log(` Chain breaks: ${result.chainBreaks > 0 ? red(String(result.chainBreaks)) : '0'}`); + if (result.errors.length > 0) { + console.log(`\n ${red('Errors:')}`); + for (const e of result.errors.slice(0, 20)) console.log(` ${red('•')} ${e}`); + if (result.errors.length > 20) console.log(` ${dim(`... ${result.errors.length - 20} more`)}`); + } + console.log(''); + } + process.exit(result.valid ? 0 : 1); +} + +/** + * --diff A B: show structural differences between two receipts. + */ +async function runDiff(opts) { + const a = JSON.parse(await readInput(opts) || '{}'); + const b = JSON.parse(readFileSync(opts.diff, 'utf-8')); + const d = diffReceipts(a, b); + if (opts.json) { + console.log(JSON.stringify(d, null, 2)); + } else { + console.log(`\n${bold('Receipt diff')}`); + console.log(` Canonical hash A: ${dim(d.canonical_hash_a)}`); + console.log(` Canonical hash B: ${dim(d.canonical_hash_b)}`); + console.log(` Payload identical: ${d.hash_equal ? green('yes') : red('no')}`); + console.log(` Signature identical: ${d.signature_equal ? green('yes') : red('no')}`); + if (d.added.length > 0) console.log(`\n ${green('Added:')} ${d.added.join(', ')}`); + if (d.removed.length > 0) console.log(` ${red('Removed:')} ${d.removed.join(', ')}`); + if (d.changed.length > 0) { + console.log(` ${yellow('Changed:')}`); + for (const c of d.changed) { + console.log(` ${c.field}: ${dim(JSON.stringify(c.before))} -> ${dim(JSON.stringify(c.after))}`); + } + } + console.log(''); + } + process.exit(d.hash_equal && d.signature_equal ? 0 : 1); +} + +/** + * verify prompt [--prompt-receipt | --sigstore-bundle | --expected-hash ] + * + * Verify the provenance of a prompt / instruction file against one of + * three sources. Closes the CLAUDE.md / SKILLS.md supply-chain gap. + */ +async function runPromptVerify(opts) { + if (!opts.file) { + console.error(red(' verify prompt requires a file path to the prompt')); + console.error(dim(' usage: verify prompt [--prompt-receipt | --sigstore-bundle | --expected-hash ]')); + process.exit(2); + } + const result = await verifyPrompt({ + promptPath: opts.file, + receiptPath: opts.promptReceipt, + sigstoreBundle: opts.sigstoreBundle, + expectedHash: opts.expectedHash, + }); + + if (opts.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(''); + console.log(`${bold('Prompt verification')}`); + console.log(` File: ${result.prompt_path}`); + console.log(` Hash: ${dim(result.prompt_hash)}`); + if (result.source) console.log(` Source: ${result.source}`); + if (result.expected_hash) { + const eq = result.expected_hash === result.prompt_hash; + console.log(` Expected: ${dim(result.expected_hash)} ${eq ? green('match') : red('MISMATCH')}`); + } + if (result.receipt_summary) { + console.log(` Receipt: ${dim(JSON.stringify(result.receipt_summary))}`); + } + if (result.bundle_summary) { + console.log(` Bundle: ${dim(JSON.stringify(result.bundle_summary))}`); + } + if (result.valid) { + console.log(` ${green('\u2713')} prompt matches the asserted provenance`); + } else { + console.log(` ${red('\u2717')} ${result.error || 'verification failed'}`); + } + console.log(''); + } + process.exit(result.valid ? 0 : 1); +} + +/** + * verify chain explore [--search-dir ] [--max-depth N] + * + * Walk the causal ancestry of a receipt via previousReceiptHash and + * validate every hash link along the way. Surfaces cryptographic + * causal integrity as a concrete operation. + */ +async function runChainExplore(opts) { + if (opts.chainVerb !== 'explore') { + console.error(red(` unknown chain subcommand: ${opts.chainVerb || '(none)'}`)); + console.error(dim(' usage: verify chain explore [--search-dir ] [--max-depth N]')); + process.exit(2); + } + if (!opts.file) { + console.error(red(' verify chain explore requires a receipt file path')); + process.exit(2); + } + const result = await exploreChain({ + receiptPath: opts.file, + searchDir: opts.chainSearchDir, + maxDepth: opts.chainMaxDepth, + }); + + if (opts.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(renderChainTree(result)); + } + process.exit(result.valid ? 0 : 1); +} + +/** + * verify compliance [--framework soc2|iso42001|eu-ai-act|all] + * --receipts-dir [--start-date ISO] [--end-date ISO] + * [--org ""] [--output ] [--json] + * + * Produce a compliance evidence bundle: control-mapped receipt + * summaries, HTML audit report suitable for auditors. + */ +async function runCompliance(opts) { + const dir = opts.proxyReceiptsDir || opts.receiptsDir || opts.file; + if (!dir) { + console.error(red(' verify compliance requires --receipts-dir ')); + console.error(dim(' usage: verify compliance --receipts-dir [--framework soc2|iso42001|eu-ai-act|all]')); + console.error(dim(' [--start-date ISO] [--end-date ISO] [--org ""] [--output ] [--json]')); + process.exit(2); + } + let result; + try { + result = exportCompliance({ + receiptsDir: dir, + framework: opts.frameworkOverride || 'all', + startDate: opts.startDate, + endDate: opts.endDate, + organizationName: opts.organization, + }); + } catch (err) { + console.error(red(` compliance export failed: ${err.message}`)); + process.exit(2); + } + + if (opts.output) { + const isHtml = opts.output.endsWith('.html'); + const body = isHtml + ? renderComplianceHTML(result) + : JSON.stringify(result, null, 2); + writeFileSync(opts.output, body); + console.log(green(` ✓ Compliance bundle written to ${opts.output}`)); + console.log(dim(` Receipts scanned: ${result.manifest.receipts_scanned} / in window: ${result.manifest.receipts_in_window}`)); + for (const [fwId, fw] of Object.entries(result.manifest.frameworks)) { + const evidenced = Object.values(fw.controls).filter((c) => c.evidence_count > 0).length; + const total = Object.keys(fw.controls).length; + console.log(dim(` ${fw.framework}: ${evidenced}/${total} controls evidenced`)); + } + return; + } + + if (opts.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + + // Terminal rendering + console.log(''); + console.log(bold('Compliance evidence bundle')); + console.log(` Organization: ${result.manifest.organization}`); + console.log(` Receipts: ${result.manifest.receipts_scanned} scanned, ${result.manifest.receipts_in_window} in window`); + if (result.manifest.window.start || result.manifest.window.end) { + console.log(` Window: ${result.manifest.window.start || '(begin)'} → ${result.manifest.window.end || '(end)'}`); + } + for (const [, fw] of Object.entries(result.manifest.frameworks)) { + console.log(''); + console.log(bold(` ${fw.framework}`)); + for (const [ctrlId, ctrl] of Object.entries(fw.controls)) { + const mark = ctrl.evidence_count > 0 ? green('●') : dim('○'); + console.log(` ${mark} ${ctrlId} ${ctrl.name} ${dim(`(${ctrl.evidence_count})`)}`); + } + } + if (result.warnings.length) { + console.log(''); + console.log(yellow(' Warnings:')); + for (const w of result.warnings) console.log(` • ${w}`); + } + console.log(''); +} + +/** + * verify dashboard [--port 3847] [--bind 127.0.0.1] [--receipts-dir ] + * + * Spin up the local dashboard server. Opens a browser tab to it. + * Binds loopback only. + */ +async function runDashboard(opts) { + const receiptsDir = opts.proxyReceiptsDir || opts.receiptsDir; + let dash; + try { + dash = await startDashboard({ + port: opts.dashboardPort || 3847, + bind: opts.dashboardBind || '127.0.0.1', + receiptsDir, + }); + } catch (err) { + console.error(red(` dashboard failed to start: ${err.message}`)); + process.exit(2); + } + console.log(''); + console.log(bold(' Veritas Acta dashboard')); + console.log(` Serving at: ${dash.url}`); + if (receiptsDir) { + console.log(` Receipts: ${receiptsDir}`); + } else { + console.log(dim(` Receipts: (none — paste JSON or drop files in the UI)`)); + } + console.log(dim(' Loopback-only. No TLS, no auth, no telemetry.')); + console.log(dim(' Press Ctrl+C to stop.')); + console.log(''); + + // Stay alive until interrupted. + await new Promise(() => {}); +} + +/** + * Canonical list of files committed by the Sigil. + * MUST match generate-sigil.mjs's MONITORED_FILES exactly. + */ +const MONITORED_FILES_FOR_SIGIL = [ + 'cli.js', + 'src/detect.js', + 'src/conformance.js', + 'src/errors.js', + 'src/engines/ed25519-receipt.js', + 'src/engines/voprf-token.js', + 'src/engines/knowledge-unit.js', + 'src/engines/selective-disclosure.js', + 'src/engines/sigil.js', + 'src/engines/attestation.js', + 'src/engines/bulk.js', + 'src/engines/diff.js', + 'src/engines/init.js', + 'src/engines/proxy.js', + 'src/engines/daemon.js', + 'src/engines/prompt.js', + 'src/engines/chain-explore.js', + 'src/engines/compliance-export.js', + 'src/engines/dsse.js', + 'src/engines/delegation.js', + 'src/engines/cosign.js', + 'src/engines/dashboard.js', + 'src/engines/rekor.js', + 'src/engines/attestation-quote.js', + 'src/engines/watch.js', + 'src/engines/sbom.js', + 'src/engines/transparency.js', + 'src/context/live-context.js', + 'src/output/terminal.js', + 'src/output/json.js', + 'src/output/html-report.js', + 'src/util/canonical.js', + 'src/util/hex.js', + 'src/util/jwks.js', + 'src/util/audit-log.js', + 'src/util/fips.js', + 'src/util/voprf-crypto.js', + 'src/util/voprf-crypto-v2.js', +]; + +function selfCheckResult(sigil) { + if (!sigil) return { canonical: false }; + const bufs = []; + for (const f of MONITORED_FILES_FOR_SIGIL) { + try { bufs.push(readFileSync(join(__dirname, f))); } catch { /* missing => modified */ } + } + const installedSourceBytes = Buffer.concat(bufs); + return selfCheck({ sigil, installedSourceBytes }); +} + +async function main() { + const opts = parseArgs(); + + if (opts.help) { printHelp(); process.exit(0); } + if (opts.version) { console.log(PKG.version); process.exit(0); } + if (opts.capabilities) { printCapabilities(); process.exit(0); } + if (opts.pinSigil) enforcePinSigil(opts.pinSigil); + + // Subcommands + if (opts.subcommand === 'init') { + const result = await runInit({ framework: opts.frameworkOverride, org: opts.attestOrg, force: opts.force }); + if (result.status === 'exists') { + console.error(yellow(result.message)); + process.exit(1); + } + console.log(''); + if (!process.env.CI && !process.env.NO_COLOR) { + console.log(renderTerminalSigil(result.key.pubHex)); + console.log(''); + } + console.log(`${green('✓')} ${bold('Veritas Acta initialized')}`); + console.log(` Directory: ${result.vaDir}`); + console.log(` Kid: ${result.key.kid}`); + console.log(` Pubkey: ${result.key.pubHex}`); + if (result.detection) { + console.log(` Framework: ${green(result.detection.framework)} (${result.detection.language})`); + } else { + console.log(` Framework: ${yellow('not auto-detected')} — see manual setup below`); + } + console.log(''); + console.log(bold('Next steps:')); + for (const line of buildNextSteps(result.detection, result.config)) { + console.log(` ${line}`); + } + console.log(''); + console.log(dim(`Protocol: https://veritasacta.com`)); + console.log(dim(`Managed: https://scopeblind.com (optional)`)); + console.log(''); + process.exit(0); + } + + if (opts.subcommand === 'proxy') { + const keyPath = opts.attestKey || join(process.cwd(), '.veritasacta', 'attester.json'); + const code = await runProxy({ + target: opts.proxyTarget, + key: keyPath, + receiptsDir: opts.proxyReceiptsDir, + }); + process.exit(code); + } + + if (opts.subcommand === 'daemon') { + await runDaemon({ + socket: opts.daemonSocket, + key: opts.attestKey, + receiptsDir: opts.proxyReceiptsDir, + }); + return; + } + + if (opts.subcommand === 'prompt') { + await runPromptVerify(opts); + return; + } + + if (opts.subcommand === 'chain') { + await runChainExplore(opts); + return; + } + + if (opts.subcommand === 'compliance') { + await runCompliance(opts); + return; + } + + if (opts.subcommand === 'dashboard') { + await runDashboard(opts); + return; + } + + if (opts.selfCheck) { await runSelfCheck(); return; } + if (opts.selfTest) { await runSelfTest(opts); return; } + if (opts.replayChain) { await runReplayChain(opts); return; } + if (opts.diff) { await runDiff(opts); return; } + + // --attest without a file: emit a standalone canonical attestation + if (opts.attest && !opts.file && !opts.stdin) { + const sigil = loadSigil(); + const r = selfCheckResult(sigil); + const att = buildCanonicalAttestation({ + sigil, canonical: r.canonical, org: opts.attestOrg, keyPath: opts.attestKey, + }); + const output = JSON.stringify(att, null, 2); + if (opts.output) { writeFileSync(opts.output, output); console.error(dim(`wrote ${opts.output}`)); } + else console.log(output); + process.exit(0); + } + + const raw = await readInput(opts); + if (raw === null) { + console.error(red('Error: no input file specified.')); + console.error('Usage: npx @veritasacta/verify [--key ]'); + process.exit(2); + } + + let input; + try { + input = JSON.parse(raw); + } catch (e) { + console.error(red(`Error: invalid JSON: ${e.message}`)); + process.exit(2); + } + + if (opts.strict && opts.allowEmbeddedKey) { + console.error(yellow('Warning: --strict overrides --allow-embedded-key (embedded keys always rejected in strict mode).')); + opts.allowEmbeddedKey = false; + } + + // FIPS enforcement (if set, checks algorithm compliance before verify) + if (opts.fips) { + const claimedAlgo = input.algorithm || input.signature?.alg || 'ed25519'; + const f = fipsStatus(claimedAlgo); + if (!f.approved) { + const result = { + valid: false, + error: 'unsupported_algorithm', + detail: `FIPS mode: ${f.reason}`, + format: 'fips-rejected', + }; + if (opts.json) console.log(JSON.stringify(result, null, 2)); + else { + console.error(red(`✗ FIPS mode rejects algorithm "${claimedAlgo}"`)); + console.error(yellow(f.reason)); + } + process.exit(2); + } + } + + let result = await dispatch(input, opts); + result = applyTierGate(result, opts.tier); + + // Attach error metadata + if (result.error) { + const meta = getError(result.error); + if (meta) result.errorMeta = meta; + } + + // Audit log + if (opts.auditLog) { + const sigil = loadSigil(); + try { + appendAuditEntry(opts.auditLog, result, { sigil, org: opts.attestOrg }); + } catch (e) { + console.error(yellow(`Warning: failed to append audit log: ${e.message}`)); + } + } + + // Audit report (HTML) + if (opts.auditReport) { + const sigil = loadSigil(); + let attestation = null; + if (opts.attest) { + const r = selfCheckResult(sigil); + attestation = buildCanonicalAttestation({ + sigil, canonical: r.canonical, org: opts.attestOrg, keyPath: opts.attestKey, + }); + } + const html = renderHtmlReport({ result, sigil, attestation }); + if (opts.output) { + writeFileSync(opts.output, html); + console.error(dim(`wrote ${opts.output}`)); + } else { + console.log(html); + } + process.exit(result.valid ? 0 : exitCodeFor(result.error)); + } + + // Output + if (opts.json) { + const obj = JSON.parse(formatAsJson(result)); + // Optionally attach emitted artifacts + if (opts.attest) { + const sigil = loadSigil(); + const r = selfCheckResult(sigil); + obj.canonical_attestation = buildCanonicalAttestation({ + sigil, canonical: r.canonical, org: opts.attestOrg, keyPath: opts.attestKey, + }); + } + if (opts.emitVerificationReceipt && result.valid) { + const sigil = loadSigil(); + obj.verification_receipt = buildVerificationReceipt({ + subjectResult: result, sigil, keyPath: opts.attestKey, + }); + } + console.log(JSON.stringify(obj, null, 2)); + } else { + if (result.format === 'knowledge-unit') console.log(formatKuResult(result, opts)); + else if (result.total !== undefined) console.log(formatBundleResult(result, opts)); + else console.log(formatReceiptResult(result, opts)); + + // Surface attestation artifacts separately for terminal mode + if (opts.attest) { + const sigil = loadSigil(); + const r = selfCheckResult(sigil); + const att = buildCanonicalAttestation({ + sigil, canonical: r.canonical, org: opts.attestOrg, keyPath: opts.attestKey, + }); + if (opts.output) { + writeFileSync(opts.output, JSON.stringify(att, null, 2)); + console.error(dim(`wrote canonical attestation to ${opts.output}`)); + } else { + console.log(`${bold('Canonical attestation:')}`); + console.log(JSON.stringify(att, null, 2)); + console.log(''); + } + } + if (opts.emitVerificationReceipt && result.valid) { + const sigil = loadSigil(); + const vr = buildVerificationReceipt({ subjectResult: result, sigil, keyPath: opts.attestKey }); + console.log(`${bold('Verification receipt:')}`); + console.log(JSON.stringify(vr, null, 2)); + console.log(''); + } + } + + // Exit. A result flagged `_partial: true` has NOT been fully + // cryptographically verified (e.g. VOPRF tokens whose DLEQ proof + // check is structural-only in this release). By default we fail + // closed with exit code 2 (undecidable). Callers who explicitly + // want the partial result surfaced as "valid" can opt in with + // --allow-partial-voprf. + if (result.valid) { + if (result._partial && !opts.allowPartialVoprf) { + if (!opts.json) { + console.error(red( + '\n Exit 2: partial verification is not a full cryptographic check.\n' + + ' Pass --allow-partial-voprf to treat a partial result as valid.\n' + )); + } + process.exit(2); + } + process.exit(0); + } + process.exit(exitCodeFor(result.error)); +} + +main().catch((e) => { + console.error(red(`Fatal error: ${e.message}`)); + if (process.env.DEBUG) console.error(e.stack); + process.exit(2); +}); diff --git a/ecosystem/CONFORMANCE-CERTIFICATION.md b/ecosystem/CONFORMANCE-CERTIFICATION.md new file mode 100644 index 0000000..9ad3538 --- /dev/null +++ b/ecosystem/CONFORMANCE-CERTIFICATION.md @@ -0,0 +1,117 @@ +# Conformance Certification — Commercial Service Design + +Draft spec for a commercial conformance certification service. Not an +open-source artifact; this document is internal product spec for the +ScopeBlind managed tier. + +## Problem + +Enterprise and regulated-industry buyers increasingly ask "is this +implementation verified?" Self-certification via the open conformance +test suite is acceptable for most; some buyers require independent +certification. + +## Service outline + +**Customer:** an implementer (or consumer of an implementation) who +needs formal third-party attestation that a specific release of an +implementation passes conformance. + +**Workflow:** + +1. Customer submits an implementation release (artifact hash + repo + link) for certification. +2. ScopeBlind runs the full conformance suite, including negative + vectors, against the submitted release. +3. ScopeBlind manually reviews: dependency tree, threat model, test + coverage, cryptographic correctness spot-checks, build + reproducibility. +4. ScopeBlind issues a signed "Conformance Certification" attestation + valid for 12 months, naming the implementation, tier, and review + summary. +5. Certification is anchored in the public registry with a link to + the signed attestation (the attestation itself remains with the + customer; only the hash is public). + +## Certification attestation format + +```json +{ + "type": "veritasacta:conformance-certification", + "spec": "draft-farley-acta-signed-receipts-03", + "certified_implementation": { + "name": "acme-runtime", + "repo": "https://github.com/acme/acme-runtime", + "version": "1.2.0", + "artifact_hash": "sha256:abc123...", + "claimed_tier": 4 + }, + "certification": { + "tier_verified": 4, + "review_summary": "ACTA-CC-2026-0008", + "review_methodology": "Full conformance suite + manual cryptographic spot-check + dependency audit + threat model review", + "issued_at": "2026-07-15T12:00:00.000Z", + "expires_at": "2027-07-15T12:00:00.000Z", + "issuing_authority": "ScopeBlind (Veritas Acta)" + }, + "signature": { + "alg": "EdDSA", + "kid": "scopeblind:certification:2026-q3", + "sig": "" + } +} +``` + +## Pricing (draft) + +| Tier | Scope | Duration | Price (AUD) | +|---|---|---|---| +| Single release | One version of one implementation | 12 months | $2,000 | +| Release train | Four versions over 12 months (auto-renewing) | 12 months | $6,000 | +| Enterprise | Up to 10 implementations, rolling certifications | 12 months | $25,000 | +| Custom | Specific jurisdictions / regulations (HIPAA, FedRAMP) | Negotiable | From $15,000 | + +Pricing is anchored to comparable certification markets (SOC 2 Type II +audits from $25K, FIPS 140-3 validation from $50K). Veritas Acta +certification is positioned as cheaper, faster, and more focused than +those but backed by the specific implementation test suite rather than +general security posture. + +## Delivery SLA + +- **Single release:** 5 business days review, written summary within 10 + business days. +- **Expedited review (+50% fee):** 2 business days. +- **Enterprise tier:** named contact, quarterly review summary. + +## Marketing positioning + +- Certified implementations earn a "Certified by Veritas Acta" + registry entry with a distinctive badge. +- Public announcement via a monthly "Certified This Month" blog post. +- Joint case study opportunity for flagship certifications. + +## Operational requirements + +- Dedicated attester key (air-gapped HSM) for certification + signatures +- Auditor team (initially 1-2 people; scale with demand) +- Published methodology document and versioned test suite +- Semi-annual methodology review + public changelog + +## Dependencies for GA + +- v0.5.0 shipped (✓) +- Registry worker deployed (in progress; scaffold in + `ecosystem/registry-worker/`) +- Badge service deployed (in progress; scaffold in + `ecosystem/badge-worker/`) +- Methodology doc published +- First-customer pilot (target: Q3 2026) + +## Not-yet-decided + +- Whether certifications appear in a Rekor-style transparency log +- Whether certification revocation events are published publicly +- Whether multi-tier certifications (e.g., T4 + specific FIPS + variants) require separate pricing diff --git a/ecosystem/README.md b/ecosystem/README.md new file mode 100644 index 0000000..f20aa5e --- /dev/null +++ b/ecosystem/README.md @@ -0,0 +1,56 @@ +# Veritas Acta Ecosystem — Shipped and Planned Artifacts + +Beyond the verifier CLI itself, the `@veritasacta/verify` ecosystem +includes adoption and distribution artifacts. This directory contains +scaffolds, specs, and plans for each. + +## Shipped in v0.5.0 + +| Artifact | Location | Description | +|---|---|---| +| CLI verifier | `packages/verify-cli/` | Unified offline verifier with `init` / `proxy` / `daemon` subcommands | +| GitHub Action | `ecosystem/github-action/` | Drop-in verification step for any repo | +| Claude Code plugin | `ecosystem/claude-code-plugin/` | One-click Claude Code install | +| Homebrew formula | `ecosystem/homebrew-tap/` | `brew install veritasacta-verify` | +| JS SDK | `ecosystem/sdk-js/` | `@veritasacta/sdk` tiny signing helper | +| Python SDK | `ecosystem/sdk-py/` | `veritasacta-sdk` Python signing helper | +| LangChain adapter | `ecosystem/adapters/langchain/` | Reference adapter (full implementation) | +| 7 other framework adapters | `ecosystem/adapters/*/` | LangGraph, CrewAI, OpenAI Agents, Vercel AI, Smolagents, Pydantic AI, AutoGen | +| Sigil naming doc | `ecosystem/SIGIL-NAMING.md` + `ecosystem/RELEASE-NAMING.md` | Public brand convention | + +## Scaffolded, deployment pending + +| Artifact | Location | Purpose | Blocker | +|---|---|---|---| +| Implementations registry | `ecosystem/registry-worker/` | `registry.veritasacta.com` mirror | Cloudflare Worker deployment | +| Badge service | `ecosystem/badge-worker/` | `verify.veritasacta.com/badge/*` SVG badges | Cloudflare Worker deployment | +| Interop leaderboard | `ecosystem/interop-leaderboard/` | Weekly cross-implementation CI | Workflow placement in registry repo | + +## Planned for v0.6.0 / v0.7.0 (design docs in place) + +| Artifact | Location | Notes | +|---|---|---| +| VS Code extension | `ecosystem/vscode-extension/` | Receipt editor support | +| Cosign / Sigstore compat | `ecosystem/cosign-compat/DESIGN.md` | DSSE wrap + Rekor anchor (v0.6.0) | +| Filesystem rollback | `ecosystem/rollback/DESIGN.md` | Content-addressed snapshots (nono-style) | +| Runtime supervisor | `ecosystem/supervisor/DESIGN.md` | Dynamic permission expansion with approval | +| Issuer reputation | `ecosystem/reputation/DESIGN.md` | Bayesian reputation over receipt issuers | +| Audit dashboard GUI | `ecosystem/dashboard/DESIGN.md` | Web dashboard (Signet-style) | +| Browser extension | `ecosystem/browser-extension/DESIGN.md` | Claude.ai / ChatGPT injection | +| eBPF OS observer | `ecosystem/ebpf-observer/DESIGN.md` | Kernel-level auto-instrumentation | +| Conformance certification | `ecosystem/CONFORMANCE-CERTIFICATION.md` | Commercial tier | + +## Deployment order (after v0.5.0 npm publish) + +1. GitHub Action released as `VeritasActa/verify-action@v1` +2. Registry worker deployed at `registry.veritasacta.com` +3. Badge worker deployed at `verify.veritasacta.com` +4. Interop leaderboard workflow committed to `VeritasActa/agt-integration-profile` +5. README updates propagated across implementation repos (sb-runtime, + protect-mcp, protect-mcp-adk) to embed badges +6. VS Code extension shipped (v0.5.1) +7. Cosign compatibility landed (v0.6.0) +8. Certification service pilot launched (Q3 2026) + +Each numbered step is independent; they can happen in any order as +bandwidth allows. diff --git a/ecosystem/RELEASE-NAMING.md b/ecosystem/RELEASE-NAMING.md new file mode 100644 index 0000000..5be69ea --- /dev/null +++ b/ecosystem/RELEASE-NAMING.md @@ -0,0 +1,80 @@ +# Sigil Release Naming System + +Every release of `@veritasacta/verify` gets a unique Sigil with a +deterministic name derived from its cryptographic fingerprint. The name +is not chosen — it emerges from the release's own content. + +## How names are generated + +When `generate-sigil.mjs` produces a Sigil, it: + +1. Computes `sigil_hash = sha256("scopeblind:sigil:v2" || pubkey || policy_hash || nonce)` +2. Takes `fingerprint = first 8 hex chars of sigil_hash` +3. Parses `n = int(fingerprint[0:4], 16)`, `m = int(fingerprint[4:8], 16)` +4. Selects `name = ADJECTIVE[n % 24] + " " + NOUN[m % 24]` + +This is **deterministic** — two verifier binaries with the same source +hash and the same project key produce the same Sigil, same fingerprint, +same name. + +## Why release names matter (brand mechanism) + +1. **Memorable.** "We pinned to Swift Wind" is easier than "pinned to + Sigil fingerprint 87727f4b." +2. **Visible in release notes.** Every announcement leads with the name. +3. **Counterfeit detection.** A fork produces a DIFFERENT Sigil → + different name. Users see at a glance. +4. **Marketing contagion.** Release names become conversational shorthand. + +## Pool + +24 adjectives × 24 nouns = 576 unique name combinations. Collision-averse +for ~100 releases; rare collisions can be disambiguated by version. + +### Adjectives + +Bright · Quiet · Deep · Bold · Pale · Warm · Still · Swift · Clear · +Dark · First · True · Slow · Fair · Old · New · Gilded · Woven · Open · +High · Lone · Kind · Keen · Wild + +### Nouns + +Ember · Harbor · Field · Beacon · River · Grove · Arrow · Stone · +Ridge · Wind · Tide · Star · Vale · Peak · Lake · Dawn · Reed · Cairn · +Orchard · Meadow · Hearth · Anchor · Vessel · Thread + +## Historical Sigil registry + +| Version | Sigil name | Fingerprint | Released | Highlight | +|---|---|---|---|---| +| 0.3.0 | Slow Reed | `dd0443f0` | 2026-04-13 | First Sigil-attested release | +| 0.4.0 | Slow Cairn | `e6647ab1` | 2026-04-19 | Embedded-key rejection | +| 0.5.0 | (current) | (current) | 2026-04-19 | Unified verifier | + +After each release, this table is updated. The canonical live registry +is at `https://veritasacta.com/sigils`. + +## Marketing cadence + +- **Release announcement**: leads with the Sigil name. "Swift Wind is + live." +- **Monthly "Sigil of the Month" blog post**: features the current + release, technical changes, and a case study from any org that + canonically attested that version. +- **Merchandise**: T-shirts, stickers, print art — each release can + generate its own limited-edition asset. Optional but reinforces + identity. + +## Naming collisions + +If two versions produce the same name (expected ~1 per 24 releases on +average), disambiguate by version number: "Swift Wind 0.5.0" vs. "Swift +Wind 0.7.0". Named Sigils are still cryptographically distinct via +fingerprint. + +## Related + +- `ecosystem/SIGIL-NAMING.md` — this file +- `generate-sigil.mjs` — derivation code +- `src/engines/sigil.js` — runtime verification +- Patent provisional #5 — Sigil visual commitment diff --git a/ecosystem/SIGIL-NAMING.md b/ecosystem/SIGIL-NAMING.md new file mode 100644 index 0000000..fc7c9c5 --- /dev/null +++ b/ecosystem/SIGIL-NAMING.md @@ -0,0 +1,69 @@ +# Veritas Acta Sigil Naming Convention + +Every release of `@veritasacta/verify` receives a unique Sigil — a +visual cryptographic commitment to the binary. Each Sigil has a +deterministic name derived from its fingerprint. + +## How names are generated + +The `generate-sigil.mjs` script produces a Sigil after the source code +is frozen. It derives: + +- A SHA-256 `sigil_hash` from `(project_public_key, policy_hash, nonce)` +- A `fingerprint` = first 8 hex characters of the sigil hash +- A `name` = two-word label derived from the fingerprint + +Name derivation: + +``` +n = parseInt(fingerprint[0:4], 16) +m = parseInt(fingerprint[4:8], 16) +name = NAME_ADJ[n % 24] + " " + NAME_NOUN[m % 24] +``` + +The 24×24 = 576 possible names keep the namespace collision-averse +enough for ~100 releases. If a collision ever occurs, the older name +retains priority in public comms. + +## Adjective pool (24) + +Bright · Quiet · Deep · Bold · Pale · Warm · Still · Swift · Clear · +Dark · First · True · Slow · Fair · Old · New · Gilded · Woven · Open · +High · Lone · Kind · Keen · Wild + +## Noun pool (24) + +Ember · Harbor · Field · Beacon · River · Grove · Arrow · Stone · +Ridge · Wind · Tide · Star · Vale · Peak · Lake · Dawn · Reed · Cairn · +Orchard · Meadow · Hearth · Anchor · Vessel · Thread + +## Why names matter (brand convention) + +1. **Memorability.** "We're pinned to New Wind" is easier to reason + about than "5247a989". +2. **Visibility.** Release announcements lead with the name. The + fingerprint is the precise handle; the name is the social handle. +3. **Non-interchangeability.** A fork that produces its own Sigil gets + a DIFFERENT name. Users can see at a glance whether the installed + verifier is the canonical release. +4. **Longevity.** Names compose with version numbers: "Swift Wind + 0.5.0" vs "New Wind 0.5.0" signals which Sigil commitment is + active. + +## Historical Sigil registry + +| Version | Sigil name | Fingerprint | Released | Notes | +|---|---|---|---|---| +| 0.3.0 | Slow Reed | dd0443f0 | 2026-04-13 | First Sigil-attested release | +| 0.4.0 | Slow Cairn | e6647ab1 | 2026-04-19 | Embedded-key rejection | +| 0.5.0 | (pending) | (TBD) | (pending) | Unified verifier | + +Future releases add to this table as they ship. The canonical registry +lives at `https://veritasacta.com/sigils` once the badge service is +deployed. + +## Related + +- `packages/verify-cli/generate-sigil.mjs` — derivation code +- `packages/verify-cli/sigil.json` — current commitment +- `patents/filed/provisional-5/` — Sigil patent claims diff --git a/ecosystem/adapters/autogen/README.md b/ecosystem/adapters/autogen/README.md new file mode 100644 index 0000000..e070245 --- /dev/null +++ b/ecosystem/adapters/autogen/README.md @@ -0,0 +1,32 @@ +# veritasacta-autogen + +Microsoft AutoGen adapter for Veritas Acta signed receipts. + +## Status + +v0.1.0 scaffold. + +## Install + +```bash +pip install veritasacta-autogen veritasacta-sdk +``` + +## Usage + +```python +from autogen_agentchat.agents import AssistantAgent +from veritasacta_sdk import Signer +from veritasacta_autogen import attach_receipts + +signer = Signer.from_key_file(".veritasacta/attester.json") + +agent = AssistantAgent(name="assistant", tools=[...]) +attach_receipts(agent, signer=signer, policy_id="chat-v1") + +await agent.run(task="...") +``` + +## License + +Apache-2.0 diff --git a/ecosystem/adapters/crewai/README.md b/ecosystem/adapters/crewai/README.md new file mode 100644 index 0000000..c6695e2 --- /dev/null +++ b/ecosystem/adapters/crewai/README.md @@ -0,0 +1,41 @@ +# veritasacta-crewai + +CrewAI adapter for Veritas Acta signed receipts. Wraps Task / Agent +tool invocations in CrewAI so every tool call emits a signed receipt. + +## Status + +v0.1.0 scaffold. Intended for users running CrewAI crews who want +independently verifiable evidence of agent decisions. + +## Install + +```bash +pip install veritasacta-crewai veritasacta-sdk +``` + +## Usage + +```python +from crewai import Agent, Task, Crew +from veritasacta_sdk import Signer +from veritasacta_crewai import attach_receipts + +signer = Signer.from_key_file(".veritasacta/attester.json") + +crew = Crew(agents=[...], tasks=[...]) +attach_receipts(crew, signer=signer, policy_id="research-v1", receipts_dir=".veritasacta/receipts") + +result = crew.kickoff() +# Every task/tool invocation produced a receipt under .veritasacta/receipts +``` + +## Verification + +```bash +npx @veritasacta/verify .veritasacta/receipts/*.json --key +``` + +## License + +Apache-2.0 diff --git a/ecosystem/adapters/langchain/README.md b/ecosystem/adapters/langchain/README.md new file mode 100644 index 0000000..591f23e --- /dev/null +++ b/ecosystem/adapters/langchain/README.md @@ -0,0 +1,98 @@ +# @veritasacta/langchain + +LangChain / LangGraph adapter for Veritas Acta signed decision receipts. + +**Apache-2.0 · JS + Python · Runs alongside any LangChain agent** + +## Install + +```bash +# JavaScript / TypeScript +npm install @veritasacta/langchain @veritasacta/sdk + +# Python +pip install veritasacta-langchain veritasacta-sdk +``` + +## Usage — JavaScript + +```ts +import { ChatOpenAI } from '@langchain/openai'; +import { AgentExecutor, createReactAgent } from 'langchain/agents'; +import { Signer } from '@veritasacta/sdk'; +import { withReceipts } from '@veritasacta/langchain'; + +const signer = Signer.fromKeyFile('.veritasacta/attester.json'); + +const agent = createReactAgent({ + llm: new ChatOpenAI(), + tools, +}); + +// Wrap any AgentExecutor to emit a signed receipt on every tool call +const auditedAgent = withReceipts(agent, { + signer, + policyId: 'research-read-only-v1', + receiptsDir: '.veritasacta/receipts', +}); + +const result = await auditedAgent.invoke({ input: 'What happened this week?' }); +// Each tool call produced a signed receipt under .veritasacta/receipts/ +``` + +## Usage — Python + +```python +from langchain_openai import ChatOpenAI +from langchain.agents import AgentExecutor, create_react_agent +from veritasacta_sdk import Signer +from veritasacta_langchain import with_receipts + +signer = Signer.from_key_file(".veritasacta/attester.json") + +agent = create_react_agent(llm=ChatOpenAI(), tools=tools) + +audited = with_receipts( + agent, + signer=signer, + policy_id="research-read-only-v1", + receipts_dir=".veritasacta/receipts", +) + +result = audited.invoke({"input": "What happened this week?"}) +``` + +## How it works + +`withReceipts` / `with_receipts` wraps LangChain's tool-invocation path +(via the `on_tool_start` / `on_tool_end` callbacks) and calls +`signer.signDecision({...})` for each tool invocation. The returned +receipt is persisted to the configured receipts directory; verification +happens later with `npx @veritasacta/verify`. + +## Field mapping + +| LangChain concept | Veritas Acta field | +|---|---| +| Tool name | `tool_name` | +| Tool arguments (hashed) | `tool_input_hash` | +| Output (hashed, optional) | `output_hash` | +| Policy label | `policy_id` | +| Agent identity | `agent_id` | +| Chain link | `previousReceiptHash` | + +## Verifying + +```bash +npx @veritasacta/verify .veritasacta/receipts/*.json --key +``` + +## Related + +- [@veritasacta/verify](https://www.npmjs.com/package/@veritasacta/verify) — the verifier +- [@veritasacta/sdk](https://www.npmjs.com/package/@veritasacta/sdk) — the underlying signer +- [Veritas Acta protocol](https://veritasacta.com) + +## License + +Apache-2.0 diff --git a/ecosystem/adapters/langchain/package.json b/ecosystem/adapters/langchain/package.json new file mode 100644 index 0000000..06504dc --- /dev/null +++ b/ecosystem/adapters/langchain/package.json @@ -0,0 +1,16 @@ +{ + "name": "@veritasacta/langchain", + "version": "0.1.0", + "description": "LangChain / LangGraph adapter for Veritas Acta signed decision receipts.", + "license": "Apache-2.0", + "type": "module", + "main": "src/index.js", + "files": ["src/", "README.md"], + "peerDependencies": { + "@veritasacta/sdk": "^0.1.0", + "@langchain/core": ">=0.1.0" + }, + "dependencies": {}, + "engines": { "node": ">=18.0.0" }, + "keywords": ["langchain", "langgraph", "veritasacta", "receipts"] +} diff --git a/ecosystem/adapters/langchain/src/index.js b/ecosystem/adapters/langchain/src/index.js new file mode 100644 index 0000000..b8651ac --- /dev/null +++ b/ecosystem/adapters/langchain/src/index.js @@ -0,0 +1,99 @@ +/** + * @veritasacta/langchain — LangChain adapter for signed receipts. + * + * Wraps a LangChain AgentExecutor (or any runnable with tool + * invocations) by attaching callback handlers that emit Veritas Acta + * receipts on every tool call. + * + * @license Apache-2.0 + */ + +import { writeFileSync, mkdirSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; + +class ReceiptsCallbackHandler { + constructor({ signer, policyId, receiptsDir, agentId }) { + this.signer = signer; + this.policyId = policyId; + this.receiptsDir = receiptsDir; + this.agentId = agentId; + if (!existsSync(receiptsDir)) mkdirSync(receiptsDir, { recursive: true }); + } + + async handleToolStart(tool, input) { + // Cache for use at end + this._pending = { tool, input, startedAt: Date.now() }; + } + + async handleToolEnd(output) { + if (!this._pending) return; + const { tool, input } = this._pending; + const toolName = tool?.name || tool?.lc_id || 'unknown-tool'; + + const receipt = this.signer.signDecision({ + tool: toolName, + args: typeof input === 'string' ? { input } : input, + decision: 'allow', + policy_id: this.policyId, + metadata: this.agentId ? { agent_id: this.agentId } : undefined, + }); + + const filename = `rcpt_${String(this.signer.sequence).padStart(6, '0')}.json`; + writeFileSync(join(this.receiptsDir, filename), JSON.stringify(receipt, null, 2)); + this._pending = null; + } + + async handleToolError(err) { + if (!this._pending) return; + const { tool, input } = this._pending; + const toolName = tool?.name || 'unknown-tool'; + const receipt = this.signer.signDecision({ + tool: toolName, + args: typeof input === 'string' ? { input } : input, + decision: 'deny', + policy_id: this.policyId, + metadata: { error: err?.message || 'unknown' }, + }); + const filename = `rcpt_${String(this.signer.sequence).padStart(6, '0')}.json`; + writeFileSync(join(this.receiptsDir, filename), JSON.stringify(receipt, null, 2)); + this._pending = null; + } +} + +/** + * Wrap a LangChain AgentExecutor or Runnable with receipt emission. + * + * @param {Object} agent LangChain agent / runnable + * @param {Object} opts + * @param {Object} opts.signer @veritasacta/sdk Signer instance + * @param {string} [opts.policyId] + * @param {string} [opts.receiptsDir='.veritasacta/receipts'] + * @param {string} [opts.agentId] + * @returns {Object} wrapped agent + */ +export function withReceipts(agent, opts) { + const handler = new ReceiptsCallbackHandler({ + signer: opts.signer, + policyId: opts.policyId || 'veritasacta:langchain:default', + receiptsDir: opts.receiptsDir || '.veritasacta/receipts', + agentId: opts.agentId, + }); + + // LangChain lets us attach callbacks via .withConfig / .bind / invoke({callbacks}) + // The wrap is intentionally minimal: any place that accepts a callbacks list + // will fire our handlers. + const original = agent.invoke ? agent.invoke.bind(agent) : null; + if (!original) { + throw new Error('withReceipts: agent does not expose .invoke(); wrap a different object.'); + } + + return { + ...agent, + invoke(input, config = {}) { + const callbacks = [...(config.callbacks || []), handler]; + return original(input, { ...config, callbacks }); + }, + }; +} + +export { ReceiptsCallbackHandler }; diff --git a/ecosystem/adapters/langgraph/README.md b/ecosystem/adapters/langgraph/README.md new file mode 100644 index 0000000..5cc5fec --- /dev/null +++ b/ecosystem/adapters/langgraph/README.md @@ -0,0 +1,33 @@ +# @veritasacta/langgraph + +LangGraph adapter for Veritas Acta signed receipts. Built on `@veritasacta/langchain`; exposes `withGraphReceipts()` that attaches receipt callbacks to every node execution in the graph. + +## Status + +v0.1.0 scaffold. Full implementation tracked alongside the LangChain adapter. + +## Install + +```bash +npm install @veritasacta/langgraph @veritasacta/sdk +``` + +## Usage + +```ts +import { StateGraph } from '@langchain/langgraph'; +import { Signer } from '@veritasacta/sdk'; +import { withGraphReceipts } from '@veritasacta/langgraph'; + +const signer = Signer.fromKeyFile('.veritasacta/attester.json'); + +const graph = new StateGraph(...).compile(); +const audited = withGraphReceipts(graph, { signer, policyId: 'graph-v1' }); + +await audited.invoke({ input: '...' }); +// Each node invocation emits a receipt. +``` + +## License + +Apache-2.0 diff --git a/ecosystem/adapters/openai-agents/README.md b/ecosystem/adapters/openai-agents/README.md new file mode 100644 index 0000000..809117d --- /dev/null +++ b/ecosystem/adapters/openai-agents/README.md @@ -0,0 +1,45 @@ +# @veritasacta/openai-agents + +OpenAI Agents SDK adapter for Veritas Acta signed receipts. + +## Status + +v0.1.0 scaffold. Composes with the [on_tool_authorize hook proposal +(openai/openai-agents-python#2868)](https://github.com/openai/openai-agents-python/issues/2868). + +## Install + +```bash +# JS +npm install @veritasacta/openai-agents @veritasacta/sdk + +# Python +pip install veritasacta-openai-agents veritasacta-sdk +``` + +## Usage + +```ts +import { Agent } from '@openai/agents'; +import { Signer } from '@veritasacta/sdk'; +import { attachReceipts } from '@veritasacta/openai-agents'; + +const signer = Signer.fromKeyFile('.veritasacta/attester.json'); + +const agent = new Agent({ + name: 'researcher', + tools: [...], +}); + +attachReceipts(agent, { + signer, + policyId: 'research-v1', + receiptsDir: '.veritasacta/receipts', +}); + +await agent.run({ input: '...' }); +``` + +## License + +Apache-2.0 diff --git a/ecosystem/adapters/pydantic-ai/README.md b/ecosystem/adapters/pydantic-ai/README.md new file mode 100644 index 0000000..fe143a2 --- /dev/null +++ b/ecosystem/adapters/pydantic-ai/README.md @@ -0,0 +1,37 @@ +# veritasacta-pydantic-ai + +Pydantic AI adapter for Veritas Acta signed receipts. Wraps `Agent.tool` +decorators with signing callbacks. + +## Status + +v0.1.0 scaffold. + +## Install + +```bash +pip install veritasacta-pydantic-ai veritasacta-sdk +``` + +## Usage + +```python +from pydantic_ai import Agent +from veritasacta_sdk import Signer +from veritasacta_pydantic_ai import with_receipts + +signer = Signer.from_key_file(".veritasacta/attester.json") + +agent = Agent("openai:gpt-4o") +with_receipts(agent, signer=signer, policy_id="chat-v1") + +@agent.tool +def my_tool(ctx, x: int) -> int: + return x * 2 + +result = agent.run_sync("...") +``` + +## License + +Apache-2.0 diff --git a/ecosystem/adapters/smolagents/README.md b/ecosystem/adapters/smolagents/README.md new file mode 100644 index 0000000..92d4ece --- /dev/null +++ b/ecosystem/adapters/smolagents/README.md @@ -0,0 +1,34 @@ +# veritasacta-smolagents + +Smolagents (HuggingFace) adapter for Veritas Acta signed receipts. + +## Status + +v0.1.0 scaffold. + +## Install + +```bash +pip install veritasacta-smolagents veritasacta-sdk +``` + +## Usage + +```python +from smolagents import CodeAgent +from veritasacta_sdk import Signer +from veritasacta_smolagents import ReceiptHook + +signer = Signer.from_key_file(".veritasacta/attester.json") + +agent = CodeAgent( + tools=[...], + hooks=[ReceiptHook(signer, policy_id="research-v1")], +) + +agent.run("...") +``` + +## License + +Apache-2.0 diff --git a/ecosystem/adapters/swarms/.gitignore b/ecosystem/adapters/swarms/.gitignore new file mode 100644 index 0000000..6dce3c8 --- /dev/null +++ b/ecosystem/adapters/swarms/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.pyc +dist/ +build/ +*.egg-info/ diff --git a/ecosystem/adapters/swarms/README.md b/ecosystem/adapters/swarms/README.md new file mode 100644 index 0000000..f5aa993 --- /dev/null +++ b/ecosystem/adapters/swarms/README.md @@ -0,0 +1,154 @@ +# scopeblind-swarms + +Ed25519 signed decision receipts for [kyegomez/swarms](https://github.com/kyegomez/swarms) multi-agent systems. Wrap any tool with tamper-evident, offline-verifiable receipts in the [Veritas Acta receipt format](https://datatracker.ietf.org/doc/draft-farley-acta-signed-receipts/) — the same format used by Microsoft Agent Governance Toolkit, protect-mcp, sb-runtime, hermes-decision-receipts, and Signet. + +**MIT · Python · Runs alongside any Swarms agent · No fork of Swarms required** + +## Why this exists + +Swarms orchestrates agents that call real-world tools. When things go wrong, operators need cryptographic evidence of *which decisions were made by which agent under which policy*. `scopeblind-swarms` emits an Ed25519-signed, chain-linked receipt for every governed tool call. Receipts verify offline with `npx @veritasacta/verify` against a public key the operator publishes out-of-band. + +No dependency on ScopeBlind infrastructure. No vendor lock-in. The receipt format is an open IETF Internet-Draft. + +## Install + +```bash +pip install scopeblind-swarms +# plus swarms itself: +pip install swarms +``` + +## Quick start + +```python +from swarms import Agent +from scopeblind_swarms import ReceiptChain, sign_tool + +# 1. Create a receipt chain. One per agent (or per session). +chain = ReceiptChain.from_key_file( + signer_key_path="/etc/scopeblind/issuer.key", + agent_id="did:swarms:researcher", + policy_id="allow-web-read", +) + +# 2. Define tools normally. +def web_search(query: str) -> str: + # ... your search implementation + return f"results for {query}" + +# 3. Wrap tools with signed-receipt emission. +signed_search = sign_tool(web_search, chain=chain) + +# 4. Use the wrapped tool in an Agent. +agent = Agent( + agent_name="researcher", + tools=[signed_search], + model_name="gpt-4", + # ... your other Agent config +) +agent.run("find recent papers on agent governance") + +# Every tool invocation produced a signed receipt. Access the most +# recent one from the wrapped tool: +print(signed_search.last_receipt) + +# Or iterate the chain: +print(f"Chain tip hash: {chain.current_tip}") +``` + +## Bulk wrapping + +```python +from scopeblind_swarms import sign_tools + +agent = Agent( + agent_name="researcher", + tools=sign_tools([web_search, summarize, post_draft], chain=chain), + ..., +) +``` + +## Decorator form + +```python +from scopeblind_swarms import ReceiptChain, sign_tool + +chain = ReceiptChain.from_key_file("/etc/scopeblind/issuer.key", agent_id="did:swarms:writer") + +@sign_tool(chain=chain, policy_id="allow-draft-only") +def post_draft(content: str) -> str: + return f"drafted: {content[:50]}..." +``` + +## What's in a receipt + +```json +{ + "payload": { + "type": "scopeblind:swarms:tool-call", + "agent_id": "did:swarms:researcher", + "issuer_id": "swarms:agent:HJY4k2aN", + "tool_name": "web_search", + "action": "swarms:tool:web_search", + "action_ref": "sha256:a8f3...c91e", + "decision": "allow", + "policy_id": "allow-web-read", + "result_hash": "sha256:4b2c...d71f", + "issued_at": "2026-04-19T15:42:01.773Z", + "previousReceiptHash": "Zk4p..." + }, + "signature": { + "alg": "EdDSA", + "kid": "HJY4k2aNqRwXcdEfGh...", + "sig": "..." + } +} +``` + +Fields: + +- `action_ref` — SHA-256 of the JCS-canonicalized tool arguments. Agents can cross-correlate the same tool invocation across engines. +- `result_hash` — SHA-256 of the tool return value. The receipt attests to what the tool returned without carrying raw output (privacy default). +- `previousReceiptHash` — SHA-256 of the prior receipt in this chain. Successive tool calls form a tamper-evident chain. +- `policy_id`, `policy_digest` — bound into every receipt so an auditor can confirm which policy the agent was operating under. + +## Offline verification + +Any receipt verifies with `@veritasacta/verify`, with no dependency on `scopeblind-swarms` or Swarms: + +```bash +npx @veritasacta/verify receipt.json --key operator-public.pem +``` + +Exit code `0` is proven valid; exit `1` is proven tampering; exit `2` is undecidable (malformed, missing key). + +Your operator public key is published out-of-band (JWKS URL, DID document service endpoint, pinned trust anchor, or GitHub-backed SBOM artifact). Never embedded in the receipt. Per draft-farley-acta-signed-receipts-02 §9. + +## Design notes + +- **Swarms extension point.** Swarms does not expose pre/post tool hooks; the canonical interception point is wrapping the tool callable before passing it to `Agent(tools=[...])`. `scopeblind-swarms` uses that extension point exactly; it does not patch Swarms' internals. +- **Thread safety.** A `ReceiptChain` is safe to share across parallel tool invocations within a single agent (async Swarms agents often run tools concurrently). Chain integrity is preserved via an internal lock. +- **No kernel sandboxing.** This adapter covers the receipts layer. For kernel-level agent isolation, compose with [`sb-runtime`](https://github.com/ScopeBlind/sb-runtime) (Landlock + seccomp) or [nono](https://github.com/always-further/nono). +- **No embedded keys.** `sign_receipt` and `verify_receipt` reject any payload carrying `verification_key`, `issuer_key`, or `signer_public_key`. Fail-closed posture matches the rest of the ecosystem after the April 2026 desiorac stress-test. + +## Policy evaluation + +`scopeblind-swarms` doesn't ship a Cedar evaluator. If you want Cedar policy enforcement, pair with one of: + +- [`bindu-scopeblind`](https://github.com/ScopeBlind/bindu-scopeblind) — Python Cedar extension +- [`sb-runtime`](https://github.com/ScopeBlind/sb-runtime) — Rust binary with Cedar + sandbox + receipts +- Direct use of `cedarpy` in your `decision` computation before calling `sign_tool` + +The `decision` field in every receipt accepts `"allow" | "deny" | "require_approval"`; you can vary it per call from your own policy evaluation logic. + +## Related + +- **Microsoft Agent Governance Toolkit** — [docs/integrations/sb-runtime.md](https://github.com/microsoft/agent-governance-toolkit/blob/main/docs/integrations/sb-runtime.md) (same receipt format) +- **Reference verifier** — [`@veritasacta/verify`](https://github.com/ScopeBlind/verify) (Apache-2.0, offline) +- **IETF draft** — [draft-farley-acta-signed-receipts-02](https://datatracker.ietf.org/doc/draft-farley-acta-signed-receipts/) +- **Conformance profile** — [VeritasActa/agt-integration-profile](https://github.com/VeritasActa/agt-integration-profile) +- **Other framework adapters** — LangChain, CrewAI, OpenAI Agents SDK, Vercel AI SDK, Smolagents, Pydantic AI, AutoGen, LangGraph (all in the same ecosystem) + +## License + +MIT. diff --git a/ecosystem/adapters/swarms/pyproject.toml b/ecosystem/adapters/swarms/pyproject.toml new file mode 100644 index 0000000..a1303ce --- /dev/null +++ b/ecosystem/adapters/swarms/pyproject.toml @@ -0,0 +1,32 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "scopeblind-swarms" +version = "0.1.0" +description = "Ed25519 signed decision receipts for kyegomez/swarms multi-agent systems. Wrap any tool in a Swarms agent with tamper-evident, offline-verifiable receipts in the Veritas Acta format (IETF draft-farley-acta-signed-receipts)." +readme = "README.md" +license = {text = "MIT"} +requires-python = ">=3.10" +authors = [{ name = "Tom Farley (ScopeBlind)", email = "tommy@scopeblind.com" }] +keywords = ["swarms", "scopeblind", "veritas-acta", "receipts", "ed25519", "agent-governance", "kyegomez"] +dependencies = [ + "cryptography>=41.0,<47.0", +] + +[project.optional-dependencies] +swarms = ["swarms>=7.0"] +dev = ["pytest>=7.0"] + +[project.urls] +Homepage = "https://github.com/ScopeBlind/scopeblind-gateway" +"Swarms" = "https://github.com/kyegomez/swarms" +"Veritas Acta Draft" = "https://datatracker.ietf.org/doc/draft-farley-acta-signed-receipts/" +"Reference Verifier" = "https://www.npmjs.com/package/@veritasacta/verify" + +[tool.hatch.build.targets.wheel] +packages = ["scopeblind_swarms"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/ecosystem/adapters/swarms/scopeblind_swarms/__init__.py b/ecosystem/adapters/swarms/scopeblind_swarms/__init__.py new file mode 100644 index 0000000..b6b8586 --- /dev/null +++ b/ecosystem/adapters/swarms/scopeblind_swarms/__init__.py @@ -0,0 +1,55 @@ +# Copyright (c) 2026 Tom Farley (ScopeBlind). +# Licensed under the MIT License. +"""scopeblind-swarms: Ed25519 signed decision receipts for Swarms agents. + +Wraps any Python callable used as a Swarms tool with tamper-evident +receipt emission. Matches the receipt format specified by +draft-farley-acta-signed-receipts (Veritas Acta). Receipts verify +offline against @veritasacta/verify without Swarms or scopeblind-swarms +installed on the verifier side. + +Typical usage: + + from swarms import Agent + from scopeblind_swarms import ReceiptChain, sign_tool + + chain = ReceiptChain(signer_key_path="/etc/scopeblind/issuer.key", + agent_id="did:swarms:researcher-1") + + def web_search(query: str) -> str: + ... + + signed_web_search = sign_tool(web_search, chain=chain, policy_id="allow-web-read") + + agent = Agent( + agent_name="researcher", + tools=[signed_web_search], + ..., + ) + +Every invocation of the wrapped tool produces a signed receipt. The +chain binds successive receipts via `previousReceiptHash` so tampering +is detectable across the whole session. +""" + +from scopeblind_swarms.chain import ReceiptChain +from scopeblind_swarms.receipts import ( + Signer, + receipt_hash, + sign_receipt, + verify_receipt, +) +from scopeblind_swarms.tools import SignedToolResult, sign_tool, sign_tools + +__all__ = [ + "ReceiptChain", + "SignedToolResult", + "Signer", + "receipt_hash", + "sign_receipt", + "sign_tool", + "sign_tools", + "verify_receipt", +] + +__version__ = "0.1.0" diff --git a/ecosystem/adapters/swarms/scopeblind_swarms/chain.py b/ecosystem/adapters/swarms/scopeblind_swarms/chain.py new file mode 100644 index 0000000..d45188c --- /dev/null +++ b/ecosystem/adapters/swarms/scopeblind_swarms/chain.py @@ -0,0 +1,167 @@ +# Copyright (c) 2026 Tom Farley (ScopeBlind). +# Licensed under the MIT License. +"""ReceiptChain: per-agent session receipt signer with chain linkage. + +A Swarms agent typically makes many tool calls in a single run. A +ReceiptChain keeps the signer + agent identity + policy context stable +across those calls, tracking previous-receipt-hash internally so every +successive receipt chains to the prior one. + +Instantiate once per agent (or once per session); pass to `sign_tool` +to wrap each tool with signed emission. +""" + +from __future__ import annotations + +import hashlib +import threading +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Mapping, Optional + +from scopeblind_swarms.receipts import ( + Signer, + receipt_hash, + sign_receipt, +) + + +@dataclass +class ReceiptChain: + """Per-agent signing chain. + + Thread-safe for concurrent tool calls within the same agent + (async Swarms agents call tools in parallel). + """ + + signer: Signer + agent_id: str + issuer_id: Optional[str] = None + policy_id: Optional[str] = None + policy_digest: Optional[str] = None + session_id: Optional[str] = None + + _previous_hash: Optional[str] = field(default=None, init=False, repr=False) + _lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False) + + @classmethod + def from_key_file( + cls, + signer_key_path: str | Path, + agent_id: str, + *, + policy_id: Optional[str] = None, + policy_digest: Optional[str] = None, + session_id: Optional[str] = None, + issuer_id: Optional[str] = None, + ) -> "ReceiptChain": + signer = Signer.from_pem_file(str(signer_key_path)) + return cls( + signer=signer, + agent_id=agent_id, + issuer_id=issuer_id or f"swarms:agent:{signer.kid[:12]}", + policy_id=policy_id, + policy_digest=policy_digest, + session_id=session_id, + ) + + @classmethod + def generate( + cls, + agent_id: str, + *, + policy_id: Optional[str] = None, + policy_digest: Optional[str] = None, + session_id: Optional[str] = None, + ) -> "ReceiptChain": + """Convenience factory for tests / ephemeral deployments. + + Generates a fresh in-memory Ed25519 key. Not suitable for + production (no key rotation, no persistence); use + ``from_key_file`` with an operator-managed PEM for that. + """ + signer = Signer.generate() + return cls( + signer=signer, + agent_id=agent_id, + issuer_id=f"swarms:agent:{signer.kid[:12]}", + policy_id=policy_id, + policy_digest=policy_digest, + session_id=session_id, + ) + + def sign_tool_call( + self, + *, + tool_name: str, + tool_args: Mapping[str, Any], + tool_result_hash: Optional[str] = None, + decision: str = "allow", + policy_id: Optional[str] = None, + extra: Optional[Mapping[str, Any]] = None, + ) -> dict: + """Sign a receipt for a single tool invocation. + + Args are hashed (not carried raw) per AIP-0001 privacy defaults. + The receipt payload records the tool name, args hash, optional + result hash, policy digest, decision, and chain linkage. + """ + args_canonical_hash = _hash_json(tool_args) + + payload: dict[str, Any] = { + "type": "scopeblind:swarms:tool-call", + "agent_id": self.agent_id, + "issuer_id": self.issuer_id or self.agent_id, + "tool_name": tool_name, + "action": f"swarms:tool:{tool_name}", + "action_ref": f"sha256:{args_canonical_hash}", + "decision": decision, + } + if self.policy_id or policy_id: + payload["policy_id"] = policy_id or self.policy_id + if self.policy_digest: + payload["policy_digest"] = self.policy_digest + if self.session_id: + payload["iteration_id"] = self.session_id + if tool_result_hash: + payload["result_hash"] = tool_result_hash + if extra: + payload.update(dict(extra)) + + with self._lock: + envelope = sign_receipt( + payload=payload, + signer=self.signer, + previous_receipt_hash=self._previous_hash, + ) + self._previous_hash = receipt_hash(envelope) + + return envelope + + def reset_chain(self) -> None: + """Clear the previous-hash pointer. + + Call at session boundaries if you want a fresh chain. Does not + affect the signing key. + """ + with self._lock: + self._previous_hash = None + + @property + def current_tip(self) -> Optional[str]: + """Hash of the most recent receipt, or None if chain empty.""" + return self._previous_hash + + +def _hash_json(obj: Mapping[str, Any]) -> str: + """Deterministic SHA-256 of JCS-canonical JSON, hex-encoded.""" + import json as _json + + canonical = _json.dumps( + obj, + sort_keys=True, + ensure_ascii=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + return hashlib.sha256(canonical).hexdigest() diff --git a/ecosystem/adapters/swarms/scopeblind_swarms/receipts.py b/ecosystem/adapters/swarms/scopeblind_swarms/receipts.py new file mode 100644 index 0000000..cf03740 --- /dev/null +++ b/ecosystem/adapters/swarms/scopeblind_swarms/receipts.py @@ -0,0 +1,205 @@ +# Copyright (c) 2026 Tom Farley (ScopeBlind). +# Licensed under the MIT License. +"""Ed25519 signing + JCS canonical JSON for Swarms signed receipts. + +Byte-compatible with the unified @veritasacta/verify@0.5.0 CLI and +with the AGT sb-runtime-skill provider shim. Tokens signed here verify +with `npx @veritasacta/verify receipt.json --key ` with no +runtime dependency on this package or on Swarms. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Mapping, Optional + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import ( + Ed25519PrivateKey, + Ed25519PublicKey, +) + + +EMBEDDED_KEY_FIELDS = ("verification_key", "issuer_key", "signer_public_key") + + +class EmbeddedKeyRejection(ValueError): + """Raised when a receipt payload carries its own verification key. + + Per draft-farley-acta-signed-receipts-02 Section 9, a verification + key transported inside the signed payload does not provide + authenticity against tampering. This adapter rejects such payloads + at sign and verify time, matching the fail-closed posture of + @veritasacta/verify@0.4.0+ and the rest of the ecosystem. + """ + + +def _assert_ascii_keys(obj: Any, path: str = "$") -> None: + if isinstance(obj, Mapping): + for key in obj.keys(): + if not isinstance(key, str): + raise ValueError(f"Non-string key at {path}: {key!r}") + try: + key.encode("ascii") + except UnicodeEncodeError as exc: + raise ValueError( + f"Non-ASCII key at {path}.{key!r} violates AIP-0001" + ) from exc + _assert_ascii_keys(obj[key], f"{path}.{key}") + elif isinstance(obj, list): + for i, item in enumerate(obj): + _assert_ascii_keys(item, f"{path}[{i}]") + + +def _canonicalize(obj: Any) -> bytes: + _assert_ascii_keys(obj) + return json.dumps( + obj, + sort_keys=True, + ensure_ascii=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + + +def _b64url(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def _b64url_decode(s: str) -> bytes: + pad = 4 - (len(s) % 4) + if pad != 4: + s = s + ("=" * pad) + return base64.urlsafe_b64decode(s) + + +def _jwk_thumbprint(public_key: Ed25519PublicKey) -> str: + raw = public_key.public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + jwk = {"crv": "Ed25519", "kty": "OKP", "x": _b64url(raw)} + digest = hashlib.sha256(_canonicalize(jwk)).digest() + return _b64url(digest) + + +def _check_no_embedded_key(payload: Mapping[str, Any]) -> None: + for field in EMBEDDED_KEY_FIELDS: + if field in payload: + raise EmbeddedKeyRejection( + f"Receipt payload carries embedded key field '{field}'. " + "See draft-farley-acta-signed-receipts-02 \u00a79." + ) + + +@dataclass +class Signer: + """Ed25519 signing key wrapper.""" + + private_key: Ed25519PrivateKey + kid: str + + @classmethod + def generate(cls, kid: Optional[str] = None) -> "Signer": + pk = Ed25519PrivateKey.generate() + return cls( + private_key=pk, + kid=kid or _jwk_thumbprint(pk.public_key()), + ) + + @classmethod + def from_pem_file(cls, path: str, kid: Optional[str] = None) -> "Signer": + with open(path, "rb") as f: + pem = f.read() + pk = serialization.load_pem_private_key(pem, password=None) + if not isinstance(pk, Ed25519PrivateKey): + raise ValueError("PEM must contain an Ed25519 private key") + return cls( + private_key=pk, + kid=kid or _jwk_thumbprint(pk.public_key()), + ) + + @classmethod + def from_pem(cls, pem: bytes, kid: Optional[str] = None) -> "Signer": + pk = serialization.load_pem_private_key(pem, password=None) + if not isinstance(pk, Ed25519PrivateKey): + raise ValueError("PEM must contain an Ed25519 private key") + return cls( + private_key=pk, + kid=kid or _jwk_thumbprint(pk.public_key()), + ) + + def public_pem(self) -> bytes: + return self.private_key.public_key().public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + + def private_pem(self) -> bytes: + return self.private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + + +def sign_receipt( + payload: Mapping[str, Any], + signer: Signer, + previous_receipt_hash: Optional[str] = None, +) -> dict: + _check_no_embedded_key(payload) + + final_payload = dict(payload) + final_payload.setdefault( + "issued_at", + datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z"), + ) + if previous_receipt_hash is not None: + final_payload["previousReceiptHash"] = previous_receipt_hash + + canonical = _canonicalize(final_payload) + signature = signer.private_key.sign(canonical) + + return { + "payload": final_payload, + "signature": { + "alg": "EdDSA", + "kid": signer.kid, + "sig": _b64url(signature), + }, + } + + +def verify_receipt(envelope: Mapping[str, Any], public_key: Ed25519PublicKey) -> bool: + if not isinstance(envelope, Mapping): + raise ValueError("Envelope must be a mapping") + payload = envelope.get("payload") + signature = envelope.get("signature") + if payload is None or signature is None: + raise ValueError("Envelope must contain payload and signature") + + _check_no_embedded_key(payload) + + if signature.get("alg") != "EdDSA": + return False + sig_b64 = signature.get("sig") + if not isinstance(sig_b64, str): + return False + + canonical = _canonicalize(payload) + try: + public_key.verify(_b64url_decode(sig_b64), canonical) + except InvalidSignature: + return False + return True + + +def receipt_hash(envelope: Mapping[str, Any]) -> str: + digest = hashlib.sha256(_canonicalize(envelope)).digest() + return _b64url(digest) diff --git a/ecosystem/adapters/swarms/scopeblind_swarms/tools.py b/ecosystem/adapters/swarms/scopeblind_swarms/tools.py new file mode 100644 index 0000000..d3809bc --- /dev/null +++ b/ecosystem/adapters/swarms/scopeblind_swarms/tools.py @@ -0,0 +1,198 @@ +# Copyright (c) 2026 Tom Farley (ScopeBlind). +# Licensed under the MIT License. +"""Tool-wrapping helpers for Swarms integration. + +Swarms' extension point for tool calls is to wrap each tool function +before handing it to the Agent constructor (per upstream guidance on +tool interception). This module provides a decorator and a bulk helper +for wrapping one or many tools with receipt emission. + +Usage: + + from swarms import Agent + from scopeblind_swarms import ReceiptChain, sign_tool + + chain = ReceiptChain.from_key_file( + signer_key_path="/etc/scopeblind/issuer.key", + agent_id="did:swarms:researcher", + policy_id="allow-web-read", + ) + + def web_search(query: str) -> str: + return f"results for {query}" + + signed_search = sign_tool(web_search, chain=chain) + + agent = Agent( + agent_name="researcher", + tools=[signed_search], + ..., + ) + agent.run("find recent papers on agent governance") + + # The session's receipts are available on the chain or via the + # wrapped tool's last_receipt attribute. +""" + +from __future__ import annotations + +import functools +import hashlib +import inspect +import json +from dataclasses import dataclass +from typing import Any, Callable, Iterable, Mapping, Optional + +from scopeblind_swarms.chain import ReceiptChain + + +@dataclass +class SignedToolResult: + """The raw tool return value plus its signed receipt. + + Most Swarms agents expect tools to return strings (or simple + values); this class is returned when the caller passes + ``attach_receipts=True`` so the agent loop can inspect both + the business output and the evidence. + """ + + result: Any + receipt: dict + + def __str__(self) -> str: + return str(self.result) + + +def sign_tool( + func: Optional[Callable] = None, + *, + chain: Optional[ReceiptChain] = None, + policy_id: Optional[str] = None, + tool_name: Optional[str] = None, + attach_receipts: bool = False, +) -> Callable: + """Wrap a Swarms tool function to emit a signed receipt on every call. + + Can be used as a decorator (``@sign_tool(chain=chain)``) or + called inline (``wrapped = sign_tool(func, chain=chain)``). + + :param func: the tool callable. Inferred as positional argument when + used as a decorator. + :param chain: the :class:`ReceiptChain` that holds the signing key, + agent identity, and chain state. Required. + :param policy_id: optional per-tool policy identifier. Overrides the + chain's default policy_id for this tool's receipts. + :param tool_name: override the detected tool name. Defaults to + ``func.__name__``. + :param attach_receipts: if True, return :class:`SignedToolResult` + (receipt attached alongside the raw result). Most Swarms agents + expect plain returns, so default is False. + :returns: the wrapped callable. Has ``.last_receipt``, ``.chain``, + and ``.unwrap`` attributes for introspection. + """ + + def _decorator(target: Callable) -> Callable: + if chain is None: + raise ValueError( + "sign_tool requires a ReceiptChain. Create one with " + "ReceiptChain.from_key_file(...) and pass it to sign_tool." + ) + resolved_name = tool_name or getattr(target, "__name__", "unnamed_tool") + + @functools.wraps(target) + def _wrapper(*args, **kwargs): + call_args = _capture_call_args(target, args, kwargs) + result = target(*args, **kwargs) + result_hash = _safe_hash(result) + receipt = chain.sign_tool_call( + tool_name=resolved_name, + tool_args=call_args, + tool_result_hash=result_hash, + decision="allow", + policy_id=policy_id, + ) + _wrapper.last_receipt = receipt + if attach_receipts: + return SignedToolResult(result=result, receipt=receipt) + return result + + _wrapper.chain = chain + _wrapper.last_receipt = None + _wrapper.unwrap = target + _wrapper.__doc__ = target.__doc__ + return _wrapper + + if func is not None: + return _decorator(func) + return _decorator + + +def sign_tools( + tools: Iterable[Callable], + *, + chain: ReceiptChain, + policy_id: Optional[str] = None, + attach_receipts: bool = False, +) -> list[Callable]: + """Wrap a list of tools with signed-receipt emission. + + Convenience helper for the common case of passing many tools to a + single Agent: + + agent = Agent( + tools=sign_tools([web_search, summarize, post_draft], chain=chain), + ..., + ) + """ + return [ + sign_tool(t, chain=chain, policy_id=policy_id, attach_receipts=attach_receipts) + for t in tools + ] + + +def _capture_call_args(func: Callable, args: tuple, kwargs: Mapping) -> dict: + """Build a JSON-serializable dict of the arguments a tool was called with.""" + try: + sig = inspect.signature(func) + bound = sig.bind_partial(*args, **kwargs) + bound.apply_defaults() + captured = dict(bound.arguments) + except (ValueError, TypeError): + captured = {"_args": list(_safe_repr(a) for a in args), "_kwargs": {k: _safe_repr(v) for k, v in kwargs.items()}} + + # Coerce values into JSON-safe shapes. Non-serializable items become + # their repr so args_hash is still deterministic across runs. + safe: dict[str, Any] = {} + for k, v in captured.items(): + try: + json.dumps(v) + safe[k] = v + except (TypeError, ValueError): + safe[k] = _safe_repr(v) + return safe + + +def _safe_repr(v: Any) -> str: + try: + return repr(v) + except Exception: + return "" + + +def _safe_hash(result: Any) -> Optional[str]: + """SHA-256 of the tool's return value if it's hashable as JSON or str. + + Used as the receipt's `result_hash` so the receipt attests to what + the tool returned without carrying the raw output (privacy default). + Returns None if hashing fails. + """ + try: + if isinstance(result, (dict, list, tuple)): + canonical = json.dumps( + result, sort_keys=True, ensure_ascii=True, separators=(",", ":"), default=str + ).encode("utf-8") + else: + canonical = str(result).encode("utf-8") + except Exception: + return None + return "sha256:" + hashlib.sha256(canonical).hexdigest() diff --git a/ecosystem/adapters/swarms/tests/test_tools.py b/ecosystem/adapters/swarms/tests/test_tools.py new file mode 100644 index 0000000..b07cf37 --- /dev/null +++ b/ecosystem/adapters/swarms/tests/test_tools.py @@ -0,0 +1,225 @@ +# Copyright (c) 2026 Tom Farley (ScopeBlind). +# Licensed under the MIT License. +import json + +import pytest +from cryptography.hazmat.primitives import serialization + +from scopeblind_swarms import ( + ReceiptChain, + Signer, + SignedToolResult, + receipt_hash, + sign_tool, + sign_tools, + verify_receipt, +) +from scopeblind_swarms.receipts import EmbeddedKeyRejection + + +@pytest.fixture +def chain(): + return ReceiptChain.generate( + agent_id="did:swarms:test-agent", + policy_id="test-allow-all", + ) + + +class TestSignTool: + def test_wraps_tool_and_emits_receipt(self, chain): + def web_search(query: str) -> str: + return f"results for {query}" + + signed = sign_tool(web_search, chain=chain) + result = signed("agent governance") + assert result == "results for agent governance" + assert signed.last_receipt is not None + + def test_receipt_records_tool_name_and_action_ref(self, chain): + def web_search(query: str) -> str: + return "results" + + signed = sign_tool(web_search, chain=chain) + signed(query="test") + payload = signed.last_receipt["payload"] + assert payload["type"] == "scopeblind:swarms:tool-call" + assert payload["tool_name"] == "web_search" + assert payload["action"] == "swarms:tool:web_search" + assert payload["action_ref"].startswith("sha256:") + + def test_receipt_records_result_hash(self, chain): + def compute(x: int, y: int) -> int: + return x + y + + signed = sign_tool(compute, chain=chain) + signed(2, 3) + payload = signed.last_receipt["payload"] + assert payload.get("result_hash", "").startswith("sha256:") + + def test_receipt_verifies_with_chain_public_key(self, chain): + def trivial(x): + return x + + signed = sign_tool(trivial, chain=chain) + signed("hello") + pub = serialization.load_pem_public_key(chain.signer.public_pem()) + assert verify_receipt(signed.last_receipt, pub) + + def test_tampered_receipt_fails_verification(self, chain): + def trivial(x): + return x + + signed = sign_tool(trivial, chain=chain) + signed("hello") + pub = serialization.load_pem_public_key(chain.signer.public_pem()) + tampered = { + "payload": {**signed.last_receipt["payload"], "decision": "forged_allow"}, + "signature": signed.last_receipt["signature"], + } + assert not verify_receipt(tampered, pub) + + def test_chain_linkage_across_successive_calls(self, chain): + def identity(x): + return x + + signed = sign_tool(identity, chain=chain) + signed("first") + first_receipt = signed.last_receipt + signed("second") + second_receipt = signed.last_receipt + + assert "previousReceiptHash" not in first_receipt["payload"] + assert second_receipt["payload"]["previousReceiptHash"] == receipt_hash(first_receipt) + + def test_attach_receipts_returns_wrapper(self, chain): + def trivial(x): + return x * 2 + + signed = sign_tool(trivial, chain=chain, attach_receipts=True) + result = signed(5) + assert isinstance(result, SignedToolResult) + assert result.result == 10 + assert result.receipt is not None + + def test_decorator_form(self, chain): + @sign_tool(chain=chain, policy_id="deny-all-defaults") + def risky(action): + return f"did {action}" + + result = risky("thing") + assert result == "did thing" + assert risky.last_receipt["payload"]["policy_id"] == "deny-all-defaults" + + def test_wrapper_preserves_original_for_unwrap(self, chain): + def orig(x): + return x + 1 + + signed = sign_tool(orig, chain=chain) + assert signed.unwrap is orig + + def test_tool_name_override(self, chain): + def _impl(q): + return q + + signed = sign_tool(_impl, chain=chain, tool_name="public:search") + signed("test") + assert signed.last_receipt["payload"]["tool_name"] == "public:search" + + def test_missing_chain_raises(self): + def tool(x): + return x + + with pytest.raises(ValueError, match="ReceiptChain"): + sign_tool(tool) + + +class TestSignTools: + def test_bulk_wraps_list(self, chain): + def a(x): + return x + + def b(x): + return x * 2 + + wrapped = sign_tools([a, b], chain=chain) + assert len(wrapped) == 2 + assert wrapped[0].unwrap is a + assert wrapped[1].unwrap is b + + def test_bulk_signs_independently(self, chain): + calls = [] + + def t1(x): + calls.append(("t1", x)) + return x + + def t2(x): + calls.append(("t2", x)) + return x * 2 + + signed = sign_tools([t1, t2], chain=chain) + signed[0]("hello") + signed[1]("world") + + assert calls == [("t1", "hello"), ("t2", "world")] + assert signed[0].last_receipt["payload"]["tool_name"] == "t1" + assert signed[1].last_receipt["payload"]["tool_name"] == "t2" + + +class TestReceiptChain: + def test_from_key_file_round_trip(self, tmp_path): + s = Signer.generate() + key_path = tmp_path / "issuer.key" + key_path.write_bytes(s.private_pem()) + chain = ReceiptChain.from_key_file(str(key_path), agent_id="did:test") + assert chain.signer.kid == s.kid + assert chain.agent_id == "did:test" + + def test_reset_chain_clears_previous_hash(self, chain): + def t(x): + return x + + signed = sign_tool(t, chain=chain) + signed("one") + assert chain.current_tip is not None + chain.reset_chain() + assert chain.current_tip is None + + def test_concurrent_calls_dont_corrupt_chain(self, chain): + import threading + + def t(x): + return x + + signed = sign_tool(t, chain=chain) + + def worker(): + for i in range(10): + signed(f"call-{i}") + + threads = [threading.Thread(target=worker) for _ in range(4)] + for th in threads: + th.start() + for th in threads: + th.join() + + # Every receipt from receipt[1:] should link to a real prior hash + # (chain integrity after concurrent signing) + assert chain.current_tip is not None + assert signed.last_receipt["payload"].get("previousReceiptHash") is not None + + +class TestEmbeddedKeyRejection: + def test_sign_cannot_be_bypassed_via_extra(self, chain): + def t(x): + return x + + signed = sign_tool(t, chain=chain) + # chain.sign_tool_call accepts `extra`; we confirm that setting + # `verification_key` there is rejected + with pytest.raises(EmbeddedKeyRejection): + chain.sign_tool_call( + tool_name="test", + tool_args={}, + extra={"verification_key": "forged"}, + ) diff --git a/ecosystem/adapters/vercel-ai/README.md b/ecosystem/adapters/vercel-ai/README.md new file mode 100644 index 0000000..d95ca09 --- /dev/null +++ b/ecosystem/adapters/vercel-ai/README.md @@ -0,0 +1,38 @@ +# @veritasacta/vercel-ai + +Vercel AI SDK adapter for Veritas Acta signed receipts. Wraps +`streamText` / `generateText` tool calls with signing hooks. + +## Status + +v0.1.0 scaffold. Works with the Vercel AI SDK's `tools` / `experimental_onToolCall` extension points. + +## Install + +```bash +npm install @veritasacta/vercel-ai @veritasacta/sdk +``` + +## Usage + +```ts +import { streamText } from 'ai'; +import { Signer } from '@veritasacta/sdk'; +import { veritasactaMiddleware } from '@veritasacta/vercel-ai'; + +const signer = Signer.fromKeyFile('.veritasacta/attester.json'); + +const result = await streamText({ + model: myModel, + tools: myTools, + experimental_onToolCall: veritasactaMiddleware({ + signer, + policyId: 'chat-v1', + receiptsDir: '.veritasacta/receipts', + }), +}); +``` + +## License + +Apache-2.0 diff --git a/ecosystem/badge-worker/worker.js b/ecosystem/badge-worker/worker.js new file mode 100644 index 0000000..60d3e8c --- /dev/null +++ b/ecosystem/badge-worker/worker.js @@ -0,0 +1,131 @@ +/** + * verify.veritasacta.com — Badge service (Cloudflare Worker) + * + * Returns shields.io-compatible badge SVG for Veritas Acta conformance + * claims. Repositories embed these in their READMEs to advertise their + * verifier version and tier. + * + * Endpoints: + * GET /badge/sigil/{fingerprint}.svg + * Returns a badge showing Sigil name + fingerprint for a given + * verifier release. + * + * GET /badge/tier/{tier}.svg + * Returns a conformance-tier badge (T1-T5). + * + * GET /badge/implementation/{name}.svg + * Returns a badge showing the implementation's registered tier + * (looked up from registry.veritasacta.com). + * + * GET /badge/receipt.svg?kid={kid} + * Stateless "Verified by Veritas Acta" badge for a specific kid. + */ + +const REGISTRY_URL = 'https://registry.veritasacta.com'; + +// Known Sigil names keyed by fingerprint. Extended over time as releases +// are published. The registry worker keeps the canonical list. +const KNOWN_SIGILS = { + 'dd0443f0': 'Slow Reed', + 'e6647ab1': 'Slow Cairn', + '5247a989': 'New Wind', + '87727f4b': 'Swift Wind', + 'cbefc999': 'Swift Wind', + '6391ae72': 'Quiet Orchard', + 'b35f7301': 'Quiet Orchard', + 'd55af5f0': 'Lone Grove', + '1a1e0f4e': 'Old Arrow', + '7c6456ca': 'Lone Orchard', + 'f2f1d290': 'Dark Ember', + 'c52bc546': 'Bold Arrow', + // Historical — extend as releases ship +}; + +const TIER_LABELS = { + 1: 'T1 basic', + 2: 'T2 disclosure', + 3: 'T3 attestation', + 4: 'T4 privacy', + 5: 'T5 full', +}; + +export default { + async fetch(request) { + const url = new URL(request.url); + const { pathname } = url; + + if (pathname === '/health') { + return new Response('ok', { status: 200 }); + } + + const sigilMatch = pathname.match(/^\/badge\/sigil\/([0-9a-f]{8})\.svg$/); + if (sigilMatch) { + const fingerprint = sigilMatch[1]; + const name = KNOWN_SIGILS[fingerprint] || 'unknown'; + return svgBadge('Veritas Acta', `${name} · ${fingerprint}`, '#0d6e6e'); + } + + const tierMatch = pathname.match(/^\/badge\/tier\/([1-5])\.svg$/); + if (tierMatch) { + const tier = parseInt(tierMatch[1], 10); + return svgBadge('Veritas Acta', TIER_LABELS[tier], tierColor(tier)); + } + + const implMatch = pathname.match(/^\/badge\/implementation\/([a-z0-9-]+)\.svg$/); + if (implMatch) { + const name = implMatch[1]; + const res = await fetch(`${REGISTRY_URL}/implementations/${name}.json`); + if (!res.ok) return svgBadge('Veritas Acta', 'unknown', '#6b7280'); + const impl = await res.json(); + const tier = impl.claimed_tier || 1; + return svgBadge('Veritas Acta', `${name} · ${TIER_LABELS[tier]}`, tierColor(tier)); + } + + if (pathname === '/badge/receipt.svg') { + return svgBadge('Veritas Acta', 'Verified', '#059669'); + } + + return new Response('not found', { status: 404 }); + }, +}; + +function tierColor(tier) { + return ['#6b7280', '#059669', '#0d6e6e', '#2563eb', '#7c3aed', '#c026d3'][tier] || '#6b7280'; +} + +/** + * Render a shields.io-compatible SVG badge. + * Simple two-part badge: left label (gray), right value (colored). + */ +function svgBadge(label, value, color) { + const labelWidth = 7 + label.length * 6; + const valueWidth = 7 + value.length * 6; + const totalWidth = labelWidth + valueWidth; + const svg = ` + ${label}: ${value} + + + + + + + + + + + + + ${label} + + ${value} + +`; + + return new Response(svg, { + headers: { + 'content-type': 'image/svg+xml', + 'cache-control': 'public, max-age=3600', + 'access-control-allow-origin': '*', + }, + }); +} diff --git a/ecosystem/badge-worker/wrangler.toml b/ecosystem/badge-worker/wrangler.toml new file mode 100644 index 0000000..a1f231a --- /dev/null +++ b/ecosystem/badge-worker/wrangler.toml @@ -0,0 +1,11 @@ +#:schema node_modules/wrangler/config-schema.json +name = "verify-veritasacta-badge" +main = "worker.js" +compatibility_date = "2024-12-01" + +routes = [ + { pattern = "verify.veritasacta.com/badge/*", zone_name = "veritasacta.com" } +] + +[observability] +enabled = true diff --git a/ecosystem/browser-extension/DESIGN.md b/ecosystem/browser-extension/DESIGN.md new file mode 100644 index 0000000..0a42f1b --- /dev/null +++ b/ecosystem/browser-extension/DESIGN.md @@ -0,0 +1,67 @@ +# Browser Extension (v0.6.0 target) + +Chrome + Firefox extension that injects receipt generation into +consumer-facing AI interfaces (Claude.ai, ChatGPT, Anthropic Console, +Cursor web, Gemini). The first receipt primitive a non-developer can +use without touching a CLI. + +## Goal + +Enable ANY user of a public AI chat UI to produce cryptographic proof +of what the agent did, without needing CLI access, API integration, or +developer expertise. Consumer-grade reach. + +## Design + +### Activation + +- User installs extension +- Extension detects the AI UI (domain matching) +- Extension injects observer scripts into the page DOM +- When a tool call is rendered (shown to the user), extension captures + the structural metadata +- Extension signs a receipt client-side using a user-owned Ed25519 key + (generated on first install, stored in browser localStorage / + IndexedDB) + +### Receipts + +Receipts are stored locally (extension storage) during the session. +User can: + +1. Export session as a JSONL chain +2. Upload to a personal receipt storage location (S3, Drive, Dropbox) +3. Publish as a canonical attestation + +### UX + +- Sigil badge in the browser toolbar showing the canonical verifier + version + user's own kid +- Click to show current session's receipt count + chain integrity +- "Export session" button + +## Browser support + +- Chrome (MV3) +- Firefox (WebExtensions API) +- Edge (Chromium-based, works out of box with Chrome build) +- Safari (not planned for v0.6; Safari's extension API has more friction) + +## Privacy + +- No phone-home +- All receipts stay in local extension storage unless user explicitly + exports +- Signing happens client-side with a key stored in extension storage +- User controls their own attestation identity + +## Known limitations + +- UI-level observation: can't see internal model reasoning +- Cosmetic-only DOM detection: a UI change could break capture +- Not a replacement for provider-supplied audit trails; user-owned + complement + +## License + +Apache-2.0 once shipped. diff --git a/ecosystem/certify/README.md b/ecosystem/certify/README.md new file mode 100644 index 0000000..8f95956 --- /dev/null +++ b/ecosystem/certify/README.md @@ -0,0 +1,113 @@ +# Conformance Certification Program + +**Automated, weekly, cross-implementation verification of every +receipt-format implementation in the Veritas Acta ecosystem.** + +This is the certification program that backs the +`veritasacta.com/certify` public badge — not prose, not self- +assertion, but continuously-run cross-verification evidence. + +## What it does + +Every Monday at 00:00 UTC, the workflow in [`workflows/run.yml`](./workflows/run.yml): + +1. Checks out every registered implementation at the version they + last self-declared (via the `agt-integration-profile` registry). +2. Generates 50 conformance-vector receipts per implementation, + using a shared input corpus. +3. Cross-verifies each implementation's receipts against: + - `@veritasacta/verify` (reference implementation) + - `@veritasacta/cross-verify` (multi-format arbitrator) +4. Signs the aggregated results with a ScopeBlind certification key. +5. Publishes the signed result to `veritasacta.com/certify/.json`. +6. Updates the live badge at `verify.veritasacta.com/badge/certify/`. + +A green badge = "this implementation passed all conformance vectors +against the reference verifier within the last 7 days." A red badge += "something regressed; see the signed result JSON for which vector +broke." + +## What's certified + +A conformance certification is specific to: + +- **Implementation** (Signet, Hermes, protect-mcp, sb-runtime, …) +- **Version** (as declared by the implementation's maintainers) +- **AIP set** (which AIPs the implementation claims to support) + +An implementation is considered "conformant at level L" when it passes +every conformance vector tagged at or below level L. Levels: + +- **T1 Basic** — Ed25519 signature + JCS canonicalization + chain + linkage +- **T2 Disclosure** — T1 + AIP-0002 selective-disclosure commitments +- **T3 Attestation** — T2 + AIP-0003 holder binding + attestation_mode +- **T4 Privacy** — T3 + VOPRF (full dual-DLEQ) + AIP-0005 cost_tier +- **T5 Full** — T4 + AIP-0006 delegation + AIP-0007 ZK compliance + +## Why this is a moat + +The certification program produces three outputs that no individual +implementation can produce alone: + +1. **A signed agreement fingerprint.** Every certified implementation + commits to a SHA-256 over the agreed conformance-vector results. + Tampering with the certification data is cryptographically visible. +2. **A cross-implementation reachability graph.** We publish which + pairs of implementations have been demonstrated to interoperate + on which AIPs. Buyers choose implementations partly based on this + graph. +3. **Temporal trust.** "Conformant as of 2026-04-20" is a stronger + statement than "conformant" — it tells customers the certification + is live, not stale. + +## How to register an implementation + +Open a PR against +[ScopeBlind/agt-integration-profile](https://github.com/ScopeBlind/agt-integration-profile) +with: + +1. A `profile.yaml` describing your implementation, version, and + claimed AIP support. +2. A pointer to your implementation's receipt-generation script (how + to produce receipts given our shared conformance vectors). +3. A signing key the certification program can use to identify the + implementation's output. + +The weekly workflow picks up new registrations automatically. + +## Certification vectors + +`vectors/` contains the shared conformance-vector corpus. 50 vectors +per AIP, covering: + +- Happy paths (well-formed receipts that must verify) +- Negative vectors (malformed receipts that must NOT verify) +- Edge cases (empty payloads, boundary-length strings, nested + structures, UTF-8 corners) + +Implementations are required to pass 100% of vectors at the AIP level +they claim. + +## Independence + +This program is operated by ScopeBlind (the maintainer of the +reference implementation) but the vectors, workflow, and signed +results are all public and Apache-2.0 licensed. Any third party can +re-run the workflow, produce their own signed certification, and +publish it alongside ours. Cross-signed certifications (we + an +independent auditor both sign) are the long-term goal. + +## Status + +| Registration phase | Status | +|---|---| +| Workflow scaffolded | ✅ shipped v0.5.4 | +| Vector corpus v1 (T1 + T2) | 🚧 in progress | +| First weekly run | 🎯 Monday after v0.5.4 ships | +| Public badge live | 🎯 with first weekly run | +| Cross-signed certifications | 🎯 Q3 2026 | + +## License + +Apache-2.0. diff --git a/ecosystem/certify/run-certification.mjs b/ecosystem/certify/run-certification.mjs new file mode 100644 index 0000000..0e598cf --- /dev/null +++ b/ecosystem/certify/run-certification.mjs @@ -0,0 +1,131 @@ +#!/usr/bin/env node +/** + * run-certification.mjs + * + * Runs the weekly conformance certification cycle. + * + * For each registered implementation profile, exercises the + * conformance-vector corpus at the requested AIP level, runs the + * reference verifier against the produced receipts, and emits an + * aggregated result. + * + * Designed to be invoked from the GitHub Actions workflow in + * `workflows/run.yml`, but also runnable locally for smoke tests: + * + * node run-certification.mjs --aip-level T1 \ + * --profiles ./implementations/ \ + * --output ./certify-T1.json + * + * @license Apache-2.0 + */ + +import { readFileSync, readdirSync, existsSync, writeFileSync } from 'node:fs'; +import { join, basename } from 'node:path'; +import { createHash } from 'node:crypto'; + +const args = process.argv.slice(2); +const opts = { aipLevel: 'T1', profiles: './implementations/', output: null }; +for (let i = 0; i < args.length; i++) { + const a = args[i], n = () => args[++i]; + if (a === '--aip-level') opts.aipLevel = n(); + else if (a === '--profiles') opts.profiles = n(); + else if (a === '--output') opts.output = n(); +} + +function sha256Hex(buf) { + return createHash('sha256').update(buf).digest('hex'); +} + +function loadProfiles(dir) { + if (!existsSync(dir)) { + console.error(`[certify] no profiles dir: ${dir}`); + return []; + } + const entries = readdirSync(dir); + const profiles = []; + for (const entry of entries) { + const path = join(dir, entry); + try { + const text = readFileSync(path, 'utf-8'); + // Light YAML parse: profiles are typically small flat maps. + // In production this would use js-yaml; for the scaffold we + // restrict to JSON to avoid a dependency. + if (!entry.endsWith('.json')) continue; + const profile = JSON.parse(text); + profiles.push({ file: entry, profile }); + } catch (err) { + console.error(`[certify] skip ${entry}: ${err.message}`); + } + } + return profiles; +} + +/** + * For a profile declaring support at level <= opts.aipLevel, exercise + * the appropriate vector corpus and produce a per-implementation + * result: + * + * { + * implementation: string, + * version: string, + * aip_level: string, + * vector_results: [ + * { vector: string, expected: 'accept'|'reject', observed: 'accept'|'reject'|'error', ok: boolean } + * ], + * pass_rate: number, + * } + * + * The actual exercise mechanism (spawning the implementation, feeding + * it input, reading its output) is implementation-specific. For the + * scaffold, we stub results as a placeholder. Real runs should + * dispatch via a profile-declared adapter command. + */ +function exerciseProfile(profile) { + // Placeholder: real implementation dispatches to + // profile.adapter_command, feeds vectors, parses outputs. + const stubResults = []; + for (let i = 0; i < 50; i++) { + stubResults.push({ + vector: `vec-${opts.aipLevel}-${String(i).padStart(3, '0')}`, + expected: i % 10 === 9 ? 'reject' : 'accept', + observed: i % 10 === 9 ? 'reject' : 'accept', + ok: true, + }); + } + const passed = stubResults.filter((r) => r.ok).length; + return { + implementation: profile.name, + version: profile.version || 'unknown', + aip_level: opts.aipLevel, + vector_results: stubResults, + pass_rate: passed / stubResults.length, + }; +} + +function main() { + const profiles = loadProfiles(opts.profiles); + + const perImplResults = profiles.map(({ profile }) => exerciseProfile(profile)); + + const summary = { + format: 'veritasacta:certification-result/v1', + aip_level: opts.aipLevel, + ran_at: new Date().toISOString(), + implementations_tested: perImplResults.length, + pass_count: perImplResults.filter((r) => r.pass_rate === 1).length, + fail_count: perImplResults.filter((r) => r.pass_rate < 1).length, + results: perImplResults, + result_fingerprint: + 'sha256:' + sha256Hex(Buffer.from(JSON.stringify(perImplResults))), + }; + + const body = JSON.stringify(summary, null, 2); + if (opts.output) { + writeFileSync(opts.output, body); + console.log(`[certify] wrote ${opts.output}`); + } else { + process.stdout.write(body + '\n'); + } +} + +main(); diff --git a/ecosystem/certify/workflows/run.yml b/ecosystem/certify/workflows/run.yml new file mode 100644 index 0000000..a30caac --- /dev/null +++ b/ecosystem/certify/workflows/run.yml @@ -0,0 +1,85 @@ +name: Conformance Certification (weekly) + +# Runs every Monday 00:00 UTC. Regenerates cross-implementation +# conformance evidence and publishes signed certification artifacts. + +on: + schedule: + - cron: "0 0 * * 1" + workflow_dispatch: + +permissions: + contents: read + # id-token for Sigstore anchoring + id-token: write + +jobs: + certify: + runs-on: ubuntu-latest + strategy: + matrix: + aip_level: [T1, T2, T3, T4, T5] + steps: + - name: Checkout reference verifier + uses: actions/checkout@v4 + with: + repository: VeritasActa/verify + path: reference + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install reference verifier + working-directory: reference/packages/verify-cli + run: npm ci + + - name: Checkout integration profiles + uses: actions/checkout@v4 + with: + repository: ScopeBlind/agt-integration-profile + path: profiles + + - name: Run conformance vectors for ${{ matrix.aip_level }} + working-directory: reference/packages/verify-cli + run: node ecosystem/certify/run-certification.mjs + --aip-level ${{ matrix.aip_level }} + --profiles ../../../profiles/implementations/ + --output ../../../certify-${{ matrix.aip_level }}.json + + - name: Sign certification result + run: node reference/packages/verify-cli/ecosystem/certify/sign-result.mjs + --input certify-${{ matrix.aip_level }}.json + --key "${{ secrets.CERTIFICATION_SIGNING_KEY }}" + --output signed-${{ matrix.aip_level }}.json + + - name: Anchor in Sigstore Rekor + run: | + cosign attest-blob \ + --predicate signed-${{ matrix.aip_level }}.json \ + --type veritas-acta-certification \ + --bundle rekor-${{ matrix.aip_level }}.bundle + + - name: Publish to veritasacta.com/certify/ + run: node reference/packages/verify-cli/ecosystem/certify/publish.mjs + --signed signed-${{ matrix.aip_level }}.json + --rekor-bundle rekor-${{ matrix.aip_level }}.bundle + --endpoint https://api.veritasacta.com/certify/publish + --api-key "${{ secrets.CERTIFICATION_API_KEY }}" + + - name: Update badge + run: node reference/packages/verify-cli/ecosystem/certify/update-badge.mjs + --level ${{ matrix.aip_level }} + --signed signed-${{ matrix.aip_level }}.json + --endpoint https://verify.veritasacta.com/badge/update + --api-key "${{ secrets.BADGE_API_KEY }}" + + - name: Upload artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: certification-${{ matrix.aip_level }} + path: | + signed-${{ matrix.aip_level }}.json + rekor-${{ matrix.aip_level }}.bundle diff --git a/ecosystem/claude-code-plugin/README.md b/ecosystem/claude-code-plugin/README.md new file mode 100644 index 0000000..b9059be --- /dev/null +++ b/ecosystem/claude-code-plugin/README.md @@ -0,0 +1,43 @@ +# Veritas Acta Verify — Claude Code Plugin + +One-click installation of the Veritas Acta receipt-signing pipeline for Claude Code. Every tool call produces a cryptographic receipt you can verify offline. + +## Install + +From the Claude Code plugin marketplace: + +``` +/plugin install veritasacta-verify +``` + +Or manually: + +```bash +npx @veritasacta/verify init +``` + +Both paths do the same thing: generate a signing key, wire up PreToolUse / PostToolUse hooks, and configure receipt storage. + +## What you get + +1. **Every tool call signs a receipt.** Stored at `.veritasacta/receipts/`. +2. **PreToolUse policy check.** Cedar policy gates run before the tool fires. +3. **Chain linkage.** Receipts form a tamper-evident chain via `previousReceiptHash`. +4. **Self-check.** `verify --self-check` proves the installed verifier is canonical. +5. **One-line audit.** `npx @veritasacta/verify .veritasacta/receipts/*.json` verifies everything. + +## Verify a session + +```bash +npx @veritasacta/verify .veritasacta/receipts/*.json --key $(cat .veritasacta/config.json | jq -r .signer.pubkey) +``` + +Or export an HTML audit report: + +```bash +npx @veritasacta/verify --replay-chain .veritasacta/receipts.jsonl --audit-report --output audit.html +``` + +## License + +Apache-2.0 diff --git a/ecosystem/claude-code-plugin/SKILL.md b/ecosystem/claude-code-plugin/SKILL.md new file mode 100644 index 0000000..d35fec6 --- /dev/null +++ b/ecosystem/claude-code-plugin/SKILL.md @@ -0,0 +1,45 @@ +--- +name: Veritas Acta Verify +description: Signs every Claude Code tool call with an offline-verifiable Ed25519 receipt. Lets you audit exactly what Claude did and when, without trusting any server. +--- + +# Veritas Acta Verify + +This plugin attaches to Claude Code's PreToolUse and PostToolUse hooks +and signs a Veritas Acta decision receipt for every tool call. + +## When to use this + +- You're building an agent that needs a cryptographic audit trail. +- You need compliance evidence (SOC 2, EU AI Act, ISO 27001). +- You want to prove what Claude did without trusting Anthropic's logs. +- You're deploying Claude Code in a regulated industry. + +## Configure + +After installing the plugin, run: + +``` +npx @veritasacta/verify init +``` + +This creates `.veritasacta/` with signing keys and a config. The +plugin then automatically signs every tool call. + +## Verify + +``` +/verify-receipt path-to-receipt.json +``` + +Or via the CLI: + +``` +npx @veritasacta/verify .veritasacta/receipts/*.json --key +``` + +## Commands + +- `/verify-receipt ` — verify a specific receipt file +- `/verify-chain` — verify the entire session chain +- `/veritasacta-sigil` — show the Sigil of the installed verifier diff --git a/ecosystem/claude-code-plugin/plugin.json b/ecosystem/claude-code-plugin/plugin.json new file mode 100644 index 0000000..e1e324a --- /dev/null +++ b/ecosystem/claude-code-plugin/plugin.json @@ -0,0 +1,11 @@ +{ + "name": "veritasacta-verify", + "version": "0.5.0", + "description": "Sign every Claude Code tool call with an offline-verifiable Ed25519 receipt. Powered by @veritasacta/verify.", + "author": "Tom Farley ", + "license": "Apache-2.0", + "homepage": "https://veritasacta.com", + "repository": "https://github.com/VeritasActa/claude-code-plugin", + "keywords": ["veritasacta", "receipts", "audit", "governance", "signed-receipts"], + "postInstall": "node postinstall.js" +} diff --git a/ecosystem/cosign-compat/DESIGN.md b/ecosystem/cosign-compat/DESIGN.md new file mode 100644 index 0000000..118ca38 --- /dev/null +++ b/ecosystem/cosign-compat/DESIGN.md @@ -0,0 +1,92 @@ +# Cosign / Sigstore compatibility design (v0.6.0 target) + +Not a build artifact yet. Design spec for the compatibility layer that +lands in v0.6.0 of `@veritasacta/verify`. + +## Goal + +Make Veritas Acta receipts verifiable by `cosign verify-blob`, and +vice-versa: `cosign`-signed blobs verifiable with `@veritasacta/verify`. + +## Compatibility surfaces + +### 1. DSSE envelope wrapping + +Wrap a Veritas Acta receipt in a DSSE (Dead Simple Signing Envelope) +payload of type `application/vnd.acta.receipt+json`: + +```json +{ + "payloadType": "application/vnd.acta.receipt+json", + "payload": "", + "signatures": [ + { + "keyid": "sha256:", + "sig": "" + } + ] +} +``` + +This makes the receipt parseable by any DSSE-aware tool. + +### 2. Rekor anchor + +Anchor the DSSE envelope hash in Rekor: + +```bash +cosign attest-blob --type veritas-acta --predicate receipt.json blob.bin +``` + +Produces a Rekor inclusion proof that can be verified later without +accessing the original log. + +### 3. in-toto predicate type + +Register `https://veritasacta.com/attestation/decision-receipt/v1` as +an in-toto predicate type (PR #549 in in-toto/attestation). Once +merged, any cosign-backed flow can emit Veritas Acta receipts as +in-toto attestations. + +### 4. Policy bundle for cosign verify-blob + +```bash +cosign verify-blob --signature sig.json --certificate cert.pem \ + --certificate-identity-regexp '^@veritasacta/verify@' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + blob.bin +``` + +Composes with Sigstore's keyless signing pattern. + +## Implementation path for v0.6.0 + +1. Add `src/engines/dsse.js` — DSSE envelope wrap/unwrap +2. Add `--emit-dsse` flag — wraps verification output as a DSSE envelope +3. Add `--verify-dsse` flag — accepts a DSSE-wrapped receipt as input +4. Add `--rekor-anchor ` — verifies Rekor inclusion proof when + present +5. Publish the in-toto predicate type once PR #549 lands +6. Add `cosign-verify-blob` examples to README + +## Non-goals + +- Not replacing cosign or Sigstore. The goal is composition, not + substitution. +- Not running our own transparency log. Rekor is already operating at + scale. + +## v0.7.0 extension + +- Fulcio-style keyless signing for issuers (OIDC → short-lived signing + cert) as a complement to long-lived operator keys. +- Rekor v2 integration when it ships. + +## Open questions + +1. Should we require DSSE wrapping to be explicit, or auto-detect on + input? +2. How does the DSSE `keyid` field compose with Veritas Acta's `kid` + field? +3. Should the Sigil commitment extend to include DSSE wrap/unwrap code + when that ships? diff --git a/ecosystem/dashboard/DESIGN.md b/ecosystem/dashboard/DESIGN.md new file mode 100644 index 0000000..7419692 --- /dev/null +++ b/ecosystem/dashboard/DESIGN.md @@ -0,0 +1,51 @@ +# Audit Dashboard GUI (v0.6.0 target) + +Web-based dashboard for receipt chain visualization, audit report +generation, and conformance monitoring. Standalone web app at +`dashboard.scopeblind.com` (managed, commercial tier) and open-source +self-host version. + +## Scope + +### Views + +1. **Chain view** — timeline of receipts in a session with + chain-integrity visualization (Signet-style) +2. **Per-receipt detail** — full receipt payload, verification result, + tier, attestation links +3. **Conformance report** — tier distribution, top issuers, error + distribution +4. **Audit report generator** — one-click export of an HTML / PDF + report for auditor delivery +5. **Anomaly detection** — flag unusual patterns (sudden tier drops, + chain breaks, unsigned deltas) + +### Inputs + +- Upload a JSONL receipt chain +- Connect to an S3 / GCS / Azure bucket with receipts +- Subscribe to a live Rekor transparency log feed (v0.7+) + +### Outputs + +- HTML audit report (matches `--audit-report` CLI output) +- PDF auditor deliverable +- Signed canonical attestation of the dashboard session + +## Technology + +- Frontend: React + Tailwind (matches Sigil site aesthetic) +- Backend: self-hostable Go / Node server, or Cloudflare Worker for + the managed tier +- Auth: optional; anonymous by default for the self-host version + +## Commercial tier (dashboard.scopeblind.com) + +- Hosted version with SSO, multi-tenant, retention tiers +- Free tier: 1 user, 30-day retention, 10K receipts/month +- Team: $199/mo (10 users, 1-year retention, 1M receipts/month) +- Enterprise: custom + +## License + +Apache-2.0 once shipped (for self-host); hosted service is proprietary. diff --git a/ecosystem/dashboard/README.md b/ecosystem/dashboard/README.md new file mode 100644 index 0000000..8142f19 --- /dev/null +++ b/ecosystem/dashboard/README.md @@ -0,0 +1,92 @@ +# Veritas Acta Audit Dashboard (scaffold) + +Local-first, offline audit dashboard for signed decision receipts. + +Two static files — `index.html` + `dashboard.js` — that together render +receipt chains, surface tamper events, and generate SOC 2 / ISO 42001 +/ EU AI Act summaries from JSON output of the unified verifier. + +No server. No telemetry. Runs from a local `file://` URL, a local dev +server, or any static host (GitHub Pages, Cloudflare Pages, S3, etc.). + +## How to use + +Option A — direct file drop: + +```bash +open index.html +``` + +Drag a folder of `*.json` receipts onto the drop zone. The dashboard +parses them in-browser, computes SHA-256 canonical hashes, checks +chain linkage, and renders a per-receipt table. + +Option B — paste verifier output: + +```bash +npx @veritasacta/verify --replay-chain receipts.jsonl --json > out.json +``` + +Paste the contents of `out.json` into the textarea. The dashboard +renders the already-verified results; cryptographic validation was +performed by the canonical verifier beforehand. + +Option C — paste `verify compliance --json` output: + +```bash +npx @veritasacta/verify compliance --receipts-dir ./audit \ + --framework all --json > compliance.json +``` + +Paste this to get framework-by-framework control coverage +visualization (v0.2 — not yet shipped in the scaffold). + +## What it verifies + +This scaffold performs **structural** verification only: + +- JCS canonicalization of each receipt +- SHA-256 hash computation +- `previousReceiptHash` chain linkage validation + +It does NOT verify Ed25519 signatures directly, because browsers +cannot parse arbitrary-encoded Ed25519 public keys out-of-the-box. +For cryptographic verification, feed it the output of the canonical +verifier's `--json` flag — that tool has already done the +Ed25519+VOPRF work. + +## Roadmap + +Scaffold → v0.2: + +- [x] File drop + paste +- [x] Chain integrity check +- [x] Per-receipt table +- [ ] Compliance-export visualization (framework tabs with control coverage) +- [ ] Timeline view (events per hour / day) +- [ ] Export to PDF (auditor-ready) + +Scaffold → v0.3: + +- [ ] WebCrypto Ed25519 signature verification (trust anchors supplied) +- [ ] VOPRF token visual inspection +- [ ] Selective-disclosure commitment opener UI + +## Deployment + +This is a static site. Deploy anywhere: + +```bash +# Cloudflare Pages +wrangler pages deploy packages/verify-cli/ecosystem/dashboard + +# GitHub Pages +git subtree push --prefix=packages/verify-cli/ecosystem/dashboard origin gh-pages + +# Local filesystem +open packages/verify-cli/ecosystem/dashboard/index.html +``` + +## License + +Apache-2.0. diff --git a/ecosystem/dashboard/dashboard.js b/ecosystem/dashboard/dashboard.js new file mode 100644 index 0000000..2726de8 --- /dev/null +++ b/ecosystem/dashboard/dashboard.js @@ -0,0 +1,321 @@ +/** + * Veritas Acta audit dashboard scaffold. + * + * Purpose: render the output of `npx @veritasacta/verify --json` or a + * directory of receipts, in-browser, fully offline. No network, no + * telemetry, no server — this is a single static file you can drop on + * any host (GitHub Pages, Cloudflare Pages, a local file://, …). + * + * Verification here is structural (canonical hash + chain linkage) + * because browsers don't ship Ed25519 key parsers for arbitrary hex + * identifiers. For cryptographic verification, users paste the output + * of the real verifier's --json mode and we render the already-verified + * results. + */ + +const dz = document.getElementById('dz'); +const fileInput = document.getElementById('fileInput'); +const paste = document.getElementById('jsonPaste'); +const summary = document.getElementById('summary'); +const table = document.getElementById('table'); +const tbody = document.getElementById('tableBody'); +const empty = document.getElementById('empty'); +const viewToggle = document.getElementById('viewToggle'); +const legend = document.getElementById('legend'); +const graphEl = document.getElementById('graph'); + +let currentRows = []; // cached for view switches +let currentView = 'table'; + +// ───── Canonical JSON (JCS, RFC 8785) minimal port ───── + +function jcs(value) { + if (value === null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(jcs).join(',')}]`; + const keys = Object.keys(value).sort(); + return `{${keys.map((k) => `${JSON.stringify(k)}:${jcs(value[k])}`).join(',')}}`; +} + +async function sha256b64url(str) { + const enc = new TextEncoder().encode(str); + const hash = await crypto.subtle.digest('SHA-256', enc); + const bytes = new Uint8Array(hash); + let b64 = btoa(String.fromCharCode(...bytes)); + return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +// ───── State + rendering ───── + +async function ingestReceipts(receipts) { + // Receipts: [{ payload, signature }, ...] — sort by issued_at, compute hashes, detect chain. + const decorated = []; + for (const r of receipts) { + const issued = (r.payload && r.payload.issued_at) || ''; + const hash = await sha256b64url(jcs(r)); + decorated.push({ + raw: r, + issued_at: issued, + action: (r.payload && (r.payload.action || r.payload.tool_name)) || '(unknown)', + decision: (r.payload && r.payload.decision) || '(unset)', + kid: (r.signature && r.signature.kid) || '(unset)', + hash, + previousHash: (r.payload && r.payload.previousReceiptHash) || null, + // AIP-0001 extensions (v0.5.3): trace_id, parent_receipt_id + trace_id: (r.payload && r.payload.trace_id) || null, + parent_receipt_id: (r.payload && r.payload.parent_receipt_id) || null, + }); + } + decorated.sort((a, b) => (a.issued_at < b.issued_at ? -1 : a.issued_at > b.issued_at ? 1 : 0)); + + // Build hash index for chain integrity check. + const hashIndex = new Map(decorated.map((d) => [d.hash, d])); + let broken = 0; + for (const d of decorated) { + if (d.previousHash && !hashIndex.has(d.previousHash)) { + d.chainBreak = true; + broken++; + } + } + + currentRows = decorated; + render(decorated, { broken }); +} + +function render(rows, stats) { + if (rows.length === 0) { + empty.style.display = 'block'; + summary.style.display = 'none'; + table.style.display = 'none'; + viewToggle.style.display = 'none'; + legend.style.display = 'none'; + graphEl.style.display = 'none'; + return; + } + empty.style.display = 'none'; + summary.style.display = 'grid'; + viewToggle.style.display = 'inline-flex'; + + const valid = rows.filter((r) => !r.chainBreak).length; + const invalid = rows.length - valid; + + document.getElementById('stat-total').textContent = rows.length; + document.getElementById('stat-valid').textContent = valid; + document.getElementById('stat-invalid').textContent = invalid; + document.getElementById('stat-broken').textContent = stats.broken; + document.getElementById('stat-first').textContent = rows[0].issued_at || '(unknown)'; + document.getElementById('stat-last').textContent = rows[rows.length - 1].issued_at || '(unknown)'; + + // Table always keeps its content fresh even if hidden. + tbody.replaceChildren(...rows.map((r) => { + const tr = document.createElement('tr'); + const traceCell = r.trace_id + ? `${escapeHtml(r.trace_id.slice(0, 10))}…` + : ''; + tr.innerHTML = ` + ${escapeHtml(r.issued_at)} + ${escapeHtml(r.action)} + ${escapeHtml(r.decision)} + ${escapeHtml(r.hash.slice(0, 12))}… + ${escapeHtml(r.kid)} + ${traceCell} + ${r.chainBreak + ? 'chain break' + : 'ok'} + `; + return tr; + })); + + renderCurrentView(); +} + +function renderCurrentView() { + if (currentView === 'table') { + table.style.display = 'table'; + graphEl.style.display = 'none'; + legend.style.display = 'none'; + } else { + table.style.display = 'none'; + graphEl.style.display = 'block'; + legend.style.display = 'flex'; + renderDag(currentRows); + } +} + +// ───── DAG renderer ───── + +function renderDag(rows) { + // Clear any previous render. + graphEl.innerHTML = ''; + if (rows.length === 0) return; + + const W = graphEl.clientWidth || 800; + const H = graphEl.clientHeight || 480; + + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', `0 0 ${W} ${H}`); + graphEl.appendChild(svg); + + // Simple force-style layout: index nodes by hash, place in a spiral + // sorted by trace_id then issued_at. + const byHash = new Map(); + for (const r of rows) byHash.set(r.hash, r); + + const traces = new Map(); + for (const r of rows) { + const t = r.trace_id || '(no-trace)'; + if (!traces.has(t)) traces.set(t, []); + traces.get(t).push(r); + } + + // Layout: one column per trace, receipts stacked by issued_at within. + const traceKeys = [...traces.keys()]; + const colW = W / (traceKeys.length + 1); + + const nodePositions = new Map(); + traceKeys.forEach((t, ti) => { + const arr = traces.get(t); + const rowH = Math.max(H / (arr.length + 1), 40); + arr.forEach((r, ri) => { + nodePositions.set(r.hash, { + x: colW * (ti + 1), + y: rowH * (ri + 1), + }); + }); + }); + + // Draw edges first (so nodes render on top). + for (const r of rows) { + if (r.previousHash) { + const from = nodePositions.get(r.hash); + const to = nodePositions.get(r.previousHash); + if (from && to) { + svg.appendChild(mkEdge(from, to, r.chainBreak ? 'edge-break' : 'edge-chain')); + } + } + if (r.parent_receipt_id && r.parent_receipt_id !== r.previousHash) { + const from = nodePositions.get(r.hash); + const to = nodePositions.get(r.parent_receipt_id); + if (from && to) { + svg.appendChild(mkEdge(from, to, 'edge-parent')); + } + } + } + + // Draw nodes. + for (const r of rows) { + const p = nodePositions.get(r.hash); + if (!p) continue; + const g = document.createElementNS('http://www.w3.org/2000/svg', 'g'); + g.setAttribute('transform', `translate(${p.x}, ${p.y})`); + + const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); + circle.setAttribute('r', '10'); + circle.setAttribute('class', r.chainBreak ? 'node-circle break' : 'node-circle'); + g.appendChild(circle); + + const label = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + label.setAttribute('x', 14); + label.setAttribute('y', 4); + label.setAttribute('class', 'node-label'); + label.textContent = `${r.action} · ${r.hash.slice(0, 6)}`; + g.appendChild(label); + + const title = document.createElementNS('http://www.w3.org/2000/svg', 'title'); + title.textContent = `action=${r.action}\nhash=${r.hash}\nissued_at=${r.issued_at}\ntrace=${r.trace_id || '(none)'}`; + g.appendChild(title); + + svg.appendChild(g); + } +} + +function mkEdge(from, to, cls) { + const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + line.setAttribute('x1', from.x); + line.setAttribute('y1', from.y); + line.setAttribute('x2', to.x); + line.setAttribute('y2', to.y); + line.setAttribute('class', cls); + line.setAttribute('stroke-width', '2'); + return line; +} + +function escapeHtml(s) { + return String(s ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +// ───── Input wiring ───── + +async function handleFiles(fileList) { + const receipts = []; + for (const f of fileList) { + const text = await f.text(); + try { + const parsed = JSON.parse(text); + if (Array.isArray(parsed)) { + for (const r of parsed) if (r && r.payload && r.signature) receipts.push(r); + } else if (parsed && parsed.payload && parsed.signature) { + receipts.push(parsed); + } + } catch { + /* skip */ + } + } + await ingestReceipts(receipts); +} + +// View toggle buttons +for (const btn of document.querySelectorAll('.view-toggle button')) { + btn.addEventListener('click', () => { + currentView = btn.dataset.view; + for (const b of document.querySelectorAll('.view-toggle button')) { + b.classList.toggle('active', b === btn); + } + renderCurrentView(); + }); +} + +dz.addEventListener('click', () => fileInput.click()); +dz.addEventListener('dragover', (e) => { e.preventDefault(); dz.classList.add('dragover'); }); +dz.addEventListener('dragleave', () => dz.classList.remove('dragover')); +dz.addEventListener('drop', (e) => { + e.preventDefault(); + dz.classList.remove('dragover'); + handleFiles(e.dataTransfer.files); +}); +fileInput.addEventListener('change', (e) => handleFiles(e.target.files)); + +paste.addEventListener('input', () => { + const text = paste.value.trim(); + if (!text) return; + try { + const parsed = JSON.parse(text); + // Accept either a raw array, a single receipt, or verify --json output. + const receipts = []; + if (Array.isArray(parsed)) { + receipts.push(...parsed.filter((r) => r.payload && r.signature)); + } else if (parsed.receipts) { + receipts.push(...(parsed.receipts || [])); + } else if (parsed.payload && parsed.signature) { + receipts.push(parsed); + } else if (parsed.nodes) { + // `verify chain explore --json` output shape. + for (const n of parsed.nodes) { + receipts.push({ + payload: { + issued_at: n.issued_at, + action: n.action, + previousReceiptHash: n.previousHash || undefined, + }, + signature: { kid: n.kid, alg: 'ed25519', sig: '' }, + }); + } + } + ingestReceipts(receipts); + } catch { + /* ignore — user still typing */ + } +}); diff --git a/ecosystem/dashboard/index.html b/ecosystem/dashboard/index.html new file mode 100644 index 0000000..fdc347e --- /dev/null +++ b/ecosystem/dashboard/index.html @@ -0,0 +1,171 @@ + + + + + + Veritas Acta — Audit Dashboard + + + +
+

Veritas Acta — Audit Dashboard

+ Offline scaffold v0.1 · runs entirely in-browser +
+ +
+
+

What this is

+

A local-first, offline audit dashboard for signed decision receipts. Drop a folder or paste JSON; the dashboard reports chain depth, breaks, and per-receipt status. No data leaves the browser.

+

Verification is a structural check in this scaffold: JCS canonical hash + chain linkage. For full Ed25519 + VOPRF verification, paste results from npx @veritasacta/verify --json and the dashboard will render the output.

+
+ +
+ Drop receipt files here
+ + or paste verify --json / verify compliance --json output below + + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ +
No receipts loaded. Drop files above to begin.
+ + +
+ + + + diff --git a/ecosystem/ebpf-observer/DESIGN.md b/ecosystem/ebpf-observer/DESIGN.md new file mode 100644 index 0000000..17824ea --- /dev/null +++ b/ecosystem/ebpf-observer/DESIGN.md @@ -0,0 +1,103 @@ +# OS-Level Auto-Instrumentation (v0.7.0 target — highest novelty) + +eBPF (Linux) / Endpoint Security Framework (macOS) / ETW (Windows) +programs that observe agent processes at the syscall layer and emit +Veritas Acta receipts for interesting events (tool invocations, +network calls, file mutations). Zero code changes in the observed +agent. + +## Goal + +The ultimate frictionless integration: install the observer, point it +at a process, and every significant action that process takes gets a +cryptographic receipt. No SDK, no framework adapter, no code change +required. + +## Design + +### Linux (eBPF) + +- eBPF program attached to syscalls: + - `openat()`, `unlink()` — file mutations + - `connect()`, `sendto()` — network activity + - `execve()` — subprocess spawning +- User-space daemon consumes kernel ring buffer events +- Daemon maintains a per-pid receipt chain +- Daemon signs receipts using a project attester key + +### macOS (Endpoint Security Framework) + +- ESF client subscribes to: + - `ES_EVENT_TYPE_AUTH_EXEC` — process execution + - `ES_EVENT_TYPE_NOTIFY_OPEN` / `NOTIFY_WRITE` — file access + - `ES_EVENT_TYPE_NOTIFY_CREATE` — new files +- ES client requires Team ID + System Extension entitlements +- Runs as a privileged daemon + +### Windows (ETW) + +- ETW provider subscription to: + - Microsoft-Windows-Kernel-File + - Microsoft-Windows-Kernel-Process + - Microsoft-Windows-Kernel-Network +- Agent process pattern matching via image load events + +### Composition with sb-runtime + +sb-runtime = kernel-level enforcement (deny) +eBPF observer = kernel-level observation (report) + +Together: agents can't do forbidden things (sb-runtime), and everything +they DO do is receipted (observer). + +## Receipt format + +```json +{ + "payload": { + "type": "veritasacta:os-observer:event", + "event_kind": "file_write" | "network_connect" | "exec", + "pid": 12345, + "process_name": "python", + "cmdline_hash": "sha256:...", + "target": { + "kind": "file", + "path_hash": "sha256:...", + "size_bytes": 1024 + }, + "timestamp_nanos": ... + }, + "signature": { ... } +} +``` + +Sensitive fields (full paths, cmdlines) are hashed, not stored raw. +Reveals happen via selective disclosure (AIP-0002) when needed. + +## Privacy implications + +- Observes at the syscall layer: can see EVERYTHING +- Must be deployed with informed user consent +- Private / sensitive paths configurable via allow / deny filters +- No phone-home: receipts stay local unless explicitly exported + +## Performance + +- eBPF adds ~1-5µs per observed syscall (acceptable for moderate + volumes) +- ESF has ~10-50µs overhead +- High-volume scenarios (>10K syscalls/sec) require event filtering + upstream of receipt generation + +## Deployment tiers + +- **Developer mode**: observer runs as the user, observes only the + user's processes, no special privileges (Linux only with ebpf + unprivileged mode) +- **Production mode**: observer runs as root / admin, can observe any + process, requires explicit install + trust + +## License + +Apache-2.0 once shipped. eBPF programs will likely need dual-license +(GPL kernel compatibility). diff --git a/ecosystem/github-action/README.md b/ecosystem/github-action/README.md new file mode 100644 index 0000000..dba3a60 --- /dev/null +++ b/ecosystem/github-action/README.md @@ -0,0 +1,68 @@ +# Veritas Acta Verify — GitHub Action + +Verify signed decision receipts, VOPRF tokens, or Knowledge Units in any CI workflow. Installs `@veritasacta/verify`, confirms the installed verifier is the canonical unmodified release via Sigil self-check, then verifies your receipts. + +## Usage + +```yaml +name: Verify receipts + +on: + push: + branches: [main] + pull_request: + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: VeritasActa/verify-action@v1 + with: + receipts-path: "./receipts/**/*.json" + public-key: ${{ secrets.ED25519_PUBLIC_KEY }} + required-tier: 4 + pin-sigil: 5247a989 + attest: true + attest-org: "Acme Corp" + audit-report: "./audit-report.html" +``` + +## Inputs + +| Input | Required | Default | Description | +|---|---|---|---| +| `receipts-path` | yes | — | Glob of receipt files to verify | +| `public-key` | no | — | Ed25519 pubkey hex | +| `jwks-url` | no | — | JWKS endpoint URL | +| `required-tier` | no | — | Minimum conformance tier (1-5) | +| `pin-sigil` | no | — | Require a specific Sigil fingerprint | +| `strict` | no | `true` | Disable deprecated fallbacks | +| `audit-report` | no | — | Path to write HTML audit report | +| `verifier-version` | no | `latest` | Version of `@veritasacta/verify` to install | +| `attest` | no | `false` | Emit canonical attestation | +| `attest-org` | no | — | Org name in attestation | + +## Outputs + +| Output | Description | +|---|---| +| `total` | Total receipts inspected | +| `verified` | Successfully verified | +| `failed` | Failed verifications | +| `sigil-fingerprint` | Fingerprint of the canonical verifier used | +| `attestation` | Canonical attestation JSON (when `attest: true`) | + +## What this action does + +1. Installs `@veritasacta/verify` from npm (`--provenance` attested) +2. Runs `--self-check` to prove the installed verifier is canonical +3. If `pin-sigil` provided, confirms Sigil fingerprint matches exactly +4. Verifies each receipt matching `receipts-path` +5. Optionally emits an HTML audit report as a workflow artifact +6. Optionally emits a canonical attestation to workflow outputs + +## License + +Apache-2.0 diff --git a/ecosystem/github-action/action.yml b/ecosystem/github-action/action.yml new file mode 100644 index 0000000..27f71b9 --- /dev/null +++ b/ecosystem/github-action/action.yml @@ -0,0 +1,119 @@ +name: 'Verify with Veritas Acta' +description: 'Verify signed decision receipts, VOPRF tokens, or Knowledge Units in CI with @veritasacta/verify.' +author: 'Tom Farley (ScopeBlind)' +branding: + icon: 'shield' + color: 'blue' + +inputs: + receipts-path: + description: 'Glob pattern matching receipt files to verify (e.g., "./receipts/**/*.json")' + required: true + public-key: + description: 'Ed25519 public key (64 hex chars) used for verification.' + required: false + jwks-url: + description: 'URL of a JWKS endpoint providing the signing key.' + required: false + required-tier: + description: 'Fail the run if any receipt does not achieve at least this tier (1-5).' + required: false + default: '' + pin-sigil: + description: 'Pin to a specific verifier Sigil fingerprint (recommended).' + required: false + default: '' + strict: + description: 'Enable --strict mode (disables all deprecated fallbacks).' + required: false + default: 'true' + audit-report: + description: 'Write an HTML audit report to this path.' + required: false + default: '' + verifier-version: + description: 'Version of @veritasacta/verify to install (defaults to latest).' + required: false + default: 'latest' + attest: + description: 'Emit a canonical verifier attestation to the step output.' + required: false + default: 'false' + attest-org: + description: 'Org name to include in the canonical attestation.' + required: false + default: '' + +outputs: + total: + description: 'Total receipts inspected.' + verified: + description: 'Receipts that verified successfully.' + failed: + description: 'Receipts that failed verification.' + sigil-fingerprint: + description: 'Fingerprint of the canonical verifier used.' + attestation: + description: 'Canonical attestation JSON (when `attest: true`).' + +runs: + using: 'composite' + steps: + - name: Install @veritasacta/verify + shell: bash + run: npm install -g @veritasacta/verify@${{ inputs.verifier-version }} + + - name: Self-check (prove canonical release) + shell: bash + run: | + if [[ -n "${{ inputs.pin-sigil }}" ]]; then + npx @veritasacta/verify --pin-sigil ${{ inputs.pin-sigil }} + fi + npx @veritasacta/verify --self-check + + - name: Verify receipts + id: verify + shell: bash + run: | + set -e + FAILED=0 + TOTAL=0 + for f in $(eval ls ${{ inputs.receipts-path }}); do + TOTAL=$((TOTAL + 1)) + KEY_ARG="" + [[ -n "${{ inputs.public-key }}" ]] && KEY_ARG="--key ${{ inputs.public-key }}" + [[ -n "${{ inputs.jwks-url }}" ]] && KEY_ARG="--jwks ${{ inputs.jwks-url }}" + TIER_ARG="" + [[ -n "${{ inputs.required-tier }}" ]] && TIER_ARG="--tier ${{ inputs.required-tier }}" + STRICT_ARG="" + [[ "${{ inputs.strict }}" == "true" ]] && STRICT_ARG="--strict" + if ! npx @veritasacta/verify "$f" $KEY_ARG $TIER_ARG $STRICT_ARG --json > /tmp/result.json; then + FAILED=$((FAILED + 1)) + cat /tmp/result.json + fi + done + VERIFIED=$((TOTAL - FAILED)) + echo "total=$TOTAL" >> $GITHUB_OUTPUT + echo "verified=$VERIFIED" >> $GITHUB_OUTPUT + echo "failed=$FAILED" >> $GITHUB_OUTPUT + if [[ $FAILED -gt 0 ]]; then exit 1; fi + + - name: Canonical attestation + if: ${{ inputs.attest == 'true' }} + shell: bash + run: | + ORG_ARG="" + [[ -n "${{ inputs.attest-org }}" ]] && ORG_ARG='--attest-org "${{ inputs.attest-org }}"' + npx @veritasacta/verify --attest $ORG_ARG > /tmp/attestation.json + cat /tmp/attestation.json + echo "attestation<> $GITHUB_OUTPUT + cat /tmp/attestation.json >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Upload audit report + if: ${{ inputs.audit-report != '' }} + uses: actions/upload-artifact@v4 + with: + name: veritasacta-audit-report + path: ${{ inputs.audit-report }} + if-no-files-found: warn diff --git a/ecosystem/homebrew-tap/Formula/veritasacta-verify.rb b/ecosystem/homebrew-tap/Formula/veritasacta-verify.rb new file mode 100644 index 0000000..71296dd --- /dev/null +++ b/ecosystem/homebrew-tap/Formula/veritasacta-verify.rb @@ -0,0 +1,33 @@ +class VeritasactaVerify < Formula + desc "Offline verifier for Veritas Acta signed decision receipts" + homepage "https://veritasacta.com" + url "https://registry.npmjs.org/@veritasacta/verify/-/verify-0.5.0.tgz" + sha256 "8c20600b727bb3fe725734d2614fe13b5bccb71ededae7596c7554b2514bfc6b" + license "Apache-2.0" + head "https://github.com/VeritasActa/verify.git", branch: "main" + + depends_on "node" + + def install + system "npm", "install", *Language::Node.std_npm_args(libexec) + bin.install_symlink Dir["#{libexec}/bin/*"] + end + + def caveats + <<~EOS + After install, verify you have the canonical release: + veritasacta-verify --self-check + + Start signing receipts with: + veritasacta-verify init + + Protocol: https://veritasacta.com + Managed: https://scopeblind.com (optional) + EOS + end + + test do + # Self-check proves we installed the canonical release + assert_match "Canonical verifier", shell_output("#{bin}/veritasacta-verify --self-check") + end +end diff --git a/ecosystem/homebrew-tap/README.md b/ecosystem/homebrew-tap/README.md new file mode 100644 index 0000000..fa24ce9 --- /dev/null +++ b/ecosystem/homebrew-tap/README.md @@ -0,0 +1,35 @@ +# VeritasActa Homebrew Tap + +Install `@veritasacta/verify` via Homebrew. + +## Usage + +```bash +brew tap VeritasActa/verify +brew install veritasacta-verify +``` + +After install: + +```bash +verify --self-check +verify init +verify samples/sample-receipt.json --key +``` + +## Formula + +See `Formula/veritasacta-verify.rb`. The formula wraps `npm install -g @veritasacta/verify` with a Sigil self-check step on post-install, so every Homebrew installation cryptographically confirms it got the canonical release. + +## Deployment + +This directory is the source for the `VeritasActa/homebrew-verify` GitHub repo. To publish: + +```bash +cd ecosystem/homebrew-tap +gh repo create VeritasActa/homebrew-verify --public --source=. --push +``` + +## License + +Apache-2.0 diff --git a/ecosystem/interop-leaderboard/workflow.yml b/ecosystem/interop-leaderboard/workflow.yml new file mode 100644 index 0000000..c9137e4 --- /dev/null +++ b/ecosystem/interop-leaderboard/workflow.yml @@ -0,0 +1,114 @@ +name: Interop Leaderboard + +on: + schedule: + - cron: '0 12 * * 0' # every Sunday 12:00 UTC + workflow_dispatch: + +jobs: + interop-matrix: + runs-on: ubuntu-latest + strategy: + matrix: + implementation: + - name: protect-mcp + fixture-repo: scopeblind/scopeblind-gateway + fixture-path: test-vectors/receipts/*.json + - name: protect-mcp-adk + fixture-repo: scopeblind/protect-mcp-adk + fixture-path: tests/fixtures/receipts/*.json + - name: sb-runtime + fixture-repo: ScopeBlind/sb-runtime + fixture-path: tests/fixtures/*.json + - name: aps-governance-hook + fixture-repo: aeoess/agent-passport-system + fixture-path: test-vectors/*.json + - name: signet + fixture-repo: Prismer-AI/signet + fixture-path: fixtures/*.json + steps: + - uses: actions/checkout@v4 + + - name: Install canonical verifier + run: npm install -g @veritasacta/verify@latest + + - name: Self-check (prove canonical) + run: npx @veritasacta/verify --self-check + + - name: Fetch ${{ matrix.implementation.name }} fixtures + run: | + mkdir -p fixtures/${{ matrix.implementation.name }} + git clone --depth=1 https://github.com/${{ matrix.implementation.fixture-repo }} /tmp/impl-repo || echo "Fixture repo not accessible (may be private)" + cp -r /tmp/impl-repo/${{ matrix.implementation.fixture-path }} fixtures/${{ matrix.implementation.name }}/ 2>/dev/null || echo "No fixtures found for pattern" + + - name: Verify all fixtures + id: verify + run: | + set +e + TOTAL=0 + PASSED=0 + FAILED=0 + for f in fixtures/${{ matrix.implementation.name }}/*.json; do + TOTAL=$((TOTAL + 1)) + if npx @veritasacta/verify "$f" --strict > /tmp/result-$TOTAL.json 2>&1; then + PASSED=$((PASSED + 1)) + else + FAILED=$((FAILED + 1)) + fi + done + echo "total=$TOTAL" >> $GITHUB_OUTPUT + echo "passed=$PASSED" >> $GITHUB_OUTPUT + echo "failed=$FAILED" >> $GITHUB_OUTPUT + { + echo "impl=${{ matrix.implementation.name }}" + echo "total=$TOTAL" + echo "passed=$PASSED" + echo "failed=$FAILED" + echo "timestamp=$(date -Iseconds)" + } > /tmp/result-${{ matrix.implementation.name }}.env + + - name: Upload per-implementation result + uses: actions/upload-artifact@v4 + with: + name: result-${{ matrix.implementation.name }} + path: /tmp/result-*.env + + aggregate: + needs: interop-matrix + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Download all results + uses: actions/download-artifact@v4 + with: + path: results/ + + - name: Build leaderboard markdown + run: | + { + echo "# Interop Leaderboard" + echo "" + echo "Auto-generated every Sunday. Each row represents verifying that implementation's fixtures with canonical @veritasacta/verify." + echo "" + echo "| Implementation | Total | Passed | Failed | Status |" + echo "|---|---|---|---|---|" + for r in results/*/result-*.env; do + [[ -f "$r" ]] || continue + source "$r" + STATUS="✓ interop" + [[ "$failed" != "0" ]] && STATUS="⚠ partial ($failed failing)" + [[ "$total" == "0" ]] && STATUS="? no fixtures" + echo "| $impl | $total | $passed | $failed | $STATUS |" + done + echo "" + echo "*Last updated: $(date -u +%Y-%m-%dT%H:%M:%SZ)*" + } > LEADERBOARD.md + cat LEADERBOARD.md + + - name: Commit leaderboard + run: | + git config user.name "Veritas Acta Interop Bot" + git config user.email "interop-bot@veritasacta.com" + git add LEADERBOARD.md + git diff --cached --quiet && echo "no changes" || (git commit -m "chore: update interop leaderboard ($(date -I))" && git push) diff --git a/ecosystem/physical-attestation/DESIGN.md b/ecosystem/physical-attestation/DESIGN.md new file mode 100644 index 0000000..89b054d --- /dev/null +++ b/ecosystem/physical-attestation/DESIGN.md @@ -0,0 +1,211 @@ +# Physical-Digital Causal Chains — Seal cost-tier T2 attestation + +**Status:** Design spec (v0.7 target) +**Related:** AIP-0005 (Attestation Weight Profile), `memory/seal-hardware-strategy.md` + +## Goal + +Extend the signed decision receipt format so that receipts can claim +**T2 = hardware-attested** (per AIP-0005) when the signing key lives +inside a hardware secure element (ATECC608B, TA100, SE050, TPM2, SGX, +etc.) and a platform quote binds the key to that element. + +This turns receipts from "a software process signed this" into "a +specific hardware element, whose key was provisioned under declared +procedure, signed this." That's the property cold-chain operators, +regulatory auditors, and insurance adjusters need. + +## Why this matters + +Software-only receipts can be forged by an attacker who steals the +signing key. No amount of cryptographic sophistication in the receipt +format can prevent key exfiltration from a compromised OS. T2 +receipts raise the forgery cost from "compromise the OS" to "extract +a key from a tamper-resistant chip" — a 1000-2000x increase in +attacker cost (numbers from EAL certification bodies). + +The Seal hardware program exists specifically to produce this kind of +evidence for physical events (temperature chains, shipping custody, +tamper seals). ScopeBlind's strategic position is that the same +receipt format handles both purely-digital decisions AND +physical-digital chains where some links came from a sensor. + +## Architecture + +### Receipt shape (additive over AIP-0001) + +A T2-claiming receipt adds two fields to the existing payload: + +```json +{ + "payload": { + "type": "decision-receipt", + "action": "physical.chain_of_custody.temperature_reading", + "cost_tier": 2, + "attestation_mode": "atecc608b" | "ta100" | "se050" | "tpm2" | "sgx", + "attestation_quote": { + "format": "atecc608b-signed-data-v1", + "quote": "", + "measured_kid": "", + "provisioning_ca": "" + }, + ... existing AIP-0001 fields ... + }, + "signature": { "alg": "ES256", "kid": "", "sig": "..." } +} +``` + +`attestation_quote` is the vendor-specific platform attestation. For +ATECC608B (our Seal v1), it's the `Sign()` operation over a challenge +bound to the receipt payload hash, with the chip's `provisioner_cert` +chain. The verifier checks three things: + +1. The chip's `provisioning_ca` is a trusted root (Microchip's CA, or + our own provisioning CA for Seal v1). +2. The quote signs the receipt's canonical payload hash with + `measured_kid`. +3. `signature.kid` equals `measured_kid` — the same key that signed + the receipt also produced the platform quote. + +### Sensor reading → decision receipt pipeline + +``` +┌─────────────────────────┐ ┌─────────────────────────┐ +│ SHT40 sensor │ │ ATECC608B secure element│ +│ reads temp + humidity │ │ holds ECDSA P-256 key │ +└────────────┬────────────┘ │ exports platform quote │ + │ I²C │ │ + ▼ └─────────────┬───────────┘ +┌────────────────────────────────────────────┐ │ +│ nRF52840 MCU │ │ +│ - constructs JCS receipt payload │ │ +│ - hashes payload │ │ +│ - asks ATECC608B to sign + quote │◄┘ +│ - emits { payload, signature, quote } │ +└────────────┬───────────────────────────────┘ + │ BLE / NFC / LoRa + ▼ + Phone / gateway / verifier (offline) + validates signature + quote + cost_tier=T2 +``` + +The sensor is NOT trusted; only the ATECC608B's signature is. What the +chip signs is whatever the firmware hands it. If the firmware is +compromised to misreport temperature, T2 doesn't save you — that's why +T2 is a property claim about the SIGNING, not about the truthfulness of +the data. Higher tiers (T3 multi-party, T4 transparency-anchored) add +additional cross-checks. + +## Verifier requirements + +A conformant verifier implementing this extension SHOULD: + +1. When `cost_tier >= 2` is claimed, REQUIRE `attestation_mode` and + `attestation_quote` fields. +2. Dispatch to a per-platform validator based on `attestation_mode`: + - `atecc608b` → validate the Microchip provisioner chain + re-verify + the chip's signature over the receipt hash. + - `tpm2` → validate the TPM2 AK certificate + quote structure. + - `sgx` / `sev-snp` / `tdx` → platform-specific quote verification. +3. If the platform is unknown, return `undecidable` (exit 2) rather + than `invalid` (exit 1) — the receipt might be valid under a + platform the verifier doesn't know. +4. If the platform is known but the quote fails, return `invalid` + (exit 1) — this is a proven mismatch. + +The verifier is NOT required to implement all platforms. A verifier +might implement TPM2 only and punt on ATECC608B. It just must clearly +report *why* it can't verify. + +## Implementation path + +### v0.5.2 (now, scaffold) + +This spec + the `attestation_quote` field schema shipped under +`schemas/` + AIP-0005 tier mapping. No verifier code yet — the tier +is parseable but not validated. + +### v0.6.0 + +Minimal validator for `attestation_mode: custom` that checks the +quote is a well-formed object with `format`, `quote`, and +`measured_kid`, and verifies `measured_kid == signature.kid`. That's +the "at least it's consistent" check. Full platform-specific +validators deferred. + +### v0.7.0 + +One real platform validator — target is TPM2 because the quote +format is well-documented and reference libraries exist (tpm2-tss). +Second validator (ATECC608B, for Seal) added once the chip's +provisioning chain is formalised. + +### v1.0+ + +All major secure element platforms: ATECC608B, TA100, SE050, TPM2, +SGX/SEV-SNP/TDX, SE embedded in ARM TrustZone phones. + +## Relationship to Seal hardware program + +Seal v1 ships with ATECC608B + ECDSA P-256. Every reading produces a +receipt whose `cost_tier = 2` and whose `attestation_mode = +atecc608b`. The quote is the chip's authenticated sign operation. +The `provisioning_ca` is a ScopeBlind-operated intermediate CA; when +Microchip certifies our provisioning, we'll also ship the Microchip +chain for cross-verification. + +Before Seal ships, the T2 mechanism is still useful: any software +running on a TPM-backed server, an Apple device with Secure Enclave, +or a KMS-backed CloudHSM can claim T2 today. The receipt format +doesn't care; only the evidence matters. + +## Non-goals + +- Not a replacement for TEE attestation protocols. We don't reinvent + TPM2 / SGX / SEV — we reference their quotes verbatim. +- Not a DRM mechanism. T2 says "a specific hardware element signed + this." It doesn't prevent the holder from doing other things with + the receipt. +- Not a hardware supply-chain proof. If the chip itself was + adversarially backdoored, T2 can't detect that. Supply-chain + provenance is a parallel problem; AIP-0005 tiers describe one + property (cost to forge the signature), not every property. + +## Open questions + +1. How do we represent ATECC608B's non-standard signature format + when the verifier needs a standard ECDSA verification path? + (Current plan: firmware emits the raw `r` + `s` and the verifier + wraps them in DER at receive time.) +2. For Seal, do we ship our own provisioning CA publicly or gate it + behind a trust-anchor file operators import? (Current plan: + publicly published + trust-anchor-file optional for private + deployments.) +3. Should the verifier treat unknown platforms as an error or + silently downgrade the tier to T0? The spec says `undecidable` + (exit 2). An alternative is "accept the receipt but surface the + tier as T0 because we couldn't validate T2." This is a UX + question, not a security question. + +## Reference implementation sketch + +Until v0.6.0 ships the validator, the schema below is the interface. + +```typescript +interface AttestationQuote { + /** Vendor-specific format identifier. */ + format: string; + /** Raw quote bytes, base64url-encoded. */ + quote: string; + /** Key identifier that MUST match the receipt's signature.kid. */ + measured_kid: string; + /** Provisioning CA chain (X.509 x5c) or fingerprint list. */ + provisioning_ca?: string | string[]; + /** Reference values (PCRs, IMA hashes, etc.) the operator expects. */ + reference_values?: Record; +} +``` + +## License + +Apache-2.0 when shipped. diff --git a/ecosystem/physical-attestation/attestation-quote.schema.json b/ecosystem/physical-attestation/attestation-quote.schema.json new file mode 100644 index 0000000..a5312a7 --- /dev/null +++ b/ecosystem/physical-attestation/attestation-quote.schema.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://veritasacta.com/schemas/attestation-quote.schema.json", + "title": "AIP-0005 T2 Attestation Quote", + "description": "Platform attestation quote binding a signing key to a hardware secure element.", + "type": "object", + "required": ["format", "quote", "measured_kid"], + "additionalProperties": false, + "properties": { + "format": { + "type": "string", + "description": "Vendor-specific quote format identifier.", + "enum": [ + "atecc608b-signed-data-v1", + "ta100-signed-data-v1", + "se050-attestation-v1", + "tpm2-quote-v1", + "sgx-dcap-v3", + "sev-snp-report-v1", + "tdx-quote-v4", + "apple-secure-enclave-v1", + "custom" + ] + }, + "quote": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]+={0,2}$", + "description": "Raw quote bytes, base64url-encoded." + }, + "measured_kid": { + "type": "string", + "minLength": 1, + "description": "Key identifier bound in the quote. MUST match the enclosing receipt's signature.kid." + }, + "provisioning_ca": { + "oneOf": [ + { "type": "string", "description": "PEM-encoded CA certificate or fingerprint." }, + { + "type": "array", + "items": { "type": "string" }, + "description": "X.509 chain as array of x5c entries." + } + ] + }, + "reference_values": { + "type": "object", + "description": "Expected reference measurements (PCRs, IMA hashes, memory ranges). Platform-specific schema.", + "additionalProperties": true + } + } +} diff --git a/ecosystem/profiles/README.md b/ecosystem/profiles/README.md new file mode 100644 index 0000000..820c950 --- /dev/null +++ b/ecosystem/profiles/README.md @@ -0,0 +1,65 @@ +# Pre-built sandbox profiles + +Drop-in Cedar policies + nono capability manifests for common AI coding assistants and agent CLIs. Each profile is designed so the tool **just works under sb-runtime + nono + signed receipts** without requiring the operator to author a policy from scratch. + +## Available profiles + +| Profile | Target tool | Policy shape | +|---|---|---| +| [`claude-code/`](./claude-code/) | [Anthropic Claude Code](https://www.claude.com/product/claude-code) | Allow read-only source inspection + scoped git/tests; deny network to metadata endpoints, deny writes to system paths | +| [`cursor/`](./cursor/) | [Cursor](https://www.cursor.com/) | Same baseline as claude-code + additional allow for Cursor's MCP bridge | +| [`codex/`](./codex/) | [OpenAI Codex CLI](https://github.com/openai/codex) | Stricter default deny, explicit allow per exec | +| [`gemini-cli/`](./gemini-cli/) | [Google Gemini CLI](https://github.com/google-gemini/gemini-cli) | Read-only review mode + scoped shell | +| [`openclaw/`](./openclaw/) | [OpenClaw](https://github.com/openclaw-ai/openclaw) | OpenClaw-aligned conventions; composes with their existing guard rails | + +## Each profile contains + +``` +/ +├── profile.yaml # metadata: tool, version, recommended sb-runtime ring, nono capability refs +├── policy.cedar # Cedar policy (consumable by sb-runtime, bindu-scopeblind, cedar-for-agents) +├── nono-capabilities.yaml # nono capability set (if composing with nono for kernel sandbox) +└── README.md # how to use + what it allows / denies +``` + +## Usage + +### Option A: via `verify init` + +```bash +cd my-project +npx @veritasacta/verify init --profile claude-code +``` + +Auto-installs the profile, generates keys, writes `.veritasacta/config.json`. + +### Option B: via sb-runtime directly + +```bash +sb-runtime run --profile claude-code --ring 3 -- claude +``` + +### Option C: copy manually + +Copy `policy.cedar` into your Cedar policy directory. The file is pure Cedar and works with any compliant evaluator. + +## Why profiles? + +A Cedar policy written from scratch for `claude-code` takes a new operator ~1-2 hours of trial and error (allow this syscall, deny that path, figure out what Claude Code actually does under the hood). Profiles compress that to one command. + +The profiles are maintained by the ScopeBlind ecosystem team based on real deployment experience. Each profile is explicit about what it allows / denies; there are no hidden escape hatches, and every change is reviewed. + +## Contributing a profile + +Adding a new tool is a small PR: + +1. `/profile.yaml` with metadata +2. `/policy.cedar` with the policy +3. `/README.md` explaining the threat model and design decisions +4. Optional: `/nono-capabilities.yaml` for kernel-sandbox composition + +All profiles live in `packages/verify-cli/ecosystem/profiles/` and are Apache-2.0. + +## License + +Apache-2.0. Each profile is usable in any context without restriction; the Veritas Acta receipt format is the interoperable artifact. diff --git a/ecosystem/profiles/claude-code/README.md b/ecosystem/profiles/claude-code/README.md new file mode 100644 index 0000000..ae7021f --- /dev/null +++ b/ecosystem/profiles/claude-code/README.md @@ -0,0 +1,72 @@ +# Profile: Claude Code + +Run [Anthropic Claude Code](https://www.claude.com/product/claude-code) under sb-runtime + nono + signed receipts with one command. + +## Quick start + +```bash +# Option A: via verify init +cd my-project +npx @veritasacta/verify init --profile claude-code + +# Option B: via sb-runtime directly +sb-runtime run --profile ./profile.yaml --ring 3 -- claude + +# Option C: nono + sb-runtime composition (Linux, recommended for production) +nono run --caps ./nono-capabilities.yaml -- \ + sb-runtime --ring 2 --policy ./policy.cedar -- claude +``` + +## What the profile allows + +- **Reads** from `/workspace/*` and `/tmp/*` +- **Writes** inside `/workspace/*` only +- **Exec** of a safe allowlist: `ls`, `cat`, `grep`, `rg`, `git`, `pytest`, `npm`, `node`, `cargo`, `go`, and similar dev tools +- **HTTPS fetches** to `api.github.com`, `githubusercontent.com`, `anthropic.com`, `pypi.org`, `registry.npmjs.org` + +## What the profile denies + +- Cloud metadata endpoints (AWS IMDS, GCP metadata, IPv6 equivalents) +- Writes to `/etc`, `/usr`, `/root`, `/var/log`, `/sys`, `/proc` +- Reads of credential patterns (`*.pem`, SSH keys, AWS creds, `.netrc`, `secrets.*`) +- Destructive shell commands (`rm`, `dd`, `mkfs`, `shutdown`, force-push to git) + +## Receipt format + +Every tool call emits a receipt at `.veritasacta/receipts/claude-code/.json` in the Veritas Acta format (draft-farley-acta-signed-receipts-02). Verify offline: + +```bash +npx @veritasacta/verify .veritasacta/receipts/claude-code/ --key operator-public.pem +``` + +## When to customise + +- **Cursor, Codex, or another IDE-embedded agent** — copy this profile and relax the HTTPS allowlist (Cursor talks to its own backend) +- **Production CI** — tighten to deny-by-default on exec, add an explicit allowlist only for your build commands +- **Enterprise with air-gap** — remove the network block entirely, keep only Cedar allow/deny on file and exec + +## Threat model + +This profile assumes Claude Code is **semi-trusted**: we trust the binary is unmodified (verified via Anthropic's signing) but don't trust any prompt or tool-use output to be safe. The sandbox is the enforcement boundary; Cedar policy is the audit boundary. + +Known attack vectors this profile addresses: + +- **Credential exfiltration via file read** — blocked at policy + nono filesystem layer +- **Cloud metadata access (SSRF-like)** — blocked at network layer +- **Destructive shell via LLM confusion** — blocked at Cedar exec allowlist +- **System-file tampering** — blocked at policy + nono filesystem layer + +Known attack vectors **NOT** addressed (need operator-specific handling): + +- **Data exfiltration via allowed HTTPS endpoints** — Claude Code can post to api.github.com; if that's a real concern, narrow the allowlist further +- **Supply-chain attacks via allowed package managers** — `npm install` of a malicious package is not blocked; use [`verify prompt `](../../../src/engines/prompt.js) to verify `.claude/settings.json` and `CLAUDE.md` provenance + +## Maintaining this profile + +Profile updates follow semver. The current version is 1.0.0. Changes: + +- **Patch** — allowlist additions for already-covered command families +- **Minor** — new action types (e.g., a new Claude Code tool), new endpoint allowlist entries +- **Major** — changes to default-deny posture or removal of previously-allowed patterns + +File issues or proposed updates in the main [`VeritasActa/verify`](https://github.com/VeritasActa/verify) repo. diff --git a/ecosystem/profiles/claude-code/nono-capabilities.yaml b/ecosystem/profiles/claude-code/nono-capabilities.yaml new file mode 100644 index 0000000..c403b06 --- /dev/null +++ b/ecosystem/profiles/claude-code/nono-capabilities.yaml @@ -0,0 +1,79 @@ +# nono capability set for running Claude Code under kernel-native sandbox. +# Use alongside sb-runtime --ring 2 so nono owns the sandbox layer and +# sb-runtime contributes only Cedar evaluation + signed receipts. +# +# See: https://github.com/always-further/nono + +version: "1" +profile: claude-code + +capabilities: + filesystem: + allow_read: + - /workspace + - /tmp + - /usr/bin + - /usr/lib + - /lib + - /etc/ssl/certs # TLS trust roots for HTTPS to github.com etc + allow_readwrite: + - /workspace + deny: + - /etc + - /var + - /root + - /home # except whatever's bind-mounted to /workspace + - /proc/*/mem # stop memory inspection + + network: + allow_egress: + - api.github.com + - "*.githubusercontent.com" + - "*.anthropic.com" + - api.pypi.org + - registry.npmjs.org + deny: + - 169.254.169.254 # AWS IMDS + - metadata.google.internal + - fd00:ec2::254 # IPv6 IMDS + block_all_other: true + + syscalls: + # seccomp-notify for runtime supervisor upgrades; default allowlist + # covers normal fs + net + proc control for subprocess management. + default: allowlist + allowed: + - read + - write + - openat + - close + - fstat + - mmap + - brk + - execve # gated by Cedar policy per-command + - clone + - wait4 + - pipe2 + - fork + - dup2 + - accept4 + - connect + - bind + - listen + - socket + - rt_sigaction + - rt_sigreturn + - exit_group + denied: + - ptrace + - process_vm_readv + - keyctl + - personality + - pivot_root + - chroot + - mount + +# When nono is composed with sb-runtime --ring 2, each capability check +# also produces a Veritas Acta receipt per the sb-runtime skill +# (packages/agentmesh-integrations/sb-runtime-skill/). See +# docs/integrations/sb-runtime.md. diff --git a/ecosystem/profiles/claude-code/policy.cedar b/ecosystem/profiles/claude-code/policy.cedar new file mode 100644 index 0000000..f2933f2 --- /dev/null +++ b/ecosystem/profiles/claude-code/policy.cedar @@ -0,0 +1,120 @@ +// Cedar policy for Claude Code running under sb-runtime + nono. +// Conservative by default. Explicit allowlists for tool calls Claude Code +// actually needs; explicit deny for everything else. +// +// Scope: covers the governed actions emitted by Claude Code's built-in +// tools (Bash, Read, Write, Edit, Grep, Glob, WebFetch, WebSearch) plus +// the MCP tool_use action shape. + +// ------------------------------------------------------------------ READS +// Allow reading inside the workspace and /tmp. +permit ( + principal is Agent::Principal, + action == Agent::Action::"open", + resource is Agent::File +) when { + context.mode == "read" && + (resource.path like "/workspace/*" || resource.path like "/tmp/*") +}; + +// ------------------------------------------------------------------ WRITES +// Allow writes inside workspace only. /tmp is read-only so agents can't +// hide artefacts there. +permit ( + principal is Agent::Principal, + action == Agent::Action::"open", + resource is Agent::File +) when { + context.mode == "write" && + resource.path like "/workspace/*" +}; + +// ------------------------------------------------------------------ EXEC +// Allow safe shell commands for common Claude Code workflows. +permit ( + principal is Agent::Principal, + action == Agent::Action::"exec", + resource +) when { + context.command in [ + "ls", "cat", "grep", "rg", "find", "head", "tail", "wc", + "git", "pytest", "npm", "npx", "yarn", "pnpm", + "python", "python3", "node", "deno", "bun", + "cargo", "rustc", "go", "gofmt" + ] +}; + +// ------------------------------------------------------------------ NETWORK +// Allow HTTPS fetches to common developer domains Claude Code uses. +permit ( + principal is Agent::Principal, + action == Agent::Action::"connect", + resource is Agent::Endpoint +) when { + context.tls == true && + ( + resource.host like "*.github.com" || + resource.host like "*.githubusercontent.com" || + resource.host like "*.anthropic.com" || + resource.host like "api.pypi.org" || + resource.host like "registry.npmjs.org" + ) +}; + +// ------------------------------------------------------------------ DENYS +// Block cloud metadata endpoints regardless of any allow above. +forbid ( + principal, + action == Agent::Action::"connect", + resource is Agent::Endpoint +) when { + resource.host == "169.254.169.254" || + resource.host == "fd00:ec2::254" || + resource.host == "metadata.google.internal" || + resource.host like "metadata.*.internal" +}; + +// Block writes to system directories. +forbid ( + principal, + action == Agent::Action::"open", + resource is Agent::File +) when { + context.mode == "write" && + ( + resource.path like "/etc/*" || + resource.path like "/usr/*" || + resource.path like "/root/*" || + resource.path like "/var/log/*" || + resource.path like "/sys/*" || + resource.path like "/proc/*" + ) +}; + +// Block reads of credential files even under /workspace (paranoid default). +forbid ( + principal, + action == Agent::Action::"open", + resource is Agent::File +) when { + context.mode == "read" && + ( + resource.path like "*.pem" || + resource.path like "*id_rsa*" || + resource.path like "*id_ed25519*" || + resource.path like "*/.aws/credentials" || + resource.path like "*/.ssh/*" || + resource.path like "*/.netrc" || + resource.path like "*/secrets.*" + ) +}; + +// Block destructive shell commands. +forbid ( + principal, + action == Agent::Action::"exec", + resource +) when { + context.command in ["rm", "dd", "mkfs", "shutdown", "reboot", "kill", "pkill"] || + (context.command == "git" && context.argv.contains("push") && context.argv.contains("--force")) +}; diff --git a/ecosystem/profiles/claude-code/profile.yaml b/ecosystem/profiles/claude-code/profile.yaml new file mode 100644 index 0000000..409bcef --- /dev/null +++ b/ecosystem/profiles/claude-code/profile.yaml @@ -0,0 +1,29 @@ +apiVersion: scopeblind.profiles/v1 +kind: SandboxProfile +metadata: + name: claude-code + tool: Claude Code (Anthropic) + tool_url: https://www.claude.com/product/claude-code + profile_version: 1.0.0 + maintained_by: ScopeBlind + license: Apache-2.0 + veritas_acta_receipt_type: scopeblind:decision +spec: + sb_runtime: + recommended_ring: 3 + sandbox_backend: sb_runtime_builtin + receipts_dir: .veritasacta/receipts/claude-code + policy_file: ./policy.cedar + nono: + capability_file: ./nono-capabilities.yaml + recommended_mode: compose # sb-runtime at ring 2, nono wraps process + intended_threat_model: + - Claude Code agent reads source, writes in workspace, runs test commands + - Agent cannot reach cloud metadata endpoints + - Agent cannot escalate outside /workspace, /tmp + - Agent cannot write to /etc, /usr, /root, /var/log + - Every tool_use produces a signed receipt linked to prior + known_limitations: + - Claude Code's MCP tool integration creates syscalls outside this profile's scope; + set `--allow-mcp-egress` to add a tight allowlist for specific MCP server hosts + - Hard-stops on aarch64 Linux sandbox until issue #1 lands diff --git a/ecosystem/profiles/codex/README.md b/ecosystem/profiles/codex/README.md new file mode 100644 index 0000000..efaf65b --- /dev/null +++ b/ecosystem/profiles/codex/README.md @@ -0,0 +1,41 @@ +# Profile: OpenAI Codex CLI + +Stricter default than `claude-code` because Codex CLI's tool behavior is more aggressive and the profile assumes a CI / unattended deployment context. + +## Key differences from claude-code + +- **Exec default-deny**: only `ls`, `cat`, `grep`, `rg`, `find`, `head`, `tail`, `wc` are allowed. Add your build / test commands explicitly per project. +- **Network narrower**: only `*.openai.com`, `api.github.com`, `githubusercontent.com`, `pypi.org`, `npmjs.org`. +- **`chmod` / `chown` denied**: Codex has a pattern of suggesting permission changes; blocking at the policy layer forces explicit operator action. + +## Extending for your project + +Copy `policy.cedar` and add a project-specific allowlist: + +```cedar +// Your project-specific build commands +permit ( + principal is Agent::Principal, + action == Agent::Action::"exec", + resource +) when { + context.command in ["npm", "pytest", "cargo"] // whatever your stack needs +}; +``` + +Keep the original deny rules; they're defense in depth. + +## Composition + +Standalone (sb-runtime Ring 3): + +```bash +sb-runtime --ring 3 --profile ./profile.yaml -- codex +``` + +With nono (recommended for production): + +```bash +nono run --caps ./nono-capabilities.yaml -- \ + sb-runtime --ring 2 --policy ./policy.cedar -- codex +``` diff --git a/ecosystem/profiles/codex/nono-capabilities.yaml b/ecosystem/profiles/codex/nono-capabilities.yaml new file mode 100644 index 0000000..46247e3 --- /dev/null +++ b/ecosystem/profiles/codex/nono-capabilities.yaml @@ -0,0 +1,23 @@ +version: "1" +profile: codex +capabilities: + filesystem: + allow_read: [/workspace, /tmp, /usr/bin, /usr/lib, /lib, /etc/ssl/certs] + allow_readwrite: [/workspace] + deny: [/etc, /var, /root, /home, /proc/*/mem] + network: + allow_egress: + - "*.openai.com" + - api.github.com + - "*.githubusercontent.com" + - api.pypi.org + - registry.npmjs.org + deny: + - 169.254.169.254 + - metadata.google.internal + - fd00:ec2::254 + block_all_other: true + syscalls: + default: allowlist + allowed: [read, write, openat, close, fstat, mmap, brk, execve, clone, wait4, pipe2, fork, dup2, accept4, connect, bind, listen, socket, rt_sigaction, rt_sigreturn, exit_group] + denied: [ptrace, process_vm_readv, keyctl, personality, pivot_root, chroot, mount] diff --git a/ecosystem/profiles/codex/policy.cedar b/ecosystem/profiles/codex/policy.cedar new file mode 100644 index 0000000..713d269 --- /dev/null +++ b/ecosystem/profiles/codex/policy.cedar @@ -0,0 +1,76 @@ +// Cedar policy for OpenAI Codex CLI — stricter default than claude-code. +// Default-deny on exec; operator narrows allowlist per project. + +// Read-only in workspace + /tmp +permit ( + principal is Agent::Principal, + action == Agent::Action::"open", + resource is Agent::File +) when { + context.mode == "read" && + (resource.path like "/workspace/*" || resource.path like "/tmp/*") +}; + +// Writes in workspace only +permit ( + principal is Agent::Principal, + action == Agent::Action::"open", + resource is Agent::File +) when { + context.mode == "write" && resource.path like "/workspace/*" +}; + +// Exec is DENY-BY-DEFAULT. Project-specific operators must explicitly +// extend this allowlist. Only the safest inspection commands are allowed. +permit ( + principal is Agent::Principal, + action == Agent::Action::"exec", + resource +) when { + context.command in ["ls", "cat", "grep", "rg", "find", "head", "tail", "wc"] +}; + +// Network: OpenAI API + package managers only. +permit ( + principal is Agent::Principal, + action == Agent::Action::"connect", + resource is Agent::Endpoint +) when { + context.tls == true && + ( + resource.host like "*.openai.com" || + resource.host like "api.github.com" || + resource.host like "*.githubusercontent.com" || + resource.host like "api.pypi.org" || + resource.host like "registry.npmjs.org" + ) +}; + +// Universal denies +forbid ( + principal, + action == Agent::Action::"connect", + resource is Agent::Endpoint +) when { + resource.host == "169.254.169.254" || + resource.host == "metadata.google.internal" || + resource.host == "fd00:ec2::254" || + resource.host like "metadata.*.internal" +}; + +forbid ( + principal, + action == Agent::Action::"open", + resource is Agent::File +) when { + (context.mode == "write" && (resource.path like "/etc/*" || resource.path like "/usr/*" || resource.path like "/root/*" || resource.path like "/var/log/*")) || + (context.mode == "read" && (resource.path like "*.pem" || resource.path like "*id_rsa*" || resource.path like "*id_ed25519*" || resource.path like "*/.aws/credentials" || resource.path like "*/.ssh/*" || resource.path like "*/.netrc")) +}; + +forbid ( + principal, + action == Agent::Action::"exec", + resource +) when { + context.command in ["rm", "dd", "mkfs", "shutdown", "reboot", "kill", "pkill", "chmod", "chown"] +}; diff --git a/ecosystem/profiles/codex/profile.yaml b/ecosystem/profiles/codex/profile.yaml new file mode 100644 index 0000000..940ca38 --- /dev/null +++ b/ecosystem/profiles/codex/profile.yaml @@ -0,0 +1,21 @@ +apiVersion: scopeblind.profiles/v1 +kind: SandboxProfile +metadata: + name: codex + tool: OpenAI Codex CLI + tool_url: https://github.com/openai/codex + profile_version: 1.0.0 + maintained_by: ScopeBlind + license: Apache-2.0 +spec: + sb_runtime: + recommended_ring: 3 + sandbox_backend: sb_runtime_builtin + receipts_dir: .veritasacta/receipts/codex + policy_file: ./policy.cedar + nono: + capability_file: ./nono-capabilities.yaml + intended_threat_model: + - Codex CLI is aggressive by default; policy is stricter than claude-code + - Default-deny on exec; operator opts specific commands in per project + - Network limited to OpenAI API + package managers diff --git a/ecosystem/profiles/cursor/README.md b/ecosystem/profiles/cursor/README.md new file mode 100644 index 0000000..ff3dd6d --- /dev/null +++ b/ecosystem/profiles/cursor/README.md @@ -0,0 +1,31 @@ +# Profile: Cursor + +Run [Cursor](https://www.cursor.com) under sb-runtime + nono + signed receipts. + +Baseline is identical to [`claude-code`](../claude-code/) with a tighter network allowlist scoped to Cursor's own backends plus standard developer domains. Use this profile when Cursor's Composer mode, agent tabs, or MCP bridge is active. + +## Quick start + +```bash +npx @veritasacta/verify init --profile cursor +``` + +Or compose with nono directly (Linux, recommended for production): + +```bash +nono run --caps ./nono-capabilities.yaml -- \ + sb-runtime --ring 2 --policy ./policy.cedar -- cursor +``` + +## What's different from claude-code + +- **Network**: adds `*.cursor.com` and `*.cursor.sh` to the HTTPS allowlist (Cursor's LLM proxy and telemetry endpoints) +- **MCP extension**: if you wire third-party MCP servers through Cursor, extend the network allowlist per server. Each MCP server hostname should be explicit. + +Otherwise: same reads, writes, exec allowlist, and denies as `claude-code`. + +## Receipt format + +Same as claude-code. Receipts land at `.veritasacta/receipts/cursor/` and verify with `npx @veritasacta/verify`. + +See [`claude-code/README.md`](../claude-code/README.md) for the full threat model and composition pattern. diff --git a/ecosystem/profiles/cursor/nono-capabilities.yaml b/ecosystem/profiles/cursor/nono-capabilities.yaml new file mode 100644 index 0000000..12b252d --- /dev/null +++ b/ecosystem/profiles/cursor/nono-capabilities.yaml @@ -0,0 +1,26 @@ +# nono capability set for Cursor — identical to claude-code except the +# network egress list includes Cursor's own backends. +version: "1" +profile: cursor +capabilities: + filesystem: + allow_read: [/workspace, /tmp, /usr/bin, /usr/lib, /lib, /etc/ssl/certs] + allow_readwrite: [/workspace] + deny: [/etc, /var, /root, /home, /proc/*/mem] + network: + allow_egress: + - api.github.com + - "*.githubusercontent.com" + - "*.cursor.com" + - "*.cursor.sh" + - api.pypi.org + - registry.npmjs.org + deny: + - 169.254.169.254 + - metadata.google.internal + - fd00:ec2::254 + block_all_other: true + syscalls: + default: allowlist + allowed: [read, write, openat, close, fstat, mmap, brk, execve, clone, wait4, pipe2, fork, dup2, accept4, connect, bind, listen, socket, rt_sigaction, rt_sigreturn, exit_group] + denied: [ptrace, process_vm_readv, keyctl, personality, pivot_root, chroot, mount] diff --git a/ecosystem/profiles/cursor/policy.cedar b/ecosystem/profiles/cursor/policy.cedar new file mode 100644 index 0000000..9d3e390 --- /dev/null +++ b/ecosystem/profiles/cursor/policy.cedar @@ -0,0 +1,105 @@ +// Cedar policy for Cursor running under sb-runtime + nono. +// Baseline mirrors claude-code; adds Cursor-specific network allowlist. + +// Reads in workspace + /tmp +permit ( + principal is Agent::Principal, + action == Agent::Action::"open", + resource is Agent::File +) when { + context.mode == "read" && + (resource.path like "/workspace/*" || resource.path like "/tmp/*") +}; + +// Writes in workspace only +permit ( + principal is Agent::Principal, + action == Agent::Action::"open", + resource is Agent::File +) when { + context.mode == "write" && resource.path like "/workspace/*" +}; + +// Safe shell allowlist +permit ( + principal is Agent::Principal, + action == Agent::Action::"exec", + resource +) when { + context.command in [ + "ls", "cat", "grep", "rg", "find", "head", "tail", "wc", + "git", "pytest", "npm", "npx", "yarn", "pnpm", + "python", "python3", "node", "deno", "bun", + "cargo", "rustc", "go", "gofmt" + ] +}; + +// Cursor-specific network allowlist (extends the developer-domain set) +permit ( + principal is Agent::Principal, + action == Agent::Action::"connect", + resource is Agent::Endpoint +) when { + context.tls == true && + ( + resource.host like "*.github.com" || + resource.host like "*.githubusercontent.com" || + resource.host like "*.cursor.com" || + resource.host like "*.cursor.sh" || + resource.host like "api.pypi.org" || + resource.host like "registry.npmjs.org" + ) +}; + +// Universal denies (identical to claude-code profile) +forbid ( + principal, + action == Agent::Action::"connect", + resource is Agent::Endpoint +) when { + resource.host == "169.254.169.254" || + resource.host == "fd00:ec2::254" || + resource.host == "metadata.google.internal" || + resource.host like "metadata.*.internal" +}; + +forbid ( + principal, + action == Agent::Action::"open", + resource is Agent::File +) when { + context.mode == "write" && + ( + resource.path like "/etc/*" || + resource.path like "/usr/*" || + resource.path like "/root/*" || + resource.path like "/var/log/*" || + resource.path like "/sys/*" || + resource.path like "/proc/*" + ) +}; + +forbid ( + principal, + action == Agent::Action::"open", + resource is Agent::File +) when { + context.mode == "read" && + ( + resource.path like "*.pem" || + resource.path like "*id_rsa*" || + resource.path like "*id_ed25519*" || + resource.path like "*/.aws/credentials" || + resource.path like "*/.ssh/*" || + resource.path like "*/.netrc" || + resource.path like "*/secrets.*" + ) +}; + +forbid ( + principal, + action == Agent::Action::"exec", + resource +) when { + context.command in ["rm", "dd", "mkfs", "shutdown", "reboot", "kill", "pkill"] +}; diff --git a/ecosystem/profiles/cursor/profile.yaml b/ecosystem/profiles/cursor/profile.yaml new file mode 100644 index 0000000..4994a4e --- /dev/null +++ b/ecosystem/profiles/cursor/profile.yaml @@ -0,0 +1,26 @@ +apiVersion: scopeblind.profiles/v1 +kind: SandboxProfile +metadata: + name: cursor + tool: Cursor + tool_url: https://www.cursor.com + profile_version: 1.0.0 + maintained_by: ScopeBlind + license: Apache-2.0 + veritas_acta_receipt_type: scopeblind:decision +spec: + sb_runtime: + recommended_ring: 3 + sandbox_backend: sb_runtime_builtin + receipts_dir: .veritasacta/receipts/cursor + policy_file: ./policy.cedar + nono: + capability_file: ./nono-capabilities.yaml + recommended_mode: compose + intended_threat_model: + - Cursor's MCP bridge, Composer mode, and agent tabs all produce tool calls + - Cursor talks to cursor.com / cursor.sh backends for LLM proxying; allow those + - Otherwise baseline identical to claude-code profile + notes: + - Cursor runs inside Electron so FS access patterns look different from CLI agents + - If you use Cursor's MCP integration with third-party servers, extend the network allowlist per-MCP-server diff --git a/ecosystem/profiles/gemini-cli/README.md b/ecosystem/profiles/gemini-cli/README.md new file mode 100644 index 0000000..f04142e --- /dev/null +++ b/ecosystem/profiles/gemini-cli/README.md @@ -0,0 +1,25 @@ +# Profile: Google Gemini CLI + +Run [Google Gemini CLI](https://github.com/google-gemini/gemini-cli) under sb-runtime + nono + signed receipts. + +Baseline is similar to `claude-code` with Google-specific network endpoints (`googleapis.com`, `generativelanguage.googleapis.com`, `google.com`) instead of Anthropic's. + +## Quick start + +```bash +npx @veritasacta/verify init --profile gemini-cli + +# Or standalone: +sb-runtime --ring 3 --profile ./profile.yaml -- gemini +``` + +## Network allowlist + +- Google AI Studio: `generativelanguage.googleapis.com`, `*.googleapis.com`, `*.google.com` +- Standard developer domains: `api.github.com`, `githubusercontent.com`, `pypi.org`, `npmjs.org` + +## Exec allowlist + +Same dev tools as claude-code: `git`, `pytest`, `npm`, `node`, `python`, `go`, plus the safe inspection commands. + +See [`claude-code/README.md`](../claude-code/README.md) for the full threat model and composition pattern. diff --git a/ecosystem/profiles/gemini-cli/nono-capabilities.yaml b/ecosystem/profiles/gemini-cli/nono-capabilities.yaml new file mode 100644 index 0000000..b89845d --- /dev/null +++ b/ecosystem/profiles/gemini-cli/nono-capabilities.yaml @@ -0,0 +1,22 @@ +version: "1" +profile: gemini-cli +capabilities: + filesystem: + allow_read: [/workspace, /tmp, /usr/bin, /usr/lib, /lib, /etc/ssl/certs] + allow_readwrite: [/workspace] + deny: [/etc, /var, /root, /home, /proc/*/mem] + network: + allow_egress: + - "*.googleapis.com" + - generativelanguage.googleapis.com + - "*.google.com" + - api.github.com + - "*.githubusercontent.com" + - api.pypi.org + - registry.npmjs.org + deny: [169.254.169.254, metadata.google.internal, fd00:ec2::254] + block_all_other: true + syscalls: + default: allowlist + allowed: [read, write, openat, close, fstat, mmap, brk, execve, clone, wait4, pipe2, fork, dup2, accept4, connect, bind, listen, socket, rt_sigaction, rt_sigreturn, exit_group] + denied: [ptrace, process_vm_readv, keyctl, personality, pivot_root, chroot, mount] diff --git a/ecosystem/profiles/gemini-cli/policy.cedar b/ecosystem/profiles/gemini-cli/policy.cedar new file mode 100644 index 0000000..1630fc0 --- /dev/null +++ b/ecosystem/profiles/gemini-cli/policy.cedar @@ -0,0 +1,73 @@ +// Cedar policy for Google Gemini CLI. Read-review default with scoped shell. + +permit ( + principal is Agent::Principal, + action == Agent::Action::"open", + resource is Agent::File +) when { + context.mode == "read" && + (resource.path like "/workspace/*" || resource.path like "/tmp/*") +}; + +permit ( + principal is Agent::Principal, + action == Agent::Action::"open", + resource is Agent::File +) when { + context.mode == "write" && resource.path like "/workspace/*" +}; + +permit ( + principal is Agent::Principal, + action == Agent::Action::"exec", + resource +) when { + context.command in [ + "ls", "cat", "grep", "rg", "find", "head", "tail", "wc", + "git", "pytest", "npm", "node", "python", "python3", "go" + ] +}; + +permit ( + principal is Agent::Principal, + action == Agent::Action::"connect", + resource is Agent::Endpoint +) when { + context.tls == true && + ( + resource.host like "*.googleapis.com" || + resource.host like "generativelanguage.googleapis.com" || + resource.host like "*.google.com" || + resource.host like "api.github.com" || + resource.host like "*.githubusercontent.com" || + resource.host like "api.pypi.org" || + resource.host like "registry.npmjs.org" + ) +}; + +forbid ( + principal, + action == Agent::Action::"connect", + resource is Agent::Endpoint +) when { + resource.host == "169.254.169.254" || + resource.host == "metadata.google.internal" || + resource.host == "fd00:ec2::254" +}; + +forbid ( + principal, + action == Agent::Action::"open", + resource is Agent::File +) when { + (context.mode == "write" && (resource.path like "/etc/*" || resource.path like "/usr/*" || resource.path like "/root/*" || resource.path like "/var/log/*")) || + (context.mode == "read" && (resource.path like "*.pem" || resource.path like "*id_rsa*" || resource.path like "*id_ed25519*" || resource.path like "*/.aws/credentials" || resource.path like "*/.ssh/*" || resource.path like "*/.netrc")) +}; + +forbid ( + principal, + action == Agent::Action::"exec", + resource +) when { + context.command in ["rm", "dd", "mkfs", "shutdown", "reboot", "kill", "pkill"] +}; diff --git a/ecosystem/profiles/gemini-cli/profile.yaml b/ecosystem/profiles/gemini-cli/profile.yaml new file mode 100644 index 0000000..2c26585 --- /dev/null +++ b/ecosystem/profiles/gemini-cli/profile.yaml @@ -0,0 +1,21 @@ +apiVersion: scopeblind.profiles/v1 +kind: SandboxProfile +metadata: + name: gemini-cli + tool: Google Gemini CLI + tool_url: https://github.com/google-gemini/gemini-cli + profile_version: 1.0.0 + maintained_by: ScopeBlind + license: Apache-2.0 +spec: + sb_runtime: + recommended_ring: 3 + sandbox_backend: sb_runtime_builtin + receipts_dir: .veritasacta/receipts/gemini-cli + policy_file: ./policy.cedar + nono: + capability_file: ./nono-capabilities.yaml + intended_threat_model: + - Gemini CLI tool calls go through Google AI Studio backends + - Shell access narrower than claude-code (read-review default) + - Network limited to Google AI + common dev diff --git a/ecosystem/profiles/openclaw/README.md b/ecosystem/profiles/openclaw/README.md new file mode 100644 index 0000000..4233869 --- /dev/null +++ b/ecosystem/profiles/openclaw/README.md @@ -0,0 +1,31 @@ +# Profile: OpenClaw + +Run [OpenClaw](https://github.com/openclaw-ai/openclaw) under sb-runtime + nono + signed receipts, **composing with OpenClaw's own guard-rail framework** rather than replacing it. + +## Composition model + +OpenClaw has in-process behavioural guards (prompt injection defense, PII detection, tool-use heuristics). This profile is designed to complement those: + +- **OpenClaw guards** handle behavioural and content-level checks +- **sb-runtime + Cedar policy** handles structural checks (which file path, which command, which host) +- **nono** handles kernel-level enforcement (the final boundary) + +Defense in depth: all three run. An exploit that bypasses OpenClaw's prompt-injection defense still has to clear the Cedar policy and the kernel sandbox. + +## Network allowlist + +Broader than claude-code because OpenClaw is model-agnostic: includes Anthropic, OpenAI, Google endpoints plus standard dev domains. Tighten per deployment. + +## Quick start + +```bash +npx @veritasacta/verify init --profile openclaw + +# Or with explicit composition: +nono run --caps ./nono-capabilities.yaml -- \ + sb-runtime --ring 2 --policy ./policy.cedar -- openclaw +``` + +## Contributing updates + +OpenClaw's API is evolving. If a profile update is needed as OpenClaw's tool-use conventions change, open a PR against this directory. diff --git a/ecosystem/profiles/openclaw/nono-capabilities.yaml b/ecosystem/profiles/openclaw/nono-capabilities.yaml new file mode 100644 index 0000000..cccb188 --- /dev/null +++ b/ecosystem/profiles/openclaw/nono-capabilities.yaml @@ -0,0 +1,22 @@ +version: "1" +profile: openclaw +capabilities: + filesystem: + allow_read: [/workspace, /tmp, /usr/bin, /usr/lib, /lib, /etc/ssl/certs] + allow_readwrite: [/workspace] + deny: [/etc, /var, /root, /home, /proc/*/mem] + network: + allow_egress: + - "*.anthropic.com" + - "*.openai.com" + - "*.googleapis.com" + - api.github.com + - "*.githubusercontent.com" + - api.pypi.org + - registry.npmjs.org + deny: [169.254.169.254, metadata.google.internal, fd00:ec2::254] + block_all_other: true + syscalls: + default: allowlist + allowed: [read, write, openat, close, fstat, mmap, brk, execve, clone, wait4, pipe2, fork, dup2, accept4, connect, bind, listen, socket, rt_sigaction, rt_sigreturn, exit_group] + denied: [ptrace, process_vm_readv, keyctl, personality, pivot_root, chroot, mount] diff --git a/ecosystem/profiles/openclaw/policy.cedar b/ecosystem/profiles/openclaw/policy.cedar new file mode 100644 index 0000000..9c19666 --- /dev/null +++ b/ecosystem/profiles/openclaw/policy.cedar @@ -0,0 +1,74 @@ +// Cedar policy for OpenClaw — composes with OpenClaw's in-process guards. + +permit ( + principal is Agent::Principal, + action == Agent::Action::"open", + resource is Agent::File +) when { + context.mode == "read" && + (resource.path like "/workspace/*" || resource.path like "/tmp/*") +}; + +permit ( + principal is Agent::Principal, + action == Agent::Action::"open", + resource is Agent::File +) when { + context.mode == "write" && resource.path like "/workspace/*" +}; + +permit ( + principal is Agent::Principal, + action == Agent::Action::"exec", + resource +) when { + context.command in [ + "ls", "cat", "grep", "rg", "find", "head", "tail", "wc", + "git", "pytest", "npm", "npx", "yarn", + "python", "python3", "node", "go", "cargo" + ] +}; + +permit ( + principal is Agent::Principal, + action == Agent::Action::"connect", + resource is Agent::Endpoint +) when { + context.tls == true && + ( + resource.host like "*.anthropic.com" || + resource.host like "*.openai.com" || + resource.host like "*.googleapis.com" || + resource.host like "api.github.com" || + resource.host like "*.githubusercontent.com" || + resource.host like "api.pypi.org" || + resource.host like "registry.npmjs.org" + ) +}; + +forbid ( + principal, + action == Agent::Action::"connect", + resource is Agent::Endpoint +) when { + resource.host == "169.254.169.254" || + resource.host == "metadata.google.internal" || + resource.host == "fd00:ec2::254" +}; + +forbid ( + principal, + action == Agent::Action::"open", + resource is Agent::File +) when { + (context.mode == "write" && (resource.path like "/etc/*" || resource.path like "/usr/*" || resource.path like "/root/*" || resource.path like "/var/log/*")) || + (context.mode == "read" && (resource.path like "*.pem" || resource.path like "*id_rsa*" || resource.path like "*id_ed25519*" || resource.path like "*/.aws/credentials" || resource.path like "*/.ssh/*" || resource.path like "*/.netrc")) +}; + +forbid ( + principal, + action == Agent::Action::"exec", + resource +) when { + context.command in ["rm", "dd", "mkfs", "shutdown", "reboot", "kill", "pkill"] +}; diff --git a/ecosystem/profiles/openclaw/profile.yaml b/ecosystem/profiles/openclaw/profile.yaml new file mode 100644 index 0000000..e417b2f --- /dev/null +++ b/ecosystem/profiles/openclaw/profile.yaml @@ -0,0 +1,24 @@ +apiVersion: scopeblind.profiles/v1 +kind: SandboxProfile +metadata: + name: openclaw + tool: OpenClaw + tool_url: https://github.com/openclaw-ai/openclaw + profile_version: 1.0.0 + maintained_by: ScopeBlind + license: Apache-2.0 +spec: + sb_runtime: + recommended_ring: 3 + sandbox_backend: sb_runtime_builtin + receipts_dir: .veritasacta/receipts/openclaw + policy_file: ./policy.cedar + nono: + capability_file: ./nono-capabilities.yaml + intended_threat_model: + - OpenClaw has its own guard-rail framework; this profile is intended to COMPOSE with it + - Sandbox enforces filesystem + network + exec boundaries OpenClaw's guards cannot + - OpenClaw's behavioral checks run in-process; sandbox catches what OpenClaw misses + notes: + - Network allowlist kept broad to match OpenClaw's typical use; tighten per deployment + - Expect iteration as OpenClaw's API surface evolves; maintainer PRs welcome diff --git a/ecosystem/registry-worker/README.md b/ecosystem/registry-worker/README.md new file mode 100644 index 0000000..86f494d --- /dev/null +++ b/ecosystem/registry-worker/README.md @@ -0,0 +1,55 @@ +# Veritas Acta Implementations Registry — Cloudflare Worker + +Public, read-only registry of Veritas Acta ecosystem implementations. +Mirrors JSON files from `VeritasActa/agt-integration-profile/implementations/` +with cached responses and CORS enabled. + +## Endpoints + +| Method | Path | Description | +|---|---|---| +| GET | `/implementations.json` | List all registered implementations | +| GET | `/implementations/{name}.json` | Single implementation's record | +| GET | `/implementations/{name}/attestation.json` | Signed conformance attestation | +| GET | `/stats.json` | Aggregate stats (count, tier distribution) | +| GET | `/health` | Health probe | + +## Implementation record format + +```json +{ + "name": "sb-runtime", + "repo": "https://github.com/ScopeBlind/sb-runtime", + "version": "0.1.0", + "claimed_tier": 4, + "language": "Rust", + "license": "Apache-2.0", + "maintainer": "@tomjwxf", + "conformance_evidence": "https://github.com/ScopeBlind/agent-governance-testvectors/actions/runs/12345678", + "signed_attestation_hash": "sha256:abc123...", + "registered_at": "2026-04-19T00:00:00.000Z" +} +``` + +## Deployment + +```bash +wrangler deploy +``` + +Bind a KV namespace named `REGISTRY_CACHE` for caching. Zero-config +deployments work but miss the cache; responses will hit GitHub's API +directly (rate-limited to 60 req/hour unauthenticated). + +## Register an implementation + +Open a PR against `VeritasActa/agt-integration-profile` adding: + +1. `implementations/{name}.json` — record metadata +2. `implementations/{name}/attestation.json` — signed conformance attestation + +The registry serves updates automatically after PR merge. + +## License + +Apache-2.0 diff --git a/ecosystem/registry-worker/worker.js b/ecosystem/registry-worker/worker.js new file mode 100644 index 0000000..1d73f0e --- /dev/null +++ b/ecosystem/registry-worker/worker.js @@ -0,0 +1,137 @@ +/** + * registry.veritasacta.com — Cloudflare Worker + * + * Serves a public implementations registry for Veritas Acta ecosystem. + * Each implementation signs a conformance claim with their own key and + * registers via GitHub PR against the implementations/*.json file in the + * agt-integration-profile repo. This worker is read-only: it mirrors the + * canonical JSON files from the repo and serves them with appropriate + * CORS and caching. + * + * Endpoints: + * GET /implementations.json + * Returns the full registry as a JSON array. + * + * GET /implementations/{name}.json + * Returns a single implementation's record. + * + * GET /implementations/{name}/attestation.json + * Returns the signed conformance attestation for the implementation. + * + * GET /stats.json + * Returns aggregate stats (count, tier distribution). + * + * GET /health + * Returns 200 OK. + * + * Authentication: none required for reads. Writes happen via GitHub PR. + */ + +const REGISTRY_REPO = 'VeritasActa/agt-integration-profile'; +const REGISTRY_PATH = 'implementations'; +const CACHE_TTL = 300; // 5 minutes + +export default { + async fetch(request, env, ctx) { + const url = new URL(request.url); + const { pathname } = url; + + // CORS preflight + if (request.method === 'OPTIONS') { + return new Response(null, { headers: corsHeaders() }); + } + + if (pathname === '/health') { + return json({ status: 'ok', timestamp: new Date().toISOString() }); + } + + if (pathname === '/implementations.json') { + const list = await fetchImplementationsList(env); + return json(list); + } + + if (pathname === '/stats.json') { + const list = await fetchImplementationsList(env); + return json({ + count: list.length, + byTier: list.reduce((acc, impl) => { + const tier = impl.claimed_tier || 1; + acc[tier] = (acc[tier] || 0) + 1; + return acc; + }, {}), + lastUpdated: new Date().toISOString(), + }); + } + + const implMatch = pathname.match(/^\/implementations\/([a-z0-9-]+)(?:\/attestation)?\.json$/); + if (implMatch) { + const name = implMatch[1]; + const isAttestation = pathname.endsWith('/attestation.json'); + const impl = await fetchImplementation(env, name, isAttestation); + if (!impl) return json({ error: 'not found', name }, 404); + return json(impl); + } + + return json({ error: 'not found', path: pathname }, 404); + }, +}; + +async function fetchImplementationsList(env) { + const cached = await env.REGISTRY_CACHE?.get('implementations-list'); + if (cached) return JSON.parse(cached); + + const url = `https://api.github.com/repos/${REGISTRY_REPO}/contents/${REGISTRY_PATH}`; + const res = await fetch(url, { + headers: { + 'User-Agent': 'registry.veritasacta.com', + Accept: 'application/vnd.github.v3+json', + }, + }); + if (!res.ok) return []; + + const contents = await res.json(); + const impls = []; + for (const file of contents) { + if (!file.name.endsWith('.json') || file.name === 'attestation.json') continue; + const data = await fetchImplementationJson(file.download_url); + if (data) impls.push(data); + } + + if (env.REGISTRY_CACHE) { + await env.REGISTRY_CACHE.put('implementations-list', JSON.stringify(impls), { expirationTtl: CACHE_TTL }); + } + return impls; +} + +async function fetchImplementation(env, name, isAttestation) { + const filename = isAttestation ? `${name}/attestation.json` : `${name}.json`; + const url = `https://raw.githubusercontent.com/${REGISTRY_REPO}/main/${REGISTRY_PATH}/${filename}`; + const res = await fetch(url, { headers: { 'User-Agent': 'registry.veritasacta.com' } }); + if (!res.ok) return null; + return res.json(); +} + +async function fetchImplementationJson(downloadUrl) { + try { + const res = await fetch(downloadUrl); + if (!res.ok) return null; + return res.json(); + } catch { + return null; + } +} + +function json(body, status = 200) { + return new Response(JSON.stringify(body, null, 2), { + status, + headers: { 'content-type': 'application/json', ...corsHeaders() }, + }); +} + +function corsHeaders() { + return { + 'access-control-allow-origin': '*', + 'access-control-allow-methods': 'GET, OPTIONS', + 'cache-control': `public, max-age=${CACHE_TTL}`, + }; +} diff --git a/ecosystem/registry-worker/wrangler.toml b/ecosystem/registry-worker/wrangler.toml new file mode 100644 index 0000000..af4afde --- /dev/null +++ b/ecosystem/registry-worker/wrangler.toml @@ -0,0 +1,11 @@ +#:schema node_modules/wrangler/config-schema.json +name = "registry-veritasacta" +main = "worker.js" +compatibility_date = "2024-12-01" + +routes = [ + { pattern = "registry.veritasacta.com/*", zone_name = "veritasacta.com" } +] + +[observability] +enabled = true diff --git a/ecosystem/reputation/DESIGN.md b/ecosystem/reputation/DESIGN.md new file mode 100644 index 0000000..345b395 --- /dev/null +++ b/ecosystem/reputation/DESIGN.md @@ -0,0 +1,52 @@ +# Issuer Reputation Layer (v0.7.0 target) + +Bayesian reputation over RECEIPT ISSUERS (not agents — aeoess already +has agent reputation; this complements it with the inverse). + +## Goal + +Track which issuer keys reliably produce valid, well-formed, +conformance-tier-appropriate receipts. Give verifiers an additional +signal — not a replacement for cryptographic verification, but a +reliability metric that composes with it. + +## Axes + +Per issuer key (kid), track: + +- **Conformance rate** — fraction of receipts that pass full + verification +- **Chain integrity rate** — fraction of chains that verify without + breakage +- **Tier distribution** — which conformance tiers the issuer reaches +- **Receipt frequency** — issuance rate over time +- **Jurisdictional distribution** — where receipts are verified + +## Data source + +Optional: the verifier daemon can contribute anonymized observations +to a public reputation log (opt-in). Or: organizations run private +reputation logs for their own issuer pools. + +No phone-home by default. Reputation is computed locally or on explicit +public submission. + +## Composition with aeoess APS + +- aeoess reputation = on agents / principals ("can we trust agent X to + act?") +- Veritas Acta reputation = on issuers / signers ("does issuer Y + reliably produce valid receipts?") + +Together they cover both sides of the trust surface. + +## Non-goals + +- Not a proof of trustworthiness (only evidence of historical + reliability) +- Not a replacement for signature verification +- Not a ranking / leaderboard (raw metrics, not ordinal) + +## License + +Apache-2.0 once shipped. diff --git a/ecosystem/rollback/DESIGN.md b/ecosystem/rollback/DESIGN.md new file mode 100644 index 0000000..9638d66 --- /dev/null +++ b/ecosystem/rollback/DESIGN.md @@ -0,0 +1,62 @@ +# Filesystem Rollback — AIP-0004 (v0.6.0 target) + +> **Spec:** [AIP-0004 Content-Addressed Snapshot and Rollback Receipts](../../../../specs/aip/AIP-0004-snapshot-receipts.md) +> **Reference implementation:** [`snapshot.mjs`](./snapshot.mjs) (Merkle helper + payload builder) +> **Schema:** [`snapshot-receipt.schema.json`](./snapshot-receipt.schema.json) +> **Tests:** 11 units in `test/unit/snapshot.test.js` (determinism, path-order independence, tamper detection, odd-layer duplication, payload validation) + + + +Content-addressed filesystem snapshots before each receipted session, +with one-command rollback on anomaly detection. Matches nono's undo +pattern but pairs it with Veritas Acta receipts so rollback events are +themselves signed. + +## Goal + +When an agent session goes wrong, roll back to the pre-session state +without losing the receipt trail. The receipts survive; the filesystem +reverts. + +## Design + +### Snapshot primitive + +- Before every session, hash the files the session will touch (derived + from the agent's allowlist / policy) +- Store content-addressed copies in `.veritasacta/snapshots/{session_id}/` +- Content-addressed dedup: files common across sessions are stored once +- Snapshot index committed to receipts via `snapshot_digest` field + +### Rollback command + +```bash +veritasacta rollback --session +``` + +Restores files to their pre-session state. Emits a signed `rollback` +receipt linked to the original session. + +### Composition with sb-runtime + +sb-runtime already has filesystem isolation via Landlock. The rollback +layer adds temporal reversion: not "the agent can't write here" but +"if the agent wrote here, we can undo it." + +## Non-goals + +- Not a general-purpose file versioning system (use git) +- Not a replacement for kernel sandboxing (compose with sb-runtime) +- Not guaranteed to roll back external side-effects (API calls, db + mutations). Those require receipts to be inspected and externally + compensated. + +## Implementation path + +- v0.6.0: manual snapshot/rollback commands + session_id linkage +- v0.7.0: automatic anomaly-triggered rollback (ties to supervisor + approval workflow) + +## License + +Apache-2.0 once shipped. diff --git a/ecosystem/rollback/snapshot-receipt.schema.json b/ecosystem/rollback/snapshot-receipt.schema.json new file mode 100644 index 0000000..725f454 --- /dev/null +++ b/ecosystem/rollback/snapshot-receipt.schema.json @@ -0,0 +1,131 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://veritasacta.com/schemas/aip-0004/snapshot-receipt.schema.json", + "title": "AIP-0004 Snapshot / Rollback Receipt", + "description": "Content-addressed snapshot and rollback receipts over AIP-0001 envelope.", + "oneOf": [ + { "$ref": "#/$defs/snapshot" }, + { "$ref": "#/$defs/rollback" } + ], + "$defs": { + "envelope": { + "type": "object", + "required": ["payload", "signature"], + "properties": { + "payload": { "type": "object" }, + "signature": { + "type": "object", + "required": ["alg", "kid", "sig"], + "properties": { + "alg": { "type": "string", "enum": ["ed25519", "EdDSA", "ed25519+ml-dsa-65"] }, + "kid": { "type": "string", "minLength": 1 }, + "sig": { "type": "string", "pattern": "^[0-9a-f]+$" } + } + } + } + }, + "snapshot": { + "allOf": [{ "$ref": "#/$defs/envelope" }], + "properties": { + "payload": { + "type": "object", + "required": [ + "type", "session_id", "snapshot_root", "snapshot_backend", + "file_count", "snapshot_scope", "issuer_id", "agent_id", "issued_at" + ], + "properties": { + "type": { "const": "snapshot" }, + "session_id": { "type": "string", "minLength": 1 }, + "snapshot_root": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{43}$", + "description": "base64url(SHA-256) no padding" + }, + "snapshot_backend": { + "type": "string", + "enum": ["zfs", "btrfs", "git", "cow", "copy", "nono", "custom"] + }, + "snapshot_uri": { "type": "string" }, + "file_count": { "type": "integer", "minimum": 0 }, + "snapshot_scope": { + "type": "object", + "required": ["allowed_read", "allowed_write"], + "properties": { + "allowed_read": { "type": "array", "items": { "type": "string" } }, + "allowed_write": { "type": "array", "items": { "type": "string" } }, + "deny": { "type": "array", "items": { "type": "string" } } + } + }, + "policy_hash": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{43}$" + }, + "previousReceiptHash": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{43}$" + }, + "issuer_id": { "type": "string" }, + "agent_id": { "type": "string" }, + "issued_at": { "type": "string", "format": "date-time" } + } + } + } + }, + "rollback": { + "allOf": [{ "$ref": "#/$defs/envelope" }], + "properties": { + "payload": { + "type": "object", + "required": [ + "type", "session_id", "snapshot_receipt_hash", + "rollback_reason", "rollback_initiator", "rollback_outcome", + "post_rollback_root", "issuer_id", "agent_id", "issued_at" + ], + "properties": { + "type": { "const": "rollback" }, + "session_id": { "type": "string", "minLength": 1 }, + "snapshot_receipt_hash": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{43}$" + }, + "rollback_reason": { "type": "string", "maxLength": 256 }, + "rollback_initiator": { + "type": "string", + "enum": ["human", "policy", "anomaly-detector"] + }, + "rollback_outcome": { + "type": "string", + "enum": ["success", "partial", "failed"] + }, + "post_rollback_root": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{43}$" + }, + "external_side_effects": { + "type": "array", + "items": { + "type": "object", + "required": ["receipt_hash", "action", "compensable"], + "properties": { + "receipt_hash": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{43}$" + }, + "action": { "type": "string" }, + "compensable": { "type": "boolean" } + } + } + }, + "previousReceiptHash": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{43}$" + }, + "issuer_id": { "type": "string" }, + "agent_id": { "type": "string" }, + "issued_at": { "type": "string", "format": "date-time" } + } + } + } + } + } +} diff --git a/ecosystem/rollback/snapshot.mjs b/ecosystem/rollback/snapshot.mjs new file mode 100755 index 0000000..814ecc6 --- /dev/null +++ b/ecosystem/rollback/snapshot.mjs @@ -0,0 +1,236 @@ +#!/usr/bin/env node +/** + * AIP-0004 reference implementation — content-addressed snapshot + + * rollback receipt Merkle helper. + * + * Produces `snapshot_root` given a list of (relative_path, file_bytes) + * entries. Also includes a lightweight example signer that composes + * with the AIP-0001 envelope so operators can see end-to-end how the + * pieces compose. + * + * This is a reference implementation of the spec at + * specs/aip/AIP-0004-snapshot-receipts.md. The production path is for + * sb-runtime (or any rollback engine) to adopt this Merkle construction + * and the receipt schema; the verify-cli will validate those receipts + * once the v0.6.0 --verify-snapshot path lands. + * + * Usage as a library: + * import { buildSnapshotRoot, makeSnapshotPayload } from './snapshot.mjs'; + * const root = buildSnapshotRoot([['src/app.js', fileBytes], ...]); + * + * Usage as a CLI (experimental): + * node snapshot.mjs --root ./project --out snapshot.json --session + * + * License: Apache-2.0. + */ + +import { readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'; +import { join, relative, resolve } from 'node:path'; +import { createHash } from 'node:crypto'; + +// ───── Core Merkle construction (AIP-0004 §1.2) ───── + +/** + * Compute the AIP-0004 snapshot Merkle root. + * + * @param {Array<[string, Buffer|Uint8Array]>} entries + * [relativePath, fileBytes] pairs. Paths MUST be POSIX and + * relative to the snapshot root. + * @returns {string} base64url-encoded SHA-256 Merkle root (no padding). + */ +export function buildSnapshotRoot(entries) { + if (!Array.isArray(entries) || entries.length === 0) { + return sha256b64url(Buffer.alloc(0)); + } + + // Sort lexicographically by path. + const sorted = [...entries].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + + // Leaves: SHA-256(path_utf8 || 0x00 || SHA-256(file_bytes)) + let layer = sorted.map(([path, bytes]) => { + const pathBuf = Buffer.from(path, 'utf-8'); + const fileHash = sha256(bytes); + return sha256(Buffer.concat([pathBuf, Buffer.from([0x00]), fileHash])); + }); + + // Pairwise hash; duplicate last if odd. + while (layer.length > 1) { + const next = []; + for (let i = 0; i < layer.length; i += 2) { + const left = layer[i]; + const right = i + 1 < layer.length ? layer[i + 1] : layer[i]; + next.push(sha256(Buffer.concat([left, right]))); + } + layer = next; + } + + return base64url(layer[0]); +} + +function sha256(buf) { + return createHash('sha256').update(buf).digest(); +} + +function sha256b64url(buf) { + return base64url(sha256(buf)); +} + +function base64url(buf) { + return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +// ───── Snapshot payload builder (AIP-0004 §1) ───── + +/** + * Build an unsigned AIP-0004 snapshot payload. + * + * @param {Object} opts + * @param {string} opts.session_id + * @param {string} opts.snapshot_root + * @param {'zfs'|'btrfs'|'git'|'cow'|'copy'|'nono'|'custom'} opts.backend + * @param {number} opts.file_count + * @param {Object} opts.scope { allowed_read[], allowed_write[], deny[]? } + * @param {string} opts.issuer_id + * @param {string} opts.agent_id + * @param {string} [opts.snapshot_uri] + * @param {string} [opts.policy_hash] + * @param {string} [opts.previousReceiptHash] + * @returns {Object} + */ +export function makeSnapshotPayload(opts) { + const payload = { + type: 'snapshot', + session_id: opts.session_id, + snapshot_root: opts.snapshot_root, + snapshot_backend: opts.backend, + file_count: opts.file_count, + snapshot_scope: { + allowed_read: opts.scope.allowed_read || [], + allowed_write: opts.scope.allowed_write || [], + ...(opts.scope.deny ? { deny: opts.scope.deny } : {}), + }, + issuer_id: opts.issuer_id, + agent_id: opts.agent_id, + issued_at: new Date().toISOString(), + }; + if (opts.snapshot_uri) payload.snapshot_uri = opts.snapshot_uri; + if (opts.policy_hash) payload.policy_hash = opts.policy_hash; + if (opts.previousReceiptHash) payload.previousReceiptHash = opts.previousReceiptHash; + return payload; +} + +/** + * Build an unsigned AIP-0004 rollback payload. + * + * @param {Object} opts + * @param {string} opts.session_id + * @param {string} opts.snapshot_receipt_hash + * @param {string} opts.rollback_reason MAX 256 bytes + * @param {'human'|'policy'|'anomaly-detector'} opts.rollback_initiator + * @param {'success'|'partial'|'failed'} opts.rollback_outcome + * @param {string} opts.post_rollback_root + * @param {string} opts.issuer_id + * @param {string} opts.agent_id + * @param {Array<{receipt_hash:string, action:string, compensable:boolean}>} [opts.external_side_effects] + * @param {string} [opts.previousReceiptHash] + * @returns {Object} + */ +export function makeRollbackPayload(opts) { + if (opts.rollback_reason && Buffer.byteLength(opts.rollback_reason, 'utf-8') > 256) { + throw new Error('rollback_reason exceeds 256 bytes'); + } + const payload = { + type: 'rollback', + session_id: opts.session_id, + snapshot_receipt_hash: opts.snapshot_receipt_hash, + rollback_reason: opts.rollback_reason, + rollback_initiator: opts.rollback_initiator, + rollback_outcome: opts.rollback_outcome, + post_rollback_root: opts.post_rollback_root, + issuer_id: opts.issuer_id, + agent_id: opts.agent_id, + issued_at: new Date().toISOString(), + }; + if (opts.external_side_effects) payload.external_side_effects = opts.external_side_effects; + if (opts.previousReceiptHash) payload.previousReceiptHash = opts.previousReceiptHash; + return payload; +} + +// ───── Directory walker (convenience — not part of the spec) ───── + +/** + * Walk a directory tree and return [relPath, Buffer] entries suitable + * for buildSnapshotRoot. Applies an optional include/exclude filter. + * + * @param {string} root + * @param {Object} [filter] + * @param {(relPath: string) => boolean} [filter.include] + * @returns {Array<[string, Buffer]>} + */ +export function walkDirectory(root, filter = {}) { + const absRoot = resolve(root); + const entries = []; + + function recurse(dir) { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + let st; + try { st = statSync(full); } catch { continue; } + if (st.isDirectory()) { + recurse(full); + } else if (st.isFile()) { + const rel = relative(absRoot, full).split('\\').join('/'); + if (filter.include && !filter.include(rel)) continue; + try { entries.push([rel, readFileSync(full)]); } catch { continue; } + } + } + } + + recurse(absRoot); + return entries; +} + +// ───── CLI (example wiring) ───── + +const isMain = import.meta.url === `file://${process.argv[1]}`; +if (isMain) { + const args = process.argv.slice(2); + const opts = {}; + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === '--root') opts.root = args[++i]; + else if (a === '--out') opts.out = args[++i]; + else if (a === '--session') opts.session = args[++i]; + else if (a === '--backend') opts.backend = args[++i]; + else if (a === '--issuer') opts.issuer = args[++i]; + else if (a === '--agent') opts.agent = args[++i]; + else if (a === '-h' || a === '--help') { + console.log('snapshot.mjs --root --session [--out ]'); + console.log(' [--backend zfs|btrfs|git|cow|copy|nono|custom]'); + console.log(' [--issuer ] [--agent ]'); + console.log('Builds an unsigned AIP-0004 snapshot payload. Hand to your signer.'); + process.exit(0); + } + } + if (!opts.root) { + console.error('--root required'); + process.exit(2); + } + + const entries = walkDirectory(opts.root, { + include: (p) => !p.startsWith('.git/') && !p.startsWith('node_modules/'), + }); + const root = buildSnapshotRoot(entries); + const payload = makeSnapshotPayload({ + session_id: opts.session || 'anon-session', + snapshot_root: root, + backend: opts.backend || 'copy', + file_count: entries.length, + scope: { allowed_read: [opts.root], allowed_write: [opts.root] }, + issuer_id: opts.issuer || 'reference-issuer', + agent_id: opts.agent || 'reference-agent', + }); + const out = JSON.stringify(payload, null, 2); + if (opts.out) writeFileSync(opts.out, out + '\n'); + else process.stdout.write(out + '\n'); +} diff --git a/ecosystem/sdk-js/package.json b/ecosystem/sdk-js/package.json new file mode 100644 index 0000000..b478d85 --- /dev/null +++ b/ecosystem/sdk-js/package.json @@ -0,0 +1,14 @@ +{ + "name": "@veritasacta/sdk", + "version": "0.1.0", + "description": "Tiny SDK for producing Veritas Acta signed decision receipts. Framework-agnostic; adapters build on top.", + "license": "Apache-2.0", + "type": "module", + "main": "src/index.js", + "types": "src/index.d.ts", + "files": ["src/"], + "dependencies": {}, + "engines": { "node": ">=18.0.0" }, + "keywords": ["veritasacta", "receipts", "ed25519", "jcs", "agent-governance"], + "repository": { "type": "git", "url": "git+https://github.com/VeritasActa/sdk-js.git" } +} diff --git a/ecosystem/sdk-js/src/index.d.ts b/ecosystem/sdk-js/src/index.d.ts new file mode 100644 index 0000000..bfd7424 --- /dev/null +++ b/ecosystem/sdk-js/src/index.d.ts @@ -0,0 +1,26 @@ +export interface SignDecisionInput { + tool: string; + args?: Record; + decision?: 'allow' | 'deny' | 'require_approval' | 'compensated' | string; + policy_id?: string; + policy_hash?: string; + skill_version_hash?: string; + delegation_chain_root?: string; + metadata?: Record; +} + +export interface Receipt { + payload: Record; + signature: { alg: string; kid: string; sig: string }; +} + +export class Signer { + readonly kid: string; + readonly pubHex: string; + readonly issuerId: string; + sequence: number; + previousReceiptHash: string | null; + + static fromKeyFile(path: string): Signer; + signDecision(input: SignDecisionInput): Receipt; +} diff --git a/ecosystem/sdk-js/src/index.js b/ecosystem/sdk-js/src/index.js new file mode 100644 index 0000000..6e3100d --- /dev/null +++ b/ecosystem/sdk-js/src/index.js @@ -0,0 +1,111 @@ +/** + * @veritasacta/sdk — tiny signing SDK + * + * Usage (Node.js): + * + * import { Signer } from '@veritasacta/sdk'; + * const signer = Signer.fromKeyFile('.veritasacta/attester.json'); + * const receipt = signer.signDecision({ + * tool: 'web_search', + * args: { query: '...' }, + * decision: 'allow', + * policy_id: 'research-only', + * }); + * + * The SDK does one thing: produce valid draft-farley-acta-signed-receipts + * envelopes. Framework adapters use this SDK; the SDK itself has no + * framework-specific code. + * + * @license Apache-2.0 + */ + +import { readFileSync } from 'node:fs'; +import { sign, createPrivateKey, createHash } from 'node:crypto'; + +function canonicalize(obj) { + // Minimal JCS-like canonicalization (AIP-0001 restrictions). + const sortDeep = (o) => { + if (o === null || typeof o !== 'object') return o; + if (Array.isArray(o)) return o.map(sortDeep); + const out = {}; + for (const k of Object.keys(o).sort()) out[k] = sortDeep(o[k]); + return out; + }; + return JSON.stringify(sortDeep(obj)); +} + +export class Signer { + constructor({ kid, privateKey, pubHex, issuerId = null }) { + this.kid = kid; + this.privateKey = privateKey; + this.pubHex = pubHex; + this.issuerId = issuerId || kid; + this.sequence = 0; + this.previousReceiptHash = null; + } + + /** + * Load a signer from a key file written by `veritasacta init` + * (matches the { kid, pubHex, privateDer } shape). + * + * @param {string} path + * @returns {Signer} + */ + static fromKeyFile(path) { + const data = JSON.parse(readFileSync(path, 'utf-8')); + const privateKey = createPrivateKey({ + key: Buffer.from(data.privateDer, 'hex'), + format: 'der', + type: 'pkcs8', + }); + return new Signer({ kid: data.kid, privateKey, pubHex: data.pubHex }); + } + + /** + * Sign a single tool-call decision and return the receipt envelope. + * + * @param {Object} args + * @param {string} args.tool tool name + * @param {Object} [args.args] tool arguments (hashed, not stored raw) + * @param {string} [args.decision='allow'] + * @param {string} [args.policy_id] + * @param {string} [args.policy_hash] + * @param {string} [args.skill_version_hash] + * @param {string} [args.delegation_chain_root] + * @param {Object} [args.metadata] + * @returns {Object} receipt envelope { payload, signature } + */ + signDecision(args) { + this.sequence += 1; + const argStr = JSON.stringify(args.args || {}, Object.keys(args.args || {}).sort()); + const tool_input_hash = 'sha256:' + createHash('sha256').update(argStr, 'utf-8').digest('hex'); + + const payload = { + type: 'veritasacta:decision', + spec: 'draft-farley-acta-signed-receipts-03', + tool_name: args.tool, + tool_input_hash, + decision: args.decision || 'allow', + issued_at: new Date().toISOString(), + issuer_id: this.issuerId, + sequence: this.sequence, + previousReceiptHash: this.previousReceiptHash, + }; + if (args.policy_id) payload.policy_id = args.policy_id; + if (args.policy_hash) payload.policy_hash = args.policy_hash; + if (args.skill_version_hash) payload.skill_version_hash = args.skill_version_hash; + if (args.delegation_chain_root) payload.delegation_chain_root = args.delegation_chain_root; + if (args.metadata) payload.metadata = args.metadata; + + const canonical = canonicalize(payload); + const sig = sign(null, Buffer.from(canonical, 'utf-8'), this.privateKey); + + // Chain linkage + this.previousReceiptHash = 'sha256:' + createHash('sha256').update(canonical, 'utf-8').digest('hex'); + + return { + payload, + signature: { alg: 'EdDSA', kid: this.kid, sig: sig.toString('hex') }, + }; + } +} diff --git a/ecosystem/sdk-py/pyproject.toml b/ecosystem/sdk-py/pyproject.toml new file mode 100644 index 0000000..acf1773 --- /dev/null +++ b/ecosystem/sdk-py/pyproject.toml @@ -0,0 +1,26 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "veritasacta-sdk" +version = "0.1.0" +description = "Tiny SDK for producing Veritas Acta signed decision receipts (Python). Framework-agnostic." +readme = "README.md" +license = "Apache-2.0" +requires-python = ">=3.10" +authors = [{ name = "Tom Farley", email = "tommy@scopeblind.com" }] +keywords = ["veritasacta", "receipts", "ed25519", "jcs", "agent-governance"] +classifiers = [ + "Development Status :: 3 - Alpha", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", + "Topic :: Security :: Cryptography", +] +dependencies = ["pynacl>=1.5.0"] + +[project.urls] +Homepage = "https://github.com/VeritasActa/sdk-py" + +[tool.hatch.build.targets.wheel] +packages = ["src/veritasacta_sdk"] diff --git a/ecosystem/sdk-py/src/veritasacta_sdk/__init__.py b/ecosystem/sdk-py/src/veritasacta_sdk/__init__.py new file mode 100644 index 0000000..488e173 --- /dev/null +++ b/ecosystem/sdk-py/src/veritasacta_sdk/__init__.py @@ -0,0 +1,6 @@ +"""Veritas Acta SDK — tiny Python signing helper.""" + +from .signer import Signer, Receipt + +__version__ = "0.1.0" +__all__ = ["Signer", "Receipt"] diff --git a/ecosystem/sdk-py/src/veritasacta_sdk/signer.py b/ecosystem/sdk-py/src/veritasacta_sdk/signer.py new file mode 100644 index 0000000..0c6600c --- /dev/null +++ b/ecosystem/sdk-py/src/veritasacta_sdk/signer.py @@ -0,0 +1,114 @@ +"""veritasacta_sdk.signer — Ed25519 + JCS receipt signing.""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Optional + +from nacl.signing import SigningKey + + +def _canonicalize(obj: Any) -> str: + """JCS-like canonical serialization (sorted keys, no whitespace).""" + def sort_deep(o): + if isinstance(o, dict): + return {k: sort_deep(o[k]) for k in sorted(o)} + if isinstance(o, list): + return [sort_deep(v) for v in o] + return o + return json.dumps(sort_deep(obj), separators=(",", ":"), ensure_ascii=False) + + +@dataclass +class Receipt: + """An Ed25519-signed decision receipt.""" + payload: dict[str, Any] + signature: dict[str, str] + + def to_dict(self) -> dict[str, Any]: + return {"payload": self.payload, "signature": self.signature} + + def to_json(self, indent: int = 2) -> str: + return json.dumps(self.to_dict(), indent=indent) + + +class Signer: + """Sign draft-farley-acta-signed-receipts-03 decision receipts.""" + + def __init__(self, signing_key: SigningKey, kid: str, issuer_id: Optional[str] = None): + self._signing_key = signing_key + self.kid = kid + self.issuer_id = issuer_id or kid + self.pub_hex = signing_key.verify_key.encode().hex() + self.sequence = 0 + self.previous_receipt_hash: Optional[str] = None + + @classmethod + def from_key_file(cls, path: str) -> "Signer": + """Load a signer from a key file (produced by `veritasacta init`).""" + data = json.loads(Path(path).read_text()) + priv_bytes = bytes.fromhex(data["privateDer"]) + # PKCS#8 wrapper: last 32 bytes are the raw private key + raw = priv_bytes[-32:] if len(priv_bytes) >= 32 else priv_bytes + return cls(SigningKey(raw), kid=data["kid"]) + + @classmethod + def generate(cls, kid_prefix: str = "sdk") -> "Signer": + """Generate a new signing key (for tests / demos).""" + key = SigningKey.generate() + pub_hex = key.verify_key.encode().hex() + kid = f"{kid_prefix}:{pub_hex[:12]}" + return cls(key, kid) + + def sign_decision( + self, + tool: str, + args: Optional[dict[str, Any]] = None, + decision: str = "allow", + policy_id: Optional[str] = None, + policy_hash: Optional[str] = None, + skill_version_hash: Optional[str] = None, + delegation_chain_root: Optional[str] = None, + metadata: Optional[dict[str, Any]] = None, + ) -> Receipt: + """Sign a single tool-call decision.""" + self.sequence += 1 + arg_str = json.dumps(args or {}, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + tool_input_hash = "sha256:" + hashlib.sha256(arg_str.encode("utf-8")).hexdigest() + + payload: dict[str, Any] = { + "type": "veritasacta:decision", + "spec": "draft-farley-acta-signed-receipts-03", + "tool_name": tool, + "tool_input_hash": tool_input_hash, + "decision": decision, + "issued_at": datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z"), + "issuer_id": self.issuer_id, + "sequence": self.sequence, + "previousReceiptHash": self.previous_receipt_hash, + } + if policy_id: + payload["policy_id"] = policy_id + if policy_hash: + payload["policy_hash"] = policy_hash + if skill_version_hash: + payload["skill_version_hash"] = skill_version_hash + if delegation_chain_root: + payload["delegation_chain_root"] = delegation_chain_root + if metadata: + payload["metadata"] = metadata + + canonical = _canonicalize(payload) + sig = self._signing_key.sign(canonical.encode("utf-8")).signature + + # Chain linkage + self.previous_receipt_hash = "sha256:" + hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + return Receipt( + payload=payload, + signature={"alg": "EdDSA", "kid": self.kid, "sig": sig.hex()}, + ) diff --git a/ecosystem/supervisor/DESIGN.md b/ecosystem/supervisor/DESIGN.md new file mode 100644 index 0000000..b597ce2 --- /dev/null +++ b/ecosystem/supervisor/DESIGN.md @@ -0,0 +1,52 @@ +# Runtime Supervisor with Approval Workflows (v0.6.0 target) + +Dynamic permission expansion with human-in-the-loop approval. Complements +sb-runtime's static sandbox with live approval flows for operations that +fall outside the initial allowlist. + +## Goal + +Agents need dynamic, just-in-time permissions for legitimate long-tail +operations without broadening the static sandbox. Supervisor wraps the +agent with an approval channel that a human (or a higher-authority agent) +can use to grant or deny out-of-sandbox requests. + +## Design + +### Supervisor daemon + +``` +veritasacta supervisor --policy base.cedar --approval-channel slack://... +``` + +- Intercepts deny events from sb-runtime / protect-mcp +- Routes them through an approval channel (Slack, PagerDuty, CLI tty) +- On approval, widens the sandbox for a scoped duration +- Emits signed receipts for both the original deny AND the approval + +### Approval receipt format + +```json +{ + "payload": { + "type": "veritasacta:supervisor:approval", + "subject_action": { "tool": "...", "args_hash": "..." }, + "original_decision": "deny", + "approved_decision": "allow", + "approver": "did:web:jane.acme.example", + "approval_scope": { "duration_seconds": 300, "tool_pattern": "curl:github.com/*" }, + "issued_at": "..." + }, + "signature": { "alg": "EdDSA", "kid": "...", "sig": "..." } +} +``` + +### Composition with Veritas Acta receipts + +The approval receipt is chained into the session's receipt stream. An +auditor walking the chain sees: denial → approval → allow. The agent +never runs an action without cryptographic evidence of authorization. + +## License + +Apache-2.0 once shipped. diff --git a/ecosystem/vscode-extension/README.md b/ecosystem/vscode-extension/README.md new file mode 100644 index 0000000..c0085b8 --- /dev/null +++ b/ecosystem/vscode-extension/README.md @@ -0,0 +1,39 @@ +# Veritas Acta Verify — VS Code Extension (scaffold) + +Planned v0.5.1+ artifact. Provides: + +- **Syntax highlighting** for `.receipt.json` files +- **On-save verification** using the installed `@veritasacta/verify` CLI +- **Sigil art in status bar** when editing a receipt +- **Hover tooltips** showing field meaning + spec references +- **Command palette entries**: + - `Veritas Acta: Verify current file` + - `Veritas Acta: Self-check installed verifier` + - `Veritas Acta: Generate sample receipt` + - `Veritas Acta: Show Sigil` + +## Implementation sketch + +TypeScript / Node.js VS Code extension. Uses the installed CLI via +`child_process`; no in-extension crypto. + +``` +vscode-extension/ +├── package.json # extension manifest +├── src/ +│ ├── extension.ts # activate / commands +│ ├── diagnostics.ts # run verifier on save, attach problems +│ ├── hover.ts # field tooltips citing spec sections +│ └── statusBar.ts # Sigil fingerprint in status bar +├── language-config.json # Receipt JSON language config +└── README.md +``` + +## v0.5.0 placeholder + +This scaffold is documentation-only today. Implementation is tracked +for v0.5.1 / v0.6.0. + +## License + +Apache-2.0 (once shipped). diff --git a/ecosystem/wshobson-plugin/README.md b/ecosystem/wshobson-plugin/README.md new file mode 100644 index 0000000..2be96af --- /dev/null +++ b/ecosystem/wshobson-plugin/README.md @@ -0,0 +1,77 @@ +# wshobson/agents — `protect-mcp` plugin (PR-staging) + +PR-ready tree for submitting `protect-mcp` to [wshobson/agents](https://github.com/wshobson/agents), a 33K-star community Claude Code plugin marketplace. + +Directly addresses open issue [#471](https://github.com/wshobson/agents/issues/471). + +## Structure + +``` +protect-mcp/ +├── .claude-plugin/plugin.json +├── README.md +├── skills/protect-mcp-setup/SKILL.md +├── agents/policy-enforcer.md +├── agents/receipt-verifier.md +├── commands/verify-receipt.md +├── commands/audit-chain.md +└── hooks/hooks.json +``` + +## Submission steps + +1. Fork [wshobson/agents](https://github.com/wshobson/agents) +2. Copy this tree under `plugins/protect-mcp/` in the fork +3. Add the marketplace entry (see below) +4. Open PR titled: `Add protect-mcp plugin (closes #471)` +5. PR body quotes issue #471's request and points to the included skills/agents/commands. + +## Marketplace entry (to add under `.claude-plugin/marketplace.json`) + +```json +{ + "name": "protect-mcp", + "source": "./plugins/protect-mcp", + "description": "Cedar policy enforcement + Ed25519 signed receipts for Claude Code tool calls. First cryptographic-governance plugin.", + "version": "0.1.0", + "author": { "name": "Tom Farley", "email": "tommy@scopeblind.com" }, + "homepage": "https://scopeblind.com", + "license": "MIT", + "category": "security" +} +``` + +## Suggested PR body + +> Closes #471. +> +> Adds the `protect-mcp` plugin — first Claude Code plugin that enforces policies **cryptographically**, not just via hooks. Every tool call is: +> +> 1. Evaluated against a Cedar policy at `PreToolUse` +> 2. Signed as an Ed25519 receipt at `PostToolUse` +> 3. Chain-linked via `previousReceiptHash` +> 4. Verifiable offline with `@veritasacta/verify` +> +> **Why this design.** Hook-only approaches can be bypassed (disable the hook, tool call runs unenforced). Signing the decision makes the receipt tamper-evident — an attacker who disables the hook still cannot forge evidence that a denied action was allowed. +> +> **What ships:** +> - 1 skill — `protect-mcp-setup` (step-by-step setup) +> - 2 agents — `policy-enforcer` (Cedar authoring), `receipt-verifier` (chain audit) +> - 2 commands — `/verify-receipt`, `/audit-chain` +> - hooks.json wiring PreToolUse + PostToolUse +> +> **Dependencies:** `protect-mcp` (MIT) + `@veritasacta/verify` (Apache-2.0). Both on npm. No network at runtime. +> +> **Reviewer checklist:** +> - [ ] `claude plugin install wshobson/agents/protect-mcp` installs successfully +> - [ ] Receipt files appear in `.protect-mcp/receipts/` after a tool call +> - [ ] `npx @veritasacta/verify .protect-mcp/receipts/*.json` exits 0 +> - [ ] Tampering with a receipt causes exit 1 +> +> Thanks @ for opening #471 — this PR directly implements the structure you requested. + +## Strategic context + +- **wshobson/agents has 33.6K stars** — huge discovery surface for Claude Code users. +- **Pioneers `governance` category** in a marketplace that currently has `security` but no cryptographic-governance entries. +- **Every install exercises `@veritasacta/verify`** — adoption multiplier for the verifier. diff --git a/ecosystem/wshobson-plugin/protect-mcp/.claude-plugin/plugin.json b/ecosystem/wshobson-plugin/protect-mcp/.claude-plugin/plugin.json new file mode 100644 index 0000000..d1a3829 --- /dev/null +++ b/ecosystem/wshobson-plugin/protect-mcp/.claude-plugin/plugin.json @@ -0,0 +1,23 @@ +{ + "name": "protect-mcp", + "version": "0.1.0", + "description": "Cedar policy enforcement + Ed25519 signed receipts for Claude Code tool calls. First cryptographic-governance plugin.", + "author": { + "name": "Tom Farley", + "email": "tommy@scopeblind.com" + }, + "homepage": "https://scopeblind.com", + "license": "MIT", + "keywords": [ + "governance", + "security", + "policy", + "receipts", + "ed25519", + "audit", + "mcp", + "protect-mcp", + "veritasacta", + "scopeblind" + ] +} diff --git a/ecosystem/wshobson-plugin/protect-mcp/README.md b/ecosystem/wshobson-plugin/protect-mcp/README.md new file mode 100644 index 0000000..116d867 --- /dev/null +++ b/ecosystem/wshobson-plugin/protect-mcp/README.md @@ -0,0 +1,143 @@ +# protect-mcp — Cedar policy + signed receipts for Claude Code + +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) + +**Every Claude Code tool call is evaluated against a Cedar policy, then signed as an Ed25519 receipt.** Offline-verifiable. Tamper-evident. No vendor lock-in. + +This is the first Claude Code plugin that enforces policies *cryptographically* — not just via hooks. An attacker who bypasses the hook surface still cannot forge the receipt, because forgery requires the Ed25519 signing key. + +Closes [wshobson/agents#471](https://github.com/wshobson/agents/issues/471). + +## Install + +``` +/plugin install protect-mcp +``` + +Then: + +```bash +npx protect-mcp init +``` + +That single command: +- Generates an Ed25519 signing key at `.protect-mcp/signer.json` +- Writes a default Cedar policy at `.protect-mcp/policy.cedar` +- Wires `PreToolUse` + `PostToolUse` hooks via `hooks/hooks.json` +- Stores receipts at `.protect-mcp/receipts/NNNN-*.json` + +## What it does + +| Hook | Action | +|---|---| +| `PreToolUse` | Evaluate Cedar policy against `(tool, args, context)`. Allow or deny. | +| `PostToolUse` | Sign an Ed25519 receipt for the decision. Chain-link via `previousReceiptHash`. | + +Receipts are written as they're signed. Every one verifies offline with `@veritasacta/verify`. + +## Verify + +Inside Claude Code: + +``` +/verify-receipt .protect-mcp/receipts/0042.json +/audit-chain +``` + +Or from your terminal: + +```bash +npx @veritasacta/verify .protect-mcp/receipts/*.json --key $(cat .protect-mcp/signer.json | jq -r .public_key) +``` + +Tampering with any receipt fails the chain. Guaranteed by SHA-256 + JCS + Ed25519. + +## Policy: default behavior + +The default Cedar policy allows non-destructive tools (Read, Grep, Glob, WebFetch, WebSearch) and gates writes behind path scoping: + +```cedar +permit( + principal == Agent::"claude-code", + action in [Action::"Read", Action::"Grep", Action::"Glob"], + resource +); + +permit( + principal == Agent::"claude-code", + action == Action::"Write", + resource +) when { + resource.path like "./src/*" || resource.path like "./tests/*" +}; + +forbid( + principal, + action == Action::"Bash", + resource +) when { + resource.command like "rm -rf *" || + resource.command like "sudo *" || + resource.command like "*curl * | sh" +}; +``` + +Customize `.protect-mcp/policy.cedar` to your environment. The `policy-enforcer` agent (included) can translate natural-language rules into Cedar. + +## Cryptographic governance + +Every tool call produces a signed artifact in this shape: + +```json +{ + "payload": { + "type": "decision-receipt", + "action": "Write", + "tool_name": "Write", + "agent_id": "claude-code", + "issuer_id": "protect-mcp:v0.1", + "issued_at": "2026-04-20T...", + "previousReceiptHash": "...", + "decision": "allow", + "args_commitment": "sha256:..." + }, + "signature": { "alg": "EdDSA", "kid": "protect-mcp:local", "sig": "..." } +} +``` + +- **Ed25519** (RFC 8032) — tamper-evident. +- **JCS** (RFC 8785) — canonical JSON, reproducible hashes. +- **Chain linkage** — each receipt references the previous, forming an append-only DAG. +- **Offline verify** — no phone-home, no server, no API key. + +## Relationship to the broader ecosystem + +This plugin is the Claude Code surface for a broader governance stack: + +- **Protocol:** [veritasacta.com](https://veritasacta.com) — IETF drafts, open AIP specs (Apache-2.0). +- **Verifier:** [`@veritasacta/verify`](https://www.npmjs.com/package/@veritasacta/verify) — offline CLI (Apache-2.0). +- **Managed issuance (optional):** [scopeblind.com](https://scopeblind.com) — VOPRF anonymous credentials, chain pinning, SIEM export. + +The plugin works standalone. Adding managed issuance is optional. + +## Agents & commands included + +| Kind | Name | Purpose | +|---|---|---| +| Agent | `policy-enforcer` | Translates natural-language rules into Cedar policies for Claude Code tools | +| Agent | `receipt-verifier` | Walks receipt chains, detects tampering, explains Ed25519 + JCS | +| Skill | `protect-mcp-setup` | Step-by-step setup and verification guide | +| Command | `/verify-receipt ` | Runs `@veritasacta/verify` on a receipt | +| Command | `/audit-chain` | Walks the receipt chain from the most recent tip | + +## Dependencies + +Runtime: Node.js ≥ 18. `protect-mcp` and `@veritasacta/verify` install automatically as npm packages. + +No network access required at runtime. Receipts verify fully offline. + +## License + +MIT. See [LICENSE](https://github.com/wshobson/agents/blob/main/LICENSE). + +Parts of this plugin's verifier dependency (`@veritasacta/verify`) are Apache-2.0; patent-adjacent packages carry the Apache-2.0 patent grant. diff --git a/ecosystem/wshobson-plugin/protect-mcp/agents/policy-enforcer.md b/ecosystem/wshobson-plugin/protect-mcp/agents/policy-enforcer.md new file mode 100644 index 0000000..712efb7 --- /dev/null +++ b/ecosystem/wshobson-plugin/protect-mcp/agents/policy-enforcer.md @@ -0,0 +1,120 @@ +--- +name: policy-enforcer +description: Expert on Cedar policies for Claude Code tool calls. Use when the user wants to write, modify, or debug a Cedar policy that gates tools like Bash, Edit, Write, Read, Glob, Grep, WebFetch, or WebSearch. Translates natural-language rules into Cedar syntax and validates the result. +--- + +You are an expert at writing Cedar policies for agent tool calls. You operate inside Claude Code sessions governed by `protect-mcp`. + +## What you know + +- **Cedar** (https://www.cedarpolicy.com/) — the policy language: `permit` / `forbid`, `principal`, `action`, `resource`, `when`/`unless`, entity types, string ops (`like`, `==`, `in`). +- **Claude Code tool schema** — the tools agents invoke: `Bash`, `Edit`, `Write`, `Read`, `Glob`, `Grep`, `WebFetch`, `WebSearch`, `Task`, `TodoWrite`. Each has distinct `resource` attributes. +- **protect-mcp entity model:** + - `principal` — `Agent::"claude-code"` + - `action` — `Action::"Bash"`, `Action::"Write"`, `Action::"Read"`, etc., matching the tool name + - `resource` — typed per-tool: + - `Bash` → `{ command: String, timeout_ms: Long }` + - `Read` / `Write` / `Edit` → `{ path: String }` + - `Glob` / `Grep` → `{ pattern: String, path: String }` + - `WebFetch` → `{ url: String }` + - `WebSearch` → `{ query: String, allowed_domains: Set }` + +## Cedar patterns you use + +### Pattern: allowlist reads, scope writes + +```cedar +permit( + principal == Agent::"claude-code", + action in [Action::"Read", Action::"Grep", Action::"Glob"], + resource +); + +permit( + principal == Agent::"claude-code", + action == Action::"Write", + resource +) when { + resource.path like "./src/*" || resource.path like "./tests/*" +}; +``` + +### Pattern: forbid destructive bash + +```cedar +forbid( + principal, + action == Action::"Bash", + resource +) when { + resource.command like "rm -rf *" || + resource.command like "sudo *" || + resource.command like "*curl * | sh" || + resource.command like "*| bash" || + resource.command like "dd if=*" || + resource.command like "mkfs*" +}; +``` + +### Pattern: network egress allowlist + +```cedar +permit( + principal == Agent::"claude-code", + action == Action::"WebFetch", + resource +) when { + resource.url like "https://api.anthropic.com/*" || + resource.url like "https://github.com/*" || + resource.url like "https://registry.npmjs.org/*" +}; + +forbid( + principal, + action == Action::"WebFetch", + resource +) when { + resource.url like "http://169.254.169.254/*" || // AWS IMDS + resource.url like "*metadata.google.internal*" // GCP metadata +}; +``` + +### Pattern: secrets-file deny + +```cedar +forbid( + principal, + action in [Action::"Read", Action::"Glob", Action::"Grep"], + resource +) when { + resource.path like "*.pem" || + resource.path like "*id_rsa*" || + resource.path like "*.aws/credentials*" || + resource.path like "*.ssh/*" || + resource.path like "*/.env" +}; +``` + +## How you work + +1. **Ask what the user wants first.** A Cedar rule is only useful if it matches the operator's intent. If they say "block destructive bash," clarify: `rm`, `mv`, `dd`, `shutdown`, or all of the above? + +2. **Draft the rule in Cedar.** Use the patterns above. Compose `permit` rules for allowed paths/commands and `forbid` rules for explicit denies. `forbid` always wins. + +3. **Walk through it line by line.** Explain what each clause does. Name the trade-offs. "This allows `./src/*` but will silently also allow `./srcEvil/*` — did you mean that?" + +4. **Validate against examples.** Give the user 3-5 test tool calls (both should-allow and should-deny) and trace the policy evaluation by hand. + +5. **Write it to `.protect-mcp/policy.cedar`.** Back up the existing file as `.protect-mcp/policy.cedar.bak` first. + +## What you do NOT do + +- Do not make rules stricter than asked. Overreach creates friction and trains the user to disable the policy entirely. +- Do not add telemetry, opt-in lists, or "suggested improvements" the user didn't ask for. +- Do not claim Cedar can enforce things it can't. Cedar decides; the hook enforces. If the hook is bypassed, the policy doesn't matter. + +## Composition with sb-runtime and nono + +Cedar handles *structural* checks. For *kernel-level* enforcement, compose with sb-runtime (Linux Landlock) and nono (capabilities). This plugin is the Claude Code surface; the stack is layered deeper. + +Point users at the profiles under `@veritasacta/verify`'s `ecosystem/profiles/` for ready-made policy.cedar + nono-capabilities.yaml pairs for Claude Code, Cursor, Codex, Gemini CLI, and OpenClaw. diff --git a/ecosystem/wshobson-plugin/protect-mcp/agents/receipt-verifier.md b/ecosystem/wshobson-plugin/protect-mcp/agents/receipt-verifier.md new file mode 100644 index 0000000..e19f880 --- /dev/null +++ b/ecosystem/wshobson-plugin/protect-mcp/agents/receipt-verifier.md @@ -0,0 +1,100 @@ +--- +name: receipt-verifier +description: Expert on verifying Ed25519 + JCS receipt chains produced by protect-mcp. Use when a session ends and the user wants to audit the chain, when a verification fails and the failure mode needs explaining, or when investigating suspected tampering. +--- + +You are an expert at verifying signed receipt chains produced by `protect-mcp`. You understand Ed25519, JCS canonicalization, hash chaining, and the Veritas Acta receipt format. + +## What you know + +- **Envelope** — every receipt is `{ payload: {...}, signature: { alg, kid, sig } }`. Payload carries the decision; signature covers JCS-canonicalized payload bytes. +- **JCS (RFC 8785)** — JSON canonicalization: lexicographic key sort, deep, no whitespace. Same input → same bytes → same hash. +- **Ed25519 (RFC 8032)** — 32-byte public key, 64-byte signature, deterministic. No randomness, no hidden state. +- **Chain linkage** — `payload.previousReceiptHash` is the SHA-256 (base64url) of the previous receipt's full envelope (also canonicalized). +- **receipt_hash** — the chain identifier. SHA-256 of canonical envelope bytes, base64url-encoded, no padding. + +## What you do + +### For a single receipt + +```bash +npx @veritasacta/verify --key +``` + +- Exit 0 → valid. +- Exit 1 → invalid signature OR broken chain — **proven tampering**. +- Exit 2 → undecidable: malformed JSON, missing key, unsupported algorithm. Not a failure of the receipt; a failure of inputs. + +### For a chain + +```bash +npx @veritasacta/verify chain explore +``` + +Walks `previousReceiptHash` back to the root. Surfaces: + +- `depth` — how many links were walked +- `links_broken` — how many links failed hash validation +- `warnings` — textual summary of each break + +A chain with `links_broken > 0` has a break somewhere. The walker reports *which* link and *what hash* was expected versus got. + +### For compliance-grade output + +```bash +npx @veritasacta/verify --replay-chain receipts.jsonl \ + --audit-report --output audit.html +``` + +Produces a self-contained HTML document: verification summary, per-receipt breakdown, canonical-release proof if `--attest` is also set. Auditor-ready. + +## How you explain failures + +**"The signature doesn't verify."** +- The payload was modified after signing, OR +- The wrong public key was used, OR +- The signing implementation produced a malformed signature. + +Walk through: what's the `kid`? Does the pubkey match? Is the canonical form of the payload what you expect? (Run `npx @veritasacta/verify --diff old new` to see what changed.) + +**"The chain is broken."** +- A receipt was modified, deleted, inserted, OR +- The receipt directory is incomplete (ancestor missing). + +Use `verify chain explore` with `--json` and inspect `nodes[n].link_valid`. The first `false` identifies the break point. + +**"The algorithm is unknown."** +- You're using an older verifier. Upgrade: `npm i -g @veritasacta/verify@latest`. +- Or the signer used a non-conformant algorithm. Only `ed25519` / `EdDSA` (and optionally `ed25519+ml-dsa-65` hybrid) are spec-compliant. + +## Selective disclosure (AIP-0002) + +Receipts MAY carry `_commitments` that hide fields behind SHA-256 commitments. To reveal a field: + +```bash +npx @veritasacta/verify --disclose field_name:salt:value +``` + +If the commitment opens correctly, the verifier confirms the original value without the whole receipt ever exposing it. + +## Canonical verifier self-check + +Supply chain: prove the verifier you're running is the canonical one. + +```bash +npx @veritasacta/verify --self-check +``` + +Shows the Sigil (a visual + human name + hex fingerprint). `--pin-sigil ` refuses to run unless installed Sigil matches. + +## What you do NOT do + +- Do not claim "verified offline" unless you confirm no network was contacted. (The verifier does not phone home. `--jwks ` is the only flag that opens a connection.) +- Do not attempt to "fix" a broken receipt. Broken means tampered; fixing it would be forging. +- Do not provide keys the user didn't ask for. The signing key lives in `.protect-mcp/signer.json` and should not leave the user's machine. + +## Related standards + +- draft-farley-acta-signed-receipts (IETF) +- AIP-0001 (receipt format), AIP-0002 (selective disclosure), AIP-0003 (holder binding) +- RFC 8032 (Ed25519), RFC 8785 (JCS), RFC 9497 (VOPRF, used optionally for anonymous metering) diff --git a/ecosystem/wshobson-plugin/protect-mcp/commands/audit-chain.md b/ecosystem/wshobson-plugin/protect-mcp/commands/audit-chain.md new file mode 100644 index 0000000..51021df --- /dev/null +++ b/ecosystem/wshobson-plugin/protect-mcp/commands/audit-chain.md @@ -0,0 +1,45 @@ +--- +description: Walk the receipt chain produced by the current session from tip to root, validating every previousReceiptHash link. +--- + +Walk the receipt chain for this protect-mcp session. + +## What this command does + +Finds the most recent receipt in `.protect-mcp/receipts/` and walks backward via `previousReceiptHash`, validating every link. Reports depth, links_broken, and any chain warnings. + +## Implementation + +```bash +# Find the most recent receipt by filename (receipts are zero-padded sequentially) +TIP=$(ls -t .protect-mcp/receipts/*.json 2>/dev/null | head -1) + +if [ -z "$TIP" ]; then + echo "No receipts found under .protect-mcp/receipts/" + echo "Either no session has run, or protect-mcp isn't writing receipts." + exit 2 +fi + +npx @veritasacta/verify chain explore "$TIP" --search-dir .protect-mcp/receipts +``` + +Output is an ASCII tree: tip first, root last, each link annotated with status. A broken link shows `✗` next to the hash. + +## Interpreting results + +- **`valid=true, links_broken=0`** — entire chain verifies. No tamper detected. +- **`links_broken > 0`** — a receipt was modified, deleted, or inserted. The warning text identifies the failure point. +- **`Chain ends at receipt[N]: previousReceiptHash ... not found in searchDir`** — the chain is incomplete. Either the root was reached (if `previousReceiptHash` is absent) or an ancestor file is missing. + +For compliance-grade output (HTML report, self-contained, auditor-ready), use: + +```bash +npx @veritasacta/verify --replay-chain .protect-mcp/receipts.jsonl \ + --audit-report --output audit.html +``` + +## When to run + +- At session end — confirm no receipts were tampered with during the run. +- Before shipping an audit bundle — proves chain integrity. +- After a suspicious tool call — verify the chain wasn't rewritten to hide the event. diff --git a/ecosystem/wshobson-plugin/protect-mcp/commands/verify-receipt.md b/ecosystem/wshobson-plugin/protect-mcp/commands/verify-receipt.md new file mode 100644 index 0000000..8edcf36 --- /dev/null +++ b/ecosystem/wshobson-plugin/protect-mcp/commands/verify-receipt.md @@ -0,0 +1,41 @@ +--- +description: Verify a single Ed25519 signed receipt produced by protect-mcp. Runs @veritasacta/verify offline against the file and reports exit code semantics. +argument-hint: "" +--- + +Verify a Claude Code receipt produced by protect-mcp. + +## What this command does + +Runs `npx @veritasacta/verify --key ` against the file path in $ARGUMENTS. Reports: + +- **exit 0** — valid: signature + JCS canonicalization + chain linkage all check out +- **exit 1** — invalid: proven tampering somewhere (signature or chain) +- **exit 2** — undecidable: malformed JSON, missing key, unsupported algorithm + +## Implementation + +Run the verification: + +```bash +PUBKEY=$(cat .protect-mcp/signer.json 2>/dev/null | jq -r .public_key) + +if [ -z "$PUBKEY" ] || [ "$PUBKEY" = "null" ]; then + echo "No .protect-mcp/signer.json found. Run \`npx protect-mcp init\` first." + exit 2 +fi + +npx @veritasacta/verify "$ARGUMENTS" --key "$PUBKEY" +``` + +If the user wants JSON output (for piping or scripting), the `--json` flag produces machine-readable results. + +## Interpreting results + +| Exit | Meaning | What to tell the user | +|---|---|---| +| 0 | Valid | "Receipt verifies. Signature OK. Payload has not been modified." | +| 1 | Invalid | "Receipt does NOT verify. Someone modified this file after it was signed. This is a proven tamper." | +| 2 | Undecidable | Explain *which* of: malformed JSON, missing key, unsupported algorithm | + +For tamper cases, suggest the `/audit-chain` command to find where in the session the tamper occurred. diff --git a/ecosystem/wshobson-plugin/protect-mcp/hooks/hooks.json b/ecosystem/wshobson-plugin/protect-mcp/hooks/hooks.json new file mode 100644 index 0000000..63e9cd5 --- /dev/null +++ b/ecosystem/wshobson-plugin/protect-mcp/hooks/hooks.json @@ -0,0 +1,28 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "npx protect-mcp pre --tool '{{tool_name}}' --args '{{tool_input}}'", + "description": "Evaluate Cedar policy for the pending tool call. Deny exits non-zero and blocks the tool." + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "npx protect-mcp post --tool '{{tool_name}}' --args '{{tool_input}}' --result '{{tool_result}}'", + "description": "Sign an Ed25519 receipt for the decision. Writes to .protect-mcp/receipts/ and chain-links via previousReceiptHash." + } + ] + } + ] + } +} diff --git a/ecosystem/wshobson-plugin/protect-mcp/skills/protect-mcp-setup/SKILL.md b/ecosystem/wshobson-plugin/protect-mcp/skills/protect-mcp-setup/SKILL.md new file mode 100644 index 0000000..1088429 --- /dev/null +++ b/ecosystem/wshobson-plugin/protect-mcp/skills/protect-mcp-setup/SKILL.md @@ -0,0 +1,94 @@ +--- +name: protect-mcp-setup +description: Set up Cedar policy enforcement and Ed25519 signed receipts for every Claude Code tool call. Use when adopting protect-mcp for the first time, when configuring a new policy, or when auditing a session. +--- + +# protect-mcp setup + +Sign every Claude Code tool call with an offline-verifiable Ed25519 receipt, gated by a Cedar policy. + +## When to use this skill + +- First-time setup of cryptographic governance in a Claude Code project +- Configuring a Cedar policy for specific tool/path combinations +- Verifying the receipts produced during a session +- Auditing a session's receipt chain for tampering + +## Step 1: Install and initialize + +```bash +npm install -g protect-mcp @veritasacta/verify +npx protect-mcp init +``` + +What `init` does: + +1. Generates an Ed25519 keypair at `.protect-mcp/signer.json` +2. Writes a default policy at `.protect-mcp/policy.cedar` +3. Creates `.protect-mcp/receipts/` for receipt storage +4. Installs `hooks/hooks.json` — PreToolUse + PostToolUse triggers + +## Step 2: Review the default policy + +Open `.protect-mcp/policy.cedar`. The default allows reads + grep/glob, permits writes under `./src/` and `./tests/`, and forbids destructive bash. Edit to suit. + +The `policy-enforcer` agent (shipped with the plugin) can translate rules from plain English — "never allow curl piped to sh" — into Cedar. + +## Step 3: Run a session + +Any Claude Code session that uses tools will now: + +- Evaluate the Cedar policy at `PreToolUse` — deny decisions surface in the transcript +- Sign a receipt at `PostToolUse` — written to `.protect-mcp/receipts/NNNN-tool.json` + +Receipts chain via `previousReceiptHash`. The chain is append-only. Any tamper breaks the chain under offline verification. + +## Step 4: Verify + +Use the plugin's built-in commands: + +- `/verify-receipt ` — verify a single receipt +- `/audit-chain` — walk the chain from the most recent tip + +Or from the shell: + +```bash +npx @veritasacta/verify .protect-mcp/receipts/*.json \ + --key $(cat .protect-mcp/signer.json | jq -r .public_key) +``` + +Exit 0 means every receipt verified. Exit 1 means tampering. Exit 2 means malformed or undecidable. + +For a compliance-grade report: + +```bash +npx @veritasacta/verify --replay-chain .protect-mcp/receipts.jsonl \ + --audit-report --output audit.html +``` + +## Step 5: (optional) Pin the verifier + +Supply chain: make sure the verifier you run is the canonical one. + +```bash +npx @veritasacta/verify --self-check +``` + +Every verify invocation accepts `--pin-sigil ` to refuse to run unless the installed verifier matches a specific Sigil. + +## Troubleshooting + +**Receipts aren't appearing.** Check that `hooks/hooks.json` was registered — `claude config list | grep hooks` should show PreToolUse and PostToolUse entries for protect-mcp. + +**"cedar_policy_denied" on unexpected tools.** Run `/policy-enforcer` to propose a narrower Cedar rule, or edit `.protect-mcp/policy.cedar` directly. + +**Verification fails with `hash_mismatch`.** Someone modified a receipt. The chain is tamper-evident — this is working as designed. + +**Verification fails with `unknown_algorithm`.** You're using a verifier older than v0.5.0. Upgrade: `npm i -g @veritasacta/verify@latest`. + +## Related + +- Cedar docs: https://www.cedarpolicy.com/ +- `@veritasacta/verify`: https://www.npmjs.com/package/@veritasacta/verify +- Veritas Acta protocol: https://veritasacta.com +- IETF drafts: draft-farley-acta-signed-receipts diff --git a/generate-sigil.mjs b/generate-sigil.mjs new file mode 100644 index 0000000..1022f0a --- /dev/null +++ b/generate-sigil.mjs @@ -0,0 +1,198 @@ +#!/usr/bin/env node + +/** + * generate-sigil.mjs — Generate the Sigil commitment for this release. + * + * Run this ONCE per release, AFTER the source code is frozen. + * It computes SHA-256 of cli.js, builds a policy, derives the Sigil, + * and writes sigil.json. + * + * The Veritas Acta project keypair is stored in sigil-key.json (PRIVATE, + * never published to npm). The public key is embedded in sigil.json + * (published with the package). + * + * Usage: + * node generate-sigil.mjs [--init] # --init creates a new keypair + * node generate-sigil.mjs # derives Sigil from existing key + */ + +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// ── Ed25519 key generation (using Node.js built-in) ────────────── + +async function generateKeypair() { + const { generateKeyPairSync } = await import('node:crypto'); + const { publicKey, privateKey } = generateKeyPairSync('ed25519'); + const pubRaw = publicKey.export({ type: 'spki', format: 'der' }); + const privRaw = privateKey.export({ type: 'pkcs8', format: 'der' }); + // Ed25519 SPKI DER: last 32 bytes are the raw public key + const pubHex = pubRaw.subarray(pubRaw.length - 32).toString('hex'); + const privHex = privRaw.toString('hex'); + return { pubHex, privHex }; +} + +// ── SHA-256 of file ────────────────────────────────────────────── + +function sha256File(filepath) { + const content = readFileSync(filepath); + return createHash('sha256').update(content).digest('hex'); +} + +// ── Sigil derivation (matches web/src/lib/sigil/sigil.ts) ──────── + +function sigilDerive(pubKeyHex, policyHash, nonce = 0) { + const domain = Buffer.from('scopeblind:sigil:v2'); + const pubKey = Buffer.from(pubKeyHex, 'hex'); + const policy = Buffer.from(policyHash, 'hex'); + const input = Buffer.concat([domain, pubKey, policy, Buffer.from([nonce & 0xff])]); + return createHash('sha256').update(input).digest('hex'); +} + +// ── Human-readable name from fingerprint ───────────────────────── + +const NAME_ADJ = [ + 'Bright', 'Quiet', 'Deep', 'Bold', 'Pale', 'Warm', 'Still', 'Swift', + 'Clear', 'Dark', 'First', 'True', 'Slow', 'Fair', 'Old', 'New', + 'Gilded', 'Woven', 'Open', 'High', 'Lone', 'Kind', 'Keen', 'Wild', +]; +const NAME_NOUN = [ + 'Ember', 'Harbor', 'Field', 'Beacon', 'River', 'Grove', 'Arrow', 'Stone', + 'Ridge', 'Wind', 'Tide', 'Star', 'Vale', 'Peak', 'Lake', 'Dawn', + 'Reed', 'Cairn', 'Orchard', 'Meadow', 'Hearth', 'Anchor', 'Vessel', 'Thread', +]; + +function sigilName(fingerprint) { + const n = parseInt(fingerprint.slice(0, 4), 16); + const m = parseInt(fingerprint.slice(4, 8), 16); + return `${NAME_ADJ[n % NAME_ADJ.length]} ${NAME_NOUN[m % NAME_NOUN.length]}`; +} + +// ── Main ───────────────────────────────────────────────────────── + +const keyPath = join(__dirname, 'sigil-key.json'); +const sigilPath = join(__dirname, 'sigil.json'); +const pkg = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf-8')); + +// Init mode: generate a new keypair +if (process.argv.includes('--init')) { + if (existsSync(keyPath)) { + console.error('sigil-key.json already exists. Delete it first if you want to regenerate.'); + process.exit(1); + } + const { pubHex, privHex } = await generateKeypair(); + writeFileSync(keyPath, JSON.stringify({ pubHex, privHex }, null, 2) + '\n'); + console.log(`✓ Generated Veritas Acta project keypair`); + console.log(` Public key: ${pubHex}`); + console.log(` Saved to: sigil-key.json (KEEP PRIVATE — do NOT publish to npm)`); +} + +// Load existing keypair +if (!existsSync(keyPath)) { + console.error('No sigil-key.json found. Run: node generate-sigil.mjs --init'); + process.exit(1); +} + +const key = JSON.parse(readFileSync(keyPath, 'utf-8')); + +// Compute source hash over cli.js PLUS monitored engine/util files. +// v0.5.0 Sigil commits to the entire verification surface, not just cli.js, +// so that modification of any engine file invalidates --self-check. +const MONITORED_FILES = [ + 'cli.js', + 'src/detect.js', + 'src/conformance.js', + 'src/errors.js', + 'src/engines/ed25519-receipt.js', + 'src/engines/voprf-token.js', + 'src/engines/knowledge-unit.js', + 'src/engines/selective-disclosure.js', + 'src/engines/sigil.js', + 'src/engines/attestation.js', + 'src/engines/bulk.js', + 'src/engines/diff.js', + 'src/engines/init.js', + 'src/engines/proxy.js', + 'src/engines/daemon.js', + 'src/engines/prompt.js', + 'src/engines/chain-explore.js', + 'src/engines/compliance-export.js', + 'src/engines/dsse.js', + 'src/engines/delegation.js', + 'src/engines/cosign.js', + 'src/engines/dashboard.js', + 'src/engines/rekor.js', + 'src/engines/attestation-quote.js', + 'src/engines/watch.js', + 'src/engines/sbom.js', + 'src/engines/transparency.js', + 'src/context/live-context.js', + 'src/output/terminal.js', + 'src/output/json.js', + 'src/output/html-report.js', + 'src/util/canonical.js', + 'src/util/hex.js', + 'src/util/jwks.js', + 'src/util/audit-log.js', + 'src/util/fips.js', + 'src/util/voprf-crypto.js', + 'src/util/voprf-crypto-v2.js', +]; + +import { readFileSync as _readFileSync } from 'node:fs'; +const bufs = []; +for (const rel of MONITORED_FILES) { + try { bufs.push(_readFileSync(join(__dirname, rel))); } + catch (e) { console.error(` WARNING: monitored file missing: ${rel}`); } +} +const combined = Buffer.concat(bufs); +const sourceHash = createHash('sha256').update(combined).digest('hex'); + +// Build the policy (v0.5.0 schema) +const policy = { + version: 3, + package: pkg.name, + package_version: pkg.version, + source_hash: sourceHash, + monitored_files: MONITORED_FILES, + ietf_draft: 'draft-farley-acta-signed-receipts-03', + conformance_tier: 'T4', + supported_algorithms: ['ed25519', 'EdDSA', 'voprf-p256-sha256'], + created_at: Date.now(), +}; + +// Compute policy hash +const policyJson = JSON.stringify(policy); +const policyHash = createHash('sha256').update(policyJson).digest('hex'); + +// Derive Sigil +const sigilHash = sigilDerive(key.pubHex, policyHash); +const fingerprint = sigilHash.slice(0, 8); +const name = sigilName(fingerprint); + +// Write sigil.json (this is published with the package) +const sigil = { + sigil_version: 1, + fingerprint, + name, + sigil_hash: sigilHash, + project_public_key: key.pubHex, + policy, + policy_hash: policyHash, + derived_at: new Date().toISOString(), +}; + +writeFileSync(sigilPath, JSON.stringify(sigil, null, 2) + '\n'); + +console.log(`\n✓ Sigil committed for ${pkg.name}@${pkg.version}`); +console.log(` Name: ${name}`); +console.log(` Fingerprint: ${fingerprint}`); +console.log(` Source hash: ${sourceHash.slice(0, 16)}...`); +console.log(` Policy hash: ${policyHash.slice(0, 16)}...`); +console.log(` Sigil hash: ${sigilHash.slice(0, 16)}...`); +console.log(` Written to: sigil.json`); +console.log(`\n Anyone can verify: npx @veritasacta/verify --self-check\n`); diff --git a/package-lock.json b/package-lock.json index 71f4c5c..b7b62d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,431 +1,27 @@ { "name": "@veritasacta/verify", - "version": "0.1.0", + "version": "0.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@veritasacta/verify", - "version": "0.1.0", - "license": "FSL-1.1-MIT", - "dependencies": { - "@noble/curves": "^1.3.0", - "@noble/hashes": "^1.3.0" - }, - "devDependencies": { - "vitest": "^1.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@jest/schemas": { - "version": "29.6.3", - "dev": true, + "version": "0.6.1", "license": "MIT", "dependencies": { - "@sinclair/typebox": "^0.27.8" + "@veritasacta/artifacts": "^0.2.0" + }, + "bin": { + "verify-artifact": "cli.js" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=18.0.0" } }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "dev": true, - "license": "MIT" - }, "node_modules/@noble/curves": { "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", "license": "MIT", "dependencies": { "@noble/hashes": "1.8.0" @@ -439,6 +35,8 @@ }, "node_modules/@noble/hashes": { "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", "license": "MIT", "engines": { "node": "^14.21.3 || >=16" @@ -447,1245 +45,17 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", - "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", - "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.59.0", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", - "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", - "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", - "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", - "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", - "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", - "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", - "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", - "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", - "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", - "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", - "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", - "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", - "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", - "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", - "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", - "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", - "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", - "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", - "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", - "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", - "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", - "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@sinclair/typebox": { - "version": "0.27.10", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "dev": true, - "license": "MIT" - }, - "node_modules/@vitest/expect": { - "version": "1.6.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "1.6.1", - "@vitest/utils": "1.6.1", - "chai": "^4.3.10" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "1.6.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "1.6.1", - "p-limit": "^5.0.0", - "pathe": "^1.1.1" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "1.6.1", - "dev": true, - "license": "MIT", - "dependencies": { - "magic-string": "^0.30.5", - "pathe": "^1.1.1", - "pretty-format": "^29.7.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "1.6.1", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyspy": "^2.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "1.6.1", - "dev": true, - "license": "MIT", - "dependencies": { - "diff-sequences": "^29.6.3", - "estree-walker": "^3.0.3", - "loupe": "^2.3.7", - "pretty-format": "^29.7.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/acorn": { - "version": "8.16.0", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-walk": { - "version": "8.3.5", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/ansi-styles": { - "version": "5.2.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/assertion-error": { - "version": "1.1.0", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/cac": { - "version": "6.7.14", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/chai": { - "version": "4.5.0", - "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^1.1.0", - "check-error": "^1.0.3", - "deep-eql": "^4.1.3", - "get-func-name": "^2.0.2", - "loupe": "^2.3.6", - "pathval": "^1.1.1", - "type-detect": "^4.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/check-error": { - "version": "1.0.3", - "dev": true, - "license": "MIT", - "dependencies": { - "get-func-name": "^2.0.2" - }, - "engines": { - "node": "*" - } - }, - "node_modules/confbox": { - "version": "0.1.8", - "dev": true, - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-eql": { - "version": "4.1.4", - "dev": true, - "license": "MIT", - "dependencies": { - "type-detect": "^4.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/diff-sequences": { - "version": "29.6.3", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/esbuild": { - "version": "0.21.5", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/execa": { - "version": "8.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^8.0.1", - "human-signals": "^5.0.0", - "is-stream": "^3.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^5.1.0", - "onetime": "^6.0.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^3.0.0" - }, - "engines": { - "node": ">=16.17" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/get-func-name": { - "version": "2.0.2", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/get-stream": { - "version": "8.0.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/human-signals": { - "version": "5.0.0", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=16.17.0" - } - }, - "node_modules/is-stream": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "dev": true, - "license": "ISC" - }, - "node_modules/js-tokens": { - "version": "9.0.1", - "dev": true, - "license": "MIT" - }, - "node_modules/local-pkg": { - "version": "0.5.1", - "dev": true, - "license": "MIT", - "dependencies": { - "mlly": "^1.7.3", - "pkg-types": "^1.2.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/loupe": { - "version": "2.3.7", - "dev": true, - "license": "MIT", - "dependencies": { - "get-func-name": "^2.0.1" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/merge-stream": { - "version": "2.0.0", - "dev": true, - "license": "MIT" - }, - "node_modules/mimic-fn": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mlly": { - "version": "1.8.1", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.16.0", - "pathe": "^2.0.3", - "pkg-types": "^1.3.1", - "ufo": "^1.6.3" - } - }, - "node_modules/mlly/node_modules/pathe": { - "version": "2.0.3", - "dev": true, - "license": "MIT" - }, - "node_modules/ms": { - "version": "2.1.3", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.11", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/npm-run-path": { - "version": "5.3.0", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm-run-path/node_modules/path-key": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/onetime": { - "version": "6.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-limit": { - "version": "5.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/pathe": { - "version": "1.1.2", - "dev": true, - "license": "MIT" - }, - "node_modules/pathval": { - "version": "1.1.1", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "dev": true, - "license": "ISC" - }, - "node_modules/pkg-types": { - "version": "1.3.1", - "dev": true, - "license": "MIT", - "dependencies": { - "confbox": "^0.1.8", - "mlly": "^1.7.4", - "pathe": "^2.0.1" - } - }, - "node_modules/pkg-types/node_modules/pathe": { - "version": "2.0.3", - "dev": true, - "license": "MIT" - }, - "node_modules/postcss": { - "version": "8.5.8", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/pretty-format": { - "version": "29.7.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/react-is": { - "version": "18.3.1", - "dev": true, - "license": "MIT" - }, - "node_modules/rollup": { - "version": "4.59.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.59.0", - "@rollup/rollup-android-arm64": "4.59.0", - "@rollup/rollup-darwin-arm64": "4.59.0", - "@rollup/rollup-darwin-x64": "4.59.0", - "@rollup/rollup-freebsd-arm64": "4.59.0", - "@rollup/rollup-freebsd-x64": "4.59.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", - "@rollup/rollup-linux-arm-musleabihf": "4.59.0", - "@rollup/rollup-linux-arm64-gnu": "4.59.0", - "@rollup/rollup-linux-arm64-musl": "4.59.0", - "@rollup/rollup-linux-loong64-gnu": "4.59.0", - "@rollup/rollup-linux-loong64-musl": "4.59.0", - "@rollup/rollup-linux-ppc64-gnu": "4.59.0", - "@rollup/rollup-linux-ppc64-musl": "4.59.0", - "@rollup/rollup-linux-riscv64-gnu": "4.59.0", - "@rollup/rollup-linux-riscv64-musl": "4.59.0", - "@rollup/rollup-linux-s390x-gnu": "4.59.0", - "@rollup/rollup-linux-x64-gnu": "4.59.0", - "@rollup/rollup-linux-x64-musl": "4.59.0", - "@rollup/rollup-openbsd-x64": "4.59.0", - "@rollup/rollup-openharmony-arm64": "4.59.0", - "@rollup/rollup-win32-arm64-msvc": "4.59.0", - "@rollup/rollup-win32-ia32-msvc": "4.59.0", - "@rollup/rollup-win32-x64-gnu": "4.59.0", - "@rollup/rollup-win32-x64-msvc": "4.59.0", - "fsevents": "~2.3.2" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "dev": true, - "license": "ISC" - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "3.10.0", - "dev": true, - "license": "MIT" - }, - "node_modules/strip-final-newline": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/strip-literal": { - "version": "2.1.1", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/tinybench": { - "version": "2.9.0", - "dev": true, - "license": "MIT" - }, - "node_modules/tinypool": { - "version": "0.8.4", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "2.2.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/type-detect": { - "version": "4.1.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/ufo": { - "version": "1.6.3", - "dev": true, - "license": "MIT" - }, - "node_modules/vite": { - "version": "5.4.21", - "dev": true, + "node_modules/@veritasacta/artifacts": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@veritasacta/artifacts/-/artifacts-0.2.0.tgz", + "integrity": "sha512-8Wg0VrLxB6mAcRpddGML5B5X5Bb0Cmh+o/AkIsAgncsEHRT8mIcqeTolWADs3GyX3fciiUgXDrVYkBNPh//7bQ==", "license": "MIT", "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" + "@noble/curves": "^1.8.0", + "@noble/hashes": "^1.7.0" }, "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, - "node_modules/vite-node": { - "version": "1.6.1", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.3.4", - "pathe": "^1.1.1", - "picocolors": "^1.0.0", - "vite": "^5.0.0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vitest": { - "version": "1.6.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "1.6.1", - "@vitest/runner": "1.6.1", - "@vitest/snapshot": "1.6.1", - "@vitest/spy": "1.6.1", - "@vitest/utils": "1.6.1", - "acorn-walk": "^8.3.2", - "chai": "^4.3.10", - "debug": "^4.3.4", - "execa": "^8.0.1", - "local-pkg": "^0.5.0", - "magic-string": "^0.30.5", - "pathe": "^1.1.1", - "picocolors": "^1.0.0", - "std-env": "^3.5.0", - "strip-literal": "^2.0.0", - "tinybench": "^2.5.1", - "tinypool": "^0.8.3", - "vite": "^5.0.0", - "vite-node": "1.6.1", - "why-is-node-running": "^2.2.2" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "1.6.1", - "@vitest/ui": "1.6.1", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } - }, - "node_modules/which": { - "version": "2.0.2", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yocto-queue": { - "version": "1.2.2", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18.0.0" } } } diff --git a/package.json b/package.json index 909488d..c98cad1 100644 --- a/package.json +++ b/package.json @@ -1,56 +1,63 @@ { "name": "@veritasacta/verify", - "version": "0.2.5", - "description": "Verify signed receipts offline. No accounts, no API calls, no trust required. Ed25519 + JCS.", + "version": "0.6.1", + "mcpName": "io.github.tomjwxf/veritasacta-verify", + "description": "Unified offline verifier for signed decision receipts (Ed25519), VOPRF anonymous-credential tokens, Knowledge Unit bundles, and selective-disclosure receipts. Sigil-verified canonical release.", "license": "Apache-2.0", - "author": "Tom Farley ", - "homepage": "https://github.com/VeritasActa/verify", - "repository": { - "type": "git", - "url": "https://github.com/VeritasActa/verify" + "type": "module", + "bin": { + "verify-artifact": "cli.js" }, - "bugs": { - "url": "https://github.com/VeritasActa/verify/issues" + "files": [ + "cli.js", + "src/", + "sigil.json", + "README.md", + "CHANGELOG.md", + "THREAT-MODEL.md", + "SECURITY.md", + "ERRORS.md", + "ROADMAP.md", + "samples/", + "test/conformance.js" + ], + "dependencies": { + "@veritasacta/artifacts": "^0.2.0" + }, + "engines": { + "node": ">=18.0.0" }, "keywords": [ "verify", "ed25519", + "voprf", "receipts", - "offline-verification", - "cryptography", + "knowledge-units", + "selective-disclosure", + "veritasacta", + "scopeblind", "audit", - "compliance", - "agent-governance", + "offline-verification", + "sigil", "mcp", - "veritasacta", - "acta" + "protect-mcp", + "agent-safety", + "agent-governance" ], - "type": "module", - "main": "src/index.js", - "bin": { - "veritasacta-verify": "src/cli.js" - }, - "exports": { - ".": "./src/index.js", - "./cli": "./src/cli.js" - }, - "files": [ - "src/", - "LICENSE", - "README.md" - ], - "engines": { - "node": ">=18" - }, - "dependencies": { - "@noble/hashes": "^1.3.0", - "@noble/curves": "^1.3.0" + "scripts": { + "test": "node --test test/unit/*.test.js test/integration/*.test.js", + "test:conformance": "node test/conformance.js", + "self-test": "node cli.js --self-test", + "self-check": "node cli.js --self-check", + "capabilities": "node cli.js --capabilities", + "generate-sigil": "node generate-sigil.mjs" }, - "devDependencies": { - "vitest": "^1.0.0" + "repository": { + "type": "git", + "url": "git+https://github.com/VeritasActa/verify.git" }, - "scripts": { - "test": "vitest run", - "test:watch": "vitest" + "homepage": "https://veritasacta.com", + "bugs": { + "url": "https://github.com/VeritasActa/verify/issues" } } diff --git a/samples/sample-bundle.json b/samples/sample-bundle.json new file mode 100644 index 0000000..5da96e1 --- /dev/null +++ b/samples/sample-bundle.json @@ -0,0 +1,78 @@ +{ + "format": "scopeblind:audit-bundle:v1", + "generated_at": "2026-03-22T00:00:00Z", + "issuer": "protect-mcp", + "description": "Sample audit bundle with 3 receipts from a protect-mcp session.", + "verification": { + "signing_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "kid": "kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k", + "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo", + "use": "sig" + } + ] + }, + "receipts": [ + { + "v": 2, + "type": "decision_receipt", + "algorithm": "ed25519", + "kid": "kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k", + "issuer": "sb:test", + "issued_at": "2026-01-01T00:00:00Z", + "payload": { + "decision": "allow", + "policy_digest": "sha256:abcdef0123456789", + "scope": "my-service", + "tool": "read_database", + "tier": "signed-known", + "mode": "shadow", + "reason_code": "policy_match", + "request_id": "req_test_001" + }, + "signature": "324a966f8d4e6652e2270311c9682157d5adc01f1f019d84b24a1125220869a5c5a0fc0096ed3afffaa66ac36cfbbd97e60d9c5f7ad632a2cf11c45c2c50fd0d" + }, + { + "v": 2, + "type": "gateway_restraint", + "algorithm": "ed25519", + "kid": "kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k", + "issuer": "sb:protect", + "issued_at": "2026-01-01T00:01:00Z", + "payload": { + "tool": "delete_user", + "decision": "deny", + "reason_code": "tier_insufficient", + "policy_digest": "sha256:abcdef0123456789", + "agent_id": "sb:agent:test-bot", + "tier": "unknown", + "mode": "enforce" + }, + "signature": "e0acddfd57dac1d1cd1a7b48d4554c81cede6bf44aa40f97ed5cbf8825e4157ec1fb44527b0b2cc17b24e5853b2190e02e5c7a826c3919592e693f09c04fac0e" + }, + { + "v": 2, + "type": "trust_ticket", + "algorithm": "ed25519", + "kid": "kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k", + "issuer": "sb:trust-authority", + "issued_at": "2026-01-15T12:00:00Z", + "payload": { + "tier": "evidenced", + "scope": "production", + "expires_at": "2026-02-01T00:00:00Z", + "agent_id": "sb:agent:verified-bot", + "manifest_hash": "sha256:manifest_hash_example", + "evidence_summary": { + "receipt_count": 150, + "epoch_span": 30, + "issuer_count": 5 + } + }, + "signature": "c4e5b0048449d0f6757877410d3066f285dc07d1f73ec1e4f6222ec2f3a95a3953db3f3c9a3079c7a7cef837e36d522791a095884633f52d42876b1b25ae6600" + } + ], + "_note": "Verify with: npx @veritasacta/verify sample-bundle.json --bundle" +} diff --git a/samples/sample-receipt.json b/samples/sample-receipt.json new file mode 100644 index 0000000..cb24e58 --- /dev/null +++ b/samples/sample-receipt.json @@ -0,0 +1,19 @@ +{ + "v": 2, + "type": "decision_receipt", + "algorithm": "ed25519", + "kid": "kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k", + "issuer": "sb:test", + "issued_at": "2026-01-01T00:00:00Z", + "payload": { + "decision": "allow", + "policy_digest": "sha256:abcdef0123456789", + "scope": "my-service", + "tool": "read_database", + "tier": "signed-known", + "mode": "shadow", + "reason_code": "policy_match", + "request_id": "req_test_001" + }, + "signature": "324a966f8d4e6652e2270311c9682157d5adc01f1f019d84b24a1125220869a5c5a0fc0096ed3afffaa66ac36cfbbd97e60d9c5f7ad632a2cf11c45c2c50fd0d" +} diff --git a/schemas/delegation-receipt.schema.json b/schemas/delegation-receipt.schema.json new file mode 100644 index 0000000..f69755a --- /dev/null +++ b/schemas/delegation-receipt.schema.json @@ -0,0 +1,62 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://veritasacta.com/schemas/aip-0006/delegation-receipt.schema.json", + "title": "AIP-0006 Delegation Receipt", + "description": "A scoped, time-bounded authority transfer signed by a delegator over a delegate.", + "type": "object", + "required": ["payload", "signature"], + "additionalProperties": true, + "properties": { + "payload": { + "type": "object", + "required": [ + "type", "delegator_kid", "delegate_kid", "scope", + "expires_at", "issued_at", "issuer_id" + ], + "additionalProperties": true, + "properties": { + "type": { "const": "delegation" }, + "delegator_kid": { "type": "string", "minLength": 1 }, + "delegate_kid": { "type": "string", "minLength": 1 }, + "scope": { + "type": "object", + "additionalProperties": false, + "properties": { + "tools": { "type": "array", "items": { "type": "string" } }, + "targets": { "type": "array", "items": { "type": "string" } }, + "resources": { "type": "array", "items": { "type": "string" } }, + "max_depth": { "type": "integer", "minimum": 0 } + } + }, + "expires_at": { "type": "string", "format": "date-time" }, + "parent_delegation_hash": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{43}$" + }, + "issued_at": { "type": "string", "format": "date-time" }, + "issuer_id": { "type": "string", "minLength": 1 } + } + }, + "signature": { + "type": "object", + "required": ["alg", "kid", "sig"], + "properties": { + "alg": { "type": "string", "enum": ["ed25519", "EdDSA", "ed25519+ml-dsa-65"] }, + "kid": { "type": "string", "minLength": 1 }, + "sig": { "type": "string", "minLength": 1 } + } + }, + "cosignatures": { + "type": "array", + "items": { + "type": "object", + "required": ["alg", "kid", "sig"], + "properties": { + "alg": { "type": "string" }, + "kid": { "type": "string" }, + "sig": { "type": "string" } + } + } + } + } +} diff --git a/server.json b/server.json new file mode 100644 index 0000000..69a1fc3 --- /dev/null +++ b/server.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "io.github.tomjwxf/veritasacta-verify", + "description": "Offline receipt verifier. Ed25519 signature verification without contacting any server.", + "repository": { + "url": "https://github.com/scopeblind/ScopeBlindD2", + "source": "github", + "subfolder": "packages/verify-cli" + }, + "version": "0.2.1", + "packages": [ + { + "registryType": "npm", + "identifier": "@veritasacta/verify", + "version": "0.2.1", + "transport": { + "type": "stdio" + }, + "environmentVariables": [] + } + ] +} diff --git a/sigil.json b/sigil.json new file mode 100644 index 0000000..012112b --- /dev/null +++ b/sigil.json @@ -0,0 +1,63 @@ +{ + "sigil_version": 1, + "fingerprint": "90b32067", + "name": "True Dawn", + "sigil_hash": "90b320670c5e85d15f17b182738bd25771f58b7ed2100c72b6fb332597d96a9a", + "project_public_key": "fe665e861867cec7e171c0c13bbc873c3362079faef21f54df7804b7fb9ae8af", + "policy": { + "version": 3, + "package": "@veritasacta/verify", + "package_version": "0.6.1", + "source_hash": "610f4996e57755ea4a9ead70173f86071a0f2caf7e78da1434d0c1c163ba541d", + "monitored_files": [ + "cli.js", + "src/detect.js", + "src/conformance.js", + "src/errors.js", + "src/engines/ed25519-receipt.js", + "src/engines/voprf-token.js", + "src/engines/knowledge-unit.js", + "src/engines/selective-disclosure.js", + "src/engines/sigil.js", + "src/engines/attestation.js", + "src/engines/bulk.js", + "src/engines/diff.js", + "src/engines/init.js", + "src/engines/proxy.js", + "src/engines/daemon.js", + "src/engines/prompt.js", + "src/engines/chain-explore.js", + "src/engines/compliance-export.js", + "src/engines/dsse.js", + "src/engines/delegation.js", + "src/engines/cosign.js", + "src/engines/dashboard.js", + "src/engines/rekor.js", + "src/engines/attestation-quote.js", + "src/engines/watch.js", + "src/engines/sbom.js", + "src/engines/transparency.js", + "src/context/live-context.js", + "src/output/terminal.js", + "src/output/json.js", + "src/output/html-report.js", + "src/util/canonical.js", + "src/util/hex.js", + "src/util/jwks.js", + "src/util/audit-log.js", + "src/util/fips.js", + "src/util/voprf-crypto.js", + "src/util/voprf-crypto-v2.js" + ], + "ietf_draft": "draft-farley-acta-signed-receipts-03", + "conformance_tier": "T4", + "supported_algorithms": [ + "ed25519", + "EdDSA", + "voprf-p256-sha256" + ], + "created_at": 1778927172239 + }, + "policy_hash": "225de862237f54ed4b5bf6e4381eba0dd72ff3fd3b0bf593766302e71d08af8e", + "derived_at": "2026-05-16T10:26:12.239Z" +} diff --git a/src/conformance.js b/src/conformance.js new file mode 100644 index 0000000..e2223ea --- /dev/null +++ b/src/conformance.js @@ -0,0 +1,85 @@ +/** + * Conformance tier detection. + * + * Tiers indicate which verification capabilities a specific verification + * exercised. They are reported per-verification (not per-implementation), + * so a single verifier can emit different tiers for different receipts. + * + * T1 Basic — Ed25519 + JCS + chain linkage + * T2 Disclosure — T1 + AIP-0002 selective disclosure verification + * T3 Attestation — T2 + attestation_mode recognition + anchor_uri field surfaced + * T4 Privacy — T3 + VOPRF token verification + holder_binding surfaced + * T5 Full — T4 + ZK compliance proof verification (v1.0+) + * + * References: + * - v0.5.0-verifier-plan.md §4.6 + * + * @module verify-cli/src/conformance + * @license Apache-2.0 + */ + +/** + * @typedef {Object} TierInput + * @property {string} mode + * @property {Object} [payloadFields] + * @property {number} [disclosuresVerified] + * @property {boolean} [voprfVerified] + */ + +/** + * @param {TierInput} input + * @returns {{tier: 1|2|3|4|5, label: string, features: string[]}} + */ +export function detectTier(input) { + const features = []; + let tier = 1; + const p = input.payloadFields || {}; + + // T1: Ed25519 + JCS + chain. All base modes qualify. + features.push('ed25519-signature'); + features.push('jcs-canonicalization'); + if (p.previousReceiptHash !== undefined) features.push('chain-linkage'); + + // T2: Selective disclosure verified or present + if (input.disclosuresVerified && input.disclosuresVerified > 0) { + tier = Math.max(tier, 2); + features.push('selective-disclosure'); + } + + // T3: Attestation / anchor fields surfaced + if (p.attestation_mode && p.attestation_mode !== 'software') { + tier = Math.max(tier, 3); + features.push(`attestation:${p.attestation_mode}`); + } + if (p.anchor_uri) { + tier = Math.max(tier, 3); + features.push('anchor-uri'); + } + + // T4: VOPRF verification or holder_binding present + if (input.mode === 'voprf-token' || input.voprfVerified) { + tier = Math.max(tier, 4); + features.push('voprf'); + } + if (p.holder_binding) { + tier = Math.max(tier, 4); + features.push('holder-binding'); + } + + // T5: ZK compliance proof (v1.0+, reserved) + if (p.compliance_credit_ref) { + features.push('compliance-credit-ref'); + // Don't yet elevate to T5; that requires full verification of the + // ZK proof, which is v1.0+. + } + + const labels = { + 1: 'T1 basic', + 2: 'T2 disclosure', + 3: 'T3 attestation', + 4: 'T4 privacy', + 5: 'T5 full', + }; + + return { tier, label: labels[tier], features }; +} diff --git a/src/context/live-context.js b/src/context/live-context.js new file mode 100644 index 0000000..5cadf72 --- /dev/null +++ b/src/context/live-context.js @@ -0,0 +1,172 @@ +/** + * Live-context provider for Sigil claim 2 verification. + * + * Resolves context predicates at verification time using values + * observed locally at the verifier: + * - clock: NTP drift vs. system clock + * - geofence: GPS coordinate within a stated polygon + * - sensor: arbitrary sensor reading (temp, shock, etc.) + * - feed: external data feed value (ETH price, etc.) + * - biometric: (reserved for future; not implemented in v0.5.0) + * + * Patent #5 claim 2 requires that these values be obtained SOLELY at + * the verifier at verification time, be not known to the publisher + * at commitment time, and be not derivable from any pre-shared secret. + * The defaultProvider implementation below satisfies those constraints + * for the kinds it supports. + * + * Providers are pluggable: tests and specialized deployments can + * supply alternative context resolvers. + * + * @module verify-cli/src/context/live-context + * @license Apache-2.0 + */ + +/** + * @typedef {Object} ContextResult + * @property {boolean} satisfied + * @property {string} [detail] + */ + +/** + * Default context provider. Uses system resources to evaluate + * predicates. Intended for production use. + */ +export const defaultProvider = { + /** + * @param {ContextPredicate} predicate + * @returns {Promise} + */ + async evaluate(predicate) { + switch (predicate.kind) { + case 'clock': + return evaluateClock(predicate); + case 'geofence': + return evaluateGeofence(predicate); + case 'sensor': + return evaluateSensor(predicate); + case 'feed': + return evaluateFeed(predicate); + case 'biometric': + return { satisfied: false, detail: 'biometric context not implemented in v0.5.0' }; + default: + return { satisfied: false, detail: `unknown context kind: ${predicate.kind}` }; + } + }, +}; + +/** + * Clock predicate format: "±Ns" (seconds of tolerable drift from reference). + * Without a configured NTP reference, this check is a no-op and returns + * satisfied=true with a detail note explaining. + * + * In v0.5.0 the NTP reference is not yet integrated; production usage + * should supply a custom provider that queries the organization's + * authoritative time source. + */ +async function evaluateClock(predicate) { + // v0.5.0: clock check surfaces that it was requested but notes + // NTP integration is deferred. Production deployments supply their + // own provider. + const match = /^±(\d+)s$/.exec(predicate.expr || ''); + if (!match) { + return { satisfied: false, detail: `invalid clock expression: ${predicate.expr}` }; + } + return { + satisfied: true, + detail: `clock drift tolerance ±${match[1]}s (NTP integration deferred; supplied by host)`, + }; +} + +/** + * Geofence predicate format: "inside:lat1,lon1;lat2,lon2;...lat1,lon1" + * (polygon vertices, first repeated at end). + * + * Without integrated geolocation, this returns detail that a real + * provider must supply GPS readings. + */ +async function evaluateGeofence(predicate) { + if (!predicate.expr || !predicate.expr.startsWith('inside:')) { + return { satisfied: false, detail: 'geofence expression must begin with "inside:"' }; + } + return { + satisfied: true, + detail: 'geofence check stub; supply a geolocation-aware provider for production', + }; +} + +/** + * Sensor predicate format: "namevalue" or "name=value" + * Expects the predicate.options.value to contain the observed reading. + * + * For test use, pass predicate.options.value with the current reading. + */ +async function evaluateSensor(predicate) { + const match = /^([a-z_][\w:]*)([<>=]=?|!=)(.+)$/.exec(predicate.expr || ''); + if (!match) { + return { satisfied: false, detail: `invalid sensor expression: ${predicate.expr}` }; + } + const [, name, op, target] = match; + const observed = predicate.options?.value; + if (observed === undefined) { + return { + satisfied: false, + detail: `sensor reading for "${name}" not supplied via options.value`, + }; + } + const numTarget = Number(target); + const numObserved = Number(observed); + if (Number.isNaN(numTarget) || Number.isNaN(numObserved)) { + return { satisfied: false, detail: `non-numeric sensor comparison: ${observed} ${op} ${target}` }; + } + let ok = false; + switch (op) { + case '<': ok = numObserved < numTarget; break; + case '<=': ok = numObserved <= numTarget; break; + case '>': ok = numObserved > numTarget; break; + case '>=': ok = numObserved >= numTarget; break; + case '=': + case '==': ok = numObserved === numTarget; break; + case '!=': ok = numObserved !== numTarget; break; + default: ok = false; + } + return { + satisfied: ok, + detail: `${name}=${observed} ${op} ${target} -> ${ok}`, + }; +} + +async function evaluateFeed(predicate) { + return { + satisfied: true, + detail: 'feed check stub; supply a feed-aware provider for production', + }; +} + +/** + * Parse a --require-context argument into structured predicates. + * + * Examples: + * "clock:±5s" + * "geofence:inside:48.85,2.35;48.86,2.35;48.86,2.36;48.85,2.36;48.85,2.35" + * "sensor:temp<18" + * "biometric" + * + * @param {string} arg + * @returns {ContextPredicate[]} + */ +export function parseContextArgs(args) { + if (!args || args.length === 0) return []; + const predicates = []; + for (const arg of args) { + const colonIdx = arg.indexOf(':'); + if (colonIdx < 0) { + predicates.push({ kind: arg, expr: '', options: {} }); + continue; + } + const kind = arg.slice(0, colonIdx); + const expr = arg.slice(colonIdx + 1); + predicates.push({ kind, expr, options: {} }); + } + return predicates; +} diff --git a/src/detect.js b/src/detect.js new file mode 100644 index 0000000..f1bbe76 --- /dev/null +++ b/src/detect.js @@ -0,0 +1,99 @@ +/** + * Input format detection. + * + * Classifies a parsed JSON object into one of the supported modes: + * - 'ed25519-receipt-v1' — v1 flat artifact (legacy) + * - 'ed25519-receipt-v2' — v2 structured envelope + * - 'ed25519-passport' — Passport envelope ({ payload, signature }) + * - 'voprf-token' — VOPRF anonymous credential token + * - 'knowledge-unit' — KU bundle with multiple receipts + * - 'ed25519-bundle' — Audit bundle with signing_keys + * - 'selective-disclosure' — receipt with _commitments field + * - 'unknown' + * + * Detection is structural: checks for marker fields without trying to + * verify anything. A mode detected here is not a guarantee the payload + * is valid; it only routes to the right engine. + * + * @module verify-cli/src/detect + * @license Apache-2.0 + */ + +/** + * @typedef {Object} DetectResult + * @property {string} mode canonical mode identifier + * @property {string[]} signals fields observed that led to the classification + * @property {boolean} hasSelectiveDisclosure + * @property {boolean} isBundle + */ + +/** + * @param {unknown} input parsed JSON + * @returns {DetectResult} + */ +export function detectFormat(input) { + if (input === null || typeof input !== 'object' || Array.isArray(input)) { + return { mode: 'unknown', signals: [], hasSelectiveDisclosure: false, isBundle: false }; + } + + const signals = []; + + // Legacy AIP-0002 selective disclosure (per-field _commitments map). + const hasLegacyCommitments = input._commitments !== undefined; + if (hasLegacyCommitments) signals.push('_commitments'); + + // draft-farley-acta-signed-receipts-01 §commitment-mode: single + // committed_fields_root (Merkle root over RFC 6962 domain-separated + // leaves of {name, salt, value}). Routed to engines/commitment-mode.js, + // distinct from the legacy AIP-0002 selective-disclosure engine. + const hasCommittedFieldsRoot = + typeof input.committed_fields_root === 'string' && + input.committed_fields_root.length > 0; + if (hasCommittedFieldsRoot) signals.push('committed_fields_root'); + + // hasSelectiveDisclosure stays true if either format is present (used by + // CLI dispatch as a feature flag). Engine selection is by signals[]. + const hasSelectiveDisclosure = hasLegacyCommitments || hasCommittedFieldsRoot; + + // Knowledge Unit bundle detection (has ku_id or consensus_level + models_used) + if (input.type === 'knowledge_unit' || input.ku_id || (input.models_used && input.consensus_level)) { + signals.push(input.type ? 'type=knowledge_unit' : 'ku_id/consensus_level'); + return { mode: 'knowledge-unit', signals, hasSelectiveDisclosure, isBundle: false }; + } + + // Audit bundle with multiple receipts + signing_keys + if (Array.isArray(input.receipts) && input.verification?.signing_keys) { + signals.push('receipts[]', 'verification.signing_keys'); + return { mode: 'ed25519-bundle', signals, hasSelectiveDisclosure, isBundle: true }; + } + + // VOPRF token detection: token value N, DLEQ proofs, scope + if (input.token || input.N || input.nullifier) { + signals.push('voprf-marker'); + if (input.proof_I || input.proof_C || input.dleq) signals.push('dleq-proof'); + if (input.scope || (input.origin && input.epoch)) signals.push('scope'); + return { mode: 'voprf-token', signals, hasSelectiveDisclosure: false, isBundle: false }; + } + + // Passport envelope: { payload, signature: { alg, kid, sig } } + if (input.payload && input.signature && typeof input.signature === 'object' + && typeof input.signature.sig === 'string' + && typeof input.signature.alg === 'string') { + signals.push('payload+signature.alg+signature.sig'); + return { mode: 'ed25519-passport', signals, hasSelectiveDisclosure, isBundle: false }; + } + + // v2 structured envelope: top-level v: 2 + kid + issuer + payload + signature + if (input.v === 2 && input.kid && input.payload) { + signals.push('v=2', 'kid', 'payload'); + return { mode: 'ed25519-receipt-v2', signals, hasSelectiveDisclosure, isBundle: false }; + } + + // v1 flat: top-level type + timestamp + signature + if ((input.v === 1 || input.v === undefined) && input.type && input.signature) { + signals.push('type', 'signature', input.v === 1 ? 'v=1' : 'no-v'); + return { mode: 'ed25519-receipt-v1', signals, hasSelectiveDisclosure, isBundle: false }; + } + + return { mode: 'unknown', signals, hasSelectiveDisclosure, isBundle: false }; +} diff --git a/src/engines/attestation-quote.js b/src/engines/attestation-quote.js new file mode 100644 index 0000000..9b026fe --- /dev/null +++ b/src/engines/attestation-quote.js @@ -0,0 +1,375 @@ +/** + * @veritasacta/verify — hardware-attestation quote validator. + * + * Validates the `attestation_quote` field added by AIP-0005 T2 + * receipts (see ecosystem/physical-attestation/DESIGN.md). Supports + * multiple platform backends via a dispatch table: + * + * - `atecc608b-signed-data-v1` — Microchip ATECC608B (Seal v1 target). + * - `tpm2-quote-v1` — TPM 2.0 quote, verifies with the AK cert chain. + * - `apple-secure-enclave-v1` — Apple Secure Enclave attestation key. + * - `tdx-quote-v4`, `sgx-dcap-v3`, `sev-snp-report-v1` — stubbed. + * + * v0.5.4 ships a STRUCTURAL validator for every platform and a FULL + * cryptographic validator for Apple Secure Enclave (the most + * JavaScript-reachable backend, via ECDSA P-256 and a public Apple CA + * chain). Full validators for TPM / SGX / SEV are targeted v0.7.0. + * + * The design goal is: + * - Structural validator is always on: confirms the quote is + * well-formed, the measured_kid matches the receipt's + * signature.kid, and the platform is known. + * - Cryptographic validator runs when the caller supplies a trust + * anchor (root CA cert or pinned public key). + * - If the platform is known but the validator returns "need more + * trust material", we emit `undecidable` (exit 2). If the + * validator positively rejects the quote, we emit `invalid` + * (exit 1). + * + * @module verify-cli/src/engines/attestation-quote + * @license Apache-2.0 + */ + +import { createHash, createPublicKey, createVerify, verify as cryptoVerifyOneshot } from 'node:crypto'; + +const KNOWN_FORMATS = new Set([ + 'atecc608b-signed-data-v1', + 'ta100-signed-data-v1', + 'se050-attestation-v1', + 'tpm2-quote-v1', + 'apple-secure-enclave-v1', + 'sgx-dcap-v3', + 'sev-snp-report-v1', + 'tdx-quote-v4', + 'custom', +]); + +/** + * @typedef {Object} AttestationQuote + * @property {string} format + * @property {string} quote base64url-encoded raw quote + * @property {string} measured_kid + * @property {string|string[]} [provisioning_ca] + * @property {Object} [reference_values] + */ + +/** + * @typedef {Object} QuoteVerifyOptions + * @property {Object} receipt AIP-0001 envelope carrying `attestation_quote`. + * @property {Object} [trustAnchors] Map: platform → root CA pem / pinned pubkey. + * @property {boolean} [strict=false] Treat `undecidable` as `invalid`. + */ + +/** + * @typedef {Object} QuoteVerifyResult + * @property {boolean} valid + * @property {string} [error] + * @property {'structural'|'cryptographic'|'undecidable'} mode + * @property {string} [platform] + * @property {string} [measured_kid] + * @property {string} [signer_kid] + * @property {boolean} [kid_match] + */ + +/** + * Verify the `attestation_quote` on a T2-claiming receipt. + * + * @param {QuoteVerifyOptions} opts + * @returns {QuoteVerifyResult} + */ +export function verifyAttestationQuote(opts) { + const { receipt, trustAnchors = {}, strict = false } = opts; + + const payload = receipt && receipt.payload; + const quote = payload && payload.attestation_quote; + const attestationMode = payload && payload.attestation_mode; + + if (!payload) return { valid: false, error: 'missing_payload', mode: 'undecidable' }; + if (!quote) return { valid: false, error: 'no_attestation_quote', mode: 'undecidable' }; + + // Structural checks first. + const structural = structuralCheck(quote, receipt); + if (!structural.valid) return structural; + + const signerKid = (receipt.signature && receipt.signature.kid) || null; + const platform = attestationMode || inferPlatformFromFormat(quote.format); + + // Dispatch to platform validator. + switch (quote.format) { + case 'apple-secure-enclave-v1': + return verifyAppleSecureEnclaveQuote(quote, receipt, trustAnchors[platform] || trustAnchors.apple, strict); + case 'atecc608b-signed-data-v1': + return verifyATECC608BQuote(quote, receipt, trustAnchors[platform] || trustAnchors.atecc608b, strict); + case 'tpm2-quote-v1': + case 'sgx-dcap-v3': + case 'sev-snp-report-v1': + case 'tdx-quote-v4': + // Cryptographic validators shipped in v0.7. Today: structural pass + // only, surfaced as `undecidable` unless --strict is set. + return { + valid: !strict, + error: strict ? 'platform_validator_not_yet_shipped' : undefined, + mode: 'undecidable', + platform, + measured_kid: quote.measured_kid, + signer_kid: signerKid, + kid_match: quote.measured_kid === signerKid, + }; + case 'custom': + return { + valid: !strict, + error: strict ? 'custom_format_requires_operator_validator' : undefined, + mode: 'undecidable', + platform: platform || 'custom', + measured_kid: quote.measured_kid, + signer_kid: signerKid, + kid_match: quote.measured_kid === signerKid, + }; + default: + return { + valid: false, + error: `unknown_attestation_format:${quote.format}`, + mode: 'undecidable', + }; + } +} + +// ───── Structural check ───── + +function structuralCheck(quote, receipt) { + if (typeof quote !== 'object' || !quote) { + return { valid: false, error: 'quote_not_object', mode: 'undecidable' }; + } + if (typeof quote.format !== 'string' || !KNOWN_FORMATS.has(quote.format)) { + return { valid: false, error: `unknown_format:${quote.format}`, mode: 'undecidable' }; + } + if (typeof quote.quote !== 'string' || !quote.quote.length) { + return { valid: false, error: 'quote_bytes_missing', mode: 'undecidable' }; + } + if (typeof quote.measured_kid !== 'string' || !quote.measured_kid.length) { + return { valid: false, error: 'measured_kid_missing', mode: 'undecidable' }; + } + const sigKid = (receipt.signature && receipt.signature.kid) || null; + if (sigKid && sigKid !== quote.measured_kid) { + return { + valid: false, + error: 'measured_kid_does_not_match_signature_kid', + mode: 'structural', + measured_kid: quote.measured_kid, + signer_kid: sigKid, + kid_match: false, + }; + } + return { + valid: true, + mode: 'structural', + measured_kid: quote.measured_kid, + signer_kid: sigKid, + kid_match: true, + }; +} + +// ───── Apple Secure Enclave (ECDSA P-256) ───── + +/** + * Verify an Apple Secure Enclave attestation quote. + * + * The expected quote format (v1): + * + * { + * format: "apple-secure-enclave-v1", + * quote: "", + * measured_kid: "", + * provisioning_ca: "" + * } + * + * This is a simplified profile; the production Apple Attestation Service + * format is more complex (App Attest DCAppAttest format). We support a + * minimal "signed enclave-public-key" profile suitable for ScopeBlind's + * Seal-phase TPM2 equivalent on iOS. + * + * Verifier: + * 1. Parse quote bytes. + * 2. Extract measured_pubkey (the ECDSA P-256 key the enclave binds). + * 3. Verify sig over (header || measured_pubkey || nonce) under the + * provisioning CA's pubkey. + * 4. Confirm measured_pubkey hashes to measured_kid. + */ +export function verifyAppleSecureEnclaveQuote(quote, receipt, trustAnchorPem, strict) { + if (!trustAnchorPem) { + return { + valid: !strict, + error: strict ? 'no_trust_anchor_for_apple_se' : undefined, + mode: 'undecidable', + platform: 'apple-secure-enclave', + measured_kid: quote.measured_kid, + }; + } + + let quoteBytes; + try { + quoteBytes = Buffer.from(quote.quote, 'base64'); + } catch (err) { + return { valid: false, error: `bad_quote_encoding:${err.message}`, mode: 'cryptographic' }; + } + + if (quoteBytes.length < 32 + 65 + 32 + 64) { + return { + valid: false, + error: 'quote_too_short_for_apple_se_v1_format', + mode: 'cryptographic', + }; + } + + const header = quoteBytes.subarray(0, 32); + const measuredPubkey = quoteBytes.subarray(32, 32 + 65); + const nonce = quoteBytes.subarray(32 + 65, 32 + 65 + 32); + const sig = quoteBytes.subarray(32 + 65 + 32); + + // Confirm measured_kid fingerprint matches the embedded pubkey. + const fp = createHash('sha256').update(measuredPubkey).digest('hex').slice(0, 16); + if (!quote.measured_kid.endsWith(fp)) { + return { + valid: false, + error: 'measured_kid_does_not_match_embedded_pubkey_fingerprint', + mode: 'cryptographic', + }; + } + + // Verify signature over (header || measured_pubkey || nonce) under CA pubkey. + const signed = Buffer.concat([header, measuredPubkey, nonce]); + const digest = createHash('sha256').update(signed).digest(); + + let caPub; + try { + caPub = createPublicKey({ key: trustAnchorPem, format: 'pem' }); + } catch (err) { + return { valid: false, error: `ca_load_failed:${err.message}`, mode: 'cryptographic' }; + } + + const verify = createVerify('sha256'); + verify.update(digest); + verify.end(); + const ok = verify.verify(caPub, sig); + + if (!ok) { + return { + valid: false, + error: 'quote_signature_invalid', + mode: 'cryptographic', + platform: 'apple-secure-enclave', + measured_kid: quote.measured_kid, + }; + } + + return { + valid: true, + mode: 'cryptographic', + platform: 'apple-secure-enclave', + measured_kid: quote.measured_kid, + signer_kid: (receipt.signature && receipt.signature.kid) || null, + kid_match: true, + }; +} + +// ───── ATECC608B ───── + +/** + * Verify an ATECC608B "signed data" quote. + * + * Seal v1 firmware produces ECDSA P-256 signatures over the canonical + * receipt payload hash. The chip's provisioning CA is a ScopeBlind + * intermediate rooted at a Microchip-issued leaf (or operator's own + * provisioning chain). + * + * For v0.5.4 we implement the structural + signature check against a + * supplied CA PEM. Full Microchip chain validation arrives alongside + * Seal v1 hardware shipping. + */ +export function verifyATECC608BQuote(quote, receipt, trustAnchorPem, strict) { + if (!trustAnchorPem) { + return { + valid: !strict, + error: strict ? 'no_trust_anchor_for_atecc608b' : undefined, + mode: 'undecidable', + platform: 'atecc608b', + measured_kid: quote.measured_kid, + }; + } + + // Quote format: base64 of DER-encoded ECDSA signature over the + // canonical payload bytes. + let sigDer; + try { + sigDer = Buffer.from(quote.quote, 'base64'); + } catch (err) { + return { valid: false, error: `bad_quote_encoding:${err.message}`, mode: 'cryptographic' }; + } + + const payloadBytes = canonicalPayloadBytes(receipt.payload); + + let caPub; + try { + caPub = createPublicKey({ key: trustAnchorPem, format: 'pem' }); + } catch (err) { + return { valid: false, error: `ca_load_failed:${err.message}`, mode: 'cryptographic' }; + } + + let ok = false; + try { + ok = cryptoVerifyOneshot('sha256', payloadBytes, caPub, sigDer); + } catch { + ok = false; + } + + if (!ok) { + return { + valid: false, + error: 'atecc608b_signature_invalid', + mode: 'cryptographic', + platform: 'atecc608b', + measured_kid: quote.measured_kid, + }; + } + + return { + valid: true, + mode: 'cryptographic', + platform: 'atecc608b', + measured_kid: quote.measured_kid, + signer_kid: (receipt.signature && receipt.signature.kid) || null, + kid_match: true, + }; +} + +// ───── Helpers ───── + +function canonicalPayloadBytes(payload) { + // The hardware signs over the decision-payload BARE, without the + // attestation_quote or attestation_mode fields that describe the + // signing proof itself. This avoids a chicken-and-egg where the + // payload would have to contain its own signature. + const copy = { ...payload }; + delete copy.attestation_quote; + delete copy.attestation_mode; + + function jcs(v) { + if (v === null || typeof v !== 'object') return JSON.stringify(v); + if (Array.isArray(v)) return '[' + v.map(jcs).join(',') + ']'; + const keys = Object.keys(v).sort(); + return '{' + keys.map((k) => JSON.stringify(k) + ':' + jcs(v[k])).join(',') + '}'; + } + return Buffer.from(jcs(copy), 'utf-8'); +} + +function inferPlatformFromFormat(format) { + if (format.startsWith('atecc608b')) return 'atecc608b'; + if (format.startsWith('ta100')) return 'ta100'; + if (format.startsWith('se050')) return 'se050'; + if (format.startsWith('tpm2')) return 'tpm2'; + if (format.startsWith('apple-secure-enclave')) return 'apple-secure-enclave'; + if (format.startsWith('sgx')) return 'sgx'; + if (format.startsWith('sev-snp')) return 'sev-snp'; + if (format.startsWith('tdx')) return 'tdx'; + return format; +} diff --git a/src/engines/attestation.js b/src/engines/attestation.js new file mode 100644 index 0000000..aebbad2 --- /dev/null +++ b/src/engines/attestation.js @@ -0,0 +1,223 @@ +/** + * Canonical attestation and verification-receipt emission. + * + * Produces shareable cryptographic artifacts that: + * + * 1. A "canonical attestation" — proof that the operator ran the + * canonical unmodified verifier at time T. Composable network-effect + * artifact: orgs publish these to demonstrate they run the real + * verifier. Bundled with the Sigil fingerprint + verifier version. + * + * 2. A "verification receipt" — proof that the verifier checked a + * specific receipt and the signature was valid. Useful for + * transparency-log anchoring; an auditor can prove "at time T, the + * canonical verifier confirmed this receipt verifies." + * + * Both artifacts are signed with an attester-held Ed25519 key. The key + * is generated on first use and stored in `~/.veritasacta-verify/attester.json` + * unless a custom path is provided via --attest-key. + * + * Neither artifact phones home. Everything is local, offline, user-controlled. + * Publication is the user's responsibility (stdout by default; user pipes + * wherever they want it). + * + * @module verify-cli/src/engines/attestation + * @license Apache-2.0 + */ + +import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { + generateKeyPairSync, + sign, + verify as cryptoVerify, + createPrivateKey, + createPublicKey, + createHash, +} from 'node:crypto'; +import { canonicalize } from '../util/canonical.js'; + +const DEFAULT_ATTESTER_KEY_DIR = join(homedir(), '.veritasacta-verify'); +const DEFAULT_ATTESTER_KEY_FILE = join(DEFAULT_ATTESTER_KEY_DIR, 'attester.json'); + +/** + * Ensure an Ed25519 attester key exists at the given path. + * Generates a new keypair on first use. + * + * @param {string} [keyPath] + * @returns {{privateKey: import('node:crypto').KeyObject, pubHex: string, kid: string}} + */ +export function loadOrCreateAttesterKey(keyPath) { + const path = keyPath || DEFAULT_ATTESTER_KEY_FILE; + + if (existsSync(path)) { + const data = JSON.parse(readFileSync(path, 'utf-8')); + return { + privateKey: createPrivateKey({ key: Buffer.from(data.privateDer, 'hex'), format: 'der', type: 'pkcs8' }), + pubHex: data.pubHex, + kid: data.kid, + }; + } + + // Generate + const { publicKey, privateKey } = generateKeyPairSync('ed25519'); + const pubRaw = publicKey.export({ type: 'spki', format: 'der' }); + const privDer = privateKey.export({ type: 'pkcs8', format: 'der' }); + const pubHex = pubRaw.subarray(pubRaw.length - 32).toString('hex'); + const kid = `attester:${pubHex.slice(0, 12)}`; + + mkdirSync(dirname(path), { recursive: true }); + writeFileSync( + path, + JSON.stringify({ pubHex, kid, privateDer: privDer.toString('hex'), created_at: new Date().toISOString() }, null, 2), + { mode: 0o600 }, + ); + + return { privateKey, pubHex, kid }; +} + +/** + * Sign a payload with an Ed25519 key using JCS canonicalization. + */ +function signCanonical(privateKey, payload) { + const canonical = canonicalize(payload); + const sig = sign(null, Buffer.from(canonical, 'utf-8'), privateKey); + return sig.toString('hex'); +} + +/** + * @typedef {Object} CanonicalAttestationOptions + * @property {Object} sigil parsed sigil.json + * @property {boolean} canonical result of selfCheck + * @property {string} [org] optional org name + * @property {string} [keyPath] override attester key location + * @property {string} [expiry] ISO-8601 validity expiry (defaults to +7 days) + */ + +/** + * Produce a canonical attestation for this verifier run. + * Returns a signed JSON object the user can publish anywhere. + * + * @param {CanonicalAttestationOptions} opts + * @returns {Object} + */ +export function buildCanonicalAttestation(opts) { + const { sigil, canonical, org, keyPath } = opts; + const attester = loadOrCreateAttesterKey(keyPath); + + const now = new Date(); + const expiry = opts.expiry || new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString(); + + const payload = { + type: 'veritasacta:verifier-attestation', + spec: 'draft-farley-acta-signed-receipts-03', + sigil_fingerprint: sigil.fingerprint, + sigil_name: sigil.name, + sigil_hash: sigil.sigil_hash, + verifier_package: sigil.policy.package, + verifier_version: sigil.policy.package_version, + verifier_ietf_draft: sigil.policy.ietf_draft, + verifier_conformance_tier: sigil.policy.conformance_tier, + canonical: Boolean(canonical), + issued_at: now.toISOString(), + expires_at: expiry, + attester_kid: attester.kid, + }; + if (org) payload.attester_org = org; + + const sig = signCanonical(attester.privateKey, payload); + + return { + payload, + signature: { + alg: 'EdDSA', + kid: attester.kid, + sig, + }, + verification: { + attester_pubkey: attester.pubkey || attester.pubHex, + }, + }; +} + +/** + * Produce a verification receipt: a signed attestation that the + * canonical verifier checked a specific subject-receipt and it verified. + * + * @param {Object} args + * @param {Object} args.subjectResult result from a verifier engine + * @param {Object} args.sigil parsed sigil.json + * @param {string} [args.keyPath] attester key override + * @returns {Object} + */ +export function buildVerificationReceipt({ subjectResult, sigil, keyPath }) { + const attester = loadOrCreateAttesterKey(keyPath); + + // Hash the canonical form of the subject (what was verified) + const subjectHash = subjectResult.hash + || createHash('sha256').update(JSON.stringify(subjectResult)).digest('hex'); + + const payload = { + type: 'veritasacta:verification-receipt', + spec: 'draft-farley-acta-signed-receipts-03', + subject: { + hash: `sha256:${subjectHash}`, + kid: subjectResult.kid || null, + format: subjectResult.format || null, + algorithm: subjectResult.algorithm || null, + }, + verification: { + valid: Boolean(subjectResult.valid), + tier: subjectResult.tier?.tier || null, + tier_label: subjectResult.tier?.label || null, + }, + verifier: { + sigil_fingerprint: sigil.fingerprint, + sigil_name: sigil.name, + version: sigil.policy.package_version, + }, + issued_at: new Date().toISOString(), + attester_kid: attester.kid, + }; + if (subjectResult.error) payload.verification.error = subjectResult.error; + + const sig = signCanonical(attester.privateKey, payload); + + return { + payload, + signature: { + alg: 'EdDSA', + kid: attester.kid, + sig, + }, + verification: { + attester_pubkey: attester.pubHex || attester.pubkey, + }, + }; +} + +/** + * Verify an attestation artifact (the inverse of buildCanonicalAttestation). + * Used for verifying that a published attestation came from its stated + * attester. + * + * @param {Object} attestation + * @param {string} attesterPubHex + * @returns {boolean} + */ +export function verifyAttestation(attestation, attesterPubHex) { + try { + // Build the SPKI wrapper for raw Ed25519 pubkey + const spkiPrefix = Buffer.from('302a300506032b6570032100', 'hex'); + const pubRaw = Buffer.from(attesterPubHex, 'hex'); + const spki = Buffer.concat([spkiPrefix, pubRaw]); + const pubKeyObj = createPublicKey({ key: spki, format: 'der', type: 'spki' }); + + const canonical = canonicalize(attestation.payload); + const sig = Buffer.from(attestation.signature.sig, 'hex'); + return cryptoVerify(null, Buffer.from(canonical, 'utf-8'), pubKeyObj, sig); + } catch { + return false; + } +} diff --git a/src/engines/bulk.js b/src/engines/bulk.js new file mode 100644 index 0000000..02c8d9a --- /dev/null +++ b/src/engines/bulk.js @@ -0,0 +1,116 @@ +/** + * Bulk / replay verification for receipt chains. + * + * Reads a JSONL file (one receipt per line) and verifies every entry, + * reporting aggregate statistics. Supports parallel execution with a + * configurable worker count. Produces a structured summary the caller + * can consume directly or export as an audit report. + * + * Chain-linkage is also verified: each receipt's previousReceiptHash + * is compared against the preceding receipt's canonical hash. A broken + * link is surfaced with `chain_break` error. + * + * @module verify-cli/src/engines/bulk + * @license Apache-2.0 + */ + +import { readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { detectFormat } from '../detect.js'; +import { verifyReceipt } from './ed25519-receipt.js'; +import { canonicalize } from '../util/canonical.js'; + +/** + * Verify every receipt in a JSONL-formatted chain file. + * + * @param {string} filePath + * @param {Object} [opts] + * @returns {Promise} + */ +export async function replayChain(filePath, opts = {}) { + const raw = readFileSync(filePath, 'utf-8'); + const lines = raw.split('\n').filter((l) => l.trim().length > 0); + + const results = { + total: lines.length, + verified: 0, + failed: 0, + chainBreaks: 0, + byTier: { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 }, + errors: [], + receipts: [], + valid: true, + }; + + let previousPayloadHash = null; + + for (let i = 0; i < lines.length; i++) { + let receipt; + try { + receipt = JSON.parse(lines[i]); + } catch (e) { + results.failed++; + results.errors.push(`Line ${i + 1}: malformed JSON — ${e.message}`); + results.valid = false; + continue; + } + + // Check chain linkage (when the receipt claims a previous hash) + const payload = receipt.payload || receipt; + const expectedPrev = payload.previousReceiptHash; + + if (expectedPrev !== undefined && expectedPrev !== null && previousPayloadHash !== null) { + const expectedPrevHex = expectedPrev.startsWith('sha256:') + ? expectedPrev.slice(7) + : expectedPrev; + if (expectedPrevHex !== previousPayloadHash) { + results.failed++; + results.chainBreaks++; + results.errors.push( + `Line ${i + 1}: chain_break (expected prev sha256:${previousPayloadHash.slice(0, 16)}..., got ${expectedPrev.slice(0, 24)}...)`, + ); + results.valid = false; + } + } + + // Verify the signature + const detected = detectFormat(receipt); + const r = await verifyReceipt(receipt, detected.mode, opts); + results.receipts.push({ + index: i, + valid: r.valid, + error: r.error, + tier: r.payloadFields ? detectTierFromFields(r.payloadFields) : 1, + kid: r.kid, + type: r.type, + }); + + if (r.valid) { + results.verified++; + const tier = results.receipts[results.receipts.length - 1].tier; + if (results.byTier[tier] !== undefined) results.byTier[tier]++; + } else { + results.failed++; + results.valid = false; + results.errors.push(`Line ${i + 1}: ${r.error}`); + } + + // Compute the canonical hash of this receipt's payload for the next chain check + try { + const canonicalStr = canonicalize(payload); + previousPayloadHash = createHash('sha256').update(canonicalStr, 'utf-8').digest('hex'); + } catch { + previousPayloadHash = null; + } + } + + return results; +} + +function detectTierFromFields(fields) { + if (fields.compliance_credit_ref) return 4; + if (fields.holder_binding) return 4; + if (fields.attestation_mode && fields.attestation_mode !== 'software') return 3; + if (fields.anchor_uri) return 3; + return 1; +} diff --git a/src/engines/chain-explore.js b/src/engines/chain-explore.js new file mode 100644 index 0000000..b84271e --- /dev/null +++ b/src/engines/chain-explore.js @@ -0,0 +1,278 @@ +/** + * @veritasacta/verify — chain explorer + * + * Given a starting receipt (the chain tip) and a directory of receipts, + * walk the ancestry via previousReceiptHash and produce a structured + * description of the chain plus any integrity breaks encountered. + * + * This surfaces what "cryptographic causal integrity" actually means: + * if receipt B claims A caused it, we can verify that claim by hashing + * A and comparing to B.previousReceiptHash. The walker validates every + * link on the way up. + * + * @module verify-cli/src/engines/chain-explore + * @license Apache-2.0 + */ + +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { createHash } from 'node:crypto'; + +import { canonicalize } from '../util/canonical.js'; + +/** + * @typedef {Object} ChainExploreOptions + * @property {string} receiptPath Path to the starting receipt (usually + * the chain tip). + * @property {string} [searchDir] Directory to scan for ancestor + * receipts. Defaults to the dirname of + * receiptPath. + * @property {number} [maxDepth=100] Stop walking after this many ancestors. + * @property {boolean} [verify=true] Verify each hash link. + */ + +/** + * @typedef {Object} ChainNode + * @property {string} path Path the receipt was loaded from. + * @property {string} hash SHA-256 of the canonicalized envelope. + * @property {string|null} previousHash payload.previousReceiptHash, or null. + * @property {string} action payload.action, for display. + * @property {string} issued_at + * @property {string} kid + * @property {boolean} link_valid Whether this node's previousHash + * correctly matches the next ancestor's + * hash. + */ + +/** + * @typedef {Object} ChainExploreResult + * @property {boolean} valid Every link in the chain verified. + * @property {number} depth Number of receipts walked. + * @property {number} links_broken Number of broken previousReceiptHash + * links encountered. + * @property {ChainNode[]} nodes Tip first, root last. + * @property {string[]} warnings + * @property {string} [error] + */ + +/** + * Walk the receipt chain from a starting receipt back to the root. + */ +export async function exploreChain(opts) { + const { + receiptPath, + searchDir, + maxDepth = 100, + verify = true, + } = opts; + + const startPath = resolve(receiptPath); + let startReceipt; + try { + startReceipt = JSON.parse(readFileSync(startPath, 'utf-8')); + } catch (err) { + return { + valid: false, + depth: 0, + links_broken: 0, + nodes: [], + warnings: [], + error: `cannot_read_starting_receipt:${err.code || err.message}`, + }; + } + + const dir = searchDir ? resolve(searchDir) : resolve(startPath, '..'); + const hashToPath = buildReceiptIndex(dir); + + const nodes = []; + const warnings = []; + let linksBroken = 0; + + let current = startReceipt; + let currentPath = startPath; + let depth = 0; + + while (current && depth < maxDepth) { + const nodeHash = receiptHash(current); + const node = { + path: currentPath, + hash: nodeHash, + previousHash: + (current.payload && current.payload.previousReceiptHash) || null, + action: + (current.payload && (current.payload.action || current.payload.tool_name)) || + '(unknown)', + issued_at: + (current.payload && current.payload.issued_at) || '(unknown)', + kid: + (current.signature && current.signature.kid) || '(unknown)', + // Optional trace / causal-DAG fields (AIP-0001 extension). + trace_id: + (current.payload && current.payload.trace_id) || null, + parent_receipt_id: + (current.payload && current.payload.parent_receipt_id) || null, + link_valid: true, + }; + nodes.push(node); + depth++; + + if (!node.previousHash) { + // We've reached the root. + break; + } + + const ancestorPath = hashToPath.get(node.previousHash); + if (!ancestorPath) { + node.link_valid = false; + linksBroken++; + warnings.push( + `Chain ends at receipt[${depth - 1}]: previousReceiptHash ${node.previousHash.slice(0, 16)}... ` + + `not found in searchDir ${dir}` + ); + break; + } + + let ancestor; + try { + ancestor = JSON.parse(readFileSync(ancestorPath, 'utf-8')); + } catch (err) { + node.link_valid = false; + linksBroken++; + warnings.push( + `Cannot read ancestor at ${ancestorPath}: ${err.code || err.message}` + ); + break; + } + + if (verify) { + const computed = receiptHash(ancestor); + if (computed !== node.previousHash) { + // Shouldn't happen because we keyed hashToPath by computed hash, but + // retain as a defensive check. + node.link_valid = false; + linksBroken++; + warnings.push( + `Hash mismatch at ancestor of receipt[${depth - 1}]: ` + + `expected ${node.previousHash.slice(0, 16)}... got ${computed.slice(0, 16)}...` + ); + break; + } + } + + current = ancestor; + currentPath = ancestorPath; + } + + if (depth >= maxDepth) { + warnings.push(`Reached maxDepth=${maxDepth}; chain may extend further.`); + } + + return { + valid: linksBroken === 0, + depth, + links_broken: linksBroken, + nodes, + warnings, + }; +} + +/** + * Compute SHA-256 of the canonical envelope, base64url-encoded. + * Matches receipt_hash() in receipts.py across the ecosystem. + */ +function receiptHash(envelope) { + const bytes = canonicalize(envelope); + const digest = createHash('sha256').update(bytes).digest(); + return digest.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +/** + * Scan a directory for receipt files and build a hash→path index. + */ +function buildReceiptIndex(dir) { + const index = new Map(); + let entries; + try { + entries = readdirSync(dir); + } catch { + return index; + } + + for (const entry of entries) { + const p = join(dir, entry); + let st; + try { + st = statSync(p); + } catch { + continue; + } + if (!st.isFile() || !entry.endsWith('.json')) continue; + try { + const receipt = JSON.parse(readFileSync(p, 'utf-8')); + if (!receipt.payload || !receipt.signature) continue; + const hash = receiptHash(receipt); + index.set(hash, p); + } catch { + continue; + } + } + + return index; +} + +/** + * Group nodes by trace_id. Returns a map trace_id → ChainNode[]. + * Nodes with no trace_id go under the key "(no-trace)". + * + * Within each trace, nodes are ordered tip-first (as produced by the + * walker). Callers that want causal ordering by parent_receipt_id can + * post-process. + * + * @param {ChainExploreResult} result + * @returns {Object} + */ +export function groupByTrace(result) { + const out = {}; + for (const n of result.nodes || []) { + const key = n.trace_id || '(no-trace)'; + if (!out[key]) out[key] = []; + out[key].push(n); + } + return out; +} + +/** + * Render a result as a human-readable ASCII tree for terminal output. + */ +export function renderChainTree(result, opts = {}) { + const { maxLineWidth = 80 } = opts; + const lines = []; + + if (!result.valid && result.nodes.length === 0) { + return `Chain explore failed: ${result.error || '(unknown)'}`; + } + + lines.push(''); + lines.push(`Chain: depth=${result.depth} links_broken=${result.links_broken} valid=${result.valid}`); + lines.push(''); + + for (let i = 0; i < result.nodes.length; i++) { + const n = result.nodes[i]; + const prefix = i === 0 ? '▶' : '↑'; + const statusMark = n.link_valid ? ' ' : '✗'; + const line = `${prefix} ${statusMark} ${n.hash.slice(0, 12)}... ${n.action} (${n.issued_at})`; + lines.push(line.length > maxLineWidth ? line.slice(0, maxLineWidth - 1) + '…' : line); + if (i < result.nodes.length - 1) lines.push(' │'); + } + + if (result.warnings.length > 0) { + lines.push(''); + lines.push('Warnings:'); + for (const w of result.warnings) { + lines.push(` • ${w}`); + } + } + + lines.push(''); + return lines.join('\n'); +} diff --git a/src/engines/commitment-mode.js b/src/engines/commitment-mode.js new file mode 100644 index 0000000..2759434 --- /dev/null +++ b/src/engines/commitment-mode.js @@ -0,0 +1,182 @@ +/** + * Commitment-mode verification engine (draft-farley-acta-signed-receipts-01). + * + * Verifies receipts that carry a `committed_fields_root` field: a single + * SHA-256 Merkle root over RFC 6962-domain-separated leaves of + * (name, salt, value) tuples. + * + * When the caller provides a disclosure object containing a Merkle + * inclusion proof, this engine: + * 1. Reconstructs the leaf hash from the disclosed (name, salt, value) + * 2. Walks the inclusion proof + * 3. Compares the reconstructed root against committed_fields_root + * + * This engine is distinct from the legacy AIP-0002 selective-disclosure + * engine (which handles the older _commitments-map shape, kept for + * backwards compatibility). The two formats do not overlap; routing is + * by the presence of `committed_fields_root` (this engine) vs + * `_commitments` (the legacy engine). + * + * References: + * - draft-farley-acta-signed-receipts-01 §commitment-mode + * - RFC 6962 §2.1 (Merkle tree construction) + * - RFC 8785 (JCS canonicalization) + * + * @module verify-cli/src/engines/commitment-mode + * @license Apache-2.0 + */ + +import { hashLeaf, encodeLeaf, verifyProof, base64urlDecode } from '../util/merkle.js'; + +/** + * @typedef {Object} CommittedDisclosure + * @property {string} parent_receipt_hash canonical hash of the receipt + * @property {string} name field name + * @property {unknown} value cleartext value + * @property {string} salt base64url-encoded salt (no padding) + * @property {{index: number, treeSize: number, siblings: string[]}} proof + */ + +/** + * @typedef {Object} CommitmentVerifyResult + * @property {boolean} valid + * @property {string} [error] + * @property {number} disclosuresVerified + * @property {Array<{name: string, ok: boolean, reason?: string}>} checks + */ + +/** + * Verify a committed-mode receipt against zero or more disclosures. + * + * If disclosures is empty, the function returns valid=true with + * disclosuresVerified=0: the receipt's existence and committed_fields_root + * are not in scope here (the outer signature engine handles that). + * + * If disclosures are provided, each is checked against the receipt's + * committed_fields_root. All must verify for the result to be valid. + * + * @param {Object} receipt the parsed receipt with committed_fields_root + * @param {CommittedDisclosure[]} disclosures + * @returns {CommitmentVerifyResult} + */ +export function verifyCommittedReceipt(receipt, disclosures = []) { + if (!receipt || typeof receipt !== 'object') { + return { + valid: false, + error: 'malformed_receipt', + disclosuresVerified: 0, + checks: [], + }; + } + + let rootHex = receipt.committed_fields_root; + if (typeof rootHex !== 'string' || rootHex.length === 0) { + return { + valid: false, + error: 'missing_committed_fields_root', + disclosuresVerified: 0, + checks: [], + }; + } + // Strip optional "sha256:" prefix. + if (rootHex.startsWith('sha256:')) rootHex = rootHex.slice(7); + if (!/^[0-9a-fA-F]{64}$/.test(rootHex)) { + return { + valid: false, + error: 'malformed_committed_fields_root', + disclosuresVerified: 0, + checks: [], + }; + } + + if (!Array.isArray(disclosures) || disclosures.length === 0) { + return { + valid: true, + disclosuresVerified: 0, + checks: [], + }; + } + + const checks = []; + let allValid = true; + + for (const d of disclosures) { + const fieldName = d?.name; + if (typeof fieldName !== 'string' || !fieldName) { + checks.push({ name: '', ok: false, reason: 'disclosure_missing_name' }); + allValid = false; + continue; + } + + if (typeof d.salt !== 'string' || !d.salt) { + checks.push({ name: fieldName, ok: false, reason: 'disclosure_missing_salt' }); + allValid = false; + continue; + } + + if (!d.proof || typeof d.proof !== 'object') { + checks.push({ name: fieldName, ok: false, reason: 'disclosure_missing_proof' }); + allValid = false; + continue; + } + + let leafHash; + try { + // Validate base64url decode by attempting it (doesn't need the bytes + // here, just sanity-checks). Then encode the canonical leaf using + // the salt as it appears in the disclosure (base64url string). + base64urlDecode(d.salt); + const leafBytes = encodeLeaf(fieldName, d.salt, d.value); + leafHash = hashLeaf(leafBytes); + } catch (err) { + checks.push({ + name: fieldName, + ok: false, + reason: `leaf_encoding_failed: ${err?.message ?? 'unknown'}`, + }); + allValid = false; + continue; + } + + const ok = verifyProof(rootHex, leafHash, d.proof); + checks.push({ + name: fieldName, + ok, + reason: ok ? undefined : 'merkle_proof_does_not_reconstruct_root', + }); + if (!ok) allValid = false; + } + + return { + valid: allValid, + error: allValid ? undefined : 'commitment_mismatch', + disclosuresVerified: disclosures.length, + checks, + }; +} + +/** + * Load disclosure objects from a JSON file. Accepts either: + * - A single disclosure object: { parent_receipt_hash, name, value, salt, proof } + * - An array of such objects: [ {...}, {...} ] + * - A wrapper object: { disclosures: [ {...} ] } + * + * @param {string} jsonText raw file contents + * @returns {CommittedDisclosure[]} + */ +export function loadDisclosuresFromText(jsonText) { + let parsed; + try { + parsed = JSON.parse(jsonText); + } catch (err) { + throw new Error(`disclosure file is not valid JSON: ${err?.message ?? 'parse error'}`); + } + if (Array.isArray(parsed)) return parsed; + if (parsed && Array.isArray(parsed.disclosures)) return parsed.disclosures; + if (parsed && typeof parsed === 'object' && 'name' in parsed && 'proof' in parsed) { + return [parsed]; + } + throw new Error( + 'disclosure file must be a single disclosure object, an array, or a {disclosures: [...]} wrapper', + ); +} diff --git a/src/engines/compliance-export.js b/src/engines/compliance-export.js new file mode 100644 index 0000000..cbb27a5 --- /dev/null +++ b/src/engines/compliance-export.js @@ -0,0 +1,397 @@ +/** + * @veritasacta/verify — compliance export engine + * + * Given a directory of signed decision receipts, produce evidence + * bundles shaped for three compliance frameworks: + * + * - SOC 2 Trust Services Criteria (CC6 Logical Access, + * CC7 System Operations, CC8 Change Management) + * - ISO/IEC 42001 (AI Management System) — A.6, A.8 controls + * - EU AI Act Article 12 (record-keeping) + Article 13 (transparency) + * + * The engine does NOT verify receipts itself; it assumes they have + * already been verified by the main CLI. Its job is to bucket receipts + * into control-mapped evidence artifacts, compute summary statistics, + * and emit a portable JSON bundle plus a human-readable HTML report. + * + * Auditors get: + * - One signed manifest bundle.json + per-control CSVs + * - An HTML report with control names, evidence counts, timeline + * + * This is a v0 implementation. v1 will add control-specific rulesets + * and auditor sign-off receipts. + * + * @module verify-cli/src/engines/compliance-export + * @license Apache-2.0 + */ + +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +// ───── Framework mappings ───── + +/** + * SOC 2 Trust Services Criteria that can be evidenced by decision + * receipts produced by AI agent runtimes. + * + * Mapping strategy: each control lists tool-action patterns (as + * case-insensitive substrings) that, when present in a receipt's + * action field, provide evidence for that control. + */ +const SOC2_CONTROLS = { + 'CC6.1': { + name: 'Logical and Physical Access Controls', + description: + 'The entity implements logical access security software, infrastructure, and architectures over protected information assets to protect them from security events.', + actions: ['Read', 'Write', 'Edit', 'Bash', 'mcp:', 'tool:'], + policy_field: 'decision', + }, + 'CC6.6': { + name: 'Logical Access Restrictions', + description: + 'The entity implements logical access security measures to protect against threats from sources outside its system boundaries.', + actions: ['WebFetch', 'WebSearch', 'http', 'network:'], + policy_field: 'decision', + }, + 'CC7.2': { + name: 'Detection of Events', + description: + 'The entity monitors system components and the operation of controls to detect anomalies that are indicative of malicious acts, natural disasters, and errors.', + actions: ['anomaly', 'policy_violation', 'denied'], + policy_field: 'decision', + }, + 'CC8.1': { + name: 'Change Management', + description: + 'The entity authorizes, designs, develops, configures, tests, approves, and implements changes to infrastructure, data, software.', + actions: ['Write', 'Edit', 'commit', 'deploy'], + policy_field: 'action', + }, +}; + +const ISO42001_CONTROLS = { + 'A.6.1.2': { + name: 'AI System Impact Assessment', + description: + 'The organization shall assess and document AI system impact throughout the lifecycle.', + actions: ['decision', 'impact:', 'assessment:'], + policy_field: 'type', + }, + 'A.8.2.1': { + name: 'System Transparency', + description: + 'Decisions made by or with AI systems shall be transparent and traceable.', + actions: ['decision', 'tool:', 'action:'], + policy_field: 'type', + }, + 'A.8.3.1': { + name: 'Logging and Monitoring', + description: + 'The organization shall ensure appropriate logging of AI system operations.', + actions: ['*'], + policy_field: 'type', + }, + 'A.9.2.1': { + name: 'AI System Change Control', + description: + 'Changes to AI systems shall be controlled, documented, and reviewed.', + actions: ['Write', 'Edit', 'deploy', 'config:'], + policy_field: 'action', + }, +}; + +const EU_AI_ACT_CONTROLS = { + 'Art.12(1)': { + name: 'Automatic Recording of Events', + description: + 'High-risk AI systems shall technically allow for the automatic recording of events (logs) over the duration of their lifetime.', + actions: ['*'], + policy_field: 'type', + }, + 'Art.12(2)': { + name: 'Traceability of Functioning', + description: + 'The logging capabilities shall ensure a level of traceability appropriate to the intended purpose.', + actions: ['decision', 'chain:'], + policy_field: 'type', + }, + 'Art.13(1)': { + name: 'Transparency to Users', + description: + 'High-risk AI systems shall be designed and developed in such a way as to ensure their operation is sufficiently transparent.', + actions: ['decision', 'action:'], + policy_field: 'type', + }, + 'Art.14(1)': { + name: 'Human Oversight', + description: + 'High-risk AI systems shall be designed and developed such that they can be effectively overseen by natural persons.', + actions: ['human_review', 'approved_by', 'denied'], + policy_field: 'decision', + }, +}; + +const FRAMEWORKS = { + soc2: { name: 'SOC 2 Trust Services Criteria', controls: SOC2_CONTROLS }, + iso42001: { name: 'ISO/IEC 42001:2023', controls: ISO42001_CONTROLS }, + 'eu-ai-act': { name: 'EU AI Act (Regulation 2024/1689)', controls: EU_AI_ACT_CONTROLS }, +}; + +/** + * @typedef {Object} ExportOptions + * @property {string} receiptsDir Path to directory of *.json receipts. + * @property {'soc2'|'iso42001'|'eu-ai-act'|'all'} framework + * @property {string} [startDate] ISO-8601 date-time — inclusive lower bound. + * @property {string} [endDate] ISO-8601 date-time — exclusive upper bound. + * @property {string} [organizationName] Appears in the manifest and HTML header. + */ + +/** + * @typedef {Object} ExportResult + * @property {Object} manifest Summary manifest (JSON-serialisable). + * @property {Object} evidence_by_control Map control-id → matched receipts (summaries). + * @property {string[]} warnings + */ + +/** + * Produce a compliance evidence bundle from a directory of receipts. + * + * @param {ExportOptions} opts + * @returns {ExportResult} + */ +export function exportCompliance(opts) { + const { + receiptsDir, + framework = 'all', + startDate, + endDate, + organizationName = '(unspecified)', + } = opts; + + const dir = resolve(receiptsDir); + const frameworks = + framework === 'all' + ? Object.entries(FRAMEWORKS) + : [[framework, FRAMEWORKS[framework]]]; + + if (!FRAMEWORKS[framework] && framework !== 'all') { + throw new Error(`unknown_framework:${framework}`); + } + + const warnings = []; + const receipts = loadReceipts(dir, warnings); + const filtered = filterByWindow(receipts, startDate, endDate); + + const evidenceByControl = {}; + const stats = {}; + + for (const [fwId, fwDef] of frameworks) { + if (!fwDef) continue; + stats[fwId] = { framework: fwDef.name, controls: {} }; + for (const [ctrlId, ctrl] of Object.entries(fwDef.controls)) { + const matches = matchReceipts(filtered, ctrl); + const key = `${fwId}:${ctrlId}`; + evidenceByControl[key] = matches.map(summariseReceipt); + stats[fwId].controls[ctrlId] = { + name: ctrl.name, + description: ctrl.description, + evidence_count: matches.length, + first_event: matches[0] ? matches[0].payload.issued_at : null, + last_event: matches[matches.length - 1] + ? matches[matches.length - 1].payload.issued_at + : null, + }; + } + } + + const manifest = { + format: 'veritasacta-compliance-export/v0', + generated_at: new Date().toISOString(), + organization: organizationName, + receipts_scanned: receipts.length, + receipts_in_window: filtered.length, + window: { start: startDate || null, end: endDate || null }, + frameworks: stats, + }; + + return { + manifest, + evidence_by_control: evidenceByControl, + warnings, + }; +} + +/** + * Render the export as a self-contained HTML audit report. + * Returns a UTF-8 HTML string. + * + * @param {ExportResult} result + * @returns {string} + */ +export function renderComplianceHTML(result) { + const { manifest, evidence_by_control, warnings } = result; + const esc = (s) => + String(s == null ? '' : s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + + let html = ` + +Compliance Evidence Bundle — ${esc(manifest.organization)} + + +

Compliance Evidence Bundle

+
+ Organization: ${esc(manifest.organization)}
+ Generated: ${esc(manifest.generated_at)}
+ Receipts scanned: ${manifest.receipts_scanned} · in window: ${manifest.receipts_in_window}
+ Window: ${esc(manifest.window.start || '(beginning)')} → ${esc(manifest.window.end || '(end)')} +
+`; + + if (warnings.length) { + html += `
Warnings:
    `; + for (const w of warnings) html += `
  • ${esc(w)}
  • `; + html += `
`; + } + + for (const [fwId, fw] of Object.entries(manifest.frameworks)) { + html += `

${esc(fw.framework)}

`; + for (const [ctrlId, ctrl] of Object.entries(fw.controls)) { + const zero = ctrl.evidence_count === 0 ? ' zero' : ''; + html += `
+
+

${esc(ctrlId)} — ${esc(ctrl.name)}

+
${ctrl.evidence_count} receipt(s)
+
+
${esc(ctrl.description)}
`; + if (ctrl.evidence_count > 0) { + html += `
First: ${esc(ctrl.first_event || '')} · Last: ${esc(ctrl.last_event || '')}
`; + const evid = evidence_by_control[`${fwId}:${ctrlId}`] || []; + const sample = evid.slice(0, 10); + html += ` + `; + for (const e of sample) { + html += ` + + + + + + `; + } + html += `
issued_atactiondecisionkidhash
${esc(e.issued_at || '')}${esc(e.action || '')}${esc(e.decision || '')}${esc(e.kid || '')}${esc((e.receipt_hash || '').slice(0, 12))}…
`; + if (evid.length > sample.length) { + html += `
… and ${evid.length - sample.length} more in bundle.json
`; + } + } + html += `
`; + } + } + + html += ``; + return html; +} + +// ───── Helpers ───── + +function loadReceipts(dir, warnings) { + let entries; + try { + entries = readdirSync(dir); + } catch (err) { + throw new Error(`cannot_read_dir:${err.code || err.message}:${dir}`); + } + + const list = []; + for (const entry of entries) { + if (!entry.endsWith('.json')) continue; + const p = join(dir, entry); + let st; + try { st = statSync(p); } catch { continue; } + if (!st.isFile()) continue; + + let receipt; + try { receipt = JSON.parse(readFileSync(p, 'utf-8')); } + catch { warnings.push(`skipped: non-JSON or malformed: ${entry}`); continue; } + if (!receipt.payload || !receipt.signature) { + warnings.push(`skipped: missing payload/signature: ${entry}`); + continue; + } + list.push({ ...receipt, __path: p }); + } + + // Sort oldest first for timeline rendering. + list.sort((a, b) => { + const aa = (a.payload && a.payload.issued_at) || ''; + const bb = (b.payload && b.payload.issued_at) || ''; + return aa < bb ? -1 : aa > bb ? 1 : 0; + }); + return list; +} + +function filterByWindow(receipts, start, end) { + if (!start && !end) return receipts; + return receipts.filter((r) => { + const t = r.payload && r.payload.issued_at; + if (!t) return false; + if (start && t < start) return false; + if (end && t >= end) return false; + return true; + }); +} + +function matchReceipts(receipts, control) { + const patterns = control.actions.map((a) => a.toLowerCase()); + const wantWildcard = patterns.includes('*'); + + // Collect candidate fields: always action/tool_name/type/decision. + // This avoids fragile single-field gating; controls describe a + // semantic category, and any matching evidence surface counts. + const fields = ['action', 'tool_name', 'type', 'decision']; + + return receipts.filter((r) => { + if (wantWildcard) return true; + if (!r.payload) return false; + for (const f of fields) { + const v = r.payload[f]; + if (typeof v !== 'string') continue; + const lv = v.toLowerCase(); + if (patterns.some((p) => lv.includes(p))) return true; + } + return false; + }); +} + +function summariseReceipt(r) { + const p = r.payload || {}; + return { + receipt_hash: '', // filled in by caller if hash is pre-computed + issued_at: p.issued_at || null, + action: p.action || p.tool_name || null, + decision: p.decision || null, + type: p.type || null, + issuer_id: p.issuer_id || null, + agent_id: p.agent_id || null, + kid: (r.signature && r.signature.kid) || null, + }; +} diff --git a/src/engines/cosign.js b/src/engines/cosign.js new file mode 100644 index 0000000..2ebddad --- /dev/null +++ b/src/engines/cosign.js @@ -0,0 +1,213 @@ +/** + * @veritasacta/verify — bilateral (and N-ary) co-signature engine. + * + * Adds an optional envelope-level `cosignatures` array to any AIP-0001 + * receipt. Each cosignature is the same shape as `signature`: + * + * { alg: "EdDSA", kid: "", sig: "" } + * + * Cosignatures sign the SAME canonical payload bytes as the primary + * `signature`. Semantics: + * + * - The primary `signature` continues to be the authoritative + * "who produced this receipt" signature (AIP-0001 unchanged). + * - Each cosignature is independent additional evidence (the server + * that executed the call, a policy oracle, a multi-party signer, + * a notary, etc.). + * - A "bilateral" receipt is one with exactly one cosignature in + * addition to the primary signature — typically agent + server. + * + * This is purely additive. Existing receipts without `cosignatures` + * verify identically. Verifiers that don't implement this engine + * simply ignore the field. + * + * @module verify-cli/src/engines/cosign + * @license Apache-2.0 + */ + +import { verify as cryptoVerify, createPublicKey } from 'node:crypto'; +import { canonicalize } from '../util/canonical.js'; + +/** + * @typedef {Object} CosignVerifyOptions + * @property {Object} envelope Receipt envelope with optional `cosignatures`. + * @property {Object} trustAnchors Map: kid → hex-encoded Ed25519 public key (64 chars). + * @property {boolean} [requireAllValid=true] Fail if any cosignature fails. + * @property {boolean} [requireAllResolved=true] Fail if any cosignature's kid is absent from trust anchors. + */ + +/** + * @typedef {Object} CosignVerifyResult + * @property {boolean} valid + * @property {string} [error] + * @property {number} total + * @property {number} valid_count + * @property {Array<{kid: string, alg: string, sig_valid: boolean, reason?: string}>} signatures + */ + +/** + * Verify all cosignatures on an envelope against a caller-supplied + * trust-anchor map. Does NOT verify the primary `signature` — + * that's the Ed25519 receipt engine's job. + * + * @param {CosignVerifyOptions} opts + * @returns {CosignVerifyResult} + */ +export function verifyCosignatures(opts) { + const { envelope, trustAnchors, requireAllValid = true, requireAllResolved = true } = opts; + + const cos = envelope && Array.isArray(envelope.cosignatures) ? envelope.cosignatures : []; + if (cos.length === 0) { + return { + valid: true, // vacuous truth: no cosignatures = nothing to verify + total: 0, + valid_count: 0, + signatures: [], + }; + } + + const payloadBytes = canonicalize(envelope.payload); + const results = []; + let validCount = 0; + + for (const entry of cos) { + if (!entry || typeof entry !== 'object' || !entry.kid || !entry.sig) { + results.push({ + kid: (entry && entry.kid) || '(missing)', + alg: (entry && entry.alg) || '(missing)', + sig_valid: false, + reason: 'malformed_cosignature_entry', + }); + continue; + } + const kid = entry.kid; + const pubHex = trustAnchors && trustAnchors[kid]; + if (!pubHex) { + results.push({ + kid, + alg: entry.alg || 'unknown', + sig_valid: false, + reason: 'no_trust_anchor', + }); + continue; + } + + let pub; + try { + pub = pubkeyFromHex(pubHex); + } catch (err) { + results.push({ + kid, + alg: entry.alg || 'unknown', + sig_valid: false, + reason: `bad_trust_anchor_key:${err.message}`, + }); + continue; + } + + const sigBuf = decodeSig(entry.sig); + if (!sigBuf) { + results.push({ + kid, + alg: entry.alg || 'unknown', + sig_valid: false, + reason: 'bad_sig_encoding', + }); + continue; + } + + let ok = false; + try { + ok = cryptoVerify(null, payloadBytes, pub, sigBuf); + } catch { + ok = false; + } + results.push({ + kid, + alg: entry.alg || 'EdDSA', + sig_valid: ok, + ...(ok ? {} : { reason: 'signature_invalid' }), + }); + if (ok) validCount++; + } + + const anyInvalid = results.some((r) => !r.sig_valid); + const anyUnresolved = results.some((r) => r.reason === 'no_trust_anchor'); + + if (requireAllResolved && anyUnresolved) { + return { + valid: false, + error: 'cosignature_no_trust_anchor', + total: cos.length, + valid_count: validCount, + signatures: results, + }; + } + if (requireAllValid && anyInvalid) { + return { + valid: false, + error: 'cosignature_invalid', + total: cos.length, + valid_count: validCount, + signatures: results, + }; + } + return { + valid: true, + total: cos.length, + valid_count: validCount, + signatures: results, + }; +} + +/** + * Attach a cosignature to an envelope in-place. Does NOT produce the + * signature itself — the caller is expected to have signed the + * canonicalized payload with their own private key. This is a pure + * envelope-mutation helper. + * + * @param {Object} envelope + * @param {{alg: string, kid: string, sig: string}} cosig + */ +export function attachCosignature(envelope, cosig) { + if (!envelope || typeof envelope !== 'object') { + throw new Error('attachCosignature: envelope must be an object'); + } + if (!cosig || !cosig.kid || !cosig.sig) { + throw new Error('attachCosignature: cosig must have { alg, kid, sig }'); + } + if (!Array.isArray(envelope.cosignatures)) { + envelope.cosignatures = []; + } + envelope.cosignatures.push({ + alg: cosig.alg || 'EdDSA', + kid: cosig.kid, + sig: cosig.sig, + }); +} + +// ───── helpers ───── + +function pubkeyFromHex(hex) { + if (typeof hex !== 'string' || hex.length !== 64) { + throw new Error('pubkey_must_be_32_byte_hex'); + } + const raw = Buffer.from(hex, 'hex'); + const spki = Buffer.concat([ + Buffer.from('302a300506032b6570032100', 'hex'), + raw, + ]); + return createPublicKey({ key: spki, format: 'der', type: 'spki' }); +} + +function decodeSig(str) { + if (typeof str !== 'string') return null; + if (/^[0-9a-fA-F]+$/.test(str) && str.length % 2 === 0) { + return Buffer.from(str, 'hex'); + } + try { + return Buffer.from(str, 'base64'); + } catch { + return null; + } +} diff --git a/src/engines/daemon.js b/src/engines/daemon.js new file mode 100644 index 0000000..e775f1c --- /dev/null +++ b/src/engines/daemon.js @@ -0,0 +1,180 @@ +/** + * Sidecar daemon: unix socket API for receipt signing. + * + * Usage: + * veritasacta daemon [--socket /tmp/veritasacta.sock] [--key ~/.veritasacta/attester.json] + * + * The daemon listens on a Unix domain socket for HTTP-like POST requests + * and signs decision receipts on demand. Any agent in the same user + * context (in any language) can emit receipts by POSTing a JSON body to + * `/sign`; the socket handles chain linkage and key management in one + * place, without requiring the agent to embed a signing SDK. + * + * Endpoints (HTTP-over-unix-socket): + * POST /sign body: { tool, args, decision, policy_id, metadata } + * returns signed receipt JSON + * GET /pubkey returns the daemon's signing pubkey (for verifier config) + * GET /stats returns { total, chain_head, uptime_seconds } + * GET /health returns "ok" + * + * The daemon is intentionally minimal: no auth (unix socket perms are + * the auth), no persistence beyond receipt files, no clustering. For + * multi-host deployments use the managed ScopeBlind gateway. + * + * @module verify-cli/src/engines/daemon + * @license Apache-2.0 + */ + +import { createServer } from 'node:http'; +import { + readFileSync, + writeFileSync, + existsSync, + mkdirSync, + unlinkSync, +} from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { sign, createPrivateKey, createHash } from 'node:crypto'; +import { canonicalize } from '../util/canonical.js'; + +function loadKey(keyPath) { + if (!existsSync(keyPath)) { + throw new Error(`No signing key at ${keyPath}. Run \`veritasacta init\` to create one.`); + } + const data = JSON.parse(readFileSync(keyPath, 'utf-8')); + return { + kid: data.kid, + privateKey: createPrivateKey({ + key: Buffer.from(data.privateDer, 'hex'), + format: 'der', + type: 'pkcs8', + }), + pubHex: data.pubHex, + }; +} + +function signPayload(privateKey, payload) { + const canonical = canonicalize(payload); + const sig = sign(null, Buffer.from(canonical, 'utf-8'), privateKey); + return sig.toString('hex'); +} + +async function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => { + try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf-8') || '{}')); } + catch (e) { reject(e); } + }); + req.on('error', reject); + }); +} + +/** + * Start the daemon. + * @param {Object} opts + * @param {string} [opts.socket] unix socket path + * @param {string} [opts.key] key file path + * @param {string} [opts.receiptsDir] where to write signed receipts + * @returns {Promise} + */ +export async function runDaemon(opts = {}) { + const socketPath = opts.socket || join('/tmp', `veritasacta-${process.getuid?.() || 'user'}.sock`); + const keyPath = opts.key || join(homedir(), '.veritasacta-verify', 'attester.json'); + const receiptsDir = opts.receiptsDir || join(process.cwd(), '.veritasacta', 'receipts'); + + const key = loadKey(keyPath); + mkdirSync(receiptsDir, { recursive: true }); + + let sequence = 0; + let previousReceiptHash = null; + const startedAt = Date.now(); + + const server = createServer(async (req, res) => { + const respond = (status, body, contentType = 'application/json') => { + res.writeHead(status, { 'content-type': contentType }); + res.end(typeof body === 'string' ? body : JSON.stringify(body)); + }; + + try { + if (req.method === 'GET' && req.url === '/health') return respond(200, 'ok', 'text/plain'); + if (req.method === 'GET' && req.url === '/pubkey') return respond(200, { kid: key.kid, pubkey: key.pubHex }); + if (req.method === 'GET' && req.url === '/stats') { + return respond(200, { + total: sequence, + chain_head: previousReceiptHash, + uptime_seconds: Math.floor((Date.now() - startedAt) / 1000), + kid: key.kid, + }); + } + + if (req.method === 'POST' && req.url === '/sign') { + const body = await readBody(req); + const tool = body.tool || body.tool_name || 'unknown'; + const args = body.args || body.tool_args || {}; + const decision = body.decision || 'allow'; + const policyId = body.policy_id || 'veritasacta:daemon:default'; + + sequence++; + const argStr = JSON.stringify(args, Object.keys(args).sort ? Object.keys(args).sort() : undefined); + const toolInputHash = 'sha256:' + createHash('sha256').update(argStr, 'utf-8').digest('hex'); + + const payload = { + type: 'veritasacta:daemon:decision', + spec: 'draft-farley-acta-signed-receipts-03', + tool_name: tool, + tool_input_hash: toolInputHash, + decision, + policy_id: policyId, + issued_at: new Date().toISOString(), + issuer_id: key.kid, + sequence, + previousReceiptHash, + ...(body.metadata ? { metadata: body.metadata } : {}), + }; + + const sig = signPayload(key.privateKey, payload); + const receipt = { + payload, + signature: { alg: 'EdDSA', kid: key.kid, sig }, + }; + + const canonical = canonicalize(payload); + previousReceiptHash = 'sha256:' + createHash('sha256').update(canonical, 'utf-8').digest('hex'); + + const receiptFile = join(receiptsDir, `rcpt_${String(sequence).padStart(6, '0')}.json`); + writeFileSync(receiptFile, JSON.stringify(receipt, null, 2)); + + return respond(200, receipt); + } + + respond(404, { error: 'not_found' }); + } catch (err) { + respond(500, { error: 'internal_error', detail: err.message }); + } + }); + + // Clean up an existing socket file + if (existsSync(socketPath)) { + try { unlinkSync(socketPath); } catch {} + } + + return new Promise((resolve) => { + server.listen(socketPath, () => { + process.stderr.write(`[veritasacta daemon] listening on ${socketPath} (kid=${key.kid})\n`); + process.stderr.write(`[veritasacta daemon] receipts → ${receiptsDir}\n`); + process.stderr.write(`[veritasacta daemon] example: curl --unix-socket ${socketPath} http://_/stats\n`); + }); + + const shutdown = () => { + server.close(() => { + try { unlinkSync(socketPath); } catch {} + resolve(); + }); + }; + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); + }); +} diff --git a/src/engines/dashboard.js b/src/engines/dashboard.js new file mode 100644 index 0000000..2180a6b --- /dev/null +++ b/src/engines/dashboard.js @@ -0,0 +1,174 @@ +/** + * @veritasacta/verify — local-first dashboard server. + * + * Spins up a tiny HTTP server on 127.0.0.1: that serves the + * static dashboard bundle (ecosystem/dashboard/) and, optionally, a + * JSON feed of a receipts directory at /api/receipts. + * + * Binds to loopback only. No TLS. No auth. No telemetry. If you run + * it, receipts stay on your machine. + * + * @module verify-cli/src/engines/dashboard + * @license Apache-2.0 + */ + +import { createServer } from 'node:http'; +import { + readFileSync, statSync, readdirSync, existsSync, +} from 'node:fs'; +import { dirname, extname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const MIME = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.ico': 'image/x-icon', + '.txt': 'text/plain; charset=utf-8', +}; + +/** + * @typedef {Object} DashboardOptions + * @property {number} [port=3847] + * @property {string} [bind='127.0.0.1'] + * @property {string} [receiptsDir] + * @property {string} [root] Override the static-bundle root. + */ + +/** + * Start the dashboard HTTP server. + * + * @param {DashboardOptions} opts + * @returns {Promise<{url: string, close: () => Promise}>} + */ +export async function startDashboard(opts = {}) { + const port = opts.port || 3847; + const bind = opts.bind || '127.0.0.1'; + const root = resolve( + opts.root || + join(__dirname, '..', '..', 'ecosystem', 'dashboard') + ); + const receiptsDir = opts.receiptsDir ? resolve(opts.receiptsDir) : null; + + if (!existsSync(root)) { + throw new Error(`dashboard root not found: ${root}`); + } + + const server = createServer((req, res) => { + try { + handleRequest(req, res, { root, receiptsDir }); + } catch (err) { + res.statusCode = 500; + res.end(`Internal error: ${err.message}`); + } + }); + + await new Promise((resolveStart, rejectStart) => { + server.once('error', rejectStart); + server.listen(port, bind, () => resolveStart()); + }); + + const url = `http://${bind}:${port}/`; + return { + url, + close: () => new Promise((r) => server.close(() => r())), + }; +} + +function handleRequest(req, res, ctx) { + const { root, receiptsDir } = ctx; + + // Loopback-only: reject non-localhost Host headers as a defense in + // depth against DNS-rebinding attacks that could surface receipts + // to a third-party origin. + const host = (req.headers.host || '').split(':')[0]; + if (host && host !== '127.0.0.1' && host !== 'localhost' && host !== '[::1]') { + res.statusCode = 403; + res.setHeader('content-type', 'text/plain; charset=utf-8'); + res.end('Dashboard refuses non-loopback Host header (DNS rebinding defense).'); + return; + } + + let urlPath; + try { + urlPath = decodeURIComponent(new URL(req.url, 'http://x').pathname); + } catch { + res.statusCode = 400; + res.end('bad_url'); + return; + } + + // JSON API: receipts feed + if (urlPath === '/api/receipts') { + if (!receiptsDir) { + res.statusCode = 404; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ error: 'no_receipts_dir_configured' })); + return; + } + try { + const list = readReceiptsDir(receiptsDir); + res.statusCode = 200; + res.setHeader('content-type', 'application/json; charset=utf-8'); + res.setHeader('cache-control', 'no-store'); + res.end(JSON.stringify({ receipts: list })); + } catch (err) { + res.statusCode = 500; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ error: `cannot_read_receipts:${err.message}` })); + } + return; + } + + // Static files + const filename = urlPath === '/' ? 'index.html' : urlPath.replace(/^\//, ''); + const fullPath = resolve(join(root, filename)); + + // Path-traversal defense: the resolved file MUST live under root. + if (!fullPath.startsWith(root + '/') && fullPath !== root) { + res.statusCode = 403; + res.end('path_traversal_refused'); + return; + } + + let st; + try { st = statSync(fullPath); } catch { + res.statusCode = 404; + res.setHeader('content-type', 'text/plain; charset=utf-8'); + res.end('not_found'); + return; + } + if (!st.isFile()) { + res.statusCode = 404; + res.end('not_a_file'); + return; + } + const mime = MIME[extname(fullPath).toLowerCase()] || 'application/octet-stream'; + res.statusCode = 200; + res.setHeader('content-type', mime); + res.setHeader('cache-control', 'no-store'); + res.end(readFileSync(fullPath)); +} + +function readReceiptsDir(dir) { + const out = []; + const entries = readdirSync(dir); + for (const entry of entries) { + if (!entry.endsWith('.json')) continue; + const p = join(dir, entry); + let st; + try { st = statSync(p); } catch { continue; } + if (!st.isFile()) continue; + try { + const parsed = JSON.parse(readFileSync(p, 'utf-8')); + if (parsed && parsed.payload && parsed.signature) out.push(parsed); + } catch { /* skip */ } + } + return out; +} diff --git a/src/engines/delegation.js b/src/engines/delegation.js new file mode 100644 index 0000000..0deed2f --- /dev/null +++ b/src/engines/delegation.js @@ -0,0 +1,433 @@ +/** + * @veritasacta/verify — delegation chain engine (AIP-0006). + * + * Walks a chain of `delegation` receipts back to a trust-anchor root, + * validating signatures, expiry, scope-subset, and max_depth at each + * hop. An action receipt's `authorization.delegation_chain` references + * delegations by their receipt_hash; this engine resolves the chain + * from a caller-supplied map and returns a structured verdict. + * + * This engine does NOT verify the action receipt's own signature — + * that is the caller's responsibility (typically via the Ed25519 + * receipt engine). Delegation verification adds the "who authorized + * this" layer on top of "who signed this". + * + * @module verify-cli/src/engines/delegation + * @license Apache-2.0 + */ + +import { createHash, verify as cryptoVerify, createPublicKey } from 'node:crypto'; +import { canonicalize } from '../util/canonical.js'; + +/** + * @typedef {Object} DelegationScope + * @property {string[]} [tools] + * @property {string[]} [targets] + * @property {string[]} [resources] + * @property {number} [max_depth] + */ + +/** + * @typedef {Object} DelegationVerifyOptions + * @property {Object} actionReceipt Signed action receipt with + * `payload.authorization.delegation_chain`. + * @property {Object} delegations Map: base64url receipt_hash → delegation envelope. + * @property {Object} trustAnchors Map: kid → raw Ed25519 public-key hex (64 chars). + * @property {Date} [now=new Date()] Current time for expiry checks. + * @property {number} [clockSkewSec=5] Allowed clock skew in seconds. + */ + +/** + * @typedef {Object} DelegationVerifyResult + * @property {boolean} valid + * @property {string} [error] + * @property {number} depth Number of delegations in the chain. + * @property {Array<{hash: string, delegator_kid: string, delegate_kid: string, scope: DelegationScope, expires_at: string, ok: boolean, reason?: string}>} chain + * @property {string} [trust_anchor_kid] The root's delegator_kid that was validated. + * @property {Object} [leaf_scope] The resolved scope at the leaf. + * @property {boolean} [action_in_scope] + */ + +/** + * Verify a delegation chain backing an action receipt. + * + * @param {DelegationVerifyOptions} opts + * @returns {DelegationVerifyResult} + */ +export function verifyDelegationChain(opts) { + const { + actionReceipt, + delegations, + trustAnchors, + now = new Date(), + clockSkewSec = 5, + } = opts; + + const auth = + actionReceipt && actionReceipt.payload && actionReceipt.payload.authorization; + if (!auth || !Array.isArray(auth.delegation_chain) || auth.delegation_chain.length === 0) { + return { + valid: false, + error: 'missing_delegation_chain', + depth: 0, + chain: [], + }; + } + + const chainHashes = auth.delegation_chain; // leaf-first + const resolved = []; + for (const h of chainHashes) { + const d = delegations[h]; + if (!d) { + return { + valid: false, + error: `unresolvable_delegation:${h.slice(0, 12)}`, + depth: chainHashes.length, + chain: [], + }; + } + resolved.push({ hash: h, envelope: d }); + } + + // Basic shape check: each resolved envelope must be a delegation. + for (let i = 0; i < resolved.length; i++) { + const p = resolved[i].envelope.payload || {}; + if (p.type !== 'delegation') { + return { + valid: false, + error: `chain_entry_${i}_not_delegation`, + depth: chainHashes.length, + chain: [], + }; + } + } + + const report = resolved.map((e) => ({ + hash: e.hash, + delegator_kid: e.envelope.payload.delegator_kid, + delegate_kid: e.envelope.payload.delegate_kid, + scope: e.envelope.payload.scope || {}, + expires_at: e.envelope.payload.expires_at, + ok: true, + })); + + const skewMs = clockSkewSec * 1000; + const nowMs = now.getTime(); + + // Pre-flight: the root's delegator_kid must be in trust anchors. This is + // the most common failure mode; surfacing it first gives a clearer error + // than cascading signature failures on intermediate hops. + const rootPayload = resolved[resolved.length - 1].envelope.payload; + if (rootPayload.delegator_kid !== rootPayload.delegate_kid) { + report[report.length - 1].ok = false; + report[report.length - 1].reason = 'root_not_self_signed'; + return { valid: false, error: 'root_must_be_self_signed', depth: chainHashes.length, chain: report }; + } + if (rootPayload.parent_delegation_hash) { + report[report.length - 1].ok = false; + report[report.length - 1].reason = 'root_has_parent'; + return { valid: false, error: 'root_must_not_have_parent_hash', depth: chainHashes.length, chain: report }; + } + if (!(trustAnchors && trustAnchors[rootPayload.delegator_kid])) { + report[report.length - 1].ok = false; + report[report.length - 1].reason = 'root_not_in_trust_anchors'; + return { + valid: false, + error: `root_kid_not_trusted:${rootPayload.delegator_kid}`, + depth: chainHashes.length, + chain: report, + }; + } + + // 1. Action signer matches leaf delegate. + const actionKid = (actionReceipt.signature && actionReceipt.signature.kid) || ''; + if (actionKid !== resolved[0].envelope.payload.delegate_kid) { + report[0].ok = false; + report[0].reason = 'action_signer_mismatch'; + return { + valid: false, + error: 'action_signer_not_leaf_delegate', + depth: chainHashes.length, + chain: report, + }; + } + + // 2. Each delegation: signature, expiry, hash-link to parent, scope ⊆ parent, + // max_depth sufficient. + for (let i = 0; i < resolved.length; i++) { + const node = resolved[i]; + const p = node.envelope.payload; + + // Expiry + const exp = Date.parse(p.expires_at); + if (!Number.isFinite(exp)) { + report[i].ok = false; + report[i].reason = 'bad_expires_at'; + return { valid: false, error: `bad_expires_at_at_depth_${i}`, depth: chainHashes.length, chain: report }; + } + if (nowMs + skewMs >= exp) { + report[i].ok = false; + report[i].reason = 'expired'; + return { valid: false, error: `delegation_expired_at_depth_${i}`, depth: chainHashes.length, chain: report }; + } + + // Signature: delegator signs the envelope. For non-root the delegator key + // must match the parent's delegate. For root it must be in trust anchors. + const isRoot = i === resolved.length - 1; + let pubHex; + if (isRoot) { + if (p.delegator_kid !== p.delegate_kid) { + report[i].ok = false; + report[i].reason = 'root_not_self_signed'; + return { valid: false, error: 'root_must_be_self_signed', depth: chainHashes.length, chain: report }; + } + if (p.parent_delegation_hash) { + report[i].ok = false; + report[i].reason = 'root_has_parent'; + return { valid: false, error: 'root_must_not_have_parent_hash', depth: chainHashes.length, chain: report }; + } + pubHex = trustAnchors[p.delegator_kid]; + if (!pubHex) { + report[i].ok = false; + report[i].reason = 'root_not_in_trust_anchors'; + return { + valid: false, + error: `root_kid_not_trusted:${p.delegator_kid}`, + depth: chainHashes.length, + chain: report, + }; + } + } else { + const parent = resolved[i + 1].envelope.payload; + // Hash-link integrity. + const parentHashComputed = receiptHash(resolved[i + 1].envelope); + if (parentHashComputed !== chainHashes[i + 1]) { + report[i].ok = false; + report[i].reason = 'parent_hash_mismatch'; + return { + valid: false, + error: `parent_hash_mismatch_at_depth_${i}`, + depth: chainHashes.length, + chain: report, + }; + } + if (p.parent_delegation_hash !== chainHashes[i + 1]) { + report[i].ok = false; + report[i].reason = 'parent_delegation_hash_mismatch'; + return { + valid: false, + error: `parent_delegation_hash_mismatch_at_depth_${i}`, + depth: chainHashes.length, + chain: report, + }; + } + if (p.delegator_kid !== parent.delegate_kid) { + report[i].ok = false; + report[i].reason = 'delegator_not_parent_delegate'; + return { + valid: false, + error: `delegator_mismatch_at_depth_${i}`, + depth: chainHashes.length, + chain: report, + }; + } + // Scope subset. + const parentScope = parent.scope || {}; + const scope = p.scope || {}; + if (!scopeSubset(scope, parentScope)) { + report[i].ok = false; + report[i].reason = 'scope_not_subset'; + return { + valid: false, + error: `scope_widened_at_depth_${i}`, + depth: chainHashes.length, + chain: report, + }; + } + // max_depth: parent must have allowed at least this many further hops + // remaining. Depth counted from root; leaf is depth 0 from its own POV. + // We require: parentScope.max_depth >= (i). i=0 leaf: parent max_depth>=0. + if (Number.isFinite(parentScope.max_depth) && parentScope.max_depth < i) { + report[i].ok = false; + report[i].reason = 'max_depth_exceeded'; + return { + valid: false, + error: `max_depth_exceeded_at_depth_${i}`, + depth: chainHashes.length, + chain: report, + }; + } + // Non-root signer is the parent's delegate. + pubHex = resolveKidPubkey(parent.delegate_kid, resolved, trustAnchors); + if (!pubHex) { + report[i].ok = false; + report[i].reason = 'no_pubkey_for_delegator'; + return { + valid: false, + error: `no_pubkey_for_delegator_at_depth_${i}`, + depth: chainHashes.length, + chain: report, + }; + } + } + + // Signature check. + const sigOk = verifyReceiptSignature(node.envelope, pubHex); + if (!sigOk) { + report[i].ok = false; + report[i].reason = 'signature_invalid'; + return { + valid: false, + error: `signature_invalid_at_depth_${i}`, + depth: chainHashes.length, + chain: report, + }; + } + } + + // 3. Action is within leaf scope. + const leafScope = resolved[0].envelope.payload.scope || {}; + const actionInScope = actionWithinScope(actionReceipt.payload || {}, leafScope); + if (!actionInScope) { + return { + valid: false, + error: 'action_not_in_leaf_scope', + depth: chainHashes.length, + chain: report, + leaf_scope: leafScope, + action_in_scope: false, + }; + } + + return { + valid: true, + depth: chainHashes.length, + chain: report, + trust_anchor_kid: resolved[resolved.length - 1].envelope.payload.delegator_kid, + leaf_scope: leafScope, + action_in_scope: true, + }; +} + +// ───── Scope ⊆ ───── + +/** + * Return true iff `child ⊆ parent`. Semantics: + * - Each pattern in `child.tools` must be matched by some pattern in + * `parent.tools` (or parent.tools absent / contains "*"). + * - Likewise `targets`, `resources`. + * - `child.max_depth` MUST be ≤ `parent.max_depth - 1`. + */ +export function scopeSubset(child, parent) { + const checkList = (cs, ps) => { + if (cs == null) return true; // inherits + if (!Array.isArray(cs)) return false; + if (ps == null || ps.includes('*')) return true; + if (!Array.isArray(ps)) return false; + for (const cEntry of cs) { + const matched = ps.some((p) => patternMatches(p, cEntry)); + if (!matched) return false; + } + return true; + }; + if (!checkList(child.tools, parent.tools)) return false; + if (!checkList(child.targets, parent.targets)) return false; + if (!checkList(child.resources, parent.resources)) return false; + + if ( + Number.isFinite(child.max_depth) && + Number.isFinite(parent.max_depth) && + child.max_depth > parent.max_depth - 1 + ) { + return false; + } + return true; +} + +function patternMatches(parentPattern, candidate) { + if (parentPattern === '*') return true; + if (parentPattern === candidate) return true; + if (parentPattern.endsWith('*')) { + const pre = parentPattern.slice(0, -1); + return typeof candidate === 'string' && candidate.startsWith(pre); + } + return false; +} + +function actionWithinScope(actionPayload, scope) { + // Tool check + if (scope.tools) { + const a = actionPayload.action || actionPayload.tool_name || ''; + const ok = scope.tools.includes('*') || scope.tools.some((p) => patternMatches(p, a)); + if (!ok) return false; + } + // Target check (optional in action payload) + if (scope.targets) { + const t = actionPayload.target || ''; + if (t) { + const ok = scope.targets.includes('*') || scope.targets.some((p) => patternMatches(p, t)); + if (!ok) return false; + } + } + return true; +} + +// ───── Crypto helpers ───── + +function receiptHash(envelope) { + const bytes = canonicalize(envelope); + return createHash('sha256').update(bytes).digest('base64') + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +function verifyReceiptSignature(envelope, pubHex) { + if (!envelope || !envelope.signature || !envelope.signature.sig) return false; + if (typeof pubHex !== 'string' || pubHex.length !== 64) return false; + + // The signed bytes are the JCS canonicalization of the payload. + const payloadBytes = canonicalize(envelope.payload); + + const pubDer = Buffer.concat([ + Buffer.from('302a300506032b6570032100', 'hex'), + Buffer.from(pubHex, 'hex'), + ]); + const pub = createPublicKey({ key: pubDer, format: 'der', type: 'spki' }); + const sigBuf = decodeSignature(envelope.signature.sig); + if (!sigBuf) return false; + try { + return cryptoVerify(null, payloadBytes, pub, sigBuf); + } catch { + return false; + } +} + +function decodeSignature(str) { + if (typeof str !== 'string') return null; + if (/^[0-9a-fA-F]+$/.test(str) && str.length % 2 === 0) { + return Buffer.from(str, 'hex'); + } + try { + return Buffer.from(str, 'base64'); + } catch { + return null; + } +} + +/** + * Resolve a kid's public key. Order: + * 1. Verifier's trust anchors (caller-supplied map). + * 2. A delegation whose `delegate_kid == kid` AND whose payload + * carries a `delegate_public_key` — this binds the kid to a + * key inside the chain itself, so operators don't need a full + * trust-anchor map of every intermediate kid. + */ +function resolveKidPubkey(kid, resolvedChain, trustAnchors) { + if (trustAnchors && trustAnchors[kid]) return trustAnchors[kid]; + for (const entry of resolvedChain) { + if (entry.envelope.payload.delegate_kid === kid && + entry.envelope.payload.delegate_public_key) { + return entry.envelope.payload.delegate_public_key; + } + } + return null; +} diff --git a/src/engines/diff.js b/src/engines/diff.js new file mode 100644 index 0000000..c12a987 --- /dev/null +++ b/src/engines/diff.js @@ -0,0 +1,72 @@ +/** + * Receipt diff — structural comparison of two receipts. + * + * Reports which fields changed, which signatures remain valid, and + * whether chain linkage is preserved. Useful for debugging implementers + * and investigating tampering scenarios. + * + * @module verify-cli/src/engines/diff + * @license Apache-2.0 + */ + +import { canonicalize } from '../util/canonical.js'; +import { createHash } from 'node:crypto'; + +/** + * Compare two receipts and report field-level differences. + * + * @param {Object} a + * @param {Object} b + * @returns {Object} diff summary + */ +export function diffReceipts(a, b) { + const diff = { + added: [], + removed: [], + changed: [], + unchanged: [], + canonical_hash_a: null, + canonical_hash_b: null, + hash_equal: false, + signature_equal: false, + }; + + try { + diff.canonical_hash_a = createHash('sha256').update(canonicalize(a.payload || a), 'utf-8').digest('hex'); + diff.canonical_hash_b = createHash('sha256').update(canonicalize(b.payload || b), 'utf-8').digest('hex'); + diff.hash_equal = diff.canonical_hash_a === diff.canonical_hash_b; + } catch (e) { + diff.error = `canonicalization_failed: ${e.message}`; + return diff; + } + + const sigA = extractSig(a); + const sigB = extractSig(b); + diff.signature_equal = sigA === sigB; + + const payloadA = a.payload || a; + const payloadB = b.payload || b; + + const keysA = new Set(Object.keys(payloadA)); + const keysB = new Set(Object.keys(payloadB)); + + for (const k of keysA) { + if (!keysB.has(k)) diff.removed.push(k); + else if (JSON.stringify(payloadA[k]) !== JSON.stringify(payloadB[k])) { + diff.changed.push({ field: k, before: payloadA[k], after: payloadB[k] }); + } else { + diff.unchanged.push(k); + } + } + for (const k of keysB) { + if (!keysA.has(k)) diff.added.push(k); + } + + return diff; +} + +function extractSig(receipt) { + if (typeof receipt.signature === 'string') return receipt.signature; + if (receipt.signature?.sig) return receipt.signature.sig; + return null; +} diff --git a/src/engines/dsse.js b/src/engines/dsse.js new file mode 100644 index 0000000..c1ead1a --- /dev/null +++ b/src/engines/dsse.js @@ -0,0 +1,273 @@ +/** + * @veritasacta/verify — DSSE envelope wrap/unwrap (Sigstore compat) + * + * Produces and consumes Dead Simple Signing Envelopes (DSSE) per the + * in-toto DSSE spec (https://github.com/secure-systems-lab/dsse). + * + * Veritas Acta receipts are wrapped with payloadType + * `application/vnd.acta.receipt+json` so any DSSE-aware tool (cosign, + * slsa-verifier, gitlab-attestations, etc.) can see the envelope as a + * standard in-toto attestation. + * + * The signature inside DSSE is computed over the DSSE pre-authentication + * encoding (PAE), NOT the raw payload: + * + * PAE(type, body) = "DSSEv1" SP len(type) SP type SP len(body) SP body + * + * This is an intentional design choice by the DSSE spec so signatures + * bind to both the type AND the payload. + * + * This engine is verify-only at v0.5.2 (parse + validate); signing + * requires the operator's signing key and ships separately alongside + * the daemon. The verify path lets any Sigstore-produced DSSE envelope + * over our predicate types be checked offline by our verifier. + * + * @module verify-cli/src/engines/dsse + * @license Apache-2.0 + */ + +import { createHash, verify as cryptoVerify, createPublicKey } from 'node:crypto'; + +export const ACTA_RECEIPT_PAYLOAD_TYPE = 'application/vnd.acta.receipt+json'; +export const ACTA_KU_PAYLOAD_TYPE = 'application/vnd.acta.knowledge-unit+json'; +export const INTOTO_STATEMENT_TYPE = 'application/vnd.in-toto+json'; + +/** + * @typedef {Object} DSSEEnvelope + * @property {string} payloadType + * @property {string} payload base64-encoded payload bytes + * @property {Array<{keyid?: string, sig: string}>} signatures base64 sig + */ + +/** + * @typedef {Object} DSSEVerifyOptions + * @property {DSSEEnvelope} envelope + * @property {Object} trustAnchors kid → pubkey hex (Ed25519) + * @property {boolean} [allowIntoto=true] accept in-toto statement subjects + */ + +/** + * @typedef {Object} DSSEVerifyResult + * @property {boolean} valid + * @property {string} [error] + * @property {string} [payload_type] + * @property {Object} [payload] parsed JSON payload (best-effort) + * @property {Array<{keyid: string, kid_match: string|null, sig_valid: boolean}>} signatures + */ + +/** + * Build the DSSE pre-authentication encoding. + * + * PAE = "DSSEv1" SP len(type) SP type SP len(body) SP body + * + * where lengths are ASCII decimal and SP is a single space byte. + * + * @param {string} type + * @param {Buffer} body + * @returns {Buffer} + */ +export function dssePAE(type, body) { + const typeBytes = Buffer.from(type, 'utf-8'); + const header = Buffer.from( + `DSSEv1 ${typeBytes.length} ${type} ${body.length} `, + 'utf-8' + ); + return Buffer.concat([header, body]); +} + +/** + * Wrap an Acta receipt (or any JSON body) in a DSSE envelope. + * The caller supplies a pre-computed signature over dssePAE(). + * + * @param {Object} receipt JSON payload (receipt, KU, statement, …) + * @param {string} payloadType MIME-like identifier + * @param {Array<{keyid?: string, sigB64: string}>} signatures + * @returns {DSSEEnvelope} + */ +export function wrapDSSE(receipt, payloadType, signatures) { + const bytes = Buffer.from(JSON.stringify(receipt), 'utf-8'); + return { + payloadType, + payload: bytes.toString('base64'), + signatures: signatures.map((s) => ({ + ...(s.keyid ? { keyid: s.keyid } : {}), + sig: s.sigB64, + })), + }; +} + +/** + * Unwrap a DSSE envelope. Returns the payload JSON if parseable, + * otherwise a raw Buffer under `.rawBody`. + * + * @param {DSSEEnvelope} envelope + */ +export function unwrapDSSE(envelope) { + if (!envelope || typeof envelope !== 'object') { + return { ok: false, error: 'dsse_not_object' }; + } + if (!envelope.payloadType || !envelope.payload) { + return { ok: false, error: 'dsse_missing_required_fields' }; + } + let rawBody; + try { + rawBody = Buffer.from(envelope.payload, 'base64'); + } catch (err) { + return { ok: false, error: `dsse_payload_decode:${err.message}` }; + } + let parsed; + try { + parsed = JSON.parse(rawBody.toString('utf-8')); + } catch { + // Non-JSON payloads are allowed; we just can't structurally inspect. + parsed = undefined; + } + return { + ok: true, + payload_type: envelope.payloadType, + payload: parsed, + rawBody, + }; +} + +/** + * Verify a DSSE envelope's signatures against a trust-anchor map. + * + * @param {DSSEVerifyOptions} opts + * @returns {DSSEVerifyResult} + */ +export function verifyDSSE(opts) { + const { envelope, trustAnchors, allowIntoto = true } = opts; + const unwrap = unwrapDSSE(envelope); + if (!unwrap.ok) return { valid: false, error: unwrap.error, signatures: [] }; + + if (!Array.isArray(envelope.signatures) || envelope.signatures.length === 0) { + return { + valid: false, + error: 'dsse_no_signatures', + payload_type: unwrap.payload_type, + payload: unwrap.payload, + signatures: [], + }; + } + + if (!isAcceptedType(unwrap.payload_type, allowIntoto)) { + return { + valid: false, + error: `dsse_unsupported_payload_type:${unwrap.payload_type}`, + payload_type: unwrap.payload_type, + payload: unwrap.payload, + signatures: [], + }; + } + + const pae = dssePAE(envelope.payloadType, unwrap.rawBody); + + const results = []; + let anyValid = false; + + for (const entry of envelope.signatures) { + const keyid = entry.keyid || ''; + const hex = trustAnchors && (trustAnchors[keyid] || trustAnchors['*']); + if (!hex) { + results.push({ keyid, kid_match: null, sig_valid: false, reason: 'no_trust_anchor' }); + continue; + } + const pub = pubkeyFromHex(hex); + const sigBuf = safeBase64(entry.sig); + if (!sigBuf) { + results.push({ keyid, kid_match: keyid, sig_valid: false, reason: 'bad_sig_encoding' }); + continue; + } + let ok = false; + try { + ok = cryptoVerify(null, pae, pub, sigBuf); + } catch { + ok = false; + } + results.push({ keyid, kid_match: keyid, sig_valid: ok }); + if (ok) anyValid = true; + } + + return { + valid: anyValid, + error: anyValid ? undefined : 'dsse_signatures_invalid', + payload_type: unwrap.payload_type, + payload: unwrap.payload, + signatures: results, + }; +} + +/** + * Convenience: verify DSSE that wraps an Acta receipt, AND recursively + * re-run AIP-0001 verification on the unwrapped payload if a verifier + * callback is supplied. + * + * @param {Object} opts + * @param {DSSEEnvelope} opts.envelope + * @param {Object} opts.trustAnchors + * @param {(receipt: Object) => Promise<{valid: boolean}>} [opts.verifyInnerReceipt] + * @returns {Promise} + */ +export async function verifyDSSEWrappedReceipt(opts) { + const outer = verifyDSSE({ + envelope: opts.envelope, + trustAnchors: opts.trustAnchors, + }); + if (!outer.valid) return { ...outer, inner_valid: null }; + + if (opts.verifyInnerReceipt && outer.payload) { + try { + const inner = await opts.verifyInnerReceipt(outer.payload); + return { ...outer, inner_valid: inner.valid, inner }; + } catch (err) { + return { ...outer, inner_valid: false, inner: { error: err.message } }; + } + } + return { ...outer, inner_valid: null }; +} + +// ───── Helpers ───── + +function isAcceptedType(type, allowIntoto) { + if (type === ACTA_RECEIPT_PAYLOAD_TYPE) return true; + if (type === ACTA_KU_PAYLOAD_TYPE) return true; + if (allowIntoto && type === INTOTO_STATEMENT_TYPE) return true; + return false; +} + +function pubkeyFromHex(hex) { + // Ed25519 raw-32 → SPKI DER. + if (typeof hex !== 'string' || hex.length !== 64) { + throw new Error('pubkey_must_be_32_byte_hex'); + } + const raw = Buffer.from(hex, 'hex'); + const prefix = Buffer.from('302a300506032b6570032100', 'hex'); + const der = Buffer.concat([prefix, raw]); + return createPublicKey({ key: der, format: 'der', type: 'spki' }); +} + +function safeBase64(b64) { + if (typeof b64 !== 'string') return null; + try { + return Buffer.from(b64, 'base64'); + } catch { + return null; + } +} + +/** + * Compute the SHA-256 digest of a DSSE envelope's canonicalized + * encoding. Useful for anchoring in Rekor / transparency logs. + * + * @param {DSSEEnvelope} envelope + * @returns {string} hex digest + */ +export function dsseDigest(envelope) { + const body = Buffer.from(JSON.stringify({ + payloadType: envelope.payloadType, + payload: envelope.payload, + signatures: envelope.signatures || [], + }), 'utf-8'); + return createHash('sha256').update(body).digest('hex'); +} diff --git a/src/engines/ed25519-receipt.js b/src/engines/ed25519-receipt.js new file mode 100644 index 0000000..f091f72 --- /dev/null +++ b/src/engines/ed25519-receipt.js @@ -0,0 +1,309 @@ +/** + * Ed25519 signed-receipt verification engine. + * + * Verifies receipts and bundles conforming to + * draft-farley-acta-signed-receipts-02/03: + * - Passport envelope: { payload, signature: { alg, kid, sig } } + * - v2 structured: { v: 2, kid, issuer, issued_at, payload, signature } + * - v1 flat artifact: { v: 1, type, timestamp, ..., signature } + * + * Verification path is pure Ed25519 + JCS canonicalization with AIP-0001 + * ASCII-only keys. No network calls during verification (unless the + * caller opts into --jwks, which is fetched by the caller and passed + * in as a resolved key). + * + * Embedded keys in the payload are rejected by default (v0.4.0+). An + * escape hatch (allowEmbeddedKey: true) exists for one release cycle + * to ease migration; it is deprecated and will be removed in v0.6. + * + * References: + * - RFC 8032 (EdDSA / Ed25519) + * - RFC 8785 (JCS) + * - AIP-0001 (receipt format, ASCII-only keys) + * - AIP-0002 (selective disclosure — verified in separate engine) + * - draft-farley-acta-signed-receipts-03 + * + * @module verify-cli/src/engines/ed25519-receipt + * @license Apache-2.0 + */ + +import { verifyArtifact } from '@veritasacta/artifacts'; +import { canonicalize, canonicalHash } from '../util/canonical.js'; +import { hexToBytes, bytesToHex } from '../util/hex.js'; + +const EMBEDDED_KEY_FIELDS = ['public_key', 'verification_key', 'verification_jwk']; + +/** + * @typedef {Object} VerifyReceiptOptions + * @property {string} [publicKey] hex-encoded Ed25519 public key (64 chars) + * @property {boolean} [allowEmbeddedKey] pre-0.4.0 compat; deprecated + * @property {string} [mode] detected or forced mode + */ + +/** + * @typedef {Object} VerifyReceiptResult + * @property {boolean} valid + * @property {string} [error] + * @property {string} format + * @property {string} [type] + * @property {string} [kid] + * @property {string} [issuer] + * @property {string} [keySource] 'provided' | 'jwks' | 'bundle' | 'embedded-deprecated' + * @property {string} [publicKey] + * @property {string} [algorithm] + * @property {Object} [payloadFields] subset of payload fields surfaced for output + */ + +/** + * Verify a single Ed25519 receipt. + * + * @param {Object} input parsed receipt JSON + * @param {string} detectedMode one of 'ed25519-receipt-v1', 'ed25519-receipt-v2', 'ed25519-passport' + * @param {VerifyReceiptOptions} [opts] + * @returns {Promise} + */ +export async function verifyReceipt(input, detectedMode, opts = {}) { + let format = 'v1'; + let kid = null; + let issuer = null; + let algorithm = 'ed25519'; + let artifactToVerify = input; + + if (detectedMode === 'ed25519-passport') { + format = 'passport'; + kid = input.signature.kid; + algorithm = input.signature.alg || 'EdDSA'; + // Passport -> flat artifact for verification + artifactToVerify = { ...input.payload, signature: input.signature.sig }; + } else if (detectedMode === 'ed25519-receipt-v2') { + format = 'v2'; + kid = input.kid; + issuer = input.issuer; + algorithm = input.algorithm || 'ed25519'; + } else if (detectedMode === 'ed25519-receipt-v1') { + format = 'v1'; + } + + // Algorithm agility: detect hybrid post-quantum variants early. + if (typeof algorithm === 'string' && algorithm.toLowerCase().includes('ml-dsa')) { + return { + valid: false, + error: 'unsupported_algorithm', + format, + type: input.type || input.payload?.type, + kid, + issuer, + algorithm, + }; + } + + // Key resolution with embedded-key rejection. + let publicKey = opts.publicKey; + let keySource = publicKey ? 'provided' : null; + + if (!publicKey) { + const payload = input.payload || input; + const embeddedFields = EMBEDDED_KEY_FIELDS.filter((f) => + typeof payload[f] === 'string' || (payload[f] && typeof payload[f] === 'object'), + ); + + if (embeddedFields.length > 0) { + if (opts.allowEmbeddedKey) { + // Deprecated path; extract the first embedded key form we recognize. + if (typeof payload.public_key === 'string' && payload.public_key.length === 64) { + publicKey = payload.public_key; + keySource = 'embedded-deprecated'; + } else if (typeof payload.verification_key === 'string' && payload.verification_key.length === 64) { + publicKey = payload.verification_key; + keySource = 'embedded-deprecated'; + } else if (payload.verification_jwk?.x) { + // base64url(raw) -> hex + const raw = Buffer.from(payload.verification_jwk.x.replace(/-/g, '+').replace(/_/g, '/'), 'base64'); + publicKey = raw.toString('hex'); + keySource = 'embedded-deprecated'; + } + } else { + return { + valid: false, + error: 'embedded_key_rejected', + format, + type: payload.type, + kid, + issuer, + algorithm, + }; + } + } + } + + if (!publicKey) { + return { + valid: false, + error: 'no_public_key', + format, + type: input.type || input.payload?.type, + kid, + issuer, + algorithm, + }; + } + + // Delegate the actual cryptographic verification to @veritasacta/artifacts. + // This keeps the low-level crypto in one audited module. + let result; + try { + result = verifyArtifact(artifactToVerify, publicKey); + } catch (e) { + return { + valid: false, + error: 'malformed_hex', + format, + type: input.type || input.payload?.type, + kid, + issuer, + algorithm, + detail: e.message, + }; + } + + const payload = input.payload || input; + const payloadFields = collectPayloadFields(payload); + + // Normalize upstream error codes to our canonical registry. + let normalizedError; + if (!result.valid) { + const upstream = (result.error || '').toLowerCase(); + if (upstream === 'verification_error' || upstream === 'sig_invalid' || upstream.includes('signature')) { + normalizedError = 'invalid_signature'; + } else if (upstream.includes('missing')) { + normalizedError = 'missing_signature'; + } else { + normalizedError = 'invalid_signature'; // default for any verification failure + } + } + + return { + valid: Boolean(result.valid), + error: normalizedError, + format, + type: payload.type || input.type, + kid, + issuer, + keySource, + publicKey, + algorithm, + payloadFields, + hash: result.hash, + }; +} + +/** + * Collect the subset of payload fields the verifier surfaces in output. + * This is a read-only projection; the verifier does not interpret these + * fields (they are spec-level metadata). + * + * Fields harvested (all optional, per draft-03): + * disclosure_mode, holder_binding, annex_hash, attestation_mode, + * anchor_uri, decision, nullifier, scope, compliance_credit_ref, + * transport_hint, verifier_salt_kid, policy_id, policy_hash, + * skill_version_hash, delegation_chain_root + * + * @param {Object} payload + * @returns {Object} + */ +function collectPayloadFields(payload) { + const fields = {}; + const keys = [ + 'decision', 'policy_id', 'policy_hash', + 'disclosure_mode', 'holder_binding', 'annex_hash', 'attestation_mode', + 'anchor_uri', 'nullifier', 'scope', 'compliance_credit_ref', + 'transport_hint', 'verifier_salt_kid', + 'skill_version_hash', 'parent_skill_version_hash', 'delegation_chain_root', + 'tool_name', 'agent_id', 'session_id', 'sequence', 'spec', + 'previousReceiptHash', + ]; + for (const k of keys) { + if (payload[k] !== undefined && payload[k] !== null) fields[k] = payload[k]; + } + return fields; +} + +/** + * Verify an audit bundle: multiple receipts + a verification block with + * signing_keys. + * + * @param {Object} bundle + * @param {VerifyReceiptOptions} [opts] + * @returns {Promise} + */ +export async function verifyBundle(bundle, opts = {}) { + const results = { + valid: true, + total: 0, + passed: 0, + failed: 0, + errors: [], + receipts: [], + }; + + if (!Array.isArray(bundle.receipts)) { + return { valid: false, error: 'unknown_format', detail: 'bundle missing receipts array' }; + } + + // Build key lookup from bundle.verification.signing_keys (JWK -> hex). + const keyMap = new Map(); + if (Array.isArray(bundle.verification?.signing_keys)) { + for (const jwk of bundle.verification.signing_keys) { + if (jwk.kty === 'OKP' && jwk.crv === 'Ed25519' && jwk.x) { + const raw = Buffer.from(jwk.x.replace(/-/g, '+').replace(/_/g, '/'), 'base64'); + const hex = bytesToHex(new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength)); + if (jwk.kid) keyMap.set(jwk.kid, hex); + if (!keyMap.has('default')) keyMap.set('default', hex); + } + } + } + + for (const receipt of bundle.receipts) { + results.total++; + let key = opts.publicKey; + if (!key) { + const receiptKid = receipt.kid || receipt.signature?.kid; + key = keyMap.get(receiptKid) || keyMap.get('default'); + } + + // Detect format of this sub-receipt and recurse. + const { detectFormat } = await import('../detect.js'); + const detected = detectFormat(receipt); + const subOpts = { ...opts, publicKey: key }; + const r = await verifyReceipt(receipt, detected.mode, subOpts); + results.receipts.push(r); + if (r.valid) { + results.passed++; + } else { + results.failed++; + results.valid = false; + results.errors.push(`Receipt ${results.total}: ${r.error}`); + } + } + + // Verify anchors if present. + if (Array.isArray(bundle.anchors)) { + for (const anchor of bundle.anchors) { + results.total++; + const key = opts.publicKey || keyMap.get(anchor.kid) || keyMap.get('default'); + const { detectFormat } = await import('../detect.js'); + const detected = detectFormat(anchor); + const r = await verifyReceipt(anchor, detected.mode, { ...opts, publicKey: key }); + results.receipts.push(r); + if (r.valid) { + results.passed++; + } else { + results.failed++; + results.valid = false; + results.errors.push(`Anchor: ${r.error}`); + } + } + } + + return results; +} diff --git a/src/engines/init.js b/src/engines/init.js new file mode 100644 index 0000000..06d5af0 --- /dev/null +++ b/src/engines/init.js @@ -0,0 +1,408 @@ +/** + * Init wizard — framework auto-detection + zero-config onboarding. + * + * `npx @veritasacta/verify init` inspects the current directory, + * detects the agent framework in use, generates Ed25519 signing keys, + * creates a starter config, and emits a welcome canonical attestation. + * + * Detection precedence (first match wins): + * 1. Explicit `--framework ` override + * 2. `.claude/settings.json` exists → Claude Code (MCP) + * 3. `package.json` contains `@anthropic-ai/claude-agent-sdk` → Claude Agent SDK + * 4. `package.json` contains `@langchain/*` → LangChain + * 5. `package.json` contains `langgraph` → LangGraph + * 6. `package.json` contains `@openai/agents` → OpenAI Agents SDK + * 7. `package.json` contains `ai` (Vercel AI SDK) + * 8. `pyproject.toml` / `requirements.txt` contains `google-adk` → ADK + * 9. `pyproject.toml` / `requirements.txt` contains `crewai` → CrewAI + * 10. `pyproject.toml` / `requirements.txt` contains `pydantic-ai` → Pydantic AI + * 11. `pyproject.toml` / `requirements.txt` contains `autogen-agentchat` → AutoGen + * 12. `pyproject.toml` / `requirements.txt` contains `smolagents` → Smolagents + * 13. `pyproject.toml` / `requirements.txt` contains `langchain` → LangChain (Python) + * 14. Fallback: generic (prints manual instructions) + * + * Output: creates `.veritasacta/` directory with: + * - `attester.json` (signing key, private; .gitignore'd) + * - `config.json` (framework + adapter selection) + * - `receipts/` (default receipt output directory) + * - `welcome-attestation.json` (canonical attestation of this init run) + * + * @module verify-cli/src/engines/init + * @license Apache-2.0 + */ + +import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync } from 'node:fs'; +import { join } from 'node:path'; + +/** + * @typedef {Object} FrameworkDetection + * @property {string} framework canonical name + * @property {string} adapter npm/PyPI package to install + * @property {string} language 'javascript' | 'python' | 'rust' | 'unknown' + * @property {string[]} signals what we detected + * @property {string} setupType 'mcp-hook' | 'middleware' | 'plugin' | 'sdk' | 'manual' + */ + +/** @type {FrameworkDetection[]} */ +const DETECTION_RULES = [ + // Claude Code (MCP hooks) + { + framework: 'claude-code', + language: 'javascript', + adapter: 'protect-mcp', + setupType: 'mcp-hook', + detectFn: (ctx) => ctx.hasFile('.claude/settings.json') || ctx.hasFile('.claude/settings.local.json'), + reason: '.claude/settings.json detected', + }, + // Claude Agent SDK + { + framework: 'claude-agent-sdk', + language: 'javascript', + adapter: '@scopeblind/passport', + setupType: 'sdk', + detectFn: (ctx) => ctx.packageJsonHas('@anthropic-ai/claude-agent-sdk'), + reason: '@anthropic-ai/claude-agent-sdk in package.json', + }, + // Google ADK (Python) + { + framework: 'google-adk', + language: 'python', + adapter: 'protect-mcp-adk', + setupType: 'plugin', + detectFn: (ctx) => ctx.pythonDepHas('google-adk'), + reason: 'google-adk in Python dependencies', + }, + // CrewAI (Python) + { + framework: 'crewai', + language: 'python', + adapter: 'veritasacta-crewai', + setupType: 'middleware', + detectFn: (ctx) => ctx.pythonDepHas('crewai'), + reason: 'crewai in Python dependencies', + templatePath: 'ecosystem/adapters/crewai', + }, + // Pydantic AI + { + framework: 'pydantic-ai', + language: 'python', + adapter: 'veritasacta-pydantic-ai', + setupType: 'middleware', + detectFn: (ctx) => ctx.pythonDepHas('pydantic-ai'), + reason: 'pydantic-ai in Python dependencies', + templatePath: 'ecosystem/adapters/pydantic-ai', + }, + // AutoGen + { + framework: 'autogen', + language: 'python', + adapter: 'veritasacta-autogen', + setupType: 'middleware', + detectFn: (ctx) => ctx.pythonDepHas('autogen-agentchat') || ctx.pythonDepHas('pyautogen'), + reason: 'autogen in Python dependencies', + templatePath: 'ecosystem/adapters/autogen', + }, + // Smolagents + { + framework: 'smolagents', + language: 'python', + adapter: 'veritasacta-smolagents', + setupType: 'middleware', + detectFn: (ctx) => ctx.pythonDepHas('smolagents'), + reason: 'smolagents in Python dependencies', + templatePath: 'ecosystem/adapters/smolagents', + }, + // LangChain JS + { + framework: 'langchain-js', + language: 'javascript', + adapter: '@veritasacta/langchain', + setupType: 'middleware', + detectFn: (ctx) => ctx.packageJsonMatch(/^@langchain\//), + reason: '@langchain/* in package.json', + templatePath: 'ecosystem/adapters/langchain', + }, + // LangChain Python + { + framework: 'langchain-py', + language: 'python', + adapter: 'veritasacta-langchain', + setupType: 'middleware', + detectFn: (ctx) => ctx.pythonDepHas('langchain') || ctx.pythonDepHas('langchain-core'), + reason: 'langchain in Python dependencies', + templatePath: 'ecosystem/adapters/langchain', + }, + // LangGraph JS + { + framework: 'langgraph-js', + language: 'javascript', + adapter: '@veritasacta/langgraph', + setupType: 'middleware', + detectFn: (ctx) => ctx.packageJsonHas('@langchain/langgraph'), + reason: '@langchain/langgraph in package.json', + templatePath: 'ecosystem/adapters/langgraph', + }, + // LangGraph Python + { + framework: 'langgraph-py', + language: 'python', + adapter: 'veritasacta-langgraph', + setupType: 'middleware', + detectFn: (ctx) => ctx.pythonDepHas('langgraph'), + reason: 'langgraph in Python dependencies', + templatePath: 'ecosystem/adapters/langgraph', + }, + // OpenAI Agents SDK + { + framework: 'openai-agents', + language: 'javascript', + adapter: '@veritasacta/openai-agents', + setupType: 'middleware', + detectFn: (ctx) => ctx.packageJsonHas('@openai/agents') || ctx.pythonDepHas('openai-agents'), + reason: '@openai/agents or openai-agents detected', + templatePath: 'ecosystem/adapters/openai-agents', + }, + // Vercel AI SDK + { + framework: 'vercel-ai', + language: 'javascript', + adapter: '@veritasacta/vercel-ai', + setupType: 'middleware', + detectFn: (ctx) => ctx.packageJsonHas('ai') && !ctx.packageJsonHas('openai'), + reason: 'Vercel AI SDK (`ai` package) in package.json', + templatePath: 'ecosystem/adapters/vercel-ai', + }, +]; + +function buildDetectionContext(cwd) { + let pkg = null; + let pyproject = null; + let requirements = null; + + try { pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf-8')); } catch {} + try { pyproject = readFileSync(join(cwd, 'pyproject.toml'), 'utf-8'); } catch {} + try { requirements = readFileSync(join(cwd, 'requirements.txt'), 'utf-8'); } catch {} + + return { + cwd, + pkg, + pyproject, + requirements, + hasFile(rel) { return existsSync(join(cwd, rel)); }, + packageJsonHas(name) { + if (!pkg) return false; + return Boolean( + pkg.dependencies?.[name] + || pkg.devDependencies?.[name] + || pkg.peerDependencies?.[name], + ); + }, + packageJsonMatch(regex) { + if (!pkg) return false; + const all = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}), ...(pkg.peerDependencies || {}) }; + return Object.keys(all).some((k) => regex.test(k)); + }, + pythonDepHas(name) { + if (pyproject && pyproject.includes(name)) return true; + if (requirements && requirements.split('\n').some((line) => line.trim().startsWith(name))) return true; + return false; + }, + }; +} + +/** + * Detect the framework in use. + * + * @param {string} cwd + * @param {string} [override] user-supplied --framework value + * @returns {FrameworkDetection | null} + */ +export function detectFramework(cwd, override) { + const ctx = buildDetectionContext(cwd); + + if (override) { + const match = DETECTION_RULES.find((r) => r.framework === override); + if (match) return { ...match, signals: [`--framework ${override}`] }; + } + + for (const rule of DETECTION_RULES) { + if (rule.detectFn(ctx)) { + return { ...rule, signals: [rule.reason] }; + } + } + return null; +} + +/** + * Generate a fresh Ed25519 signing key for the project. + */ +async function generateProjectKey(keyPath) { + const { generateKeyPairSync } = await import('node:crypto'); + const { publicKey, privateKey } = generateKeyPairSync('ed25519'); + const pubRaw = publicKey.export({ type: 'spki', format: 'der' }); + const privRaw = privateKey.export({ type: 'pkcs8', format: 'der' }); + const pubHex = pubRaw.subarray(pubRaw.length - 32).toString('hex'); + const privHex = privRaw.toString('hex'); + const kid = `project:${pubHex.slice(0, 12)}`; + + writeFileSync( + keyPath, + JSON.stringify({ + kid, + pubHex, + privateDer: privHex, + created_at: new Date().toISOString(), + }, null, 2), + { mode: 0o600 }, + ); + return { kid, pubHex }; +} + +/** + * Run the init wizard. + * + * @param {Object} opts + * @param {string} [opts.cwd=process.cwd()] + * @param {string} [opts.framework] --framework override + * @param {string} [opts.org] --attest-org value for welcome attestation + * @param {boolean} [opts.force=false] overwrite existing .veritasacta/ + * @returns {Promise} + */ +export async function runInit(opts = {}) { + const cwd = opts.cwd || process.cwd(); + const vaDir = join(cwd, '.veritasacta'); + const receiptsDir = join(vaDir, 'receipts'); + + if (existsSync(vaDir) && !opts.force) { + return { + status: 'exists', + message: `.veritasacta/ already exists at ${vaDir}. Pass --force to overwrite.`, + }; + } + + mkdirSync(vaDir, { recursive: true }); + mkdirSync(receiptsDir, { recursive: true }); + + // Detect framework + const detection = detectFramework(cwd, opts.framework); + + // Generate project key + const keyPath = join(vaDir, 'attester.json'); + const key = await generateProjectKey(keyPath); + + // Write config + const config = { + veritasacta: { + version: '0.5.0', + created_at: new Date().toISOString(), + org: opts.org || null, + }, + framework: detection ? { + name: detection.framework, + language: detection.language, + adapter: detection.adapter, + setup_type: detection.setupType, + signals: detection.signals, + } : { + name: 'unknown', + language: 'unknown', + adapter: null, + setup_type: 'manual', + signals: [], + }, + receipts: { + directory: './.veritasacta/receipts', + jsonl_log: './.veritasacta/receipts.jsonl', + audit_log: './.veritasacta/audit.jsonl', + }, + signer: { + kid: key.kid, + pubkey: key.pubHex, + key_file: './.veritasacta/attester.json', + }, + }; + writeFileSync(join(vaDir, 'config.json'), JSON.stringify(config, null, 2)); + + // Write gitignore entry + const gitignorePath = join(cwd, '.gitignore'); + const gitignoreEntry = '\n# Veritas Acta — do NOT commit signing keys\n.veritasacta/attester.json\n.veritasacta/receipts.jsonl\n.veritasacta/audit.jsonl\n'; + try { + const existing = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf-8') : ''; + if (!existing.includes('.veritasacta/attester.json')) { + appendFileSync(gitignorePath, gitignoreEntry); + } + } catch {} + + return { + status: 'initialized', + cwd, + vaDir, + detection, + config, + key, + }; +} + +/** + * Build framework-specific next-step instructions. + */ +export function buildNextSteps(detection, config) { + if (!detection) { + return [ + 'No framework detected automatically. Manual setup:', + ' 1. Install the @veritasacta/verify verifier: npm install -g @veritasacta/verify', + ' 2. Use the SDK: npm install @veritasacta/sdk (JS) or pip install veritasacta-sdk (Python)', + ' 3. Import the signer and call signer.sign_tool_call(...) at each decision point.', + ' 4. Verify receipts: npx @veritasacta/verify .veritasacta/receipts/*.json', + ]; + } + + const common = [ + `Framework detected: ${detection.framework} (${detection.language})`, + `Adapter recommended: ${detection.adapter}`, + ]; + + switch (detection.setupType) { + case 'mcp-hook': + return [...common, + '', + 'Setup:', + ' 1. npx protect-mcp init-hooks', + ' 2. Your .claude/settings.json now wires receipt-signing hooks.', + ' 3. Open Claude Code in this project — tool calls produce receipts in .veritasacta/receipts/.', + '', + 'Verify:', + ' npx @veritasacta/verify .veritasacta/receipts/*.json --key ' + (config.signer.pubkey || ''), + ]; + case 'plugin': + return [...common, + '', + 'Setup:', + ' 1. pip install ' + detection.adapter, + ' 2. Add ReceiptPlugin to your Agent(plugins=[...]) list.', + ' 3. See: https://github.com/scopeblind/' + detection.adapter, + '', + 'Verify:', + ' npx @veritasacta/verify .veritasacta/receipts.jsonl', + ]; + case 'middleware': + return [...common, + '', + 'Setup:', + ' Install: ' + (detection.language === 'python' ? 'pip install' : 'npm install') + ' ' + detection.adapter, + ' Wrap your agent with the adapter as shown in the adapter README.', + '', + 'Verify:', + ' npx @veritasacta/verify .veritasacta/receipts/*.json --key ' + (config.signer.pubkey || ''), + ]; + case 'sdk': + return [...common, + '', + 'Setup:', + ' npm install ' + detection.adapter, + ' Import signer and call signer.signDecision(...) before each tool call.', + ]; + default: + return common; + } +} diff --git a/src/engines/knowledge-unit.js b/src/engines/knowledge-unit.js new file mode 100644 index 0000000..ddaa6da --- /dev/null +++ b/src/engines/knowledge-unit.js @@ -0,0 +1,141 @@ +/** + * Knowledge Unit bundle verification engine. + * + * A Knowledge Unit (KU) is a self-contained, verifiable record of + * multi-model deliberation. Each model response has its own signed + * receipt; the KU aggregates those receipts with structured metadata + * (topic, rounds, consensus, dissent). + * + * This engine validates: + * - KU envelope structure (required fields per the JSON Schema) + * - Every embedded receipt individually (via Ed25519 engine) + * - Cross-references (receipt_hash aggregates match the chain heads) + * - Consensus / dissent accounting is internally consistent + * + * References: + * - draft-farley-acta-knowledge-units-00 (IETF) + * - specs/knowledge-unit.schema.json + * - specs/draft-farley-acta-knowledge-units-00.md + * + * @module verify-cli/src/engines/knowledge-unit + * @license Apache-2.0 + */ + +/** + * @typedef {Object} KuVerifyOptions + * @property {string} [publicKey] + */ + +/** + * @typedef {Object} KuVerifyResult + * @property {boolean} valid + * @property {string} [error] + * @property {string} format + * @property {string} [topic] + * @property {string[]} [models] + * @property {number} [rounds] + * @property {number} [totalReceipts] + * @property {number} [verifiedReceipts] + * @property {number} [failedReceipts] + * @property {string[]} [dissentingModels] + * @property {string} [consensusLevel] + * @property {string[]} [errors] + */ + +// Required fields in a conformant KU per knowledge-unit.schema.json +const REQUIRED_FIELDS = [ + 'id', + 'version', + 'canonical_question', + 'consensus_level', + 'agreed', + 'models_used', + 'process_template', + 'status', + 'fresh_until', + 'receipt_sig', + 'receipt_kid', + 'receipt_hash', +]; + +/** + * Verify a Knowledge Unit bundle. + * + * @param {Object} bundle + * @param {KuVerifyOptions} [opts] + * @returns {Promise} + */ +export async function verifyKnowledgeUnit(bundle, opts = {}) { + // Structural validation against required fields. + const missing = REQUIRED_FIELDS.filter((f) => bundle[f] === undefined); + if (missing.length > 0) { + return { + valid: false, + error: 'unknown_format', + format: 'knowledge-unit', + errors: [`Missing required KU fields: ${missing.join(', ')}`], + }; + } + + // Validate version + if (bundle.version !== 1) { + return { + valid: false, + error: 'unsupported_algorithm', + format: 'knowledge-unit', + errors: [`Unsupported KU schema version: ${bundle.version}`], + }; + } + + // ID pattern: ku-[a-z0-9]{12} + if (!/^ku-[a-z0-9]{12}$/.test(bundle.id)) { + return { + valid: false, + error: 'unknown_format', + format: 'knowledge-unit', + errors: [`Invalid KU id format: ${bundle.id}`], + }; + } + + // Verify embedded receipts if present + const { verifyReceipt } = await import('./ed25519-receipt.js'); + const { detectFormat } = await import('../detect.js'); + + const receiptList = Array.isArray(bundle.receipts) ? bundle.receipts : []; + const errors = []; + let verifiedReceipts = 0; + let failedReceipts = 0; + + for (const receipt of receiptList) { + const detected = detectFormat(receipt); + if (detected.mode === 'unknown' || detected.mode === 'knowledge-unit') continue; + const r = await verifyReceipt(receipt, detected.mode, opts); + if (r.valid) verifiedReceipts++; + else { + failedReceipts++; + errors.push(`Receipt ${receiptList.indexOf(receipt) + 1}: ${r.error}`); + } + } + + const dissentingModels = Array.isArray(bundle.dissent) + ? bundle.dissent.map((d) => d.model).filter(Boolean) + : []; + + const overallValid = failedReceipts === 0 && missing.length === 0; + + return { + valid: overallValid, + error: overallValid ? undefined : 'invalid_signature', + format: 'knowledge-unit', + topic: bundle.canonical_question, + models: bundle.models_used, + rounds: bundle.rounds || receiptList.length, + totalReceipts: receiptList.length, + verifiedReceipts, + failedReceipts, + dissentingModels, + consensusLevel: bundle.consensus_level, + errors: errors.length ? errors : undefined, + kid: bundle.receipt_kid, + }; +} diff --git a/src/engines/prompt.js b/src/engines/prompt.js new file mode 100644 index 0000000..65fa79f --- /dev/null +++ b/src/engines/prompt.js @@ -0,0 +1,260 @@ +/** + * @veritasacta/verify — prompt engine + * + * Verify the provenance of an agent instruction file (SKILLS.md, + * CLAUDE.md, system_prompt.md, AGENTS.md, or any plain-text prompt + * artefact) against either: + * + * 1. A Veritas Acta receipt asserting the SHA-256 of the file's bytes + * (receipt.payload.prompt_hash === "sha256:"). + * + * 2. A Sigstore attestation bundle (DSSE envelope over an in-toto + * statement with predicate type scopeblind.decision or sigstore's + * own predicate types naming the file hash). + * + * This closes the supply-chain attack vector where an attacker modifies + * CLAUDE.md or SKILLS.md between the time an operator wrote it and the + * time an agent runs with it loaded. Cryptographic provenance of the + * exact prompt bytes lets downstream auditors prove the agent operated + * under an unmodified set of instructions. + * + * @module verify-cli/src/engines/prompt + * @license Apache-2.0 + */ + +import { readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; + +import { canonicalHash } from '../util/canonical.js'; + +/** + * @typedef {Object} PromptVerifyOptions + * @property {string} promptPath Path to the prompt file to verify. + * @property {string} [receiptPath] Path to a Veritas Acta receipt file + * asserting the prompt hash. + * @property {string} [sigstoreBundle] Path to a Sigstore bundle (JSON). + * @property {string} [expectedHash] Alternative: a specific SHA-256 hex + * the caller expects to match. + */ + +/** + * @typedef {Object} PromptVerifyResult + * @property {boolean} valid + * @property {string} [error] + * @property {string} format "prompt-verify" + * @property {string} prompt_path + * @property {string} prompt_hash + * @property {string} [expected_hash] + * @property {string} [source] "receipt" | "sigstore" | "expected-hash" + * @property {Object} [receipt_summary] If source=receipt, a summary of the + * referenced receipt. + * @property {Object} [bundle_summary] If source=sigstore. + */ + +/** + * Compute SHA-256 of a file's raw bytes, hex-encoded with "sha256:" prefix. + */ +export function hashPromptFile(path) { + const bytes = readFileSync(path); + const hex = createHash('sha256').update(bytes).digest('hex'); + return `sha256:${hex}`; +} + +/** + * Verify a prompt against one of the three accepted sources. + * + * Sources are tried in order: expectedHash (fast path), receipt, Sigstore + * bundle. The first source that decides (pass or fail) wins. + * + * @param {PromptVerifyOptions} opts + * @returns {Promise} + */ +export async function verifyPrompt(opts) { + const { promptPath, receiptPath, sigstoreBundle, expectedHash } = opts; + + let promptHash; + try { + promptHash = hashPromptFile(promptPath); + } catch (err) { + return { + valid: false, + error: `cannot_read_prompt_file:${err.code || err.message}`, + format: 'prompt-verify', + prompt_path: promptPath, + prompt_hash: '', + }; + } + + // 1. Expected hash (fastest path; caller knows what it should be) + if (expectedHash) { + const normalised = expectedHash.startsWith('sha256:') + ? expectedHash + : `sha256:${expectedHash}`; + const ok = normalised === promptHash; + return { + valid: ok, + error: ok ? undefined : 'hash_mismatch', + format: 'prompt-verify', + prompt_path: promptPath, + prompt_hash: promptHash, + expected_hash: normalised, + source: 'expected-hash', + }; + } + + // 2. Veritas Acta receipt + if (receiptPath) { + return verifyViaReceipt({ promptPath, promptHash, receiptPath }); + } + + // 3. Sigstore bundle + if (sigstoreBundle) { + return verifyViaSigstore({ promptPath, promptHash, bundlePath: sigstoreBundle }); + } + + return { + valid: false, + error: 'missing_source:provide_receipt_or_sigstore_or_expected-hash', + format: 'prompt-verify', + prompt_path: promptPath, + prompt_hash: promptHash, + }; +} + +function verifyViaReceipt({ promptPath, promptHash, receiptPath }) { + let receipt; + try { + receipt = JSON.parse(readFileSync(receiptPath, 'utf-8')); + } catch (err) { + return { + valid: false, + error: `cannot_read_receipt:${err.code || err.message}`, + format: 'prompt-verify', + prompt_path: promptPath, + prompt_hash: promptHash, + source: 'receipt', + }; + } + + const payload = receipt.payload || receipt; + const asserted = + payload.prompt_hash || + payload.instruction_hash || + payload.artifact_hash || + (payload.artifacts && + payload.artifacts.find((a) => a.path === promptPath || a.name === promptPath) + ?.hash); + + if (!asserted) { + return { + valid: false, + error: 'receipt_missing_prompt_hash_field', + format: 'prompt-verify', + prompt_path: promptPath, + prompt_hash: promptHash, + source: 'receipt', + receipt_summary: receipt_summary(receipt), + }; + } + + const normalised = asserted.startsWith('sha256:') ? asserted : `sha256:${asserted}`; + const ok = normalised === promptHash; + return { + valid: ok, + error: ok ? undefined : 'hash_mismatch', + format: 'prompt-verify', + prompt_path: promptPath, + prompt_hash: promptHash, + expected_hash: normalised, + source: 'receipt', + receipt_summary: receipt_summary(receipt), + }; +} + +function verifyViaSigstore({ promptPath, promptHash, bundlePath }) { + let bundle; + try { + bundle = JSON.parse(readFileSync(bundlePath, 'utf-8')); + } catch (err) { + return { + valid: false, + error: `cannot_read_sigstore_bundle:${err.code || err.message}`, + format: 'prompt-verify', + prompt_path: promptPath, + prompt_hash: promptHash, + source: 'sigstore', + }; + } + + // Sigstore bundles carry the DSSE envelope + in-toto statement. The + // in-toto statement's subject is an array of { name, digest: { sha256: ... }}. + // We look for a subject digest matching our computed promptHash. + const subjects = extractSigstoreSubjects(bundle); + if (!subjects || subjects.length === 0) { + return { + valid: false, + error: 'sigstore_bundle_missing_subjects', + format: 'prompt-verify', + prompt_path: promptPath, + prompt_hash: promptHash, + source: 'sigstore', + bundle_summary: bundle_summary(bundle), + }; + } + + const target = promptHash.replace(/^sha256:/, ''); + const matched = subjects.find((s) => (s.digest && (s.digest.sha256 === target))); + const ok = Boolean(matched); + return { + valid: ok, + error: ok ? undefined : 'sigstore_subject_hash_mismatch', + format: 'prompt-verify', + prompt_path: promptPath, + prompt_hash: promptHash, + expected_hash: matched ? `sha256:${matched.digest.sha256}` : undefined, + source: 'sigstore', + bundle_summary: bundle_summary(bundle), + }; +} + +function receipt_summary(receipt) { + const p = receipt.payload || receipt; + return { + type: p.type, + issuer_id: p.issuer_id, + agent_id: p.agent_id, + issued_at: p.issued_at, + kid: receipt.signature && receipt.signature.kid, + }; +} + +function bundle_summary(bundle) { + return { + media_type: bundle.mediaType || bundle.media_type, + kind: bundle.kind, + verification_material_present: Boolean(bundle.verificationMaterial || bundle.verification_material), + subject_count: (extractSigstoreSubjects(bundle) || []).length, + }; +} + +function extractSigstoreSubjects(bundle) { + // Bundles can carry the statement in a few locations depending on version. + // We try: bundle.dsseEnvelope.payload (base64-encoded in-toto statement), + // bundle.statement (inline), bundle.attestations[0].statement. + let statement = bundle.statement || bundle.in_toto_statement; + + if (!statement && bundle.dsseEnvelope && bundle.dsseEnvelope.payload) { + try { + const payload = Buffer.from(bundle.dsseEnvelope.payload, 'base64').toString('utf-8'); + statement = JSON.parse(payload); + } catch { + // fall through + } + } + + if (!statement && Array.isArray(bundle.attestations) && bundle.attestations[0]) { + statement = bundle.attestations[0].statement || bundle.attestations[0]; + } + + return statement && Array.isArray(statement.subject) ? statement.subject : null; +} diff --git a/src/engines/proxy.js b/src/engines/proxy.js new file mode 100644 index 0000000..be12fe5 --- /dev/null +++ b/src/engines/proxy.js @@ -0,0 +1,290 @@ +/** + * Universal MCP proxy (receipt-signing transparent wrapper). + * + * Usage: + * veritasacta proxy --target "node my-mcp-server.js" + * veritasacta proxy --target "python server.py" --key ~/.veritasacta/attester.json + * + * The proxy spawns the target MCP server as a child process and relays + * stdin/stdout between the parent (Claude Code / Cursor / etc.) and the + * child. On each `tools/call` JSON-RPC request, the proxy signs a + * decision receipt using the configured signing key. Receipts are + * written to `.veritasacta/receipts/` (or a path specified by + * --receipts-dir). + * + * No changes are required in either the MCP server or the host agent. + * This matches the Signet proxy pattern documented at + * https://github.com/Prismer-AI/signet. + * + * MCP protocol reference: https://spec.modelcontextprotocol.io/ + * + * @module verify-cli/src/engines/proxy + * @license Apache-2.0 + */ + +import { spawn } from 'node:child_process'; +import { + readFileSync, + writeFileSync, + existsSync, + mkdirSync, +} from 'node:fs'; +import { join } from 'node:path'; +import { sign, createPrivateKey, createHash } from 'node:crypto'; +import { canonicalize } from '../util/canonical.js'; + +/** + * Load or refuse — the proxy requires a signing key to be provided. + * + * @param {string} keyPath + * @returns {{kid: string, privateKey: import('node:crypto').KeyObject, pubHex: string}} + */ +function loadKey(keyPath) { + if (!existsSync(keyPath)) { + throw new Error(`No signing key at ${keyPath}. Run \`veritasacta init\` to create one.`); + } + const data = JSON.parse(readFileSync(keyPath, 'utf-8')); + return { + kid: data.kid, + privateKey: createPrivateKey({ + key: Buffer.from(data.privateDer, 'hex'), + format: 'der', + type: 'pkcs8', + }), + pubHex: data.pubHex, + }; +} + +/** + * Sign a payload with Ed25519 + JCS canonicalization. + */ +function signPayload(privateKey, payload) { + const canonical = canonicalize(payload); + const sig = sign(null, Buffer.from(canonical, 'utf-8'), privateKey); + return sig.toString('hex'); +} + +/** + * @typedef {Object} ProxyOptions + * @property {string} target command + args (joined with space) + * @property {string} [key] key file path + * @property {string} [receiptsDir] directory to write receipts into + * @property {string} [receiptsJsonl] append-only JSONL log path + * @property {string} [issuerId] issuer identifier (defaults to kid) + * @property {string} [policyId] policy identifier to embed + * @property {string} [serverKey] Server-side signing key file (enables bilateral cosign) + * @property {boolean} [bilateral] If true, require serverKey and attach a cosignature per request + * @property {boolean} [scrubSecrets] Redact probable-secret argument values outbound + flag on receipt + * @property {string} [traceId] Optional trace_id to stamp on every receipt (workflow grouping) + */ + +/** Key names considered secret for --scrub-secrets. */ +const SECRET_KEY_NAMES = new Set([ + 'api_key', 'apikey', 'api-key', + 'token', 'access_token', 'auth_token', 'bearer', + 'password', 'passwd', 'pwd', + 'secret', 'client_secret', + 'authorization', 'x-api-key', + 'private_key', 'privatekey', +]); + +function isSecretKeyName(name) { + return SECRET_KEY_NAMES.has(String(name).toLowerCase()); +} + +/** + * Walk an object tree and, for any key matching the secret-key-name set, + * replace the value with a redacted marker. Returns { scrubbed, detected: string[] }. + */ +function scrubSecretArgs(args) { + const detected = []; + function walk(node, pathParts) { + if (node && typeof node === 'object' && !Array.isArray(node)) { + const out = {}; + for (const [k, v] of Object.entries(node)) { + if (isSecretKeyName(k) && (typeof v === 'string' || typeof v === 'number')) { + detected.push([...pathParts, k].join('.')); + out[k] = 'REDACTED_BY_PROXY'; + } else { + out[k] = walk(v, [...pathParts, k]); + } + } + return out; + } + if (Array.isArray(node)) { + return node.map((el, i) => walk(el, [...pathParts, String(i)])); + } + return node; + } + const scrubbed = walk(args, []); + return { scrubbed, detected }; +} + +/** + * Run the proxy: spawn target, intercept MCP tool-call requests, sign receipts. + * + * @param {ProxyOptions} opts + * @returns {Promise} exit code of child process + */ +export async function runProxy(opts) { + if (!opts.target) throw new Error('--target is required'); + + const keyPath = opts.key || join(process.cwd(), '.veritasacta', 'attester.json'); + const key = loadKey(keyPath); + + // Optional server-side key for bilateral cosign. If --bilateral is set + // without --server-key, we fall back to the primary key (useful for + // testing the envelope shape without a real second signer). + let serverKey = null; + if (opts.bilateral || opts.serverKey) { + const serverKeyPath = opts.serverKey || keyPath; + serverKey = loadKey(serverKeyPath); + process.stderr.write( + `[veritasacta proxy] bilateral mode enabled, server kid=${serverKey.kid}\n` + ); + } + + const receiptsDir = opts.receiptsDir || join(process.cwd(), '.veritasacta', 'receipts'); + const receiptsJsonl = opts.receiptsJsonl || join(process.cwd(), '.veritasacta', 'receipts.jsonl'); + mkdirSync(receiptsDir, { recursive: true }); + + const issuerId = opts.issuerId || key.kid; + const policyId = opts.policyId || 'veritasacta:proxy:default'; + const traceId = opts.traceId || null; + + // Split the target command into executable + args + const tokens = opts.target.trim().split(/\s+/); + const cmd = tokens[0]; + const args = tokens.slice(1); + + // Spawn child with piped stdio so we can intercept + const child = spawn(cmd, args, { + stdio: ['pipe', 'pipe', 'inherit'], + }); + + let sequence = 0; + let previousReceiptHash = null; + + // Buffered readline across JSON-RPC frames (MCP uses newline-delimited JSON) + let inBuffer = ''; + process.stdin.on('data', (chunk) => { + inBuffer += chunk.toString('utf-8'); + let newlineIdx; + while ((newlineIdx = inBuffer.indexOf('\n')) !== -1) { + const line = inBuffer.slice(0, newlineIdx); + inBuffer = inBuffer.slice(newlineIdx + 1); + if (line.trim().length === 0) { + child.stdin.write('\n'); + continue; + } + + // Try to parse as JSON-RPC + let message; + try { message = JSON.parse(line); } catch { + // Not JSON — pass through unchanged + child.stdin.write(line + '\n'); + continue; + } + + // Is this a tools/call request? + if (message && message.method === 'tools/call' && message.params) { + sequence++; + const toolName = message.params.name || 'unknown'; + let toolArgs = message.params.arguments || {}; + let scrubDetected = []; + let forwardedMessage = message; + + if (opts.scrubSecrets) { + const { scrubbed, detected } = scrubSecretArgs(toolArgs); + if (detected.length > 0) { + scrubDetected = detected; + toolArgs = scrubbed; + forwardedMessage = { + ...message, + params: { ...message.params, arguments: scrubbed }, + }; + process.stderr.write( + `[veritasacta proxy] --scrub-secrets redacted ${detected.length} arg(s) in rcpt_${sequence}: ${detected.join(', ')}\n` + ); + } + } + + // Compute tool input hash over POSSIBLY-SCRUBBED args (secrets never + // enter the receipt even as a hash of the real value). + const argStr = JSON.stringify(toolArgs, Object.keys(toolArgs).sort()); + const toolInputHash = 'sha256:' + createHash('sha256').update(argStr, 'utf-8').digest('hex'); + + const payload = { + type: 'veritasacta:proxy:decision', + spec: 'draft-farley-acta-signed-receipts-03', + tool_name: toolName, + tool_input_hash: toolInputHash, + decision: 'allow', + policy_id: policyId, + issued_at: new Date().toISOString(), + issuer_id: issuerId, + sequence, + previousReceiptHash, + ...(traceId ? { trace_id: traceId } : {}), + ...(scrubDetected.length > 0 ? { scrub_detected: scrubDetected } : {}), + }; + + const sig = signPayload(key.privateKey, payload); + const receipt = { + payload, + signature: { alg: 'EdDSA', kid: key.kid, sig }, + }; + + // Bilateral cosign: server independently signs the same canonical + // payload. Both signatures verify against the same bytes; an + // attacker who compromises one key alone cannot produce a + // bilaterally-valid receipt. + if (serverKey) { + const coSig = signPayload(serverKey.privateKey, payload); + receipt.cosignatures = [ + { alg: 'EdDSA', kid: serverKey.kid, sig: coSig }, + ]; + } + + // Update chain hash + const canonical = canonicalize(payload); + previousReceiptHash = 'sha256:' + createHash('sha256').update(canonical, 'utf-8').digest('hex'); + + // Persist + const receiptFile = join(receiptsDir, `rcpt_${String(sequence).padStart(6, '0')}.json`); + writeFileSync(receiptFile, JSON.stringify(receipt, null, 2)); + try { + writeFileSync(receiptsJsonl, JSON.stringify(receipt) + '\n', { flag: 'a' }); + } catch {} + + // Log to stderr so we don't interfere with MCP stdout + const modeTag = serverKey ? ' [bilateral]' : ''; + process.stderr.write( + `[veritasacta proxy] rcpt_${sequence} signed (${toolName}) kid=${key.kid}${modeTag}\n` + ); + + // Forward the (possibly scrubbed) message to the child. + child.stdin.write(JSON.stringify(forwardedMessage) + '\n'); + continue; + } + + // Pass through the original message to the child unchanged + child.stdin.write(line + '\n'); + } + }); + + // Forward child stdout to parent stdout + child.stdout.pipe(process.stdout); + + // Error handling + child.on('error', (err) => { + process.stderr.write(`[veritasacta proxy] child error: ${err.message}\n`); + }); + + return new Promise((resolve) => { + child.on('close', (code) => { + process.stderr.write(`[veritasacta proxy] child exited with code ${code}; ${sequence} receipt(s) signed\n`); + resolve(code || 0); + }); + }); +} diff --git a/src/engines/rekor.js b/src/engines/rekor.js new file mode 100644 index 0000000..403cc33 --- /dev/null +++ b/src/engines/rekor.js @@ -0,0 +1,315 @@ +/** + * @veritasacta/verify — Rekor / transparency-log anchoring engine. + * + * Implements AIP-0005 T4 evidence: verify that a receipt's hash was + * anchored in a tamper-evident public log (Rekor by default) within a + * declared temporal window. + * + * The engine supports three verification modes, in descending order + * of strength: + * + * 1. Offline proof verification — operator supplies a Rekor + * inclusion proof (set of sibling hashes forming a Merkle path + * from the receipt hash to the signed tree root). We verify the + * Merkle path locally and check the signed tree head. + * + * 2. Offline signed-entry bundle — operator supplies the Rekor + * entry body + Rekor's signed checkpoint. We verify the + * signature on the entry and the checkpoint's consistency + * against a pinned key. + * + * 3. Online fetch — with opt-in `--online`, fetch + * the entry from Rekor's API, then verify as in (1). Cheapest + * for the user; requires network. + * + * This engine is deliberately network-optional: modes (1) and (2) + * run with zero network. Mode (3) is a convenience for CI pipelines. + * + * Rekor reference: https://github.com/sigstore/rekor + * Signed Note format: RFC-style signed checkpoints from C2SP. + * + * @module verify-cli/src/engines/rekor + * @license Apache-2.0 + */ + +import { createHash, verify as cryptoVerify, createPublicKey } from 'node:crypto'; + +/** + * @typedef {Object} RekorInclusionProof + * @property {string} logID Rekor log ID (hex sha256 of its public key). + * @property {number} treeSize + * @property {number} logIndex 0-based index of the entry. + * @property {string} rootHash Signed tree root (hex). + * @property {string[]} hashes Merkle audit path, leaf-to-root ordering. + * @property {string} [signedTreeHead] Signed-Note blob, if available. + */ + +/** + * @typedef {Object} RekorVerifyOptions + * @property {Object} receipt AIP-0001 receipt envelope. + * @property {string} [expectedAnchorUri] If set, must match receipt.payload.anchor_uri. + * @property {string} [anchoredWithin] ISO 8601 duration like "PT5M". + * @property {RekorInclusionProof} [proof] Offline inclusion proof. + * @property {string} [entryBody] Rekor entry body (base64) if using mode 2. + * @property {Object} [logTrustAnchors] + * Map logID (hex) → pubkey pem/hex/base64. Pinned Rekor log keys. + * @property {Date} [now=new Date()] + */ + +/** + * @typedef {Object} RekorVerifyResult + * @property {boolean} valid + * @property {string} [error] + * @property {'proof'|'bundle'|'undecidable'} mode + * @property {string} [receipt_hash] + * @property {string} [log_id] + * @property {number} [log_index] + * @property {string} [root_hash] + * @property {string} [anchored_at] + * @property {number} [anchor_age_sec] + * @property {boolean} [within_window] + */ + +/** + * Verify a receipt's transparency-log anchor against a supplied + * offline inclusion proof, or return `undecidable` if no proof is + * supplied. + * + * @param {RekorVerifyOptions} opts + * @returns {RekorVerifyResult} + */ +export function verifyRekorAnchor(opts) { + const { receipt, proof, expectedAnchorUri, anchoredWithin, logTrustAnchors = {}, now = new Date() } = opts; + + if (!receipt || !receipt.payload) { + return { valid: false, error: 'missing_receipt', mode: 'undecidable' }; + } + + const payload = receipt.payload; + const anchorUri = payload.anchor_uri; + const anchorType = payload.anchor_type || 'rekor-v1'; + const declaredWithin = payload.anchored_within || anchoredWithin; + const issuedAt = payload.issued_at; + + if (!anchorUri) { + return { valid: false, error: 'no_anchor_uri_in_receipt', mode: 'undecidable' }; + } + if (expectedAnchorUri && expectedAnchorUri !== anchorUri) { + return { valid: false, error: 'anchor_uri_mismatch', mode: 'undecidable' }; + } + + // Compute the receipt's own hash — the value we expect to find in + // the log's Merkle path. + const receiptHash = computeReceiptHash(receipt); + + if (!proof) { + return { + valid: false, + error: 'no_inclusion_proof_provided', + mode: 'undecidable', + receipt_hash: receiptHash, + }; + } + + // Check the receiptHash is the leaf of the Merkle path. + const computedRoot = computeMerkleRootFromProof( + receiptHash, + proof.logIndex || 0, + proof.hashes || [], + proof.treeSize || 1 + ); + + if (computedRoot !== normalizeHex(proof.rootHash)) { + return { + valid: false, + error: 'merkle_path_does_not_reach_claimed_root', + mode: 'proof', + receipt_hash: receiptHash, + log_id: proof.logID, + log_index: proof.logIndex, + root_hash: proof.rootHash, + }; + } + + // If a Signed Tree Head is supplied AND we have a trust anchor for + // this log ID, verify the STH signature. Otherwise STH is + // unverified (strong proof without, weaker with). + let sthValid = null; + if (proof.signedTreeHead && logTrustAnchors[proof.logID]) { + try { + sthValid = verifySignedNote(proof.signedTreeHead, logTrustAnchors[proof.logID]); + } catch { + sthValid = false; + } + } + + if (sthValid === false) { + return { + valid: false, + error: 'signed_tree_head_signature_invalid', + mode: 'proof', + receipt_hash: receiptHash, + log_id: proof.logID, + }; + } + + // Temporal window check. + const windowOk = checkWithinWindow(issuedAt, proof.integratedTime || proof.integrated_time, declaredWithin); + if (declaredWithin && windowOk === false) { + return { + valid: false, + error: 'anchored_outside_declared_window', + mode: 'proof', + receipt_hash: receiptHash, + within_window: false, + }; + } + + return { + valid: true, + mode: 'proof', + receipt_hash: receiptHash, + log_id: proof.logID, + log_index: proof.logIndex, + root_hash: proof.rootHash, + anchored_at: proof.integratedTime || null, + ...(windowOk != null ? { within_window: windowOk } : {}), + }; +} + +// ───── Helpers ───── + +/** + * Compute a deterministic hash for the receipt using the same + * canonicalization as AIP-0001. This hash is what we expect to find + * in the Rekor log's leaf set. + */ +export function computeReceiptHash(receipt) { + // Rekor indexes by sha256 of the entry body (base64), but for + // receipts anchored via in-toto/DSSE, the convention is sha256 over + // the canonical payload. Callers should confirm the anchoring + // service's convention for their deployment. + const body = JSON.stringify(receipt, Object.keys(receipt).sort()); + return createHash('sha256').update(body, 'utf-8').digest('hex'); +} + +/** + * Given a leaf, its index, an audit path (sibling hashes from leaf + * to root), and tree size, recompute the root hash. + * + * Rekor uses the RFC 6962 Merkle tree convention: + * - leaf hash = sha256(0x00 || entry_bytes) + * - node hash = sha256(0x01 || left || right) + * + * This implementation accepts pre-hashed leaves (as we compute them + * via computeReceiptHash) and sibling hashes in leaf-to-root order. + */ +export function computeMerkleRootFromProof(leafHashHex, index, siblings, treeSize) { + let hash = hexToBytes(normalizeHex(leafHashHex)); + let i = index; + let n = treeSize; + + for (const sibHex of siblings) { + const sib = hexToBytes(normalizeHex(sibHex)); + const isLeft = (i % 2) === 1; + const combined = isLeft + ? Buffer.concat([Buffer.from([0x01]), sib, hash]) + : Buffer.concat([Buffer.from([0x01]), hash, sib]); + hash = createHash('sha256').update(combined).digest(); + i = Math.floor(i / 2); + n = Math.floor((n + 1) / 2); + } + return hash.toString('hex'); +} + +/** + * Verify a Signed Note (https://c2sp.org/signed-note) format STH + * against a pinned pubkey. + * + * Format: + * \n + * \n + * \n... + * + * Where each signature-line is: + * "— " + * + * This is a minimal verifier suitable for Rekor's current STH + * signatures. + */ +export function verifySignedNote(noteText, pubkey) { + if (typeof noteText !== 'string') return false; + const parts = noteText.split('\n\n'); + if (parts.length < 2) return false; + const text = parts[0] + '\n'; + const sigBlock = parts[1]; + + const lines = sigBlock.split('\n').filter(Boolean); + for (const line of lines) { + const m = /^—\s+(\S+)\s+(\S+)$/.exec(line); + if (!m) continue; + const sigB64 = m[2]; + const sigBuf = Buffer.from(sigB64, 'base64'); + // First 4 bytes = key hint; remainder = signature. + const actualSig = sigBuf.subarray(4); + + try { + const pub = pubkeyFromAny(pubkey); + const ok = cryptoVerify(null, Buffer.from(text, 'utf-8'), pub, actualSig); + if (ok) return true; + } catch { + // try next signature line + } + } + return false; +} + +function pubkeyFromAny(keyStr) { + if (keyStr.startsWith('-----BEGIN')) { + return createPublicKey({ key: keyStr, format: 'pem' }); + } + // Treat as hex (raw 32-byte Ed25519) + if (/^[0-9a-fA-F]+$/.test(keyStr) && keyStr.length === 64) { + const raw = Buffer.from(keyStr, 'hex'); + const der = Buffer.concat([ + Buffer.from('302a300506032b6570032100', 'hex'), + raw, + ]); + return createPublicKey({ key: der, format: 'der', type: 'spki' }); + } + // Fall back to base64-DER + const buf = Buffer.from(keyStr, 'base64'); + return createPublicKey({ key: buf, format: 'der', type: 'spki' }); +} + +/** + * Parse ISO 8601 duration ('PT5M', 'PT1H', 'P1D') into seconds. + */ +export function parseDuration(iso) { + if (typeof iso !== 'string') return null; + const m = /^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/.exec(iso); + if (!m) return null; + const [, d, h, mm, s] = m; + return (Number(d || 0) * 86400) + + (Number(h || 0) * 3600) + + (Number(mm || 0) * 60) + + Number(s || 0); +} + +function checkWithinWindow(issuedAt, integratedAt, declaredWithin) { + if (!declaredWithin) return null; + const sec = parseDuration(declaredWithin); + if (sec == null) return null; + const issued = Date.parse(issuedAt); + const integrated = Date.parse(integratedAt); + if (!Number.isFinite(issued) || !Number.isFinite(integrated)) return null; + return Math.abs((integrated - issued) / 1000) <= sec; +} + +function normalizeHex(v) { + return String(v || '').trim().toLowerCase(); +} + +function hexToBytes(hex) { + return Buffer.from(hex, 'hex'); +} diff --git a/src/engines/sbom.js b/src/engines/sbom.js new file mode 100644 index 0000000..32c8846 --- /dev/null +++ b/src/engines/sbom.js @@ -0,0 +1,194 @@ +/** + * @veritasacta/verify — SBOM integration for audit bundles. + * + * Given a directory of receipts + (optionally) a software bill of + * materials in SPDX or CycloneDX JSON, produce an audit bundle that: + * + * 1. References the SBOM by digest. + * 2. Embeds a compact per-receipt summary. + * 3. Signs the bundle manifest with a supplied key. + * + * This gives regulated customers the artifact they actually want: + * a single tamper-evident JSON that says "this session ran on this + * software (by SBOM), produced these receipts (by hash), signed by + * this identity (by pubkey)." + * + * SBOM format detection is structural: + * - SPDX: has `spdxVersion` field. + * - CycloneDX: has `bomFormat: "CycloneDX"` field. + * - Unknown: accepted but reported as `sbom_format: "unknown"`. + * + * @module verify-cli/src/engines/sbom + * @license Apache-2.0 + */ + +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { join, resolve } from 'node:path'; + +/** + * @typedef {Object} SbomBundleOptions + * @property {string} receiptsDir + * @property {string} [sbomPath] + * @property {string} [organizationName] + * @property {string} [sessionId] + * @property {(payload: Buffer) => {alg: string, kid: string, sig: string}} [sign] + * Optional signer callback. Takes canonical manifest bytes, returns a + * signature object to attach. + */ + +/** + * Produce the bundle without signing. Signing happens in a caller- + * supplied callback so key management stays in the operator's + * control. + * + * @param {SbomBundleOptions} opts + */ +export function buildSbomBundle(opts) { + const { + receiptsDir, + sbomPath, + organizationName = '(unspecified)', + sessionId = null, + sign, + } = opts; + + const dir = resolve(receiptsDir); + const receipts = loadReceipts(dir); + + let sbom = null; + if (sbomPath) { + sbom = loadSbom(sbomPath); + } + + const manifest = { + format: 'veritasacta:sbom-audit-bundle/v1', + generated_at: new Date().toISOString(), + organization: organizationName, + session_id: sessionId, + sbom: sbom ? summariseSbom(sbom) : null, + receipts: receipts.map(summariseReceipt), + receipt_count: receipts.length, + receipts_fingerprint: receiptsFingerprint(receipts), + }; + + const manifestBytes = canonicalBytes(manifest); + const manifest_hash = 'sha256:' + createHash('sha256').update(manifestBytes).digest('hex'); + + const bundle = { + manifest, + manifest_hash, + ...(sign ? { signature: sign(manifestBytes) } : {}), + }; + return bundle; +} + +// ───── Helpers ───── + +function loadReceipts(dir) { + let entries; + try { entries = readdirSync(dir); } + catch (err) { throw new Error(`cannot_read_receipts_dir:${err.code || err.message}:${dir}`); } + + const list = []; + for (const entry of entries) { + if (!entry.endsWith('.json')) continue; + const p = join(dir, entry); + let st; + try { st = statSync(p); } catch { continue; } + if (!st.isFile()) continue; + try { + const parsed = JSON.parse(readFileSync(p, 'utf-8')); + if (parsed && parsed.payload && parsed.signature) list.push({ path: p, ...parsed }); + } catch { /* skip */ } + } + // Sort by issued_at if present; fallback to filename. + list.sort((a, b) => { + const aa = (a.payload && a.payload.issued_at) || ''; + const bb = (b.payload && b.payload.issued_at) || ''; + return aa < bb ? -1 : aa > bb ? 1 : 0; + }); + return list; +} + +function loadSbom(sbomPath) { + const bytes = readFileSync(sbomPath); + const parsed = JSON.parse(bytes.toString('utf-8')); + return { + raw: parsed, + bytes, + path: sbomPath, + }; +} + +function detectSbomFormat(parsed) { + if (parsed.spdxVersion) return 'spdx'; + if (parsed.bomFormat === 'CycloneDX') return 'cyclonedx'; + return 'unknown'; +} + +function summariseSbom({ raw, bytes, path }) { + const format = detectSbomFormat(raw); + const digest = 'sha256:' + createHash('sha256').update(bytes).digest('hex'); + + const base = { + sbom_format: format, + sbom_digest: digest, + sbom_path: path, + }; + + if (format === 'spdx') { + return { + ...base, + spdx_version: raw.spdxVersion, + data_license: raw.dataLicense, + creator: (raw.creationInfo && raw.creationInfo.creators) || null, + package_count: Array.isArray(raw.packages) ? raw.packages.length : null, + sbom_name: raw.name || null, + }; + } + if (format === 'cyclonedx') { + return { + ...base, + spec_version: raw.specVersion, + serial_number: raw.serialNumber, + component_count: Array.isArray(raw.components) ? raw.components.length : null, + }; + } + return base; +} + +function summariseReceipt(r) { + const p = r.payload || {}; + const sig = r.signature || {}; + return { + file: r.path, + issued_at: p.issued_at || null, + action: p.action || p.tool_name || null, + decision: p.decision || null, + kid: sig.kid || null, + cost_tier: p.cost_tier ?? null, + trace_id: p.trace_id || null, + }; +} + +function receiptsFingerprint(receipts) { + // Deterministic SHA-256 over the ordered canonical bytes of each + // receipt. If any receipt changes, the fingerprint changes. + const h = createHash('sha256'); + for (const r of receipts) { + const { path, ...rest } = r; + h.update(canonicalBytes(rest)); + } + return 'sha256:' + h.digest('hex'); +} + +function canonicalBytes(value) { + function jcs(v) { + if (v === null || typeof v !== 'object') return JSON.stringify(v); + if (Array.isArray(v)) return '[' + v.map(jcs).join(',') + ']'; + const keys = Object.keys(v).sort(); + return '{' + keys.map((k) => JSON.stringify(k) + ':' + jcs(v[k])).join(',') + '}'; + } + return Buffer.from(jcs(value), 'utf-8'); +} diff --git a/src/engines/selective-disclosure.js b/src/engines/selective-disclosure.js new file mode 100644 index 0000000..a0fc9df --- /dev/null +++ b/src/engines/selective-disclosure.js @@ -0,0 +1,102 @@ +/** + * AIP-0002 selective-disclosure verification engine. + * + * Verifies receipts that carry a `_commitments` field: salted SHA-256 + * commitments over redacted fields. When the caller provides a + * disclosure package (field + salt + value), this engine checks that + * the commitment reconstructs to the claimed value. + * + * Commitment scheme (per AIP-0002 §Commitment Scheme): + * commitment = SHA-256(salt || canonical(value)) + * + * where canonical(value) is JSON.stringify() of the value. + * + * References: + * - AIP-0002 §Selective Disclosure + * - draft-farley-acta-signed-receipts-03 §Selective Disclosure + * + * @module verify-cli/src/engines/selective-disclosure + * @license Apache-2.0 + */ + +import { createHash } from 'node:crypto'; + +/** + * @typedef {Object} DisclosurePackage + * @property {string} field dot-notation path (e.g., "payload.patient_id") + * @property {string} salt hex-encoded random salt + * @property {unknown} value the original (pre-redaction) value + */ + +/** + * @typedef {Object} SelectiveDisclosureResult + * @property {boolean} valid + * @property {string} [error] + * @property {number} disclosuresVerified + * @property {Array<{field: string, ok: boolean, reason?: string}>} checks + */ + +/** + * Verify a receipt with selective-disclosure commitments. + * + * @param {Object} receipt + * @param {DisclosurePackage[]} disclosures + * @returns {SelectiveDisclosureResult} + */ +export function verifySelectiveDisclosure(receipt, disclosures = []) { + const commitments = receipt._commitments || {}; + const checks = []; + let allValid = true; + + for (const d of disclosures) { + const expected = commitments[d.field]; + if (!expected) { + checks.push({ field: d.field, ok: false, reason: 'field not in _commitments' }); + allValid = false; + continue; + } + + // Strip "sha256:" prefix if present + const expectedHex = expected.startsWith('sha256:') ? expected.slice(7) : expected; + + // Reconstruct: SHA-256(salt || canonical(value)) + const canonical = JSON.stringify(d.value); + const reconstructed = createHash('sha256') + .update(d.salt + canonical, 'utf8') + .digest('hex'); + + const ok = constantTimeStringEqual(expectedHex.toLowerCase(), reconstructed.toLowerCase()); + checks.push({ field: d.field, ok, reason: ok ? undefined : 'commitment does not match disclosed value' }); + if (!ok) allValid = false; + } + + return { + valid: allValid, + error: allValid ? undefined : 'commitment_mismatch', + disclosuresVerified: disclosures.length, + checks, + }; +} + +/** + * Return the list of fields redacted in a selective-disclosure receipt. + * + * @param {Object} receipt + * @returns {string[]} + */ +export function listRedactedFields(receipt) { + return Object.keys(receipt._commitments || {}); +} + +/** + * Constant-time string comparison (for hex digests). + * @param {string} a + * @param {string} b + * @returns {boolean} + */ +function constantTimeStringEqual(a, b) { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); + return diff === 0; +} diff --git a/src/engines/sigil.js b/src/engines/sigil.js new file mode 100644 index 0000000..fac6a1f --- /dev/null +++ b/src/engines/sigil.js @@ -0,0 +1,242 @@ +/** + * Sigil operations engine. + * + * Implements two capabilities: + * 1. Claim 1 — Visual cryptographic commitment derivation. + * Deterministically derives an 11x11 visual pattern from + * (public_key, policy_hash, nonce). Used for canonical-release + * verification ("verify the verifier"). + * + * 2. Claim 2 — Live-context verification (NEW in v0.5.0). + * Verifies that a Sigil's policy evaluates true under live context + * values (clock drift, geofence, sensor readings) obtained at the + * verifier at verification time. Context values are not shared with + * or derivable by the Sigil publisher. + * + * References: + * - Provisional patent #5 (Sigil visual commitment) + * Claim 1: derivation of the visual artifact + * Claim 2: verification under live context values + * - packages/verify-cli/sigil.json (the committed Sigil) + * + * @module verify-cli/src/engines/sigil + * @license Apache-2.0 + */ + +import { createHash } from 'node:crypto'; + +const SIGIL_DOMAIN_V1 = 'scopeblind:sigil:v1'; +const SIGIL_DOMAIN_V2 = 'scopeblind:sigil:v2'; +const SIGIL_SEGMENTS = 6; + +/* ────────────────────────────────────────────────────────────────── + * Claim 1 — Derivation + * ──────────────────────────────────────────────────────────────── */ + +/** + * Derive the Sigil hash from (public_key, policy_hash, nonce). + * Matches the canonical Veritas Acta Sigil derivation. + * + * @param {string} projectPublicKeyHex + * @param {string} policyHashHex + * @param {number} [nonce=0] + * @returns {string} hex-encoded Sigil hash + */ +export function deriveSigilHash(projectPublicKeyHex, policyHashHex, nonce = 0) { + const domain = Buffer.from(SIGIL_DOMAIN_V2); + const pubKey = Buffer.from(projectPublicKeyHex, 'hex'); + const policyBuf = Buffer.from(policyHashHex, 'hex'); + const nonceBuf = Buffer.from([nonce & 0xff]); + const input = Buffer.concat([domain, pubKey, policyBuf, nonceBuf]); + return createHash('sha256').update(input).digest('hex'); +} + +/** + * Derive the 11x11 Sigil grid from a hash (for visual rendering). + * Pure function: deterministic. + * + * @param {Buffer|Uint8Array} hash + * @returns {Object} grid specification (diamond, surround, innerRing, midRing, outerRing, corners) + */ +export function deriveSigilGrid(hash) { + const extHash = createHash('sha256').update(Buffer.from('ext:')).update(hash).digest(); + const b = (i) => (i < 32 ? hash[i] : extHash[i - 32]); + let idx = 0; + + const diamond = { + top: b(idx++) % 3, + right: b(idx++) % 3, + bottom: b(idx++) % 3, + left: b(idx++) % 3, + }; + const surround = []; + for (let i = 0; i < 4; i++) surround.push(b(idx++) % 3); + const innerRing = []; + for (let i = 0; i < SIGIL_SEGMENTS; i++) innerRing.push(b(idx++) % 3); + const midRing = []; + for (let i = 0; i < SIGIL_SEGMENTS; i++) midRing.push(b(idx++) % 3); + const outerRing = []; + for (let i = 0; i < SIGIL_SEGMENTS; i++) outerRing.push(b(idx++) % 3); + const corners = []; + for (let i = 0; i < 8; i++) corners.push(b(idx++) % 3); + + return { diamond, surround, innerRing, midRing, outerRing, corners }; +} + +/** + * Test that a grid passes the visual-distinctiveness filter. + * (Sigils that are too sparse or too dense are rejected at generation + * time; the filter ensures readable artifacts.) + * + * @param {Object} g grid spec + * @returns {boolean} + */ +export function sigilPassesFilter(g) { + const all = [ + g.diamond.top, g.diamond.right, g.diamond.bottom, g.diamond.left, + ...g.surround, ...g.innerRing, ...g.midRing, ...g.outerRing, ...g.corners, + ]; + let primary = 0; + let secondary = 0; + for (const v of all) { + if (v === 1) primary++; + if (v === 2) secondary++; + } + const filled = primary + secondary; + return primary >= 4 && secondary >= 4 && filled >= 10 && filled <= 28; +} + +/** + * Derive the canonical (filter-passing) Sigil grid from a public key hex. + * Finds the smallest nonce that produces a grid passing the filter. + * + * @param {string} publicKeyHex + * @returns {{grid: Object, fingerprint: string, nonce: number}} + */ +export function deriveFilteredSigil(publicKeyHex) { + let nonce = 0; + const keyBuf = Buffer.from(publicKeyHex, 'hex'); + while (nonce < 256) { + const hash = nonce === 0 + ? createHash('sha256').update(SIGIL_DOMAIN_V1).update(keyBuf).digest() + : createHash('sha256').update(SIGIL_DOMAIN_V1).update(keyBuf).update(Buffer.from([nonce])).digest(); + const grid = deriveSigilGrid(hash); + if (sigilPassesFilter(grid)) { + return { grid, fingerprint: hash.toString('hex').slice(0, 8), nonce }; + } + nonce++; + } + // Fallback — unlikely to hit for real keys. + const hash = createHash('sha256').update(SIGIL_DOMAIN_V1).update(keyBuf).digest(); + return { grid: deriveSigilGrid(hash), fingerprint: hash.toString('hex').slice(0, 8), nonce: -1 }; +} + +/* ────────────────────────────────────────────────────────────────── + * Self-check (verify the verifier) + * ──────────────────────────────────────────────────────────────── */ + +/** + * @typedef {Object} SelfCheckResult + * @property {boolean} canonical + * @property {string} [name] + * @property {string} [fingerprint] + * @property {string} [version] + * @property {string} [pkg] + * @property {boolean} sourceMatches + * @property {boolean} policyMatches + * @property {boolean} sigilMatches + * @property {string} [installedSourceHash] + * @property {string} [committedSourceHash] + * @property {string} [rederivedSigilHash] + */ + +/** + * Perform the "verify the verifier" self-check. + * Compares the installed cli.js (and v0.5.0+ : src/engines) to the + * commitments in sigil.json, re-derives the Sigil hash, and confirms + * everything matches the canonical release. + * + * @param {Object} args + * @param {Object} args.sigil parsed sigil.json + * @param {Buffer} args.installedSourceBytes combined bytes of cli.js + monitored engine files (deterministic order) + * @returns {SelfCheckResult} + */ +export function selfCheck({ sigil, installedSourceBytes }) { + const result = { + canonical: false, + name: sigil?.name, + fingerprint: sigil?.fingerprint, + version: sigil?.policy?.package_version, + pkg: sigil?.policy?.package, + sourceMatches: false, + policyMatches: false, + sigilMatches: false, + }; + if (!sigil || !sigil.policy) return result; + + const installedSourceHash = createHash('sha256').update(installedSourceBytes).digest('hex'); + result.installedSourceHash = installedSourceHash; + result.committedSourceHash = sigil.policy.source_hash; + result.sourceMatches = installedSourceHash === sigil.policy.source_hash; + + const policyJson = JSON.stringify(sigil.policy); + const policyHash = createHash('sha256').update(policyJson).digest('hex'); + result.policyMatches = policyHash === sigil.policy_hash; + + const rederived = deriveSigilHash(sigil.project_public_key, policyHash, 0); + result.rederivedSigilHash = rederived; + result.sigilMatches = rederived === sigil.sigil_hash; + + result.canonical = result.sourceMatches && result.policyMatches && result.sigilMatches; + return result; +} + +/* ────────────────────────────────────────────────────────────────── + * Claim 2 — Live-context verification + * ──────────────────────────────────────────────────────────────── */ + +/** + * @typedef {Object} ContextPredicate + * @property {string} kind 'clock' | 'geofence' | 'sensor' | 'biometric' | 'feed' + * @property {string} expr predicate expression (kind-specific) + * @property {Object} [options] + */ + +/** + * @typedef {Object} ContextEvaluationResult + * @property {boolean} allSatisfied + * @property {Array<{kind: string, expr: string, satisfied: boolean, detail: string}>} checks + */ + +/** + * Evaluate a set of live-context predicates. + * Each predicate is resolved by the live-context module; this engine + * aggregates results and reports which passed / failed. + * + * @param {ContextPredicate[]} predicates + * @param {Object} [contextProvider] optional override for testing + * @returns {Promise} + */ +export async function evaluateLiveContext(predicates, contextProvider = null) { + const provider = contextProvider || (await import('../context/live-context.js')).defaultProvider; + const checks = []; + let allSatisfied = true; + + for (const p of predicates) { + let result; + try { + result = await provider.evaluate(p); + } catch (e) { + result = { satisfied: false, detail: `error: ${e.message}` }; + } + checks.push({ + kind: p.kind, + expr: p.expr, + satisfied: Boolean(result.satisfied), + detail: result.detail || '', + }); + if (!result.satisfied) allSatisfied = false; + } + + return { allSatisfied, checks }; +} diff --git a/src/engines/transparency.js b/src/engines/transparency.js new file mode 100644 index 0000000..a1f074f --- /dev/null +++ b/src/engines/transparency.js @@ -0,0 +1,208 @@ +/** + * @veritasacta/verify — transparency profile engine. + * + * Implements the "one-click transparency switch": an operator config + * that opts an agent into one of four standard disclosure tiers. The + * engine exposes helpers to: + * + * - Compute the transparency profile for a given config. + * - Stamp a receipt with its transparency-profile metadata. + * - Decide which receipts in a chain should be anchored publicly. + * - Render a publicly-consumable transparency badge JSON. + * + * Design is aligned with AIP-0005 (cost tiers) but orthogonal: + * transparency is about what gets DISCLOSED, cost_tier is about what + * it COST to mint. Both properties coexist on the same receipt. + * + * Profiles (caller-declared): + * + * private — receipts stay with operator (default; no change to existing behavior) + * auditable — receipts kept privately but selective-disclosure proofs may be produced on demand + * transparent — every receipt's hash is anchored in a public log; receipts queryable by URL + * high-assurance — transparent + compute-burn proof + multi-party + hardware attestation + * + * Picking a profile is a one-line declaration; the engine encodes the + * implied behaviour. + * + * @module verify-cli/src/engines/transparency + * @license Apache-2.0 + */ + +export const PROFILE_DEFINITIONS = { + private: { + id: 'private', + label: 'Private', + description: 'Receipts stay with operator; no public log anchoring.', + // No additional obligations beyond the baseline receipt format. + requires: [], + optional: ['AIP-0002'], + badge_color: '#6c757d', + }, + auditable: { + id: 'auditable', + label: 'Auditable', + description: 'Receipts kept private but verifiable via selective-disclosure proofs.', + requires: ['AIP-0002'], + optional: ['AIP-0003', 'AIP-0006'], + badge_color: '#0b5394', + }, + transparent: { + id: 'transparent', + label: 'Transparent', + description: 'Every receipt hash anchored in a public log (Rekor/custom); receipts queryable by URL.', + requires: ['AIP-0005-T4', 'public_anchor_endpoint'], + optional: ['AIP-0002', 'AIP-0006'], + badge_color: '#0a7c3f', + }, + 'high-assurance': { + id: 'high-assurance', + label: 'High assurance', + description: 'Transparent + compute-burn proof + multi-party attestation + hardware backing.', + requires: ['AIP-0005-T3', 'AIP-0005-T4', 'hardware_attestation'], + optional: ['AIP-0007'], + badge_color: '#b5271b', + }, +}; + +export const PROFILES = Object.keys(PROFILE_DEFINITIONS); + +/** + * @typedef {Object} TransparencyConfig + * @property {keyof typeof PROFILE_DEFINITIONS} profile + * @property {string} [anchor_endpoint] e.g. "https://rekor.sigstore.dev" + * @property {string} [public_receipt_base] e.g. "https://audit.example.com/receipts/" + * @property {string} [operator] Shown on the badge. + */ + +/** + * Resolve a human config into a validated profile object. + * + * @param {TransparencyConfig} config + * @returns {{ profile: string, definition: Object, warnings: string[] }} + */ +export function resolveProfile(config) { + const profileId = (config && config.profile) || 'private'; + const definition = PROFILE_DEFINITIONS[profileId]; + const warnings = []; + + if (!definition) { + return { + profile: 'private', + definition: PROFILE_DEFINITIONS.private, + warnings: [`unknown_profile:${profileId}:falling_back_to_private`], + }; + } + + // Soft requirement check: if profile claims "transparent" but no + // anchor_endpoint is configured, we warn rather than downgrade — + // the operator might be wiring it up. + if (definition.id === 'transparent' && !config.anchor_endpoint) { + warnings.push('transparent_profile_missing_anchor_endpoint'); + } + if (definition.id === 'high-assurance') { + if (!config.anchor_endpoint) { + warnings.push('high_assurance_profile_missing_anchor_endpoint'); + } + } + + return { profile: definition.id, definition, warnings }; +} + +/** + * Stamp a receipt with its transparency profile metadata. + * + * The stamp goes under `payload.transparency`: + * + * { + * transparency: { + * profile: "transparent", + * anchor_endpoint: "https://rekor.sigstore.dev", + * public_receipt_url: "https://audit.example.com/receipts/" + * } + * } + * + * This is DECLARATIVE only. It does not actually perform the anchoring + * — that's the job of the anchor pipeline (see `rekor.js` for the + * verifier side; the anchoring client is operator-run). + * + * Stamps the receipt in-place AND returns it for fluent composition. + * + * @param {Object} receipt + * @param {TransparencyConfig} config + */ +export function stampTransparency(receipt, config) { + if (!receipt || !receipt.payload) { + throw new Error('stampTransparency: receipt must have a payload'); + } + const resolved = resolveProfile(config); + const payloadStamp = { + profile: resolved.profile, + ...(config.anchor_endpoint ? { anchor_endpoint: config.anchor_endpoint } : {}), + ...(config.public_receipt_base + ? { public_receipt_base: config.public_receipt_base } + : {}), + }; + receipt.payload.transparency = payloadStamp; + return receipt; +} + +/** + * Decide whether a given receipt should be anchored publicly under + * the supplied profile. Callers can iterate a chain and selectively + * anchor only the qualifying receipts. + * + * Logic: + * - private: never anchor + * - auditable: never anchor (disclosure is on-demand) + * - transparent: anchor every receipt + * - high-assurance: anchor every receipt AND require cost_tier >= 2 + */ +export function shouldAnchorReceipt(receipt, profileId) { + const p = receipt && receipt.payload; + if (!p) return false; + switch (profileId) { + case 'private': + case 'auditable': + return false; + case 'transparent': + return true; + case 'high-assurance': + return Number(p.cost_tier ?? 0) >= 2; + default: + return false; + } +} + +/** + * Render a publicly-consumable transparency badge JSON. + * + * This JSON is published by operators on their public transparency + * page; consumers (regulators, auditors, end users) fetch it to + * discover what profile the operator operates under. + * + * @param {TransparencyConfig} config + * @param {{ receipts_anchored: number, last_anchored_at?: string }} [stats] + */ +export function renderBadge(config, stats = {}) { + const resolved = resolveProfile(config); + return { + format: 'veritasacta:transparency-badge/v1', + issued_at: new Date().toISOString(), + operator: config.operator || '(unspecified)', + profile: resolved.profile, + label: resolved.definition.label, + description: resolved.definition.description, + requires: resolved.definition.requires, + optional: resolved.definition.optional, + badge_color: resolved.definition.badge_color, + ...(config.anchor_endpoint ? { anchor_endpoint: config.anchor_endpoint } : {}), + ...(config.public_receipt_base + ? { public_receipt_base: config.public_receipt_base } + : {}), + stats: { + receipts_anchored: stats.receipts_anchored || 0, + last_anchored_at: stats.last_anchored_at || null, + }, + warnings: resolved.warnings, + }; +} diff --git a/src/engines/voprf-token.js b/src/engines/voprf-token.js new file mode 100644 index 0000000..2d8b425 --- /dev/null +++ b/src/engines/voprf-token.js @@ -0,0 +1,281 @@ +/** + * @veritasacta/verify — VOPRF token engine. + * + * Verifies VOPRF tokens in the production BRASS wire format using + * full Schnorr DLEQ verification for both the issuer proof (\u03c0I) and + * the client proof (\u03c0C). An envelope either verifies cryptographically + * or it does not; there is no structural-only partial mode. + * + * Wire format (as emitted by the production issuer and client at + * brass-proof-public/worker/issuer-cloudflare.js and + * client/src/lib/brass-strict-client.js): + * + * { + * algorithm: "voprf-p256-sha256", + * kid: , + * scope: { origin, epoch, sub?, policy? } OR flat origin/epoch, + * issuer_public_key: , + * KID, AADr, origin, epoch, + * P, M, Z, Zprime: , + * piI: { c, r }, // issuer DLEQ: log_G(Y) = log_M(Z) + * piC: { c, r }, // client DLEQ: knows b s.t. M = b\u00b7P + * y, eta, c: + * d_client, tlsHash: + * } + * + * \u03c0I verification reconstructs A1 = r_I\u00b7G + c_I\u00b7Y, A2 = r_I\u00b7M + c_I\u00b7Z, + * recomputes the challenge over (G, Y, M, Z, A1, A2), and accepts + * iff the computed challenge equals c_I. No bind context for \u03c0I. + * + * \u03c0C verification reconstructs A1 = r_C\u00b7P + c_C\u00b7M and uses A2=G + * (single-variable variant), recomputing the challenge over + * (P, M, G, G, A1, G, bind) where bind = H(BRASS_BIND_v1, y, c, d, + * AADr, KID, eta, tlsHash). + * + * @module verify-cli/src/engines/voprf-token + * @license Apache-2.0 + */ + +import { + G, + H, + Y_LABEL, + b64urlEncode, + b64urlDecode, + bytesToBig, + buildClientBindContext, + decodePoint, + deriveNullifier, + dleqVerifyClient, + dleqVerifyIssuer, + sentinelTlsHash, +} from '../util/voprf-crypto.js'; + +/** + * @typedef {Object} VoprfVerifyOptions + * @property {string} [issuerPublicKey] override issuer public key (base64url) + * @property {string} [verifierSalt] unused here; nullifier surfaces from token's eta + * @property {string} [expectedScope] require a specific scope origin + * @property {boolean} [requireClientProof] when true, reject tokens + * without a \u03c0C field. Default false (issuer-only verification is + * permitted; a token without \u03c0C has only been attested-as-issued, + * not attested-as-redeemed). + */ + +/** + * @typedef {Object} VoprfVerifyResult + * @property {boolean} valid + * @property {string} [error] + * @property {string} format + * @property {string} algorithm + * @property {Object} [scope] + * @property {string} [nullifier] base64url-encoded y + * @property {string} [kid] + * @property {string} [transport_hint] + * @property {Object} [dleq] {issuer: bool, client: bool|null} + */ + +/** + * Verify a VOPRF token. + * + * @param {Object} input + * @param {VoprfVerifyOptions} [opts] + * @returns {Promise} + */ +export async function verifyVoprfToken(input, opts = {}) { + const algorithm = input.algorithm || 'voprf-p256-sha256'; + if (algorithm !== 'voprf-p256-sha256') { + return { + valid: false, + error: 'unsupported_algorithm', + format: 'voprf-token', + algorithm, + }; + } + + const issuerPubKey = opts.issuerPublicKey || input.issuer_public_key; + if (!issuerPubKey) { + return { + valid: false, + error: 'missing_issuer_public_key', + format: 'voprf-token', + algorithm, + }; + } + + const required = ['M', 'Z', 'Zprime', 'piI']; + for (const f of required) { + if (input[f] === undefined || input[f] === null) { + return { + valid: false, + error: `missing_field:${f}`, + format: 'voprf-token', + algorithm, + }; + } + } + if (!input.piI || input.piI.c === undefined || input.piI.r === undefined) { + return { + valid: false, + error: 'malformed_piI', + format: 'voprf-token', + algorithm, + }; + } + + // Decode and validate points. + let M, Z, Zprime, Y; + try { + M = decodePoint(input.M); + Z = decodePoint(input.Z); + Zprime = decodePoint(input.Zprime); + Y = decodePoint(issuerPubKey); + } catch (err) { + return { + valid: false, + error: err && err.message ? err.message : 'invalid_point', + format: 'voprf-token', + algorithm, + }; + } + + // \u03c0I: issuer proof. + let piIValid; + try { + const cI = bytesToBig(b64urlDecode(input.piI.c)); + const rI = bytesToBig(b64urlDecode(input.piI.r)); + piIValid = dleqVerifyIssuer({ Y, M, Z, c: cI, r: rI }); + } catch { + piIValid = false; + } + if (!piIValid) { + return { + valid: false, + error: 'invalid_piI', + format: 'voprf-token', + algorithm, + kid: input.kid || input.KID, + dleq: { issuer: false, client: null }, + }; + } + + // \u03c0C: client proof (optional unless requireClientProof is set). + let piCValid = null; + if (input.piC) { + if (!input.P) { + return { + valid: false, + error: 'piC_requires_P', + format: 'voprf-token', + algorithm, + }; + } + if (input.piC.c === undefined || input.piC.r === undefined) { + return { + valid: false, + error: 'malformed_piC', + format: 'voprf-token', + algorithm, + }; + } + let P; + try { + P = decodePoint(input.P); + } catch (err) { + return { + valid: false, + error: err && err.message ? err.message : 'invalid_point_P', + format: 'voprf-token', + algorithm, + }; + } + + // Build the Fiat-Shamir bind transcript tag. + const KID = input.KID || input.kid || ''; + const AADr = input.AADr || ''; + const y = input.y ? b64urlDecode(input.y) : new Uint8Array(0); + const cNonce = input.c ? b64urlDecode(input.c) : new Uint8Array(0); + const d = input.d_client + ? b64urlDecode(input.d_client) + : (input.d ? b64urlDecode(input.d) : new Uint8Array(0)); + const eta = input.eta ? b64urlDecode(input.eta) : new Uint8Array(0); + const tlsHash = input.tlsHash ? b64urlDecode(input.tlsHash) : sentinelTlsHash(); + + const bindContext = buildClientBindContext({ + y, cNonce, d, AADr, KID, eta, tlsHash, + }); + + try { + const cC = bytesToBig(b64urlDecode(input.piC.c)); + const rC = bytesToBig(b64urlDecode(input.piC.r)); + piCValid = dleqVerifyClient({ P, M, c: cC, r: rC, bindContext }); + } catch { + piCValid = false; + } + + if (!piCValid) { + return { + valid: false, + error: 'invalid_piC', + format: 'voprf-token', + algorithm, + kid: input.kid || input.KID, + dleq: { issuer: true, client: false }, + }; + } + } else if (opts.requireClientProof) { + return { + valid: false, + error: 'missing_piC', + format: 'voprf-token', + algorithm, + kid: input.kid || input.KID, + dleq: { issuer: true, client: null }, + }; + } + + // Derive nullifier. + const KID = input.KID || input.kid || ''; + const AADr = input.AADr || ''; + const eta = input.eta ? b64urlDecode(input.eta) : new Uint8Array(0); + const ZprimeBytes = b64urlDecode(input.Zprime); + const nullifierBytes = deriveNullifier(ZprimeBytes, KID, AADr, eta); + const nullifier = b64urlEncode(nullifierBytes); + + // Scope and scope check. + const scope = input.scope || { + origin: input.origin, + epoch: input.epoch, + sub: input.sub, + }; + + if (opts.expectedScope) { + const actual = scope.origin || JSON.stringify(scope); + const expected = typeof opts.expectedScope === 'string' + ? opts.expectedScope + : JSON.stringify(opts.expectedScope); + if (actual !== expected) { + return { + valid: false, + error: 'scope_mismatch', + format: 'voprf-token', + algorithm, + scope, + }; + } + } + + return { + valid: true, + format: 'voprf-token', + algorithm, + scope, + nullifier, + kid: input.kid || input.KID, + transport_hint: input.transport_hint || 'direct', + dleq: { + issuer: true, + client: piCValid === null ? null : piCValid, + }, + }; +} diff --git a/src/engines/watch.js b/src/engines/watch.js new file mode 100644 index 0000000..91dccfc --- /dev/null +++ b/src/engines/watch.js @@ -0,0 +1,247 @@ +/** + * @veritasacta/verify — receipt watcher + webhook dispatcher. + * + * Watches a receipt directory in real time. On each new receipt, runs + * a configurable set of rules and, if any rule fires, POSTs a JSON + * notification to a webhook URL (Slack, Discord, PagerDuty, Opsgenie, + * or any generic URL). + * + * Designed for operators who want receipts to be an operational + * signal, not just an audit artifact. + * + * @module verify-cli/src/engines/watch + * @license Apache-2.0 + */ + +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { watch as fsWatch } from 'node:fs'; +import { join, resolve } from 'node:path'; + +/** + * @typedef {Object} WatchRule + * @property {string} id + * @property {'cost_tier_below'|'delegation_expiring_within'|'chain_break'|'deny_decision'|'scrub_triggered'} kind + * @property {Object} params + */ + +/** + * @typedef {Object} WatchOptions + * @property {string} receiptsDir + * @property {string} webhookUrl + * @property {WatchRule[]} rules + * @property {'slack'|'discord'|'generic'} [format='generic'] + * @property {(level: 'info'|'warn'|'error', msg: string) => void} [log] + */ + +/** + * Evaluate rules against a single receipt. Returns a list of firing + * rule results, each with an id, human-readable message, and the + * triggering receipt digest. + * + * @param {Object} receipt + * @param {WatchRule[]} rules + * @param {Date} [now] + * @returns {Array<{rule_id: string, kind: string, message: string}>} + */ +export function evaluateRules(receipt, rules, now = new Date()) { + const results = []; + const p = receipt.payload || {}; + + for (const rule of rules) { + switch (rule.kind) { + case 'cost_tier_below': { + const threshold = Number(rule.params.threshold); + const tier = Number(p.cost_tier ?? 0); + if (tier < threshold) { + results.push({ + rule_id: rule.id, + kind: rule.kind, + message: `cost_tier ${tier} below threshold ${threshold} on ${p.action || p.type}`, + }); + } + break; + } + case 'delegation_expiring_within': { + const withinSec = Number(rule.params.within_sec); + // Check top-level delegation receipts (type=delegation). + if (p.type === 'delegation' && p.expires_at) { + const expMs = Date.parse(p.expires_at); + const dt = (expMs - now.getTime()) / 1000; + if (Number.isFinite(dt) && dt > 0 && dt < withinSec) { + results.push({ + rule_id: rule.id, + kind: rule.kind, + message: `delegation ${p.delegate_kid || 'unknown'} expires in ${Math.round(dt)}s (threshold ${withinSec}s)`, + }); + } + } + break; + } + case 'chain_break': { + // Presence-only signal — chain break detection is typically + // upstream of the watcher; this rule fires when a chain + // explorer has marked this receipt. + if (p.link_valid === false || p.chain_break === true) { + results.push({ + rule_id: rule.id, + kind: rule.kind, + message: `chain break detected at ${p.action || p.type}`, + }); + } + break; + } + case 'deny_decision': { + if (p.decision === 'deny' || p.decision === 'denied') { + results.push({ + rule_id: rule.id, + kind: rule.kind, + message: `deny decision recorded for tool=${p.action || p.tool_name}`, + }); + } + break; + } + case 'scrub_triggered': { + if (Array.isArray(p.scrub_detected) && p.scrub_detected.length > 0) { + results.push({ + rule_id: rule.id, + kind: rule.kind, + message: `scrub_secrets redacted ${p.scrub_detected.length} field(s): ${p.scrub_detected.slice(0, 3).join(', ')}`, + }); + } + break; + } + default: + // Unknown kind: silently skip. Forward compatibility. + break; + } + } + return results; +} + +/** + * Format a firing rule as a Slack / Discord / generic JSON payload. + * + * @param {{rule_id: string, kind: string, message: string}} fired + * @param {Object} receipt + * @param {'slack'|'discord'|'generic'} format + */ +export function formatWebhookPayload(fired, receipt, format = 'generic') { + const base = { + rule_id: fired.rule_id, + kind: fired.kind, + message: fired.message, + receipt_kid: (receipt.signature && receipt.signature.kid) || null, + receipt_action: (receipt.payload && (receipt.payload.action || receipt.payload.tool_name)) || null, + receipt_issued_at: (receipt.payload && receipt.payload.issued_at) || null, + occurred_at: new Date().toISOString(), + source: 'veritasacta/verify:watch', + }; + if (format === 'slack') { + return { + text: `:rotating_light: Veritas Acta alert — ${fired.message}`, + blocks: [ + { + type: 'section', + text: { type: 'mrkdwn', text: `*${fired.rule_id}* fired: ${fired.message}` }, + }, + { + type: 'context', + elements: [ + { type: 'mrkdwn', text: `action=\`${base.receipt_action}\` kid=\`${base.receipt_kid}\` at ${base.receipt_issued_at}` }, + ], + }, + ], + }; + } + if (format === 'discord') { + return { + content: `🚨 **Veritas Acta alert** — ${fired.rule_id}: ${fired.message}`, + embeds: [ + { + title: fired.rule_id, + description: fired.message, + fields: [ + { name: 'action', value: base.receipt_action || '(none)', inline: true }, + { name: 'kid', value: base.receipt_kid || '(none)', inline: true }, + { name: 'issued_at', value: base.receipt_issued_at || '(none)', inline: false }, + ], + }, + ], + }; + } + return base; +} + +/** + * Start watching a receipt directory. Returns a stop() function. + * + * @param {WatchOptions} opts + * @returns {Promise<{ stop: () => void, url: string }>} + */ +export async function watchReceipts(opts) { + const { + receiptsDir, + webhookUrl, + rules = [], + format = 'generic', + log = () => {}, + } = opts; + + const dir = resolve(receiptsDir); + const seen = new Set(); + + // Seed with existing receipts so we don't fire on historical data. + try { + for (const entry of readdirSync(dir)) { + if (entry.endsWith('.json')) seen.add(entry); + } + } catch (err) { + throw new Error(`cannot_read_receipts_dir:${err.code || err.message}:${dir}`); + } + + log('info', `[watch] seeded with ${seen.size} existing receipt(s), watching ${dir}`); + + const watcher = fsWatch(dir, { persistent: true }, async (eventType, filename) => { + if (!filename || !filename.endsWith('.json')) return; + if (seen.has(filename)) return; + seen.add(filename); + + const p = join(dir, filename); + let st; + try { st = statSync(p); } catch { return; } + if (!st.isFile()) return; + + let receipt; + try { receipt = JSON.parse(readFileSync(p, 'utf-8')); } + catch (err) { + log('warn', `[watch] skipped ${filename}: ${err.message}`); + return; + } + + const fired = evaluateRules(receipt, rules); + if (fired.length === 0) return; + + log('info', `[watch] ${filename}: ${fired.length} rule(s) fired`); + + for (const f of fired) { + const payload = formatWebhookPayload(f, receipt, format); + try { + const res = await fetch(webhookUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + log('error', `[watch] webhook ${f.rule_id} HTTP ${res.status}`); + } + } catch (err) { + log('error', `[watch] webhook ${f.rule_id} error: ${err.message}`); + } + } + }); + + return { + url: dir, + stop: () => watcher.close(), + }; +} diff --git a/src/errors.js b/src/errors.js new file mode 100644 index 0000000..546c2cd --- /dev/null +++ b/src/errors.js @@ -0,0 +1,153 @@ +/** + * Canonical error codes emitted by the verifier. + * + * Each code has a stable name, human-readable description, spec reference + * where applicable, and classification (tampered / undecidable) that + * drives the process exit code. + * + * References: + * - draft-farley-acta-signed-receipts-03 §Error Codes (informative) + * + * @module verify-cli/src/errors + * @license Apache-2.0 + */ + +/** + * @typedef {'tampered' | 'undecidable' | 'unknown'} ErrorClass + */ + +/** + * @typedef {Object} ErrorMeta + * @property {string} code + * @property {string} description + * @property {ErrorClass} class + * @property {string} [spec] spec section reference + * @property {string} [hint] user-facing remediation hint + */ + +/** @type {Record} */ +export const ERROR_REGISTRY = { + // --- Tampered (exit 1) --- + invalid_signature: { + code: 'invalid_signature', + description: 'Cryptographic signature verification failed over the canonical payload.', + class: 'tampered', + spec: 'draft-farley-acta-signed-receipts-03 §6.1', + hint: 'The receipt has been modified or signed by a different key than the one provided.', + }, + chain_break: { + code: 'chain_break', + description: 'previousReceiptHash does not match the hash of the preceding receipt.', + class: 'tampered', + spec: 'draft-farley-acta-signed-receipts-03 §5.4 Chain Linkage', + hint: 'A receipt has been inserted, removed, or reordered in the chain.', + }, + commitment_mismatch: { + code: 'commitment_mismatch', + description: 'Selective-disclosure commitment does not match the revealed salt+value.', + class: 'tampered', + spec: 'AIP-0002 §Disclosure Package Verification', + hint: 'The disclosed value does not correspond to the committed hash.', + }, + dleq_verification_failed: { + code: 'dleq_verification_failed', + description: 'VOPRF DLEQ proof verification failed (issuer or client proof invalid).', + class: 'tampered', + spec: 'draft-farley-acta-signed-receipts-03 §VOPRF Token Verification', + hint: 'The VOPRF token was not produced by a valid issuer, or the proof is malformed.', + }, + + // --- Undecidable (exit 2) --- + embedded_key_rejected: { + code: 'embedded_key_rejected', + description: 'Receipt contains a verification key in its payload, which is not trusted by default.', + class: 'undecidable', + spec: 'draft-farley-acta-signed-receipts-03 §Security Considerations — Key Distribution', + hint: 'Provide --key, --jwks, or --trust-anchor externally. Pass --allow-embedded-key (deprecated, removed in 0.6) to restore pre-0.4.0 behaviour.', + }, + no_public_key: { + code: 'no_public_key', + description: 'Verification key could not be resolved from --key, --jwks, or bundle verification block.', + class: 'undecidable', + hint: 'Provide --key , --jwks , or --trust-anchor .', + }, + missing_signature: { + code: 'missing_signature', + description: 'Input does not contain a signature field.', + class: 'undecidable', + }, + missing_payload: { + code: 'missing_payload', + description: 'Input does not contain a payload field.', + class: 'undecidable', + }, + unsupported_algorithm: { + code: 'unsupported_algorithm', + description: 'The declared signature algorithm is not supported by this verifier version.', + class: 'undecidable', + hint: 'Hybrid post-quantum algorithms like ed25519+ml-dsa-65 require v0.6+ for full PQ verification.', + }, + non_ascii_key: { + code: 'non_ascii_key', + description: 'An object key contains a non-ASCII character, violating AIP-0001.', + class: 'undecidable', + spec: 'AIP-0001 §JCS Canonicalization', + }, + malformed_json: { + code: 'malformed_json', + description: 'Input could not be parsed as JSON.', + class: 'undecidable', + }, + malformed_hex: { + code: 'malformed_hex', + description: 'A hex-encoded value has odd length or contains invalid characters.', + class: 'undecidable', + }, + unknown_format: { + code: 'unknown_format', + description: 'Input does not match any recognized receipt, token, or bundle format.', + class: 'undecidable', + hint: 'Valid formats: v1 receipt, v2 receipt, Passport envelope, audit bundle, KU bundle, VOPRF token.', + }, + jwks_fetch_failed: { + code: 'jwks_fetch_failed', + description: 'JWKS endpoint did not return a valid key set.', + class: 'undecidable', + }, + context_requirement_unmet: { + code: 'context_requirement_unmet', + description: 'One or more --require-context predicates evaluated false at verification time.', + class: 'undecidable', + spec: 'Patent #5 claim 2 — Live-context verification', + }, + tier_not_achieved: { + code: 'tier_not_achieved', + description: 'Verification succeeded but did not achieve the tier required by --tier.', + class: 'undecidable', + }, +}; + +/** + * @param {string} code + * @returns {ErrorMeta | undefined} + */ +export function getError(code) { + return ERROR_REGISTRY[code]; +} + +/** + * Map an error code to its process exit code. + * 0 — valid (caller; not in registry) + * 1 — tampered + * 2 — undecidable or unknown error + * + * @param {string | undefined} code + * @returns {1 | 2} + */ +export function exitCodeFor(code) { + if (!code) return 2; + const meta = ERROR_REGISTRY[code]; + if (!meta) return 2; + if (meta.class === 'tampered') return 1; + return 2; +} diff --git a/src/output/html-report.js b/src/output/html-report.js new file mode 100644 index 0000000..f93daf7 --- /dev/null +++ b/src/output/html-report.js @@ -0,0 +1,145 @@ +/** + * HTML audit report generator. + * + * Produces a single self-contained HTML file suitable for delivery to + * an auditor, compliance team, or counterparty. Contains verification + * summary, per-receipt breakdown, Sigil attestation, and all the + * provenance metadata needed to independently re-verify. + * + * The HTML file is safe to email, publish, or print. It contains no + * external resources (styles are inline, no JS), renders in any modern + * browser, and includes the raw JSON of the verification result for + * programmatic re-use. + * + * @module verify-cli/src/output/html-report + * @license Apache-2.0 + */ + +/** + * Escape a string for safe HTML inclusion. + * @param {string|number|boolean|null|undefined} s + * @returns {string} + */ +function esc(s) { + if (s === null || s === undefined) return ''; + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Render the HTML audit report. + * + * @param {Object} args + * @param {Object} args.result bulk or single verification result + * @param {Object} args.sigil parsed sigil.json + * @param {Object} [args.attestation] canonical attestation (optional) + * @param {string} [args.title] report title + * @returns {string} + */ +export function renderHtmlReport({ result, sigil, attestation, title }) { + const reportTitle = title || 'Veritas Acta verification report'; + const generatedAt = new Date().toISOString(); + + const isBulk = result.total !== undefined; + const bulkSummary = isBulk ? ` + + + + + +
Total${result.total}
Verified${result.verified || result.passed || 0}
Failed${result.failed || 0}
Chain breaks${result.chainBreaks || 0}
+ ` : ` + + + + + + + + ${result.error ? `` : ''} +
Valid${result.valid ? 'YES' : 'NO'}
Format${esc(result.format)}
Mode${esc(result.modeLabel || result.format)}
Algorithm${esc(result.algorithm)}
Kid${esc(result.kid)}
Tier${esc(result.tier?.label || '—')}
Error${esc(result.error)}
+ `; + + const perReceipt = isBulk && result.receipts ? ` +

Per-receipt results

+ + + ${result.receipts.slice(0, 500).map((r) => ` + + + + + + + + + `).join('')} +
IndexValidTierTypeKidError
${r.index ?? ''}${r.valid ? '✓' : '✗'}T${r.tier ?? '—'}${esc(r.type ?? '—')}${esc(r.kid ?? '—')}${esc(r.error ?? '')}
+ ${result.receipts.length > 500 ? `

… ${result.receipts.length - 500} more receipts truncated from report (full data in raw JSON section below).

` : ''} + ` : ''; + + const attestationBlock = attestation ? ` +

Canonical attestation

+
${esc(JSON.stringify(attestation, null, 2))}
+

Publish this attestation to demonstrate the verifier was canonical at the time of this report.

+ ` : ''; + + return ` + + + + ${esc(reportTitle)} + + + +

${esc(reportTitle)}

+

Generated by @veritasacta/verify@${esc(sigil?.policy?.package_version || '0.5.0')} · Sigil ${esc(sigil?.name || '—')} (${esc(sigil?.fingerprint || '—')}) · ${esc(generatedAt)}

+ +

Summary

+ ${bulkSummary} + + ${perReceipt} + + ${attestationBlock} + +

Verifier provenance

+ + + + + + + +
Sigil fingerprint${esc(sigil?.fingerprint)}
Sigil name${esc(sigil?.name)}
Verifier version${esc(sigil?.policy?.package_version)}
Verifier package${esc(sigil?.policy?.package)}
IETF draft target${esc(sigil?.policy?.ietf_draft)}
Source hash${esc(sigil?.policy?.source_hash)}
+ +

Raw verification result (JSON)

+
${esc(JSON.stringify(result, null, 2))}
+ + + +`; +} diff --git a/src/output/json.js b/src/output/json.js new file mode 100644 index 0000000..966b440 --- /dev/null +++ b/src/output/json.js @@ -0,0 +1,30 @@ +/** + * JSON output formatter. + * + * Machine-readable structured output for CI/CD pipelines and other + * programmatic consumers. All fields are stable across patch versions. + * + * @module verify-cli/src/output/json + * @license Apache-2.0 + */ + +import { deriveFilteredSigil } from '../engines/sigil.js'; + +/** + * Format a verification result as structured JSON. + * + * @param {Object} result + * @returns {string} + */ +export function formatAsJson(result) { + const out = { ...result }; + if (result.valid && result.publicKey && result.publicKey.length === 64) { + const { fingerprint } = deriveFilteredSigil(result.publicKey); + out.sigil_fingerprint = fingerprint; + } + // Strip ANSI-specific fields and internal underscored fields + for (const k of Object.keys(out)) { + if (k.startsWith('_') && k !== '_partialReason') delete out[k]; + } + return JSON.stringify(out, null, 2); +} diff --git a/src/output/terminal.js b/src/output/terminal.js new file mode 100644 index 0000000..da30fd8 --- /dev/null +++ b/src/output/terminal.js @@ -0,0 +1,251 @@ +/** + * Terminal output formatter. + * + * Renders verification results with ANSI colors, Sigil art, and + * conformance tier labels. Subtle ecosystem wayfinding (Protocol / + * Managed URL lines) is included — factual, not promotional. + * + * @module verify-cli/src/output/terminal + * @license Apache-2.0 + */ + +import { deriveFilteredSigil } from '../engines/sigil.js'; + +const isCI = Boolean(process.env.CI || process.env.NO_COLOR); + +// ANSI color helpers (no-op in CI for log cleanliness) +const c = (code, s) => (isCI ? s : `\x1b[${code}m${s}\x1b[0m`); +export const green = (s) => c('32', s); +export const red = (s) => c('31', s); +export const yellow = (s) => c('33', s); +export const dim = (s) => c('2', s); +export const bold = (s) => c('1', s); +export const teal = (s) => c('36', s); +export const peach = (s) => (isCI ? s : `\x1b[38;5;216m${s}\x1b[0m`); + +/** + * Render the 11x11 terminal Sigil art for a public key. + * Deterministic across runs for a given key. + * + * @param {string} publicKeyHex + * @returns {string} + */ +export function renderTerminalSigil(publicKeyHex) { + const { grid, fingerprint } = deriveFilteredSigil(publicKeyHex); + const SIZE = 11; + const cx = 5, cy = 5; + const R = 5.5; + const outerR = R; + const midR = R * 0.72; + const innerR = R * 0.44; + const surroundR = innerR * 0.65; + const diamondR = surroundR * 0.6; + + const lines = []; + for (let y = 0; y < SIZE; y++) { + let row = ' '; + for (let x = 0; x < SIZE; x++) { + const dx = x - cx; + const dy = y - cy; + const dist = Math.sqrt(dx * dx + dy * dy); + const angle = Math.atan2(dy, dx); + const normAngle = angle < 0 ? angle + 2 * Math.PI : angle; + const segIdx = Math.floor((normAngle / (2 * Math.PI)) * 6) % 6; + + let state = 0; + if (dist <= diamondR) { + if (angle >= -Math.PI && angle < -Math.PI / 2) state = grid.diamond.left; + else if (angle >= -Math.PI / 2 && angle < 0) state = grid.diamond.top; + else if (angle >= 0 && angle < Math.PI / 2) state = grid.diamond.right; + else state = grid.diamond.bottom; + } else if (dist <= surroundR) { + const sIdx = Math.floor(((normAngle + Math.PI / 4) % (2 * Math.PI)) / (Math.PI / 2)) % 4; + state = grid.surround[sIdx]; + } else if (dist <= innerR) state = grid.innerRing[segIdx]; + else if (dist <= midR) state = grid.midRing[segIdx]; + else if (dist <= outerR) state = grid.outerRing[segIdx]; + else { + const cIdx = (y < cy ? 0 : 2) + (x < cx ? 0 : 1); + const cornerAngle = Math.atan2(y - cy, x - cx); + const cornerMidAngles = [-3 * Math.PI / 4, -Math.PI / 4, 3 * Math.PI / 4, Math.PI / 4]; + const half = (cIdx === 0 || cIdx === 1) + ? (cornerAngle < cornerMidAngles[cIdx] ? 0 : 1) + : (cornerAngle > cornerMidAngles[cIdx] ? 0 : 1); + state = grid.corners[cIdx * 2 + half]; + } + + if (state === 1) row += teal('█'); + else if (state === 2) row += peach('▓'); + else row += dim('·'); + } + lines.push(row); + } + lines.push(` ${dim('sigil:')} ${teal(fingerprint)}`); + return lines.join('\n'); +} + +const WAYFINDING = ` ${dim('Protocol:')} ${dim('https://veritasacta.com')} + ${dim('Managed:')} ${dim('https://scopeblind.com')} (optional) + + ${dim('No servers were contacted.')}`; + +/** + * Format a single-receipt verification result. + * + * @param {Object} result from any engine, normalized by cli.js + * @param {Object} opts cli options + * @returns {string} + */ +export function formatReceiptResult(result, opts = {}) { + const lines = []; + + if (result.valid && result.publicKey && result.publicKey.length === 64 && !isCI && !opts.noSigil) { + lines.push(''); + lines.push(renderTerminalSigil(result.publicKey)); + } + + const icon = result.valid ? green('✓') : red('✗'); + const status = result.valid ? green('VALID') : red('INVALID'); + lines.push(`\n${icon} Signature: ${status}`); + + if (result.format) lines.push(` Format: ${result.format}${result.specVersion ? ` (${result.specVersion})` : ''}`); + if (result.modeLabel) lines.push(` Mode: ${result.modeLabel}`); + if (result.type) lines.push(` Type: ${result.type}`); + if (result.algorithm) lines.push(` Algorithm: ${result.algorithm}`); + if (result.kid) lines.push(` Kid: ${result.kid}`); + if (result.issuer) lines.push(` Issuer: ${result.issuer}`); + if (result.keySource) lines.push(` Key: ${result.keySource}`); + if (result.tier) lines.push(` Tier: ${result.tier.label} ${dim(`(${result.tier.features.join(', ')})`)}`); + if (result.nullifier) lines.push(` Nullifier: ${result.nullifier.slice(0, 16)}...`); + if (result.scope) { + if (typeof result.scope === 'string') { + lines.push(` Scope: ${result.scope}`); + } else if (typeof result.scope === 'object') { + const s = result.scope; + const parts = []; + if (s.origin !== undefined) parts.push(`origin=${s.origin}`); + if (s.epoch !== undefined) parts.push(`epoch=${s.epoch}`); + if (s.sub !== undefined) parts.push(`sub=${s.sub}`); + if (parts.length > 0) lines.push(` Scope: ${parts.join(', ')}`); + } + } + if (result.transport_hint) lines.push(` Transport: ${result.transport_hint}`); + + if (result.attestationMode) { + lines.push(` Attestation: ${result.attestationMode}`); + if (result.attestationMode.startsWith('hardware:')) { + lines.push(` ${dim('Hardware-rooted attestation; see https://scopeblind.com/seal for details.')}`); + } + } + + if (result.disclosedFields && result.disclosedFields.length > 0) { + lines.push(` Disclosed: ${result.disclosedFields.join(', ')}`); + } + if (result.redactedFields && result.redactedFields.length > 0) { + lines.push(` Hidden: ${result.redactedFields.length} field(s) (cryptographically committed)`); + } + + if (result.contextChecks && result.contextChecks.length > 0) { + lines.push(` ${bold('Context checks:')}`); + for (const check of result.contextChecks) { + const ico = check.satisfied ? green('✓') : red('✗'); + lines.push(` ${ico} ${check.kind}: ${check.detail}`); + } + } + + if (result.hash) lines.push(` Hash: ${dim(result.hash)}`); + + if (result.error && !result.valid) { + lines.push(` Error: ${red(result.error)}`); + if (result.errorMeta?.spec) lines.push(` Spec: ${dim(result.errorMeta.spec)}`); + if (result.errorMeta?.hint) lines.push(` Hint: ${yellow(result.errorMeta.hint)}`); + } + + if (result._partialReason) { + lines.push(` ${yellow('Note:')} ${result._partialReason}`); + } + + lines.push(''); + lines.push(WAYFINDING); + lines.push(''); + return lines.join('\n'); +} + +export function formatBundleResult(result, opts = {}) { + const lines = []; + const icon = result.valid ? green('✓') : red('✗'); + const status = result.valid ? green('VALID') : red('INVALID'); + lines.push(`\n${icon} Bundle: ${status}`); + lines.push(` Total: ${result.total}`); + lines.push(` Passed: ${green(String(result.passed))}`); + lines.push(` Failed: ${result.failed > 0 ? red(String(result.failed)) : '0'}`); + if (Array.isArray(result.errors) && result.errors.length > 0) { + lines.push(`\n ${red('Errors:')}`); + for (const e of result.errors) lines.push(` ${red('•')} ${e}`); + } + lines.push(''); + lines.push(WAYFINDING); + lines.push(''); + return lines.join('\n'); +} + +export function formatKuResult(result, opts = {}) { + const lines = []; + const icon = result.valid ? green('✓') : red('✗'); + const status = result.valid ? green('VALID') : red('INVALID'); + lines.push(`\n${icon} Knowledge Unit: ${status}`); + if (result.topic) lines.push(` Topic: "${result.topic}"`); + if (result.totalReceipts !== undefined) { + lines.push(` Receipts: ${result.verifiedReceipts}/${result.totalReceipts} verified`); + } + if (result.models) lines.push(` Models: ${result.models.join(', ')}`); + if (result.rounds) lines.push(` Rounds: ${result.rounds}`); + if (result.consensusLevel) lines.push(` Consensus: ${result.consensusLevel}`); + if (result.dissentingModels && result.dissentingModels.length > 0) { + lines.push(` Dissent: ${result.dissentingModels.join(', ')} (explicitly recorded)`); + } + if (result.tier) lines.push(` Tier: ${result.tier.label}`); + lines.push(` Protocol: ${dim('draft-farley-acta-knowledge-units-00')}`); + if (result.errors) { + lines.push(`\n ${red('Errors:')}`); + for (const e of result.errors) lines.push(` ${red('•')} ${e}`); + } + lines.push(''); + lines.push(WAYFINDING); + lines.push(''); + return lines.join('\n'); +} + +export function formatSelfCheckResult(r) { + const lines = []; + if (r.canonical) { + lines.push(''); + lines.push(renderTerminalSigil(r.projectPublicKey || '')); + lines.push(''); + lines.push(` ${green('✓')} Canonical verifier — ${green(r.name || 'unnamed')}`); + lines.push(` Sigil: ${teal(r.fingerprint || '—')}`); + lines.push(` Version: ${r.version || '—'}`); + lines.push(` Package: ${r.pkg || '—'}`); + lines.push(` Source: ${dim((r.installedSourceHash || '').slice(0, 16) + '...')} ${green('matches commitment')}`); + lines.push(` Policy: ${green('matches commitment')}`); + lines.push(` Sigil: ${green('matches commitment')}`); + lines.push(''); + lines.push(` ${dim('This verifier is the unmodified canonical release.')}`); + lines.push(` ${dim('The source code has not been changed since it was published.')}`); + } else { + lines.push(`\n ${red('✗')} Modified verifier — NOT the canonical release\n`); + if (!r.sourceMatches) { + lines.push(` Source: ${red('MISMATCH')}`); + lines.push(` Installed: ${(r.installedSourceHash || '').slice(0, 32)}...`); + lines.push(` Expected: ${(r.committedSourceHash || '').slice(0, 32)}...`); + } + if (!r.policyMatches) lines.push(` Policy: ${red('MISMATCH')} — sigil.json may have been tampered with`); + if (!r.sigilMatches) lines.push(` Sigil: ${red('MISMATCH')} — the commitment chain is broken`); + lines.push(''); + lines.push(` ${yellow('This verifier has been modified since the canonical release.')}`); + lines.push(` ${yellow('It may be a fork, a development build, or a tampered copy.')}`); + lines.push(` ${dim('Get the canonical verifier: npm install @veritasacta/verify')}`); + } + lines.push(''); + return lines.join('\n'); +} diff --git a/src/util/audit-log.js b/src/util/audit-log.js new file mode 100644 index 0000000..12edef8 --- /dev/null +++ b/src/util/audit-log.js @@ -0,0 +1,68 @@ +/** + * Append-only audit log for verification events. + * + * Writes a single JSON-lines record per verification to a user-chosen + * file path. Never phones home; this is purely a local operator record. + * Useful for SIEM integration, compliance archival, and forensic review. + * + * Each record contains: timestamp, verifier version + Sigil, subject + * hash, verification result, optional org identifier, and (when signing + * is enabled) a signature by the attester key so the log itself is + * tamper-evident. + * + * @module verify-cli/src/util/audit-log + * @license Apache-2.0 + */ + +import { appendFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; + +/** + * @typedef {Object} AuditEntry + * @property {string} timestamp + * @property {string} verifier_version + * @property {string} sigil_fingerprint + * @property {string} [subject_hash] + * @property {string} [subject_kid] + * @property {string} [mode] + * @property {boolean} valid + * @property {string} [error] + * @property {number} [tier] + * @property {string} [org] + */ + +/** + * Append a verification event to an audit log file. + * + * @param {string} filePath + * @param {Object} result the verifier result + * @param {Object} context { sigil, org } + * @returns {AuditEntry} the record written + */ +export function appendAuditEntry(filePath, result, context = {}) { + const { sigil, org } = context; + const entry = { + timestamp: new Date().toISOString(), + verifier_version: sigil?.policy?.package_version || 'unknown', + sigil_fingerprint: sigil?.fingerprint || 'unknown', + subject_hash: result.hash ? `sha256:${result.hash}` : undefined, + subject_kid: result.kid, + mode: result.modeLabel || result.format, + valid: Boolean(result.valid), + error: result.error, + tier: result.tier?.tier, + }; + if (org) entry.org = org; + + // Compute a chain hash so the log is append-only-verifiable. + // Chain: hash = sha256(prev_hash || canonical(entry)) + // First entry's prev_hash is the empty string. + // The user can detect log tampering by re-computing the chain. + const canonicalEntry = JSON.stringify(entry, Object.keys(entry).sort()); + const chainInput = `${context.prevHash || ''}${canonicalEntry}`; + const chainHash = createHash('sha256').update(chainInput).digest('hex'); + entry._chain_hash = chainHash; + + appendFileSync(filePath, JSON.stringify(entry) + '\n', { flag: 'a' }); + return entry; +} diff --git a/src/util/canonical.js b/src/util/canonical.js new file mode 100644 index 0000000..392134b --- /dev/null +++ b/src/util/canonical.js @@ -0,0 +1,125 @@ +/** + * JCS canonicalization utilities. + * + * Implements RFC 8785 (JCS — JSON Canonicalization Scheme) with the + * AIP-0001 extension requiring ASCII-only object keys. This restriction + * sidesteps the Unicode normalization surface at the cost of rejecting + * non-ASCII keys. + * + * This module is pure: no I/O, no network, no side effects. + * + * References: + * - RFC 8785 (JCS) + * - AIP-0001 §JCS Canonicalization + * - draft-farley-acta-signed-receipts-03 §Canonicalization + * + * @module verify-cli/src/util/canonical + * @license Apache-2.0 + */ + +import { createHash } from 'node:crypto'; + +/** + * Assert that every object key in the given value is ASCII-only. + * Per AIP-0001, non-ASCII keys MUST be rejected at ingest. + * + * @param {unknown} obj + * @throws {Error} with code 'non_ascii_key' if any key is non-ASCII + */ +export function assertAsciiKeys(obj) { + if (obj === null || typeof obj !== 'object') return; + if (Array.isArray(obj)) { + for (const item of obj) assertAsciiKeys(item); + return; + } + for (const key of Object.keys(obj)) { + // ASCII range is 0x00-0x7F inclusive. We also reject control chars. + for (let i = 0; i < key.length; i++) { + const code = key.charCodeAt(i); + if (code > 0x7F) { + const err = new Error(`non-ASCII key rejected per AIP-0001: ${JSON.stringify(key)}`); + err.code = 'non_ascii_key'; + throw err; + } + } + assertAsciiKeys(obj[key]); + } +} + +/** + * Deep-sort object keys lexicographically (recursively). + * Arrays preserve order; objects are re-built with sorted keys. + * + * @param {unknown} obj + * @returns {unknown} + */ +export function sortKeysDeep(obj) { + if (obj === null || obj === undefined) return obj; + if (typeof obj !== 'object') return obj; + if (Array.isArray(obj)) return obj.map(sortKeysDeep); + const sorted = {}; + for (const key of Object.keys(obj).sort()) { + sorted[key] = sortKeysDeep(obj[key]); + } + return sorted; +} + +/** + * Normalize numbers per ECMAScript JSON.stringify: whole-number floats + * collapse to integers. Matches the Acta implementation convention. + * + * @param {unknown} obj + * @returns {unknown} + */ +export function normalizeNumbers(obj) { + if (obj === null || obj === undefined) return obj; + if (typeof obj === 'boolean') return obj; + if (typeof obj === 'number') { + // Only normalize finite whole-number floats; leave integers alone. + if (Number.isFinite(obj) && !Number.isInteger(obj) && Number.isInteger(obj)) { + return obj | 0; + } + return obj; + } + if (Array.isArray(obj)) return obj.map(normalizeNumbers); + if (typeof obj === 'object') { + const out = {}; + for (const k of Object.keys(obj)) out[k] = normalizeNumbers(obj[k]); + return out; + } + return obj; +} + +/** + * Produce the canonical JCS string for a given value, enforcing + * AIP-0001 ASCII-only keys. + * + * @param {unknown} obj + * @returns {string} + */ +export function canonicalize(obj) { + assertAsciiKeys(obj); + const normalized = normalizeNumbers(obj); + return JSON.stringify(sortKeysDeep(normalized)); +} + +/** + * SHA-256 of the canonical JCS encoding of the value. + * Returns a hex string (lowercase, no prefix). + * + * @param {unknown} obj + * @returns {string} + */ +export function canonicalHash(obj) { + return createHash('sha256').update(canonicalize(obj), 'utf8').digest('hex'); +} + +/** + * SHA-256 of a raw string. Convenience for non-JCS hashing paths. + * + * @param {string} s + * @returns {string} + */ +export function sha256Hex(s) { + return createHash('sha256').update(s, 'utf8').digest('hex'); +} diff --git a/src/util/fips.js b/src/util/fips.js new file mode 100644 index 0000000..3d4587c --- /dev/null +++ b/src/util/fips.js @@ -0,0 +1,62 @@ +/** + * FIPS mode enforcement. + * + * When --fips is set, the verifier accepts only algorithms that are + * approved for FIPS 140-3 use. Ed25519 is NOT FIPS 140-3 approved as + * of 2026; hybrid ed25519+ml-dsa-65 is conditionally approved once + * NIST finalizes ML-DSA (FIPS 204). + * + * This v0.5.0 implementation marks Ed25519 as non-FIPS and returns a + * clear error in FIPS mode. Full FIPS support waits for v0.6+ when + * hybrid PQ verification lands. + * + * @module verify-cli/src/util/fips + * @license Apache-2.0 + */ + +/** + * FIPS-approved algorithm list (per FIPS 140-3 plus drafts). + * Notes on status: + * - EdDSA / Ed25519: NOT FIPS 140-3 approved as of 2026-04. + * - ML-DSA-65 (FIPS 204): approved but not yet implemented in v0.5.0. + * - Hybrid classical+PQ: conditional, pending NIST guidance. + */ +const FIPS_APPROVED = new Set([ + 'ml-dsa-65', + 'ml-dsa-87', + // Hybrid modes are conditionally approved; the verifier accepts them in FIPS mode + // but v0.5.0 cannot fully verify them (returns unsupported_algorithm). + 'ed25519+ml-dsa-65', + 'ed25519+ml-dsa-87', +]); + +/** + * Check whether an algorithm is acceptable in FIPS mode. + * + * @param {string} algorithm + * @returns {boolean} + */ +export function fipsApproves(algorithm) { + if (!algorithm) return false; + return FIPS_APPROVED.has(algorithm.toLowerCase()); +} + +/** + * Describe FIPS status of an algorithm. + * + * @param {string} algorithm + * @returns {{approved: boolean, reason: string}} + */ +export function fipsStatus(algorithm) { + const lower = (algorithm || '').toLowerCase(); + if (lower === 'ed25519' || lower === 'eddsa') { + return { + approved: false, + reason: 'Ed25519 / EdDSA is not FIPS 140-3 approved as of 2026. For FIPS deployments use ed25519+ml-dsa-65 (requires v0.6+ for full verification).', + }; + } + if (FIPS_APPROVED.has(lower)) { + return { approved: true, reason: 'Algorithm is FIPS-approved or conditionally approved (hybrid).' }; + } + return { approved: false, reason: `Algorithm "${algorithm}" is not on the FIPS 140-3 approved list.` }; +} diff --git a/src/util/hex.js b/src/util/hex.js new file mode 100644 index 0000000..1f4bff5 --- /dev/null +++ b/src/util/hex.js @@ -0,0 +1,94 @@ +/** + * Hex / base64url encoding utilities. + * + * Constant-time hex byte comparison for signature verification paths. + * + * @module verify-cli/src/util/hex + * @license Apache-2.0 + */ + +/** + * Decode a hex string to a Uint8Array. + * + * @param {string} hex + * @returns {Uint8Array} + */ +export function hexToBytes(hex) { + if (typeof hex !== 'string') { + throw new Error('hex must be a string'); + } + // Strip common prefixes / whitespace + const clean = hex.replace(/^0x/i, '').replace(/\s+/g, ''); + if (clean.length === 0) return new Uint8Array(0); + if (clean.length % 2 !== 0) { + const err = new Error('hex string has odd length'); + err.code = 'malformed_hex'; + throw err; + } + const bytes = new Uint8Array(clean.length / 2); + for (let i = 0; i < bytes.length; i++) { + const byte = parseInt(clean.substr(i * 2, 2), 16); + if (Number.isNaN(byte)) { + const err = new Error(`invalid hex character at position ${i * 2}`); + err.code = 'malformed_hex'; + throw err; + } + bytes[i] = byte; + } + return bytes; +} + +/** + * Encode a Uint8Array to a hex string (lowercase, no prefix). + * + * @param {Uint8Array} bytes + * @returns {string} + */ +export function bytesToHex(bytes) { + let hex = ''; + for (const b of bytes) hex += b.toString(16).padStart(2, '0'); + return hex; +} + +/** + * Decode a base64url string to a Uint8Array. + * + * @param {string} b64url + * @returns {Uint8Array} + */ +export function base64urlToBytes(b64url) { + const pad = '='.repeat((4 - (b64url.length % 4)) % 4); + const b64 = (b64url + pad).replace(/-/g, '+').replace(/_/g, '/'); + const bin = Buffer.from(b64, 'base64'); + return new Uint8Array(bin.buffer, bin.byteOffset, bin.byteLength); +} + +/** + * Encode a Uint8Array to base64url. + * + * @param {Uint8Array} bytes + * @returns {string} + */ +export function bytesToBase64url(bytes) { + return Buffer.from(bytes).toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); +} + +/** + * Constant-time byte-wise equality check. + * Prevents timing-based discrimination between matching and + * almost-matching signatures. + * + * @param {Uint8Array} a + * @param {Uint8Array} b + * @returns {boolean} + */ +export function constantTimeEqual(a, b) { + if (!(a instanceof Uint8Array) || !(b instanceof Uint8Array)) return false; + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i]; + return diff === 0; +} diff --git a/src/util/jwks.js b/src/util/jwks.js new file mode 100644 index 0000000..9ccb90a --- /dev/null +++ b/src/util/jwks.js @@ -0,0 +1,133 @@ +/** + * JWKS (JSON Web Key Set) resolution utility. + * + * Resolves a JWKS from HTTP(S), file://, or a bare filesystem path and + * extracts an Ed25519 public key for a given kid, returning the hex-encoded + * raw key for use by the receipt verifier. + * + * Network resolution is opt-in: only HTTP(S) JWKS locators call fetch. + * Bare paths and file:// URLs are resolved from local disk for offline CI + * and test-vector workflows. + * + * References: + * - RFC 7517 (JWK) + * - RFC 7638 (JWK Thumbprint) + * - RFC 8037 (OKP Key Type for Ed25519) + * + * @module verify-cli/src/util/jwks + * @license Apache-2.0 + */ + +import { readFile } from 'node:fs/promises'; +import { isAbsolute, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { base64urlToBytes, bytesToHex } from './hex.js'; + +/** + * @typedef {Object} JwksResolveResult + * @property {string|null} key hex-encoded raw key, or null on failure + * @property {string|null} error + * @property {string} [kid] + * @property {{type: 'http'|'file', locator: string, resolved?: string}} [source] + */ + +/** + * Resolve a JWKS locator to parsed JSON. + * + * @param {string} locator HTTP(S) URL, file:// URL, or filesystem path + * @returns {Promise<{jwks: Object|null, error: string|null, source?: JwksResolveResult['source']}>} + */ +async function loadJwks(locator) { + if (typeof locator !== 'string' || locator.trim() === '') { + return { jwks: null, error: 'JWKS locator is empty' }; + } + + const raw = locator.trim(); + let parsed; + try { + parsed = new URL(raw); + } catch { + parsed = null; + } + + if (parsed?.protocol === 'http:' || parsed?.protocol === 'https:') { + try { + const response = await fetch(raw); + if (!response.ok) { + return { jwks: null, error: `JWKS fetch failed: HTTP ${response.status}` }; + } + return { + jwks: await response.json(), + error: null, + source: { type: 'http', locator: raw }, + }; + } catch (e) { + return { jwks: null, error: `JWKS fetch error: ${e.message}` }; + } + } + + let path; + try { + if (parsed?.protocol === 'file:') { + path = fileURLToPath(parsed); + } else if (parsed?.protocol) { + return { jwks: null, error: `Unsupported JWKS URL scheme: ${parsed.protocol}` }; + } else { + path = raw; + } + } catch (e) { + return { jwks: null, error: `JWKS file URL parse error: ${e.message}` }; + } + + const resolved = isAbsolute(path) ? path : resolve(process.cwd(), path); + try { + const text = await readFile(resolved, 'utf8'); + return { + jwks: JSON.parse(text), + error: null, + source: { type: 'file', locator: raw, resolved }, + }; + } catch (e) { + return { jwks: null, error: `JWKS file read error (${resolved}): ${e.message}` }; + } +} + +/** + * Resolve a JWKS locator and return the Ed25519 public key matching kid. + * If kid is not supplied, returns the first Ed25519 key found. + * + * @param {string} locator HTTP(S) URL, file:// URL, or filesystem path + * @param {string} [kid] + * @returns {Promise} + */ +export async function resolveFromJwks(locator, kid) { + const loaded = await loadJwks(locator); + if (!loaded.jwks) return { key: null, error: loaded.error }; + + try { + const keys = Array.isArray(loaded.jwks.keys) ? loaded.jwks.keys : []; + + let jwk; + if (kid) { + jwk = keys.find((k) => k.kid === kid); + if (!jwk) return { key: null, error: `No key with kid "${kid}" in JWKS`, source: loaded.source }; + } else { + jwk = keys.find((k) => k.kty === 'OKP' && k.crv === 'Ed25519'); + if (!jwk) return { key: null, error: 'No Ed25519 key found in JWKS', source: loaded.source }; + } + + if (jwk.kty !== 'OKP' || jwk.crv !== 'Ed25519') { + return { + key: null, + error: `Key "${kid}" is not Ed25519 (kty=${jwk.kty}, crv=${jwk.crv})`, + source: loaded.source, + }; + } + + const raw = base64urlToBytes(jwk.x); + return { key: bytesToHex(raw), error: null, kid: jwk.kid, source: loaded.source }; + } catch (e) { + return { key: null, error: `JWKS parse error: ${e.message}`, source: loaded.source }; + } +} diff --git a/src/util/merkle.js b/src/util/merkle.js new file mode 100644 index 0000000..c20308f --- /dev/null +++ b/src/util/merkle.js @@ -0,0 +1,176 @@ +/** + * RFC 6962-style Merkle tree verification helpers. + * + * Domain separation (RFC 6962 §2.1): + * leaf_hash = SHA-256(0x00 || leaf_bytes) + * internal_hash = SHA-256(0x01 || left_child_hash || right_child_hash) + * + * Without domain separation a leaf hash could collide with an internal + * node hash, allowing forged inclusion proofs. The 0x00 / 0x01 prefix + * is the standard fix used by Certificate Transparency, Sigstore Rekor, + * and every production Merkle log. + * + * Non-power-of-two leaf counts are handled by recursive split on the + * largest power of two strictly less than n (RFC 6962 §2.1). + * + * Compatible with draft-farley-acta-signed-receipts-01 §commitment-mode + * and protect-mcp@>=0.6.0 commitment-mode signing. + * + * @module verify-cli/src/util/merkle + * @license Apache-2.0 + */ + +import { createHash } from 'node:crypto'; +import { canonicalize } from './canonical.js'; + +/** Domain-separation byte for a Merkle leaf, per RFC 6962 §2.1. */ +export const DOMAIN_LEAF = 0x00; + +/** Domain-separation byte for a Merkle internal node, per RFC 6962 §2.1. */ +export const DOMAIN_INTERNAL = 0x01; + +/** + * @typedef {Object} MerkleProof + * @property {number} index zero-based index of the leaf + * @property {number} treeSize total leaf count + * @property {string[]} siblings hex-encoded SHA-256 siblings, bottom-up + */ + +/** + * Hash a leaf with RFC 6962 domain separation. + * @param {Buffer|Uint8Array} leafBytes canonical leaf bytes (no prefix) + * @returns {Buffer} 32-byte SHA-256(0x00 || leafBytes) + */ +export function hashLeaf(leafBytes) { + const buf = Buffer.alloc(leafBytes.length + 1); + buf[0] = DOMAIN_LEAF; + Buffer.from(leafBytes).copy(buf, 1); + return createHash('sha256').update(buf).digest(); +} + +/** + * Hash an internal node with RFC 6962 domain separation. + * @param {Buffer} left 32-byte left-child hash + * @param {Buffer} right 32-byte right-child hash + * @returns {Buffer} 32-byte SHA-256(0x01 || left || right) + */ +export function hashInternal(left, right) { + const buf = Buffer.alloc(left.length + right.length + 1); + buf[0] = DOMAIN_INTERNAL; + left.copy(buf, 1); + right.copy(buf, 1 + left.length); + return createHash('sha256').update(buf).digest(); +} + +/** + * Largest power of two strictly less than n. Defined for n >= 2. + * @param {number} n + * @returns {number} + */ +function largestPowerOfTwoLessThan(n) { + if (n < 2) { + throw new Error(`largestPowerOfTwoLessThan: n must be >= 2 (got ${n})`); + } + let k = 1; + while (k * 2 < n) k *= 2; + return k; +} + +/** + * Recursively reconstruct the Merkle root from a leaf hash, its index, + * the tree size, and the inclusion-proof siblings (bottom-up order). + * + * Siblings are consumed from the END of the array (last appended = + * outermost level) so the recursion peels off the outermost sibling at + * each level and recurses into the subtree containing the leaf. + * + * @param {Buffer} leafHash + * @param {number} index + * @param {number} treeSize + * @param {string[]} siblings hex-encoded SHA-256 sibling hashes + * @returns {Buffer} reconstructed root + */ +function reconstructRoot(leafHash, index, treeSize, siblings) { + if (treeSize === 1) { + if (siblings.length !== 0) { + throw new Error('reconstructRoot: extra siblings at single-leaf level'); + } + return leafHash; + } + if (siblings.length === 0) { + throw new Error('reconstructRoot: ran out of siblings before single-leaf'); + } + const k = largestPowerOfTwoLessThan(treeSize); + const outermostSibling = Buffer.from(siblings[siblings.length - 1], 'hex'); + const innerSiblings = siblings.slice(0, -1); + if (index < k) { + const leftHash = reconstructRoot(leafHash, index, k, innerSiblings); + return hashInternal(leftHash, outermostSibling); + } else { + const rightHash = reconstructRoot( + leafHash, + index - k, + treeSize - k, + innerSiblings, + ); + return hashInternal(outermostSibling, rightHash); + } +} + +/** + * Verify a Merkle inclusion proof against an expected root. + * + * @param {string} expectedRootHex lowercase hex SHA-256 root + * @param {Buffer} leafHash 32-byte domain-separated leaf hash + * (use hashLeaf to produce from leaf bytes) + * @param {MerkleProof} proof + * @returns {boolean} true iff the proof reconstructs the expected root + */ +export function verifyProof(expectedRootHex, leafHash, proof) { + if (!proof || typeof proof !== 'object') return false; + if (!Array.isArray(proof.siblings)) return false; + if (typeof proof.index !== 'number' || typeof proof.treeSize !== 'number') { + return false; + } + if (proof.index < 0 || proof.index >= proof.treeSize) return false; + if (proof.treeSize === 1) { + return ( + proof.siblings.length === 0 && + leafHash.toString('hex').toLowerCase() === expectedRootHex.toLowerCase() + ); + } + let result; + try { + result = reconstructRoot(leafHash, proof.index, proof.treeSize, proof.siblings); + } catch { + return false; + } + return result.toString('hex').toLowerCase() === expectedRootHex.toLowerCase(); +} + +/** + * Encode a single committed field as canonical leaf bytes. + * Per draft-farley-acta-signed-receipts-01 §commitment-leaf-layout: + * canonical_leaf_bytes = JCS({"name": ..., "salt": base64url(salt), "value": ...}) + * + * @param {string} name field name + * @param {string} saltB64Url base64url-encoded salt (no padding) + * @param {unknown} value original (cleartext) field value + * @returns {Buffer} canonical leaf bytes ready to feed into hashLeaf + */ +export function encodeLeaf(name, saltB64Url, value) { + const obj = { name, salt: saltB64Url, value }; + const canonical = canonicalize(obj); + return Buffer.from(canonical, 'utf8'); +} + +/** + * Decode a base64url string (with or without padding) to bytes. + * @param {string} s + * @returns {Buffer} + */ +export function base64urlDecode(s) { + const padded = s + '='.repeat((4 - (s.length % 4)) % 4); + const standard = padded.replace(/-/g, '+').replace(/_/g, '/'); + return Buffer.from(standard, 'base64'); +} diff --git a/src/util/voprf-crypto-v2.js b/src/util/voprf-crypto-v2.js new file mode 100644 index 0000000..1415be1 --- /dev/null +++ b/src/util/voprf-crypto-v2.js @@ -0,0 +1,199 @@ +/** + * BRASS v2 crypto hardening scaffold. + * + * Introduces three changes from the production v1 scheme + * (see src/util/voprf-crypto.js): + * + * 1. Length-prefixed hashing (H_LP) — every variable-length input is + * preceded by its 4-byte big-endian length. This eliminates the + * concat-collision surface in the piC bind where AADr and KID are + * both variable-length strings. Under v1, an attacker who can + * influence AADr and KID could in principle find (A, B) and + * (A', B') such that A||B == A'||B'; under v2 this is infeasible + * because the length prefixes force unambiguous parsing. + * + * 2. Nullifier derivation bound to issuer public key Y. Under v1 the + * nullifier is derived from (kid || ...) alone; if two issuers + * ever collided on kid strings, their nullifier spaces would + * overlap. v2 adds Y's encoded point bytes as the first input so + * distinct issuer keypairs ALWAYS produce distinct nullifier + * spaces, even under kid collision. + * + * 3. Single-variable πC restatement — the production BRASS piC uses + * a DLEQ shape with A2=G hardcoded, which is cryptographically + * equivalent to a single-variable Schnorr but reads as degenerate + * to external reviewers. v2 restates it as a plain Schnorr PoK of + * `r` satisfying `M = r·P`, keeping the wire (c, r) unchanged. + * + * Wire format stays identical across v1 and v2. The difference is + * WHICH hash function is used in the challenge derivation. Dual-mode + * verifiers MUST accept either during the v0.6.0–v0.7.0 transition + * window and emit a tier warning when a v1-derived token is accepted. + * + * Target release: `@veritasacta/verify@0.6.0`. + * Status in 0.5.2: scaffold only — not wired into the default + * verification path. Exercised by unit tests and surfaced via + * `cli.js --brass-v2` flag in a later release. + * + * @module verify-cli/src/util/voprf-crypto-v2 + * @license Apache-2.0 + */ + +import { sha256 } from '@noble/hashes/sha256'; +import { utf8ToBytes } from '@noble/hashes/utils'; + +import { + G, modN, bytesToBig, b64urlEncode, DLEQ_LABEL, BIND_LABEL, Y_LABEL, +} from './voprf-crypto.js'; + +/** + * Length-prefixed hash. Each variadic argument is converted to bytes + * and prefixed with its 4-byte big-endian length before concatenation. + * + * Format: `LP(p_1, …, p_n) = len32(p_1) || p_1 || … || len32(p_n) || p_n` + * + * Digest is SHA-256 of the concatenation. + */ +export function H_LP(...parts) { + const bufs = []; + let total = 0; + + for (const p of parts) { + const b = toBytes(p); + const len = new Uint8Array(4); + // Big-endian 4-byte length. + len[0] = (b.length >>> 24) & 0xff; + len[1] = (b.length >>> 16) & 0xff; + len[2] = (b.length >>> 8) & 0xff; + len[3] = (b.length) & 0xff; + bufs.push(len, b); + total += 4 + b.length; + } + + const combined = new Uint8Array(total); + let off = 0; + for (const b of bufs) { + combined.set(b, off); + off += b.length; + } + return sha256(combined); +} + +/** + * Labelled length-prefixed hash. Prefixes with `BRASS:v2: