@consiliency/agent-board-cli 1.24.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/dist/assets.d.ts +22 -0
- package/dist/assets.js +61 -0
- package/dist/assets.json +6 -0
- package/dist/cli.d.ts +37 -3
- package/dist/cli.js +68 -26
- package/dist/config_edit.d.ts +37 -0
- package/dist/config_edit.js +549 -0
- package/dist/doctor.d.ts +7 -1
- package/dist/doctor.js +144 -2
- package/dist/identity_writer.d.ts +9 -7
- package/dist/identity_writer.js +18 -30
- package/dist/setup.d.ts +116 -0
- package/dist/setup.js +603 -0
- package/dist/setup_storage.d.ts +117 -0
- package/dist/setup_storage.js +447 -0
- package/dist/setup_writers.d.ts +128 -0
- package/dist/setup_writers.js +653 -0
- package/keyslot/agent-board-keyslot-load@.service +20 -0
- package/keyslot/agent-board-keyslot@.service +20 -0
- package/keyslot/constants.sh +73 -0
- package/keyslot/keygen.mjs +67 -0
- package/keyslot/keyslot-load.sh +78 -0
- package/keyslot/lib.sh +599 -0
- package/keyslot/optin.sh +185 -0
- package/keyslot/probe.sh +135 -0
- package/keyslot/provision.sh +530 -0
- package/keyslot/seam.sh +48 -0
- package/keyslot/status.sh +254 -0
- package/package.json +8 -4
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
|
+
}
|