@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/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 +46 -3
- package/dist/doctor.js +242 -7
- package/dist/doctor_scopes.d.ts +162 -2
- package/dist/doctor_scopes.js +794 -16
- 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
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Installed to ~/.config/systemd/user/ by scripts/keyslot/provision.sh (IF-0-KEYSEAL-2).
|
|
2
|
+
# Its directive set is dev0's hand-installed unit (M7), so an existing hand install is
|
|
3
|
+
# adopted by directive-set equality rather than rewritten.
|
|
4
|
+
[Unit]
|
|
5
|
+
Description=Message Board key slot for %i (dedicated, non-forwarded ssh-agent; starts empty)
|
|
6
|
+
Documentation=https://github.com/Consiliency/message-board
|
|
7
|
+
|
|
8
|
+
[Service]
|
|
9
|
+
Type=simple
|
|
10
|
+
UMask=0077
|
|
11
|
+
# Shared parent dir is created, never removed: RuntimeDirectory= would be deleted
|
|
12
|
+
# when ANY instance stops, pulling the sockets out from under the other instances.
|
|
13
|
+
ExecStartPre=/bin/mkdir -p -m 0700 %t/agent-board
|
|
14
|
+
ExecStartPre=/bin/rm -f %t/agent-board/%i.sock
|
|
15
|
+
ExecStart=/usr/bin/ssh-agent -D -a %t/agent-board/%i.sock
|
|
16
|
+
Restart=on-failure
|
|
17
|
+
RestartSec=5
|
|
18
|
+
|
|
19
|
+
[Install]
|
|
20
|
+
WantedBy=default.target
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# shellcheck shell=bash
|
|
2
|
+
# KEYSEAL constants (IF-0-KEYSEAL-1/-2). Sourced by keyslot-load.sh and by every
|
|
3
|
+
# scripts/keyslot tool; a value here is never restated anywhere else.
|
|
4
|
+
|
|
5
|
+
# The slot name: IF-0-KEYSEAL-1.
|
|
6
|
+
# shellcheck disable=SC2034
|
|
7
|
+
KEYSEAL_SLOT_RE='^[a-z][a-z0-9-]{0,31}$'
|
|
8
|
+
|
|
9
|
+
# Unit template names, without "@.service".
|
|
10
|
+
# shellcheck disable=SC2034
|
|
11
|
+
KEYSEAL_BASE_UNIT=agent-board-keyslot
|
|
12
|
+
# shellcheck disable=SC2034
|
|
13
|
+
KEYSEAL_LOAD_UNIT=agent-board-keyslot-load
|
|
14
|
+
|
|
15
|
+
# The installed copies of the loader and its siblings, relative to $HOME, at 0755 (A1). Each
|
|
16
|
+
# version lives in an immutable directory v-<first 16 hex of the set sha256>; slot <s> runs
|
|
17
|
+
# the version its symlink slot-<s> points at, recorded in <s>.loader-sha256 beside the slot.
|
|
18
|
+
# shellcheck disable=SC2034
|
|
19
|
+
KEYSEAL_LIBEXEC_REL=.local/libexec/agent-board
|
|
20
|
+
# Sorted by name: a version's sha is over its files in name order (keyseal_files_sha).
|
|
21
|
+
# shellcheck disable=SC2034
|
|
22
|
+
KEYSEAL_INSTALL_SET=(constants.sh keyslot-load.sh optin.sh seam.sh)
|
|
23
|
+
# shellcheck disable=SC2034
|
|
24
|
+
KEYSEAL_VERSION_PREFIX=v-
|
|
25
|
+
# shellcheck disable=SC2034
|
|
26
|
+
KEYSEAL_SLOT_LINK_PREFIX=slot-
|
|
27
|
+
# shellcheck disable=SC2034
|
|
28
|
+
KEYSEAL_VERSION_HEX=16
|
|
29
|
+
|
|
30
|
+
# The loader's binaries, by absolute path: no PATH assumption. Each has a seam override
|
|
31
|
+
# named KEYSEAL_TEST_<NAME> (seam.sh), honoured only under KEYSEAL_TEST_ROOT.
|
|
32
|
+
# shellcheck disable=SC2034
|
|
33
|
+
KEYSEAL_BIN_SYSTEMD_CREDS=/usr/bin/systemd-creds
|
|
34
|
+
# shellcheck disable=SC2034
|
|
35
|
+
KEYSEAL_BIN_SSH_ADD=/usr/bin/ssh-add
|
|
36
|
+
# shellcheck disable=SC2034
|
|
37
|
+
KEYSEAL_BIN_SLEEP=/usr/bin/sleep
|
|
38
|
+
# shellcheck disable=SC2034
|
|
39
|
+
KEYSEAL_LOADER_BINS=("$KEYSEAL_BIN_SYSTEMD_CREDS" "$KEYSEAL_BIN_SSH_ADD" "$KEYSEAL_BIN_SLEEP")
|
|
40
|
+
# The unsealed branch's checks (optin.sh), by absolute path too: coreutils and ssh-keygen.
|
|
41
|
+
# shellcheck disable=SC2034
|
|
42
|
+
KEYSEAL_BIN_STAT=/usr/bin/stat
|
|
43
|
+
# shellcheck disable=SC2034
|
|
44
|
+
KEYSEAL_BIN_SHA256SUM=/usr/bin/sha256sum
|
|
45
|
+
# shellcheck disable=SC2034
|
|
46
|
+
KEYSEAL_BIN_ID=/usr/bin/id
|
|
47
|
+
# shellcheck disable=SC2034
|
|
48
|
+
KEYSEAL_BIN_BASE64=/usr/bin/base64
|
|
49
|
+
# shellcheck disable=SC2034
|
|
50
|
+
KEYSEAL_BIN_TAIL=/usr/bin/tail
|
|
51
|
+
# shellcheck disable=SC2034
|
|
52
|
+
KEYSEAL_BIN_SSH_KEYGEN=/usr/bin/ssh-keygen
|
|
53
|
+
# The host identity an opt-in binds to (sha256 of it, first 16 hex). Seam override
|
|
54
|
+
# KEYSEAL_TEST_MACHINE_ID_FILE, under KEYSEAL_TEST_ROOT only.
|
|
55
|
+
# shellcheck disable=SC2034
|
|
56
|
+
KEYSEAL_MACHINE_ID_FILE=/etc/machine-id
|
|
57
|
+
|
|
58
|
+
# How long the loader waits for the slot socket: 50 x 0.1 s = 5 s.
|
|
59
|
+
# shellcheck disable=SC2034
|
|
60
|
+
KEYSEAL_SOCK_WAIT_STEPS=50
|
|
61
|
+
|
|
62
|
+
# The sealed credential's name for slot $1.
|
|
63
|
+
keyseal_cred_name() { printf 'agent-board-key-%s' "$1"; }
|
|
64
|
+
|
|
65
|
+
# The slot agent's socket for slot $1.
|
|
66
|
+
keyseal_sock_path() { printf '%s/agent-board/%s.sock' "$XDG_RUNTIME_DIR" "$1"; }
|
|
67
|
+
|
|
68
|
+
# K: the slot directory. XDG_CONFIG_HOME if set, else $HOME/.config, which is what the user
|
|
69
|
+
# manager resolves when (as on dev0) it exports HOME but no XDG_CONFIG_HOME.
|
|
70
|
+
keyseal_key_dir() { printf '%s/agent-board/keyslot' "${XDG_CONFIG_HOME:-$HOME/.config}"; }
|
|
71
|
+
|
|
72
|
+
# The unsealed key of slot $1 (A1): a 0600 file, written only on the opt-in path.
|
|
73
|
+
keyseal_unsealed_key_path() { printf '%s/%s.key' "$(keyseal_key_dir)" "$1"; }
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// keygen.mjs --pub-out <path>: generate one Ed25519 key in memory (IF-0-KEYSEAL-1 step 4).
|
|
3
|
+
//
|
|
4
|
+
// The private key goes to STDOUT ONLY, which provision.sh pipes straight into
|
|
5
|
+
// `systemd-creds --user encrypt`. It is an OpenSSH `openssh-key-v1` container with cipher
|
|
6
|
+
// none, not PKCS#8: OpenSSH 9.6p1 (Ubuntu 24.04) rejects an Ed25519 PKCS#8 PEM with
|
|
7
|
+
// `invalid format`, in `ssh-add -` and `ssh-keygen -y` alike, while every OpenSSH since 6.5
|
|
8
|
+
// reads this container. Node exports no OpenSSH private format, so it is written here, per
|
|
9
|
+
// PROTOCOL.key. The one filesystem write is the raw public key, as 43-char
|
|
10
|
+
// base64url, to --pub-out (created 0600, never overwritten). A static test pins that this is
|
|
11
|
+
// the only write and that nothing but node:crypto and node:fs is imported (KS-S1).
|
|
12
|
+
//
|
|
13
|
+
// KEYSEAL_TEST_FAIL_AT=keygen, honoured only under KEYSEAL_TEST_ROOT and only in the `fail`
|
|
14
|
+
// mode, exits 1 AFTER the full PEM and the pub are written (KS-4): the pipefail pin.
|
|
15
|
+
|
|
16
|
+
import { generateKeyPairSync, randomBytes } from "node:crypto";
|
|
17
|
+
import { writeFileSync } from "node:fs";
|
|
18
|
+
|
|
19
|
+
const at = process.argv.indexOf("--pub-out");
|
|
20
|
+
const pubOut = at > 0 ? process.argv[at + 1] : undefined;
|
|
21
|
+
if (!pubOut || process.argv.length !== 4) {
|
|
22
|
+
process.stderr.write("usage: keygen.mjs --pub-out <path>\n");
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const { privateKey } = generateKeyPairSync("ed25519");
|
|
27
|
+
const jwk = privateKey.export({ format: "jwk" });
|
|
28
|
+
writeFileSync(pubOut, `${jwk.x}\n`, { mode: 0o600, flag: "wx" });
|
|
29
|
+
|
|
30
|
+
function sshString(b) {
|
|
31
|
+
const buf = Buffer.isBuffer(b) ? b : Buffer.from(b);
|
|
32
|
+
const len = Buffer.alloc(4);
|
|
33
|
+
len.writeUInt32BE(buf.length);
|
|
34
|
+
return Buffer.concat([len, buf]);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function opensshPem() {
|
|
38
|
+
const pub = Buffer.from(jwk.x, "base64url");
|
|
39
|
+
const seed = Buffer.from(jwk.d, "base64url");
|
|
40
|
+
const nkeys = Buffer.alloc(4);
|
|
41
|
+
nkeys.writeUInt32BE(1);
|
|
42
|
+
const check = randomBytes(4);
|
|
43
|
+
const wire = Buffer.concat([sshString("ssh-ed25519"), sshString(pub)]);
|
|
44
|
+
let priv = Buffer.concat([check, check, wire, sshString(Buffer.concat([seed, pub])), sshString("")]);
|
|
45
|
+
const pad = [];
|
|
46
|
+
for (let i = 1; (priv.length + pad.length) % 8 !== 0; i++) pad.push(i);
|
|
47
|
+
priv = Buffer.concat([priv, Buffer.from(pad)]);
|
|
48
|
+
const body = Buffer.concat([
|
|
49
|
+
Buffer.from("openssh-key-v1\0"),
|
|
50
|
+
sshString("none"),
|
|
51
|
+
sshString("none"),
|
|
52
|
+
sshString(""),
|
|
53
|
+
nkeys,
|
|
54
|
+
sshString(wire),
|
|
55
|
+
sshString(priv),
|
|
56
|
+
]);
|
|
57
|
+
const b64 = body.toString("base64").match(/.{1,70}/g).join("\n");
|
|
58
|
+
return `-----BEGIN OPENSSH PRIVATE KEY-----\n${b64}\n-----END OPENSSH PRIVATE KEY-----\n`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const failHere =
|
|
62
|
+
Boolean(process.env.KEYSEAL_TEST_ROOT) &&
|
|
63
|
+
process.env.KEYSEAL_TEST_FAIL_AT === "keygen" &&
|
|
64
|
+
(process.env.KEYSEAL_TEST_FAIL_MODE ?? "fail") === "fail";
|
|
65
|
+
process.stdout.write(opensshPem(), () => {
|
|
66
|
+
process.exit(failHere ? 1 : 0);
|
|
67
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# keyslot-load.sh <slot>: load the key of <slot> into its slot agent.
|
|
3
|
+
#
|
|
4
|
+
# Run by agent-board-keyslot-load@<slot>.service from ~/.local/libexec/agent-board/slot-<slot>/,
|
|
5
|
+
# a symlink to the immutable version directory that slot runs (A1), so it sources its siblings
|
|
6
|
+
# by its own directory and works with the checkout gone. One loader, two modes:
|
|
7
|
+
# sealed <slot>.cred: decrypted into a pipe to ssh-add; it never exists as a file
|
|
8
|
+
# (IF-0-KEYSEAL-2).
|
|
9
|
+
# unsealed <slot>.key (A1, opt-in only): streamed into ssh-add ONLY when optin.sh's
|
|
10
|
+
# predicate holds, the same one status.sh uses: a valid opt-in for this machine,
|
|
11
|
+
# user, slot and pin; the key 0600 and this user's; its public key the pin. The
|
|
12
|
+
# loader runs at every boot and status does not, so the refusal lives here. This
|
|
13
|
+
# branch never resolves systemd-creds, so it runs on a host that has none.
|
|
14
|
+
# Both present is a contradiction, refused.
|
|
15
|
+
set -euo pipefail
|
|
16
|
+
|
|
17
|
+
if [[ ${BASH_SOURCE[0]} == */* ]]; then here=${BASH_SOURCE[0]%/*}; else here=.; fi
|
|
18
|
+
. "$here/constants.sh"
|
|
19
|
+
. "$here/seam.sh"
|
|
20
|
+
. "$here/optin.sh"
|
|
21
|
+
|
|
22
|
+
slot=${1-}
|
|
23
|
+
if [[ ! $slot =~ $KEYSEAL_SLOT_RE ]]; then
|
|
24
|
+
echo "keyslot-load: bad_slot" >&2
|
|
25
|
+
exit 1
|
|
26
|
+
fi
|
|
27
|
+
: "${XDG_RUNTIME_DIR:?keyslot-load: XDG_RUNTIME_DIR unset}"
|
|
28
|
+
: "${HOME:?keyslot-load: HOME unset}"
|
|
29
|
+
|
|
30
|
+
ssh_add=$(seam_resolve KEYSEAL_TEST_SSH_ADD "$KEYSEAL_BIN_SSH_ADD")
|
|
31
|
+
sleep=$(seam_resolve KEYSEAL_TEST_SLEEP "$KEYSEAL_BIN_SLEEP")
|
|
32
|
+
|
|
33
|
+
# Set, never inherited: the manager exports an ambient SSH_AUTH_SOCK (M7), and the unit's
|
|
34
|
+
# Environment= is only as good as the unit file.
|
|
35
|
+
SSH_AUTH_SOCK=$(keyseal_sock_path "$slot")
|
|
36
|
+
export SSH_AUTH_SOCK
|
|
37
|
+
|
|
38
|
+
for ((i = 0; i < KEYSEAL_SOCK_WAIT_STEPS; i++)); do
|
|
39
|
+
[[ -S $SSH_AUTH_SOCK ]] && break
|
|
40
|
+
"$sleep" 0.1
|
|
41
|
+
done
|
|
42
|
+
if [[ ! -S $SSH_AUTH_SOCK ]]; then
|
|
43
|
+
echo "keyslot-load: socket_absent" >&2
|
|
44
|
+
exit 1
|
|
45
|
+
fi
|
|
46
|
+
|
|
47
|
+
cred=$(keyseal_key_dir)/$slot.cred
|
|
48
|
+
key=$(keyseal_unsealed_key_path "$slot")
|
|
49
|
+
if [[ -e $key || -L $key ]]; then
|
|
50
|
+
if [[ -e $cred || -L $cred ]]; then
|
|
51
|
+
echo "keyslot-load: slot_contradiction (both $slot.cred and $slot.key)" >&2
|
|
52
|
+
exit 1
|
|
53
|
+
fi
|
|
54
|
+
mid_file=$(seam_resolve KEYSEAL_TEST_MACHINE_ID_FILE "$KEYSEAL_MACHINE_ID_FILE")
|
|
55
|
+
mid=$(keyseal_machine_id_of "$mid_file") || mid=unreadable
|
|
56
|
+
# On success the predicate prints the pin it verified: that is the value compared below.
|
|
57
|
+
if ! pin=$(keyseal_unsealed_verify "$slot" "$("$KEYSEAL_BIN_ID" -u)" "$mid"); then
|
|
58
|
+
printf 'keyslot-load: unsealed_key_refused: %s\n' "${pin//$'\n'/; }" >&2
|
|
59
|
+
exit 1
|
|
60
|
+
fi
|
|
61
|
+
seam_pause verified "$sleep" || exit 1
|
|
62
|
+
# ssh-add is fed the descriptor that is checked here, never a re-open of the path: open it
|
|
63
|
+
# under principle 4, derive its public key through it, compare with the pin, load from it.
|
|
64
|
+
keyseal_trusted_open "$key" 65536 >&2 || { echo "keyslot-load: unsealed_key_refused" >&2; exit 1; }
|
|
65
|
+
kp=$(keyseal_fd_pub "$KEYSEAL_FD" "$slot.key") || kp=
|
|
66
|
+
if [[ -z $pin || $kp != "$pin" ]]; then
|
|
67
|
+
echo "keyslot-load: unsealed_key_refused: the opened key is not the pin" >&2
|
|
68
|
+
exit 1
|
|
69
|
+
fi
|
|
70
|
+
seam_pause opened "$sleep" || exit 1
|
|
71
|
+
SSH_AUTH_SOCK=$SSH_AUTH_SOCK "$ssh_add" - <&"$KEYSEAL_FD"
|
|
72
|
+
exit
|
|
73
|
+
fi
|
|
74
|
+
|
|
75
|
+
creds=$(seam_resolve KEYSEAL_TEST_SYSTEMD_CREDS "$KEYSEAL_BIN_SYSTEMD_CREDS")
|
|
76
|
+
# pipefail: a decrypt that fails after printing still fails the load.
|
|
77
|
+
"$creds" --user decrypt --name="$(keyseal_cred_name "$slot")" "$cred" - \
|
|
78
|
+
| "$ssh_add" -
|