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,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Materialize .env files FROM the Total Recall secrets store.
|
|
3
|
+
*
|
|
4
|
+
* Model: TR secrets.enc is the SSOT for all user API keys.
|
|
5
|
+
* Repo / deploy .env files are projections — never the other way around.
|
|
6
|
+
*
|
|
7
|
+
* Never write secret values into vault markdown, openwiki, or surfaces.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import { loadSecrets, listSecretsMeta, resolveSecretsPath } from './secrets-store.mjs';
|
|
14
|
+
|
|
15
|
+
const META_KEY = '__tr_secrets_meta';
|
|
16
|
+
|
|
17
|
+
const BEGIN_MARK = '# BEGIN TOTAL-RECALL-SECRETS';
|
|
18
|
+
const END_MARK = '# END TOTAL-RECALL-SECRETS';
|
|
19
|
+
|
|
20
|
+
function envHeader() {
|
|
21
|
+
return `${BEGIN_MARK}
|
|
22
|
+
# Generated by Total Recall — do not hand-edit secret values here.
|
|
23
|
+
# SSOT: secrets store (npx total-recall secret …)
|
|
24
|
+
# Re-export: npx total-recall secret export-env --path <repo>
|
|
25
|
+
# ${new Date().toISOString()}
|
|
26
|
+
`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Merge a managed TR secrets block into an existing .env without wiping
|
|
31
|
+
* non-secret / hand-managed keys outside the BEGIN/END markers.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} existing - current file contents (or '')
|
|
34
|
+
* @param {string} managedBody - full block including BEGIN/END marks
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
export function mergeEnvManagedBlock(existing, managedBody) {
|
|
38
|
+
const managed = String(managedBody || '').trimEnd() + '\n';
|
|
39
|
+
const text = existing == null ? '' : String(existing);
|
|
40
|
+
|
|
41
|
+
if (!text.trim()) return managed;
|
|
42
|
+
|
|
43
|
+
const beginIdx = text.indexOf(BEGIN_MARK);
|
|
44
|
+
const endIdx = text.indexOf(END_MARK);
|
|
45
|
+
|
|
46
|
+
if (beginIdx !== -1 && endIdx !== -1 && endIdx > beginIdx) {
|
|
47
|
+
const before = text.slice(0, beginIdx).replace(/\s+$/, '');
|
|
48
|
+
const after = text.slice(endIdx + END_MARK.length).replace(/^\r?\n?/, '');
|
|
49
|
+
const parts = [];
|
|
50
|
+
if (before) parts.push(before.endsWith('\n') ? before : `${before}\n`);
|
|
51
|
+
parts.push(managed.endsWith('\n') ? managed : `${managed}\n`);
|
|
52
|
+
if (after.trim()) {
|
|
53
|
+
parts.push(after.startsWith('\n') ? after : `\n${after}`);
|
|
54
|
+
}
|
|
55
|
+
return parts.join('').replace(/\n{3,}/g, '\n\n');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// No prior TR block — append managed section after existing content
|
|
59
|
+
const base = text.endsWith('\n') ? text : `${text}\n`;
|
|
60
|
+
return `${base}\n${managed}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Resolve a project slug from a filesystem path (last segment).
|
|
65
|
+
* @param {string} projectPath
|
|
66
|
+
*/
|
|
67
|
+
export function projectSlugFromPath(projectPath) {
|
|
68
|
+
if (!projectPath) return null;
|
|
69
|
+
const abs = path.resolve(projectPath);
|
|
70
|
+
// Bindings are stored trimmed, so a directory name with stray whitespace must match.
|
|
71
|
+
return path.basename(abs).trim();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Load project-registry entries (if any).
|
|
76
|
+
*/
|
|
77
|
+
export function loadProjectRegistry() {
|
|
78
|
+
const registryPath = path.join(
|
|
79
|
+
os.homedir(),
|
|
80
|
+
'.agent',
|
|
81
|
+
'skills',
|
|
82
|
+
'total-recall',
|
|
83
|
+
'config',
|
|
84
|
+
'project-registry.json',
|
|
85
|
+
);
|
|
86
|
+
if (!fs.existsSync(registryPath)) return [];
|
|
87
|
+
try {
|
|
88
|
+
const data = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
|
|
89
|
+
return Array.isArray(data) ? data : [];
|
|
90
|
+
} catch {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Decide whether a secret meta row applies to a target project.
|
|
97
|
+
*
|
|
98
|
+
* @param {object} metaRow - from listSecretsMeta
|
|
99
|
+
* @param {{ projectPath?: string, projectSlug?: string, includeGlobal?: boolean, keys?: string[] }} target
|
|
100
|
+
*/
|
|
101
|
+
export function secretMatchesTarget(metaRow, target = {}) {
|
|
102
|
+
if (target.keys?.length && !target.keys.includes(metaRow.key)) return false;
|
|
103
|
+
|
|
104
|
+
const slug = target.projectSlug || projectSlugFromPath(target.projectPath);
|
|
105
|
+
const repos = Array.isArray(metaRow.repos) ? metaRow.repos : [];
|
|
106
|
+
const projectPath = metaRow.project_path || '';
|
|
107
|
+
|
|
108
|
+
// Explicit key list mode
|
|
109
|
+
if (target.keys?.length) return true;
|
|
110
|
+
|
|
111
|
+
// Bound to this repo by slug or path (a key may be bound to several repos)
|
|
112
|
+
if (slug && repos.some((r) => r === slug || r === '*' || r.endsWith(`/${slug}`))) {
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
if (target.projectPath && projectPath) {
|
|
116
|
+
const a = path.resolve(target.projectPath);
|
|
117
|
+
const b = path.resolve(projectPath);
|
|
118
|
+
if (a === b || a.startsWith(b + path.sep) || b.startsWith(a + path.sep)) return true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Unbound secrets: include when includeGlobal (default true for full SSOT export)
|
|
122
|
+
if (target.includeGlobal !== false && repos.length === 0 && !projectPath) {
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Also include when tagged global scope with empty repos
|
|
127
|
+
if (target.includeGlobal !== false && metaRow.scope === 'global' && repos.length === 0) {
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Build dotenv body (KEY=value lines) for matching secrets.
|
|
136
|
+
* @returns {{ body: string, keys: string[], exampleBody: string, count: number }}
|
|
137
|
+
*/
|
|
138
|
+
export async function buildEnvProjection(brainDir, target = {}) {
|
|
139
|
+
const secrets = await loadSecrets(brainDir);
|
|
140
|
+
const metaRows = await listSecretsMeta(brainDir);
|
|
141
|
+
const byKey = new Map(metaRows.map((m) => [m.key, m]));
|
|
142
|
+
|
|
143
|
+
const selected = [];
|
|
144
|
+
for (const [key, value] of Object.entries(secrets)) {
|
|
145
|
+
if (key === META_KEY) continue;
|
|
146
|
+
if (value == null || value === '') continue;
|
|
147
|
+
const meta = byKey.get(key) || { key, repos: [], scope: 'global' };
|
|
148
|
+
if (!secretMatchesTarget(meta, target)) continue;
|
|
149
|
+
selected.push({ key, value: String(value), meta });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
selected.sort((a, b) => a.key.localeCompare(b.key));
|
|
153
|
+
|
|
154
|
+
const lines = selected.map(({ key, value }) => {
|
|
155
|
+
// Quote if needed
|
|
156
|
+
const needsQuote = /[\s#"'$\\]/.test(value);
|
|
157
|
+
const v = needsQuote ? JSON.stringify(value) : value;
|
|
158
|
+
return `${key}=${v}`;
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const exampleLines = selected.map(({ key }) => `${key}=`);
|
|
162
|
+
|
|
163
|
+
const body = `${envHeader()}${lines.join('\n')}${lines.length ? '\n' : ''}${END_MARK}\n`;
|
|
164
|
+
const exampleBody = `# Example env — names only. Values live in Total Recall secrets store.
|
|
165
|
+
# Generate real .env: npx total-recall secret export-env --path .
|
|
166
|
+
${exampleLines.join('\n')}${exampleLines.length ? '\n' : ''}`;
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
body,
|
|
170
|
+
exampleBody,
|
|
171
|
+
keys: selected.map((s) => s.key),
|
|
172
|
+
count: selected.length,
|
|
173
|
+
store: resolveSecretsPath(brainDir),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Write .env (and optional .env.example) into a project directory.
|
|
179
|
+
*
|
|
180
|
+
* @param {string} brainDir
|
|
181
|
+
* @param {string} projectPath - repo root
|
|
182
|
+
* @param {{
|
|
183
|
+
* filename?: string,
|
|
184
|
+
* example?: boolean,
|
|
185
|
+
* dryRun?: boolean,
|
|
186
|
+
* includeGlobal?: boolean,
|
|
187
|
+
* keys?: string[],
|
|
188
|
+
* mode?: number,
|
|
189
|
+
* }} opts
|
|
190
|
+
*/
|
|
191
|
+
export async function exportEnvToProject(brainDir, projectPath, opts = {}) {
|
|
192
|
+
const abs = path.resolve(projectPath);
|
|
193
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) {
|
|
194
|
+
throw new Error(`Project path is not a directory: ${abs}`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const filename = opts.filename || '.env';
|
|
198
|
+
const target = {
|
|
199
|
+
projectPath: abs,
|
|
200
|
+
projectSlug: projectSlugFromPath(abs),
|
|
201
|
+
includeGlobal: opts.includeGlobal !== false,
|
|
202
|
+
keys: opts.keys,
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const projection = await buildEnvProjection(brainDir, target);
|
|
206
|
+
const envPath = path.join(abs, filename);
|
|
207
|
+
const examplePath = path.join(abs, `${filename}.example`);
|
|
208
|
+
|
|
209
|
+
if (opts.dryRun) {
|
|
210
|
+
return {
|
|
211
|
+
dryRun: true,
|
|
212
|
+
envPath,
|
|
213
|
+
examplePath: opts.example !== false ? examplePath : null,
|
|
214
|
+
keys: projection.keys,
|
|
215
|
+
count: projection.count,
|
|
216
|
+
store: projection.store,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const mode = opts.mode ?? 0o600;
|
|
221
|
+
// Merge by default so non-secret APP_NAME=… etc. outside the TR block survive
|
|
222
|
+
const replaceAll = opts.replaceAll === true;
|
|
223
|
+
let finalBody = projection.body;
|
|
224
|
+
let merged = false;
|
|
225
|
+
if (!replaceAll && fs.existsSync(envPath)) {
|
|
226
|
+
try {
|
|
227
|
+
const existing = fs.readFileSync(envPath, 'utf8');
|
|
228
|
+
finalBody = mergeEnvManagedBlock(existing, projection.body);
|
|
229
|
+
merged = true;
|
|
230
|
+
} catch {
|
|
231
|
+
finalBody = projection.body;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
fs.writeFileSync(envPath, finalBody, { encoding: 'utf8', mode });
|
|
235
|
+
try {
|
|
236
|
+
fs.chmodSync(envPath, mode);
|
|
237
|
+
} catch {
|
|
238
|
+
/* windows / fs without chmod */
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
let wroteExample = false;
|
|
242
|
+
if (opts.example !== false) {
|
|
243
|
+
fs.writeFileSync(examplePath, projection.exampleBody, { encoding: 'utf8', mode: 0o644 });
|
|
244
|
+
wroteExample = true;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Ensure .env is gitignored
|
|
248
|
+
ensureGitignoreHasEnv(abs);
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
dryRun: false,
|
|
252
|
+
envPath,
|
|
253
|
+
examplePath: wroteExample ? examplePath : null,
|
|
254
|
+
keys: projection.keys,
|
|
255
|
+
count: projection.count,
|
|
256
|
+
store: projection.store,
|
|
257
|
+
projectSlug: target.projectSlug,
|
|
258
|
+
merged,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Export to multiple registered projects.
|
|
264
|
+
*/
|
|
265
|
+
export async function exportEnvToRegistry(brainDir, opts = {}) {
|
|
266
|
+
const registry = loadProjectRegistry();
|
|
267
|
+
const results = [];
|
|
268
|
+
for (const entry of registry) {
|
|
269
|
+
const projectPath = entry.path || entry.project_root;
|
|
270
|
+
if (!projectPath || !fs.existsSync(projectPath)) {
|
|
271
|
+
results.push({
|
|
272
|
+
name: entry.name,
|
|
273
|
+
path: projectPath,
|
|
274
|
+
ok: false,
|
|
275
|
+
error: 'path missing',
|
|
276
|
+
});
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
try {
|
|
280
|
+
const r = await exportEnvToProject(brainDir, projectPath, opts);
|
|
281
|
+
results.push({ name: entry.name, ...r, ok: true });
|
|
282
|
+
} catch (err) {
|
|
283
|
+
results.push({ name: entry.name, path: projectPath, ok: false, error: err.message });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return results;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Build a deploy payload: dotenv text + optional JSON map (no disk write).
|
|
291
|
+
*/
|
|
292
|
+
export async function buildDeploySecretsPayload(brainDir, target = {}) {
|
|
293
|
+
const projection = await buildEnvProjection(brainDir, {
|
|
294
|
+
includeGlobal: true,
|
|
295
|
+
...target,
|
|
296
|
+
});
|
|
297
|
+
const secrets = await loadSecrets(brainDir);
|
|
298
|
+
const map = {};
|
|
299
|
+
for (const key of projection.keys) {
|
|
300
|
+
if (secrets[key] != null) map[key] = String(secrets[key]);
|
|
301
|
+
}
|
|
302
|
+
return {
|
|
303
|
+
dotenv: projection.body,
|
|
304
|
+
map,
|
|
305
|
+
keys: projection.keys,
|
|
306
|
+
count: projection.count,
|
|
307
|
+
store: projection.store,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function ensureGitignoreHasEnv(projectPath) {
|
|
312
|
+
const gi = path.join(projectPath, '.gitignore');
|
|
313
|
+
const linesNeeded = ['.env', '.env.local', '.env.*.local'];
|
|
314
|
+
let content = '';
|
|
315
|
+
if (fs.existsSync(gi)) {
|
|
316
|
+
content = fs.readFileSync(gi, 'utf8');
|
|
317
|
+
}
|
|
318
|
+
const missing = linesNeeded.filter((l) => !content.split(/\r?\n/).some((line) => line.trim() === l));
|
|
319
|
+
if (!missing.length) return;
|
|
320
|
+
const block = `\n# Total Recall secrets projections\n${missing.join('\n')}\n`;
|
|
321
|
+
fs.appendFileSync(gi, content.endsWith('\n') || !content ? block : `\n${block}`);
|
|
322
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secrets store — portable keys separate from the SSSS memory vault.
|
|
3
|
+
*
|
|
4
|
+
* Format: AES-256-GCM ciphertext at <brain>/config/secrets.enc with mode 0o600.
|
|
5
|
+
*
|
|
6
|
+
* Never write secret values into vault markdown, openwiki, or compiled surfaces.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from 'fs';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import crypto from 'crypto';
|
|
12
|
+
import os from 'os';
|
|
13
|
+
import { encryptSecrets, decryptSecrets, encryptSecretsSync, decryptSecretsSync } from './crypto.mjs';
|
|
14
|
+
import { DEFAULT_KEYCHAIN_SERVICE, readKeychainPassword } from './secrets-keychain.mjs';
|
|
15
|
+
import { writeFileSecure, appendFileSecure } from './secure-file.mjs';
|
|
16
|
+
import { appendAuthMetadata } from './metadata-state.mjs';
|
|
17
|
+
|
|
18
|
+
export const META_KEY = '__tr_secrets_meta';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {string} brainDir
|
|
22
|
+
*/
|
|
23
|
+
export function resolveSecretsPath(brainDir) {
|
|
24
|
+
if (brainDir.endsWith('.agent')) {
|
|
25
|
+
return path.join(brainDir, 'secrets.enc');
|
|
26
|
+
}
|
|
27
|
+
return path.join(brainDir, 'config', 'secrets.enc');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {string} brainDir
|
|
32
|
+
*/
|
|
33
|
+
export function resolveAuditPath(brainDir) {
|
|
34
|
+
return path.join(brainDir, 'logs', 'secrets-audit.jsonl');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {string} brainDir
|
|
39
|
+
*/
|
|
40
|
+
export function resolveUsagePath(brainDir) {
|
|
41
|
+
return path.join(brainDir, 'logs', 'usage.jsonl');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The master password: the environment first, then — on macOS — the Keychain
|
|
46
|
+
* entry `secret rekey` already maintains (service `total-recall-secrets`,
|
|
47
|
+
* overridable with TR_SECRETS_KEYCHAIN_SERVICE), then the host env file
|
|
48
|
+
* (see readTrEnvFilePassword).
|
|
49
|
+
*
|
|
50
|
+
* Only an interactive shell profile used to read the Keychain, so a
|
|
51
|
+
* non-interactive process on the same Mac — a mesh `exec`, a release script —
|
|
52
|
+
* could not open a store its own user owns. Resolved once per process; the
|
|
53
|
+
* value is returned to the caller and never logged. TR_SECRETS_NO_KEYCHAIN=1
|
|
54
|
+
* disables the fallback.
|
|
55
|
+
*/
|
|
56
|
+
let keychainPassword;
|
|
57
|
+
export function secretsPassword({
|
|
58
|
+
env = process.env,
|
|
59
|
+
readKeychain = readTrKeychainPassword,
|
|
60
|
+
readEnvFile = readTrEnvFilePassword,
|
|
61
|
+
} = {}) {
|
|
62
|
+
const fromEnv = env.TR_SECRETS_PASSWORD || env.TR_MASTER_PASSWORD;
|
|
63
|
+
if (fromEnv) return fromEnv;
|
|
64
|
+
if (env.TR_SECRETS_NO_KEYCHAIN !== '1') {
|
|
65
|
+
let fromKeychain;
|
|
66
|
+
if (readKeychain !== readTrKeychainPassword) fromKeychain = readKeychain(env) || null;
|
|
67
|
+
else {
|
|
68
|
+
if (keychainPassword === undefined) keychainPassword = readKeychain(env) || null;
|
|
69
|
+
fromKeychain = keychainPassword;
|
|
70
|
+
}
|
|
71
|
+
if (fromKeychain) return fromKeychain;
|
|
72
|
+
}
|
|
73
|
+
return readEnvFile(env) || null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The host's env-file carrier: `TR_ENV_FILE`, else `~/.agent/tr.env` — the file
|
|
78
|
+
* `auto-pull.sh` sources and `secret rekey --env-file` rotates. Only a shell
|
|
79
|
+
* that sources it used to see the password, so a non-interactive process on a
|
|
80
|
+
* Linux host (an agent's tool shell, cron, a systemd unit) could not open the
|
|
81
|
+
* store its own user owns. The file is honoured only when it is a regular file
|
|
82
|
+
* (not a symlink) that this user owns and no one else can read or write, in a
|
|
83
|
+
* directory this user owns that no one else can write to.
|
|
84
|
+
*/
|
|
85
|
+
export function readTrEnvFilePassword(env = process.env) {
|
|
86
|
+
const file = env.TR_ENV_FILE || path.join(env.HOME || os.homedir(), '.agent', 'tr.env');
|
|
87
|
+
try {
|
|
88
|
+
// lstat: a symlink could point the carrier at a file someone else controls.
|
|
89
|
+
const stat = fs.lstatSync(file);
|
|
90
|
+
if (!stat.isFile() || (stat.mode & 0o077) !== 0) return null;
|
|
91
|
+
// A directory others can write to lets them replace the file.
|
|
92
|
+
const dir = fs.statSync(path.dirname(file));
|
|
93
|
+
if ((dir.mode & 0o022) !== 0) return null;
|
|
94
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : null;
|
|
95
|
+
if (uid !== null && (stat.uid !== uid || dir.uid !== uid)) return null;
|
|
96
|
+
const line = fs
|
|
97
|
+
.readFileSync(file, 'utf8')
|
|
98
|
+
.split(/\r?\n/)
|
|
99
|
+
.find((entry) => /^\s*(?:export\s+)?TR_SECRETS_PASSWORD\s*=/.test(entry));
|
|
100
|
+
if (!line) return null;
|
|
101
|
+
let value = line.slice(line.indexOf('=') + 1).trim();
|
|
102
|
+
if (value.length >= 2 && /^(['"]).*\1$/.test(value)) value = value.slice(1, -1);
|
|
103
|
+
return value || null;
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function readTrKeychainPassword(env) {
|
|
110
|
+
if (process.platform !== 'darwin') return null;
|
|
111
|
+
try {
|
|
112
|
+
return readKeychainPassword({
|
|
113
|
+
service: env.TR_SECRETS_KEYCHAIN_SERVICE || DEFAULT_KEYCHAIN_SERVICE,
|
|
114
|
+
account: env.USER || os.userInfo().username,
|
|
115
|
+
});
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Reject actual plaintext JSON; a ciphertext salt may also begin with a brace. */
|
|
122
|
+
export function isPlainJsonStore(buf) {
|
|
123
|
+
if (!buf?.length || buf[0] !== 0x7b /* { */) return false;
|
|
124
|
+
try {
|
|
125
|
+
const parsed = JSON.parse(buf.toString('utf8'));
|
|
126
|
+
return Boolean(parsed) && typeof parsed === 'object' && !Array.isArray(parsed);
|
|
127
|
+
} catch {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Load raw secrets object synchronously
|
|
134
|
+
*/
|
|
135
|
+
export function loadSecretsSync(brainDir) {
|
|
136
|
+
const filePath = resolveSecretsPath(brainDir);
|
|
137
|
+
if (!fs.existsSync(filePath)) return {};
|
|
138
|
+
|
|
139
|
+
const buf = fs.readFileSync(filePath);
|
|
140
|
+
const password = secretsPassword();
|
|
141
|
+
|
|
142
|
+
if (!password) throw new Error('Unlock auth to read the encrypted credential store');
|
|
143
|
+
if (isPlainJsonStore(buf)) throw new Error('Plaintext credential store refused; encrypted native auth store required');
|
|
144
|
+
return decryptSecretsSync(buf, password);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Save raw secrets object synchronously
|
|
149
|
+
*/
|
|
150
|
+
export function saveSecretsSync(brainDir, obj) {
|
|
151
|
+
const filePath = resolveSecretsPath(brainDir);
|
|
152
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
153
|
+
|
|
154
|
+
const password = secretsPassword();
|
|
155
|
+
if (!password) throw new Error('TR_SECRETS_PASSWORD or TR_MASTER_PASSWORD is required to write secrets');
|
|
156
|
+
const buf = encryptSecretsSync(obj, password);
|
|
157
|
+
writeFileSecure(filePath, buf, { mode: 0o600 });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Load raw secrets object (values included). Caller must not log values.
|
|
162
|
+
* @param {string} brainDir
|
|
163
|
+
*/
|
|
164
|
+
export async function loadSecrets(brainDir) {
|
|
165
|
+
const filePath = resolveSecretsPath(brainDir);
|
|
166
|
+
if (!fs.existsSync(filePath)) return {};
|
|
167
|
+
|
|
168
|
+
const buf = fs.readFileSync(filePath);
|
|
169
|
+
const password = secretsPassword();
|
|
170
|
+
|
|
171
|
+
if (!password) throw new Error('Unlock auth to read the encrypted credential store');
|
|
172
|
+
if (isPlainJsonStore(buf)) throw new Error('Plaintext credential store refused; encrypted native auth store required');
|
|
173
|
+
return decryptSecrets(buf, password);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Persist secrets object.
|
|
178
|
+
* @param {string} brainDir
|
|
179
|
+
* @param {object} secrets
|
|
180
|
+
*/
|
|
181
|
+
export async function saveSecrets(brainDir, secrets, { onlyIfMissing = false } = {}) {
|
|
182
|
+
const filePath = resolveSecretsPath(brainDir);
|
|
183
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
184
|
+
const password = secretsPassword();
|
|
185
|
+
|
|
186
|
+
if (!password) throw new Error('TR_SECRETS_PASSWORD or TR_MASTER_PASSWORD is required to write secrets');
|
|
187
|
+
const buf = await encryptSecrets(secrets, password);
|
|
188
|
+
if (onlyIfMissing) installMissingSecretsBuffer(filePath, buf);
|
|
189
|
+
else writeFileSecure(filePath, buf, { mode: 0o600 });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function validateSecretsBuffer(buffer) {
|
|
193
|
+
const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
|
|
194
|
+
const password = secretsPassword();
|
|
195
|
+
if (!password) throw new Error('Unlock auth to validate encrypted credentials');
|
|
196
|
+
if (isPlainJsonStore(buf)) throw new Error('Plaintext credential import refused; encrypted native auth store required');
|
|
197
|
+
const parsed = await decryptSecrets(buf, password);
|
|
198
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('Invalid encrypted credential payload');
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Validate then atomically replace the encrypted secrets store. */
|
|
203
|
+
function installMissingSecretsBuffer(filePath, buffer) {
|
|
204
|
+
const temporary = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
205
|
+
try {
|
|
206
|
+
fs.writeFileSync(temporary, buffer, { mode: 0o600, flag: 'wx' });
|
|
207
|
+
// A hard-link publishes the complete encrypted file without replacing a
|
|
208
|
+
// store created by another process while encryption/validation awaited.
|
|
209
|
+
fs.linkSync(temporary, filePath);
|
|
210
|
+
} finally {
|
|
211
|
+
if (fs.existsSync(temporary)) fs.unlinkSync(temporary);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export async function replaceSecretsBufferAtomic(brainDir, buffer, { actor = 'mesh-sync', action = 'mesh_sync_replace', onlyIfMissing = false } = {}) {
|
|
216
|
+
await validateSecretsBuffer(buffer);
|
|
217
|
+
const filePath = resolveSecretsPath(brainDir);
|
|
218
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
219
|
+
if (onlyIfMissing) {
|
|
220
|
+
installMissingSecretsBuffer(filePath, buffer);
|
|
221
|
+
appendAudit(brainDir, { action, key: '(encrypted-store)', actor });
|
|
222
|
+
return { success: true, path: filePath };
|
|
223
|
+
}
|
|
224
|
+
const tempPath = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
225
|
+
try {
|
|
226
|
+
fs.writeFileSync(tempPath, buffer, { mode: 0o600 });
|
|
227
|
+
fs.renameSync(tempPath, filePath);
|
|
228
|
+
fs.chmodSync(filePath, 0o600);
|
|
229
|
+
} finally {
|
|
230
|
+
if (fs.existsSync(tempPath)) fs.rmSync(tempPath, { force: true });
|
|
231
|
+
}
|
|
232
|
+
appendAudit(brainDir, { action, key: '(encrypted-store)', actor });
|
|
233
|
+
return { success: true, path: filePath };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function ensureMeta(secrets) {
|
|
237
|
+
if (!secrets[META_KEY] || typeof secrets[META_KEY] !== 'object') {
|
|
238
|
+
secrets[META_KEY] = { keys: {}, version: 1 };
|
|
239
|
+
}
|
|
240
|
+
if (!secrets[META_KEY].keys) secrets[META_KEY].keys = {};
|
|
241
|
+
return secrets;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function appendAudit(brainDir, event) {
|
|
245
|
+
const auditPath = resolveAuditPath(brainDir);
|
|
246
|
+
fs.mkdirSync(path.dirname(auditPath), { recursive: true });
|
|
247
|
+
const row = {
|
|
248
|
+
ts: new Date().toISOString(),
|
|
249
|
+
...event,
|
|
250
|
+
// never include values
|
|
251
|
+
};
|
|
252
|
+
appendAuthMetadata(auditPath, row);
|
|
253
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* macOS Keychain as a master-password carrier.
|
|
3
|
+
*
|
|
4
|
+
* `secrets-rekey` knows how to update file carriers — a LaunchAgent plist, an
|
|
5
|
+
* env file — because it can stage, back up and atomically rename them. The
|
|
6
|
+
* Keychain is not a file, so it was simply not a carrier at all. That is fine
|
|
7
|
+
* right up until a shell profile reads the password from the Keychain, which is
|
|
8
|
+
* the arrangement macOS installs steer people toward: a rekey then re-encrypts
|
|
9
|
+
* the store, updates the carriers it knows about, and leaves the Keychain
|
|
10
|
+
* holding the retired password. Every interactive shell breaks at once, with an
|
|
11
|
+
* error — "Unsupported state or unable to authenticate data" — that describes
|
|
12
|
+
* the symptom and not the cause.
|
|
13
|
+
*
|
|
14
|
+
* Portability: this module is macOS-only by nature and reports itself
|
|
15
|
+
* unavailable everywhere else, so callers can treat it as optional.
|
|
16
|
+
*
|
|
17
|
+
* The password is passed to `security` on stdin, never in argv. Anything in
|
|
18
|
+
* argv is visible to every process on the machine through `ps`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { spawnSync } from 'node:child_process';
|
|
22
|
+
|
|
23
|
+
const SECURITY_BIN = '/usr/bin/security';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Service name Total Recall stores its own master password under. A product
|
|
27
|
+
* constant like the `TR_SECRETS_PASSWORD` variable name — not a device or user
|
|
28
|
+
* detail — and overridable per invocation.
|
|
29
|
+
*/
|
|
30
|
+
export const DEFAULT_KEYCHAIN_SERVICE = 'total-recall-secrets';
|
|
31
|
+
|
|
32
|
+
/** Is the Keychain usable on this host? */
|
|
33
|
+
export function keychainAvailable({ platform = process.platform } = {}) {
|
|
34
|
+
if (platform !== 'darwin') return false;
|
|
35
|
+
const probe = spawnSync(SECURITY_BIN, ['help'], { encoding: 'utf8', timeout: 5_000 });
|
|
36
|
+
return probe.status === 0 || probe.status === 1; // `help` exits non-zero on some releases
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Current password held for a service/account, or null when there is no entry.
|
|
41
|
+
* The value is returned, never logged — callers compare it, they do not print it.
|
|
42
|
+
*/
|
|
43
|
+
export function readKeychainPassword({ service, account = process.env.USER }) {
|
|
44
|
+
if (!service) throw new Error('Keychain carrier requires a service name');
|
|
45
|
+
const res = spawnSync(
|
|
46
|
+
SECURITY_BIN,
|
|
47
|
+
['find-generic-password', '-a', String(account), '-s', String(service), '-w'],
|
|
48
|
+
{ encoding: 'utf8', timeout: 10_000 },
|
|
49
|
+
);
|
|
50
|
+
if (res.status !== 0) return null;
|
|
51
|
+
// `security` terminates the value with a newline that is not part of it.
|
|
52
|
+
return (res.stdout || '').replace(/\n$/, '') || null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Create or replace the entry. `-U` updates in place when one already exists,
|
|
57
|
+
* which keeps this idempotent and atomic for a single item.
|
|
58
|
+
*
|
|
59
|
+
* Omitting a value after `-w` makes `security` prompt for the password and read
|
|
60
|
+
* it from stdin — twice, to confirm — which is how the secret is kept out of
|
|
61
|
+
* argv.
|
|
62
|
+
*/
|
|
63
|
+
export function writeKeychainPassword({ service, account = process.env.USER, password }) {
|
|
64
|
+
if (!service) throw new Error('Keychain carrier requires a service name');
|
|
65
|
+
if (!password) throw new Error('Refusing to write an empty Keychain password');
|
|
66
|
+
const res = spawnSync(
|
|
67
|
+
SECURITY_BIN,
|
|
68
|
+
['add-generic-password', '-U', '-a', String(account), '-s', String(service), '-w'],
|
|
69
|
+
{ input: `${password}\n${password}\n`, encoding: 'utf8', timeout: 10_000 },
|
|
70
|
+
);
|
|
71
|
+
if (res.status !== 0) {
|
|
72
|
+
// stderr may echo the prompt text but never the value itself.
|
|
73
|
+
throw new Error(
|
|
74
|
+
`Could not update Keychain entry ${service}: ${(res.stderr || '').trim().slice(0, 200) || `exit ${res.status}`}`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** `{ service, account }` → a stable label for logs and results. */
|
|
81
|
+
export function describeKeychainCarrier(carrier) {
|
|
82
|
+
return `keychain:${carrier.service}(${carrier.account || process.env.USER})`;
|
|
83
|
+
}
|