tr-plugin-auth 0.0.0-stage → 1.0.1
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/README.md +45 -2
- package/cli.mjs +384 -0
- package/package.json +40 -4
- package/plugin.json +302 -0
- package/src/access-keys.mjs +225 -0
- package/src/agent-dir.mjs +3 -0
- package/src/browser-session.mjs +240 -0
- package/src/cli-secret.mjs +1695 -0
- package/src/config.mjs +4 -0
- package/src/configuration.mjs +19 -0
- package/src/credential-preservation.mjs +28 -0
- package/src/crypto.mjs +195 -0
- package/src/env-import.mjs +502 -0
- package/src/fact-seeker.mjs +3 -0
- package/src/frontmatter.mjs +3 -0
- package/src/google-oauth.mjs +139 -0
- package/src/host-runtime.mjs +7 -0
- package/src/leader-election.mjs +9 -0
- package/src/logger.mjs +3 -0
- package/src/mesh-auth.mjs +7 -0
- package/src/mesh-identity.mjs +16 -0
- package/src/mesh-secrets.mjs +146 -0
- package/src/mesh.mjs +23 -0
- package/src/metadata-state.mjs +17 -0
- package/src/project-id.mjs +3 -0
- package/src/provider-account-sync.mjs +1145 -0
- package/src/provider-catalog.mjs +515 -0
- package/src/provider-rotation-recipes.mjs +299 -0
- package/src/research-gate.mjs +3 -0
- package/src/research-queue.mjs +3 -0
- package/src/rotation-capability.mjs +189 -0
- package/src/routes/auth.mjs +207 -0
- package/src/routes/keys.mjs +61 -0
- package/src/routes/secrets-runtime.mjs +5 -0
- package/src/routes/secrets.mjs +747 -0
- package/src/routes/webauthn.mjs +163 -0
- package/src/runtime-credentials.mjs +19 -0
- package/src/runtime.mjs +3 -0
- package/src/secret-integration-research.mjs +340 -0
- package/src/secrets-env-diff.mjs +132 -0
- package/src/secrets-env-export.mjs +322 -0
- package/src/secrets-gateway.mjs +253 -0
- package/src/secrets-keychain.mjs +83 -0
- package/src/secrets-rekey.mjs +366 -0
- package/src/secrets-remote-deploy.mjs +250 -0
- package/src/secrets-rotate.mjs +437 -0
- package/src/secrets-store.mjs +756 -0
- package/src/secrets-sync.mjs +81 -0
- package/src/secure-file.mjs +71 -0
- package/src/server-auth.mjs +500 -0
- package/src/session-credentials.mjs +71 -0
- package/src/ssss-store.mjs +13 -0
- package/src/stdin.mjs +31 -0
- package/src/throttled-fetch.mjs +3 -0
- package/src/vault.mjs +3 -0
- package/src/vfs-documents.mjs +3 -0
- package/src/webauthn-store.mjs +265 -0
- package/ui/DESIGN.md +19 -0
- package/ui/card.js +274 -0
- package/ui/panel.js +338 -0
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transactional master-password rotation for encrypted Total Recall stores.
|
|
3
|
+
*
|
|
4
|
+
* Secret material stays in process memory. Callers must source the old/new
|
|
5
|
+
* passwords without argv, stdout, logs, or transcripts.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import crypto from 'node:crypto';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { clearDerivedKeyCache, decryptSecrets, encryptSecrets } from './crypto.mjs';
|
|
12
|
+
import { isPlainJsonStore } from './secrets-store.mjs';
|
|
13
|
+
import {
|
|
14
|
+
describeKeychainCarrier,
|
|
15
|
+
readKeychainPassword,
|
|
16
|
+
writeKeychainPassword,
|
|
17
|
+
} from './secrets-keychain.mjs';
|
|
18
|
+
|
|
19
|
+
const MASTER_KEY = 'TR_SECRETS_PASSWORD';
|
|
20
|
+
|
|
21
|
+
function assertObject(value) {
|
|
22
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
23
|
+
throw new Error('Secrets payload must be an object');
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function xmlEscape(value) {
|
|
29
|
+
return String(value)
|
|
30
|
+
.replaceAll('&', '&')
|
|
31
|
+
.replaceAll('<', '<')
|
|
32
|
+
.replaceAll('>', '>')
|
|
33
|
+
.replaceAll('"', '"')
|
|
34
|
+
.replaceAll("'", ''');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function xmlUnescape(value) {
|
|
38
|
+
return String(value)
|
|
39
|
+
.replaceAll(''', "'")
|
|
40
|
+
.replaceAll('"', '"')
|
|
41
|
+
.replaceAll('>', '>')
|
|
42
|
+
.replaceAll('<', '<')
|
|
43
|
+
.replaceAll('&', '&');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseEnvValue(raw) {
|
|
47
|
+
const value = String(raw).trim();
|
|
48
|
+
if (
|
|
49
|
+
value.length >= 2 &&
|
|
50
|
+
((value.startsWith('"') && value.endsWith('"')) ||
|
|
51
|
+
(value.startsWith("'") && value.endsWith("'")))
|
|
52
|
+
) {
|
|
53
|
+
return value.slice(1, -1);
|
|
54
|
+
}
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function readMasterPasswordFromCarrierText(text, type) {
|
|
59
|
+
if (type === 'launchd-plist') {
|
|
60
|
+
const match = String(text).match(
|
|
61
|
+
/<key>\s*TR_SECRETS_PASSWORD\s*<\/key>\s*<string>([\s\S]*?)<\/string>/,
|
|
62
|
+
);
|
|
63
|
+
return match ? xmlUnescape(match[1]) : null;
|
|
64
|
+
}
|
|
65
|
+
if (type === 'env') {
|
|
66
|
+
const line = String(text)
|
|
67
|
+
.split(/\r?\n/)
|
|
68
|
+
.find((entry) => /^\s*(?:export\s+)?TR_SECRETS_PASSWORD\s*=/.test(entry));
|
|
69
|
+
if (!line) return null;
|
|
70
|
+
return parseEnvValue(line.slice(line.indexOf('=') + 1));
|
|
71
|
+
}
|
|
72
|
+
throw new Error(`Unsupported credential carrier type: ${type}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function updateMasterPasswordCarrierText(text, type, newPassword, options = {}) {
|
|
76
|
+
if (type === 'launchd-plist') {
|
|
77
|
+
const source = String(text);
|
|
78
|
+
const expression =
|
|
79
|
+
/(<key>\s*TR_SECRETS_PASSWORD\s*<\/key>\s*<string>)[\s\S]*?(<\/string>)/;
|
|
80
|
+
if (expression.test(source)) {
|
|
81
|
+
return source.replace(expression, `$1${xmlEscape(newPassword)}$2`);
|
|
82
|
+
}
|
|
83
|
+
if (!options.allowInsert) {
|
|
84
|
+
throw new Error('LaunchAgent does not define TR_SECRETS_PASSWORD');
|
|
85
|
+
}
|
|
86
|
+
const environment = /(<key>\s*EnvironmentVariables\s*<\/key>\s*<dict>)/;
|
|
87
|
+
if (!environment.test(source)) {
|
|
88
|
+
throw new Error('LaunchAgent does not define an EnvironmentVariables dictionary');
|
|
89
|
+
}
|
|
90
|
+
return source.replace(
|
|
91
|
+
environment,
|
|
92
|
+
`$1\n\t\t<key>${MASTER_KEY}</key>\n\t\t<string>${xmlEscape(newPassword)}</string>`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (type === 'env') {
|
|
97
|
+
const source = String(text);
|
|
98
|
+
const expression = /^\s*(?:export\s+)?TR_SECRETS_PASSWORD\s*=.*$/m;
|
|
99
|
+
const replacement = `${MASTER_KEY}=${newPassword}`;
|
|
100
|
+
if (expression.test(source)) return source.replace(expression, replacement);
|
|
101
|
+
if (!options.allowInsert) throw new Error('Environment file does not define TR_SECRETS_PASSWORD');
|
|
102
|
+
return `${source.replace(/\s*$/, '')}\n${replacement}\n`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
throw new Error(`Unsupported credential carrier type: ${type}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function readMasterPasswordFromCarrier(carrier) {
|
|
109
|
+
const text = fs.readFileSync(carrier.path, 'utf8');
|
|
110
|
+
return readMasterPasswordFromCarrierText(text, carrier.type);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function generateSecretsMasterPassword(bytes = 48) {
|
|
114
|
+
if (!Number.isInteger(bytes) || bytes < 32) {
|
|
115
|
+
throw new Error('Master password generation requires at least 32 random bytes');
|
|
116
|
+
}
|
|
117
|
+
return crypto.randomBytes(bytes).toString('base64url');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function decodeStore(buffer, oldPassword) {
|
|
121
|
+
// Was a fifth copy of "first byte is `{` therefore plain JSON". An encrypted
|
|
122
|
+
// store opens with a random salt, so that byte is `{` about once in 256
|
|
123
|
+
// stores; the shared check parses instead of guessing.
|
|
124
|
+
if (isPlainJsonStore(buffer)) {
|
|
125
|
+
return { payload: assertObject(JSON.parse(buffer.toString('utf8') || '{}')), encrypted: false };
|
|
126
|
+
}
|
|
127
|
+
if (!oldPassword) throw new Error('Encrypted secrets store has no current master password');
|
|
128
|
+
return { payload: assertObject(await decryptSecrets(buffer, oldPassword)), encrypted: true };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function payloadFingerprint(payload) {
|
|
132
|
+
return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function writeStagedFile(targetPath, data, mode, transactionId) {
|
|
136
|
+
const stagedPath = `${targetPath}.rekey-${transactionId}.tmp`;
|
|
137
|
+
const fd = fs.openSync(stagedPath, 'wx', mode);
|
|
138
|
+
try {
|
|
139
|
+
fs.writeFileSync(fd, data);
|
|
140
|
+
fs.fsyncSync(fd);
|
|
141
|
+
} finally {
|
|
142
|
+
fs.closeSync(fd);
|
|
143
|
+
}
|
|
144
|
+
fs.chmodSync(stagedPath, mode);
|
|
145
|
+
return stagedPath;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function fsyncDirectory(targetPath) {
|
|
149
|
+
let fd;
|
|
150
|
+
try {
|
|
151
|
+
fd = fs.openSync(path.dirname(targetPath), 'r');
|
|
152
|
+
fs.fsyncSync(fd);
|
|
153
|
+
} catch {
|
|
154
|
+
// Some filesystems do not allow directory fsync. File fsync + atomic rename
|
|
155
|
+
// still provides the strongest portable behavior available here.
|
|
156
|
+
} finally {
|
|
157
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Re-encrypt stores and update persistent credential carriers as one rollback-
|
|
163
|
+
* protected transaction. Backups encrypted with the old password are deleted
|
|
164
|
+
* immediately after verification succeeds.
|
|
165
|
+
*/
|
|
166
|
+
export async function rekeySecretsTransaction({
|
|
167
|
+
storePaths,
|
|
168
|
+
carriers,
|
|
169
|
+
keychainCarriers,
|
|
170
|
+
oldPassword,
|
|
171
|
+
newPassword,
|
|
172
|
+
allowStoreOnly = false,
|
|
173
|
+
}) {
|
|
174
|
+
const uniqueStores = [...new Set((storePaths || []).filter(Boolean))];
|
|
175
|
+
const uniqueCarriers = [...new Map((carriers || []).map((item) => [item.path, item])).values()];
|
|
176
|
+
const uniqueKeychain = [
|
|
177
|
+
...new Map(
|
|
178
|
+
(keychainCarriers || []).map((item) => [`${item.service}\u0000${item.account || ''}`, item]),
|
|
179
|
+
).values(),
|
|
180
|
+
];
|
|
181
|
+
if (!uniqueStores.length) throw new Error('No secrets stores were provided');
|
|
182
|
+
if (!uniqueCarriers.length && !uniqueKeychain.length && !allowStoreOnly) {
|
|
183
|
+
throw new Error('No persistent credential carriers were provided');
|
|
184
|
+
}
|
|
185
|
+
if (!oldPassword) throw new Error('Current master password is required');
|
|
186
|
+
if (!newPassword || newPassword.length < 32 || newPassword === oldPassword) {
|
|
187
|
+
throw new Error('New master password must be distinct and at least 32 characters');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const targets = new Set();
|
|
191
|
+
for (const targetPath of [...uniqueStores, ...uniqueCarriers.map((item) => item.path)]) {
|
|
192
|
+
if (targets.has(targetPath)) throw new Error(`Duplicate rekey target: ${targetPath}`);
|
|
193
|
+
targets.add(targetPath);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Checked before anything is staged: a Keychain entry holding a password that
|
|
197
|
+
// is neither the current nor the new one means someone else has already moved
|
|
198
|
+
// it, and continuing would leave the fleet split across two passwords.
|
|
199
|
+
for (const carrier of uniqueKeychain) {
|
|
200
|
+
const current = readKeychainPassword(carrier);
|
|
201
|
+
if (current && current !== oldPassword && current !== newPassword) {
|
|
202
|
+
throw new Error(
|
|
203
|
+
`Keychain entry does not match the current master password: ${describeKeychainCarrier(carrier)}`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const transactionId = crypto.randomUUID();
|
|
209
|
+
const writes = [];
|
|
210
|
+
const storeChecks = [];
|
|
211
|
+
/** Prior Keychain values, kept so a late failure can put them back. */
|
|
212
|
+
const keychainRestores = [];
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
for (const storePath of uniqueStores) {
|
|
216
|
+
if (!fs.existsSync(storePath)) continue;
|
|
217
|
+
const original = fs.readFileSync(storePath);
|
|
218
|
+
const decoded = await decodeStore(original, oldPassword);
|
|
219
|
+
const encrypted = await encryptSecrets(decoded.payload, newPassword);
|
|
220
|
+
const verified = assertObject(await decryptSecrets(encrypted, newPassword));
|
|
221
|
+
const expectedFingerprint = payloadFingerprint(decoded.payload);
|
|
222
|
+
if (payloadFingerprint(verified) !== expectedFingerprint) {
|
|
223
|
+
throw new Error(`Rekey verification failed for ${storePath}`);
|
|
224
|
+
}
|
|
225
|
+
writes.push({
|
|
226
|
+
path: storePath,
|
|
227
|
+
original,
|
|
228
|
+
mode: 0o600,
|
|
229
|
+
staged: writeStagedFile(storePath, encrypted, 0o600, transactionId),
|
|
230
|
+
});
|
|
231
|
+
storeChecks.push({
|
|
232
|
+
path: storePath,
|
|
233
|
+
expectedFingerprint,
|
|
234
|
+
keys: Object.keys(decoded.payload).length,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
if (!storeChecks.length) throw new Error('None of the provided secrets stores exist');
|
|
238
|
+
|
|
239
|
+
for (const carrier of uniqueCarriers) {
|
|
240
|
+
const original = fs.readFileSync(carrier.path);
|
|
241
|
+
const source = original.toString('utf8');
|
|
242
|
+
const current = readMasterPasswordFromCarrierText(source, carrier.type);
|
|
243
|
+
if (current && current !== oldPassword) {
|
|
244
|
+
throw new Error(`Credential carrier does not match the current master password: ${carrier.path}`);
|
|
245
|
+
}
|
|
246
|
+
if (!current && !carrier.allowInsert) {
|
|
247
|
+
throw new Error(`Credential carrier is missing TR_SECRETS_PASSWORD: ${carrier.path}`);
|
|
248
|
+
}
|
|
249
|
+
const updated = updateMasterPasswordCarrierText(source, carrier.type, newPassword, {
|
|
250
|
+
allowInsert: carrier.allowInsert,
|
|
251
|
+
});
|
|
252
|
+
if (readMasterPasswordFromCarrierText(updated, carrier.type) !== newPassword) {
|
|
253
|
+
throw new Error(`Credential carrier verification failed: ${carrier.path}`);
|
|
254
|
+
}
|
|
255
|
+
const mode = fs.statSync(carrier.path).mode & 0o777;
|
|
256
|
+
writes.push({
|
|
257
|
+
path: carrier.path,
|
|
258
|
+
original,
|
|
259
|
+
mode,
|
|
260
|
+
staged: writeStagedFile(carrier.path, updated, mode, transactionId),
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
for (const write of writes) {
|
|
265
|
+
write.backup = `${write.path}.rekey-${transactionId}.backup`;
|
|
266
|
+
fs.copyFileSync(write.path, write.backup, fs.constants.COPYFILE_EXCL);
|
|
267
|
+
fs.chmodSync(write.backup, write.mode);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const applied = [];
|
|
271
|
+
try {
|
|
272
|
+
for (const write of writes) {
|
|
273
|
+
fs.renameSync(write.staged, write.path);
|
|
274
|
+
fs.chmodSync(write.path, write.mode);
|
|
275
|
+
fsyncDirectory(write.path);
|
|
276
|
+
applied.push(write);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
for (const check of storeChecks) {
|
|
280
|
+
const current = fs.readFileSync(check.path);
|
|
281
|
+
const verified = assertObject(await decryptSecrets(current, newPassword));
|
|
282
|
+
if (payloadFingerprint(verified) !== check.expectedFingerprint) {
|
|
283
|
+
throw new Error(`Post-commit verification failed for ${check.path}`);
|
|
284
|
+
}
|
|
285
|
+
let oldPasswordAccepted = false;
|
|
286
|
+
try {
|
|
287
|
+
await decryptSecrets(current, oldPassword);
|
|
288
|
+
oldPasswordAccepted = true;
|
|
289
|
+
} catch {
|
|
290
|
+
// Expected: AES-GCM authentication rejects the retired key.
|
|
291
|
+
}
|
|
292
|
+
if (oldPasswordAccepted) throw new Error(`Retired master password still decrypts ${check.path}`);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
for (const carrier of uniqueCarriers) {
|
|
296
|
+
if (readMasterPasswordFromCarrier(carrier) !== newPassword) {
|
|
297
|
+
throw new Error(`Post-commit credential carrier verification failed: ${carrier.path}`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Last, because it is the only participant that cannot be staged: the
|
|
302
|
+
// Keychain is written in place. Its previous value is captured first so
|
|
303
|
+
// the rollback below can restore it alongside the files.
|
|
304
|
+
for (const carrier of uniqueKeychain) {
|
|
305
|
+
keychainRestores.push({ carrier, previous: readKeychainPassword(carrier) });
|
|
306
|
+
writeKeychainPassword({ ...carrier, password: newPassword });
|
|
307
|
+
if (readKeychainPassword(carrier) !== newPassword) {
|
|
308
|
+
throw new Error(
|
|
309
|
+
`Post-commit Keychain verification failed: ${describeKeychainCarrier(carrier)}`,
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
} catch (error) {
|
|
314
|
+
// A Keychain entry that cannot be put back leaves this machine holding a
|
|
315
|
+
// password the store no longer accepts — the exact failure this carrier
|
|
316
|
+
// exists to prevent, and invisible unless it is said out loud. It must not
|
|
317
|
+
// replace the original error, so it is appended to it.
|
|
318
|
+
const unrestored = [];
|
|
319
|
+
for (const { carrier, previous } of keychainRestores.reverse()) {
|
|
320
|
+
try {
|
|
321
|
+
if (previous) writeKeychainPassword({ ...carrier, password: previous });
|
|
322
|
+
} catch {
|
|
323
|
+
unrestored.push(describeKeychainCarrier(carrier));
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (unrestored.length) {
|
|
327
|
+
error.message +=
|
|
328
|
+
`\nAND the previous password could not be restored to: ${unrestored.join(', ')}.` +
|
|
329
|
+
' Shells reading those entries will fail to decrypt until they are set by hand.';
|
|
330
|
+
}
|
|
331
|
+
for (const write of applied.reverse()) {
|
|
332
|
+
if (write.backup && fs.existsSync(write.backup)) {
|
|
333
|
+
fs.renameSync(write.backup, write.path);
|
|
334
|
+
fs.chmodSync(write.path, write.mode);
|
|
335
|
+
fsyncDirectory(write.path);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
throw error;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
for (const write of writes) {
|
|
342
|
+
if (write.backup && fs.existsSync(write.backup)) fs.rmSync(write.backup, { force: true });
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return {
|
|
346
|
+
stores: storeChecks.map(({ path: storePath, keys }) => ({ path: storePath, keys })),
|
|
347
|
+
carriers: uniqueCarriers.map((carrier) => ({ path: carrier.path, type: carrier.type })),
|
|
348
|
+
keychain: uniqueKeychain.map((carrier) => ({
|
|
349
|
+
service: carrier.service,
|
|
350
|
+
account: carrier.account || process.env.USER,
|
|
351
|
+
})),
|
|
352
|
+
old_password_rejected: true,
|
|
353
|
+
};
|
|
354
|
+
} finally {
|
|
355
|
+
// Both passwords were derived against during this transaction. The cache
|
|
356
|
+
// keys on the password digest so a stale entry can never be SERVED, but
|
|
357
|
+
// there is no reason to keep the retired key sitting in memory once the
|
|
358
|
+
// rotation is over — and on the failure path the staged-but-abandoned new
|
|
359
|
+
// key should go too.
|
|
360
|
+
clearDerivedKeyCache();
|
|
361
|
+
for (const write of writes) {
|
|
362
|
+
if (write.staged && fs.existsSync(write.staged)) fs.rmSync(write.staged, { force: true });
|
|
363
|
+
if (write.backup && fs.existsSync(write.backup)) fs.rmSync(write.backup, { force: true });
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Push the Total Recall secrets-store SSOT to a remote deploy target over SSH.
|
|
3
|
+
*
|
|
4
|
+
* Generic by design — Total Recall core never hardcodes a hostname, path, or
|
|
5
|
+
* product name. Every target (host, remote path, restart command) is
|
|
6
|
+
* declared by the user in this repo's own remote-targets.json, the same way
|
|
7
|
+
* repos/scopes are declared for export-env. This module only knows how to:
|
|
8
|
+
* 1. build the same dotenv projection export-env already builds,
|
|
9
|
+
* 2. fetch + merge it into a REMOTE file over SSH (never local disk),
|
|
10
|
+
* 3. optionally run a user-declared restart command over SSH.
|
|
11
|
+
*
|
|
12
|
+
* Secret values never touch argv, shell history, or process listings on
|
|
13
|
+
* either end — they're piped over stdin to a remote `cat > file` and never
|
|
14
|
+
* echoed back. Callers must not log the returned `dotenv`/`map` fields.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { spawnSync } from 'node:child_process';
|
|
20
|
+
import { buildEnvProjection, mergeEnvManagedBlock, projectSlugFromPath } from './secrets-env-export.mjs';
|
|
21
|
+
import { resolveSecretsPath } from './secrets-store.mjs';
|
|
22
|
+
import { authState } from './metadata-state.mjs';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {string} brainDir
|
|
26
|
+
*/
|
|
27
|
+
export function resolveRemoteTargetsPath(brainDir) {
|
|
28
|
+
return path.join(path.dirname(resolveSecretsPath(brainDir)), 'remote-targets.json');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @typedef {{
|
|
33
|
+
* name: string,
|
|
34
|
+
* host: string,
|
|
35
|
+
* user?: string,
|
|
36
|
+
* port?: number,
|
|
37
|
+
* remotePath: string,
|
|
38
|
+
* filename?: string,
|
|
39
|
+
* restartCommand?: string,
|
|
40
|
+
* sshOpts?: string[],
|
|
41
|
+
* keys?: string[],
|
|
42
|
+
* includeGlobal?: boolean,
|
|
43
|
+
* }} RemoteTarget
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/** @param {string} brainDir @returns {RemoteTarget[]} */
|
|
47
|
+
export function loadRemoteTargets(brainDir) {
|
|
48
|
+
const p = resolveRemoteTargetsPath(brainDir);
|
|
49
|
+
const data = authState(p).read([]);
|
|
50
|
+
if (!Array.isArray(data)) throw new Error('Invalid auth deployment target registry');
|
|
51
|
+
return data;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** @param {string} brainDir @param {RemoteTarget[]} targets */
|
|
55
|
+
export function saveRemoteTargets(brainDir, targets) {
|
|
56
|
+
const p = resolveRemoteTargetsPath(brainDir);
|
|
57
|
+
if (!Array.isArray(targets)) throw new Error('Invalid auth deployment target registry');
|
|
58
|
+
const store = authState(p); store.read([]); store.write(targets);
|
|
59
|
+
return store.documentPath;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** @param {string} brainDir @param {RemoteTarget} target */
|
|
63
|
+
export function addRemoteTarget(brainDir, target) {
|
|
64
|
+
if (!target?.name) throw new Error('Remote target requires a name');
|
|
65
|
+
if (!target?.host) throw new Error('Remote target requires a host');
|
|
66
|
+
if (!target?.remotePath) throw new Error('Remote target requires remotePath (remote directory)');
|
|
67
|
+
const targets = loadRemoteTargets(brainDir).filter((t) => t.name !== target.name);
|
|
68
|
+
targets.push({
|
|
69
|
+
name: target.name,
|
|
70
|
+
host: target.host,
|
|
71
|
+
user: target.user || 'root',
|
|
72
|
+
port: target.port || 22,
|
|
73
|
+
remotePath: target.remotePath,
|
|
74
|
+
filename: target.filename || '.env',
|
|
75
|
+
restartCommand: target.restartCommand || null,
|
|
76
|
+
sshOpts: target.sshOpts || [],
|
|
77
|
+
keys: target.keys || undefined,
|
|
78
|
+
includeGlobal: target.includeGlobal !== false,
|
|
79
|
+
});
|
|
80
|
+
const p = saveRemoteTargets(brainDir, targets);
|
|
81
|
+
return { path: p, targets };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** @param {string} brainDir @param {string} name */
|
|
85
|
+
export function removeRemoteTarget(brainDir, name) {
|
|
86
|
+
const targets = loadRemoteTargets(brainDir);
|
|
87
|
+
const next = targets.filter((t) => t.name !== name);
|
|
88
|
+
const removed = next.length !== targets.length;
|
|
89
|
+
if (removed) saveRemoteTargets(brainDir, next);
|
|
90
|
+
return { removed, targets: next };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function sshBaseArgs(target) {
|
|
94
|
+
return [
|
|
95
|
+
'-o', 'BatchMode=yes',
|
|
96
|
+
'-o', 'ConnectTimeout=8',
|
|
97
|
+
'-p', String(target.port || 22),
|
|
98
|
+
...(target.sshOpts || []),
|
|
99
|
+
];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function sshDestination(target) {
|
|
103
|
+
return `${target.user || 'root'}@${target.host}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Run a remote command over SSH, optionally piping stdin. Never uses a
|
|
108
|
+
* shell-interpolated string built from secret values — the remote command
|
|
109
|
+
* itself is a fixed, non-secret string; the secret body travels over stdin.
|
|
110
|
+
*/
|
|
111
|
+
function runSsh(target, remoteCommand, { input } = {}) {
|
|
112
|
+
const args = [...sshBaseArgs(target), sshDestination(target), remoteCommand];
|
|
113
|
+
const result = spawnSync('ssh', args, {
|
|
114
|
+
input: input != null ? input : undefined,
|
|
115
|
+
encoding: 'utf8',
|
|
116
|
+
timeout: 20000,
|
|
117
|
+
});
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function quoteRemotePath(p) {
|
|
122
|
+
// POSIX single-quote escaping — safe for arbitrary remote paths.
|
|
123
|
+
return `'${String(p).replace(/'/g, `'\\''`)}'`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Fetch the current remote file body (empty string if absent/unreachable-file).
|
|
128
|
+
*/
|
|
129
|
+
function fetchRemoteFile(target, remoteFile) {
|
|
130
|
+
const r = runSsh(target, `cat ${quoteRemotePath(remoteFile)} 2>/dev/null || true`);
|
|
131
|
+
if (r.error) throw new Error(`SSH connect failed for target "${target.name}": ${r.error.message}`);
|
|
132
|
+
return r.stdout || '';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Write body to the remote file via stdin (never as an argv/shell string),
|
|
137
|
+
* then chmod 600. Uses a temp file + atomic rename on the remote side so a
|
|
138
|
+
* dropped connection mid-write can't leave a truncated secrets file live.
|
|
139
|
+
*/
|
|
140
|
+
function writeRemoteFile(target, remoteFile, body) {
|
|
141
|
+
const tmp = `${remoteFile}.tr-tmp.$$`;
|
|
142
|
+
const cmd =
|
|
143
|
+
`set -e; TMP="${remoteFile}.tr-tmp.$$"; ` +
|
|
144
|
+
`cat > "$TMP" && chmod 600 "$TMP" && mv "$TMP" ${quoteRemotePath(remoteFile)}`;
|
|
145
|
+
const r = runSsh(target, cmd, { input: body });
|
|
146
|
+
if (r.error) throw new Error(`SSH write failed for target "${target.name}": ${r.error.message}`);
|
|
147
|
+
if (r.status !== 0) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
`Remote write failed for target "${target.name}" (exit ${r.status}): ${(r.stderr || '').slice(0, 500)}`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Deploy the SSOT projection to one configured remote target.
|
|
157
|
+
*
|
|
158
|
+
* @param {string} brainDir
|
|
159
|
+
* @param {string} targetName
|
|
160
|
+
* @param {{ dryRun?: boolean, keys?: string[], skipRestart?: boolean }} opts
|
|
161
|
+
*/
|
|
162
|
+
export async function deployEnvToRemote(brainDir, targetName, opts = {}) {
|
|
163
|
+
const targets = loadRemoteTargets(brainDir);
|
|
164
|
+
const target = targets.find((t) => t.name === targetName);
|
|
165
|
+
if (!target) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`Unknown remote target "${targetName}". Configure one first: npx total-recall secret remote add ${targetName} --host <host> --path </remote/dir>`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Match the same projection local `export-env --path .` builds: repo-bound
|
|
172
|
+
// secrets (set with --repo <name>) only resolve when the caller's project
|
|
173
|
+
// slug is passed through. Without this, deployEnvToRemote silently dropped
|
|
174
|
+
// every repo-scoped key and pushed only unbound/global ones — the opposite
|
|
175
|
+
// of what --repo scoping is for. cwd is the right default since this CLI
|
|
176
|
+
// is always invoked from the repo root (same assumption --path . makes).
|
|
177
|
+
const projectPath = opts.projectPath || process.cwd();
|
|
178
|
+
const projection = await buildEnvProjection(brainDir, {
|
|
179
|
+
includeGlobal: target.includeGlobal !== false,
|
|
180
|
+
keys: opts.keys?.length ? opts.keys : target.keys,
|
|
181
|
+
projectPath,
|
|
182
|
+
projectSlug: opts.projectSlug || projectSlugFromPath(projectPath),
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const remoteFile = path.posix.join(target.remotePath, target.filename || '.env');
|
|
186
|
+
|
|
187
|
+
if (opts.dryRun) {
|
|
188
|
+
return {
|
|
189
|
+
dryRun: true,
|
|
190
|
+
target: target.name,
|
|
191
|
+
host: target.host,
|
|
192
|
+
remoteFile,
|
|
193
|
+
keys: projection.keys,
|
|
194
|
+
count: projection.count,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (!projection.count) {
|
|
199
|
+
return {
|
|
200
|
+
dryRun: false,
|
|
201
|
+
target: target.name,
|
|
202
|
+
host: target.host,
|
|
203
|
+
remoteFile,
|
|
204
|
+
keys: [],
|
|
205
|
+
count: 0,
|
|
206
|
+
skipped: 'no matching keys',
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const existing = fetchRemoteFile(target, remoteFile);
|
|
211
|
+
const merged = mergeEnvManagedBlock(existing, projection.body);
|
|
212
|
+
writeRemoteFile(target, remoteFile, merged);
|
|
213
|
+
|
|
214
|
+
let restarted = false;
|
|
215
|
+
let restartOutput = null;
|
|
216
|
+
if (target.restartCommand && !opts.skipRestart) {
|
|
217
|
+
const r = runSsh(target, target.restartCommand);
|
|
218
|
+
restarted = r.status === 0;
|
|
219
|
+
restartOutput = (r.stdout || '') + (r.stderr || '');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
dryRun: false,
|
|
224
|
+
target: target.name,
|
|
225
|
+
host: target.host,
|
|
226
|
+
remoteFile,
|
|
227
|
+
keys: projection.keys,
|
|
228
|
+
count: projection.count,
|
|
229
|
+
restarted,
|
|
230
|
+
restartOutput: restartOutput ? restartOutput.slice(0, 2000) : null,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Deploy to every remote target that has this key bound via `keys`, or to
|
|
236
|
+
* every target when the key list is unset (target opts into "all keys").
|
|
237
|
+
*/
|
|
238
|
+
export async function deployKeyToRemotes(brainDir, key, opts = {}) {
|
|
239
|
+
const targets = loadRemoteTargets(brainDir);
|
|
240
|
+
const matching = targets.filter((t) => !t.keys?.length || t.keys.includes(key));
|
|
241
|
+
const results = [];
|
|
242
|
+
for (const t of matching) {
|
|
243
|
+
try {
|
|
244
|
+
results.push(await deployEnvToRemote(brainDir, t.name, opts));
|
|
245
|
+
} catch (err) {
|
|
246
|
+
results.push({ target: t.name, host: t.host, ok: false, error: err.message });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return results;
|
|
250
|
+
}
|