@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
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { type OsKeychainProbeResult } from "@consiliency/agent-board-client";
|
|
2
|
+
export type KeyStorage = "os_keychain" | "sealed" | "unsealed" | "external" | "refused";
|
|
3
|
+
export interface KeyStorageSelection {
|
|
4
|
+
storage: KeyStorage;
|
|
5
|
+
/** An authored code: never a path or any other file string. */
|
|
6
|
+
reason: string;
|
|
7
|
+
/** For a refusal: what the user runs or changes. */
|
|
8
|
+
fix?: string;
|
|
9
|
+
/**
|
|
10
|
+
* The reference enrollment consumes: provision.sh's `agent_socket:` line for sealed/unsealed, the
|
|
11
|
+
* user's own `--agent-socket` for external. Absent for os_keychain: the caller names the handle.
|
|
12
|
+
*/
|
|
13
|
+
keyReference?: string;
|
|
14
|
+
/** The keyslot the reference names (sealed/unsealed). */
|
|
15
|
+
slot?: string;
|
|
16
|
+
/** The #536 repairs performed, by name, in order. */
|
|
17
|
+
repairs: string[];
|
|
18
|
+
}
|
|
19
|
+
export interface RunOutcome {
|
|
20
|
+
/** null when the child did not exit on its own (killed, timed out, not spawned). */
|
|
21
|
+
code: number | null;
|
|
22
|
+
stdout: string;
|
|
23
|
+
stderr: string;
|
|
24
|
+
}
|
|
25
|
+
export type StorageRunner = (file: string, args: readonly string[], options: {
|
|
26
|
+
env: NodeJS.ProcessEnv;
|
|
27
|
+
}) => Promise<RunOutcome>;
|
|
28
|
+
/** The `deps` step's result, as far as a refusal needs it. */
|
|
29
|
+
export interface DepsOutcome {
|
|
30
|
+
status: "done" | "already" | "skipped" | "failed" | "undetermined";
|
|
31
|
+
reason: string;
|
|
32
|
+
/** The exact install command, when `deps` did not install the keychain tool. */
|
|
33
|
+
installCommand?: string;
|
|
34
|
+
}
|
|
35
|
+
export interface KeyStorageContext {
|
|
36
|
+
harness: string;
|
|
37
|
+
/** Default: the harness. */
|
|
38
|
+
slot?: string;
|
|
39
|
+
/** True ONLY when the user passed `--allow-unsealed-key`. */
|
|
40
|
+
allowUnsealedKey?: boolean;
|
|
41
|
+
/** The user's `--agent-socket <ref>`. */
|
|
42
|
+
agentSocket?: string;
|
|
43
|
+
env?: NodeJS.ProcessEnv;
|
|
44
|
+
platform?: NodeJS.Platform;
|
|
45
|
+
/** Default `process.getuid()`. */
|
|
46
|
+
uid?: number;
|
|
47
|
+
deps?: DepsOutcome;
|
|
48
|
+
probeKeychain?: () => Promise<OsKeychainProbeResult>;
|
|
49
|
+
run?: StorageRunner;
|
|
50
|
+
/** Default `resolveKeyslotDir()`. */
|
|
51
|
+
keyslotDir?: string;
|
|
52
|
+
/** Default `/etc/machine-id`. */
|
|
53
|
+
machineIdFile?: string;
|
|
54
|
+
bash?: string;
|
|
55
|
+
systemctl?: string;
|
|
56
|
+
}
|
|
57
|
+
export declare const SLOT_RE: RegExp;
|
|
58
|
+
export declare const defaultStorageRunner: StorageRunner;
|
|
59
|
+
/** The opt-in record's host comparand: sha256 of the whole machine-id file, first 16 hex (`sha256sum <`). */
|
|
60
|
+
export declare function machineIdOf(file?: string): string | null;
|
|
61
|
+
/** `$XDG_RUNTIME_DIR/agent-board`, where each keyslot's agent socket lives (constants.sh). */
|
|
62
|
+
export declare function keyslotSocketDir(env: NodeJS.ProcessEnv): string | null;
|
|
63
|
+
/** `<configRoot>/agent-board/keyslot`, the slot directory (constants.sh `keyseal_key_dir`). */
|
|
64
|
+
export declare function keyslotKeyDir(env: NodeJS.ProcessEnv): string | null;
|
|
65
|
+
/** `<configRoot>/agent-board/external-agent` (IF-0-KEYSEAL-6). */
|
|
66
|
+
export declare function externalAgentDeclarationPath(env: NodeJS.ProcessEnv): string | null;
|
|
67
|
+
/**
|
|
68
|
+
* The socket an `agent_socket:` handle signs through, resolved exactly as the client's backend does
|
|
69
|
+
* (`keystore_agent_socket.ts` `resolveSocketPath`): a non-empty `MESSAGE_BOARD_AGENT_SOCKET_PATH`
|
|
70
|
+
* overrides the handle; the handle's part before `#` otherwise, with the sentinels meaning
|
|
71
|
+
* `$SSH_AUTH_SOCK`. null when nothing names a socket.
|
|
72
|
+
*/
|
|
73
|
+
export declare function resolveAgentSocketPath(handle: string, env: NodeJS.ProcessEnv): string | null;
|
|
74
|
+
/** Device and inode of what a path resolves to; null when it does not resolve. */
|
|
75
|
+
export declare function fileIdentity(path: string): {
|
|
76
|
+
dev: number;
|
|
77
|
+
ino: number;
|
|
78
|
+
} | null;
|
|
79
|
+
/**
|
|
80
|
+
* Principle 4 on a directory and every directory above it: a real directory (never a symlink),
|
|
81
|
+
* owned by `uid` or root, not group/other-writable unless root-owned and sticky. One problem per line.
|
|
82
|
+
*/
|
|
83
|
+
export declare function untrustedAncestors(dir: string, uid: number): string[];
|
|
84
|
+
export interface OwnerOnlyRecord {
|
|
85
|
+
values: Record<string, string>;
|
|
86
|
+
/** Empty only when the file opened under principle 4 and holds exactly `fields`, once each. */
|
|
87
|
+
problems: string[];
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* A `KEY=value` record opened as the keyslot scripts open the opt-in (optin.sh): no symlink, a
|
|
91
|
+
* regular file, the same inode after the open, this uid's, no group/other bit, bounded size, every
|
|
92
|
+
* ancestor trusted; then exactly the listed fields, none unknown, none repeated.
|
|
93
|
+
*/
|
|
94
|
+
export declare function readOwnerOnlyRecord(path: string, fields: readonly string[], uid: number, maxBytes?: number): OwnerOnlyRecord;
|
|
95
|
+
export declare const OPTIN_FIELDS: readonly ["version", "machine_id", "uid", "slot", "pub_sha256", "granted_at"];
|
|
96
|
+
/**
|
|
97
|
+
* The unsealed opt-in `<K>/<slot>.unsealed-optin`, field by field against this host, user, slot and
|
|
98
|
+
* pin (IF-0-KEYSEAL-6 "Opt-in record"). Empty = valid.
|
|
99
|
+
*/
|
|
100
|
+
export declare function unsealedOptinProblems(env: NodeJS.ProcessEnv, slot: string, uid: number, machineId: string | null): string[];
|
|
101
|
+
export declare const EXTERNAL_FIELDS: readonly ["machine_id", "uid", "socket"];
|
|
102
|
+
/**
|
|
103
|
+
* The external-agent declaration against the socket actually used: this machine, this user, and a
|
|
104
|
+
* canonical `socket=` (absolute, its own realpath) that is the same device and inode. Empty = valid.
|
|
105
|
+
*/
|
|
106
|
+
export declare function externalDeclarationProblems(env: NodeJS.ProcessEnv, socketPath: string, uid: number, machineId: string | null): string[];
|
|
107
|
+
/** The #536 repair, then the full ancestor walk. Returns the repairs, or the problems. */
|
|
108
|
+
export declare function repairKeyDir(env: NodeJS.ProcessEnv, uid: number): {
|
|
109
|
+
repairs: string[];
|
|
110
|
+
problems: string[];
|
|
111
|
+
root: string | null;
|
|
112
|
+
};
|
|
113
|
+
/**
|
|
114
|
+
* IF-0-SETUP-3: the key storage, measured, in the IF-0-KEYSEAL-6 order. Never throws for a host
|
|
115
|
+
* condition; a refusal carries the fix.
|
|
116
|
+
*/
|
|
117
|
+
export declare function selectKeyStorage(ctx: KeyStorageContext): Promise<KeyStorageSelection>;
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
// SETUP IF-0-SETUP-3 (cli half) — which key storage `setup` uses, following the IF-0-KEYSEAL-6 table
|
|
2
|
+
// in its order:
|
|
3
|
+
//
|
|
4
|
+
// 1. the client's `probeOsKeychainRoundTrip()`: store, read back byte-equal, delete a throwaway;
|
|
5
|
+
// 2. setup's own gate: platform linux, `systemctl --user` reachable, bash >= 4 (probe.sh cannot say
|
|
6
|
+
// "no user systemd", and bash 3.2 fails on its `declare -A`), so a host with no user systemd is
|
|
7
|
+
// never offered a key slot;
|
|
8
|
+
// 3. the packaged `provision.sh`, which selects sealed or unsealed. `--allow-unsealed-key` is passed
|
|
9
|
+
// only when the user gave it; the mode is then MEASURED with `status.sh --json`, never inferred
|
|
10
|
+
// from what provision was asked;
|
|
11
|
+
// 4. `external`, only with `--agent-socket <ref>` and a declaration naming that socket;
|
|
12
|
+
// 5. otherwise refused, naming the fix.
|
|
13
|
+
//
|
|
14
|
+
// Before provision runs, the key directory's ancestors are made safe as a named step (#536): a fresh
|
|
15
|
+
// user under a user-private group gets a 0775 `~/.config`, which the unsealed opt-in's principle-4
|
|
16
|
+
// walk refuses. Only a directory the user owns is repaired; anything else is `unsafe_key_dir`, and
|
|
17
|
+
// provision is never reached with an unsafe directory.
|
|
18
|
+
//
|
|
19
|
+
// Every `reason` is an authored code, never file content; the human fix is in `fix`. The same
|
|
20
|
+
// module holds the owner-only KEY=value record reader that doctor's `key_storage` uses for the
|
|
21
|
+
// unsealed opt-in and the external-agent declaration (IF-0-KEYSEAL-6: "checked like the opt-in").
|
|
22
|
+
import { execFile } from "node:child_process";
|
|
23
|
+
import { createHash } from "node:crypto";
|
|
24
|
+
import { chmodSync, closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
25
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
26
|
+
import { probeOsKeychainRoundTrip } from "@consiliency/agent-board-client";
|
|
27
|
+
import { resolveKeyslotDir } from "./assets.js";
|
|
28
|
+
export const SLOT_RE = /^[a-z][a-z0-9-]{0,31}$/;
|
|
29
|
+
const REFERENCE_LINE = /^agent_socket:\/\S+#[A-Za-z0-9_-]{43}$/;
|
|
30
|
+
/** States in which `systemctl --user` has a manager answering. */
|
|
31
|
+
const MANAGER_REACHABLE = new Set(["initializing", "starting", "running", "degraded", "maintenance", "stopping"]);
|
|
32
|
+
const RUN_TIMEOUT_MS = 180_000;
|
|
33
|
+
export const defaultStorageRunner = (file, args, { env }) => new Promise((resolve) => {
|
|
34
|
+
execFile(file, [...args], { env, timeout: RUN_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024, encoding: "utf8" }, (error, stdout, stderr) => {
|
|
35
|
+
const code = error ? (typeof error.code === "number" && !error.killed ? error.code : null) : 0;
|
|
36
|
+
resolve({ code, stdout: String(stdout ?? ""), stderr: String(stderr ?? "") });
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
const currentUid = () => (typeof process.getuid === "function" ? process.getuid() : -1);
|
|
40
|
+
const configRoot = (env) => {
|
|
41
|
+
const xdg = env.XDG_CONFIG_HOME?.trim();
|
|
42
|
+
if (xdg)
|
|
43
|
+
return xdg;
|
|
44
|
+
const home = env.HOME?.trim();
|
|
45
|
+
return home ? join(home, ".config") : null;
|
|
46
|
+
};
|
|
47
|
+
// ---------------------------------------------------------------------------------------------
|
|
48
|
+
// Shared measurements (doctor's key_storage uses these too)
|
|
49
|
+
// ---------------------------------------------------------------------------------------------
|
|
50
|
+
/** The opt-in record's host comparand: sha256 of the whole machine-id file, first 16 hex (`sha256sum <`). */
|
|
51
|
+
export function machineIdOf(file = "/etc/machine-id") {
|
|
52
|
+
try {
|
|
53
|
+
return createHash("sha256").update(readFileSync(file)).digest("hex").slice(0, 16);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** `$XDG_RUNTIME_DIR/agent-board`, where each keyslot's agent socket lives (constants.sh). */
|
|
60
|
+
export function keyslotSocketDir(env) {
|
|
61
|
+
const runtime = env.XDG_RUNTIME_DIR?.trim();
|
|
62
|
+
return runtime ? join(runtime, "agent-board") : null;
|
|
63
|
+
}
|
|
64
|
+
/** `<configRoot>/agent-board/keyslot`, the slot directory (constants.sh `keyseal_key_dir`). */
|
|
65
|
+
export function keyslotKeyDir(env) {
|
|
66
|
+
const root = configRoot(env);
|
|
67
|
+
return root ? join(root, "agent-board", "keyslot") : null;
|
|
68
|
+
}
|
|
69
|
+
/** `<configRoot>/agent-board/external-agent` (IF-0-KEYSEAL-6). */
|
|
70
|
+
export function externalAgentDeclarationPath(env) {
|
|
71
|
+
const root = configRoot(env);
|
|
72
|
+
return root ? join(root, "agent-board", "external-agent") : null;
|
|
73
|
+
}
|
|
74
|
+
const SOCKET_SENTINELS = new Set(["", "default", "$ssh_auth_sock", "ssh_auth_sock"]);
|
|
75
|
+
/**
|
|
76
|
+
* The socket an `agent_socket:` handle signs through, resolved exactly as the client's backend does
|
|
77
|
+
* (`keystore_agent_socket.ts` `resolveSocketPath`): a non-empty `MESSAGE_BOARD_AGENT_SOCKET_PATH`
|
|
78
|
+
* overrides the handle; the handle's part before `#` otherwise, with the sentinels meaning
|
|
79
|
+
* `$SSH_AUTH_SOCK`. null when nothing names a socket.
|
|
80
|
+
*/
|
|
81
|
+
export function resolveAgentSocketPath(handle, env) {
|
|
82
|
+
const override = (env.MESSAGE_BOARD_AGENT_SOCKET_PATH ?? "").trim();
|
|
83
|
+
const hash = handle.indexOf("#");
|
|
84
|
+
const spec = override !== "" ? override : (hash >= 0 ? handle.slice(0, hash) : handle).trim();
|
|
85
|
+
if (SOCKET_SENTINELS.has(spec.toLowerCase())) {
|
|
86
|
+
const sock = env.SSH_AUTH_SOCK;
|
|
87
|
+
return sock ? sock : null;
|
|
88
|
+
}
|
|
89
|
+
return spec;
|
|
90
|
+
}
|
|
91
|
+
/** Device and inode of what a path resolves to; null when it does not resolve. */
|
|
92
|
+
export function fileIdentity(path) {
|
|
93
|
+
try {
|
|
94
|
+
const stats = statSync(realpathSync(path));
|
|
95
|
+
return { dev: stats.dev, ino: stats.ino };
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const sameFile = (a, b) => a !== null && b !== null && a.dev === b.dev && a.ino === b.ino;
|
|
102
|
+
/**
|
|
103
|
+
* Principle 4 on a directory and every directory above it: a real directory (never a symlink),
|
|
104
|
+
* owned by `uid` or root, not group/other-writable unless root-owned and sticky. One problem per line.
|
|
105
|
+
*/
|
|
106
|
+
export function untrustedAncestors(dir, uid) {
|
|
107
|
+
const problems = [];
|
|
108
|
+
if (!isAbsolute(dir))
|
|
109
|
+
return [`${dir} is not absolute`];
|
|
110
|
+
const parts = dir.split("/").filter(Boolean);
|
|
111
|
+
const paths = ["/", ...parts.map((_, index) => `/${parts.slice(0, index + 1).join("/")}`)];
|
|
112
|
+
for (const path of paths) {
|
|
113
|
+
let stats;
|
|
114
|
+
try {
|
|
115
|
+
stats = lstatSync(path);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
problems.push(`ancestor ${path}: missing`);
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
if (stats.isSymbolicLink()) {
|
|
122
|
+
problems.push(`ancestor ${path} is a symlink`);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (!stats.isDirectory()) {
|
|
126
|
+
problems.push(`ancestor ${path} is not a directory`);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (stats.uid !== uid && stats.uid !== 0)
|
|
130
|
+
problems.push(`ancestor ${path} is owned by uid ${stats.uid}`);
|
|
131
|
+
if ((stats.mode & 0o022) !== 0 && !((stats.mode & 0o1000) !== 0 && stats.uid === 0))
|
|
132
|
+
problems.push(`ancestor ${path} is group/other-writable`);
|
|
133
|
+
}
|
|
134
|
+
return problems;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* A `KEY=value` record opened as the keyslot scripts open the opt-in (optin.sh): no symlink, a
|
|
138
|
+
* regular file, the same inode after the open, this uid's, no group/other bit, bounded size, every
|
|
139
|
+
* ancestor trusted; then exactly the listed fields, none unknown, none repeated.
|
|
140
|
+
*/
|
|
141
|
+
export function readOwnerOnlyRecord(path, fields, uid, maxBytes = 4096) {
|
|
142
|
+
const values = {};
|
|
143
|
+
const problems = untrustedAncestors(dirname(path), uid);
|
|
144
|
+
let before;
|
|
145
|
+
try {
|
|
146
|
+
before = lstatSync(path);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return { values, problems: [...problems, "missing"] };
|
|
150
|
+
}
|
|
151
|
+
if (before.isSymbolicLink())
|
|
152
|
+
return { values, problems: [...problems, "is a symlink"] };
|
|
153
|
+
if (!before.isFile())
|
|
154
|
+
return { values, problems: [...problems, "is not a regular file"] };
|
|
155
|
+
let text;
|
|
156
|
+
let fd = null;
|
|
157
|
+
try {
|
|
158
|
+
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
159
|
+
const after = fstatSync(fd);
|
|
160
|
+
if (after.dev !== before.dev || after.ino !== before.ino)
|
|
161
|
+
return { values, problems: [...problems, "changed while it was opened"] };
|
|
162
|
+
if (after.uid !== uid)
|
|
163
|
+
problems.push(`is owned by uid ${after.uid}`);
|
|
164
|
+
if ((after.mode & 0o077) !== 0)
|
|
165
|
+
problems.push(`has mode ${(after.mode & 0o777).toString(8)}, not 0600`);
|
|
166
|
+
if (after.size > maxBytes)
|
|
167
|
+
return { values, problems: [...problems, `is over ${maxBytes} bytes`] };
|
|
168
|
+
text = readFileSync(fd, "utf8");
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return { values, problems: [...problems, "unreadable"] };
|
|
172
|
+
}
|
|
173
|
+
finally {
|
|
174
|
+
if (fd !== null)
|
|
175
|
+
closeSync(fd);
|
|
176
|
+
}
|
|
177
|
+
for (const line of text.split("\n")) {
|
|
178
|
+
if (line === "")
|
|
179
|
+
continue;
|
|
180
|
+
const eq = line.indexOf("=");
|
|
181
|
+
const key = eq > 0 ? line.slice(0, eq) : line;
|
|
182
|
+
if (eq <= 0 || !fields.includes(key)) {
|
|
183
|
+
problems.push(`unknown or malformed line "${key.slice(0, 32)}"`);
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (key in values) {
|
|
187
|
+
problems.push(`duplicate ${key}`);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
values[key] = line.slice(eq + 1);
|
|
191
|
+
}
|
|
192
|
+
for (const field of fields)
|
|
193
|
+
if (!(field in values))
|
|
194
|
+
problems.push(`missing ${field}`);
|
|
195
|
+
return { values, problems };
|
|
196
|
+
}
|
|
197
|
+
export const OPTIN_FIELDS = Object.freeze(["version", "machine_id", "uid", "slot", "pub_sha256", "granted_at"]);
|
|
198
|
+
const OPTIN_GRAMMAR = {
|
|
199
|
+
version: /^1$/, machine_id: /^[0-9a-f]{16}$/, uid: /^[0-9]+$/, slot: SLOT_RE,
|
|
200
|
+
pub_sha256: /^[0-9a-f]{64}$/, granted_at: /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/,
|
|
201
|
+
};
|
|
202
|
+
/**
|
|
203
|
+
* The unsealed opt-in `<K>/<slot>.unsealed-optin`, field by field against this host, user, slot and
|
|
204
|
+
* pin (IF-0-KEYSEAL-6 "Opt-in record"). Empty = valid.
|
|
205
|
+
*/
|
|
206
|
+
export function unsealedOptinProblems(env, slot, uid, machineId) {
|
|
207
|
+
const dir = keyslotKeyDir(env);
|
|
208
|
+
if (!dir)
|
|
209
|
+
return ["no config root"];
|
|
210
|
+
const { values, problems } = readOwnerOnlyRecord(join(dir, `${slot}.unsealed-optin`), OPTIN_FIELDS, uid);
|
|
211
|
+
for (const field of OPTIN_FIELDS) {
|
|
212
|
+
if (field in values && !OPTIN_GRAMMAR[field].test(values[field]))
|
|
213
|
+
problems.push(`malformed ${field}`);
|
|
214
|
+
}
|
|
215
|
+
let pinSha = null;
|
|
216
|
+
try {
|
|
217
|
+
const pub = join(dir, `${slot}.pub`);
|
|
218
|
+
if (!lstatSync(pub).isSymbolicLink())
|
|
219
|
+
pinSha = createHash("sha256").update(readFileSync(pub)).digest("hex");
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
pinSha = null;
|
|
223
|
+
}
|
|
224
|
+
if ("machine_id" in values && (machineId === null || values.machine_id !== machineId))
|
|
225
|
+
problems.push("machine_id is another machine's");
|
|
226
|
+
if ("uid" in values && values.uid !== String(uid))
|
|
227
|
+
problems.push("uid is another user's");
|
|
228
|
+
if ("slot" in values && values.slot !== slot)
|
|
229
|
+
problems.push("slot is another slot's");
|
|
230
|
+
if ("pub_sha256" in values && values.pub_sha256 !== pinSha)
|
|
231
|
+
problems.push("pub_sha256 does not match the pin file");
|
|
232
|
+
return problems;
|
|
233
|
+
}
|
|
234
|
+
export const EXTERNAL_FIELDS = Object.freeze(["machine_id", "uid", "socket"]);
|
|
235
|
+
/**
|
|
236
|
+
* The external-agent declaration against the socket actually used: this machine, this user, and a
|
|
237
|
+
* canonical `socket=` (absolute, its own realpath) that is the same device and inode. Empty = valid.
|
|
238
|
+
*/
|
|
239
|
+
export function externalDeclarationProblems(env, socketPath, uid, machineId) {
|
|
240
|
+
const path = externalAgentDeclarationPath(env);
|
|
241
|
+
if (!path)
|
|
242
|
+
return ["no config root"];
|
|
243
|
+
const { values, problems } = readOwnerOnlyRecord(path, EXTERNAL_FIELDS, uid);
|
|
244
|
+
if ("machine_id" in values && (machineId === null || values.machine_id !== machineId))
|
|
245
|
+
problems.push("machine_id is another machine's");
|
|
246
|
+
if ("uid" in values && values.uid !== String(uid))
|
|
247
|
+
problems.push("uid is another user's");
|
|
248
|
+
if ("socket" in values) {
|
|
249
|
+
const declared = values.socket;
|
|
250
|
+
let canonical = false;
|
|
251
|
+
try {
|
|
252
|
+
canonical = isAbsolute(declared) && realpathSync(declared) === declared;
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
canonical = false;
|
|
256
|
+
}
|
|
257
|
+
if (!canonical)
|
|
258
|
+
problems.push("socket is not a canonical path");
|
|
259
|
+
else if (!sameFile(fileIdentity(declared), fileIdentity(socketPath)))
|
|
260
|
+
problems.push("socket is not the agent socket in use");
|
|
261
|
+
}
|
|
262
|
+
return problems;
|
|
263
|
+
}
|
|
264
|
+
// ---------------------------------------------------------------------------------------------
|
|
265
|
+
// selectKeyStorage
|
|
266
|
+
// ---------------------------------------------------------------------------------------------
|
|
267
|
+
const refused = (reason, fix, repairs = []) => ({ storage: "refused", reason, fix, repairs });
|
|
268
|
+
/** The #536 repair, then the full ancestor walk. Returns the repairs, or the problems. */
|
|
269
|
+
export function repairKeyDir(env, uid) {
|
|
270
|
+
const repairs = [];
|
|
271
|
+
const root = configRoot(env);
|
|
272
|
+
if (!root || !isAbsolute(root))
|
|
273
|
+
return { repairs, problems: ["no absolute config root (HOME or XDG_CONFIG_HOME)"], root };
|
|
274
|
+
const boardDir = join(root, "agent-board");
|
|
275
|
+
const owned = (path) => {
|
|
276
|
+
try {
|
|
277
|
+
const stats = lstatSync(path);
|
|
278
|
+
return stats.isDirectory() && !stats.isSymbolicLink() && stats.uid === uid ? stats : null;
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
let exists = true;
|
|
285
|
+
try {
|
|
286
|
+
lstatSync(root);
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
exists = false;
|
|
290
|
+
}
|
|
291
|
+
if (!exists) {
|
|
292
|
+
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
293
|
+
repairs.push("config_root_created");
|
|
294
|
+
}
|
|
295
|
+
const rootStats = owned(root);
|
|
296
|
+
if (rootStats && (rootStats.mode & 0o022) !== 0) {
|
|
297
|
+
chmodSync(root, rootStats.mode & 0o7777 & ~0o022);
|
|
298
|
+
repairs.push("config_root_group_write_removed");
|
|
299
|
+
}
|
|
300
|
+
let boardExists = true;
|
|
301
|
+
try {
|
|
302
|
+
lstatSync(boardDir);
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
boardExists = false;
|
|
306
|
+
}
|
|
307
|
+
if (!boardExists && rootStats) {
|
|
308
|
+
mkdirSync(boardDir, { mode: 0o700 });
|
|
309
|
+
repairs.push("agent_board_dir_created");
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
const boardStats = owned(boardDir);
|
|
313
|
+
if (boardStats && (boardStats.mode & 0o777) !== 0o700) {
|
|
314
|
+
chmodSync(boardDir, 0o700);
|
|
315
|
+
repairs.push("agent_board_dir_0700");
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return { repairs, problems: untrustedAncestors(boardDir, uid), root };
|
|
319
|
+
}
|
|
320
|
+
/** provision.sh's exit-1 refusals, by the token it prints. Anything else is `provision_failed`. */
|
|
321
|
+
const PROVISION_REFUSALS = {
|
|
322
|
+
allow_unsealed_refused: "this host can seal (probe.sh: capable), so an unsealed key is never written here: re-run setup WITHOUT --allow-unsealed-key",
|
|
323
|
+
probe_error: "probe.sh could not judge this host (a user manager that is not running, no XDG_RUNTIME_DIR, or a failed round trip on systemd >= 256): run the packaged probe.sh --json and fix what it names",
|
|
324
|
+
unsafe_key_dir: "a directory above the key slot is not safe (principle 4): make every ancestor yours or root's and not group/other-writable, then re-run setup",
|
|
325
|
+
downgrade_refused: "this slot holds a sealed key, which is never turned unsealed: remove the slot's .cred and .pub first, then re-run with --allow-unsealed-key",
|
|
326
|
+
config_dir_mismatch: "XDG_CONFIG_HOME/HOME differ from the user manager's (systemctl --user show-environment): run setup from the same environment the manager has",
|
|
327
|
+
slot_busy: "another provision holds this slot's lock: wait for it and re-run setup",
|
|
328
|
+
loader_busy: "another provision holds the loader lock: wait for it and re-run setup",
|
|
329
|
+
slot_inconsistent: "the slot directory holds a state provision.sh does not name; nothing was deleted: run the packaged status.sh --json and resolve what it reports",
|
|
330
|
+
rotate_needs_capable: "this host cannot seal a new key",
|
|
331
|
+
};
|
|
332
|
+
function withDeps(fix, deps) {
|
|
333
|
+
return deps?.installCommand && deps.status !== "done" && deps.status !== "already"
|
|
334
|
+
? `${fix}; or install the keychain tool (${deps.installCommand}) and re-run setup`
|
|
335
|
+
: fix;
|
|
336
|
+
}
|
|
337
|
+
const EXTERNAL_FIX = (path) => `declare your own agent in ${path ?? "<config root>/agent-board/external-agent"} (0600, yours): machine_id=<sha256(/etc/machine-id), first 16 hex>, uid=<your uid>, socket=<the socket's canonical path>`;
|
|
338
|
+
/** Step 4: an external agent, only with --agent-socket and a declaration naming that socket. */
|
|
339
|
+
function externalAgent(agentSocket, env, uid, machineIdFile, repairs) {
|
|
340
|
+
const reference = agentSocket.startsWith("agent_socket:") ? agentSocket : `agent_socket:${agentSocket}`;
|
|
341
|
+
const socket = resolveAgentSocketPath(reference.slice("agent_socket:".length), env);
|
|
342
|
+
const declaration = externalAgentDeclarationPath(env);
|
|
343
|
+
if (socket === null)
|
|
344
|
+
return refused("agent_socket_unresolved", "--agent-socket names no socket (and SSH_AUTH_SOCK is unset)", repairs);
|
|
345
|
+
const problems = externalDeclarationProblems(env, socket, uid, machineIdOf(machineIdFile));
|
|
346
|
+
if (problems.length === 0)
|
|
347
|
+
return { storage: "external", reason: "external_declared", keyReference: reference, repairs };
|
|
348
|
+
return refused(problems.includes("missing") ? "external_undeclared" : "external_declaration_mismatch", EXTERNAL_FIX(declaration), repairs);
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* IF-0-SETUP-3: the key storage, measured, in the IF-0-KEYSEAL-6 order. Never throws for a host
|
|
352
|
+
* condition; a refusal carries the fix.
|
|
353
|
+
*/
|
|
354
|
+
export async function selectKeyStorage(ctx) {
|
|
355
|
+
const env = ctx.env ?? process.env;
|
|
356
|
+
const platform = ctx.platform ?? process.platform;
|
|
357
|
+
const uid = ctx.uid ?? currentUid();
|
|
358
|
+
const run = ctx.run ?? defaultStorageRunner;
|
|
359
|
+
const slot = ctx.slot ?? ctx.harness;
|
|
360
|
+
if (!SLOT_RE.test(ctx.harness) || !SLOT_RE.test(slot))
|
|
361
|
+
return refused("bad_slot", "the harness and slot must match ^[a-z][a-z0-9-]{0,31}$");
|
|
362
|
+
// 0. An explicit --agent-socket wins over everything discovery would find — the keychain and a
|
|
363
|
+
// key slot alike (code review r1, owner decision; a plan amendment to the IF-0-KEYSEAL-6
|
|
364
|
+
// order, recorded in SL-7): a user who names their own agent means it. Nothing is probed.
|
|
365
|
+
if (ctx.agentSocket !== undefined)
|
|
366
|
+
return externalAgent(ctx.agentSocket, env, uid, ctx.machineIdFile, []);
|
|
367
|
+
// 1. A live keychain round trip.
|
|
368
|
+
let keychain = null;
|
|
369
|
+
try {
|
|
370
|
+
keychain = await (ctx.probeKeychain ?? (() => probeOsKeychainRoundTrip({ platform })))();
|
|
371
|
+
}
|
|
372
|
+
catch {
|
|
373
|
+
keychain = null;
|
|
374
|
+
}
|
|
375
|
+
if (keychain?.ok)
|
|
376
|
+
return { storage: "os_keychain", reason: "keychain_round_trip", repairs: [] };
|
|
377
|
+
// 2. Setup's own gate. A failure here skips the key slot, not the external agent.
|
|
378
|
+
let gate = null;
|
|
379
|
+
if (platform !== "linux") {
|
|
380
|
+
gate = platform === "darwin"
|
|
381
|
+
? { reason: "macos_keychain_unavailable", fix: "the login keychain did not complete a round trip: unlock it (security unlock-keychain) in a logged-in session and re-run setup" }
|
|
382
|
+
: { reason: "platform_unsupported", fix: "setup's key storage supports Linux and macOS; on Windows run setup inside WSL, or pass --agent-socket <ref> with an external-agent declaration" };
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
const manager = await run(ctx.systemctl ?? "/usr/bin/systemctl", ["--user", "is-system-running"], { env });
|
|
386
|
+
if (!MANAGER_REACHABLE.has(manager.stdout.trim())) {
|
|
387
|
+
gate = { reason: "no_user_systemd", fix: "no user systemd manager answers `systemctl --user`, so no key slot is offered: log in to a desktop session whose keychain unlocks, or run setup from a login session with a user manager (loginctl enable-linger $USER), or pass --agent-socket <ref>" };
|
|
388
|
+
}
|
|
389
|
+
else {
|
|
390
|
+
const bash = await run(ctx.bash ?? "/bin/bash", ["-c", "printf %s \"${BASH_VERSINFO[0]}\""], { env });
|
|
391
|
+
const major = bash.code === 0 && /^\d+$/.test(bash.stdout.trim()) ? Number(bash.stdout.trim()) : null;
|
|
392
|
+
if (major === null || major < 4)
|
|
393
|
+
gate = { reason: "bash_too_old", fix: "the keyslot scripts need bash >= 4 at /bin/bash; install a current bash, or use a desktop keychain" };
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
// 3. The key slot: provision.sh selects the mode; status.sh measures it.
|
|
397
|
+
let slotRefusal = null;
|
|
398
|
+
let repairs = [];
|
|
399
|
+
if (gate === null) {
|
|
400
|
+
let dir;
|
|
401
|
+
try {
|
|
402
|
+
dir = ctx.keyslotDir ?? resolveKeyslotDir();
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
return refused("keyslot_scripts_absent", "the packaged keyslot scripts are missing: reinstall @consiliency/agent-board-cli");
|
|
406
|
+
}
|
|
407
|
+
const repaired = repairKeyDir(env, uid);
|
|
408
|
+
repairs = repaired.repairs;
|
|
409
|
+
if (repaired.problems.length > 0) {
|
|
410
|
+
return refused("unsafe_key_dir", `the key directory is not safe (${repaired.problems.join("; ")}): chmod g-w,o-w on each named directory you own (for a fresh account: chmod g-w ${repaired.root ?? "~/.config"} && chmod 0700 ${repaired.root ?? "~/.config"}/agent-board), then re-run setup`, repairs);
|
|
411
|
+
}
|
|
412
|
+
const args = ["--harness", ctx.harness, "--slot", slot, ...(ctx.allowUnsealedKey === true ? ["--allow-unsealed-key"] : [])];
|
|
413
|
+
const provision = await run(join(dir, "provision.sh"), args, { env });
|
|
414
|
+
if (provision.code === 0) {
|
|
415
|
+
const line = provision.stdout.split("\n").map((entry) => entry.trim()).filter(Boolean).at(-1) ?? "";
|
|
416
|
+
if (!REFERENCE_LINE.test(line))
|
|
417
|
+
return refused("provision_output_unrecognised", "provision.sh exited 0 without printing an agent_socket: reference; run it by hand and report the output", repairs);
|
|
418
|
+
const status = await run(join(dir, "status.sh"), ["--harness", ctx.harness, "--slot", slot, "--json"], { env });
|
|
419
|
+
let mode = null;
|
|
420
|
+
try {
|
|
421
|
+
mode = JSON.parse(status.stdout).mode;
|
|
422
|
+
}
|
|
423
|
+
catch {
|
|
424
|
+
mode = null;
|
|
425
|
+
}
|
|
426
|
+
if (mode === "sealed" || mode === "unsealed")
|
|
427
|
+
return { storage: mode, reason: `keyslot_${mode}`, keyReference: line, slot, repairs };
|
|
428
|
+
return refused("keyslot_mode_unmeasured", "provision.sh succeeded but status.sh does not measure the slot as sealed or unsealed: run the packaged status.sh --json and resolve what it reports", repairs);
|
|
429
|
+
}
|
|
430
|
+
if (provision.code === 2) {
|
|
431
|
+
slotRefusal = /incapable_for_keyslot/.test(provision.stderr)
|
|
432
|
+
? { reason: "incapable_for_keyslot", fix: "no key slot survives a reboot here: run `loginctl enable-linger $USER` and install openssh-client (ssh-agent, ssh-add, ssh-keygen), then re-run setup" }
|
|
433
|
+
: /capability_absent/.test(provision.stderr)
|
|
434
|
+
? { reason: "unsealed_needs_opt_in", fix: "this host cannot seal (systemd below 256, or no systemd-creds): re-run with --allow-unsealed-key to accept a 0600 key under an opt-in record, or use a desktop keychain" }
|
|
435
|
+
: { reason: "provision_undetermined", fix: "provision.sh could not establish a key slot: run it by hand and read what it reports" };
|
|
436
|
+
}
|
|
437
|
+
else {
|
|
438
|
+
const token = Object.keys(PROVISION_REFUSALS).find((name) => provision.stderr.includes(name));
|
|
439
|
+
return token
|
|
440
|
+
? refused(token, PROVISION_REFUSALS[token], repairs)
|
|
441
|
+
: refused("provision_failed", "provision.sh failed: run the packaged provision.sh by hand and read what it reports", repairs);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
// 5. Refused, naming the fix.
|
|
445
|
+
const cause = gate ?? slotRefusal ?? { reason: "no_key_storage", fix: "no key storage applies" };
|
|
446
|
+
return refused(cause.reason, withDeps(cause.fix, ctx.deps), repairs);
|
|
447
|
+
}
|