@consiliency/agent-board-cli 1.23.0 → 1.25.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/keyslot/lib.sh ADDED
@@ -0,0 +1,599 @@
1
+ # shellcheck shell=bash
2
+ # KEYSEAL operator-script library (SL-2): sourced by probe.sh, provision.sh, status.sh and
3
+ # cross-user.sh from the checkout. It is NOT in the install set: only the loader runs with
4
+ # the checkout gone.
5
+ #
6
+ # Names shared with the loader come from constants.sh and seam.sh (SL-1) and are never
7
+ # restated here. This file adds the operator tools' own binaries, the chain check
8
+ # (IF-0-KEYSEAL-3 check 2), the recovery predicates (IF-0-KEYSEAL-1) and the scan
9
+ # (IF-0-KEYSEAL-5).
10
+
11
+ keyseal_here=${BASH_SOURCE[0]%/*}
12
+ [[ $keyseal_here == "${BASH_SOURCE[0]}" ]] && keyseal_here=.
13
+ # shellcheck source=constants.sh
14
+ . "$keyseal_here/constants.sh"
15
+ # shellcheck source=seam.sh
16
+ . "$keyseal_here/seam.sh"
17
+ # shellcheck source=optin.sh
18
+ . "$keyseal_here/optin.sh"
19
+
20
+ # The operator tools' binaries, by absolute path. Each has a seam override named
21
+ # KEYSEAL_TEST_<NAME>, honoured only under KEYSEAL_TEST_ROOT (seam.sh).
22
+ KEYSEAL_BIN_SYSTEMCTL=/usr/bin/systemctl
23
+ KEYSEAL_BIN_LOGINCTL=/usr/bin/loginctl
24
+ KEYSEAL_BIN_FLOCK=/usr/bin/flock
25
+ KEYSEAL_BIN_SSH_AGENT=/usr/bin/ssh-agent
26
+ KEYSEAL_BIN_FIND=/usr/bin/find
27
+ KEYSEAL_BIN_XARGS=/usr/bin/xargs
28
+ # /usr/bin/grep, never a PATH lookup: an interactive shell's `grep` may be a shim with
29
+ # different exit semantics.
30
+ KEYSEAL_BIN_GREP=/usr/bin/grep
31
+
32
+ # The flat layout (c5e358f): its install set and that set's version, sorted as
33
+ # keyseal_files_sha takes them. A test derives the sha from fixtures/legacy-c5e358f.
34
+ KEYSEAL_LEGACY_INSTALL_SET=(constants.sh keyslot-load.sh seam.sh)
35
+ KEYSEAL_LEGACY_SET_SHA=8e7b771be5a461e4d6b032f518e4c790d91b315011ae2d96eda44bc6961c1fdc
36
+ # The loader template's ExecStart before the A1 layout (the flat layout, c5e358f). status.sh
37
+ # recognises an installed template that differs from the shipped one ONLY in this line as the
38
+ # pre-migration layout (legacy_loader_present, 2), not as a chain violation.
39
+ KEYSEAL_LEGACY_LOAD_EXEC='%h/.local/libexec/agent-board/keyslot-load.sh %i'
40
+
41
+ # IF-0-KEYSEAL-5: the scan scope, exactly. Roots are resolved at scan time.
42
+ KEYSEAL_SCAN_MAX_BYTES=1048576
43
+ KEYSEAL_SCAN_PRUNE_NAMES=(node_modules .git .cache .npm)
44
+ KEYSEAL_SCAN_PRUNE_PATHS=(.local/share/pnpm)
45
+ KEYSEAL_SCAN_HEADERS=('-----BEGIN OPENSSH PRIVATE KEY-----' '-----BEGIN PRIVATE KEY-----')
46
+ # What the scan does not detect, by stable id (reported in every status JSON).
47
+ KEYSEAL_SCAN_NOT_DETECTED=(pruned over_1mib not_owned re_encoded_or_encrypted outside_roots)
48
+
49
+ keyseal_die() {
50
+ printf '%s: %s\n' "${0##*/}" "$*" >&2
51
+ exit 1
52
+ }
53
+
54
+ # Resolve every binary through the seam into CREDS, SYSTEMCTL, LOGINCTL, SSH_ADD, SLEEP and
55
+ # FLOCK, and the machine-id file into MACHINE_ID_FILE. A refused override returns 1 (the
56
+ # caller exits 1: KS-14).
57
+ keyseal_resolve_bins() {
58
+ CREDS=$(seam_resolve KEYSEAL_TEST_SYSTEMD_CREDS "$KEYSEAL_BIN_SYSTEMD_CREDS") || return 1
59
+ SYSTEMCTL=$(seam_resolve KEYSEAL_TEST_SYSTEMCTL "$KEYSEAL_BIN_SYSTEMCTL") || return 1
60
+ LOGINCTL=$(seam_resolve KEYSEAL_TEST_LOGINCTL "$KEYSEAL_BIN_LOGINCTL") || return 1
61
+ SSH_ADD=$(seam_resolve KEYSEAL_TEST_SSH_ADD "$KEYSEAL_BIN_SSH_ADD") || return 1
62
+ SLEEP=$(seam_resolve KEYSEAL_TEST_SLEEP "$KEYSEAL_BIN_SLEEP") || return 1
63
+ SSH_KEYGEN=$(seam_resolve KEYSEAL_TEST_SSH_KEYGEN "$KEYSEAL_BIN_SSH_KEYGEN") || return 1
64
+ MACHINE_ID_FILE=$(seam_resolve KEYSEAL_TEST_MACHINE_ID_FILE "$KEYSEAL_MACHINE_ID_FILE") || return 1
65
+ FLOCK=$KEYSEAL_BIN_FLOCK
66
+ }
67
+
68
+ # The opt-in's host comparand: the first 16 hex of sha256(/etc/machine-id). rc 1 if unreadable.
69
+ keyseal_machine_id() { keyseal_machine_id_of "$MACHINE_ID_FILE"; }
70
+
71
+ keyseal_seam_json() { if seam_active; then printf true; else printf false; fi; }
72
+
73
+ # A JSON string literal. Values here are paths, hashes and short codes.
74
+ keyseal_json_str() {
75
+ local s=$1
76
+ s=${s//\\/\\\\}
77
+ s=${s//\"/\\\"}
78
+ s=${s//$'\n'/\\n}
79
+ s=${s//$'\r'/\\r}
80
+ s=${s//$'\t'/\\t}
81
+ printf '"%s"' "$s"
82
+ }
83
+
84
+ keyseal_uid() {
85
+ if seam_active && [[ -n ${KEYSEAL_TEST_UID-} ]]; then printf '%s' "$KEYSEAL_TEST_UID"; else id -u; fi
86
+ }
87
+
88
+ keyseal_linger() {
89
+ "$LOGINCTL" show-user "$(id -u)" -p Linger --value 2>/dev/null || printf unknown
90
+ }
91
+
92
+ keyseal_sha256_file() { local h; h=$(sha256sum <"$1") || return 1; printf '%s' "${h%% *}"; }
93
+ keyseal_sha256_str() { local h; h=$(printf '%s' "$1" | sha256sum); printf '%s' "${h%% *}"; }
94
+
95
+ keyseal_unit_dir() { printf '%s/systemd/user' "${XDG_CONFIG_HOME:-$HOME/.config}"; }
96
+ keyseal_libexec_dir() { printf '%s/%s' "$HOME" "$KEYSEAL_LIBEXEC_REL"; }
97
+ keyseal_config_root() { printf '%s' "${XDG_CONFIG_HOME:-$HOME/.config}"; }
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # Loader versions (A1)
101
+ # ---------------------------------------------------------------------------
102
+
103
+ # A version: sha256 over one `<name> <sha256>` line per file, names in sorted order. rc 1 if a
104
+ # name is missing or not a regular file.
105
+ keyseal_files_sha() {
106
+ local dir=$1 f lines= h
107
+ shift
108
+ for f in "$@"; do
109
+ [[ -f $dir/$f && ! -L $dir/$f ]] || return 1
110
+ h=$(keyseal_sha256_file "$dir/$f") || return 1
111
+ lines+="$f $h"$'\n'
112
+ done
113
+ keyseal_sha256_str "$lines"
114
+ }
115
+ # The version of the install set in directory $1 (KEYSEAL_INSTALL_SET is sorted).
116
+ keyseal_set_sha() { keyseal_files_sha "$1" "${KEYSEAL_INSTALL_SET[@]}"; }
117
+ # The version a version DIRECTORY holds: every entry in it, so a planted extra file changes it.
118
+ keyseal_dir_sha() {
119
+ local dir=$1 e names=()
120
+ # Dotfiles too: a planted .x is part of what the directory holds.
121
+ for e in "$dir"/* "$dir"/.[!.]* "$dir"/..?*; do [[ -e $e || -L $e ]] && names+=("${e##*/}"); done
122
+ ((${#names[@]})) || return 1
123
+ keyseal_files_sha "$dir" "${names[@]}"
124
+ }
125
+
126
+ # v-<prefix> for a 64-hex set sha.
127
+ keyseal_version_dirname() { printf '%s%s' "$KEYSEAL_VERSION_PREFIX" "${1:0:$KEYSEAL_VERSION_HEX}"; }
128
+ keyseal_slot_link() { printf '%s/%s%s' "$(keyseal_libexec_dir)" "$KEYSEAL_SLOT_LINK_PREFIX" "$1"; }
129
+ keyseal_loader_record() { printf '%s/%s.loader-sha256' "$(keyseal_key_dir)" "$1"; }
130
+
131
+ # The recorded version of slot $1, if well-formed. rc 1 absent, rc 2 malformed.
132
+ keyseal_read_loader_record() {
133
+ local rec line=
134
+ rec=$(keyseal_loader_record "$1")
135
+ [[ -e $rec || -L $rec ]] || return 1
136
+ [[ -f $rec && ! -L $rec ]] || return 2
137
+ IFS= read -r line <"$rec" || [[ -n $line ]] || return 2
138
+ [[ $line =~ ^[0-9a-f]{64}$ ]] || return 2
139
+ printf '%s' "$line"
140
+ }
141
+
142
+ # The legacy flat set: ANY member present as a regular file directly in libexec (a partial
143
+ # leftover counts too; it is still legacy_loader_present).
144
+ keyseal_legacy_present() {
145
+ local f
146
+ for f in "${KEYSEAL_LEGACY_INSTALL_SET[@]}"; do [[ -f $(keyseal_libexec_dir)/$f ]] && return 0; done
147
+ return 1
148
+ }
149
+ # The flat set is exactly the one c5e358f shipped (review round 1, Opus S1): never launder
150
+ # unknown content into a recorded version.
151
+ keyseal_legacy_known() {
152
+ [[ $(keyseal_files_sha "$(keyseal_libexec_dir)" "${KEYSEAL_LEGACY_INSTALL_SET[@]}" || true) == "$KEYSEAL_LEGACY_SET_SHA" ]]
153
+ }
154
+
155
+ # The slots whose loader instance is enabled: the `.wants` links `enable` writes for
156
+ # `[Install] WantedBy=agent-board-keyslot@%i.service`. One slot per line.
157
+ keyseal_enabled_slots() {
158
+ local link s dir
159
+ dir=$(keyseal_unit_dir)
160
+ for link in "$dir/$KEYSEAL_BASE_UNIT@"*.service.wants/"$KEYSEAL_LOAD_UNIT@"*.service; do
161
+ [[ -L $link || -e $link ]] || continue
162
+ s=${link##*/"$KEYSEAL_LOAD_UNIT@"}
163
+ s=${s%.service}
164
+ [[ $s =~ $KEYSEAL_SLOT_RE && $link == "$dir/$KEYSEAL_BASE_UNIT@$s.service.wants/"* ]] && printf '%s\n' "$s"
165
+ done
166
+ }
167
+
168
+ # Judge slot $1's loader against the running tooling in $2. Sets KEYSEAL_LOADER_STATE:
169
+ # current the slot runs the recorded version, and it is the running tooling's
170
+ # outdated it runs its recorded version, which differs from the running tooling's (2)
171
+ # unrecorded no <slot>.loader-sha256, as on a slot provisioned before A1 (2)
172
+ # mismatch the installed files match no recorded version (1): KEYSEAL_LOADER_PROBLEM
173
+ # and KEYSEAL_LOADER_RECORDED / KEYSEAL_LOADER_RUNNING (64-hex, or empty).
174
+ keyseal_loader_check() {
175
+ local slot=$1 src=$2 rec rc=0 link target
176
+ KEYSEAL_LOADER_PROBLEM= KEYSEAL_LOADER_RECORDED=
177
+ KEYSEAL_LOADER_RUNNING=$(keyseal_set_sha "$src") || KEYSEAL_LOADER_RUNNING=
178
+ rec=$(keyseal_read_loader_record "$slot") || rc=$?
179
+ if ((rc == 1)); then KEYSEAL_LOADER_STATE=unrecorded; return 0; fi
180
+ if ((rc != 0)); then KEYSEAL_LOADER_STATE=mismatch KEYSEAL_LOADER_PROBLEM="$slot.loader-sha256 is malformed"; return 0; fi
181
+ KEYSEAL_LOADER_RECORDED=$rec
182
+ link=$(keyseal_slot_link "$slot")
183
+ target=$(keyseal_version_dirname "$rec")
184
+ if [[ ! -L $link ]]; then
185
+ KEYSEAL_LOADER_STATE=mismatch KEYSEAL_LOADER_PROBLEM="${link##*/} is not a symlink"
186
+ elif [[ $(readlink -- "$link") != "$target" ]]; then
187
+ KEYSEAL_LOADER_STATE=mismatch KEYSEAL_LOADER_PROBLEM="${link##*/} does not point at the recorded version $target"
188
+ elif [[ -L $(keyseal_libexec_dir)/$target || ! -d $(keyseal_libexec_dir)/$target ]]; then
189
+ KEYSEAL_LOADER_STATE=mismatch KEYSEAL_LOADER_PROBLEM="$target is not a directory"
190
+ elif [[ $(keyseal_dir_sha "$(keyseal_libexec_dir)/$target" || true) != "$rec" ]]; then
191
+ KEYSEAL_LOADER_STATE=mismatch KEYSEAL_LOADER_PROBLEM="the installed files in $target match no recorded version"
192
+ elif [[ $rec != "$KEYSEAL_LOADER_RUNNING" ]]; then
193
+ KEYSEAL_LOADER_STATE=outdated
194
+ else
195
+ KEYSEAL_LOADER_STATE=current
196
+ fi
197
+ }
198
+
199
+ # A test seam point: under KEYSEAL_TEST_ROOT only, KEYSEAL_TEST_FAIL_AT=<point> makes the
200
+ # script fail (exit 1), SIGTERM or SIGKILL itself, or pause until $KEYSEAL_TEST_ROOT/resume
201
+ # exists (KEYSEAL_TEST_FAIL_MODE = fail | term | kill | pause). A no-op in production.
202
+ keyseal_seam_point() {
203
+ seam_active || return 0
204
+ [[ ${KEYSEAL_TEST_FAIL_AT-} == "$1" ]] || return 0
205
+ case ${KEYSEAL_TEST_FAIL_MODE:-fail} in
206
+ # At keygen and encrypt the `fail` mode belongs to the CHILD (keygen.mjs, the
207
+ # systemd-creds stub), so what fails the run is the pipeline's own status (KS-4): a
208
+ # seam exit here would mask a lost pipefail.
209
+ fail) [[ $1 == keygen || $1 == encrypt ]] || keyseal_die "seam_fail_at $1" ;;
210
+ term) kill -TERM $$ ;;
211
+ kill) kill -KILL $$ ;;
212
+ pause)
213
+ : >"$KEYSEAL_TEST_ROOT/paused.$1"
214
+ local i
215
+ for ((i = 0; i < 1200; i++)); do
216
+ [[ -e $KEYSEAL_TEST_ROOT/resume ]] && return 0
217
+ "$SLEEP" 0.05
218
+ done
219
+ keyseal_die "seam_pause_timeout $1"
220
+ ;;
221
+ esac
222
+ }
223
+
224
+ # Node helpers. Everything here reads; nothing writes a file. `derive` reads a PEM on stdin
225
+ # (a pipe from decrypt) and prints the raw public key as base64url.
226
+ KEYSEAL_NODE_JS=$(cat <<'JS'
227
+ const fs = require("node:fs");
228
+ const crypto = require("node:crypto");
229
+ const [mode, ...args] = process.argv.slice(1);
230
+ const out = (s) => process.stdout.write(s);
231
+
232
+ function readStdin() { return fs.readFileSync(0); }
233
+
234
+ function sshString(buf, off) {
235
+ if (off + 4 > buf.length) throw new Error("truncated");
236
+ const n = buf.readUInt32BE(off);
237
+ if (off + 4 + n > buf.length) throw new Error("truncated");
238
+ return [buf.subarray(off + 4, off + 4 + n), off + 4 + n];
239
+ }
240
+
241
+ // ssh-ed25519 wire blob -> raw 32 bytes as base64url, or null.
242
+ function ed25519FromWire(wire) {
243
+ const [type, off] = sshString(wire, 0);
244
+ if (type.toString() !== "ssh-ed25519") return null;
245
+ const [raw] = sshString(wire, off);
246
+ return raw.length === 32 ? raw.toString("base64url") : null;
247
+ }
248
+
249
+ // Classify one PEM block for the scan: "match", "nomatch", "encrypted" or "unverifiable".
250
+ function classifyBlock(kind, body, pin) {
251
+ if (kind === "OPENSSH PRIVATE KEY") {
252
+ if (!/^[A-Za-z0-9+/=\s]+$/.test(body)) return "unverifiable";
253
+ const buf = Buffer.from(body.replace(/\s+/g, ""), "base64");
254
+ const magic = Buffer.from("openssh-key-v1\0");
255
+ if (!buf.subarray(0, magic.length).equals(magic)) return "unverifiable";
256
+ let off = magic.length;
257
+ let cipher; let n;
258
+ try {
259
+ [cipher, off] = sshString(buf, off);
260
+ [, off] = sshString(buf, off); // kdfname
261
+ [, off] = sshString(buf, off); // kdfoptions
262
+ if (off + 4 > buf.length) return "unverifiable";
263
+ n = buf.readUInt32BE(off); off += 4;
264
+ if (n < 1 || n > 16) return "unverifiable";
265
+ // The public section is stored in the clear even when the key is encrypted.
266
+ const pubs = [];
267
+ for (let i = 0; i < n; i++) { let w; [w, off] = sshString(buf, off); pubs.push(w); }
268
+ if (cipher.toString() !== "none") return "encrypted";
269
+ for (const w of pubs) { if (ed25519FromWire(w) === pin && pin) return "match"; }
270
+ return "nomatch";
271
+ } catch { return "unverifiable"; }
272
+ }
273
+ // PKCS#8 BEGIN PRIVATE KEY is unencrypted by format.
274
+ try {
275
+ const key = crypto.createPrivateKey({ key: `-----BEGIN PRIVATE KEY-----\n${body}\n-----END PRIVATE KEY-----\n`, format: "pem" });
276
+ if (key.asymmetricKeyType !== "ed25519") return "nomatch";
277
+ return pin && crypto.createPublicKey(key).export({ format: "jwk" }).x === pin ? "match" : "nomatch";
278
+ } catch { return "unverifiable"; }
279
+ }
280
+
281
+ // An unencrypted openssh-key-v1 PEM (what keygen.mjs seals) -> Ed25519 KeyObject, built from
282
+ // the private SEED. Every embedded public copy must equal what the seed derives, so the pin
283
+ // cannot be satisfied by a clear-text field that disagrees with the key ssh-agent would load.
284
+ function opensshEd25519(text) {
285
+ const m = /-----BEGIN OPENSSH PRIVATE KEY-----\s*([A-Za-z0-9+/=\s]+?)\s*-----END OPENSSH PRIVATE KEY-----/.exec(text);
286
+ if (!m) return null;
287
+ const buf = Buffer.from(m[1].replace(/\s+/g, ""), "base64");
288
+ const magic = Buffer.from("openssh-key-v1\0");
289
+ if (!buf.subarray(0, magic.length).equals(magic)) throw new Error("not openssh-key-v1");
290
+ let off = magic.length; let cipher; let wire; let priv;
291
+ [cipher, off] = sshString(buf, off);
292
+ if (cipher.toString() !== "none") throw new Error("encrypted");
293
+ [, off] = sshString(buf, off); // kdfname
294
+ [, off] = sshString(buf, off); // kdfoptions
295
+ if (off + 4 > buf.length || buf.readUInt32BE(off) !== 1) throw new Error("not exactly one key");
296
+ off += 4;
297
+ [wire, off] = sshString(buf, off);
298
+ [priv] = sshString(buf, off);
299
+ if (priv.length < 8 || !priv.subarray(0, 4).equals(priv.subarray(4, 8))) throw new Error("checkint mismatch");
300
+ let p = 8; let type; let pub; let sk;
301
+ [type, p] = sshString(priv, p);
302
+ [pub, p] = sshString(priv, p);
303
+ [sk] = sshString(priv, p);
304
+ if (type.toString() !== "ssh-ed25519" || pub.length !== 32 || sk.length !== 64) throw new Error("not ed25519");
305
+ const der = Buffer.concat([Buffer.from("302e020100300506032b657004220420", "hex"), sk.subarray(0, 32)]);
306
+ const key = crypto.createPrivateKey({ key: der, format: "der", type: "pkcs8" });
307
+ const x = crypto.createPublicKey(key).export({ format: "jwk" }).x;
308
+ for (const copy of [pub, sk.subarray(32)]) if (copy.toString("base64url") !== x) throw new Error("public key does not derive from the seed");
309
+ if (ed25519FromWire(wire) !== x) throw new Error("public section does not derive from the seed");
310
+ return key;
311
+ }
312
+
313
+ if (mode === "derive") {
314
+ const text = readStdin().toString("utf8");
315
+ const key = opensshEd25519(text) ?? crypto.createPrivateKey({ key: text, format: "pem" });
316
+ if (key.asymmetricKeyType !== "ed25519") throw new Error("not ed25519");
317
+ out(`${crypto.createPublicKey(key).export({ format: "jwk" }).x}\n`);
318
+ } else if (mode === "agentkeys") {
319
+ // `ssh-add -L` on stdin -> one line per identity: the base64url raw key, or `other`.
320
+ for (const line of readStdin().toString("utf8").split("\n")) {
321
+ const parts = line.trim().split(/\s+/);
322
+ if (parts.length < 2) continue;
323
+ let pub = null;
324
+ try { pub = ed25519FromWire(Buffer.from(parts[1], "base64")); } catch {}
325
+ out(`${pub ?? "other"}\n`);
326
+ }
327
+ } else if (mode === "directives") {
328
+ // KS-7: the (section, key, value) multiset, minus comments, blanks, Description=,
329
+ // Documentation=; sorted, one per line.
330
+ let section = ""; const lines = [];
331
+ for (const raw of fs.readFileSync(args[0], "utf8").split("\n")) {
332
+ const l = raw.trim();
333
+ if (!l || l.startsWith("#") || l.startsWith(";")) continue;
334
+ const s = /^\[(.+)\]$/.exec(l);
335
+ if (s) { section = s[1]; continue; }
336
+ const eq = l.indexOf("=");
337
+ if (eq <= 0) { lines.push(`!unparseable\t${l}`); continue; }
338
+ const k = l.slice(0, eq).trim();
339
+ if (k === "Description" || k === "Documentation") continue;
340
+ lines.push(`${section}\t${k}\t${l.slice(eq + 1).trim()}`);
341
+ }
342
+ out(lines.sort().join("\n") + "\n");
343
+ } else if (mode === "chain") {
344
+ // IF-0-KEYSEAL-3 check 2 for ONE unit. stdin: `systemctl --user show -p …` output.
345
+ // args: shipped unit file, expected FragmentPath, slot, home, runtime dir, uid, and an
346
+ // optional ExecStart= value that replaces the shipped one (the legacy flat line, A1).
347
+ const [shipped, fragment, slot, home, runtime, uid, execOverride] = args;
348
+ const EXEC = ["ExecStartPre", "ExecStart", "ExecStartPost", "ExecStop", "ExecStopPost"];
349
+ const expand = (v) => v.replace(/%([%hti])/g, (_, c) => (c === "%" ? "%" : c === "h" ? home : c === "t" ? runtime : slot));
350
+ const want = { Environment: [] }; for (const p of EXEC) want[p] = [];
351
+ let section = "";
352
+ for (const raw of fs.readFileSync(shipped, "utf8").split("\n")) {
353
+ const l = raw.trim();
354
+ if (!l || l.startsWith("#")) continue;
355
+ const s = /^\[(.+)\]$/.exec(l);
356
+ if (s) { section = s[1]; continue; }
357
+ const eq = l.indexOf("=");
358
+ const k = l.slice(0, eq); const v = l.slice(eq + 1);
359
+ const val = section === "Service" && k === "ExecStart" && execOverride ? execOverride : v;
360
+ if (section === "Service" && k in want) want[k].push(expand(val.replace(/^[-@:+!|]+/, "").trim()).split(/\s+/).join(" "));
361
+ }
362
+ const got = { Environment: [], DropInPaths: null, FragmentPath: null }; for (const p of EXEC) got[p] = [];
363
+ const problems = [];
364
+ for (const line of readStdin().toString("utf8").split("\n")) {
365
+ if (!line) continue;
366
+ const eq = line.indexOf("=");
367
+ const k = line.slice(0, eq); const v = line.slice(eq + 1);
368
+ if (EXEC.includes(k)) {
369
+ const m = / argv\[\]=(.*?) ; ignore_errors=/.exec(v);
370
+ if (!m) { problems.push(`${k}: unparseable record`); continue; }
371
+ got[k].push(m[1]);
372
+ } else if (k === "Environment") { if (v) got.Environment.push(v); }
373
+ else if (k === "DropInPaths") got.DropInPaths = v ? v.split(/\s+/) : [];
374
+ else if (k === "FragmentPath") got.FragmentPath = v;
375
+ }
376
+ for (const p of [...EXEC, "Environment"]) {
377
+ if (JSON.stringify(got[p]) !== JSON.stringify(want[p])) problems.push(`${p} differs from the shipped unit`);
378
+ }
379
+ if (got.FragmentPath !== fragment) problems.push(`FragmentPath is not ${fragment}`);
380
+ if (got.DropInPaths === null) problems.push("DropInPaths not reported");
381
+ for (const d of got.DropInPaths ?? []) {
382
+ let userWritable = true;
383
+ try {
384
+ const st = fs.statSync(d); const dst = fs.statSync(require("node:path").dirname(d));
385
+ const w = (p) => { try { fs.accessSync(p, fs.constants.W_OK); return true; } catch { return false; } };
386
+ userWritable = String(st.uid) === uid || String(dst.uid) === uid || w(d) || w(require("node:path").dirname(d));
387
+ } catch {}
388
+ if (userWritable) problems.push(`drop-in in a user-writable location: ${d}`);
389
+ }
390
+ out(JSON.stringify({ ok: problems.length === 0, problems }) + "\n");
391
+ } else if (mode === "classify") {
392
+ // IF-0-KEYSEAL-5: candidates (NUL-separated file on args[0]) -> JSON. args[3], when set,
393
+ // is the ONE exempt path (A1: an opted-in unsealed slot's <slot>.key, 0600, this uid's). It
394
+ // is matched by path, never by inode, and never enters `seen`: a hard link elsewhere is a copy.
395
+ const pin = process.env.KEYSEAL_SCAN_PIN || "";
396
+ const uid = Number(args[1]);
397
+ const max = Number(args[2]);
398
+ const exempt = args[3] || "";
399
+ const raw = fs.readFileSync(args[0]).toString("utf8").split("\0").filter(Boolean);
400
+ const seen = new Set();
401
+ const res = { candidates: 0, plaintext_matches: [], encrypted_skipped: [], unverifiable: [], exempt: [] };
402
+ for (const p of raw) {
403
+ let st;
404
+ try { st = fs.lstatSync(p); } catch { continue; }
405
+ if (!st.isFile() || st.uid !== uid || st.size > max) continue;
406
+ if (exempt && p === exempt && (st.mode & 0o777) === 0o600) {
407
+ // Overlapping roots ($HOME under /tmp) list one path twice: report it once.
408
+ if (!res.exempt.includes(p)) res.exempt.push(p);
409
+ continue;
410
+ }
411
+ const id = `${st.dev}:${st.ino}`;
412
+ if (seen.has(id)) continue;
413
+ seen.add(id);
414
+ let text;
415
+ try { text = fs.readFileSync(p, "latin1"); } catch { res.unverifiable.push(p); continue; }
416
+ const lines = text.split("\n").map((l) => l.replace(/\r$/, ""));
417
+ const verdicts = new Set();
418
+ for (let i = 0; i < lines.length; i++) {
419
+ const m = /^-----BEGIN (OPENSSH PRIVATE KEY|PRIVATE KEY)-----$/.exec(lines[i]);
420
+ if (!m) continue;
421
+ const end = lines.indexOf(`-----END ${m[1]}-----`, i + 1);
422
+ if (end < 0) { verdicts.add("unverifiable"); continue; }
423
+ verdicts.add(classifyBlock(m[1], lines.slice(i + 1, end).join("\n"), pin));
424
+ i = end;
425
+ }
426
+ if (verdicts.size === 0) continue;
427
+ res.candidates++;
428
+ if (verdicts.has("match")) res.plaintext_matches.push(p);
429
+ if (verdicts.has("encrypted")) res.encrypted_skipped.push(p);
430
+ if (verdicts.has("unverifiable")) res.unverifiable.push(p);
431
+ }
432
+ out(JSON.stringify(res) + "\n");
433
+ } else {
434
+ process.stderr.write(`keyseal node helper: unknown mode ${mode}\n`);
435
+ process.exit(2);
436
+ }
437
+ JS
438
+ )
439
+
440
+ keyseal_node() { node -e "$KEYSEAL_NODE_JS" -- "$@"; }
441
+
442
+ # The raw public key the sealed blob of slot $1 derives to, via a PIPED decrypt: the key
443
+ # exists only in the pipe and in node's memory. Empty output on any failure, with rc 3 when
444
+ # the decrypt failed and rc 4 when it succeeded but no Ed25519 key derives from its output:
445
+ # status.sh tells "no key could be read" (undeterminable when sealing is broken) from "a
446
+ # different key" (a contradiction).
447
+ keyseal_derive_pub() {
448
+ local slot=$1 cred res st pub
449
+ cred="$(keyseal_key_dir)/$slot.cred"
450
+ res=$("$CREDS" --user decrypt --name="$(keyseal_cred_name "$slot")" "$cred" - 2>/dev/null \
451
+ | keyseal_node derive 2>/dev/null
452
+ printf '\n%s %s' "${PIPESTATUS[0]}" "${PIPESTATUS[1]}")
453
+ st=${res##*$'\n'}
454
+ pub=${res%$'\n'*}
455
+ pub=${pub%$'\n'}
456
+ [[ ${st%% *} == 0 ]] || return 3
457
+ [[ ${st##* } == 0 && -n $pub ]] || return 4
458
+ printf '%s' "$pub"
459
+ }
460
+
461
+ # The first line of a file, if it is a well-formed pin (43-char base64url).
462
+ keyseal_read_pin() {
463
+ local line=
464
+ [[ -f $1 ]] || return 1
465
+ IFS= read -r line <"$1" || [[ -n $line ]] || return 1
466
+ [[ $line =~ ^[A-Za-z0-9_-]{43}$ ]] || return 1
467
+ printf '%s' "$line"
468
+ }
469
+
470
+ # The identities the slot agent holds, one per line (base64url raw key, or `other`).
471
+ keyseal_agent_keys() {
472
+ local out rc
473
+ out=$(SSH_AUTH_SOCK=$(keyseal_sock_path "$1") "$SSH_ADD" -L 2>/dev/null) && rc=0 || rc=$?
474
+ # ssh-add -L exits 1 with "The agent has no identities." on an empty agent.
475
+ if ((rc != 0)); then
476
+ [[ $out == *"no identities"* ]] && return 0
477
+ return 1
478
+ fi
479
+ printf '%s\n' "$out" | keyseal_node agentkeys
480
+ }
481
+
482
+ # Exactly one identity, equal to the pin.
483
+ keyseal_agent_holds_exactly() {
484
+ local keys
485
+ keys=$(keyseal_agent_keys "$1") || return 1
486
+ [[ $keys == "$2" ]]
487
+ }
488
+
489
+ # Effective config dirs: ours and the user manager's must agree (IF-0-KEYSEAL-2). Each side
490
+ # is XDG_CONFIG_HOME if set, else $HOME/.config.
491
+ keyseal_config_dir_check() {
492
+ local env_out mhome= mxdg= line ours theirs
493
+ env_out=$("$SYSTEMCTL" --user show-environment) || return 1
494
+ while IFS= read -r line; do
495
+ case $line in
496
+ HOME=*) mhome=${line#HOME=} ;;
497
+ XDG_CONFIG_HOME=*) mxdg=${line#XDG_CONFIG_HOME=} ;;
498
+ esac
499
+ done <<<"$env_out"
500
+ [[ -n $mhome || -n $mxdg ]] || return 1
501
+ ours=$(realpath -m -- "${XDG_CONFIG_HOME:-$HOME/.config}")
502
+ theirs=$(realpath -m -- "${mxdg:-$mhome/.config}")
503
+ [[ $ours == "$theirs" ]]
504
+ }
505
+
506
+ # IF-0-KEYSEAL-3 check 2, the chain, for slot $1 against the shipped units and install set
507
+ # in $2 (the scripts/keyslot checkout). Sets KEYSEAL_CHAIN_PROBLEMS (newline-joined; any is
508
+ # exit 1), KEYSEAL_CHAIN_LEGACY (yes when the installed loader template is the pre-A1 flat one,
509
+ # so the slot runs the unmigrated flat files: 2, never judged as a violation), and the
510
+ # keyseal_loader_check variables for the slot's loader version (A1).
511
+ keyseal_chain_check() {
512
+ local slot=$1 src=$2 unit template shown verdict problems= legacy_verdict
513
+ local props=ExecStartPre,ExecStart,ExecStartPost,ExecStop,ExecStopPost,Environment,DropInPaths,FragmentPath
514
+ KEYSEAL_CHAIN_LEGACY=no
515
+ for template in "$KEYSEAL_BASE_UNIT" "$KEYSEAL_LOAD_UNIT"; do
516
+ unit="$template@$slot.service"
517
+ if ! shown=$("$SYSTEMCTL" --user show -p "$props" "$unit"); then
518
+ problems+="$unit: show failed"$'\n'
519
+ continue
520
+ fi
521
+ verdict=$(printf '%s\n' "$shown" | keyseal_node chain "$src/$template@.service" \
522
+ "$(keyseal_unit_dir)/$template@.service" "$slot" "$HOME" "$XDG_RUNTIME_DIR" "$(id -u)") || verdict=
523
+ if [[ $verdict != '{"ok":true,"problems":[]}' && $template == "$KEYSEAL_LOAD_UNIT" ]]; then
524
+ legacy_verdict=$(printf '%s\n' "$shown" | keyseal_node chain "$src/$template@.service" \
525
+ "$(keyseal_unit_dir)/$template@.service" "$slot" "$HOME" "$XDG_RUNTIME_DIR" "$(id -u)" \
526
+ "$KEYSEAL_LEGACY_LOAD_EXEC") || legacy_verdict=
527
+ if [[ $legacy_verdict == '{"ok":true,"problems":[]}' ]]; then
528
+ KEYSEAL_CHAIN_LEGACY=yes
529
+ continue
530
+ fi
531
+ fi
532
+ if [[ $verdict != '{"ok":true,"problems":[]}' ]]; then
533
+ problems+="$unit: ${verdict:-chain helper failed}"$'\n'
534
+ fi
535
+ done
536
+ keyseal_loader_check "$slot" "$src"
537
+ # Under the legacy template the flat files are what runs: they must be the shipped flat set.
538
+ if [[ $KEYSEAL_CHAIN_LEGACY == yes ]] && ! keyseal_legacy_known; then
539
+ problems+="loader: the legacy loader files are not the shipped flat set (c5e358f)"$'\n'
540
+ fi
541
+ if [[ $KEYSEAL_CHAIN_LEGACY == no && $KEYSEAL_LOADER_STATE == mismatch ]]; then
542
+ problems+="loader: $KEYSEAL_LOADER_PROBLEM"$'\n'
543
+ fi
544
+ KEYSEAL_CHAIN_PROBLEMS=$problems
545
+ [[ -z $problems ]]
546
+ }
547
+
548
+ # The opt-in record of slot $1 against its pin (optin.sh, the loader's own predicate). Prints
549
+ # the problems, one per line; rc 0 valid.
550
+ keyseal_optin_check() {
551
+ local mid
552
+ mid=$(keyseal_machine_id) || mid=unreadable
553
+ keyseal_optin_verify "$(keyseal_key_dir)/$1.unsealed-optin" "$(keyseal_key_dir)/$1.pub" "$1" "$(keyseal_uid)" "$mid"
554
+ }
555
+
556
+ # The whole unsealed predicate for slot $1, exactly as keyslot-load.sh runs it before loading.
557
+ keyseal_unsealed_check() {
558
+ local mid
559
+ mid=$(keyseal_machine_id) || mid=unreadable
560
+ keyseal_unsealed_verify "$1" "$(keyseal_uid)" "$mid"
561
+ }
562
+
563
+ # IF-0-KEYSEAL-5. Prints the scan JSON (the `scan` object of status); rc 0 when it ran, 1
564
+ # when it could not (a failed find/xargs/grep, not "found something"). $2, when set, is the
565
+ # one exempt path: an opted-in unsealed slot's <slot>.key (A1).
566
+ keyseal_scan() {
567
+ local pin=$1 exempt=${2-} uid list rc_find rc_xargs r json roots=() present=() prune=() h
568
+ uid=$(id -u)
569
+ roots=("$HOME" "$XDG_RUNTIME_DIR" "${TMPDIR:-/tmp}" /tmp /var/tmp)
570
+ for r in "${roots[@]}"; do [[ -d $r ]] && present+=("$r"); done
571
+ prune=(\()
572
+ for h in "${KEYSEAL_SCAN_PRUNE_NAMES[@]}"; do prune+=(-name "$h" -o); done
573
+ for h in "${KEYSEAL_SCAN_PRUNE_PATHS[@]}"; do prune+=(-path "*/$h" -o); done
574
+ unset 'prune[${#prune[@]}-1]'
575
+ prune+=(\))
576
+ local greps=()
577
+ for h in "${KEYSEAL_SCAN_HEADERS[@]}"; do greps+=(-e "$h" -e "$h"$'\r'); done
578
+ list=$(mktemp "${XDG_RUNTIME_DIR:-/tmp}/keyseal-scan.XXXXXX") || return 1
579
+ "$KEYSEAL_BIN_FIND" -H "${present[@]}" -type d "${prune[@]}" -prune -o \
580
+ -type f -user "$uid" -size -$((KEYSEAL_SCAN_MAX_BYTES + 1))c -print0 2>/dev/null \
581
+ | "$KEYSEAL_BIN_XARGS" -0 -r "$KEYSEAL_BIN_GREP" -lsZ -x -F "${greps[@]}" -- >"$list"
582
+ rc_find=${PIPESTATUS[0]} rc_xargs=${PIPESTATUS[1]}
583
+ # find: 0, or 1 when some directory was unreadable. xargs: 0, or 123 when some grep found
584
+ # nothing. Anything else means the scan did not run to completion.
585
+ if ! { ((rc_find <= 1)) && ((rc_xargs == 0 || rc_xargs == 123)); }; then
586
+ rm -f -- "$list"
587
+ return 1
588
+ fi
589
+ json=$(KEYSEAL_SCAN_PIN=$pin keyseal_node classify "$list" "$uid" "$KEYSEAL_SCAN_MAX_BYTES" "$exempt") || { rm -f -- "$list"; return 1; }
590
+ rm -f -- "$list"
591
+ local jr=() jp=() jn=() jh=()
592
+ for r in "${roots[@]}"; do jr+=("$(keyseal_json_str "$r")"); done
593
+ for h in "${KEYSEAL_SCAN_PRUNE_NAMES[@]}" "${KEYSEAL_SCAN_PRUNE_PATHS[@]}"; do jp+=("$(keyseal_json_str "$h")"); done
594
+ for h in "${KEYSEAL_SCAN_NOT_DETECTED[@]}"; do jn+=("$(keyseal_json_str "$h")"); done
595
+ for h in "${KEYSEAL_SCAN_HEADERS[@]}"; do jh+=("$(keyseal_json_str "$h")"); done
596
+ local IFS=,
597
+ printf '{"roots":[%s],"pruned":[%s],"max_bytes":%s,"headers":[%s],"not_detected":[%s],%s\n' \
598
+ "${jr[*]}" "${jp[*]}" "$KEYSEAL_SCAN_MAX_BYTES" "${jh[*]}" "${jn[*]}" "${json#\{}"
599
+ }