@tpsdev-ai/flair 0.22.1 → 0.24.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,369 @@
1
+ /**
2
+ * auth-resolve.ts — the ONE shared CLI credential resolver (flair#747).
3
+ *
4
+ * Before this file existed, every auth-requiring CLI surface hand-rolled its
5
+ * own version of "how do I authenticate this request against a Flair
6
+ * instance" — `api()`, `fetchHealthDetail()`, and `bootstrap` in src/cli.ts
7
+ * each carried a slightly different, independently-maintained chain. The
8
+ * worst gap: several of them never tried an Ed25519 agent key at all unless
9
+ * an agentId was ALREADY known by some OTHER means, so a machine with a
10
+ * perfectly good `~/.flair/keys/<agentId>.key` and nothing else (the normal
11
+ * shape for a headless/agent machine) silently fell through to "no
12
+ * credentials" on surfaces that a human operator's `~/.flair/admin-pass`
13
+ * machine sailed through without noticing. flair#741/#742 fixed exactly
14
+ * this for `flair upgrade`'s verification calls only (`verifyAuthedGet`,
15
+ * still exported from src/cli.ts as a thin wrapper over `api()` below).
16
+ * This module generalizes that fix into the floor every surface gets.
17
+ *
18
+ * ── Resolution order ─────────────────────────────────────────────────────
19
+ *
20
+ * 1. EXPLICIT — a caller-supplied credential: `explicitAdminPass` (e.g. a
21
+ * resolved `--admin-pass` flag) or `explicitKeyPath` (e.g. `--key`,
22
+ * paired with `agentId`). Always wins; this is the operator saying
23
+ * "use exactly this."
24
+ * 2. ENV — `FLAIR_TOKEN` (Bearer) or `FLAIR_ADMIN_PASS` / `HDB_ADMIN_
25
+ * PASSWORD` (Basic admin auth). Ambient but still an explicit
26
+ * operator/CI choice.
27
+ * 3. A PINNED agent identity — `agentId` was already known (an --agent
28
+ * flag, FLAIR_AGENT_ID env, or an id the caller extracted from its own
29
+ * request body/query string) — signed with THAT agent's key via the
30
+ * standard `resolveKeyPath` lookup. Deliberately ABOVE the admin-pass
31
+ * file: naming a specific agent identity is a deliberate choice that
32
+ * should win over the ambient convenience file (flair#634 precedent —
33
+ * preserved bit-for-bit; see test/unit/local-no-auth.test.ts).
34
+ * 4. The secure `~/.flair/admin-pass` file `flair init` writes (LOCAL
35
+ * TARGETS ONLY — never sent to a --target/FLAIR_URL/FLAIR_TARGET
36
+ * remote instance; see `resolveLocalAdminPass`).
37
+ * 5. THE FLOOR (flair#747, generalizing flair#742's upgrade-only
38
+ * fallback) — when NOTHING above resolved to anything sendable at
39
+ * all, sign the SAME request with every registered key under
40
+ * `~/.flair/keys` (sorted, deterministic order), first one the server
41
+ * accepts wins. This is the credential every headless/agent machine
42
+ * actually has, and is exactly the flair#741 incident's fix: an agent
43
+ * key existed the whole time, nothing tried it.
44
+ *
45
+ * Tier 5 ONLY ever engages when the primary attempt fails with
46
+ * `ApiHttpError.noCredentials` — i.e. truly nothing was sent (a bare 403
47
+ * with no Authorization header at all). A credential that WAS sent and got
48
+ * rejected (bad admin pass, unregistered FLAIR_AGENT_ID) is a different,
49
+ * more specific failure; guessing at unrelated keys on disk in that case
50
+ * would obscure the real error instead of explaining it (the flair#741 fix
51
+ * #3 lesson, applied here — see `sendJsonRequest`'s 403 branch).
52
+ */
53
+ import { existsSync, readFileSync, readdirSync, statSync, } from "node:fs";
54
+ import { homedir } from "node:os";
55
+ import { join } from "node:path";
56
+ import { createPrivateKey, randomUUID, sign as nodeCryptoSign } from "node:crypto";
57
+ // ─── Failure classification ─────────────────────────────────────────────────
58
+ /**
59
+ * Thrown by `sendJsonRequest` for an HTTP-level failure (as opposed to a
60
+ * network error, which fetch() itself throws before a status code ever
61
+ * exists). Data-carrying, not just a message string, so callers downstream
62
+ * can make decisions without re-parsing text (flair#741):
63
+ * - `status`: the HTTP status code, so probeInstance (src/probe.ts) can
64
+ * classify ProbeResult.authFailureKind (401/403 = credential failure,
65
+ * duck-typed off this property).
66
+ * - `noCredentials`: true only for the specific bare-403-no-auth-header
67
+ * case — distinguishes "we had nothing to send" from "we sent
68
+ * something and it was rejected", which is exactly the signal
69
+ * `authedRequest` uses to decide whether tier 5 (the floor) applies.
70
+ */
71
+ export class ApiHttpError extends Error {
72
+ status;
73
+ noCredentials;
74
+ constructor(status, message, noCredentials = false) {
75
+ super(message);
76
+ this.name = "ApiHttpError";
77
+ this.status = status;
78
+ this.noCredentials = noCredentials;
79
+ }
80
+ }
81
+ // ─── Admin-pass file handling ───────────────────────────────────────────────
82
+ /**
83
+ * Read a secret (admin password, Fabric password, ...) from a file, refusing
84
+ * if the file is world/group readable.
85
+ *
86
+ * Secret files (default ~/.flair/admin-pass; also used for
87
+ * --fabric-password-file) are short-lived values generated by `openssl
88
+ * rand` — mode 0600 keeps them out of reach of other local users + most
89
+ * backup tooling. A 0644 file silently leaks the secret to anyone with read
90
+ * access to the user's home (multi-user hosts, NFS, time-machine snapshots,
91
+ * etc.).
92
+ *
93
+ * Throws with an actionable error if the file isn't owner-only, otherwise
94
+ * returns the trimmed file content (values generated by `openssl rand
95
+ * -base64` conventionally end in a newline). `flagName` is only used to
96
+ * personalize the error text (e.g. "--admin-pass-file" vs
97
+ * "--fabric-password-file") — the check itself is identical either way.
98
+ */
99
+ export function readSecretFileSecure(path, flagName) {
100
+ if (!existsSync(path)) {
101
+ throw new Error(`${flagName} path does not exist: ${path}`);
102
+ }
103
+ const st = statSync(path);
104
+ if (st.mode & 0o077) {
105
+ const modeOctal = (st.mode & 0o777).toString(8).padStart(3, "0");
106
+ throw new Error(`Refusing to read ${flagName} at ${path}: permissions ${modeOctal} are too open. ` +
107
+ `Run \`chmod 600 ${path}\` to restrict to owner-only.`);
108
+ }
109
+ const content = readFileSync(path, "utf-8").replace(/\s+$/, "");
110
+ if (!content) {
111
+ throw new Error(`${flagName}: file is empty or contains only whitespace: ${path}`);
112
+ }
113
+ return content;
114
+ }
115
+ /** `readSecretFileSecure` specialized for --admin-pass-file (see that function for the shared check). */
116
+ export function readAdminPassFileSecure(path) {
117
+ return readSecretFileSecure(path, "--admin-pass-file");
118
+ }
119
+ export function defaultAdminPassPath() {
120
+ return join(homedir(), ".flair", "admin-pass");
121
+ }
122
+ export function defaultKeysDir() {
123
+ return join(homedir(), ".flair", "keys");
124
+ }
125
+ /**
126
+ * Resolve an admin password for LOCAL-only CLI convenience (`agent add`,
127
+ * `principal add`) without requiring `--admin-pass` on every call (#590).
128
+ * Also reused by `authedRequest`'s tier 4 (local-target file fallback,
129
+ * flair#634) — called there as `resolveLocalAdminPass(undefined,
130
+ * !isLocal)`, so only its file leg ever fires (the env leg is already
131
+ * handled by tier 2 first).
132
+ *
133
+ * Resolution order: explicit value (the `--admin-pass` flag) → `FLAIR_ADMIN_PASS`
134
+ * env → the secure `~/.flair/admin-pass` file `flair init` already writes with
135
+ * mode 0600 (read via `readAdminPassFileSecure`, which enforces that mode).
136
+ *
137
+ * When `isRemoteTarget` is true, ONLY the explicit value is honored — the env
138
+ * and file legs are skipped entirely. This is the security-critical guard: a
139
+ * `--target`/`--ops-target` deploy must never silently reuse THIS machine's
140
+ * local admin secret against someone else's Harper instance. Remote callers
141
+ * keep requiring an explicit `--admin-pass`.
142
+ *
143
+ * Throws (via readAdminPassFileSecure) if the file exists but has unsafe
144
+ * permissions, so a misconfigured file surfaces as an actionable chmod error
145
+ * instead of a generic "admin pass required" message.
146
+ */
147
+ export function resolveLocalAdminPass(explicit, isRemoteTarget = false, adminPassPath = defaultAdminPassPath()) {
148
+ if (explicit)
149
+ return explicit;
150
+ if (isRemoteTarget)
151
+ return undefined;
152
+ if (process.env.FLAIR_ADMIN_PASS)
153
+ return process.env.FLAIR_ADMIN_PASS;
154
+ if (!existsSync(adminPassPath))
155
+ return undefined;
156
+ return readAdminPassFileSecure(adminPassPath);
157
+ }
158
+ // ─── Ed25519 agent-key signing ──────────────────────────────────────────────
159
+ /** Find the agent's private key file from standard locations. */
160
+ export function resolveKeyPath(agentId) {
161
+ const candidates = [
162
+ process.env.FLAIR_KEY_DIR ? join(process.env.FLAIR_KEY_DIR, `${agentId}.key`) : null,
163
+ join(homedir(), ".flair", "keys", `${agentId}.key`),
164
+ join(homedir(), ".tps", "secrets", "flair", `${agentId}-priv.key`),
165
+ ].filter(Boolean);
166
+ return candidates.find((p) => existsSync(p)) ?? null;
167
+ }
168
+ /** Build a TPS-Ed25519 auth header from a raw 32-byte seed on disk. */
169
+ export function buildEd25519Auth(agentId, method, path, keyPath) {
170
+ const raw = readFileSync(keyPath);
171
+ const pkcs8Header = Buffer.from("302e020100300506032b657004220420", "hex");
172
+ let privKey;
173
+ if (raw.length === 32) {
174
+ // Raw 32-byte seed
175
+ privKey = createPrivateKey({ key: Buffer.concat([pkcs8Header, raw]), format: "der", type: "pkcs8" });
176
+ }
177
+ else {
178
+ // Try as base64-encoded PKCS8 DER (standard Flair key format)
179
+ const decoded = Buffer.from(raw.toString("utf-8").trim(), "base64");
180
+ if (decoded.length === 32) {
181
+ // Base64-encoded raw seed
182
+ privKey = createPrivateKey({ key: Buffer.concat([pkcs8Header, decoded]), format: "der", type: "pkcs8" });
183
+ }
184
+ else {
185
+ // Full PKCS8 DER or PEM
186
+ try {
187
+ privKey = createPrivateKey({ key: decoded, format: "der", type: "pkcs8" });
188
+ }
189
+ catch {
190
+ privKey = createPrivateKey(raw);
191
+ }
192
+ }
193
+ }
194
+ const ts = Date.now().toString();
195
+ const nonce = randomUUID();
196
+ const payload = `${agentId}:${ts}:${nonce}:${method}:${path}`;
197
+ const sig = nodeCryptoSign(null, Buffer.from(payload), privKey).toString("base64");
198
+ return `TPS-Ed25519 ${agentId}:${ts}:${nonce}:${sig}`;
199
+ }
200
+ /** Authenticated fetch against Flair using Ed25519. */
201
+ export async function authFetch(baseUrl, agentId, keyPath, method, path, body) {
202
+ const auth = buildEd25519Auth(agentId, method, path, keyPath);
203
+ const headers = { Authorization: auth };
204
+ if (body !== undefined)
205
+ headers["Content-Type"] = "application/json";
206
+ return fetch(`${baseUrl}${path}`, {
207
+ method,
208
+ headers,
209
+ body: body !== undefined ? JSON.stringify(body) : undefined,
210
+ });
211
+ }
212
+ export function isLocalBase(base) {
213
+ try {
214
+ const url = new URL(base);
215
+ return url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "::1";
216
+ }
217
+ catch {
218
+ return !base;
219
+ }
220
+ }
221
+ // ─── HTTP + response handling ───────────────────────────────────────────────
222
+ /**
223
+ * Perform the actual Flair REST call with an already-resolved Authorization
224
+ * header (or none) and classify the response. Shared by the primary
225
+ * (tiers 1-4) attempt and each tier-5 floor attempt below, so both paths
226
+ * agree on 204/empty-body handling, JSON parsing, and the no-credentials
227
+ * 403 hint text.
228
+ */
229
+ export async function sendJsonRequest(baseUrl, method, path, body, authHeader, isLocal) {
230
+ const res = await fetch(`${baseUrl}${path}`, {
231
+ method,
232
+ headers: {
233
+ "content-type": "application/json",
234
+ ...(authHeader ? { authorization: authHeader } : {}),
235
+ },
236
+ body: body ? JSON.stringify(body) : undefined,
237
+ });
238
+ // Handle 204 No Content (e.g., PUT upsert returns empty body)
239
+ if (res.status === 204 || res.headers.get("content-length") === "0") {
240
+ if (!res.ok)
241
+ throw new ApiHttpError(res.status, `HTTP ${res.status}`);
242
+ return { ok: true };
243
+ }
244
+ const text = await res.text();
245
+ if (!res.ok) {
246
+ // 403 with no credentials sent at all is the flair#634 case: a gated
247
+ // resource rejected a credential-less call. Name the fix instead of
248
+ // surfacing the raw "forbidden" body — never a stack trace.
249
+ if (res.status === 403 && !authHeader) {
250
+ const hint = isLocal
251
+ ? "Set FLAIR_ADMIN_PASS, or run `flair init` to provision ~/.flair/admin-pass or an agent key."
252
+ : "Set FLAIR_ADMIN_PASS (remote targets have no local admin-pass fallback).";
253
+ throw new ApiHttpError(403, `HTTP 403: no credentials sent. ${hint}`, /* noCredentials */ true);
254
+ }
255
+ throw new ApiHttpError(res.status, text || `HTTP ${res.status}`);
256
+ }
257
+ if (!text)
258
+ return { ok: true };
259
+ return JSON.parse(text);
260
+ }
261
+ /**
262
+ * Tier 5, the floor (flair#747, generalizing flair#742's `verifyAuthedGet`
263
+ * GET-only fallback to any method/body): try every registered key in
264
+ * `keysDir` in sorted (deterministic) filename order, first one that
265
+ * authenticates wins. Returns `undefined` — never throws — when every key
266
+ * fails or none exist, so the caller can rethrow its ORIGINAL
267
+ * no-credentials error rather than a floor-specific one.
268
+ */
269
+ export async function tryAgentKeyFloor(baseUrl, method, path, body, keysDir) {
270
+ let keyFiles = [];
271
+ try {
272
+ keyFiles = readdirSync(keysDir).filter((f) => f.endsWith(".key")).sort();
273
+ }
274
+ catch {
275
+ // No keys dir at all — nothing to fall back to.
276
+ }
277
+ const isLocal = isLocalBase(baseUrl);
278
+ for (const kf of keyFiles) {
279
+ const agentId = kf.replace(/\.key$/, "");
280
+ const keyPath = join(keysDir, kf);
281
+ try {
282
+ const authHeader = buildEd25519Auth(agentId, method, path, keyPath);
283
+ return await sendJsonRequest(baseUrl, method, path, body, authHeader, isLocal);
284
+ }
285
+ catch {
286
+ // This key didn't work (unregistered, bad signature, network blip on
287
+ // this one attempt) — try the next one.
288
+ }
289
+ }
290
+ return undefined;
291
+ }
292
+ /**
293
+ * The shared resolver: given a Flair REST call and whatever credential
294
+ * material the caller already knows about, resolve auth across all 5 tiers
295
+ * (see module header) and perform the request. Throws `ApiHttpError` on
296
+ * any non-2xx response (after the tier-5 floor has also been exhausted).
297
+ */
298
+ export async function authedRequest(method, path, body, opts) {
299
+ const isLocal = opts.isLocal ?? isLocalBase(opts.baseUrl);
300
+ const keysDir = opts.keysDir ?? defaultKeysDir();
301
+ let authHeader;
302
+ // Tier 1: explicit — caller-resolved flag material always wins.
303
+ if (opts.explicitAdminPass) {
304
+ authHeader = `Basic ${Buffer.from(`admin:${opts.explicitAdminPass}`).toString("base64")}`;
305
+ }
306
+ else if (opts.explicitKeyPath && opts.agentId) {
307
+ try {
308
+ authHeader = buildEd25519Auth(opts.agentId, method, path, opts.explicitKeyPath);
309
+ }
310
+ catch (err) {
311
+ const message = err instanceof Error ? err.message : String(err);
312
+ console.error(`Warning: Ed25519 auth failed for agent '${opts.agentId}': ${message}`);
313
+ }
314
+ }
315
+ // Tier 2: env — FLAIR_TOKEN (Bearer), else FLAIR_ADMIN_PASS/HDB_ADMIN_PASSWORD (Basic).
316
+ if (!authHeader) {
317
+ if (process.env.FLAIR_TOKEN) {
318
+ authHeader = `Bearer ${process.env.FLAIR_TOKEN}`;
319
+ }
320
+ else if (process.env.FLAIR_ADMIN_PASS || process.env.HDB_ADMIN_PASSWORD) {
321
+ const adminPass = process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD;
322
+ authHeader = `Basic ${Buffer.from(`admin:${adminPass}`).toString("base64")}`;
323
+ }
324
+ }
325
+ // Tier 3: a PINNED agent identity — sign specifically as this agent via
326
+ // the standard key lookup (skipped if tier 1 already signed as this
327
+ // agent via an explicit key path).
328
+ if (!authHeader && opts.agentId) {
329
+ const keyPath = resolveKeyPath(opts.agentId);
330
+ if (keyPath) {
331
+ try {
332
+ authHeader = buildEd25519Auth(opts.agentId, method, path, keyPath);
333
+ }
334
+ catch (err) {
335
+ const message = err instanceof Error ? err.message : String(err);
336
+ console.error(`Warning: Ed25519 auth failed for agent '${opts.agentId}': ${message}`);
337
+ }
338
+ }
339
+ }
340
+ // Tier 4: LOCAL TARGETS ONLY — the secure ~/.flair/admin-pass file.
341
+ if (!authHeader) {
342
+ try {
343
+ const filePass = resolveLocalAdminPass(undefined, !isLocal);
344
+ if (filePass) {
345
+ authHeader = `Basic ${Buffer.from(`admin:${filePass}`).toString("base64")}`;
346
+ }
347
+ }
348
+ catch (err) {
349
+ // File exists but has unsafe permissions — warn (never the secret
350
+ // itself) and fall through to no-auth / the floor.
351
+ const message = err instanceof Error ? err.message : String(err);
352
+ console.error(`Warning: ~/.flair/admin-pass unusable: ${message}`);
353
+ }
354
+ }
355
+ try {
356
+ return await sendJsonRequest(opts.baseUrl, method, path, body, authHeader, isLocal);
357
+ }
358
+ catch (err) {
359
+ // Tier 5 — the floor. Only engages when NOTHING above resolved to
360
+ // anything sendable (see module header for why a rejected credential
361
+ // does not retry here).
362
+ if (err instanceof ApiHttpError && err.noCredentials) {
363
+ const floorResult = await tryAgentKeyFloor(opts.baseUrl, method, path, body, keysDir);
364
+ if (floorResult !== undefined)
365
+ return floorResult;
366
+ }
367
+ throw err;
368
+ }
369
+ }