@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.
package/README.md ADDED
@@ -0,0 +1,382 @@
1
+ # AgentSafe Signer — the local key-custody daemon
2
+
3
+ Reference implementation of [`docs/design/agent-key-custody-local-signer-daemon-plan.md`](../../docs/design/agent-key-custody-local-signer-daemon-plan.md).
4
+ A separate OS process holds an agent's or a service's Ed25519 signing key — `agentsafe-guard`/
5
+ `agentsafe-mcp-guard` never hold it themselves — and exposes a small, closed set of signing
6
+ operations over a local socket. The key never enters the calling guard's own process, encrypted
7
+ or otherwise, under any code path.
8
+
9
+ **This is a first implementation pass, not the finished design.** It exists to make the protocol,
10
+ key storage, and socket layer real and tested, not to claim every property the design doc
11
+ describes is fully delivered yet. See "Status" below for exactly what that means.
12
+
13
+ ## Try it
14
+
15
+ ```bash
16
+ npm test # daemon-protocol + daemon-key-exfiltration + kek-backends + service-installer + daemon-log-integrity, offline, no network
17
+ ```
18
+
19
+ ```bash
20
+ node cli.mjs start --state-dir ./.signer --role agent --admin
21
+ ```
22
+
23
+ Resolves the KEK from a real OS-keychain backend automatically (see "OS-keychain KEK backends"
24
+ below) — no passphrase needed unless none is available on the host, in which case it says so and
25
+ tells you what to set. Opens the signing socket (always on) and the admin socket (closes after one
26
+ `generate-key` call or 60s, whichever comes first — see the design doc's "The admin interface").
27
+
28
+ ## Status
29
+
30
+ **Implemented and tested:**
31
+ - The full eight-operation signing-socket protocol (`sign-authorize`, `sign-envelope`,
32
+ `sign-handshake-nonce`, `sign-key-control-challenge`, `sign-log-checkpoint`,
33
+ `sign-local-decision`, `get-identity`, `ping`) and the two-operation admin socket
34
+ (`generate-key`, `status`), matching the design doc's closed operation set exactly — no generic
35
+ "sign this string" primitive.
36
+ - Canonical message construction via the *real*, backend-generated `buildAuthMessage`/
37
+ `envelopeHashFor` (`npm run build:signer-core` from `backend/`, the same generation mechanism
38
+ `agentsafe-guard`/`agentsafe-mcp-guard` already use for their own copies) — not a daemon-local
39
+ reimplementation that could drift.
40
+ - Per-request validation: identity binding (`DAEMON_IDENTITY_MISMATCH`), amount
41
+ finite/non-negative, no `|` in `action`, `issuedAt` freshness bound at *sign* time (closes the
42
+ pre-signed-stockpile gap, not just the verifier's own freshness check), role-based operation
43
+ allow-listing (`DAEMON_OPERATION_NOT_PERMITTED`), a closed `DAEMON_*` error-code set, fail-closed
44
+ on every rejection.
45
+ - Per-operation rate limiting (sliding window), shadow mode by default per the design doc's own
46
+ "ship in shadow, promote from real data" guidance — `shadowMode: false` to enforce.
47
+ - Local, append-only logging of every request (operation/timestamp/`requestId`/outcome), verified
48
+ to never contain key material — both by fuzzing every response and by statically checking the
49
+ logging call sites' own source.
50
+ - **OS-keychain-backed KEK storage** (`kek-backends.mjs`) — see its own section below for exactly
51
+ what's verified vs. implemented-but-untested per platform. The passphrase+scrypt path from the
52
+ first implementation pass is now the last-resort fallback, not the only option.
53
+ - **Wired into both `agentsafe-guard` (0.9.0) and `agentsafe-mcp-guard` (0.5.0)** via the
54
+ `keyProvider` seam — `keyProvider: 'daemon'` talks to a running instance of this daemon over its
55
+ socket on either side; the key never enters either guard's own process. Proven end to end by
56
+ `agentsafe-guard/daemon-keyprovider.smoke.mjs` and `agentsafe-mcp-guard/daemon-keyprovider.smoke.mjs`
57
+ (a real signer daemon signing for a real guard, both roles).
58
+ - **OS service unit generation** (`service-installer.mjs`) — see its own section below for exactly
59
+ what "generation" does and, deliberately, does not do.
60
+ - `daemon-protocol.smoke.mjs`, `daemon-key-exfiltration.smoke.mjs`, `kek-backends.smoke.mjs`,
61
+ `service-installer.smoke.mjs`, `daemon-envelope-parity.smoke.mjs`,
62
+ `daemon-admin-socket.smoke.mjs`, `daemon-socket-permissions.smoke.mjs`,
63
+ `daemon-hardening.smoke.mjs` and `daemon-hardening-tier2.smoke.mjs` from the design doc's
64
+ smoke-test suite, all real and passing, not just named — **all nine now exist as their own
65
+ files**, which was an open gap in the previous pass.
66
+ - **Socket permissions (T4)** — `daemon-socket-permissions.smoke.mjs` asserts the signing AND admin
67
+ sockets are mode exactly `0600`, owned by the invoking uid, inside a `0700` directory, read back
68
+ from the filesystem after a real `listen()`. It carries its own negative control (a deliberately
69
+ `0666` file must be rejected by the same comparison), and it documents a real window it found:
70
+ `net.Server.listen()` creates the socket node at `0777 & ~umask` — observed `0755` — and
71
+ `daemon.mjs` chmods it to `0600` a syscall later. That window is **not** a T4 hole, because
72
+ traversing to the socket also needs `+x` on its parent, and the parent is created `0700` *before*
73
+ the bind; the test asserts that directory mode is what closes it, rather than leaving the window
74
+ undocumented. What it does **not** prove: an actual connect attempt from a second OS user —
75
+ creating one needs root, so T4 rests here on the permission bits the kernel enforces, not on an
76
+ observed `EACCES` from a foreign uid. The last two closed with zero bugs found in `daemon.mjs` itself — the underlying
77
+ behavior (canonical-message parity, the admin socket's own-timeout/one-op-then-close lifecycle)
78
+ was already correct; these two just prove it rather than leave it merely documented.
79
+ - **A real, ACL-restricted named pipe on Windows** (`windows-secure-pipe.mjs`) — an earlier pass
80
+ of this README claimed this needed a native addon (see git history); that was wrong. The same
81
+ shell-to-PowerShell technique already used for DPAPI works here too: .NET's
82
+ `System.IO.Pipes.NamedPipeServerStream` accepts a real `PipeSecurity` restricting the pipe to
83
+ the current Windows user (verified: `GetAccessRules().Count === 1`), and a small relay process
84
+ bridges it to Node's own `net` socket API over the relay's stdio. `toPlatformSocketPath` and
85
+ `daemon.mjs`'s `startServer` route through it automatically on `win32`; `daemon-protocol.smoke.mjs`
86
+ and `daemon-admin-socket.smoke.mjs` both exercise the real transport (not a mock) on every run.
87
+ Two genuine Windows-specific bugs were found and fixed getting here, verified empirically rather
88
+ than assumed: additional multi-instance pipes need `PipeAccessRights.FullControl`, not
89
+ `ReadWrite`, or every instance past the first fails with "Access to the path is denied"; and the
90
+ pipe must be opened with `PipeOptions.Asynchronous`, not `None` — a synchronous handle can't
91
+ safely run the relay's two concurrent `CopyToAsync` directions at once, and without it a
92
+ response written back to a connected client was silently lost. A third, subtler bug surfaced
93
+ only under real concurrent load (several relay processes constructing a
94
+ `NamedPipeServerStream` with a custom `PipeSecurity` at nearly the same instant, even for
95
+ DIFFERENT pipe names): one occasionally hangs indefinitely inside the .NET constructor call
96
+ itself, never reporting readiness and never exiting on its own — with no bound on this, the
97
+ hang was unrecoverable and propagated all the way up to `daemon.startSigningServer`/
98
+ `startAdminServer`'s own callers. Fixed with a per-attempt construction timeout
99
+ (`SPAWN_TIMEOUT_MS`) that kills a stuck attempt and retries (bounded, `MAX_SPAWN_ATTEMPTS`, for
100
+ the one-shot admin socket; unbounded, matching its own "always on" design, for the signing
101
+ socket's pool) — self-healing rather than assuming the first attempt always succeeds.
102
+ - **Local, tamper-evident log hash-chaining** (`log-checkpoint.mjs`, T11 — "the covered half"):
103
+ the daemon periodically (`DEFAULT_CHECKPOINT_INTERVAL_MS`, 15 minutes) batches whatever new lines
104
+ have accumulated in `signer.log` since the last checkpoint, hashes each line's exact stored
105
+ bytes, builds a Merkle root over the batch (`merkle.mjs`, the same algorithm
106
+ `backend/src/features/magp/merkle.ts` already uses — ported rather than imported, since this
107
+ package has no dependency on the backend or any sibling package, by design), and chains that
108
+ root to the previous checkpoint's own hash. `agentsafe-signer verify-log --state-dir <dir>`
109
+ recomputes the whole chain directly from the log's own bytes and reports the exact checkpoint
110
+ where tampering was introduced, if any — proven by `daemon-log-integrity.smoke.mjs` actually
111
+ flipping a byte inside an already-checkpointed line and confirming both the direct API and the
112
+ real CLI subcommand detect it, not just that an untouched chain verifies. This closes the
113
+ "covered half" of T11 named in the design doc's own threat model; it does **not**, on its own,
114
+ stop an attacker who controls both the log and the checkpoints file from rewriting both
115
+ consistently — that needs a checkpoint's hash anchored somewhere outside this host, which is a
116
+ separate, still-outstanding piece (see below).
117
+
118
+ **Also implemented and tested, since this section was last written accurately:**
119
+ - **Externally anchoring a checkpoint hash (the rest of T11).** `log-anchor.mjs` asks the daemon to
120
+ sign each pending checkpoint (`sign-log-checkpoint`) and submits it to the backend's
121
+ `POST /evidence/signer-checkpoint` (DID-signature-authenticated, not session-based — a headless
122
+ daemon has no user session to present), which anchors it via HCS. Idempotent, capped at
123
+ 96 anchors/agent/day. `agentsafe-signer log-anchor` CLI subcommand; `log-anchor.smoke.mjs`.
124
+ - **A real migration/fresh-provisioning CLI.** `agentsafe-signer migrate` drives
125
+ `rotateNetworkIdentity` end to end (generate-key → rotate → sign-key-control-challenge →
126
+ verify-key — the proof-of-possession trio that didn't exist for BYOK network identities until
127
+ this was built); `create-metamynd-agent --byok --daemon-socket` drives fresh provisioning the
128
+ same way. Both routed through the daemon; the private key never enters either CLI's process.
129
+ - **Real `mlock()`/`VirtualLock()` protection for the decrypted key.** `secure-memory.mjs`, backed
130
+ by `sodium-native` (this package's only dependency, added deliberately — this specific property
131
+ cannot be reached via a shell-out the way every other gap here was closed, since it must run
132
+ inside the process that owns the memory). `secure-memory.smoke.mjs`.
133
+ - **Core-dump-disable and Linux `ptrace`-denial**, closed declaratively rather than in the daemon's
134
+ own runtime code: the generated systemd units now prefix `ExecStart=` with
135
+ `setpriv --dumpable 0 --` (a real binary, no shell needed — ptrace-deny, pairs with the
136
+ already-present `LimitCORE=0`), and the generated launchd plist routes through `/bin/sh -c
137
+ 'ulimit -c 0; exec "$@"'` (launchd has no `LimitCORE`-equivalent key, and `ulimit` is a shell
138
+ builtin) — both a true `exec()` chain, never a forked supervisor process. **Disclosed, not
139
+ closed**: macOS has no `ptrace`-deny reachable this way at all — `PT_DENY_ATTACH` must be called
140
+ *by* the target process itself via a syscall, the same architectural limitation `secure-memory.mjs`
141
+ discloses for `mlock()`.
142
+ - **`sign-local-decision`, closing the gap left when local-decision audit reporting first
143
+ shipped.** A daemon-custody agent can now sign its own local-first block/escalate/non-value-allow
144
+ verdict for `POST /policy/decisions/local` the same way the static-key provider always could —
145
+ `agentsafe-guard`'s `createDaemonKeyProvider` gained the matching client method. Agent-role only,
146
+ same as `sign-authorize`/`sign-envelope` (a service-role daemon never evaluates a mandate
147
+ locally). `daemon-protocol.smoke.mjs`, `daemon-envelope-parity.smoke.mjs`.
148
+
149
+ **Deliberately not yet implemented — real gaps, not oversights:**
150
+ - **The generated systemd unit has never started a daemon.** Running it for real on Ubuntu 24.04
151
+ LTS / systemd 255 (the first time this was tried on any Linux host) found five defects in a
152
+ single `ExecStart` line, four of them fatal before any daemon code ran. Three are fixed here;
153
+ two remain and are listed under "OS service unit generation" below. Until those two are closed,
154
+ `cli.mjs install`'s output is a template to adapt, not something that runs as generated.
155
+ - `daemon-hardening.smoke.mjs` **currently fails** — on the two real defects above, not on a test
156
+ bug. That is deliberate: it is the assertion that would have caught them, and greening it by
157
+ weakening it would restore the exact blind spot that let a never-runnable unit ship.
158
+ - All the smoke tests the design doc names now exist as their own files:
159
+ `daemon-socket-permissions.smoke.mjs` (T4), `daemon-hardening.smoke.mjs` (Tier 1) and
160
+ `daemon-hardening-tier2.smoke.mjs` (Tier 2, verified against a real system unit — see above). **Log integrity** is its own real file
161
+ (`daemon-log-integrity.smoke.mjs`, above) — though it covers only the local hash-chain half of
162
+ T11, not the external-anchoring half, which remains open per the bullet above.
163
+
164
+ ## Hardening tiers — what a real host actually confirms (T3, T5)
165
+
166
+ `daemon-hardening.smoke.mjs` checks the tier directives **from outside** the process, against the
167
+ kernel's own view, never by trusting the unit file's text (`service-installer.smoke.mjs` already
168
+ covers the text; these are different questions, and the `setpriv` defect above is exactly what
169
+ happens when only the text is checked). It reports three outcomes, not two — `ok`, `FAIL`, and
170
+ **INCONCLUSIVE** — because on this axis a green check that would stay green with the hardening
171
+ removed is worse than no check.
172
+
173
+ Confirmed on Ubuntu 24.04.4 / systemd 255:
174
+
175
+ - **`LimitCORE=0` works.** A live service process reported `Max core file size 0 0` in
176
+ `/proc/<pid>/limits`, soft *and* hard, with a negative control proving an unconstrained process
177
+ on the same host reports `unlimited` — so the check is reading the directive, not the host
178
+ default.
179
+ - **`SocketMode=0600`/`DirectoryMode=0700` work** on the real `%t/agentsafe/` path (see above).
180
+
181
+ Reported INCONCLUSIVE rather than passed, at Tier 1:
182
+
183
+ - **`ptrace` denial (T3).** This host runs `kernel.yama.ptrace_scope=1`, which already denies a
184
+ same-UID sibling's `PTRACE_ATTACH` for *every* process, hardened or not. An `EPERM` here is the
185
+ ambient LSM policy, not evidence the unit hardened anything, so the test says so instead of
186
+ banking the pass. Distinguishing the two needs `ptrace_scope=0`, a root-only host change.
187
+
188
+ ### Tier 2 is now verified against a real system unit
189
+
190
+ `daemon-hardening-tier2.smoke.mjs` is the second file the design doc names, and it decides what the
191
+ Tier 1 file structurally cannot: `DynamicUser=` is a **system**-manager feature, so a
192
+ `systemctl --user` service always runs as the invoking user and can never demonstrate it. Run
193
+ against a Tier 2 unit installed at `/etc/systemd/system/` on Ubuntu 24.04.4 / systemd 255, audited
194
+ from an ordinary unprivileged shell:
195
+
196
+ | Claim | Result |
197
+ |---|---|
198
+ | The daemon runs under a genuinely separate UID | **Verified** — uid `64750`, user `agentsafe-signer`, against an invoking uid of `1000` |
199
+ | A same-user `PTRACE_ATTACH` is refused (T3) | **Verified** — `EPERM`, from a real `PTRACE_ATTACH` probe, not from the unit's declared text |
200
+ | The daemon's `/proc` entries are unreadable to the agent's own account | **Verified** — `/proc/<pid>/environ` returns `EACCES` |
201
+ | `RemoveIPC=true` and `DynamicUser=true` are live on the loaded unit | **Verified** — `systemctl show` reports `yes` for both |
202
+ | Its state directory is out of reach of the invoking user | **Verified indirectly** — `/var/lib/private` is `0700 root`, so uid 1000 cannot even traverse to it |
203
+
204
+ **This is also what closes the Tier 1 namespacing caveat below.** The Tier 2 unit sets
205
+ `PrivateTmp=true` and `PrivateDevices=true` — the two directives that, at Tier 1, flipped a
206
+ same-UID `PTRACE_ATTACH` from `EPERM` to success. With both live and `yama.ptrace_scope=1`, the
207
+ cross-UID attach was still refused, because that refusal is the ordinary permission check and owes
208
+ nothing to Yama. Tier 2 does not inherit the Tier 1 hole; it closes it. The test asserts this
209
+ contrast directly rather than leaving it as prose here.
210
+
211
+ Still INCONCLUSIVE even with root, and reported as such:
212
+
213
+ - **`RemoveIPC`'s functional reap.** The directive is confirmed *live* on the loaded unit, which is
214
+ what the unit is responsible for. Observing the reap itself means creating IPC objects as the
215
+ dynamic user and then stopping the unit — privileged and destructive, and systemd's behavior
216
+ rather than this package's, so the read-only audit does not do it.
217
+ - **Direct `stat` of the state directory.** `/var/lib/private/<name>` is root-only traversable, so
218
+ the ownership check cannot read through it unprivileged. That is consistent with the isolation
219
+ being real, and is reported as unproven rather than inferred.
220
+
221
+ Two things the installation surfaced that are worth repeating, because neither is obvious from the
222
+ unit file:
223
+
224
+ - **Tier 2 cannot use a home-directory interpreter.** `nodePath` defaults to `process.execPath`,
225
+ which is correct at Tier 1 (the unit runs as the invoking user) and **wrong** at Tier 2: a
226
+ `DynamicUser` account is a different UID and cannot traverse a `0750` home, which is the Ubuntu
227
+ default. On the test host the dynamic user could reach neither the nvm `node` binary nor the
228
+ package under `~`. `cli.mjs install` now takes `--node-path` and **warns** when a Tier 2
229
+ `ExecStart` resolves under `/home`; the package itself needs a system path too
230
+ (`/usr/local/share/agentsafe/signer/`, as the design doc already specifies).
231
+ - **The privileged step stayed a human step.** The design doc requires it ("must always be a
232
+ printed instruction the operator runs themselves, never something the installer executes on their
233
+ behalf"), and `service-installer.smoke.mjs` asserts by source inspection that this package never
234
+ shells out to `systemctl`. The verification above was done by handing the operator a script to
235
+ run, not by the test installing anything.
236
+
237
+ **A counterintuitive result worth re-measuring on your own target host.** While establishing the
238
+ Yama baseline, bisecting one directive at a time and reproducing across rounds: a plain
239
+ `systemd-run --user` node process correctly refused `PTRACE_ATTACH` (`EPERM`), but adding **either**
240
+ `PrivateTmp=true` **or** `PrivateDevices=true` — both of which the generated unit sets — flipped the
241
+ identical process to `ATTACH_SUCCEEDED`. On such a host the "hardened" unit is *more* ptrace-exposed
242
+ than an unhardened one. This was measured, not derived from documentation, and the underlying kernel
243
+ mechanism was not chased down; it is recorded here as a caveat, and the test emits it as a standing
244
+ INCONCLUSIVE whenever the unit contains those directives and Yama is active. It does not change the
245
+ guidance — Tier 2's dedicated UID was already the mitigation the threat model relies on for T3, and
246
+ Tier 1 was always documented as "partial" there — but it does mean Tier 1 should not be assumed to
247
+ inherit host Yama protection.
248
+
249
+ One methodological note, since it produced a misleading result before it was caught: a `ptrace`
250
+ probe that calls `PTRACE_ATTACH` and then `PTRACE_DETACH` without an intervening `waitpid()` races,
251
+ and can leave the target in `T (stopped)` with `TracerPid: 0` — i.e. it silently stops the service
252
+ it was auditing. That is what happened here, and the daemon had to be `SIGCONT`ed back. Any probe
253
+ used for this check needs to wait for the stop before detaching.
254
+
255
+ ## OS service unit generation
256
+
257
+ `service-installer.mjs` generates the real systemd/launchd/Windows unit content from the design
258
+ doc's own templates — parameterized by identity and tier — and `cli.mjs install` writes them to a
259
+ local `--out-dir` you choose. **It never writes to a real system service location
260
+ (`~/.config/systemd/user`, `/etc/systemd/system`, `~/Library/LaunchAgents`, the Windows service
261
+ registry) and never runs `systemctl`/`launchctl`/`sc.exe` itself** — verified by
262
+ `service-installer.smoke.mjs`'s own static check of the module's source, not just asserted in
263
+ this paragraph. It prints the exact command to actually install the result; running that command
264
+ is always a separate, deliberate, human step — registering a real system service is a genuine
265
+ system modification, deliberately out of scope for what this module does on its own.
266
+
267
+ ```bash
268
+ node cli.mjs install --identity <did> --daemon-path /opt/agentsafe/signer/daemon.mjs \
269
+ --platform linux --tier 2 --out-dir ./service-units
270
+ ```
271
+
272
+ Tested: the Tier 1/Tier 2 content differences match the design doc exactly (the `%h`-vs-absolute
273
+ `ExecStart` path and the `RemoveIPC=true` placement — both real bugs the code-review pass on the
274
+ design doc itself caught before any of this was written) for systemd; the launchd plist's
275
+ `SockPathMode`/`RunAtLoad`/`KeepAlive` values; and the Windows `sc.exe` argv shape (Virtual Service
276
+ Account, no password to manage).
277
+
278
+ **Now also tested, for the first time, by actually installing and starting it** (Ubuntu 24.04.4
279
+ LTS, systemd 255, `systemctl --user`). The socket unit works: systemd created
280
+ `%t/agentsafe/signer-%i.sock` at mode `0600` in a `0700` directory, owned by the invoking user —
281
+ `SocketMode=`/`DirectoryMode=` take effect exactly as written, which is T4 confirmed on the real
282
+ deployment path rather than only in a temp dir. `LimitCORE=0` also verifiably takes effect:
283
+ `/proc/<pid>/limits` on the live process reported `Max core file size 0 0`, read from outside the
284
+ process.
285
+
286
+ The **service** unit did not start. Five defects in one `ExecStart` line, each found only by
287
+ running it:
288
+
289
+ | # | Defect | Symptom | Status |
290
+ |---|---|---|---|
291
+ | 1 | `setpriv --dumpable 0 --` wrapper | util-linux's `setpriv` **has no `--dumpable` option** (checked on util-linux 2.39.3; the string is absent from the binary). Unit died at exec: `setpriv: unrecognized option '--dumpable'`, `status=1/FAILURE` | **Fixed** — wrapper removed |
292
+ | 2 | `%h/` prefixed onto an **absolute** `--daemon-path` | `ExecStart` resolved to `/home/u/home/u/...`; the CLI's own documented example passes an absolute path, so this was the common case | **Fixed** — `%h` now applied only to a relative path |
293
+ | 3 | `nodePath` defaulting to `/usr/bin/node` | Absent on any nvm/asdf/volta host (this one had node only under `~/.nvm`); systemd reports a bare `203/EXEC` with no hint which binary was missing | **Fixed** — defaults to `process.execPath` |
294
+ | 4 | `MemoryDenyWriteExecute=true` | V8 is a JIT; its `mprotect` fails with `ENOMEM` inside `v8::base::OS::SetPermissions` during Isolate init and node dies with `SIGTRAP` (`Check failed: 12 == errno`) before running any daemon code. **No Node service can set this directive** | **Fixed** — removed from both tiers |
295
+ | 5 | `ExecStart` points at `daemon.mjs` | `daemon.mjs` is a library module with no argv handling — `node daemon.mjs` returns 0 immediately and serves nothing | **Fixed** — `ExecStart` now runs `cli.mjs start --state-dir %S/agentsafe/%i --role <role> --identity %i` |
296
+ | 6 | `Type=notify` | Nothing in the package sends `sd_notify READY=1`, so systemd fails the unit with `Result: protocol` even though node is healthy and listening | **Fixed** — `Type=exec`, which still waits for a successful `execve()` without requiring the notify protocol |
297
+
298
+ **With all six fixed, the generated unit now starts and serves.** Verified by installing the
299
+ generator's own output unmodified (plus a drop-in supplying `AGENTSAFE_SIGNER_PASSPHRASE`, since
300
+ no OS-keychain backend works on that host — see below): `systemctl --user start` reached
301
+ `active (running)`, the daemon unlocked, and a real `ping` over its socket returned
302
+ `{"ok":true,"result":{"status":"ok","unlocked":true}}`. `LimitCORE=0` was confirmed on that live
303
+ process from outside it (`/proc/<pid>/limits`: `Max core file size 0 0`).
304
+
305
+ ### Still open: socket activation is declared but not implemented
306
+
307
+ The generated `.socket` unit is real and works on its own — systemd creates and listens on
308
+ `%t/agentsafe/signer-%i.sock` at `0600` — but **the daemon never accepts that socket**. Nothing in
309
+ `daemon.mjs` or `cli.mjs` reads `LISTEN_FDS`/`LISTEN_PID`, so `cli.mjs start` binds its own socket
310
+ at `<state-dir>/signer.sock` instead. Measured on the running unit, the two paths are simply
311
+ different:
312
+
313
+ ```
314
+ socket unit listens on : /run/user/1000/agentsafe/signer-1c741375.sock (%t/…)
315
+ daemon listens on : ~/.local/state/agentsafe/1c741375/signer.sock (%S/…)
316
+ ```
317
+
318
+ So a client that connects to the systemd socket triggers the service and then talks to nobody;
319
+ `Requires=…​.socket` currently buys the ordering relationship and nothing else. Consumers should
320
+ point at the daemon's own `<state-dir>/signer.sock` until this is closed.
321
+
322
+ Closing it properly means implementing the sd_notify/`LISTEN_FDS` protocol in the daemon — a real
323
+ feature, not a generator tweak, and deliberately out of scope for this verification pass in the
324
+ same way the macOS ptrace-deny gap above is: disclosed as a first-implementation-pass limit rather
325
+ than quietly left for someone to discover in production. Also still untested: launchd and Windows
326
+ installation, unchanged from the previous pass.
327
+
328
+ ## OS-keychain KEK backends
329
+
330
+ `kek-backends.mjs` tries a real platform backend first, in order, and only falls back to
331
+ passphrase+scrypt if none is available — always printing which one was actually used, so an
332
+ operator can see what's protecting their key rather than assume.
333
+
334
+ **A backend "being available" and "actually working" are checked separately.** `available()` only
335
+ proves the tool exists and runs (e.g. `systemd-creds --version` exits 0) — it can't cheaply prove
336
+ the backend can actually encrypt/decrypt in this environment. Caught for real, not hypothesized:
337
+ wiring `agentsafe-signer`'s tests into CI (`ubuntu-latest`) found that `systemd-creds` is present
338
+ and passes its own `--version` check there, but `systemd-creds encrypt` fails with "Failed to
339
+ determine local credential host secret: Permission denied" — the runner has no host/TPM secret
340
+ for it to use. `resolveKek()` now catches a failure from the selected backend and falls through to
341
+ the next one (down to `passphrase` as the last resort) instead of crashing the daemon — a backend
342
+ that is present but unusable is not meaningfully different from one that is absent.
343
+
344
+ | Platform | Backend | Mechanism | Verified in this pass? |
345
+ |---|---|---|---|
346
+ | Windows | `dpapi` | `ProtectedData.Protect`/`Unprotect` (`CurrentUser` scope), via a PowerShell one-liner — no native addon | **Yes** — real protect/unprotect round-trip, including a simulated daemon restart (`kek-backends.smoke.mjs`), run on the actual development machine for this pass |
347
+ | macOS | `macos-keychain` | `security add-generic-password`/`find-generic-password` | No — implemented per `man security`'s documented behavior, never run against a real macOS host |
348
+ | Linux | `systemd-creds` | `systemd-creds encrypt`/`decrypt` (TPM- or machine-ID-backed) — tried first, fits the Tier 2 systemd-service deployment model this daemon targets | **Still no.** Retried on a real Ubuntu 24.04 / systemd 255 host and it failed there too, with the *same* error as CI: `Failed to determine local credential host secret: Permission denied`. Root cause now pinned rather than guessed (see below) |
349
+ | Linux (no systemd-creds) | `secret-tool` | Secret Service (GNOME Keyring/KWallet) via `secret-tool store`/`lookup` | **Still no, and not testable on that host.** `secret-tool` is not installed, no keyring daemon is running, and `org.freedesktop.secrets` is neither on the session bus nor D-Bus-activatable — so there was no Secret Service to exercise. Installing a keyring purely to make the test pass would prove nothing about a real desktop host, so it was not done |
350
+ | Any (no backend available, or every available one failed) | `passphrase` | `AGENTSAFE_SIGNER_PASSPHRASE` + scrypt (unchanged from the first pass) | Yes (carried over), but explicitly the weakest tier — the daemon says so out loud when it's the one in use |
351
+
352
+ **Why `systemd-creds` fails on a normal Linux host, precisely.** It needs one of two secrets, and
353
+ a non-root user can obtain neither:
354
+
355
+ - **Host secret** — `/var/lib/systemd/credential.secret`, created by `systemd-creds setup`. The
356
+ file does not exist by default and `/var/lib/systemd` is `root:root 0755`, so creating it needs
357
+ root. This is the `Permission denied` above, on a real host and on CI alike; CI was not the
358
+ anomaly.
359
+ - **TPM2** — `systemd-creds encrypt --with-key=tpm2` returns `Failed to create TPM2 context:
360
+ Operation not supported`; the host has no `/dev/tpm*` at all.
361
+
362
+ There is no user-scoped alternative: `systemd-creds` has no `--user` flag (systemd 255). So on any
363
+ host where the operator cannot run `systemd-creds setup` as root, and which has no TPM, this
364
+ backend can only ever be *present-but-unusable* — which `resolveKek` already handles by falling
365
+ through. Verified end-to-end on that path: with `AGENTSAFE_SIGNER_PASSPHRASE` set,
366
+ `kek-backends.smoke.mjs` passes with `backend actually used on this host: passphrase`, having
367
+ logged the `systemd-creds` failure and moved on. With no passphrase either, `resolveKek` throws
368
+ and the test exits non-zero — correct fail-closed behavior, but worth knowing before running the
369
+ suite on a bare Linux box.
370
+
371
+ **Before trusting the macOS/Linux backends in production**, run `kek-backends.smoke.mjs` on the
372
+ real target OS — it's written to exercise whichever backend `resolveKek` actually selects, so the
373
+ same test file is the real verification step for those platforms too, not new tooling to build.
374
+
375
+ ## Regenerating the bundled canonical-message logic
376
+
377
+ ```bash
378
+ cd ../../backend && npm run build:signer-core
379
+ ```
380
+
381
+ Regenerates `policy-core.mjs` and `governance-envelope.mjs` from their real TypeScript sources —
382
+ never hand-edit those two files.
package/cli.mjs ADDED
@@ -0,0 +1,213 @@
1
+ #!/usr/bin/env node
2
+ // cli.mjs — starts the signer daemon. Resolves the KEK from a real OS-keychain backend when one
3
+ // is available (kek-backends.mjs), falling back to a passphrase only when none is — see that
4
+ // module's own header and README.md "Status" for exactly which backends are verified vs.
5
+ // implemented-but-untested-on-a-real-host in this pass.
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { SignerDaemon } from './daemon.mjs';
9
+ import { deriveKek, loadOrCreateSalt } from './keystore.mjs';
10
+ import { resolveKek } from './kek-backends.mjs';
11
+ import { generateSystemdUnits, generateLaunchdPlist, generateWindowsService } from './service-installer.mjs';
12
+ import { verifyLogCheckpoints, listCheckpoints } from './log-checkpoint.mjs';
13
+ import { runLogAnchor } from './log-anchor.mjs';
14
+ import { runMigrate } from './migrate.mjs';
15
+ import { parseArgs } from './daemon-client.mjs';
16
+
17
+ /**
18
+ * Writes generated unit files to `--out-dir` (a local directory, NEVER a real system service
19
+ * location) and prints the exact command an operator runs to actually install them. Never invokes
20
+ * systemctl/launchctl/sc.exe itself — see service-installer.mjs's own header for why.
21
+ */
22
+ function runInstall(args) {
23
+ const platform = args.platform ?? process.platform;
24
+ const tier = args.tier === '2' || args.tier === 2 ? 2 : 1;
25
+ const identity = args.identity;
26
+ const daemonPath = args['daemon-path'] ?? path.join(process.cwd(), 'daemon.mjs');
27
+ const outDir = args['out-dir'] ?? path.join(process.cwd(), 'service-units');
28
+ // --node-path matters most at Tier 2. The generator defaults to process.execPath, which is
29
+ // correct at Tier 1 (the unit runs AS the invoking user, so their nvm/asdf node is reachable)
30
+ // but wrong at Tier 2: a DynamicUser account is a different UID and cannot traverse a home
31
+ // directory that is mode 0750, which is the Ubuntu default. Verified on a real host — the
32
+ // dynamic user could reach neither the nvm node binary nor the package under ~. Tier 2 needs an
33
+ // interpreter on a system path, and this is how the operator names it.
34
+ const nodePath = args['node-path'];
35
+ const role = args.role === 'service' ? 'service' : 'agent';
36
+ if (!identity) {
37
+ console.error('agentsafe-signer install requires --identity <agentDid or serviceDid>');
38
+ process.exit(1);
39
+ }
40
+ fs.mkdirSync(outDir, { recursive: true });
41
+
42
+ if (platform === 'linux') {
43
+ const { socketUnit, serviceUnit, id, enableCommand } = generateSystemdUnits({ identity, tier, daemonPath, role, ...(nodePath ? { nodePath } : {}) });
44
+ fs.writeFileSync(path.join(outDir, `agentsafe-signer@${id}.socket`), socketUnit);
45
+ fs.writeFileSync(path.join(outDir, `agentsafe-signer@${id}.service`), serviceUnit);
46
+ console.log(`Wrote systemd unit files to ${outDir}`);
47
+ console.log(`Tier ${tier} — copy them into place, then run:\n ${enableCommand}`);
48
+ if (tier === 2 && /^\/home\/|^\/Users\//.test(serviceUnit.match(/^ExecStart=(\S+)/m)?.[1] ?? '')) {
49
+ console.warn(
50
+ '\n[warning] Tier 2 runs under DynamicUser=true — a separate UID that usually CANNOT read a\n' +
51
+ ' home directory (0750 by default). The ExecStart interpreter above is under /home,\n' +
52
+ ' so this unit will fail to start. Pass --node-path /usr/local/bin/node (or another\n' +
53
+ ' system path) and install the package somewhere system-wide, e.g.\n' +
54
+ ' /usr/local/share/agentsafe/signer/.');
55
+ }
56
+ } else if (platform === 'darwin') {
57
+ const { plist, plistFileName, enableCommand } = generateLaunchdPlist({ identity, tier, daemonPath, socketDir: outDir });
58
+ fs.writeFileSync(path.join(outDir, plistFileName), plist);
59
+ console.log(`Wrote launchd plist to ${outDir}`);
60
+ console.log(`Copy it into ~/Library/LaunchAgents/, then run:\n ${enableCommand}`);
61
+ } else if (platform === 'win32') {
62
+ const { serviceName, enableCommand } = generateWindowsService({ identity, daemonPath });
63
+ fs.writeFileSync(path.join(outDir, `${serviceName}.install.txt`), enableCommand + '\n');
64
+ console.log(`Wrote the sc.exe install command to ${path.join(outDir, `${serviceName}.install.txt`)}`);
65
+ console.log(`Run it yourself, from an elevated prompt:\n ${enableCommand}`);
66
+ } else {
67
+ console.error(`Unsupported --platform "${platform}" — use linux, darwin, or win32.`);
68
+ process.exit(1);
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Recomputes every local log checkpoint (T11) directly from signer.log's own bytes and reports
74
+ * whether the chain is intact — the operator-facing proof that the daemon's own request log
75
+ * hasn't been tampered with since it was last checkpointed. Read-only: never touches the log or
76
+ * checkpoints file, and never touches the network (see log-anchor.mjs for the separate, optional,
77
+ * live step that anchors a checkpointHash externally).
78
+ */
79
+ function runVerifyLog(args) {
80
+ const stateDir = args['state-dir'] ?? path.join(process.cwd(), '.agentsafe-signer');
81
+ const checkpoints = listCheckpoints(stateDir);
82
+ if (checkpoints.length === 0) {
83
+ console.log(`[agentsafe-signer] no checkpoints yet in ${stateDir} — nothing to verify.`);
84
+ return;
85
+ }
86
+ const result = verifyLogCheckpoints(stateDir);
87
+ const anchoredCount = checkpoints.filter((cp) => cp.anchoredAt).length;
88
+ console.log(`[agentsafe-signer] ${checkpoints.length} checkpoint(s), ${anchoredCount} anchored, ${checkpoints.length - anchoredCount} pending.`);
89
+ if (result.ok) {
90
+ console.log(`[agentsafe-signer] OK — the log hash-chain verifies clean across all ${result.checkedCount} checkpoint(s).`);
91
+ return;
92
+ }
93
+ console.error(`[agentsafe-signer] TAMPER DETECTED at checkpoint index ${result.failedIndex}: ${result.reason}`);
94
+ process.exit(1);
95
+ }
96
+
97
+ /**
98
+ * Anchors every pending local checkpoint (T11) via the backend's /evidence/signer-checkpoint
99
+ * endpoint — the separate, optional, LIVE-NETWORK step; requires an already-running daemon at
100
+ * `--socket-path` to sign each anchor request, since only the daemon holds the key. See
101
+ * log-anchor.mjs's own header for why this is a separate script rather than a daemon feature.
102
+ */
103
+ async function runLogAnchorCommand(args) {
104
+ const stateDir = args['state-dir'] ?? path.join(process.cwd(), '.agentsafe-signer');
105
+ const socketPath = args['socket-path'] ?? path.join(stateDir, 'signer.sock');
106
+ const apiUrl = args['api-url'] ?? process.env.AGENTSAFE_SIGNER_API_URL;
107
+ if (!apiUrl) {
108
+ console.error('agentsafe-signer log-anchor requires --api-url <backend base url> (or AGENTSAFE_SIGNER_API_URL)');
109
+ process.exit(1);
110
+ }
111
+ const failed = await runLogAnchor({ stateDir, socketPath, apiUrl });
112
+ if (failed > 0) process.exit(1);
113
+ }
114
+
115
+ /**
116
+ * Rotates an existing agent's network identity onto a fresh, daemon-generated key
117
+ * (rotateNetworkIdentity's real BYOK + proof-of-possession flow — see migrate.mjs's own header
118
+ * for exactly why this exists and what it requires). Requires the daemon's admin socket to be
119
+ * open right now (`agentsafe-signer start --admin`).
120
+ */
121
+ async function runMigrateCommand(args) {
122
+ const stateDir = args['state-dir'] ?? path.join(process.cwd(), '.agentsafe-signer');
123
+ const socketPath = args['socket-path'] ?? path.join(stateDir, 'signer.sock');
124
+ const adminSocketPath = args['admin-socket-path'] ?? path.join(stateDir, 'signer-admin.sock');
125
+ const apiUrl = args['api-url'] ?? process.env.AGENTSAFE_SIGNER_API_URL;
126
+ const authToken = args['auth-token'] ?? process.env.AGENTSAFE_SIGNER_AUTH_TOKEN;
127
+ const ref = args.ref;
128
+ const networkIdentityId = args['network-identity-id'];
129
+
130
+ const missing = [];
131
+ if (!apiUrl) missing.push('--api-url (or AGENTSAFE_SIGNER_API_URL)');
132
+ if (!authToken) missing.push('--auth-token (or AGENTSAFE_SIGNER_AUTH_TOKEN)');
133
+ if (!ref) missing.push('--ref <agentIdentityId or DID>');
134
+ if (!networkIdentityId) missing.push('--network-identity-id <id>');
135
+ if (missing.length > 0) {
136
+ console.error(`agentsafe-signer migrate requires: ${missing.join(', ')}`);
137
+ process.exit(1);
138
+ }
139
+
140
+ console.log('[migrate] asking the daemon to generate a fresh key (requires --admin to be open right now)...');
141
+ const result = await runMigrate({ stateDir, socketPath, adminSocketPath, apiUrl, authToken, ref, networkIdentityId });
142
+ console.log(`[migrate] rotated to ${result.did} and verified proof of possession.`);
143
+ console.log('[migrate] restart the daemon bound to the new identity:');
144
+ console.log(` ${result.restartCommand}`);
145
+ }
146
+
147
+ async function main() {
148
+ const args = parseArgs(process.argv.slice(2));
149
+ const command = args._[0];
150
+ if (command === 'install') return runInstall(args);
151
+ if (command === 'verify-log') return runVerifyLog(args);
152
+ if (command === 'log-anchor') return runLogAnchorCommand(args);
153
+ if (command === 'migrate') return runMigrateCommand(args);
154
+ if (command !== 'start') {
155
+ console.error('Usage: agentsafe-signer start --state-dir <dir> --role agent|service [--identity <did>] [--admin] [--allow-rekey]');
156
+ console.error(' or: agentsafe-signer install --identity <did> --daemon-path <path> [--platform linux|darwin|win32] [--tier 1|2] [--role agent|service] [--node-path <path>] [--out-dir <dir>]');
157
+ console.error(' or: agentsafe-signer verify-log --state-dir <dir>');
158
+ console.error(' or: agentsafe-signer log-anchor --state-dir <dir> --api-url <backend base url>');
159
+ console.error(' or: agentsafe-signer migrate --state-dir <dir> --api-url <backend base url> --auth-token <token> --ref <agentIdentityId-or-did> --network-identity-id <id>');
160
+ process.exit(1);
161
+ }
162
+
163
+ const stateDir = args['state-dir'] ?? path.join(process.cwd(), '.agentsafe-signer');
164
+ const role = args.role;
165
+ if (role !== 'agent' && role !== 'service') {
166
+ console.error("--role must be 'agent' or 'service'");
167
+ process.exit(1);
168
+ }
169
+
170
+ let kek, kekBackend;
171
+ if (args['kek-backend'] === 'passphrase') {
172
+ // Explicit opt-out of OS-keychain detection, e.g. for local dev/testing.
173
+ const passphrase = process.env.AGENTSAFE_SIGNER_PASSPHRASE;
174
+ if (!passphrase) {
175
+ console.error('--kek-backend passphrase requires AGENTSAFE_SIGNER_PASSPHRASE to be set.');
176
+ process.exit(1);
177
+ }
178
+ kek = deriveKek(passphrase, loadOrCreateSalt(stateDir));
179
+ kekBackend = 'passphrase (forced)';
180
+ } else {
181
+ try {
182
+ ({ kek, backend: kekBackend } = resolveKek(stateDir));
183
+ } catch (err) {
184
+ console.error(`[agentsafe-signer] ${err.message}`);
185
+ process.exit(1);
186
+ }
187
+ }
188
+ console.log(`[agentsafe-signer] KEK backend: ${kekBackend}${kekBackend.startsWith('passphrase') ? ' — weaker than an OS-keychain backend, see README.md "Status"' : ''}`);
189
+
190
+ const daemon = new SignerDaemon({
191
+ stateDir,
192
+ role,
193
+ agentDid: typeof args.identity === 'string' ? args.identity : null,
194
+ kek,
195
+ shadowMode: args.enforce !== true,
196
+ });
197
+ await daemon.waitUntilUnlocked();
198
+
199
+ await daemon.startSigningServer(path.join(stateDir, 'signer.sock'));
200
+ console.log(`[agentsafe-signer] signing socket listening: ${path.join(stateDir, 'signer.sock')}`);
201
+
202
+ if (args.admin) {
203
+ const timeoutMs = typeof args['admin-timeout'] === 'string' ? Number(args['admin-timeout']) : 60_000;
204
+ console.log(`[agentsafe-signer] admin socket open for ${timeoutMs}ms or one operation, whichever comes first`);
205
+ await daemon.startAdminServer(path.join(stateDir, 'signer-admin.sock'), { timeoutMs });
206
+ console.log('[agentsafe-signer] admin socket closed');
207
+ }
208
+ }
209
+
210
+ main().catch((err) => {
211
+ console.error('[agentsafe-signer] fatal:', err);
212
+ process.exit(1);
213
+ });