flowviant 0.25.0 → 0.27.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,504 @@
1
+ /**
2
+ * Team env sync — the daemon is the CRYPTO ANCHOR. This machine holds a
3
+ * persistent X25519 keypair (~/.flowviant/env-keypair.json, 0600); the
4
+ * project's private key reaches it only sealed to that pubkey. Everything the
5
+ * server stores is ciphertext it cannot open.
6
+ *
7
+ * Duties per roster tick (handleRosterEnv):
8
+ * - register this machine's pubkey (once) → an admin approves in Settings.
9
+ * - bootstrap the project keypair when none exists (first machine): generate
10
+ * it + a standing RECOVERY keypair wrapped under a one-time passphrase
11
+ * printed exactly once — rotations re-seal to the same recovery pub, so
12
+ * that passphrase survives forever.
13
+ * - sync: on a bundle version change, unwrap the priv, open every sealed
14
+ * value, cache (encrypted under a key derived from our own priv), and
15
+ * rematerialize env files into the agent worktrees.
16
+ * - execute wrap jobs (admin approved a new machine → seal the priv to it).
17
+ * - execute rotations (a machine was revoked → new keypair, re-seal all
18
+ * values, re-wrap every enrolled machine, re-seal recovery).
19
+ *
20
+ * Materialization writes per-targetFile KEY=value files into a worktree and
21
+ * registers each path in the worktree's git info/exclude — untracked AND
22
+ * unstageable, so an agent can never commit them. The WIKI worktree never
23
+ * gets env (the cartographer doesn't need secrets).
24
+ *
25
+ * scrub() redacts every known plaintext value from daemon-posted uplinks
26
+ * (turn streams, wiki progress, vault sync). Agent-MCP-direct payloads
27
+ * (evidence, progress, complete) never pass through the daemon — those are
28
+ * covered by the prompt contract, not here.
29
+ */
30
+
31
+ import {
32
+ readFileSync,
33
+ writeFileSync,
34
+ mkdirSync,
35
+ existsSync,
36
+ appendFileSync,
37
+ rmSync,
38
+ } from 'node:fs';
39
+ import { execFileSync } from 'node:child_process';
40
+ import { homedir, hostname } from 'node:os';
41
+ import { join, dirname, resolve } from 'node:path';
42
+ import sodium from 'libsodium-wrappers';
43
+ import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
44
+ import { c, info, note, ok, warn } from './ui.mjs';
45
+
46
+ const B64 = () => sodium.base64_variants.ORIGINAL;
47
+ const KEYPAIR_PATH = join(homedir(), '.flowviant', 'env-keypair.json');
48
+ const CACHE_DIR = join(homedir(), '.flowviant', 'env-cache');
49
+ const SCRUB_MIN_LENGTH = 6; // mirrors shared ENV_SCRUB_MIN_LENGTH
50
+ const envUrl = (tail) => FLEET_URL.replace(/\/agents\/?$/, `/env/${tail}`);
51
+
52
+ // ── Module state (one project per daemon, same as the vault) ───────────────
53
+ let keypair = null; // { publicKey: Uint8Array, privateKey: Uint8Array }
54
+ let registeredOnce = false;
55
+ let projectPriv = null; // Uint8Array — unwrapped project private key
56
+ let bundleVersion = -1; // last materialized bundle version (-1 = never)
57
+ let values = []; // [{ name, targetFile, value }]
58
+ let cachedProjectId = null;
59
+
60
+ export async function sodiumReady() {
61
+ await sodium.ready;
62
+ }
63
+
64
+ /** 6-emoji key fingerprint — algorithm MUST match the web's pubkeyEmoji
65
+ * (EnvironmentSettings.tsx) so the human can compare terminal ↔ approve card. */
66
+ // MUST stay byte-identical to the web's pubkeyEmoji (EnvironmentSettings.tsx) —
67
+ // the human compares the two. 32 glyphs × 8 positions ≈ 40 bits; each position
68
+ // mixes the whole key so no byte is mute (a compromised-server pubkey swap must
69
+ // grind a full collision, not just the tail).
70
+ const FP_EMOJI = ['🦊','🐙','🦕','🐝','🦉','🐬','🦁','🐸','🦄','🐢','🦋','🐺','🦜','🐳','🦔','🐌','🦩','🐿️','🦥','🐨','🦦','🐇','🦡','🐝','🦨','🐜','🦢','🐋','🦭','🐞','🦚','🐊'];
71
+ export function pubkeyEmoji(pubkeyB64) {
72
+ let out = '';
73
+ for (let i = 0; i < 8; i++) {
74
+ let acc = i + 1;
75
+ for (let j = 0; j < pubkeyB64.length; j++) {
76
+ acc = (acc * 31 + pubkeyB64.charCodeAt(j) * (i + 2)) % 1_000_003;
77
+ }
78
+ out += FP_EMOJI[acc % FP_EMOJI.length];
79
+ }
80
+ return out;
81
+ }
82
+
83
+ /** This machine's persistent keypair (created on first use, 0600). */
84
+ export async function ensureKeypair() {
85
+ await sodium.ready;
86
+ if (keypair) return keypair;
87
+ try {
88
+ const stored = JSON.parse(readFileSync(KEYPAIR_PATH, 'utf8'));
89
+ keypair = {
90
+ publicKey: sodium.from_base64(stored.pub, B64()),
91
+ privateKey: sodium.from_base64(stored.priv, B64()),
92
+ };
93
+ return keypair;
94
+ } catch {
95
+ /* first run */
96
+ }
97
+ keypair = sodium.crypto_box_keypair();
98
+ mkdirSync(dirname(KEYPAIR_PATH), { recursive: true });
99
+ writeFileSync(
100
+ KEYPAIR_PATH,
101
+ JSON.stringify({
102
+ pub: sodium.to_base64(keypair.publicKey, B64()),
103
+ priv: sodium.to_base64(keypair.privateKey, B64()),
104
+ }),
105
+ { mode: 0o600 }
106
+ );
107
+ return keypair;
108
+ }
109
+
110
+ export function myPubB64() {
111
+ return keypair ? sodium.to_base64(keypair.publicKey, B64()) : null;
112
+ }
113
+
114
+ /** Query params the roster poll carries: identity + materialized version. */
115
+ export async function envQueryParams() {
116
+ await ensureKeypair();
117
+ const params = { envpub: myPubB64() };
118
+ if (bundleVersion >= 0) params.envv = String(bundleVersion);
119
+ return params;
120
+ }
121
+
122
+ // ── HTTP helpers ───────────────────────────────────────────────────────────
123
+ async function post(tail, body) {
124
+ const res = await fetch(envUrl(tail), {
125
+ method: 'POST',
126
+ headers: {
127
+ Authorization: `Bearer ${FLEET_TOKEN}`,
128
+ 'User-Agent': USER_AGENT,
129
+ 'Content-Type': 'application/json',
130
+ },
131
+ signal: AbortSignal.timeout(30_000),
132
+ body: JSON.stringify(body),
133
+ });
134
+ const json = await res.json().catch(() => ({}));
135
+ if (!res.ok || json?.success === false) {
136
+ throw new Error(`env ${tail} failed (${res.status}${json?.error ? `: ${json.error}` : ''})`);
137
+ }
138
+ return json?.data;
139
+ }
140
+
141
+ export async function fetchBundle() {
142
+ const res = await fetch(`${envUrl('bundle')}?pubkey=${encodeURIComponent(myPubB64())}`, {
143
+ headers: { Authorization: `Bearer ${FLEET_TOKEN}`, 'User-Agent': USER_AGENT },
144
+ signal: AbortSignal.timeout(30_000),
145
+ });
146
+ const json = await res.json().catch(() => ({}));
147
+ if (!res.ok || !json?.data) throw new Error(`env bundle fetch failed (${res.status})`);
148
+ return json.data;
149
+ }
150
+
151
+ // ── Crypto ─────────────────────────────────────────────────────────────────
152
+ const seal = (bytes, pubB64) => sodium.to_base64(sodium.crypto_box_seal(bytes, sodium.from_base64(pubB64, B64())), B64());
153
+ const openSealed = (b64, pub, priv) => sodium.crypto_box_seal_open(sodium.from_base64(b64, B64()), pub, priv);
154
+
155
+ /** Cache the decrypted bundle at rest, encrypted under a key derived from our
156
+ * own priv — the worktrees hold the same plaintext anyway; this just keeps
157
+ * the cache from being a SECOND, tidier copy. */
158
+ function cacheKey() {
159
+ return sodium.crypto_generichash(sodium.crypto_secretbox_KEYBYTES, keypair.privateKey);
160
+ }
161
+ function writeCache(projectId, payload) {
162
+ try {
163
+ mkdirSync(CACHE_DIR, { recursive: true });
164
+ const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
165
+ const box = sodium.crypto_secretbox_easy(sodium.from_string(JSON.stringify(payload)), nonce, cacheKey());
166
+ writeFileSync(
167
+ join(CACHE_DIR, `${projectId}.json`),
168
+ JSON.stringify({ nonce: sodium.to_base64(nonce, B64()), box: sodium.to_base64(box, B64()) }),
169
+ { mode: 0o600 }
170
+ );
171
+ } catch {
172
+ /* cache is best-effort */
173
+ }
174
+ }
175
+ function readCache(projectId) {
176
+ try {
177
+ const { nonce, box } = JSON.parse(readFileSync(join(CACHE_DIR, `${projectId}.json`), 'utf8'));
178
+ const plain = sodium.crypto_secretbox_open_easy(
179
+ sodium.from_base64(box, B64()),
180
+ sodium.from_base64(nonce, B64()),
181
+ cacheKey()
182
+ );
183
+ return JSON.parse(sodium.to_string(plain));
184
+ } catch {
185
+ return null;
186
+ }
187
+ }
188
+
189
+ /** Offline start: materialize from the encrypted cache before the first poll. */
190
+ export async function loadCachedEnv(projectId) {
191
+ await ensureKeypair();
192
+ const cached = readCache(projectId);
193
+ if (!cached) return false;
194
+ values = cached.values ?? [];
195
+ bundleVersion = cached.bundleVersion ?? -1;
196
+ cachedProjectId = projectId;
197
+ return values.length > 0;
198
+ }
199
+
200
+ // ── Materialization ────────────────────────────────────────────────────────
201
+
202
+ /** Per-worktree git exclude — untracked AND unstageable. A git worktree's
203
+ * `.git` is a FILE pointing at its private gitdir; info/exclude there applies
204
+ * to that worktree only and never touches the user's repo. */
205
+ function excludeInWorktree(wt, relPaths) {
206
+ try {
207
+ const dotGit = join(wt, '.git');
208
+ let gitdir = dotGit;
209
+ try {
210
+ const content = readFileSync(dotGit, 'utf8');
211
+ const m = content.match(/^gitdir:\s*(.+)\s*$/m);
212
+ if (m) gitdir = resolve(wt, m[1].trim());
213
+ } catch {
214
+ /* .git is a directory (main checkout) — use it directly */
215
+ }
216
+ const excludePath = join(gitdir, 'info', 'exclude');
217
+ mkdirSync(dirname(excludePath), { recursive: true });
218
+ let existing = '';
219
+ try {
220
+ existing = readFileSync(excludePath, 'utf8');
221
+ } catch {
222
+ /* fresh */
223
+ }
224
+ const missing = relPaths.filter((p) => !existing.split('\n').includes(`/${p}`));
225
+ if (missing.length) {
226
+ appendFileSync(excludePath, `${existing.endsWith('\n') || !existing ? '' : '\n'}${missing.map((p) => `/${p}`).join('\n')}\n`);
227
+ }
228
+ } catch {
229
+ /* best-effort — the agent prompt still forbids committing secrets */
230
+ }
231
+ }
232
+
233
+ const isSafeTarget = (p) =>
234
+ p &&
235
+ p.length <= 200 &&
236
+ !p.includes('\\') &&
237
+ !p.includes('\0') &&
238
+ !p.startsWith('/') &&
239
+ p.split('/').every((s) => s.length > 0 && s !== '.' && s !== '..');
240
+
241
+ /** Is this path TRACKED in the repo? info/exclude only hides UNTRACKED files —
242
+ * materializing secrets into a tracked file would make them stageable and
243
+ * committable. We refuse those paths entirely. */
244
+ function isTrackedInGit(wt, relPath) {
245
+ try {
246
+ execFileSync('git', ['ls-files', '--error-unmatch', '--', relPath], {
247
+ cwd: wt,
248
+ stdio: 'ignore',
249
+ });
250
+ return true;
251
+ } catch {
252
+ return false;
253
+ }
254
+ }
255
+
256
+ // Per-worktree: the target files we last materialized, so a file that lost all
257
+ // its keys (or a key that moved files) gets its stale copy removed.
258
+ const lastFilesByWorktree = new Map();
259
+
260
+ /** Render KEY=value with values that contain newlines/= safely quoted so one
261
+ * value can't fabricate another key line. */
262
+ function renderEnvFile(list) {
263
+ const lines = list.map((v) => {
264
+ const needsQuote = /[\n\r"'`$\\ ]/.test(v.value) || v.value === '';
265
+ if (!needsQuote) return `${v.name}=${v.value}`;
266
+ const esc = v.value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '');
267
+ return `${v.name}="${esc}"`;
268
+ });
269
+ return `# Materialized by flowviant env sync — DO NOT COMMIT.\n${lines.join('\n')}\n`;
270
+ }
271
+
272
+ /** Write the decrypted env into ONE worktree. Never call on the wiki worktree. */
273
+ export function materializeInto(wt) {
274
+ if (!wt || !existsSync(wt)) return;
275
+ const byFile = new Map();
276
+ for (const v of values) {
277
+ if (!isSafeTarget(v.targetFile)) continue;
278
+ const list = byFile.get(v.targetFile) ?? [];
279
+ list.push(v);
280
+ byFile.set(v.targetFile, list);
281
+ }
282
+
283
+ const written = [];
284
+ for (const [file, list] of byFile) {
285
+ if (isTrackedInGit(wt, file)) {
286
+ warn(`env: "${file}" is tracked in git — refusing to write secrets there (gitignore it). Its keys are NOT materialized.`);
287
+ continue;
288
+ }
289
+ try {
290
+ const abs = join(wt, file);
291
+ mkdirSync(dirname(abs), { recursive: true });
292
+ const body = renderEnvFile(list);
293
+ // Skip an identical rewrite — otherwise every bundle bump touches the
294
+ // file mtime and hot-restarts a running preview dev-server mid-review.
295
+ let prior = null;
296
+ try {
297
+ prior = readFileSync(abs, 'utf8');
298
+ } catch {
299
+ /* new file */
300
+ }
301
+ if (prior !== body) writeFileSync(abs, body, { mode: 0o600 });
302
+ written.push(file);
303
+ } catch (e) {
304
+ warn(`env: could not write ${file} into worktree: ${e.message}`);
305
+ }
306
+ }
307
+
308
+ // Remove files we materialized last time that have no keys now (all deleted,
309
+ // or every key moved elsewhere) — a stale secret file must not linger.
310
+ const prevFiles = lastFilesByWorktree.get(wt) ?? [];
311
+ for (const stale of prevFiles) {
312
+ if (!written.includes(stale) && !isTrackedInGit(wt, stale)) {
313
+ try {
314
+ rmSync(join(wt, stale), { force: true });
315
+ } catch {
316
+ /* best-effort */
317
+ }
318
+ }
319
+ }
320
+ lastFilesByWorktree.set(wt, written);
321
+ if (written.length) excludeInWorktree(wt, written);
322
+ }
323
+
324
+ // ── Uplink scrubbing ───────────────────────────────────────────────────────
325
+
326
+ /** Redact every known secret value from daemon-posted text. Values shorter
327
+ * than the floor ("1", "true") would redact half the stream — skipped. */
328
+ export function scrub(text) {
329
+ if (typeof text !== 'string' || !text || !values.length) return text;
330
+ let out = text;
331
+ for (const v of values) {
332
+ if (typeof v.value === 'string' && v.value.length >= SCRUB_MIN_LENGTH) {
333
+ out = out.split(v.value).join(`[REDACTED:${v.name}]`);
334
+ }
335
+ }
336
+ return out;
337
+ }
338
+
339
+ // ── Roster tick ────────────────────────────────────────────────────────────
340
+
341
+ let busy = false; // one env operation at a time — ticks are cheap to skip
342
+
343
+ /**
344
+ * React to the roster's env block. Returns { changed } — true when the bundle
345
+ * was (re)materialized so the caller refreshes its worktrees.
346
+ */
347
+ export async function handleRosterEnv(env, { projectId } = {}) {
348
+ if (!env || busy) return { changed: false };
349
+ busy = true;
350
+ try {
351
+ await ensureKeypair();
352
+ if (projectId) cachedProjectId = projectId;
353
+ // First tick after a restart: warm from the encrypted cache so worktrees
354
+ // can materialize even if the bundle fetch below fails transiently.
355
+ if (bundleVersion < 0 && cachedProjectId) await loadCachedEnv(cachedProjectId);
356
+
357
+ // 1. Introduce this machine (idempotent server-side). registeredOnce is set
358
+ // only AFTER the POST lands — a transient failure must retry next poll, not
359
+ // wedge registration until restart.
360
+ if (env.status === 'none' && !registeredOnce) {
361
+ const label = hostname() || 'daemon';
362
+ await post('register', { pubkey: myPubB64(), label });
363
+ registeredOnce = true;
364
+ const fp = pubkeyEmoji(myPubB64());
365
+ info(`${c.cyan('env')} · this machine requested env access as ${c.bold(label)}`);
366
+ note(` fingerprint ${fp} — an admin approves it in Settings → Environment (compare the emoji).`);
367
+ return { changed: false };
368
+ }
369
+ if (env.status === 'pending') return { changed: false }; // waiting on the admin
370
+ if (env.status === 'revoked') return { changed: false };
371
+
372
+ // 2. Bootstrap: no project keypair exists — this machine creates it.
373
+ if (env.bootstrapNeeded && (env.status === 'approved' || env.status === 'enrolled' || env.status === 'none')) {
374
+ if (env.status === 'none') return { changed: false }; // register first, next tick
375
+ await bootstrapProject();
376
+ return { changed: false }; // next tick syncs as enrolled
377
+ }
378
+ if (env.status !== 'enrolled') return { changed: false };
379
+
380
+ // 3. Wrap jobs + rotation + sync — all need the bundle.
381
+ const needSync = env.bundleVersion !== bundleVersion;
382
+ if (!needSync && !env.pendingWraps && !env.rotationPending) return { changed: false };
383
+ const bundle = await fetchBundle();
384
+ if (!bundle.wrappedPriv || !bundle.projectPub) return { changed: false };
385
+ projectPriv = openSealed(bundle.wrappedPriv, keypair.publicKey, keypair.privateKey);
386
+ const projectPub = sodium.from_base64(bundle.projectPub, B64());
387
+
388
+ // Execute approved enrollments: seal the priv to each new machine. The
389
+ // wrap's epoch rides along — the server rejects (stale) if a rotation moved
390
+ // it since we fetched, so nobody enrolls with a dead key.
391
+ if (bundle.pendingWraps.length) {
392
+ const wraps = bundle.pendingWraps.map((p) => ({
393
+ daemonId: p.daemonId,
394
+ wrappedPriv: seal(projectPriv, p.pubkey),
395
+ }));
396
+ const res = await post('wraps', { pubkey: myPubB64(), keyEpoch: bundle.keyEpoch, wraps });
397
+ if (res?.stale) note(`${c.cyan('env')} ${c.dim('— wraps raced a rotation; retrying next poll')}`);
398
+ else ok(`${c.cyan('env')} ${c.dim(`— delivered the key to ${wraps.length} newly approved machine${wraps.length === 1 ? '' : 's'}`)}`);
399
+ }
400
+
401
+ // Decrypt the values we have — carrying each key's VERSION so a rotation can
402
+ // prove it re-sealed the current value (not one a concurrent write moved).
403
+ const opened = [];
404
+ let allOpened = true;
405
+ for (const k of bundle.keys) {
406
+ try {
407
+ const plain = openSealed(k.ciphertext, projectPub, projectPriv);
408
+ opened.push({ name: k.name, env: k.env, targetFile: k.targetFile, value: sodium.to_string(plain), version: k.version });
409
+ } catch {
410
+ allOpened = false;
411
+ warn(`env: could not open ${k.name} (epoch ${k.keyEpoch}) — skipping; a rotation should heal it`);
412
+ }
413
+ }
414
+
415
+ // Execute a pending rotation: new keypair, full coverage, all wraps. If we
416
+ // couldn't open every value, DON'T attempt — a partial rotate would fail
417
+ // the server's coverage check; let another enrolled daemon (which can open
418
+ // them) do it. Server serializes concurrent executors via a claim lock.
419
+ if (bundle.rotationPending) {
420
+ if (!allOpened) {
421
+ warn(`env: skipping rotation — this machine can't open every value; another daemon will rotate`);
422
+ return { changed: false };
423
+ }
424
+ const next = sodium.crypto_box_keypair();
425
+ const nextPubB64 = sodium.to_base64(next.publicKey, B64());
426
+ const res = await post('rotate', {
427
+ pubkey: myPubB64(),
428
+ fromEpoch: bundle.keyEpoch,
429
+ projectPub: nextPubB64,
430
+ values: opened.map((v) => ({ name: v.name, env: v.env, ciphertext: seal(sodium.from_string(v.value), nextPubB64), version: v.version })),
431
+ wraps: bundle.enrolledDaemons.map((d) => ({ daemonId: d.daemonId, wrappedPriv: seal(next.privateKey, d.pubkey) })),
432
+ ...(bundle.recoveryPub ? { recoverySealed: seal(next.privateKey, bundle.recoveryPub) } : {}),
433
+ }).catch((e) => {
434
+ // epoch_stale / value_moved / coverage → a concurrent change; the next
435
+ // poll re-fetches and retries. Not fatal.
436
+ note(`${c.cyan('env')} ${c.dim(`— rotation deferred (${e.message}); retrying next poll`)}`);
437
+ return null;
438
+ });
439
+ if (res) ok(`${c.cyan('env')} ${c.dim('— project key rotated (a machine was revoked); next poll syncs the new epoch')}`);
440
+ return { changed: false }; // resync on the next tick at the new version
441
+ }
442
+
443
+ if (needSync) {
444
+ values = opened;
445
+ bundleVersion = bundle.bundleVersion;
446
+ if (cachedProjectId) writeCache(cachedProjectId, { values, bundleVersion });
447
+ ok(`${c.cyan('env')} ${c.dim(`— synced ${values.length} secret${values.length === 1 ? '' : 's'} (env v${bundleVersion})`)}`);
448
+ return { changed: true };
449
+ }
450
+ return { changed: false };
451
+ } catch (e) {
452
+ warn(`env sync: ${e.message} — will retry next poll`);
453
+ return { changed: false };
454
+ } finally {
455
+ busy = false;
456
+ }
457
+ }
458
+
459
+ /** First machine creates the project keypair + the standing recovery target.
460
+ * The recovery passphrase prints ONCE — rotations re-seal to the same
461
+ * recovery pub, so this passphrase works forever. */
462
+ async function bootstrapProject() {
463
+ const project = sodium.crypto_box_keypair();
464
+ const recovery = sodium.crypto_box_keypair();
465
+ // Human-typable passphrase: 6 groups of 4 from an unambiguous alphabet.
466
+ const ALPHA = 'abcdefghjkmnpqrstuvwxyz23456789';
467
+ const raw = sodium.randombytes_buf(24);
468
+ const passphrase = Array.from(raw, (b, i) => ALPHA[b % ALPHA.length] + ((i + 1) % 4 === 0 && i < 23 ? '-' : '')).join('');
469
+ const salt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES);
470
+ const kdfKey = sodium.crypto_pwhash(
471
+ sodium.crypto_secretbox_KEYBYTES,
472
+ passphrase,
473
+ salt,
474
+ sodium.crypto_pwhash_OPSLIMIT_MODERATE,
475
+ sodium.crypto_pwhash_MEMLIMIT_MODERATE,
476
+ sodium.crypto_pwhash_ALG_DEFAULT
477
+ );
478
+ const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
479
+ const recoverySecret = JSON.stringify({
480
+ pub: sodium.to_base64(recovery.publicKey, B64()),
481
+ priv: sodium.to_base64(recovery.privateKey, B64()),
482
+ });
483
+ const recoveryBlob = [
484
+ sodium.to_base64(salt, B64()),
485
+ sodium.to_base64(nonce, B64()),
486
+ sodium.to_base64(sodium.crypto_secretbox_easy(sodium.from_string(recoverySecret), nonce, kdfKey), B64()),
487
+ ].join(':');
488
+
489
+ await post('bootstrap', {
490
+ pubkey: myPubB64(),
491
+ projectPub: sodium.to_base64(project.publicKey, B64()),
492
+ selfWrap: seal(project.privateKey, myPubB64()),
493
+ recoveryPub: sodium.to_base64(recovery.publicKey, B64()),
494
+ recoveryBlob,
495
+ recoverySealed: seal(project.privateKey, sodium.to_base64(recovery.publicKey, B64())),
496
+ });
497
+
498
+ console.log('');
499
+ ok(`${c.cyan('env')} — this machine created the project's env keypair.`);
500
+ console.log(` ${c.bold('RECOVERY CODE')} ${c.dim('(shown ONCE — save it in a password manager):')}`);
501
+ console.log(` ${c.bold(c.yellow(passphrase))}`);
502
+ note(' If every enrolled machine is ever lost, this code is the only way back into the secrets.');
503
+ console.log('');
504
+ }