@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.
@@ -0,0 +1,530 @@
1
+ #!/bin/bash
2
+ # provision.sh --harness <h> [--slot <s>] [--rotate] [--allow-unsealed-key] | [--refresh-loader]:
3
+ # put a key into slot <s> and load it into the slot agent (IF-0-KEYSEAL-1, IF-0-KEYSEAL-6).
4
+ # <s> defaults to <h>.
5
+ #
6
+ # Prints ONE line on success, the reference enrollment consumes:
7
+ # agent_socket:$XDG_RUNTIME_DIR/agent-board/<s>.sock#<pub43>
8
+ # Exit 0 provisioned, 1 failed or refused, 2 capability absent.
9
+ #
10
+ # MODE (A1), from probe.sh's `outcome`, never its exit code alone:
11
+ # capable sealed. --allow-unsealed-key is REFUSED (exit 1): unsealed is never
12
+ # chosen on a host that can seal.
13
+ # incapable unsealed, only with --allow-unsealed-key; without it exit 2, unless
14
+ # the slot already holds a committed unsealed key (then maintained).
15
+ # incapable-for-keyslot exit 2, the fix named: no mode survives a reboot.
16
+ # error exit 1 probe_error. An error is never treated as incapable.
17
+ # No downgrade: a slot with a <s>.cred is never turned unsealed; remove the pair first, then
18
+ # provision with a fresh flag. --rotate on an unsealed slot rotates INTO sealed mode, and only
19
+ # on a capable host.
20
+ #
21
+ # SEALED: the private key exists only in keygen.mjs's memory, the pipe into `systemd-creds
22
+ # encrypt`, and later the loader's pipe into ssh-add. It is never a file.
23
+ # UNSEALED: keygen.mjs's PEM goes straight into <s>.key (0600, noclobber); that file is the
24
+ # only plaintext this tool ever writes, and only on the opt-in path. Then the opt-in record
25
+ # <s>.unsealed-optin, then the pin <s>.pub: the pin's rename is the commit.
26
+ #
27
+ # LOADER (A1): each version of the install set lives in an immutable
28
+ # ~/.local/libexec/agent-board/v-<sha16>/; the template runs slot-%i/keyslot-load.sh through a
29
+ # per-slot symlink slot-<s> -> v-<sha16>, recorded in <s>.loader-sha256. Every run points THIS
30
+ # slot at the running tooling's version (restarting its loader when that changed) and leaves
31
+ # every other slot's version alone. --refresh-loader does only that, plus steps 5 and 6, and
32
+ # never re-keys. The first run under this layout migrates the flat layout (c5e358f), all
33
+ # slots under one lock (.loader.lock), template swap last, legacy files removed only after a
34
+ # successful daemon-reload.
35
+ #
36
+ # Transactional: under a per-slot lock, the recovery table below is evaluated first, so an
37
+ # interruption at any step leaves a state the next run finishes. A committed slot is NEVER
38
+ # deleted; only staging files and uncommitted generations are.
39
+ #
40
+ # found action
41
+ # nothing fresh: steps 3, 4, 5, 6
42
+ # *.staging only, or staged beside a committed slot delete the staging files, re-evaluate
43
+ # SEALED
44
+ # <s>.cred + <s>.pub.staging deriving from it half-committed: mv the pub, then
45
+ # (the old <s>.pub may exist) steps 3, 5 (with a restart), 6
46
+ # <s>.cred + <s>.pub deriving from it committed: steps 3, 5, 6; with
47
+ # --rotate 3, 4, 5 (restart), 6
48
+ # UNSEALED
49
+ # <s>.key + valid opt-in + <s>.pub deriving from key committed: steps 3, 5, 6; with --rotate
50
+ # on a capable host 3, 4 sealed, 5, 6
51
+ # <s>.key with no <s>.pub but a <s>.pub.staging, or an uncommitted unsealed generation
52
+ # an empty <s>.key alone (with any opt-in files) (never printed): delete it, re-evaluate
53
+ # UNSEALED -> SEALED (--rotate)
54
+ # <s>.cred + <s>.pub.staging deriving from it, with half-rotated: mv the pub, re-evaluate
55
+ # the old <s>.key and opt-in
56
+ # <s>.cred + <s>.pub deriving from it, with the old rotated: delete the old <s>.key and
57
+ # <s>.key (not deriving to the pin) and/or opt-in opt-in, re-evaluate
58
+ # anything else slot_inconsistent, exit 1
59
+ set -euo pipefail
60
+
61
+ here=${BASH_SOURCE[0]%/*}
62
+ [[ $here == "${BASH_SOURCE[0]}" ]] && here=.
63
+ # shellcheck source=lib.sh
64
+ . "$here/lib.sh"
65
+
66
+ usage="usage: provision.sh --harness <h> [--slot <s>] [--rotate] [--allow-unsealed-key] | [--refresh-loader]"
67
+ harness= slot= rotate=no allow_unsealed=no refresh=no
68
+ while (($#)); do
69
+ case $1 in
70
+ --harness) harness=${2-}; shift 2 || keyseal_die "usage: --harness <h>" ;;
71
+ --slot) slot=${2-}; shift 2 || keyseal_die "usage: --slot <s>" ;;
72
+ --rotate) rotate=yes; shift ;;
73
+ --allow-unsealed-key) allow_unsealed=yes; shift ;;
74
+ --refresh-loader) refresh=yes; shift ;;
75
+ *) keyseal_die "$usage" ;;
76
+ esac
77
+ done
78
+ [[ $harness =~ $KEYSEAL_SLOT_RE ]] || keyseal_die "bad_harness"
79
+ slot=${slot:-$harness}
80
+ [[ $slot =~ $KEYSEAL_SLOT_RE ]] || keyseal_die "bad_slot"
81
+ if [[ $refresh == yes && ($rotate == yes || $allow_unsealed == yes) ]]; then
82
+ keyseal_die "--refresh-loader never re-keys: it takes no --rotate or --allow-unsealed-key"
83
+ fi
84
+ : "${XDG_RUNTIME_DIR:?provision: XDG_RUNTIME_DIR unset}"
85
+ : "${HOME:?provision: HOME unset}"
86
+ keyseal_resolve_bins || exit 1
87
+ seam_active && echo "provision: seam=true" >&2
88
+
89
+ K=$(keyseal_key_dir)
90
+ cred=$K/$slot.cred pub=$K/$slot.pub key=$K/$slot.key optin=$K/$slot.unsealed-optin
91
+ cred_s=$K/$slot.cred.staging pub_s=$K/$slot.pub.staging optin_s=$K/$slot.unsealed-optin.staging
92
+ base_unit=$KEYSEAL_BASE_UNIT@$slot.service
93
+ load_unit=$KEYSEAL_LOAD_UNIT@$slot.service
94
+
95
+ # Capability first, before any file exists (KS-9). --refresh-loader never generates a key, so
96
+ # it does not need one.
97
+ want=maintain
98
+ if [[ $refresh == no ]]; then
99
+ probe_out=$("$here/probe.sh" --json 2>/dev/null) || true
100
+ outcome=
101
+ [[ $probe_out =~ \"outcome\":\"([a-z-]+)\" ]] && outcome=${BASH_REMATCH[1]}
102
+ case $outcome in
103
+ capable)
104
+ [[ $allow_unsealed == no ]] || keyseal_die "allow_unsealed_refused: probe.sh reports this host capable of sealing; an unsealed key is never written where a sealed one can be. An existing unsealed slot is turned sealed with provision.sh --harness $harness --slot $slot --rotate (no --allow-unsealed-key)"
105
+ want=sealed
106
+ ;;
107
+ incapable)
108
+ if [[ $allow_unsealed == yes ]]; then
109
+ want=unsealed
110
+ elif [[ -e $key || -L $key ]]; then
111
+ want=maintain
112
+ else
113
+ echo "provision: capability_absent (probe.sh: incapable; this host cannot seal). To accept an unsealed 0600 key for this slot, re-run with --allow-unsealed-key" >&2
114
+ exit 2
115
+ fi
116
+ ;;
117
+ incapable-for-keyslot)
118
+ echo "provision: incapable_for_keyslot (probe.sh: no key slot survives a reboot here; run probe.sh for the fix)" >&2
119
+ exit 2
120
+ ;;
121
+ *)
122
+ echo "provision: probe_error (probe.sh outcome '${outcome:-unreadable}'); refusing, and an error never admits an unsealed key" >&2
123
+ exit 1
124
+ ;;
125
+ esac
126
+ fi
127
+
128
+ # The loader runs under the user manager, which resolves K from ITS environment.
129
+ keyseal_config_dir_check || keyseal_die "config_dir_mismatch: XDG_CONFIG_HOME/HOME differ from the user manager's (systemctl --user show-environment)"
130
+
131
+ umask 077
132
+ mkdir -p -m 0700 "$K"
133
+ exec {lock_fd}>"$K/$slot.lock"
134
+ "$FLOCK" -n "$lock_fd" || keyseal_die "slot_busy"
135
+ keyseal_seam_point locked
136
+
137
+ # Principle 4 on the key directory BEFORE any plaintext can exist (review round 1, Opus B1): an
138
+ # opt-in under a symlinked, foreign-owned or group-writable ancestor could never validate, so
139
+ # the unsealed key it guards would be stranded at rest.
140
+ if [[ $want == unsealed ]] && ! problems=$(keyseal_trusted_ancestors "$K"); then
141
+ keyseal_die "unsafe_key_dir: $K fails principle 4, so no unsealed key is written: ${problems//$'\n'/; }"
142
+ fi
143
+
144
+ # No downgrade, judged BEFORE the recovery table: on a host that cannot decrypt, the table
145
+ # could not even derive the committed pin, and nothing here may depend on it.
146
+ if [[ $want == unsealed ]] && [[ -e $cred || -L $cred ]] && [[ -e $pub || -L $pub || -e $pub_s || -L $pub_s ]]; then
147
+ keyseal_die "downgrade_refused: slot $slot holds a sealed key ($slot.cred + $slot.pub); it is never turned unsealed. Remove that pair first, then provision with a fresh --allow-unsealed-key"
148
+ fi
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # step 2: recovery
152
+ # ---------------------------------------------------------------------------
153
+ has() { [[ -e $1 || -L $1 ]]; }
154
+ optin_valid() { keyseal_optin_check "$slot" >/dev/null; }
155
+ key_pub() { keyseal_keyfile_pub "$key" 2>/dev/null; }
156
+
157
+ table_row() {
158
+ local derived= kp=
159
+ if ! has "$cred" && ! has "$pub" && ! has "$key" && ! has "$optin" && ! has "$cred_s" && ! has "$pub_s" && ! has "$optin_s"; then
160
+ echo empty; return
161
+ fi
162
+ if has "$key" || has "$optin"; then
163
+ if has "$cred"; then
164
+ # unsealed -> sealed rotation, past the blob's rename.
165
+ derived=$(keyseal_derive_pub "$slot") || derived=
166
+ [[ -n $derived ]] || { echo inconsistent; return; }
167
+ if has "$pub_s" && ! has "$cred_s" && [[ $(keyseal_read_pin "$pub_s" || true) == "$derived" ]]; then
168
+ echo half-rotated; return
169
+ fi
170
+ if has "$pub" && [[ $(keyseal_read_pin "$pub" || true) == "$derived" ]]; then
171
+ kp=$(key_pub) || kp=
172
+ if [[ -n $kp && $kp == "$derived" ]]; then echo inconsistent; return; fi
173
+ echo rotated; return
174
+ fi
175
+ echo inconsistent; return
176
+ fi
177
+ if ! has "$pub"; then
178
+ # The pin's rename is the unsealed commit: before it, nothing was ever printed.
179
+ if has "$pub_s" || { has "$key" && [[ -f $key && ! -L $key && ! -s $key ]] && ! has "$optin"; }; then
180
+ echo uncommitted-unsealed; return
181
+ fi
182
+ echo inconsistent; return
183
+ fi
184
+ kp=$(key_pub) || kp=
185
+ if [[ -n $kp && $(keyseal_read_pin "$pub" || true) == "$kp" ]] && optin_valid; then
186
+ if has "$cred_s" || has "$pub_s" || has "$optin_s"; then echo staging; else echo committed-unsealed; fi
187
+ return
188
+ fi
189
+ echo inconsistent; return
190
+ fi
191
+ if ! has "$cred" && ! has "$pub"; then echo staging; return; fi
192
+ has "$cred" && derived=$(keyseal_derive_pub "$slot") || derived=
193
+ if [[ -n $derived ]] && has "$pub_s" && ! has "$cred_s" && [[ $(keyseal_read_pin "$pub_s" || true) == "$derived" ]]; then
194
+ echo half; return
195
+ fi
196
+ if [[ -n $derived ]] && has "$pub" && [[ $(keyseal_read_pin "$pub" || true) == "$derived" ]]; then
197
+ if has "$cred_s" || has "$pub_s" || has "$optin_s"; then echo staging; else echo committed; fi
198
+ return
199
+ fi
200
+ echo inconsistent
201
+ }
202
+
203
+ # `finished`: this run completed an interrupted commit, so --rotate does not rotate again.
204
+ restart=no generate=no finished=no
205
+ while :; do
206
+ row=$(table_row)
207
+ case $row in
208
+ empty)
209
+ case $want in
210
+ sealed) generate=sealed ;;
211
+ unsealed) generate=unsealed ;;
212
+ *)
213
+ if [[ $refresh == yes ]]; then keyseal_die "nothing_to_refresh: slot $slot holds no committed key"; fi
214
+ echo "provision: capability_absent (probe.sh: incapable, and slot $slot holds no committed key). Re-run with --allow-unsealed-key to accept an unsealed 0600 key" >&2
215
+ exit 2
216
+ ;;
217
+ esac
218
+ break
219
+ ;;
220
+ staging) rm -f -- "$cred_s" "$pub_s" "$optin_s"; sync -- "$K" ;;
221
+ uncommitted-unsealed) rm -f -- "$key" "$pub_s" "$optin" "$optin_s" "$cred_s"; sync -- "$K" ;;
222
+ half | half-rotated)
223
+ mv -f -- "$pub_s" "$pub"
224
+ sync -- "$K"
225
+ restart=yes finished=yes
226
+ ;;
227
+ rotated)
228
+ rm -f -- "$optin" "$key"
229
+ sync -- "$K"
230
+ restart=yes finished=yes
231
+ ;;
232
+ committed)
233
+ # (want=unsealed cannot reach here: the downgrade refusal above ran first.)
234
+ if [[ $rotate == yes && $finished == no ]]; then
235
+ [[ $want == sealed ]] || keyseal_die "rotate_needs_capable: this host cannot seal a new key (probe.sh)"
236
+ generate=sealed restart=yes
237
+ fi
238
+ break
239
+ ;;
240
+ committed-unsealed)
241
+ if [[ $rotate == yes ]]; then
242
+ [[ $want == sealed ]] || keyseal_die "rotate_needs_capable: --rotate turns an unsealed slot sealed, which needs probe.sh capable; this host is not"
243
+ generate=sealed restart=yes
244
+ elif [[ $want == sealed ]]; then
245
+ echo "provision: upgrade_available: this host can now seal; provision.sh --harness $harness --slot $slot --rotate, then re-enroll and revoke the old identity" >&2
246
+ fi
247
+ break
248
+ ;;
249
+ *) keyseal_die "slot_inconsistent: $K holds a state the recovery table does not name; nothing was deleted" ;;
250
+ esac
251
+ done
252
+ keyseal_seam_point recovered
253
+
254
+ # ---------------------------------------------------------------------------
255
+ # step 3: install, migrate, reload, and check the chain BEFORE any key is loaded. Every
256
+ # layout change holds .loader.lock (outside the slot grammar), across all slots.
257
+ # ---------------------------------------------------------------------------
258
+ unit_dir=$(keyseal_unit_dir)
259
+ libexec=$(keyseal_libexec_dir)
260
+ load_tpl=$unit_dir/$KEYSEAL_LOAD_UNIT@.service
261
+ mkdir -p -- "$unit_dir" "$libexec"
262
+ exec {layout_fd}>"$K/.loader.lock"
263
+ "$FLOCK" -w 60 "$layout_fd" || keyseal_die "loader_busy: another provision holds $K/.loader.lock"
264
+ keyseal_seam_point layout-locked
265
+
266
+ if [[ -e $unit_dir/$KEYSEAL_BASE_UNIT@.service ]]; then
267
+ # Adoption (KS-7): an existing base unit is kept only if its directive multiset equals the
268
+ # shipped one; otherwise refuse, and leave it byte-identical.
269
+ if [[ $(keyseal_node directives "$unit_dir/$KEYSEAL_BASE_UNIT@.service") != \
270
+ "$(keyseal_node directives "$here/$KEYSEAL_BASE_UNIT@.service")" ]]; then
271
+ keyseal_die "base_unit_mismatch: $unit_dir/$KEYSEAL_BASE_UNIT@.service differs from the shipped directive set; refusing to adopt or replace it"
272
+ fi
273
+ else
274
+ install -m 0644 -- "$here/$KEYSEAL_BASE_UNIT@.service" "$unit_dir/"
275
+ fi
276
+
277
+ # Install the set in $1 as its immutable version directory, or verify the one already there.
278
+ # Prints the set sha.
279
+ install_version() {
280
+ local src=$1 sha d tmp f
281
+ shift
282
+ sha=$(keyseal_files_sha "$src" "$@") || keyseal_die "install_set_incomplete in $src"
283
+ d=$libexec/$(keyseal_version_dirname "$sha")
284
+ if has "$d"; then
285
+ [[ -d $d && ! -L $d && $(keyseal_dir_sha "$d" || true) == "$sha" ]] ||
286
+ keyseal_die "loader_version_mismatch: $d exists and does not hold version $sha; refusing to overwrite an immutable version"
287
+ else
288
+ tmp=$(mktemp -d "$libexec/.v-staging.XXXXXX")
289
+ for f in "$@"; do install -m 0755 -- "$src/$f" "$tmp/"; done
290
+ chmod 0755 "$tmp"
291
+ sync -- "$tmp"
292
+ mv -T -- "$tmp" "$d"
293
+ sync -- "$libexec"
294
+ fi
295
+ printf '%s' "$sha"
296
+ }
297
+ # Point slot $1 at version $2 (atomic rename of a fresh symlink), and record it.
298
+ point_slot() {
299
+ local s=$1 sha=$2 link tmp
300
+ link=$(keyseal_slot_link "$s")
301
+ if has "$link" && [[ ! -L $link ]]; then keyseal_die "slot_link_not_a_symlink: $link"; fi
302
+ tmp=$libexec/.$KEYSEAL_SLOT_LINK_PREFIX$s.staging
303
+ rm -f -- "$tmp"
304
+ ln -s -- "$(keyseal_version_dirname "$sha")" "$tmp"
305
+ mv -T -f -- "$tmp" "$link"
306
+ sync -- "$libexec"
307
+ }
308
+ record_slot() {
309
+ local s=$1 sha=$2 rec
310
+ rec=$(keyseal_loader_record "$s")
311
+ printf '%s\n' "$sha" >"$rec.staging"
312
+ sync -- "$rec.staging"
313
+ mv -f -- "$rec.staging" "$rec"
314
+ sync -- "$K"
315
+ }
316
+ template_is() { [[ -f $load_tpl ]] && cmp -s -- "$1" "$load_tpl"; }
317
+ template_is_legacy() {
318
+ [[ -f $load_tpl ]] || return 1
319
+ local line
320
+ while IFS= read -r line; do
321
+ line=${line#"${line%%[![:space:]]*}"}
322
+ [[ $line == "ExecStart=$KEYSEAL_LEGACY_LOAD_EXEC" ]] && return 0
323
+ done <"$load_tpl"
324
+ return 1
325
+ }
326
+ swap_template() {
327
+ install -m 0644 -- "$here/$KEYSEAL_LOAD_UNIT@.service" "$load_tpl.staging"
328
+ mv -f -- "$load_tpl.staging" "$load_tpl"
329
+ sync -- "$unit_dir"
330
+ }
331
+
332
+ # Every slot the template serves: each with committed material in K (a blob, pin or key; enabled
333
+ # or not, running or not) and each enabled loader instance (review round 1, adversarial 2).
334
+ all_slots() {
335
+ local f s
336
+ {
337
+ keyseal_enabled_slots
338
+ for f in "$K"/*.cred "$K"/*.pub "$K"/*.key; do
339
+ [[ -e $f ]] || continue
340
+ s=${f##*/}
341
+ s=${s%.*}
342
+ [[ $s =~ $KEYSEAL_SLOT_RE ]] && printf '%s\n' "$s"
343
+ done
344
+ } | sort -u
345
+ }
346
+ # The slots that hold a key: the ones legacy removal must not strand.
347
+ committed_slots() {
348
+ local f s
349
+ for f in "$K"/*.cred "$K"/*.pub "$K"/*.key; do
350
+ [[ -e $f ]] || continue
351
+ s=${f##*/}
352
+ s=${s%.*}
353
+ [[ $s =~ $KEYSEAL_SLOT_RE ]] && printf '%s\n' "$s"
354
+ done | sort -u
355
+ }
356
+
357
+ # Migration from the flat layout: every slot onto a version directory holding the flat files it
358
+ # runs today, THEN the template, THEN the legacy files. Only the flat set c5e358f shipped is
359
+ # migrated: unknown content is never laundered into a recorded version (review round 1, S1).
360
+ if template_is_legacy; then
361
+ keyseal_legacy_present || keyseal_die "legacy_layout_incomplete: $load_tpl runs the flat loader but $libexec holds no flat install set"
362
+ keyseal_legacy_known || keyseal_die "legacy_loader_unknown: the flat loader files in $libexec are not the set c5e358f shipped; refusing to migrate them (status.sh reports the chain)"
363
+ legacy_sha=$(install_version "$libexec" "${KEYSEAL_LEGACY_INSTALL_SET[@]}")
364
+ keyseal_seam_point migrate-install
365
+ mapfile -t migrating < <(all_slots)
366
+ for s in "${migrating[@]}"; do point_slot "$s" "$legacy_sha"; done
367
+ keyseal_seam_point migrate-link
368
+ for s in "${migrating[@]}"; do record_slot "$s" "$legacy_sha"; done
369
+ keyseal_seam_point migrate-record
370
+ swap_template
371
+ keyseal_seam_point migrate-swap
372
+ "$SYSTEMCTL" --user daemon-reload || keyseal_die "daemon_reload_failed after the template swap; the legacy files are kept"
373
+ keyseal_seam_point migrate-reload
374
+ elif ! template_is "$here/$KEYSEAL_LOAD_UNIT@.service"; then
375
+ swap_template
376
+ fi
377
+
378
+ sha=$(install_version "$here" "${KEYSEAL_INSTALL_SET[@]}")
379
+ loader_changed=no
380
+ [[ $(keyseal_read_loader_record "$slot" || true) == "$sha" && $(readlink -- "$(keyseal_slot_link "$slot")" || true) == "$(keyseal_version_dirname "$sha")" ]] || loader_changed=yes
381
+ point_slot "$slot" "$sha"
382
+ keyseal_seam_point link-pointed
383
+ record_slot "$slot" "$sha"
384
+ "$SYSTEMCTL" --user daemon-reload || keyseal_die "daemon_reload_failed"
385
+ keyseal_seam_point pointed
386
+
387
+ # Legacy flat files: removed only once every slot holding a key has its symlink and
388
+ # daemon-reload has succeeded (above, in this run). A missing symlink is a DELETION
389
+ # precondition, never an error: the files stay, status reports legacy_loader_present (2), and
390
+ # `provision.sh --refresh-loader` for that slot (which has a key, so it can) links it and
391
+ # removes them (review round 1, Gemini 1). An enabled slot with no key cannot load anything,
392
+ # before or after, so it never holds them.
393
+ if keyseal_legacy_present && ! template_is_legacy; then
394
+ unlinked=
395
+ while IFS= read -r s; do
396
+ [[ -z $s || -L $(keyseal_slot_link "$s") ]] || unlinked+=" $s"
397
+ done < <(committed_slots)
398
+ if [[ -z $unlinked ]]; then
399
+ for f in "${KEYSEAL_LEGACY_INSTALL_SET[@]}"; do rm -f -- "${libexec:?}/$f"; done
400
+ sync -- "$libexec"
401
+ keyseal_seam_point migrate-cleanup
402
+ else
403
+ echo "provision: legacy loader files kept: slot(s)$unlinked have no ${KEYSEAL_SLOT_LINK_PREFIX}<s> symlink yet; provision.sh --refresh-loader for each removes them" >&2
404
+ fi
405
+ fi
406
+
407
+ # A version directory goes only when no slot symlink references it; staging leftovers go.
408
+ declare -A referenced=()
409
+ for l in "$libexec/$KEYSEAL_SLOT_LINK_PREFIX"*; do
410
+ [[ -L $l ]] && referenced[$(readlink -- "$l")]=1
411
+ done
412
+ for d in "$libexec/$KEYSEAL_VERSION_PREFIX"* "$libexec"/.v-staging.*; do
413
+ [[ -d $d && ! -L $d ]] || continue
414
+ [[ -n ${referenced[${d##*/}]-} ]] || rm -rf -- "$d"
415
+ done
416
+
417
+ keyseal_chain_check "$slot" "$here" || keyseal_die "chain check failed before loading any key:"$'\n'"$KEYSEAL_CHAIN_PROBLEMS"
418
+ [[ $KEYSEAL_LOADER_STATE == current && $KEYSEAL_CHAIN_LEGACY == no ]] ||
419
+ keyseal_die "loader not current after install ($KEYSEAL_LOADER_STATE)"
420
+ exec {layout_fd}>&-
421
+
422
+ # ---------------------------------------------------------------------------
423
+ # step 4: generate and commit
424
+ # ---------------------------------------------------------------------------
425
+ if [[ $generate == sealed ]]; then
426
+ rm -f -- "$cred_s" "$pub_s"
427
+ # pipefail: a keygen that fails after printing a whole PEM still fails this step (KS-4).
428
+ node "$here/keygen.mjs" --pub-out "$pub_s" \
429
+ | "$CREDS" --user encrypt --name="$(keyseal_cred_name "$slot")" - "$cred_s" \
430
+ || keyseal_die "keygen_or_encrypt_failed; the staging files are deleted by the next run"
431
+ keyseal_seam_point keygen
432
+ keyseal_seam_point encrypt
433
+ sync -- "$cred_s" "$pub_s"
434
+ mv -f -- "$cred_s" "$cred"
435
+ sync -- "$K"
436
+ keyseal_seam_point cred-rename
437
+ mv -f -- "$pub_s" "$pub"
438
+ sync -- "$K"
439
+ keyseal_seam_point pub-rename
440
+ if has "$optin" || has "$key"; then
441
+ # An unsealed slot rotated into sealed mode: its old key goes, opt-in first.
442
+ rm -f -- "$optin"
443
+ sync -- "$K"
444
+ keyseal_seam_point optin-remove
445
+ rm -f -- "$key"
446
+ sync -- "$K"
447
+ keyseal_seam_point key-remove
448
+ fi
449
+ elif [[ $generate == unsealed ]]; then
450
+ rm -f -- "$pub_s" "$optin_s"
451
+ # The opt-in path: the PEM goes into <s>.key, 0600 (umask 077), never over an existing file.
452
+ (set -C && node "$here/keygen.mjs" --pub-out "$pub_s" >"$key") ||
453
+ keyseal_die "keygen_failed; the uncommitted key is deleted by the next run"
454
+ keyseal_seam_point keygen
455
+ sync -- "$key" "$pub_s"
456
+ mid=$(keyseal_machine_id) || keyseal_die "machine_id_unreadable: $MACHINE_ID_FILE"
457
+ {
458
+ printf 'version=1\n'
459
+ printf 'machine_id=%s\n' "$mid"
460
+ printf 'uid=%s\n' "$(keyseal_uid)"
461
+ printf 'slot=%s\n' "$slot"
462
+ printf 'pub_sha256=%s\n' "$(keyseal_sha256_file "$pub_s")"
463
+ printf 'granted_at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
464
+ } >"$optin_s"
465
+ sync -- "$optin_s"
466
+ mv -f -- "$optin_s" "$optin"
467
+ sync -- "$K"
468
+ keyseal_seam_point optin-rename
469
+ # BEFORE the pin's rename (the commit), re-read both halves under the loader's own predicate:
470
+ # the opt-in against the staged pin, the key's public key against it. A failure is still an
471
+ # uncommitted generation, nothing was printed, so it is deleted now (review round 1, B1).
472
+ kp=$(keyseal_keyfile_pub "$key") || kp=
473
+ if ! problems=$(keyseal_optin_verify "$optin" "$pub_s" "$slot" "$(keyseal_uid)" "$mid") ||
474
+ [[ $kp != "$(keyseal_read_pin "$pub_s" || true)" ]]; then
475
+ rm -f -- "$key" "$optin" "$pub_s"
476
+ sync -- "$K"
477
+ keyseal_die "slot_inconsistent: the opt-in does not validate against the staged pin (${problems//$'\n'/; }); the uncommitted key is deleted"
478
+ fi
479
+ mv -f -- "$pub_s" "$pub"
480
+ sync -- "$K"
481
+ keyseal_seam_point pub-rename
482
+ echo "provision: slot $slot is UNSEALED by opt-in: $key is a plaintext 0600 key (probe.sh: incapable). status.sh reports it; provision.sh --rotate once this host can seal" >&2
483
+ fi
484
+ pin=$(keyseal_read_pin "$pub") || keyseal_die "slot_inconsistent: no pin after commit"
485
+
486
+ # ---------------------------------------------------------------------------
487
+ # step 5: enable and start; restart on rotate or recovery, reload on a new loader version
488
+ # ---------------------------------------------------------------------------
489
+ "$SYSTEMCTL" --user enable --now "$base_unit" "$load_unit" >/dev/null 2>&1 || true
490
+ for u in "$base_unit" "$load_unit"; do
491
+ [[ $("$SYSTEMCTL" --user is-enabled "$u" 2>/dev/null || true) == enabled ]] || keyseal_die "not_enabled: $u"
492
+ done
493
+ keyseal_seam_point enable
494
+ if [[ $restart == yes ]]; then
495
+ "$SYSTEMCTL" --user restart "$base_unit" >/dev/null 2>&1 || true
496
+ elif [[ $loader_changed == yes ]]; then
497
+ "$SYSTEMCTL" --user restart "$load_unit" >/dev/null 2>&1 || true
498
+ fi
499
+ keyseal_seam_point load
500
+
501
+ # ---------------------------------------------------------------------------
502
+ # step 6: every path ends with the agent holding exactly the pin
503
+ # ---------------------------------------------------------------------------
504
+ # Waits while the loader may still be running; a loader that has finished (active or
505
+ # failed) with the wrong key set is a mismatch now, not in 5 s.
506
+ holds_pin() {
507
+ local i
508
+ for ((i = 0; i < KEYSEAL_SOCK_WAIT_STEPS; i++)); do
509
+ keyseal_agent_holds_exactly "$slot" "$pin" && return 0
510
+ case $("$SYSTEMCTL" --user is-active "$load_unit" 2>/dev/null || true) in
511
+ active | failed) return 1 ;;
512
+ esac
513
+ "$SLEEP" 0.1
514
+ done
515
+ return 1
516
+ }
517
+ if ! holds_pin; then
518
+ "$SYSTEMCTL" --user restart "$base_unit" >/dev/null 2>&1 || true
519
+ holds_pin || keyseal_die "pin_not_loaded: the slot agent does not hold exactly the committed key after one restart"
520
+ fi
521
+
522
+ printf 'agent_socket:%s#%s\n' "$(keyseal_sock_path "$slot")" "$pin"
523
+ if [[ $rotate == yes ]]; then
524
+ cat >&2 <<EOT
525
+ provision: slot $slot rotated. Next, in order:
526
+ 1. re-enroll the new key (owner): it is not yet known to the board;
527
+ 2. re-point the harness at the new reference printed above;
528
+ 3. revoke the old key (owner) once the harness signs with the new one.
529
+ EOT
530
+ fi
@@ -0,0 +1,48 @@
1
+ # shellcheck shell=bash
2
+ # The test seam. An override is honoured only under KEYSEAL_TEST_ROOT, and only when it
3
+ # resolves inside that root; anything else is refused, never silently replaced by the
4
+ # default, so a stray variable in a real environment cannot redirect a binary.
5
+
6
+ # 0 when the seam is active (a test run), else 1. Scripts stamp their output with it.
7
+ seam_active() { [[ -n ${KEYSEAL_TEST_ROOT-} ]]; }
8
+
9
+ # seam_resolve <VAR> <default>: print the value of $VAR when it is set and confined to
10
+ # KEYSEAL_TEST_ROOT, else <default> when $VAR is unset or empty; otherwise print
11
+ # seam_override_refused to stderr and return 1.
12
+ seam_resolve() {
13
+ local var=$1 default=$2 override root real
14
+ override=${!var-}
15
+ if [[ -z $override ]]; then
16
+ printf '%s' "$default"
17
+ return 0
18
+ fi
19
+ if ! seam_active; then
20
+ printf 'seam_override_refused: %s is set without KEYSEAL_TEST_ROOT\n' "$var" >&2
21
+ return 1
22
+ fi
23
+ # realpath only on this branch: the production path never depends on it.
24
+ root=$(/usr/bin/realpath -m -- "$KEYSEAL_TEST_ROOT") || root=
25
+ real=$(/usr/bin/realpath -m -- "$override") || real=
26
+ if [[ -z $root || $root == / || -z $real || $real/ != "$root"/* ]]; then
27
+ printf 'seam_override_refused: %s is outside KEYSEAL_TEST_ROOT\n' "$var" >&2
28
+ return 1
29
+ fi
30
+ printf '%s' "$override"
31
+ }
32
+
33
+ # seam_pause <point> <sleep-binary>: under KEYSEAL_TEST_ROOT only, when KEYSEAL_TEST_FAIL_AT is
34
+ # <point> and KEYSEAL_TEST_FAIL_MODE is `pause`, create $KEYSEAL_TEST_ROOT/paused.<point> and wait
35
+ # (at most 60 s) for $KEYSEAL_TEST_ROOT/resume. The loader's only seam: it never moves, writes or
36
+ # deletes anything else, so the TEST does whatever the pause is for. A no-op in production.
37
+ seam_pause() {
38
+ seam_active || return 0
39
+ [[ ${KEYSEAL_TEST_FAIL_AT-} == "$1" && ${KEYSEAL_TEST_FAIL_MODE-} == pause ]] || return 0
40
+ : >"$KEYSEAL_TEST_ROOT/paused.$1"
41
+ local i
42
+ for ((i = 0; i < 1200; i++)); do
43
+ [[ -e $KEYSEAL_TEST_ROOT/resume ]] && return 0
44
+ "$2" 0.05
45
+ done
46
+ printf 'seam_pause_timeout %s\n' "$1" >&2
47
+ return 1
48
+ }