@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 +382 -0
- package/cli.mjs +213 -0
- package/daemon-client.mjs +97 -0
- package/daemon.mjs +558 -0
- package/governance-envelope.mjs +69 -0
- package/kek-backends.mjs +192 -0
- package/keystore.mjs +108 -0
- package/log-anchor.mjs +112 -0
- package/log-checkpoint.mjs +191 -0
- package/merkle.mjs +68 -0
- package/migrate.mjs +120 -0
- package/package.json +47 -0
- package/policy-core.mjs +601 -0
- package/secure-memory.mjs +38 -0
- package/service-installer.mjs +244 -0
- package/windows-secure-pipe.mjs +367 -0
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
|
+
});
|