@askalf/truecopy 0.0.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,75 @@
1
+ // OS-keychain storage for canon's PRIVATE signing key — held by the operating
2
+ // system, tied to your login, never written as plaintext. Zero deps: each
3
+ // platform's native CLI/API.
4
+ // macOS → Keychain (security)
5
+ // Linux → Secret Service (secret-tool / libsecret)
6
+ // Windows → DPAPI user-scope (PowerShell; an encrypted blob only this user can read)
7
+ // The PUBLIC key + keyId stay in ~/.canon/signing-key.json (public is not secret).
8
+ // CANON_KEYCHAIN_FAKE=<file> is a TEST seam (a plain JSON file standing in for the
9
+ // keychain); CANON_NO_KEYCHAIN forces the plaintext-file fallback.
10
+ import { execFileSync } from 'node:child_process';
11
+ import fs from 'node:fs';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+
15
+ const SERVICE = 'canon', ACCOUNT = 'signing-key';
16
+ const home = () => path.join(process.env.CANON_HOME || os.homedir(), '.canon');
17
+ const blobFile = () => path.join(home(), 'signing-key.dpapi');
18
+ const run = (cmd, args, input) => execFileSync(cmd, args, { input, stdio: ['pipe', 'pipe', 'ignore'], encoding: 'utf8' });
19
+ const fake = () => process.env.CANON_KEYCHAIN_FAKE;
20
+
21
+ export function keychainAvailable() {
22
+ if (process.env.CANON_NO_KEYCHAIN) return false; // explicit opt-out / test seam
23
+ if (fake()) return true;
24
+ if (process.platform === 'darwin' || process.platform === 'win32') return true; // security / DPAPI are built in
25
+ if (process.platform === 'linux') { try { run('secret-tool', ['--version']); return true; } catch { return false; } }
26
+ return false;
27
+ }
28
+
29
+ /** Read the stored value (string), or null if none. */
30
+ export function keychainGet() {
31
+ if (fake()) { try { return JSON.parse(fs.readFileSync(fake(), 'utf8')).value || null; } catch { return null; } }
32
+ try {
33
+ if (process.platform === 'darwin') return run('security', ['find-generic-password', '-a', ACCOUNT, '-s', SERVICE, '-w']).trim() || null;
34
+ if (process.platform === 'linux') return run('secret-tool', ['lookup', 'service', SERVICE, 'account', ACCOUNT]).trim() || null;
35
+ if (process.platform === 'win32') {
36
+ const blob = blobFile();
37
+ if (!fs.existsSync(blob)) return null;
38
+ const enc = fs.readFileSync(blob, 'utf8').trim();
39
+ const ps = `Add-Type -AssemblyName System.Security; $b=[Convert]::FromBase64String('${enc}'); $p=[Security.Cryptography.ProtectedData]::Unprotect($b,$null,'CurrentUser'); [Console]::Out.Write([Text.Encoding]::UTF8.GetString($p))`;
40
+ return run('powershell', ['-NoProfile', '-NonInteractive', '-Command', ps]).trim() || null;
41
+ }
42
+ } catch { return null; }
43
+ return null;
44
+ }
45
+
46
+ /** Store the value (string) in the OS keychain. On Linux (secret-tool) and Windows
47
+ * (PowerShell DPAPI) the value goes via stdin, so it never appears in a process
48
+ * listing. macOS `security` has NO stdin password input (its -w prompt reads /dev/tty,
49
+ * not stdin), so there the value is passed as an argument — briefly visible in `ps` for
50
+ * the command's duration. A CLI limitation; closing it fully needs the native Security
51
+ * framework (a non-CLI follow-up). */
52
+ export function keychainSet(value) {
53
+ if (fake()) { fs.mkdirSync(path.dirname(fake()), { recursive: true }); fs.writeFileSync(fake(), JSON.stringify({ value }), { mode: 0o600 }); return; }
54
+ // macOS: `security` can't take the password on stdin (see note above), so it goes as an
55
+ // argument and is briefly visible in `ps`. Accepted CLI limitation — do NOT "fix" this
56
+ // with `-w` and no inline value: that reads /dev/tty, not stdin, and silently stores the
57
+ // wrong secret (caught by the macOS CI added in this change).
58
+ if (process.platform === 'darwin') { run('security', ['add-generic-password', '-a', ACCOUNT, '-s', SERVICE, '-w', value, '-U']); return; }
59
+ if (process.platform === 'linux') { run('secret-tool', ['store', '--label=canon signing key', 'service', SERVICE, 'account', ACCOUNT], value); return; }
60
+ if (process.platform === 'win32') {
61
+ const ps = `Add-Type -AssemblyName System.Security; $p=[Text.Encoding]::UTF8.GetBytes([Console]::In.ReadToEnd()); $b=[Security.Cryptography.ProtectedData]::Protect($p,$null,'CurrentUser'); [Console]::Out.Write([Convert]::ToBase64String($b))`;
62
+ const enc = run('powershell', ['-NoProfile', '-NonInteractive', '-Command', ps], value).trim();
63
+ fs.mkdirSync(home(), { recursive: true });
64
+ fs.writeFileSync(blobFile(), enc, { mode: 0o600 });
65
+ return;
66
+ }
67
+ throw new Error('canon: no OS keychain available on this platform');
68
+ }
69
+
70
+ export const keychainKind = () =>
71
+ fake() ? 'test-fake'
72
+ : process.platform === 'darwin' ? 'macOS Keychain'
73
+ : process.platform === 'win32' ? 'Windows DPAPI (user scope)'
74
+ : process.platform === 'linux' ? 'Secret Service (libsecret)'
75
+ : 'unknown';
package/src/lock.mjs ADDED
@@ -0,0 +1,44 @@
1
+ // canon.lock — the pinned, vetted set. One entry per trusted skill: where it came
2
+ // from, the content hash you trusted, the scan verdict at pin time, and (optional)
3
+ // a signature. `verify` re-derives the hash and flags any drift from this file.
4
+ import fs from 'node:fs';
5
+
6
+ export const DEFAULT_LOCK = 'canon.lock';
7
+
8
+ // `skills` is ALWAYS a NULL-PROTOTYPE map, on every readLock path — so a skill
9
+ // keyed by a prototype member ("__proto__", "toString", "constructor", …) can't
10
+ // hijack `[[Set]]`/`[[Get]]`. On a plain object `lock.skills["__proto__"] = entry`
11
+ // invokes the __proto__ setter (the entry is silently dropped — pin reports
12
+ // success but writes nothing), and `"toString" in lock.skills` is always true
13
+ // (unpin "removes" a phantom, and on a fresh lock even CREATES the file). A
14
+ // null-proto map makes every key an ordinary own property. Also guards the
15
+ // corrupt/hostile `skills: null | array | string` shape (would crash
16
+ // Object.entries / index assignment).
17
+ const asSkills = (s) =>
18
+ Object.assign(Object.create(null), s && typeof s === 'object' && !Array.isArray(s) ? s : {});
19
+ const emptyLock = () => ({ version: 1, skills: asSkills(null) });
20
+
21
+ // A MISSING lock and a CORRUPT lock are different: an absent lock with
22
+ // mustExist=false is a legitimately empty trust set; a present-but-unparseable
23
+ // lock (truncated, merge-conflict markers, non-object) must fail CLOSED — never
24
+ // silently degrade to "nothing pinned, all clear".
25
+ export function readLock(p = DEFAULT_LOCK, { mustExist = false } = {}) {
26
+ let raw;
27
+ try { raw = fs.readFileSync(p, 'utf8'); }
28
+ catch (e) {
29
+ if (e && e.code === 'ENOENT') {
30
+ if (mustExist) { const err = new Error(`no canon.lock at ${p}`); err.code = 'ELOCKMISSING'; throw err; }
31
+ return emptyLock();
32
+ }
33
+ throw e;
34
+ }
35
+ let l;
36
+ try { l = JSON.parse(raw); }
37
+ catch (e) { const err = new Error(`canon.lock at ${p} is present but unparseable: ${e.message}`); err.code = 'ELOCKCORRUPT'; throw err; }
38
+ if (!l || typeof l !== 'object' || Array.isArray(l)) { const err = new Error(`canon.lock at ${p} is not a lock object`); err.code = 'ELOCKCORRUPT'; throw err; }
39
+ return { version: 1, ...l, skills: asSkills(l.skills) };
40
+ }
41
+
42
+ export function writeLock(lock, p = DEFAULT_LOCK) {
43
+ fs.writeFileSync(p, JSON.stringify(lock, null, 2) + '\n');
44
+ }
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env node
2
+ // canon-mcp — enforce canon.lock on a live MCP server. Point your MCP client at
3
+ // canon-mcp [--lock canon.lock] [--name <pinned>] [--strict] -- <server cmd...>
4
+ // Only vetted, unmodified, unpoisoned tools reach the client.
5
+ import { runGate } from './mcp.mjs';
6
+
7
+ const argv = process.argv.slice(2);
8
+ const sep = argv.indexOf('--');
9
+ const pre = sep >= 0 ? argv.slice(0, sep) : argv;
10
+ const cmd = sep >= 0 ? argv.slice(sep + 1) : [];
11
+ const opt = (n, d) => {
12
+ const i = pre.indexOf(n);
13
+ if (i >= 0) { const nx = pre[i + 1]; return nx !== undefined && !nx.startsWith('--') ? nx : true; } // `--n value` or bare `--n`
14
+ const eq = pre.find((x) => x.startsWith(n + '='));
15
+ return eq ? eq.slice(n.length + 1) : d;
16
+ };
17
+
18
+ if (!cmd.length || pre.includes('-h') || pre.includes('--help')) {
19
+ process.stderr.write('usage: canon-mcp [--lock canon.lock] [--name <pinned>] [--strict] -- <mcp-server cmd...>\n');
20
+ process.exit(cmd.length ? 0 : 2);
21
+ }
22
+ runGate({ command: cmd[0], args: cmd.slice(1), lockPath: opt('--lock', 'canon.lock'), name: opt('--name', null) || null, strict: !!opt('--strict', false) });
package/src/mcp.mjs ADDED
@@ -0,0 +1,102 @@
1
+ // canon-mcp — a stdio MCP proxy that ENFORCES canon.lock at runtime. It sits
2
+ // between an MCP client and a downstream MCP server, filters `tools/list` down to
3
+ // the vetted set, and blocks `tools/call` to anything it dropped. Un-vetted,
4
+ // drifted, or poisoned tools never reach the agent. JSON-RPC over newline stdio.
5
+ //
6
+ // Where warden-mcp firewalls what a tool DOES, canon-mcp gates what tools EXIST.
7
+ import { spawn } from 'node:child_process';
8
+ import readline from 'node:readline';
9
+ import { gateTools, toolHash } from './gate.mjs';
10
+ import { readLock } from './lock.mjs';
11
+
12
+ const blockReply = (id, name) =>
13
+ JSON.stringify({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: `⛔ canon blocked an un-vetted / drifted tool: ${name}` }], isError: true } });
14
+
15
+ const batchError = (msg) => {
16
+ // JSON-RPC batching was removed from MCP (2025-06-18) and would let a tools/call
17
+ // to a dropped tool ride past the single-message gate — so reject a client batch
18
+ // rather than forward it raw. id null per JSON-RPC for a batch-level error.
19
+ const id = Array.isArray(msg) ? (msg.find((m) => m && m.id != null)?.id ?? null) : null;
20
+ return { reply: JSON.stringify({ jsonrpc: '2.0', id, error: { code: -32600, message: '⛔ canon: JSON-RPC batching is not supported through the gate — send individual requests' } }) };
21
+ };
22
+
23
+ /** client → server. { forward } to pass on, or { reply } to short-circuit a blocked call. */
24
+ export function inspectClient(line, state) {
25
+ let msg;
26
+ try { msg = JSON.parse(line); } catch { return { forward: line }; }
27
+ if (Array.isArray(msg)) return batchError(msg); // a batch could smuggle a blocked tools/call — fail closed
28
+ if (msg && msg.method && msg.id != null) state.pending[msg.id] = msg.method;
29
+ // Fail CLOSED on calls: a tool is callable only once we've SEEN and vetted a
30
+ // tools/list AND its name survived that gating. Before any gated list (`listed`
31
+ // is false), block everything — a tool known to the agent out-of-band, or
32
+ // smuggled past tools/list, must not be forwarded and executed.
33
+ if (msg && msg.method === 'tools/call') {
34
+ const name = msg.params?.name;
35
+ if (!state.listed || !state.allowedNames?.has(name) || state.blocked.has(name)) return { reply: blockReply(msg.id, name) };
36
+ }
37
+ return { forward: line };
38
+ }
39
+
40
+ // Gate ONE server→client message in place: strip any tools array down to the
41
+ // vetted set and record vetted/blocked names. Non-tools messages pass through
42
+ // (their pending id is cleared). Extracted so a JSON-RPC batch can gate each
43
+ // element instead of forwarding the array raw.
44
+ function gateServerMsg(msg, state, opts) {
45
+ const isToolsResult = msg && msg.result && Array.isArray(msg.result.tools);
46
+ if (!isToolsResult) { if (msg && msg.id != null) delete state.pending[msg.id]; return msg; }
47
+ if (msg.id != null) delete state.pending[msg.id];
48
+ const { allowed, report } = gateTools(msg.result.tools, opts.entry);
49
+ const blockAll = opts.strict && report.some((r) => r.status !== 'vetted'); // strict: any problem → block the whole server
50
+ for (const r of report) if (r.status !== 'vetted') { state.blocked.add(r.tool); opts.onWarn?.(`${blockAll ? 'strict — blocking all (' : 'dropped '}${r.tool} (${r.status})${blockAll ? ')' : ''}`); }
51
+ const keep = blockAll ? new Set() : allowed; // allowed is now a Set of hashes
52
+ const kept = msg.result.tools.filter((t) => t && typeof t === 'object' && keep.has(toolHash(t)));
53
+ msg.result.tools = kept;
54
+ // Remember the vetted names so a later tools/call can be allow-listed (fail
55
+ // closed). `listed` flips the call gate from blocklist to allowlist.
56
+ state.listed = true;
57
+ state.allowedNames = state.allowedNames || new Set();
58
+ for (const t of kept) state.allowedNames.add(t.name);
59
+ return msg;
60
+ }
61
+
62
+ /** server → client. Rewrites ANY result carrying a tools array down to the vetted
63
+ * set — not only one correlated to a tracked tools/list id. A hostile server can
64
+ * push an UNSOLICITED tools result, answer tools/list TWICE (the 2nd after the id
65
+ * is cleared), bury tools in another reply, or hide a tools/list inside a
66
+ * JSON-RPC BATCH array; all must be gated, never forwarded raw. */
67
+ export function inspectServer(line, state, opts = {}) {
68
+ let msg;
69
+ try { msg = JSON.parse(line); } catch { return { forward: line }; }
70
+ if (Array.isArray(msg)) return { forward: JSON.stringify(msg.map((m) => gateServerMsg(m, state, opts))) };
71
+ return { forward: JSON.stringify(gateServerMsg(msg, state, opts)) };
72
+ }
73
+
74
+ /** Which lock entry governs this server: explicit --name, else the sole MCP entry. */
75
+ export function pickEntry(lock, name) {
76
+ if (name) return (lock.skills && lock.skills[name]) || null;
77
+ const mcp = Object.entries(lock.skills || {}).filter(([, e]) => e.kind === 'mcp');
78
+ return mcp.length === 1 ? mcp[0][1] : null;
79
+ }
80
+
81
+ /** Spawn the downstream server and wire the two gated streams together. */
82
+ export function runGate({ command, args = [], lockPath = 'canon.lock', name = null, strict = false, onWarn } = {}) {
83
+ const warn = onWarn || ((m) => process.stderr.write('[canon] ' + m + '\n'));
84
+ const entry = pickEntry(readLock(lockPath), name);
85
+ if (!entry) warn(`no pinned ${name ? '"' + name + '" ' : ''}MCP entry in ${lockPath} — every tool will be treated as unvetted (canon add it first)`);
86
+ const child = spawn(command, args, { stdio: ['pipe', 'pipe', 'inherit'] });
87
+ const state = { pending: {}, blocked: new Set(), allowedNames: new Set(), listed: false };
88
+ const opts = { entry, strict, onWarn: warn };
89
+
90
+ readline.createInterface({ input: process.stdin }).on('line', (line) => {
91
+ if (!line.trim()) return;
92
+ const r = inspectClient(line, state);
93
+ if (r.reply) process.stdout.write(r.reply + '\n');
94
+ if (r.forward) child.stdin.write(r.forward + '\n');
95
+ });
96
+ readline.createInterface({ input: child.stdout }).on('line', (line) => {
97
+ if (!line.trim()) return;
98
+ process.stdout.write(inspectServer(line, state, opts).forward + '\n');
99
+ });
100
+ process.stdin.on('end', () => { try { child.stdin.end(); } catch {} });
101
+ child.on('exit', (code) => process.exit(code ?? 0));
102
+ }
package/src/scan.mjs ADDED
@@ -0,0 +1,66 @@
1
+ // Poison scan — reuse warden's supply-chain scanner (the OpenClaw poisoned-skill
2
+ // class: injection / exfil instructions hidden in a tool's name, description, or
3
+ // schema). canon adds the provenance layer on top; warden owns the detection.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { createRequire } from 'node:module';
7
+ import { scanMcpTools } from '@askalf/redstamp/mcp';
8
+ import { decide } from '@askalf/redstamp';
9
+ import { canonicalJson } from './hash.mjs';
10
+
11
+ // Which detection engine vetted a pin. redstamp is a pinned dependency that gets
12
+ // bumped, so "clean" is always clean-as-of-some-ruleset — recording the version at
13
+ // pin time lets verify explain the "same bytes, suddenly poisoned" outcome as
14
+ // detection-improved rather than looking like a tamper. Resolved locally and
15
+ // offline; redstamp's exports map hides ./package.json, so resolve the entry
16
+ // module and walk up to its package.json. Never throws — provenance is an
17
+ // enrichment, and an unreadable version must not break pinning.
18
+ const resolveEngineEntry = () => createRequire(import.meta.url).resolve('@askalf/redstamp');
19
+ let cached; // per-process: the engine version can't change mid-run
20
+ export function detectionInfo(resolveEntry) {
21
+ if (!resolveEntry && cached !== undefined) return cached;
22
+ let det = null;
23
+ try {
24
+ let dir = path.dirname((resolveEntry || resolveEngineEntry)());
25
+ for (let up = path.dirname(dir); ; dir = up, up = path.dirname(dir)) {
26
+ const p = path.join(dir, 'package.json');
27
+ if (fs.existsSync(p)) {
28
+ const pkg = JSON.parse(fs.readFileSync(p, 'utf8'));
29
+ if (pkg && pkg.name === '@askalf/redstamp' && pkg.version) { det = { engine: 'redstamp', version: pkg.version }; break; }
30
+ }
31
+ if (up === dir) break;
32
+ }
33
+ } catch { det = null; }
34
+ if (!resolveEntry) cached = det;
35
+ return det;
36
+ }
37
+
38
+ /** Scan a loaded skill for poisoning. → { verdict: 'clean'|'flagged', findings, advisories }
39
+ *
40
+ * Severity-aware by SURFACE. Long-form skill/file prose flags only on a
41
+ * CRITICAL finding — an injection/exfil *instruction*; a bare sensitive-path or
42
+ * secret-env *mention* comes back as an advisory instead (documentation
43
+ * legitimately teaches credential handling — the official Claude Code
44
+ * marketplace flagged 19/29 skills on mentions alone). MCP tool definitions
45
+ * keep the strict any-finding rule: short descriptions are exactly the surface
46
+ * the mention heuristics were tuned for. A finding without a `severity` field
47
+ * (an older warden) is treated as critical — fail closed, never laxer. */
48
+ export function scanSkill(skill) {
49
+ const all = scanMcpTools((skill && skill.scanTargets) || []);
50
+ const prose = !!skill && (skill.kind === 'skill' || skill.kind === 'file');
51
+ const findings = prose ? all.filter((f) => (f && f.severity) !== 'advisory') : all;
52
+ const advisories = prose ? all.filter((f) => f && f.severity === 'advisory') : [];
53
+ // An MCP manifest's launch fields (command/args/env) actually RUN when the
54
+ // server starts — an RCE or key-exfil hidden there must be caught too, not just
55
+ // poison in the tool descriptions.
56
+ const lz = skill && skill.launch;
57
+ if (lz && (lz.command || lz.args || lz.env)) {
58
+ const cmd = [lz.command, ...(Array.isArray(lz.args) ? lz.args : []), lz.env ? canonicalJson(lz.env) : '']
59
+ .filter(Boolean).join(' ');
60
+ const v = decide({ tool: 'shell', input: { command: cmd } });
61
+ if (v.tier === 'black' || v.tier === 'red') {
62
+ findings.push({ tool: (skill.name || 'server') + ' (launch)', flags: (v.why || []).filter((w) => /[☠⚠]/.test(w)), severity: 'critical' });
63
+ }
64
+ }
65
+ return { verdict: findings.length ? 'flagged' : 'clean', findings, advisories };
66
+ }
package/src/sign.mjs ADDED
@@ -0,0 +1,128 @@
1
+ // Ed25519 attestation. `--sign` stamps a pinned entry with a signature over its
2
+ // content hash. WHO signed it is identified by the public key embedded in the
3
+ // signature; WHETHER you accept that signer is a separate decision — the trust set
4
+ // (see trust.mjs). Your machine's own key (in ~/.canon) is implicitly trusted, so a
5
+ // local `--sign` round-trips with no extra step; a publisher you trust signs with
6
+ // THEIR key and you add it once via `canon trust add`.
7
+ //
8
+ // The PRIVATE key is held in the OS keychain (keychain.mjs) — never written as
9
+ // plaintext — when one is available; only the PUBLIC key lives in signing-key.json
10
+ // (public is not secret). Hosts with no keychain fall back to a 0600 plaintext file,
11
+ // and a pre-keychain plaintext key is migrated into the keychain on first sign. In CI,
12
+ // set CANON_SIGNING_KEY (the private key) to sign without any local key at all —
13
+ // signing moves off developer boxes into CI, and everyone else only `verify`s.
14
+ import crypto from 'node:crypto';
15
+ import fs from 'node:fs';
16
+ import os from 'node:os';
17
+ import path from 'node:path';
18
+ import { keychainAvailable, keychainGet, keychainSet } from './keychain.mjs';
19
+
20
+ // Resolved at call time (not import) so CANON_HOME can be set per-test/per-run.
21
+ const keyFile = () => path.join(process.env.CANON_HOME || os.homedir(), '.canon', 'signing-key.json');
22
+
23
+ // PEM bytes can differ only by line endings (CRLF on Windows, a trailing newline
24
+ // from a file read) yet be the same key — normalize before hashing/verifying so a
25
+ // key's identity is stable across platforms.
26
+ const normPem = (p) => String(p).replace(/\r\n/g, '\n').trim();
27
+ const enc64 = (s) => Buffer.from(s, 'utf8').toString('base64');
28
+ const dec64 = (s) => Buffer.from(s, 'base64').toString('utf8');
29
+
30
+ // A signing key injected via the environment — CI mode. CANON_SIGNING_KEY holds the
31
+ // PRIVATE key (base64-encoded PEM, or a raw PEM); the PUBLIC key is DERIVED from it,
32
+ // so the CI secret is just the private half and signing needs no file or keychain.
33
+ // Read-only: never writes to disk/keychain (CI runners are ephemeral). Takes priority
34
+ // over the local key, so `canon … --sign` in CI signs with this identity. Because the
35
+ // public key is derived, a key minted locally and exported here keeps the same keyId.
36
+ function envKey() {
37
+ const raw = process.env.CANON_SIGNING_KEY;
38
+ if (!raw) return null;
39
+ try {
40
+ const privateKey = raw.includes('-----BEGIN') ? raw : dec64(raw);
41
+ const publicKey = crypto.createPublicKey(privateKey).export({ type: 'spki', format: 'pem' });
42
+ return { publicKey, privateKey };
43
+ } catch {
44
+ return null; // malformed → fall through to the local key
45
+ }
46
+ }
47
+
48
+ /** A stable short fingerprint for a public key — how the trust set addresses it. */
49
+ export function keyId(publicKey) {
50
+ if (!publicKey) return '';
51
+ return crypto.createHash('sha256').update(normPem(publicKey)).digest('hex').slice(0, 16);
52
+ }
53
+
54
+ // The file half of the key record: { publicKey } (+ a legacy plaintext privateKey on
55
+ // pre-keychain installs). Null when there's no key file at all.
56
+ const readFile = () => { try { return JSON.parse(fs.readFileSync(keyFile(), 'utf8')); } catch { return null; } };
57
+
58
+ /** The local key if it already exists, else null — never generates one (so a
59
+ * read-only path like `verify` building its trust set can't create key material).
60
+ * The private half comes from the OS keychain; a legacy plaintext private in the
61
+ * file is honored as a fallback so existing installs keep working pre-migration.
62
+ *
63
+ * `envAllowed:false` skips the CANON_SIGNING_KEY env key and returns ONLY the
64
+ * machine's persistent local identity. The trust set uses this: the CI signing
65
+ * key is for signing, not for auto-trusting — folding it into the verify-time
66
+ * trust set as `self` would let anyone who can inject that env var into a verify
67
+ * process become a trusted signer. (Commit its public key to canon.trust to
68
+ * verify a lock it signed — the documented flow.) */
69
+ export function loadKey({ envAllowed = true } = {}) {
70
+ if (envAllowed) { const env = envKey(); if (env) return env; } // CI: key from CANON_SIGNING_KEY
71
+ const file = readFile();
72
+ if (!file || !file.publicKey) return file; // no key (or unreadable)
73
+ if (keychainAvailable()) {
74
+ const stored = keychainGet();
75
+ if (stored) return { publicKey: file.publicKey, privateKey: dec64(stored) };
76
+ }
77
+ // keychain empty/unavailable → honor a legacy plaintext private if present.
78
+ return file.privateKey ? { publicKey: file.publicKey, privateKey: file.privateKey } : { publicKey: file.publicKey };
79
+ }
80
+
81
+ export function ensureKey() {
82
+ const env = envKey();
83
+ if (env) return env; // CI: sign with the injected key; never generate/migrate
84
+ const file = keyFile();
85
+ const existing = readFile();
86
+ if (existing && existing.publicKey) {
87
+ // Migrate a legacy plaintext private key into the keychain, then strip it from
88
+ // the file. Same keypair → canon's identity is preserved. One-time, on first
89
+ // sign after the upgrade.
90
+ if (existing.privateKey && keychainAvailable()) {
91
+ keychainSet(enc64(existing.privateKey));
92
+ fs.writeFileSync(file, JSON.stringify({ publicKey: existing.publicKey }), { mode: 0o600 });
93
+ }
94
+ return loadKey();
95
+ }
96
+ // Generate a fresh keypair.
97
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519', {
98
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
99
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
100
+ });
101
+ fs.mkdirSync(path.dirname(file), { recursive: true });
102
+ if (keychainAvailable()) {
103
+ keychainSet(enc64(privateKey));
104
+ fs.writeFileSync(file, JSON.stringify({ publicKey }), { mode: 0o600 });
105
+ } else {
106
+ fs.writeFileSync(file, JSON.stringify({ publicKey, privateKey }), { mode: 0o600 });
107
+ }
108
+ return { publicKey, privateKey };
109
+ }
110
+
111
+ /** Sign a content hash → { alg, pub, val }. Signs with the local key by default;
112
+ * pass a { publicKey, privateKey } to sign as a specific publisher. */
113
+ export function signHash(hash, key) {
114
+ const { publicKey, privateKey } = key || ensureKey();
115
+ const val = crypto.sign(null, Buffer.from(hash), privateKey).toString('base64');
116
+ return { alg: 'ed25519', pub: publicKey, val };
117
+ }
118
+
119
+ /** Cryptographically verify a signature object against a hash, using the public key
120
+ * embedded in the signature. This proves the bytes were signed by whoever holds
121
+ * that key — NOT that you trust that key. Trust (key ∈ your trust set) is a
122
+ * separate gate, in trust.mjs, so a valid signature from an unknown key still
123
+ * surfaces as `untrusted` rather than silently passing. */
124
+ export function verifyHashSig(hash, sig) {
125
+ if (!sig || sig.alg !== 'ed25519' || !sig.val || !sig.pub) return false;
126
+ try { return crypto.verify(null, Buffer.from(hash), normPem(sig.pub), Buffer.from(sig.val, 'base64')); }
127
+ catch { return false; }
128
+ }
package/src/skill.mjs ADDED
Binary file
package/src/trust.mjs ADDED
@@ -0,0 +1,79 @@
1
+ // The trust set — WHOSE signatures canon accepts. A signed lock entry only counts
2
+ // as vetted-by-a-publisher if its signing key is in this set. Three sources, unioned:
3
+ // - your machine's own key (implicit, named `self`) → local `--sign` just works
4
+ // - a repo-committed `canon.trust` → travels with the repo, so a
5
+ // signed lock verifies in CI
6
+ // - a user-global `~/.canon/trust.json` → keys you trust everywhere
7
+ // `canon.trust` is the one that matters for sharing: commit it and a teammate's /
8
+ // CI's `canon verify` knows which publisher key to expect.
9
+ import fs from 'node:fs';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+ import { loadKey, keyId } from './sign.mjs';
13
+
14
+ export const DEFAULT_REPO_TRUST = 'canon.trust';
15
+ const homeStore = () => path.join(process.env.CANON_HOME || os.homedir(), '.canon', 'trust.json');
16
+
17
+ function readStore(file) {
18
+ try { const j = JSON.parse(fs.readFileSync(file, 'utf8')); return Array.isArray(j?.keys) ? j.keys : []; }
19
+ catch { return []; }
20
+ }
21
+ function writeStore(file, keys, repo) {
22
+ fs.mkdirSync(path.dirname(file), { recursive: true });
23
+ // global store is private (it's your trust decisions); a repo canon.trust is meant
24
+ // to be committed, so don't lock it down to 0600.
25
+ fs.writeFileSync(file, JSON.stringify({ version: 1, keys }, null, 2) + '\n', repo ? undefined : { mode: 0o600 });
26
+ }
27
+
28
+ /** Build the active trust set as a Map: keyId → { id, name, publicKey }.
29
+ * Always includes your own key as `self`, so a locally-signed lock verifies with
30
+ * no extra trust step. */
31
+ export function loadTrust({ trustPath, cwd = process.cwd() } = {}) {
32
+ const map = new Map();
33
+ const add = (k) => {
34
+ if (!k || !k.publicKey) return;
35
+ const id = keyId(k.publicKey);
36
+ if (!map.has(id)) map.set(id, { id, name: k.name || id.slice(0, 12), publicKey: k.publicKey });
37
+ };
38
+ // self = the machine's PERSISTENT local key only — NOT a CANON_SIGNING_KEY env
39
+ // key. A CI signing key signs; it isn't auto-trusted at verify time (that would
40
+ // let env-var injection into a verify process mint a trusted signer). Its public
41
+ // key belongs in canon.trust if you want to verify what it signed.
42
+ const self = loadKey({ envAllowed: false }); if (self) add({ name: 'self', publicKey: self.publicKey });
43
+ for (const k of readStore(homeStore())) add(k);
44
+ for (const k of readStore(trustPath || path.join(cwd, DEFAULT_REPO_TRUST))) add(k);
45
+ return map;
46
+ }
47
+
48
+ /** Name of the trusted signer for a public key, or null if its key isn't trusted. */
49
+ export function trustedSigner(publicKey, trust) {
50
+ if (!publicKey) return null;
51
+ const entry = trust.get(keyId(publicKey));
52
+ return entry ? entry.name : null;
53
+ }
54
+
55
+ /** Add a publisher public key to the trust set (global by default, or repo canon.trust). */
56
+ export function trustKey(publicKey, name, { repo = false, cwd = process.cwd() } = {}) {
57
+ const file = repo ? path.join(cwd, DEFAULT_REPO_TRUST) : homeStore();
58
+ const id = keyId(publicKey);
59
+ if (!id) throw new Error('not a public key');
60
+ const entry = { id, name: name || id.slice(0, 12), publicKey: String(publicKey).replace(/\r\n/g, '\n').trim() };
61
+ const keys = readStore(file).filter((k) => keyId(k.publicKey) !== id);
62
+ keys.push(entry);
63
+ writeStore(file, keys, repo);
64
+ return entry;
65
+ }
66
+
67
+ /** Remove a trusted key by exact id or id-prefix (global store). → count removed. */
68
+ export function untrustKey(idOrPrefix, { cwd = process.cwd() } = {}) {
69
+ const file = homeStore();
70
+ const keys = readStore(file);
71
+ const kept = keys.filter((k) => !(k.id === idOrPrefix || (k.id || keyId(k.publicKey)).startsWith(idOrPrefix)));
72
+ if (kept.length !== keys.length) writeStore(file, kept, false);
73
+ return keys.length - kept.length;
74
+ }
75
+
76
+ /** The flattened trust set (incl. implicit `self`), for display. */
77
+ export function listTrust(opts) {
78
+ return [...loadTrust(opts).values()];
79
+ }
package/index.js DELETED
@@ -1 +0,0 @@
1
- module.exports = 'name reservation — real release imminent';