@metamynd/agentsafe-signer 0.13.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,69 @@
1
+ // GENERATED from backend/src/features/policy/mandate/governance-envelope.ts — do not edit. Regenerate: npm run build:signer-core
2
+
3
+ // src/features/policy/mandate/governance-envelope.ts
4
+ import { createHash } from "node:crypto";
5
+ var ENVELOPE_VERSION = "1.0";
6
+ function stableStringify(value) {
7
+ if (value === null || typeof value !== "object") return JSON.stringify(value ?? null);
8
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
9
+ const obj = value;
10
+ const keys = Object.keys(obj).filter((k) => obj[k] !== void 0).sort();
11
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(",")}}`;
12
+ }
13
+ function envelopeIntegrityHash(env) {
14
+ const { integrity: _omit, ...rest } = env;
15
+ return createHash("sha256").update(stableStringify(rest)).digest("hex");
16
+ }
17
+ function buildGovernanceEnvelope(input) {
18
+ const base = {
19
+ envelopeId: `env:${input.nonce}`,
20
+ version: ENVELOPE_VERSION,
21
+ createdAt: input.issuedAt,
22
+ agent: { did: input.agentDid },
23
+ action: {
24
+ actionId: `act:${input.nonce}`,
25
+ actionType: input.action,
26
+ amount: input.amount,
27
+ currency: input.currency,
28
+ merchant: input.merchant ?? null,
29
+ ...input.materiality ? { materiality: input.materiality } : {}
30
+ },
31
+ ...input.trace ? { trace: input.trace } : {},
32
+ ...input.itinerary ? { context: input.itinerary } : {}
33
+ };
34
+ return {
35
+ ...base,
36
+ integrity: {
37
+ payloadHash: envelopeIntegrityHash(base),
38
+ signature: input.signature,
39
+ signatureType: "Ed25519"
40
+ }
41
+ };
42
+ }
43
+ function envelopeHashFor(input) {
44
+ return buildGovernanceEnvelope(input).integrity.payloadHash;
45
+ }
46
+ function authorizeInputFromEnvelope(env) {
47
+ const nonce = env.envelopeId.startsWith("env:") ? env.envelopeId.slice(4) : env.envelopeId;
48
+ return {
49
+ agentDid: env.agent.did,
50
+ action: env.action.actionType,
51
+ amount: env.action.amount ?? 0,
52
+ currency: env.action.currency ?? "",
53
+ merchant: env.action.merchant ?? void 0,
54
+ itinerary: env.context,
55
+ trace: env.trace,
56
+ materiality: env.action.materiality,
57
+ nonce,
58
+ issuedAt: env.createdAt,
59
+ signature: env.integrity.signature ?? ""
60
+ };
61
+ }
62
+ export {
63
+ ENVELOPE_VERSION,
64
+ authorizeInputFromEnvelope,
65
+ buildGovernanceEnvelope,
66
+ envelopeHashFor,
67
+ envelopeIntegrityHash,
68
+ stableStringify
69
+ };
@@ -0,0 +1,192 @@
1
+ // kek-backends.mjs — OS-keychain-backed KEK storage, replacing the passphrase+scrypt placeholder
2
+ // from the first implementation pass. Shells out to each platform's own standard tool rather than
3
+ // adding a native-module dependency (matches this package's zero-npm-dependency discipline):
4
+ // Windows DPAPI via PowerShell, macOS Keychain via `security`, Linux via `systemd-creds` (fits
5
+ // the Tier 2 systemd-service deployment model this daemon targets) with `secret-tool` as a
6
+ // fallback for desktop sessions. Passphrase+scrypt remains the last-resort fallback when none of
7
+ // these are available — explicitly weaker, never silent about which one is actually in use.
8
+ //
9
+ // VERIFIED in this implementation pass: only the Windows DPAPI backend, on this development
10
+ // machine (Windows). The macOS and Linux backends are implemented against each tool's documented
11
+ // CLI syntax but have NOT been run against a real macOS or Linux host — see README.md "Status".
12
+ import { execFileSync } from 'node:child_process';
13
+ import crypto from 'node:crypto';
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import { deriveKek, loadOrCreateSalt } from './keystore.mjs';
17
+
18
+ const KEK_BYTES = 32;
19
+
20
+ function toolAvailable(cmd, args) {
21
+ try {
22
+ execFileSync(cmd, args, { stdio: 'ignore', windowsHide: true });
23
+ return true;
24
+ } catch (err) {
25
+ // ENOENT = tool not installed. Any other failure (e.g. a probe command that legitimately
26
+ // exits non-zero) still proves the tool EXISTS, which is all this check needs to know.
27
+ return err.code !== 'ENOENT';
28
+ }
29
+ }
30
+
31
+ // --- Windows: DPAPI, via PowerShell (no native addon, no extra dependency) ------------------
32
+
33
+ const DPAPI_PROTECT_SCRIPT = `
34
+ Add-Type -AssemblyName System.Security
35
+ $b64 = [Console]::In.ReadToEnd()
36
+ $bytes = [Convert]::FromBase64String($b64)
37
+ $protected = [System.Security.Cryptography.ProtectedData]::Protect($bytes, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)
38
+ [Console]::Out.Write([Convert]::ToBase64String($protected))
39
+ `.trim();
40
+
41
+ const DPAPI_UNPROTECT_SCRIPT = `
42
+ Add-Type -AssemblyName System.Security
43
+ $b64 = [Console]::In.ReadToEnd()
44
+ $bytes = [Convert]::FromBase64String($b64)
45
+ $plain = [System.Security.Cryptography.ProtectedData]::Unprotect($bytes, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)
46
+ [Console]::Out.Write([Convert]::ToBase64String($plain))
47
+ `.trim();
48
+
49
+ function dpapiRun(script, inputB64) {
50
+ return execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], {
51
+ input: inputB64,
52
+ encoding: 'utf8',
53
+ windowsHide: true,
54
+ }).trim();
55
+ }
56
+
57
+ const windowsDpapiBackend = {
58
+ name: 'dpapi',
59
+ available() {
60
+ return process.platform === 'win32';
61
+ },
62
+ getOrCreateKek(stateDir) {
63
+ const blobPath = path.join(stateDir, 'kek.dpapi');
64
+ if (fs.existsSync(blobPath)) {
65
+ const protectedB64 = fs.readFileSync(blobPath, 'utf8');
66
+ const plainB64 = dpapiRun(DPAPI_UNPROTECT_SCRIPT, protectedB64);
67
+ return Buffer.from(plainB64, 'base64');
68
+ }
69
+ const kek = crypto.randomBytes(KEK_BYTES);
70
+ const protectedB64 = dpapiRun(DPAPI_PROTECT_SCRIPT, kek.toString('base64'));
71
+ fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
72
+ fs.writeFileSync(blobPath, protectedB64, { mode: 0o600 });
73
+ return kek;
74
+ },
75
+ };
76
+
77
+ // --- macOS: Keychain, via `security` --------------------------------------------------------
78
+ // NOT verified against a real macOS host in this pass — implemented per `man security`'s
79
+ // documented add-generic-password/find-generic-password behavior.
80
+
81
+ function keychainAccount(stateDir) {
82
+ return 'agentsafe-signer-' + crypto.createHash('sha256').update(path.resolve(stateDir)).digest('hex').slice(0, 16);
83
+ }
84
+
85
+ const macosKeychainBackend = {
86
+ name: 'macos-keychain',
87
+ available() {
88
+ return process.platform === 'darwin' && toolAvailable('security', ['help']);
89
+ },
90
+ getOrCreateKek(stateDir) {
91
+ const service = 'AgentSafe Signer';
92
+ const account = keychainAccount(stateDir);
93
+ try {
94
+ const existing = execFileSync('security', ['find-generic-password', '-a', account, '-s', service, '-w'], { encoding: 'utf8' }).trim();
95
+ return Buffer.from(existing, 'base64');
96
+ } catch {
97
+ const kek = crypto.randomBytes(KEK_BYTES);
98
+ execFileSync('security', ['add-generic-password', '-a', account, '-s', service, '-w', kek.toString('base64'), '-U']);
99
+ return kek;
100
+ }
101
+ },
102
+ };
103
+
104
+ // --- Linux: systemd-creds first (fits the Tier 2 service model), secret-tool as a desktop
105
+ // fallback. Neither verified against a real Linux host in this pass.
106
+
107
+ function credName(stateDir) {
108
+ return 'agentsafe-signer-kek-' + crypto.createHash('sha256').update(path.resolve(stateDir)).digest('hex').slice(0, 16);
109
+ }
110
+
111
+ const systemdCredsBackend = {
112
+ name: 'systemd-creds',
113
+ available() {
114
+ return process.platform === 'linux' && toolAvailable('systemd-creds', ['--version']);
115
+ },
116
+ getOrCreateKek(stateDir) {
117
+ const name = credName(stateDir);
118
+ const blobPath = path.join(stateDir, 'kek.cred');
119
+ if (fs.existsSync(blobPath)) {
120
+ const encrypted = fs.readFileSync(blobPath, 'utf8');
121
+ const plainB64 = execFileSync('systemd-creds', ['decrypt', `--name=${name}`, '-', '-'], { input: encrypted, encoding: 'utf8' }).trim();
122
+ return Buffer.from(plainB64, 'base64');
123
+ }
124
+ const kek = crypto.randomBytes(KEK_BYTES);
125
+ const encrypted = execFileSync('systemd-creds', ['encrypt', `--name=${name}`, '-', '-'], { input: kek.toString('base64'), encoding: 'utf8' });
126
+ fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
127
+ fs.writeFileSync(blobPath, encrypted, { mode: 0o600 });
128
+ return kek;
129
+ },
130
+ };
131
+
132
+ const secretToolBackend = {
133
+ name: 'secret-tool',
134
+ available() {
135
+ return process.platform === 'linux' && toolAvailable('secret-tool', ['--version']);
136
+ },
137
+ getOrCreateKek(stateDir) {
138
+ const account = credName(stateDir);
139
+ const attrs = ['service', 'agentsafe-signer', 'account', account];
140
+ try {
141
+ const existing = execFileSync('secret-tool', ['lookup', ...attrs], { encoding: 'utf8' }).trim();
142
+ if (!existing) throw new Error('not found');
143
+ return Buffer.from(existing, 'base64');
144
+ } catch {
145
+ const kek = crypto.randomBytes(KEK_BYTES);
146
+ execFileSync('secret-tool', ['store', '--label=AgentSafe Signer KEK', ...attrs], { input: kek.toString('base64') + '\n' });
147
+ return kek;
148
+ }
149
+ },
150
+ };
151
+
152
+ // --- Fallback: passphrase + scrypt (the whole KeyStore from the first pass) -----------------
153
+
154
+ function passphraseFallback(stateDir) {
155
+ const passphrase = process.env.AGENTSAFE_SIGNER_PASSPHRASE;
156
+ if (!passphrase) {
157
+ throw new Error(
158
+ 'No OS-keychain backend is available on this platform/host, and AGENTSAFE_SIGNER_PASSPHRASE ' +
159
+ 'is not set. Set it to a real secret from your own secret manager to use the weaker ' +
160
+ 'passphrase fallback, or run on a platform with a supported keychain backend.',
161
+ );
162
+ }
163
+ const saltHex = loadOrCreateSalt(stateDir);
164
+ return deriveKek(passphrase, saltHex);
165
+ }
166
+
167
+ const PLATFORM_BACKENDS = [windowsDpapiBackend, macosKeychainBackend, systemdCredsBackend, secretToolBackend];
168
+
169
+ /**
170
+ * Resolves the KEK for `stateDir`, preferring a real OS-keychain backend over the passphrase
171
+ * fallback, and always reporting which one was actually used — an operator should be able to see
172
+ * what's protecting their key, not have to assume.
173
+ * @returns {{ kek: Buffer, backend: string }}
174
+ */
175
+ export function resolveKek(stateDir) {
176
+ for (const backend of PLATFORM_BACKENDS) {
177
+ if (!backend.available()) continue;
178
+ try {
179
+ return { kek: backend.getOrCreateKek(stateDir), backend: backend.name };
180
+ } catch (err) {
181
+ // `available()` only proves the tool exists and runs (e.g. `systemd-creds --version`) — it
182
+ // can't cheaply prove the backend actually WORKS in this environment. systemd-creds needs a
183
+ // host/TPM secret that a container or restricted host may not have even though the binary
184
+ // itself is present; caught for real on a GitHub Actions runner ("Failed to determine local
185
+ // credential host secret: Permission denied"), not assumed. A backend that is present but
186
+ // unusable is not meaningfully different from one that is absent — fall through to the next
187
+ // one rather than crashing the whole daemon over it.
188
+ console.error(`[agentsafe-signer] KEK backend "${backend.name}" is available but failed (${err.message}); trying the next one.`);
189
+ }
190
+ }
191
+ return { kek: passphraseFallback(stateDir), backend: 'passphrase' };
192
+ }
package/keystore.mjs ADDED
@@ -0,0 +1,108 @@
1
+ // keystore.mjs — encrypted-at-rest Ed25519 key storage. The KEK itself is supplied by the
2
+ // caller (daemon.mjs/cli.mjs), not derived here: this module only ever handles encrypt/decrypt
3
+ // of the private key, never how the KEK was obtained. See README.md "Status" for what backs the
4
+ // KEK today (a passphrase, not yet a real OS-keychain integration) and what that gap means.
5
+ import crypto from 'node:crypto';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { withLockedCopy } from './secure-memory.mjs';
9
+
10
+ export const SCRYPT_PARAMS = { N: 16384, r: 8, p: 1, keylen: 32 };
11
+
12
+ /** Derive a 32-byte KEK from a passphrase + salt. Salt is not secret — safe to store alongside the encrypted key. */
13
+ export function deriveKek(passphrase, saltHex) {
14
+ return crypto.scryptSync(passphrase, Buffer.from(saltHex, 'hex'), SCRYPT_PARAMS.keylen, {
15
+ N: SCRYPT_PARAMS.N,
16
+ r: SCRYPT_PARAMS.r,
17
+ p: SCRYPT_PARAMS.p,
18
+ });
19
+ }
20
+
21
+ export function loadOrCreateSalt(stateDir) {
22
+ const saltPath = path.join(stateDir, 'salt.hex');
23
+ if (fs.existsSync(saltPath)) return fs.readFileSync(saltPath, 'utf8').trim();
24
+ fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
25
+ const saltHex = crypto.randomBytes(16).toString('hex');
26
+ fs.writeFileSync(saltPath, saltHex, { mode: 0o600 });
27
+ return saltHex;
28
+ }
29
+
30
+ export class KeyStoreError extends Error {
31
+ constructor(code) {
32
+ super(code);
33
+ this.code = code;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Encrypted-at-rest Ed25519 private key. Never exposes the private key itself — `sign()` is the
39
+ * only operation that touches it, and it's decrypted fresh for each call and zeroed immediately
40
+ * after (see sign()'s own `finally`), not held decrypted between calls.
41
+ */
42
+ export class KeyStore {
43
+ #kek;
44
+ #dir;
45
+ #record = null;
46
+
47
+ constructor({ dir, kek }) {
48
+ this.#dir = dir;
49
+ this.#kek = kek;
50
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
51
+ this.#load();
52
+ }
53
+
54
+ get #keystorePath() {
55
+ return path.join(this.#dir, 'keystore.enc.json');
56
+ }
57
+
58
+ #load() {
59
+ if (!fs.existsSync(this.#keystorePath)) return;
60
+ this.#record = JSON.parse(fs.readFileSync(this.#keystorePath, 'utf8'));
61
+ }
62
+
63
+ hasKey() {
64
+ return this.#record !== null;
65
+ }
66
+
67
+ publicKeyHex() {
68
+ return this.#record?.publicKeyHex ?? null;
69
+ }
70
+
71
+ /** Generates a new Ed25519 keypair inside this store. Refuses to overwrite an existing key. */
72
+ generate({ allowRekey = false } = {}) {
73
+ if (this.hasKey() && !allowRekey) throw new KeyStoreError('KEY_ALREADY_EXISTS');
74
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
75
+ const privateDer = privateKey.export({ format: 'der', type: 'pkcs8' });
76
+ const publicKeyHex = publicKey.export({ format: 'der', type: 'spki' }).toString('hex');
77
+ // withLockedCopy mlocks the DER bytes for the brief window they're plaintext here, and zeroes
78
+ // `privateDer` itself immediately — see secure-memory.mjs for why this needs a real dependency.
79
+ const { iv, authTag, ciphertext } = withLockedCopy(privateDer, (locked) => {
80
+ const iv = crypto.randomBytes(12);
81
+ const cipher = crypto.createCipheriv('aes-256-gcm', this.#kek, iv);
82
+ const ciphertext = Buffer.concat([cipher.update(locked), cipher.final()]);
83
+ return { iv, authTag: cipher.getAuthTag(), ciphertext };
84
+ });
85
+ const record = {
86
+ publicKeyHex,
87
+ iv: iv.toString('hex'),
88
+ authTag: authTag.toString('hex'),
89
+ ciphertext: ciphertext.toString('hex'),
90
+ };
91
+ fs.writeFileSync(this.#keystorePath, JSON.stringify(record), { mode: 0o600 });
92
+ this.#record = record;
93
+ return publicKeyHex;
94
+ }
95
+
96
+ /** Signs `messageBuffer` with the stored private key. The decrypted key never leaves this function. */
97
+ sign(messageBuffer) {
98
+ if (!this.hasKey()) throw new KeyStoreError('NOT_PROVISIONED');
99
+ const { iv, authTag, ciphertext } = this.#record;
100
+ const decipher = crypto.createDecipheriv('aes-256-gcm', this.#kek, Buffer.from(iv, 'hex'));
101
+ decipher.setAuthTag(Buffer.from(authTag, 'hex'));
102
+ const privateDer = Buffer.concat([decipher.update(Buffer.from(ciphertext, 'hex')), decipher.final()]);
103
+ return withLockedCopy(privateDer, (locked) => {
104
+ const privateKey = crypto.createPrivateKey({ key: locked, format: 'der', type: 'pkcs8' });
105
+ return crypto.sign(null, messageBuffer, privateKey).toString('hex');
106
+ });
107
+ }
108
+ }
package/log-anchor.mjs ADDED
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env node
2
+ // log-anchor.mjs — the separate, optional, LIVE-NETWORK half of T11 (docs/design/agent-key-
3
+ // custody-local-signer-daemon-plan.md). log-checkpoint.mjs already produces local, chained
4
+ // checkpoints with zero network dependency; this script reads whichever ones aren't anchored yet
5
+ // (pendingAnchors), asks the already-running daemon to sign an anchor request for each one
6
+ // (sign-log-checkpoint — only the daemon holds the key), and POSTs the signed request to the
7
+ // backend's /evidence/signer-checkpoint endpoint, which verifies the signature against the
8
+ // agent's own DID and anchors the checkpoint hash via HCS.
9
+ //
10
+ // Deliberately a SEPARATE process from daemon.mjs, not a daemon feature: the daemon stays
11
+ // local-first / offline-capable by design — this is the one piece of the whole system that
12
+ // genuinely needs a live network connection, and keeping it a separate, operator-scheduled script
13
+ // (cron, a systemd timer) means a daemon that never gets anchored still signs and logs correctly;
14
+ // anchoring is a bonus property, not a dependency of normal operation.
15
+ //
16
+ // node log-anchor.mjs --state-dir ./.signer --api-url https://api.example.com/v1
17
+ import path from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { pendingAnchors, markAnchored } from './log-checkpoint.mjs';
20
+ import { parseArgs, daemonRequest } from './daemon-client.mjs';
21
+
22
+ async function anchorOne(checkpoint, { socketPath, apiUrl, agentDid }) {
23
+ const nonce = crypto.randomUUID();
24
+ const issuedAt = new Date().toISOString();
25
+ const { signature } = await daemonRequest(socketPath, 'sign-log-checkpoint', {
26
+ checkpointHash: checkpoint.checkpointHash,
27
+ previousCheckpointHash: checkpoint.previousCheckpointHash,
28
+ entryCount: checkpoint.entryCount,
29
+ nonce,
30
+ issuedAt,
31
+ });
32
+
33
+ const body = {
34
+ agentDid,
35
+ checkpointHash: checkpoint.checkpointHash,
36
+ previousCheckpointHash: checkpoint.previousCheckpointHash,
37
+ entryCount: checkpoint.entryCount,
38
+ nonce,
39
+ issuedAt,
40
+ signature,
41
+ };
42
+ const res = await fetch(new URL('/evidence/signer-checkpoint', apiUrl), {
43
+ method: 'POST',
44
+ headers: { 'content-type': 'application/json' },
45
+ body: JSON.stringify(body),
46
+ });
47
+ const json = await res.json().catch(() => null);
48
+ if (!res.ok || !json?.success) {
49
+ throw new Error(`anchor request failed (HTTP ${res.status}): ${json?.message ?? 'no response body'}`);
50
+ }
51
+ return json.data; // { txId, ref, network, anchoredAt, alreadyAnchored }
52
+ }
53
+
54
+ /**
55
+ * Anchors every pending local checkpoint for the daemon at `socketPath`/`stateDir`. Exported so
56
+ * cli.mjs's `log-anchor` subcommand and this file's own direct-invocation `main()` share one
57
+ * implementation rather than drifting. Returns the number of checkpoints that failed to anchor
58
+ * (0 = fully caught up); never throws for an individual checkpoint's failure — each is
59
+ * independent, and a later run retries whatever didn't get marked anchored this time.
60
+ */
61
+ export async function runLogAnchor({ stateDir, socketPath, apiUrl }) {
62
+ const pending = pendingAnchors(stateDir);
63
+ if (pending.length === 0) {
64
+ console.log('[log-anchor] nothing pending — every local checkpoint is already anchored.');
65
+ return 0;
66
+ }
67
+
68
+ // Ask the already-running daemon who it is, rather than requiring a redundant --identity flag
69
+ // that could drift from the daemon's own bound agentDid.
70
+ const { identity: agentDid } = await daemonRequest(socketPath, 'get-identity', {});
71
+ if (!agentDid) {
72
+ console.error('[log-anchor] the daemon has no bound identity yet (not provisioned) — nothing to anchor as.');
73
+ return pending.length;
74
+ }
75
+
76
+ let failed = 0;
77
+ for (const checkpoint of pending) {
78
+ try {
79
+ const data = await anchorOne(checkpoint, { socketPath, apiUrl, agentDid });
80
+ markAnchored(stateDir, checkpoint.checkpointHash, { txId: data.txId, ref: data.ref });
81
+ console.log(`[log-anchor] checkpoint ${checkpoint.index} (${checkpoint.checkpointHash.slice(0, 12)}…) -> ${data.txId}${data.alreadyAnchored ? ' (already anchored)' : ''}`);
82
+ } catch (err) {
83
+ failed++;
84
+ console.error(`[log-anchor] checkpoint ${checkpoint.index} FAILED: ${err.message}`);
85
+ }
86
+ }
87
+ return failed;
88
+ }
89
+
90
+ async function main() {
91
+ const args = parseArgs(process.argv.slice(2));
92
+ const stateDir = args['state-dir'] ?? path.join(process.cwd(), '.agentsafe-signer');
93
+ const socketPath = args['socket-path'] ?? path.join(stateDir, 'signer.sock');
94
+ const apiUrl = args['api-url'] ?? process.env.AGENTSAFE_SIGNER_API_URL;
95
+ if (!apiUrl) {
96
+ console.error('Usage: agentsafe-signer log-anchor requires --api-url <backend base url> (or AGENTSAFE_SIGNER_API_URL)');
97
+ process.exit(1);
98
+ }
99
+ const failed = await runLogAnchor({ stateDir, socketPath, apiUrl });
100
+ if (failed > 0) process.exit(1);
101
+ }
102
+
103
+ // Only run as a CLI when invoked directly (`node log-anchor.mjs`), not when cli.mjs imports
104
+ // runLogAnchor from this same file. path.resolve(), not a raw string/URL compare — Windows path
105
+ // separators and drive-letter casing make a direct import.meta.url-vs-argv[1] string comparison
106
+ // unreliable there.
107
+ if (process.argv[1] && path.resolve(fileURLToPath(import.meta.url)) === path.resolve(process.argv[1])) {
108
+ main().catch((err) => {
109
+ console.error('[log-anchor] fatal:', err);
110
+ process.exit(1);
111
+ });
112
+ }
@@ -0,0 +1,191 @@
1
+ // log-checkpoint.mjs — periodic, local, tamper-evident checkpointing of the daemon's own
2
+ // signer.log (docs/design/agent-key-custody-local-signer-daemon-plan.md, T11: "periodically
3
+ // hash-chaining the log ... would give tamper-evidence without inventing a new mechanism").
4
+ //
5
+ // Fully offline and dependency-free: this only proves the log wasn't altered SINCE a checkpoint
6
+ // was taken (a privileged local attacker who tampers with an already-checkpointed line will break
7
+ // that checkpoint's own Merkle root, or the hash-chain link to it, and verifyLogCheckpoints below
8
+ // will say exactly where). It does NOT, on its own, stop an attacker who controls both the log
9
+ // AND the checkpoint file from rewriting both consistently — that is what externally anchoring a
10
+ // checkpointHash (log-anchor.mjs, a separate optional step, needs live network + backend) is for.
11
+ // This module never touches the network.
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { sha256Hex, merkleRoot } from './merkle.mjs';
15
+
16
+ /** The `previousCheckpointHash` for checkpoint index 0 — a fixed, documented constant so the
17
+ * very first checkpoint's chain link is still well-defined and verifiable, not an arbitrary or
18
+ * attacker-choosable value. */
19
+ export const GENESIS_HASH = sha256Hex('agentsafe-signer-log-checkpoint-genesis');
20
+
21
+ function checkpointsPath(stateDir) {
22
+ return path.join(stateDir, 'signer.log.checkpoints.jsonl');
23
+ }
24
+
25
+ function logPath(stateDir) {
26
+ return path.join(stateDir, 'signer.log');
27
+ }
28
+
29
+ function readCheckpoints(stateDir) {
30
+ const p = checkpointsPath(stateDir);
31
+ if (!fs.existsSync(p)) return [];
32
+ return fs
33
+ .readFileSync(p, 'utf8')
34
+ .split('\n')
35
+ .filter((line) => line.trim().length > 0)
36
+ .map((line) => JSON.parse(line));
37
+ }
38
+
39
+ /**
40
+ * The exact byte range of the log to include in the next checkpoint: from the last checkpoint's
41
+ * `toOffset` (or 0) up to the end of the last COMPLETE line currently in the file. Stopping at the
42
+ * last complete line — never the raw file size — means a checkpoint can never include a line that
43
+ * was only partially flushed, even though `fs.appendFileSync`'s single synchronous write call
44
+ * makes a torn write for one JSON line essentially impossible in practice; this is a defensive
45
+ * bound, not a response to an observed failure.
46
+ */
47
+ function pendingRange(stateDir, fromOffset) {
48
+ const p = logPath(stateDir);
49
+ if (!fs.existsSync(p)) return null;
50
+ const buf = fs.readFileSync(p);
51
+ if (buf.length <= fromOffset) return null;
52
+ const region = buf.subarray(fromOffset);
53
+ const lastNewline = region.lastIndexOf(0x0a);
54
+ if (lastNewline === -1) return null; // no complete line since the last checkpoint yet
55
+ const toOffset = fromOffset + lastNewline + 1;
56
+ const lines = region
57
+ .subarray(0, lastNewline + 1)
58
+ .toString('utf8')
59
+ .split('\n')
60
+ .filter((line) => line.length > 0);
61
+ if (lines.length === 0) return null;
62
+ return { toOffset, lines };
63
+ }
64
+
65
+ /**
66
+ * Computes and appends the next checkpoint over whatever new, complete log lines have accumulated
67
+ * since the last one. Returns the new checkpoint record, or `null` if there was nothing new to
68
+ * checkpoint (never appends an empty/no-op checkpoint — a chain of identical, content-free links
69
+ * would be dead weight, not evidence of anything).
70
+ *
71
+ * Each leaf is the hash of a log line's exact stored BYTES, not a re-parsed/re-serialized version
72
+ * of it — hashing the raw bytes catches any byte-level edit (e.g. changing one field's value
73
+ * in-place without touching key order), not just a naive attacker's attempt to reorder JSON keys.
74
+ */
75
+ export function takeCheckpoint(stateDir) {
76
+ const existing = readCheckpoints(stateDir);
77
+ const fromOffset = existing.length > 0 ? existing[existing.length - 1].toOffset : 0;
78
+ const previousCheckpointHash = existing.length > 0 ? existing[existing.length - 1].checkpointHash : GENESIS_HASH;
79
+
80
+ const pending = pendingRange(stateDir, fromOffset);
81
+ if (!pending) return null;
82
+
83
+ const leaves = pending.lines.map((line) => sha256Hex(line));
84
+ const root = merkleRoot(leaves);
85
+ const checkpointHash = sha256Hex(`${previousCheckpointHash}:${root}`);
86
+
87
+ const checkpoint = {
88
+ index: existing.length,
89
+ fromOffset,
90
+ toOffset: pending.toOffset,
91
+ entryCount: leaves.length,
92
+ merkleRoot: root,
93
+ previousCheckpointHash,
94
+ checkpointHash,
95
+ createdAt: new Date().toISOString(),
96
+ };
97
+
98
+ fs.appendFileSync(checkpointsPath(stateDir), JSON.stringify(checkpoint) + '\n', { mode: 0o600 });
99
+ return checkpoint;
100
+ }
101
+
102
+ /**
103
+ * Recomputes every checkpoint directly from the raw log bytes at its recorded offsets and confirms
104
+ * both properties a tamper-evident chain needs: (1) the batch's Merkle root still matches what is
105
+ * actually stored in the log at that byte range, and (2) each checkpoint's own hash correctly
106
+ * chains from the previous one (or GENESIS_HASH, for the first). Returns as soon as the first
107
+ * mismatch is found — a tampered log doesn't need every subsequent checkpoint checked to know it's
108
+ * been tampered with.
109
+ */
110
+ export function verifyLogCheckpoints(stateDir) {
111
+ const checkpoints = readCheckpoints(stateDir);
112
+ if (checkpoints.length === 0) return { ok: true, checkedCount: 0 };
113
+
114
+ const buf = fs.existsSync(logPath(stateDir)) ? fs.readFileSync(logPath(stateDir)) : Buffer.alloc(0);
115
+ let previousCheckpointHash = GENESIS_HASH;
116
+
117
+ for (let i = 0; i < checkpoints.length; i++) {
118
+ const cp = checkpoints[i];
119
+
120
+ if (cp.previousCheckpointHash !== previousCheckpointHash) {
121
+ return { ok: false, checkedCount: i, failedIndex: i, reason: 'chain link does not match the previous checkpoint\'s own hash' };
122
+ }
123
+
124
+ if (buf.length < cp.toOffset) {
125
+ return { ok: false, checkedCount: i, failedIndex: i, reason: 'log file is shorter than this checkpoint recorded — truncated or replaced' };
126
+ }
127
+ const region = buf.subarray(cp.fromOffset, cp.toOffset).toString('utf8');
128
+ const lines = region.split('\n').filter((line) => line.length > 0);
129
+ const recomputedRoot = merkleRoot(lines.map((line) => sha256Hex(line)));
130
+ if (recomputedRoot !== cp.merkleRoot) {
131
+ return { ok: false, checkedCount: i, failedIndex: i, reason: 'Merkle root no longer matches the log content at this checkpoint\'s byte range — a covered line was modified' };
132
+ }
133
+
134
+ const recomputedCheckpointHash = sha256Hex(`${cp.previousCheckpointHash}:${cp.merkleRoot}`);
135
+ if (recomputedCheckpointHash !== cp.checkpointHash) {
136
+ return { ok: false, checkedCount: i, failedIndex: i, reason: 'checkpointHash does not match its own recorded previousCheckpointHash + merkleRoot — the checkpoint record itself was edited' };
137
+ }
138
+
139
+ previousCheckpointHash = cp.checkpointHash;
140
+ }
141
+
142
+ return { ok: true, checkedCount: checkpoints.length };
143
+ }
144
+
145
+ function anchorsPath(stateDir) {
146
+ return path.join(stateDir, 'signer.log.anchors.jsonl');
147
+ }
148
+
149
+ function readAnchors(stateDir) {
150
+ const p = anchorsPath(stateDir);
151
+ if (!fs.existsSync(p)) return new Map();
152
+ const map = new Map();
153
+ for (const line of fs.readFileSync(p, 'utf8').split('\n')) {
154
+ if (!line.trim()) continue;
155
+ const record = JSON.parse(line);
156
+ map.set(record.checkpointHash, record); // last write for a given hash wins, matching a re-anchor after a retry
157
+ }
158
+ return map;
159
+ }
160
+
161
+ /**
162
+ * Records the result of successfully anchoring one checkpoint. Deliberately a SEPARATE, append-
163
+ * only file rather than rewriting checkpoints.jsonl in place: `takeCheckpoint` (called only by the
164
+ * daemon's own periodic timer) and this function (called by the separate, operator-run
165
+ * `log-anchor.mjs` process) can run in different processes at any time, and an append is always
166
+ * safe against a concurrent append elsewhere — a read-modify-write rewrite of the checkpoints file
167
+ * from a second process would risk silently dropping a checkpoint the daemon appended in between.
168
+ */
169
+ export function markAnchored(stateDir, checkpointHash, { txId, ref, anchoredAt = new Date().toISOString() }) {
170
+ if (!readCheckpoints(stateDir).some((cp) => cp.checkpointHash === checkpointHash)) {
171
+ throw new Error(`markAnchored: no checkpoint with checkpointHash ${checkpointHash}`);
172
+ }
173
+ const record = { checkpointHash, txId, ref, anchoredAt };
174
+ fs.appendFileSync(anchorsPath(stateDir), JSON.stringify(record) + '\n', { mode: 0o600 });
175
+ return record;
176
+ }
177
+
178
+ /** Every checkpoint, each with its anchor record merged in (if any) as `anchoredAt`/`txId`/`ref`. */
179
+ export function listCheckpoints(stateDir) {
180
+ const anchors = readAnchors(stateDir);
181
+ return readCheckpoints(stateDir).map((cp) => {
182
+ const anchor = anchors.get(cp.checkpointHash);
183
+ return anchor ? { ...cp, anchoredAt: anchor.anchoredAt, txId: anchor.txId, ref: anchor.ref } : cp;
184
+ });
185
+ }
186
+
187
+ /** The checkpoints not yet anchored externally (see log-anchor.mjs) — no matching record in the
188
+ * separate anchors file. */
189
+ export function pendingAnchors(stateDir) {
190
+ return listCheckpoints(stateDir).filter((cp) => !cp.anchoredAt);
191
+ }