@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,97 @@
1
+ // daemon-client.mjs — shared helpers for agentsafe-signer's OWN operator-facing scripts
2
+ // (log-anchor.mjs, migrate.mjs) that need to connect to an already-running daemon's socket and
3
+ // parse CLI-style arguments. Not for external packages — agentsafe-guard/agentsafe-mcp-guard
4
+ // deliberately duplicate their own small copy of the connection logic instead of depending on
5
+ // this package (see either's key-providers.mjs); this module is purely internal, avoiding a
6
+ // THIRD hand-copy of the same ~40 lines within this package itself.
7
+ import net from 'node:net';
8
+ import crypto from 'node:crypto';
9
+ import { toPlatformSocketPath } from './daemon.mjs';
10
+
11
+ const PROTOCOL_VERSION = 1;
12
+
13
+ export function parseArgs(argv) {
14
+ const args = { _: [] };
15
+ for (let i = 0; i < argv.length; i++) {
16
+ const a = argv[i];
17
+ if (a.startsWith('--')) {
18
+ const key = a.slice(2);
19
+ const next = argv[i + 1];
20
+ if (next === undefined || next.startsWith('--')) {
21
+ args[key] = true;
22
+ } else {
23
+ args[key] = next;
24
+ i++;
25
+ }
26
+ } else {
27
+ args._.push(a);
28
+ }
29
+ }
30
+ return args;
31
+ }
32
+
33
+ /** Same connection + ENOENT-retry logic as agentsafe-guard/agentsafe-mcp-guard's own
34
+ * key-providers.mjs (see either's comment for why): on Windows the signing socket is a pool of
35
+ * independent named-pipe instances, each consumed by one connection and replaced asynchronously,
36
+ * so a request can transiently race that replacement window even though the daemon is healthy.
37
+ *
38
+ * `connectTimeoutMs` bounds the WHOLE call, not just the ENOENT-retry loop: a Windows named-pipe
39
+ * connection attempt can in rare cases neither error nor connect (the relay process behind
40
+ * windows-secure-pipe.mjs itself wedged mid-startup) — with only the retry-loop deadline, that
41
+ * left a real caller hung indefinitely (found via a 5+ hour stuck process, not a timeout). The
42
+ * overall timer below fires unconditionally regardless of the socket's state, so a wedged
43
+ * connection now fails fast with a clear error instead of hanging forever. */
44
+ export function daemonRequest(socketPath, op, params, { connectTimeoutMs = 3000 } = {}) {
45
+ return new Promise((resolve, reject) => {
46
+ const deadline = Date.now() + connectTimeoutMs;
47
+ let settled = false;
48
+ const overallTimer = setTimeout(() => {
49
+ settled = true;
50
+ reject(Object.assign(new Error(`agentsafe-signer daemon unreachable at ${socketPath}: timed out after ${connectTimeoutMs}ms`), { code: 'DAEMON_UNREACHABLE' }));
51
+ }, connectTimeoutMs);
52
+ function attempt() {
53
+ if (settled) return;
54
+ const sock = net.connect(toPlatformSocketPath(socketPath));
55
+ const requestId = crypto.randomUUID();
56
+ let buf = '';
57
+ const cleanup = () => sock.destroy();
58
+ sock.once('error', (err) => {
59
+ cleanup();
60
+ if (settled) return;
61
+ if (err.code === 'ENOENT' && Date.now() < deadline) {
62
+ setTimeout(attempt, 20);
63
+ return;
64
+ }
65
+ settled = true;
66
+ clearTimeout(overallTimer);
67
+ reject(Object.assign(new Error(`agentsafe-signer daemon unreachable at ${socketPath}: ${err.message}`), { code: 'DAEMON_UNREACHABLE' }));
68
+ });
69
+ sock.once('connect', () => {
70
+ if (settled) return;
71
+ sock.write(JSON.stringify({ protocolVersion: PROTOCOL_VERSION, requestId, op, params }) + '\n');
72
+ });
73
+ sock.on('data', (chunk) => {
74
+ if (settled) return;
75
+ buf += chunk.toString('utf8');
76
+ const idx = buf.indexOf('\n');
77
+ if (idx === -1) return;
78
+ let res;
79
+ try {
80
+ res = JSON.parse(buf.slice(0, idx));
81
+ } catch (err) {
82
+ cleanup();
83
+ settled = true;
84
+ clearTimeout(overallTimer);
85
+ reject(err);
86
+ return;
87
+ }
88
+ cleanup();
89
+ settled = true;
90
+ clearTimeout(overallTimer);
91
+ if (res.ok) resolve(res.result);
92
+ else reject(Object.assign(new Error(res.error?.message || res.error?.code || 'daemon rejected request'), { code: res.error?.code }));
93
+ });
94
+ }
95
+ attempt();
96
+ });
97
+ }
package/daemon.mjs ADDED
@@ -0,0 +1,558 @@
1
+ // daemon.mjs — the local signer daemon (docs/design/agent-key-custody-local-signer-daemon-plan.md).
2
+ // A separate process from the calling guard: holds the key, exposes a small closed set of signing
3
+ // operations over a local socket, and never returns the key itself over either socket, under any
4
+ // code path. See README.md for what's implemented in this pass vs. deliberately deferred.
5
+ import net from 'node:net';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import crypto from 'node:crypto';
9
+ import { buildAuthMessage, buildCheckpointAnchorMessage, buildLocalDecisionMessage } from './policy-core.mjs';
10
+ import { envelopeHashFor } from './governance-envelope.mjs';
11
+ import { KeyStore, KeyStoreError } from './keystore.mjs';
12
+ import { createSecurePipePool, createSecurePipeOnce } from './windows-secure-pipe.mjs';
13
+ import { takeCheckpoint } from './log-checkpoint.mjs';
14
+
15
+ export const PROTOCOL_VERSION = 1;
16
+ export const MAX_LINE_BYTES = 64 * 1024;
17
+ export const FRESHNESS_MS = 5 * 60 * 1000;
18
+ export const CLOCK_SKEW_MS = 30 * 1000;
19
+
20
+ const ALL_SIGNING_OPS = new Set(['sign-authorize', 'sign-envelope', 'sign-handshake-nonce', 'sign-key-control-challenge', 'sign-log-checkpoint', 'sign-local-decision', 'get-identity', 'ping']);
21
+
22
+ // sign-log-checkpoint is in BOTH role sets, unlike every other sign-* op: it authenticates the
23
+ // daemon's own log-tamper-evidence checkpoint (T11), which every daemon keeps regardless of
24
+ // role — an agent-role daemon's log needs the same anchoring as a service-role daemon's.
25
+ // sign-local-decision, like sign-authorize/sign-envelope, is agent-role-only — it signs an
26
+ // AGENT's own local-first block/escalate/non-value-allow verdict for audit reporting
27
+ // (agentsafe-guard.mjs's reportLocalDecision()); a service-role daemon never evaluates a
28
+ // mandate locally, so it has nothing to report.
29
+ const SIGNING_OPS_BY_ROLE = {
30
+ agent: new Set(['sign-authorize', 'sign-envelope', 'sign-handshake-nonce', 'sign-key-control-challenge', 'sign-log-checkpoint', 'sign-local-decision', 'get-identity', 'ping']),
31
+ service: new Set(['sign-handshake-nonce', 'sign-log-checkpoint', 'get-identity', 'ping']),
32
+ };
33
+
34
+ const DEFAULT_RATE_LIMITS = {
35
+ 'sign-authorize': { max: 20, windowMs: 10_000 },
36
+ 'sign-envelope': { max: 20, windowMs: 10_000 },
37
+ 'sign-handshake-nonce': { max: 10, windowMs: 60_000 },
38
+ 'sign-key-control-challenge': { max: 3, windowMs: 60 * 60 * 1000 },
39
+ // Local decisions are reported for every locally-decided block/escalate/non-value-allow, not
40
+ // just value-moving actions — under local-first mode that's the overwhelming majority of an
41
+ // agent's tool calls, so this ceiling is deliberately higher than sign-authorize's.
42
+ 'sign-local-decision': { max: 60, windowMs: 10_000 },
43
+ // Checkpoints are taken on a coarse timer (DEFAULT_CHECKPOINT_INTERVAL_MS), so anchoring one is
44
+ // an infrequent, operator-driven action, not agent-runtime traffic — a low ceiling is plenty and
45
+ // catches a misbehaving or compromised anchor script hammering this op instead of running on
46
+ // its own schedule.
47
+ 'sign-log-checkpoint': { max: 5, windowMs: 60 * 60 * 1000 },
48
+ };
49
+
50
+ export const DEFAULT_CHECKPOINT_INTERVAL_MS = 15 * 60 * 1000;
51
+
52
+ // Matches agentsafe-guard.mjs's own REPORTABLE_LOCAL_DECISIONS exactly (LOCAL_DECISIONS the
53
+ // backend's /policy/decisions/local actually accepts) — this daemon must never sign a
54
+ // 'quarantine'/'suspend' or other value here just because a caller asked; only a genuine
55
+ // local-first verdict shape is a valid thing to have signed under this op.
56
+ const REPORTABLE_LOCAL_DECISIONS = new Set(['allow', 'observe', 'block', 'escalate']);
57
+
58
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
59
+ const HEX_RE = /^[0-9a-f]+$/i;
60
+ const SHA256_HEX_RE = /^[0-9a-f]{64}$/i;
61
+
62
+ export class DaemonError extends Error {
63
+ constructor(code, message = code) {
64
+ super(message);
65
+ this.code = code;
66
+ }
67
+ }
68
+
69
+ class RateLimiter {
70
+ constructor() {
71
+ this.windows = new Map();
72
+ }
73
+ /** Returns true if this hit is within the limit. `max: 0` or `undefined` disables the limit for this op. */
74
+ hit(op, max, windowMs) {
75
+ if (!max) return true;
76
+ const now = Date.now();
77
+ let w = this.windows.get(op);
78
+ if (!w || now - w.start >= windowMs) {
79
+ w = { start: now, count: 0 };
80
+ this.windows.set(op, w);
81
+ }
82
+ w.count++;
83
+ return w.count <= max;
84
+ }
85
+ }
86
+
87
+ function isFiniteNonNegative(n) {
88
+ return typeof n === 'number' && Number.isFinite(n) && n >= 0;
89
+ }
90
+
91
+ function freshnessOk(issuedAt, now = Date.now()) {
92
+ const ts = Date.parse(issuedAt);
93
+ if (Number.isNaN(ts)) return false;
94
+ const age = now - ts;
95
+ return age <= FRESHNESS_MS && age >= -CLOCK_SKEW_MS;
96
+ }
97
+
98
+ /** One JSON-Lines connection: buffers bytes, splits on '\n', parses+dispatches one request per line. */
99
+ function attachJsonLines(socket, onLine, { maxLineBytes = MAX_LINE_BYTES } = {}) {
100
+ let buf = Buffer.alloc(0);
101
+ socket.on('data', (chunk) => {
102
+ buf = Buffer.concat([buf, chunk]);
103
+ if (buf.length > maxLineBytes && !buf.includes(0x0a)) {
104
+ socket.destroy();
105
+ return;
106
+ }
107
+ let idx;
108
+ while ((idx = buf.indexOf(0x0a)) !== -1) {
109
+ const line = buf.subarray(0, idx).toString('utf8');
110
+ buf = buf.subarray(idx + 1);
111
+ if (line.trim().length === 0) continue;
112
+ if (Buffer.byteLength(line, 'utf8') > maxLineBytes) {
113
+ socket.destroy();
114
+ return;
115
+ }
116
+ onLine(line);
117
+ }
118
+ });
119
+ }
120
+
121
+ function writeResponse(socket, requestId, payload) {
122
+ const body = { protocolVersion: PROTOCOL_VERSION, requestId, ...payload };
123
+ socket.write(JSON.stringify(body) + '\n');
124
+ }
125
+
126
+ export class SignerDaemon {
127
+ #unlocked = false;
128
+ #unlockPromise;
129
+ #limiter = new RateLimiter();
130
+ #rateLimits;
131
+ #shadowMode;
132
+ #adminOpen = false;
133
+ #agentDid;
134
+ #log;
135
+ #checkpointTimer = null;
136
+
137
+ constructor({ stateDir, role, agentDid = null, kek, rateLimits = {}, shadowMode = true, log, checkpointIntervalMs = DEFAULT_CHECKPOINT_INTERVAL_MS }) {
138
+ if (role !== 'agent' && role !== 'service') throw new Error(`role must be 'agent' or 'service', got '${role}'`);
139
+ this.role = role;
140
+ this.stateDir = stateDir;
141
+ this.#agentDid = agentDid;
142
+ this.#rateLimits = { ...DEFAULT_RATE_LIMITS, ...rateLimits };
143
+ this.#shadowMode = shadowMode;
144
+ this.#log = log ?? defaultLogger(stateDir);
145
+ fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
146
+ this.#unlockPromise = Promise.resolve().then(() => {
147
+ this.keystore = new KeyStore({ dir: stateDir, kek });
148
+ this.#unlocked = true;
149
+ });
150
+ // Fully local/offline (T11) — see log-checkpoint.mjs. `.unref()` so this timer alone never
151
+ // keeps the process alive; the signing socket already does that for a real daemon, and tests
152
+ // that construct a SignerDaemon without ever starting a socket already call process.exit()
153
+ // explicitly rather than waiting for handles to drain.
154
+ if (checkpointIntervalMs > 0) {
155
+ this.#checkpointTimer = setInterval(() => this.checkpointLog(), checkpointIntervalMs);
156
+ this.#checkpointTimer.unref?.();
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Takes a checkpoint of the log right now, regardless of the timer — real callers get this for
162
+ * free on the interval above; tests and an operator-triggered `log-anchor.mjs` run call it
163
+ * directly for a deterministic, on-demand checkpoint rather than waiting out the timer. A log
164
+ * entry recording the checkpoint is written AFTER computing it, so it naturally lands in the
165
+ * NEXT checkpoint's batch rather than needing to somehow retroactively include itself.
166
+ */
167
+ checkpointLog() {
168
+ const checkpoint = takeCheckpoint(this.stateDir);
169
+ if (checkpoint) {
170
+ this.#log({ type: 'log-checkpoint', checkpointHash: checkpoint.checkpointHash, entryCount: checkpoint.entryCount, at: checkpoint.createdAt });
171
+ }
172
+ return checkpoint;
173
+ }
174
+
175
+ /** Stops the periodic checkpoint timer — not required for normal process-exit cleanup (the
176
+ * timer is already unref'd), but available for anything that wants a SignerDaemon's lifecycle
177
+ * fully quiesced without exiting the process (e.g. a test harness running many in one process). */
178
+ stopCheckpointing() {
179
+ if (this.#checkpointTimer) clearInterval(this.#checkpointTimer);
180
+ this.#checkpointTimer = null;
181
+ }
182
+
183
+ get unlocked() {
184
+ return this.#unlocked;
185
+ }
186
+
187
+ get identity() {
188
+ return this.#agentDid;
189
+ }
190
+
191
+ setIdentity(did) {
192
+ this.#agentDid = did;
193
+ }
194
+
195
+ async waitUntilUnlocked() {
196
+ await this.#unlockPromise;
197
+ }
198
+
199
+ #assertAllowedOp(op) {
200
+ if (!ALL_SIGNING_OPS.has(op)) throw new DaemonError('DAEMON_UNKNOWN_OPERATION', `unknown operation ${op}`);
201
+ if (!SIGNING_OPS_BY_ROLE[this.role].has(op)) throw new DaemonError('DAEMON_OPERATION_NOT_PERMITTED', `${op} not permitted for role ${this.role}`);
202
+ }
203
+
204
+ #assertRateLimit(op) {
205
+ const cfg = this.#rateLimits[op];
206
+ if (!cfg) return;
207
+ const ok = this.#limiter.hit(op, cfg.max, cfg.windowMs);
208
+ if (!ok) {
209
+ this.#log({ type: 'rate-limit', op, shadow: this.#shadowMode, at: new Date().toISOString() });
210
+ if (!this.#shadowMode) throw new DaemonError('DAEMON_RATE_LIMITED');
211
+ }
212
+ }
213
+
214
+ #sign(messageBuffer) {
215
+ if (!this.#unlocked) throw new DaemonError('DAEMON_LOCKED');
216
+ try {
217
+ return this.keystore.sign(messageBuffer);
218
+ } catch (err) {
219
+ if (err instanceof KeyStoreError && err.code === 'NOT_PROVISIONED') throw new DaemonError('DAEMON_NOT_PROVISIONED');
220
+ throw new DaemonError('DAEMON_INTERNAL_ERROR');
221
+ }
222
+ }
223
+
224
+ #assertIdentity(agentDid) {
225
+ if (!this.#agentDid) throw new DaemonError('DAEMON_NOT_PROVISIONED');
226
+ if (agentDid !== this.#agentDid) throw new DaemonError('DAEMON_IDENTITY_MISMATCH');
227
+ }
228
+
229
+ /** Dispatch one signing-socket request. Returns the `result` payload; throws DaemonError on any rejection. */
230
+ handleSigningRequest({ op, params }) {
231
+ this.#assertAllowedOp(op);
232
+ switch (op) {
233
+ case 'ping':
234
+ return { status: 'ok', unlocked: this.#unlocked };
235
+ case 'get-identity':
236
+ return { identity: this.#agentDid, publicKeyHex: this.#unlocked ? this.keystore.publicKeyHex() : null, role: this.role };
237
+ case 'sign-authorize':
238
+ return this.#handleSignAuthorize(params);
239
+ case 'sign-envelope':
240
+ return this.#handleSignEnvelope(params);
241
+ case 'sign-handshake-nonce':
242
+ return this.#handleSignHandshakeNonce(params);
243
+ case 'sign-key-control-challenge':
244
+ return this.#handleSignKeyControlChallenge(params);
245
+ case 'sign-log-checkpoint':
246
+ return this.#handleSignLogCheckpoint(params);
247
+ case 'sign-local-decision':
248
+ return this.#handleSignLocalDecision(params);
249
+ default:
250
+ throw new DaemonError('DAEMON_UNKNOWN_OPERATION');
251
+ }
252
+ }
253
+
254
+ #validateCoreFields({ agentDid, action, amount, currency, merchant, resource, nonce, issuedAt }) {
255
+ if (typeof agentDid !== 'string' || typeof action !== 'string' || typeof nonce !== 'string' || typeof issuedAt !== 'string') {
256
+ throw new DaemonError('DAEMON_MALFORMED_REQUEST', 'missing required field');
257
+ }
258
+ if (action.length === 0 || action.includes('|')) throw new DaemonError('DAEMON_MALFORMED_REQUEST', 'invalid action');
259
+ if (!isFiniteNonNegative(amount)) throw new DaemonError('DAEMON_MALFORMED_REQUEST', 'amount must be finite and non-negative');
260
+ if (merchant !== undefined && typeof merchant !== 'string') throw new DaemonError('DAEMON_MALFORMED_REQUEST', 'invalid merchant');
261
+ if (currency !== undefined && typeof currency !== 'string') throw new DaemonError('DAEMON_MALFORMED_REQUEST', 'invalid currency');
262
+ if (resource !== undefined && typeof resource !== 'string') throw new DaemonError('DAEMON_MALFORMED_REQUEST', 'invalid resource');
263
+ this.#assertIdentity(agentDid);
264
+ if (!freshnessOk(issuedAt)) throw new DaemonError('DAEMON_MALFORMED_REQUEST', 'issuedAt outside freshness window');
265
+ }
266
+
267
+ #handleSignAuthorize(params = {}) {
268
+ this.#assertRateLimit('sign-authorize');
269
+ this.#validateCoreFields(params);
270
+ const { agentDid, action, amount, currency = 'USD', merchant, resource, nonce, issuedAt } = params;
271
+ // `resource` is the 8th canonical field (canonical.ts) — dropping it here (found live: it
272
+ // was) means every request signed via this daemon silently omits it from the signature,
273
+ // so the real gate's 8-field reconstruction never matches for any resource-bearing call.
274
+ const message = buildAuthMessage({ agentDid, action, amount, currency, merchant, resource, nonce, issuedAt });
275
+ return { signature: this.#sign(Buffer.from(message, 'utf8')) };
276
+ }
277
+
278
+ /**
279
+ * `LocalDecisionMessageFields` has a completely different shape from `AuthMessageFields`
280
+ * (agentDid/action/decision/reasonCode/nonce/issuedAt vs. .../amount/currency/merchant/...) —
281
+ * cannot reuse #validateCoreFields, which requires amount and treats merchant/currency as
282
+ * optional strings that don't exist on this shape at all.
283
+ */
284
+ #validateLocalDecisionFields({ agentDid, action, decision, reasonCode, nonce, issuedAt }) {
285
+ if (typeof agentDid !== 'string' || typeof action !== 'string' || typeof nonce !== 'string' || typeof issuedAt !== 'string') {
286
+ throw new DaemonError('DAEMON_MALFORMED_REQUEST', 'missing required field');
287
+ }
288
+ if (action.length === 0 || action.includes('|')) throw new DaemonError('DAEMON_MALFORMED_REQUEST', 'invalid action');
289
+ if (typeof decision !== 'string' || !REPORTABLE_LOCAL_DECISIONS.has(decision)) {
290
+ throw new DaemonError('DAEMON_MALFORMED_REQUEST', 'decision must be one of allow|observe|block|escalate');
291
+ }
292
+ if (typeof reasonCode !== 'string' || reasonCode.length === 0) {
293
+ throw new DaemonError('DAEMON_MALFORMED_REQUEST', 'reasonCode must be a non-empty string');
294
+ }
295
+ this.#assertIdentity(agentDid);
296
+ if (!freshnessOk(issuedAt)) throw new DaemonError('DAEMON_MALFORMED_REQUEST', 'issuedAt outside freshness window');
297
+ }
298
+
299
+ /**
300
+ * Signs an agent's own local-first verdict for best-effort audit reporting (see
301
+ * agentsafe-guard.mjs's reportLocalDecision()) — a daemon-custody agent gets the same
302
+ * audit-visibility enhancement the static-key provider already has.
303
+ */
304
+ #handleSignLocalDecision(params = {}) {
305
+ this.#assertRateLimit('sign-local-decision');
306
+ this.#validateLocalDecisionFields(params);
307
+ const { agentDid, action, decision, reasonCode, nonce, issuedAt } = params;
308
+ const message = buildLocalDecisionMessage({ agentDid, action, decision, reasonCode, nonce, issuedAt });
309
+ return { signature: this.#sign(Buffer.from(message, 'utf8')) };
310
+ }
311
+
312
+ #handleSignEnvelope(params = {}) {
313
+ this.#assertRateLimit('sign-envelope');
314
+ this.#validateCoreFields(params);
315
+ const { agentDid, action, amount, currency = 'USD', merchant, itinerary, trace, materiality, nonce, issuedAt } = params;
316
+ const hash = envelopeHashFor({ agentDid, action, amount, currency, merchant, itinerary, trace, materiality, nonce, issuedAt, signature: '' });
317
+ return { envelopeSignature: this.#sign(Buffer.from(hash, 'utf8')) };
318
+ }
319
+
320
+ #handleSignHandshakeNonce(params = {}) {
321
+ this.#assertRateLimit('sign-handshake-nonce');
322
+ const { nonce } = params;
323
+ if (typeof nonce !== 'string' || !UUID_RE.test(nonce)) throw new DaemonError('DAEMON_MALFORMED_REQUEST', 'nonce must be a UUID');
324
+ return { signature: this.#sign(Buffer.from(nonce, 'utf8')) };
325
+ }
326
+
327
+ #handleSignKeyControlChallenge(params = {}) {
328
+ this.#assertRateLimit('sign-key-control-challenge');
329
+ const { challenge } = params;
330
+ if (typeof challenge !== 'string' || challenge.length === 0 || challenge.length > 4096 || !HEX_RE.test(challenge)) {
331
+ throw new DaemonError('DAEMON_MALFORMED_REQUEST', 'challenge must be non-empty hex');
332
+ }
333
+ // Signs the UTF-8 bytes of the hex STRING itself, not the hex-decoded bytes — matches the
334
+ // real convention both agentsafe-guard.mjs's original sign(challenge) and
335
+ // create-metamynd-agent's signChallengeHex already use (crypto.sign(..., Buffer.from(challenge,
336
+ // 'utf8'), ...)). Signing the decoded bytes instead would produce a signature the real
337
+ // verify-key backend endpoint does not accept — caught by daemon-keyprovider.smoke.mjs in
338
+ // agentsafe-guard, which is the first place this daemon was actually wired to a real caller.
339
+ return { signature: this.#sign(Buffer.from(challenge, 'utf8')) };
340
+ }
341
+
342
+ /**
343
+ * Signs a log-checkpoint anchor request (T11) — proves to the backend that THIS agent/service's
344
+ * daemon produced this checkpointHash, so the anchored HCS message can be trusted to actually
345
+ * come from the claimed identity. Unlike sign-authorize/sign-envelope, `agentDid` is never taken
346
+ * from `params` — it's always this daemon's own bound identity, the same way
347
+ * sign-key-control-challenge always signs under its own identity rather than trusting a
348
+ * caller-supplied one, so there is no identity-mismatch case to check here at all.
349
+ */
350
+ #handleSignLogCheckpoint(params = {}) {
351
+ this.#assertRateLimit('sign-log-checkpoint');
352
+ if (!this.#agentDid) throw new DaemonError('DAEMON_NOT_PROVISIONED');
353
+ const { checkpointHash, previousCheckpointHash, entryCount, nonce, issuedAt } = params;
354
+ if (typeof checkpointHash !== 'string' || !SHA256_HEX_RE.test(checkpointHash)) {
355
+ throw new DaemonError('DAEMON_MALFORMED_REQUEST', 'checkpointHash must be a sha256 hex digest');
356
+ }
357
+ if (typeof previousCheckpointHash !== 'string' || !SHA256_HEX_RE.test(previousCheckpointHash)) {
358
+ throw new DaemonError('DAEMON_MALFORMED_REQUEST', 'previousCheckpointHash must be a sha256 hex digest');
359
+ }
360
+ if (!Number.isInteger(entryCount) || entryCount < 0) {
361
+ throw new DaemonError('DAEMON_MALFORMED_REQUEST', 'entryCount must be a non-negative integer');
362
+ }
363
+ if (typeof nonce !== 'string' || !UUID_RE.test(nonce)) throw new DaemonError('DAEMON_MALFORMED_REQUEST', 'nonce must be a UUID');
364
+ if (typeof issuedAt !== 'string' || !freshnessOk(issuedAt)) throw new DaemonError('DAEMON_MALFORMED_REQUEST', 'issuedAt outside freshness window');
365
+
366
+ const message = buildCheckpointAnchorMessage({ agentDid: this.#agentDid, checkpointHash, previousCheckpointHash, entryCount, nonce, issuedAt });
367
+ return { signature: this.#sign(Buffer.from(message, 'utf8')) };
368
+ }
369
+
370
+ /** Dispatch one admin-socket request. Only reachable while the admin socket is open (see startAdminServer). */
371
+ handleAdminRequest({ op, params = {} }) {
372
+ switch (op) {
373
+ case 'status':
374
+ return { hasKey: this.#unlocked ? this.keystore.hasKey() : false };
375
+ case 'generate-key': {
376
+ if (!this.#unlocked) throw new DaemonError('DAEMON_LOCKED');
377
+ try {
378
+ const publicKeyHex = this.keystore.generate({ allowRekey: !!params.allowRekey });
379
+ this.#log({ type: 'admin-generate-key', at: new Date().toISOString() });
380
+ return { publicKeyHex };
381
+ } catch (err) {
382
+ if (err instanceof KeyStoreError && err.code === 'KEY_ALREADY_EXISTS') throw new DaemonError('DAEMON_KEY_ALREADY_EXISTS');
383
+ throw new DaemonError('DAEMON_INTERNAL_ERROR');
384
+ }
385
+ }
386
+ default:
387
+ throw new DaemonError('DAEMON_UNKNOWN_OPERATION');
388
+ }
389
+ }
390
+
391
+ /** Starts the always-on signing socket. Resolves once listening. */
392
+ startSigningServer(socketPath) {
393
+ return startServer(socketPath, (socket) => {
394
+ attachJsonLines(socket, (line) => {
395
+ let req;
396
+ try {
397
+ req = JSON.parse(line);
398
+ } catch {
399
+ writeResponse(socket, null, { ok: false, error: { code: 'DAEMON_MALFORMED_REQUEST', message: 'invalid JSON' } });
400
+ return;
401
+ }
402
+ const requestId = typeof req.requestId === 'string' ? req.requestId : null;
403
+ if (req.protocolVersion !== PROTOCOL_VERSION) {
404
+ writeResponse(socket, requestId, { ok: false, error: { code: 'DAEMON_UNSUPPORTED_PROTOCOL_VERSION' } });
405
+ return;
406
+ }
407
+ try {
408
+ const result = this.handleSigningRequest(req);
409
+ this.#log({ type: 'sign', op: req.op, requestId, outcome: 'ok', at: new Date().toISOString() });
410
+ writeResponse(socket, requestId, { ok: true, result });
411
+ } catch (err) {
412
+ const code = err instanceof DaemonError ? err.code : 'DAEMON_INTERNAL_ERROR';
413
+ this.#log({ type: 'sign', op: req.op, requestId, outcome: code, at: new Date().toISOString() });
414
+ writeResponse(socket, requestId, { ok: false, error: { code, message: err.message } });
415
+ }
416
+ });
417
+ });
418
+ }
419
+
420
+ /**
421
+ * Starts the admin socket for exactly one successful operation or `timeoutMs`, whichever comes
422
+ * first, then closes it — deliberately not always-on the way the signing socket is (see
423
+ * docs/design/agent-key-custody-local-signer-daemon-plan.md, "The admin interface").
424
+ */
425
+ startAdminServer(socketPath, { timeoutMs = 60_000 } = {}) {
426
+ if (this.#adminOpen) throw new Error('admin socket already open');
427
+ this.#adminOpen = true;
428
+ return new Promise((resolve, reject) => {
429
+ let server;
430
+ const timer = setTimeout(() => server?.close(), timeoutMs);
431
+ // On Windows, {once, timeoutMs} lets the underlying secure-pipe relay own its own timeout
432
+ // too (windows-secure-pipe.mjs) — redundant with the `timer` above by design, not a bug:
433
+ // .close() is idempotent, and this layer's timer is still what Linux/macOS relies on since
434
+ // their branch of startServer has no timeout concept of its own.
435
+ startServer(socketPath, (socket) => {
436
+ attachJsonLines(socket, (line) => {
437
+ let req;
438
+ try {
439
+ req = JSON.parse(line);
440
+ } catch {
441
+ writeResponse(socket, null, { ok: false, error: { code: 'DAEMON_MALFORMED_REQUEST' } });
442
+ return;
443
+ }
444
+ const requestId = typeof req.requestId === 'string' ? req.requestId : null;
445
+ try {
446
+ const result = this.handleAdminRequest(req);
447
+ writeResponse(socket, requestId, { ok: true, result });
448
+ if (req.op === 'generate-key') {
449
+ clearTimeout(timer);
450
+ server.close();
451
+ }
452
+ } catch (err) {
453
+ const code = err instanceof DaemonError ? err.code : 'DAEMON_INTERNAL_ERROR';
454
+ writeResponse(socket, requestId, { ok: false, error: { code, message: err.message } });
455
+ }
456
+ });
457
+ }, { once: true, timeoutMs }).then((s) => {
458
+ server = s;
459
+ // If the pipe already closed before this .then() ever ran (its own overall timeoutMs
460
+ // elapsed with no connection — see startServer's own 'close' handling on Windows), a
461
+ // 'close' listener attached now would wait forever for an event that already happened.
462
+ // `.closed` is the synchronous escape hatch for exactly that race.
463
+ if (server.closed) {
464
+ this.#adminOpen = false;
465
+ resolve();
466
+ return;
467
+ }
468
+ server.on('close', () => {
469
+ this.#adminOpen = false;
470
+ resolve();
471
+ });
472
+ }, reject);
473
+ });
474
+ }
475
+ }
476
+
477
+ /** The short pipe name .NET's NamedPipeServerStream wants — the client-facing `\\.\pipe\...` path
478
+ * and the server-side relay (windows-secure-pipe.mjs) both derive from this SAME function, so
479
+ * they always agree on the name without either one hard-coding the other's format. */
480
+ function windowsPipeName(logicalPath) {
481
+ const name = crypto.createHash('sha256').update(path.resolve(logicalPath)).digest('hex').slice(0, 32);
482
+ return `agentsafe-signer-${name}`;
483
+ }
484
+
485
+ /**
486
+ * Windows has no filesystem-path Unix domain sockets — Node requires the `\\.\pipe\` namespace
487
+ * there instead (the design doc's own "named pipe on Windows" distinction). A logical path is
488
+ * still passed in everywhere else in this module so callers don't need platform branches of
489
+ * their own; only this function and `startServer` below know the difference.
490
+ */
491
+ export function toPlatformSocketPath(logicalPath) {
492
+ if (process.platform !== 'win32') return logicalPath;
493
+ return `\\\\.\\pipe\\${windowsPipeName(logicalPath)}`;
494
+ }
495
+
496
+ /**
497
+ * `{ once, timeoutMs }` route to the admin socket's one-shot lifecycle (real on every platform —
498
+ * see createSecurePipeOnce and the Linux/macOS branch below); omitted, it's the always-on signing
499
+ * socket. Resolves once the pipe/socket is genuinely ready to accept, not just "spawn requested" —
500
+ * on Windows that means waiting for the first relay instance's own ACL confirmation.
501
+ */
502
+ function startServer(logicalPath, onConnection, { once = false, timeoutMs } = {}) {
503
+ if (process.platform === 'win32') {
504
+ const pipeName = windowsPipeName(logicalPath);
505
+ return new Promise((resolve, reject) => {
506
+ const server = once ? createSecurePipeOnce(pipeName, onConnection, { timeoutMs }) : createSecurePipePool(pipeName, onConnection, {});
507
+ server.once('aclVerified', (ruleCount) => {
508
+ // Real ACL restriction, not a filesystem mode bit — see windows-secure-pipe.mjs. A rule
509
+ // count other than 1 (current user only) means the pipe was NOT restricted as intended;
510
+ // fail loudly rather than silently serve on an unexpectedly-open pipe.
511
+ if (ruleCount !== 1) {
512
+ server.close();
513
+ reject(new Error(`named pipe ACL has ${ruleCount} rules, expected exactly 1 (current user only)`));
514
+ return;
515
+ }
516
+ resolve(server);
517
+ });
518
+ // Handles 'close' firing BEFORE 'aclVerified' ever did — two different reasons, two
519
+ // different outcomes. (1) createSecurePipeOnce exhausted its retries without the pipe ever
520
+ // becoming ready at all: emitted WITH an Error, and this promise REJECTS — before this
521
+ // existed, that case had no failure path at all and just hung forever. (2) The pipe's own
522
+ // overall timeoutMs elapsed with no client ever connecting — the normal, expected one-shot
523
+ // admin-socket lifecycle (see startAdminServer's own {once,timeoutMs}): emitted with NO
524
+ // error, and this promise still RESOLVES with the (already-closed) server — a caller like
525
+ // startAdminServer needs the server object either way to know its own #adminOpen bookkeeping
526
+ // is done, and checks `server.closed` synchronously for exactly this case (a 'close'
527
+ // listener attached only after this .then() fires would never see an event that already
528
+ // happened). A normal close AFTER a successful 'aclVerified'-driven resolve() is a harmless
529
+ // no-op here — whichever of resolve/reject settles a promise first wins.
530
+ server.once('close', (err) => {
531
+ if (err) reject(err);
532
+ else resolve(server);
533
+ });
534
+ });
535
+ }
536
+ const socketPath = logicalPath;
537
+ return new Promise((resolve, reject) => {
538
+ const server = net.createServer(onConnection);
539
+ server.on('error', reject);
540
+ if (fs.existsSync(socketPath)) fs.unlinkSync(socketPath);
541
+ fs.mkdirSync(path.dirname(socketPath), { recursive: true, mode: 0o700 });
542
+ server.listen(socketPath, () => {
543
+ fs.chmodSync(socketPath, 0o600);
544
+ resolve(server);
545
+ });
546
+ });
547
+ }
548
+
549
+ function defaultLogger(stateDir) {
550
+ const logPath = path.join(stateDir, 'signer.log');
551
+ return (entry) => {
552
+ try {
553
+ fs.appendFileSync(logPath, JSON.stringify(entry) + '\n', { mode: 0o600 });
554
+ } catch {
555
+ /* logging must never crash the daemon */
556
+ }
557
+ };
558
+ }