tr-plugin-auth 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -2
- package/cli.mjs +384 -0
- package/package.json +40 -4
- package/plugin.json +266 -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 +1687 -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,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-provider rotation recipes.
|
|
3
|
+
*
|
|
4
|
+
* A recipe describes how to obtain a NEW credential from a provider console and
|
|
5
|
+
* how to prove the new value works. Recipes are data, not bespoke code, so
|
|
6
|
+
* adding a provider is a catalog edit rather than a new module.
|
|
7
|
+
*
|
|
8
|
+
* `verified: true` means the selectors were confirmed against the live console
|
|
9
|
+
* and TR may drive the flow autonomously. `verified: false` means TR opens the
|
|
10
|
+
* console, tells the human exactly what to click, and detects the result — it
|
|
11
|
+
* must NEVER blind-click guessed selectors on a billing or payments dashboard.
|
|
12
|
+
*
|
|
13
|
+
* Never log secret values.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {{
|
|
18
|
+
* provider: string,
|
|
19
|
+
* console_url: string,
|
|
20
|
+
* verified: boolean,
|
|
21
|
+
* signed_in?: string,
|
|
22
|
+
* signed_out?: string,
|
|
23
|
+
* create_hint: string,
|
|
24
|
+
* revoke_hint?: string,
|
|
25
|
+
* value_pattern?: RegExp,
|
|
26
|
+
* create?: (page: any, ctx: { label: string }) => Promise<string|null>,
|
|
27
|
+
* verify?: (value: string) => Promise<boolean>,
|
|
28
|
+
* }} RotationRecipe
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** Probe helper — returns true when the credential authenticates. */
|
|
32
|
+
async function probe(url, headers, okStatuses = [200]) {
|
|
33
|
+
try {
|
|
34
|
+
const res = await fetch(url, { headers });
|
|
35
|
+
return okStatuses.includes(res.status);
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const bearer = (v) => ({ Authorization: `Bearer ${v}` });
|
|
42
|
+
|
|
43
|
+
/** @type {Record<string, RotationRecipe>} */
|
|
44
|
+
export const ROTATION_RECIPES = {
|
|
45
|
+
openrouter: {
|
|
46
|
+
provider: 'openrouter',
|
|
47
|
+
console_url: 'https://openrouter.ai/settings/keys',
|
|
48
|
+
verified: true,
|
|
49
|
+
signed_in: 'button:has-text("New Key")',
|
|
50
|
+
signed_out: 'a:has-text("Sign in")',
|
|
51
|
+
create_hint: 'Click "New Key", name it, then copy the value shown once.',
|
|
52
|
+
revoke_hint: 'Open the ⋮ menu on the old key row and choose Delete.',
|
|
53
|
+
value_pattern: /^sk-or-v1-[a-f0-9]{64}$/,
|
|
54
|
+
async create(page, { label }) {
|
|
55
|
+
await page.getByRole('button', { name: /new key/i }).click();
|
|
56
|
+
const name = page.getByRole('textbox').first();
|
|
57
|
+
await name.fill(label);
|
|
58
|
+
await page.getByRole('button', { name: /^create$/i }).click();
|
|
59
|
+
// The value is displayed exactly once, in a readonly field.
|
|
60
|
+
const field = page.locator('input[readonly], textarea[readonly]').first();
|
|
61
|
+
await field.waitFor({ state: 'visible', timeout: 15_000 });
|
|
62
|
+
return (await field.inputValue()) || null;
|
|
63
|
+
},
|
|
64
|
+
verify: (v) => probe('https://openrouter.ai/api/v1/auth/key', bearer(v)),
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
github: {
|
|
68
|
+
provider: 'github',
|
|
69
|
+
console_url: 'https://github.com/settings/tokens',
|
|
70
|
+
verified: false,
|
|
71
|
+
signed_in: 'summary[aria-label*="user" i], img.avatar-user',
|
|
72
|
+
signed_out: 'form[action="/session"]',
|
|
73
|
+
create_hint:
|
|
74
|
+
'Generate new token (classic) → set the same scopes as the old token → Generate → copy the ghp_… value.',
|
|
75
|
+
revoke_hint: 'Delete the old token row once TR confirms the new value is stored.',
|
|
76
|
+
value_pattern: /^gh[ps]_[A-Za-z0-9]{36,}$/,
|
|
77
|
+
verify: (v) => probe('https://api.github.com/user', { ...bearer(v), 'User-Agent': 'total-recall' }),
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
stripe: {
|
|
81
|
+
provider: 'stripe',
|
|
82
|
+
console_url: 'https://dashboard.stripe.com/apikeys',
|
|
83
|
+
verified: false,
|
|
84
|
+
signed_in: 'text=/API keys/i',
|
|
85
|
+
signed_out: 'input[name="email"]',
|
|
86
|
+
create_hint:
|
|
87
|
+
'Roll the secret key. Set the old key to expire in ~1 hour (NOT immediately) so live traffic keeps working during the swap, then copy the new sk_live_… value.',
|
|
88
|
+
revoke_hint: 'The roll schedules the old key for expiry — no separate revoke needed.',
|
|
89
|
+
value_pattern: /^sk_(live|test)_[A-Za-z0-9]{24,}$/,
|
|
90
|
+
verify: (v) => probe('https://api.stripe.com/v1/account', bearer(v)),
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
openai: {
|
|
94
|
+
provider: 'openai',
|
|
95
|
+
console_url: 'https://platform.openai.com/api-keys',
|
|
96
|
+
verified: false,
|
|
97
|
+
create_hint: 'Create new secret key → copy the sk-… value shown once.',
|
|
98
|
+
value_pattern: /^sk-[A-Za-z0-9_-]{20,}$/,
|
|
99
|
+
verify: (v) => probe('https://api.openai.com/v1/models', bearer(v)),
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
anthropic: {
|
|
103
|
+
provider: 'anthropic',
|
|
104
|
+
console_url: 'https://console.anthropic.com/settings/keys',
|
|
105
|
+
verified: false,
|
|
106
|
+
create_hint: 'Create Key → copy the sk-ant-… value shown once.',
|
|
107
|
+
value_pattern: /^sk-ant-[A-Za-z0-9_-]{20,}$/,
|
|
108
|
+
verify: (v) => probe('https://api.anthropic.com/v1/models', { 'x-api-key': v, 'anthropic-version': '2023-06-01' }),
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
digitalocean: {
|
|
112
|
+
provider: 'digitalocean',
|
|
113
|
+
console_url: 'https://cloud.digitalocean.com/account/api/tokens',
|
|
114
|
+
verified: false,
|
|
115
|
+
create_hint: 'Generate New Token → full scopes as needed → copy the dop_v1_… value.',
|
|
116
|
+
value_pattern: /^dop_v1_[a-f0-9]{64}$/,
|
|
117
|
+
verify: (v) => probe('https://api.digitalocean.com/v2/account', bearer(v)),
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
vercel: {
|
|
121
|
+
provider: 'vercel',
|
|
122
|
+
console_url: 'https://vercel.com/account/settings/tokens',
|
|
123
|
+
verified: false,
|
|
124
|
+
create_hint: 'Create Token → set scope and expiry → copy the value shown once.',
|
|
125
|
+
verify: (v) => probe('https://api.vercel.com/v2/user', bearer(v)),
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
supabase: {
|
|
129
|
+
provider: 'supabase',
|
|
130
|
+
console_url: 'https://supabase.com/dashboard/account/tokens',
|
|
131
|
+
verified: false,
|
|
132
|
+
create_hint: 'Generate new token → copy the sbp_… value shown once.',
|
|
133
|
+
value_pattern: /^sbp_[a-f0-9]{40,}$/,
|
|
134
|
+
verify: (v) => probe('https://api.supabase.com/v1/organizations', bearer(v)),
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
elevenlabs: {
|
|
138
|
+
provider: 'elevenlabs',
|
|
139
|
+
console_url: 'https://elevenlabs.io/app/settings/api-keys',
|
|
140
|
+
verified: false,
|
|
141
|
+
create_hint: 'Create API key → copy the value shown once.',
|
|
142
|
+
verify: (v) => probe('https://api.elevenlabs.io/v1/user', { 'xi-api-key': v }),
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
brave: {
|
|
146
|
+
provider: 'brave',
|
|
147
|
+
console_url: 'https://api-dashboard.search.brave.com/app/keys',
|
|
148
|
+
verified: false,
|
|
149
|
+
create_hint: 'Add API key → copy the value.',
|
|
150
|
+
verify: (v) =>
|
|
151
|
+
probe('https://api.search.brave.com/res/v1/web/search?q=test', {
|
|
152
|
+
'X-Subscription-Token': v,
|
|
153
|
+
Accept: 'application/json',
|
|
154
|
+
}),
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
huggingface: {
|
|
158
|
+
provider: 'huggingface',
|
|
159
|
+
console_url: 'https://huggingface.co/settings/tokens',
|
|
160
|
+
verified: false,
|
|
161
|
+
create_hint: 'New token → set role → copy the hf_… value.',
|
|
162
|
+
value_pattern: /^hf_[A-Za-z0-9]{30,}$/,
|
|
163
|
+
verify: (v) => probe('https://huggingface.co/api/whoami-v2', bearer(v)),
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
telnyx: {
|
|
167
|
+
provider: 'telnyx',
|
|
168
|
+
console_url: 'https://portal.telnyx.com/#/app/api-keys',
|
|
169
|
+
verified: false,
|
|
170
|
+
create_hint: 'Create API Key → copy the KEY… value shown once.',
|
|
171
|
+
verify: (v) => probe('https://api.telnyx.com/v2/whoami', bearer(v)),
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
fal: {
|
|
175
|
+
provider: 'fal',
|
|
176
|
+
console_url: 'https://fal.ai/dashboard/keys',
|
|
177
|
+
verified: false,
|
|
178
|
+
create_hint: 'Add key → copy the value shown once.',
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
tavily: {
|
|
182
|
+
provider: 'tavily',
|
|
183
|
+
console_url: 'https://app.tavily.com/home',
|
|
184
|
+
verified: false,
|
|
185
|
+
create_hint: 'Create API key → copy the tvly-… value.',
|
|
186
|
+
value_pattern: /^tvly-[A-Za-z0-9]{20,}$/,
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
exa: {
|
|
190
|
+
provider: 'exa',
|
|
191
|
+
console_url: 'https://dashboard.exa.ai/api-keys',
|
|
192
|
+
verified: false,
|
|
193
|
+
create_hint: 'Create API key → copy the value.',
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
serper: {
|
|
197
|
+
provider: 'serper',
|
|
198
|
+
console_url: 'https://serper.dev/api-key',
|
|
199
|
+
verified: false,
|
|
200
|
+
create_hint: 'Copy or regenerate the API key.',
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
npm: {
|
|
204
|
+
provider: 'npm',
|
|
205
|
+
console_url: 'https://www.npmjs.com/settings/~/tokens',
|
|
206
|
+
verified: false,
|
|
207
|
+
create_hint: 'Generate New Token → Granular/Classic → copy the npm_… value.',
|
|
208
|
+
value_pattern: /^npm_[A-Za-z0-9]{36}$/,
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
pexels: {
|
|
212
|
+
provider: 'pexels',
|
|
213
|
+
console_url: 'https://www.pexels.com/api/new/',
|
|
214
|
+
verified: false,
|
|
215
|
+
create_hint: 'Request/regenerate the API key and copy it.',
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
'browser-use': {
|
|
219
|
+
provider: 'browser-use',
|
|
220
|
+
console_url: 'https://cloud.browser-use.com/settings/api-keys',
|
|
221
|
+
verified: false,
|
|
222
|
+
create_hint: 'Create API key → copy the value.',
|
|
223
|
+
},
|
|
224
|
+
|
|
225
|
+
'artificial-analysis': {
|
|
226
|
+
provider: 'artificial-analysis',
|
|
227
|
+
console_url: 'https://artificialanalysis.ai/account/api-keys',
|
|
228
|
+
verified: false,
|
|
229
|
+
create_hint: 'Create API key → copy the value.',
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
google: {
|
|
233
|
+
provider: 'google',
|
|
234
|
+
console_url: 'https://aistudio.google.com/app/apikey',
|
|
235
|
+
verified: false,
|
|
236
|
+
create_hint: 'Create API key → select project → copy the AIza… value.',
|
|
237
|
+
value_pattern: /^AIza[A-Za-z0-9_-]{30,}$/,
|
|
238
|
+
verify: (v) =>
|
|
239
|
+
probe(`https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(v)}`, {}),
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
smtp2go: {
|
|
243
|
+
provider: 'smtp2go',
|
|
244
|
+
console_url: 'https://app.smtp2go.com/settings/apikeys/',
|
|
245
|
+
verified: false,
|
|
246
|
+
create_hint: 'Add API Key → set permissions → copy the api-… value.',
|
|
247
|
+
value_pattern: /^api-[A-Za-z0-9]{20,}$/,
|
|
248
|
+
},
|
|
249
|
+
|
|
250
|
+
xai: {
|
|
251
|
+
provider: 'xai',
|
|
252
|
+
console_url: 'https://console.x.ai',
|
|
253
|
+
verified: false,
|
|
254
|
+
create_hint: 'API Keys → Create API key → copy the xai-… value.',
|
|
255
|
+
value_pattern: /^xai-[A-Za-z0-9]{20,}$/,
|
|
256
|
+
verify: (v) => probe('https://api.x.ai/v1/models', bearer(v)),
|
|
257
|
+
},
|
|
258
|
+
|
|
259
|
+
telegram: {
|
|
260
|
+
provider: 'telegram',
|
|
261
|
+
console_url: 'https://t.me/BotFather',
|
|
262
|
+
verified: false,
|
|
263
|
+
// Bot tokens are reissued by messaging @BotFather, not on a web console —
|
|
264
|
+
// TR can open the link but the exchange happens inside Telegram.
|
|
265
|
+
create_hint: 'Message @BotFather → /revoke → pick the bot → copy the reissued token.',
|
|
266
|
+
revoke_hint: '/revoke already invalidates the previous token — no separate step.',
|
|
267
|
+
value_pattern: /^\d{6,}:[A-Za-z0-9_-]{30,}$/,
|
|
268
|
+
verify: (v) => probe(`https://api.telegram.org/bot${encodeURIComponent(v)}/getMe`, {}),
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* @param {string} providerId
|
|
274
|
+
* @returns {RotationRecipe|null}
|
|
275
|
+
*/
|
|
276
|
+
export function getRecipe(providerId) {
|
|
277
|
+
if (!providerId) return null;
|
|
278
|
+
return ROTATION_RECIPES[String(providerId).toLowerCase()] || null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Providers whose flows TR may drive without a human watching each click. */
|
|
282
|
+
export function listVerifiedRecipes() {
|
|
283
|
+
return Object.values(ROTATION_RECIPES).filter((r) => r.verified).map((r) => r.provider);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Validate a captured value against the provider's expected shape.
|
|
288
|
+
* A shape mismatch usually means the wrong DOM node was read — treat as failure
|
|
289
|
+
* rather than storing garbage over a working credential.
|
|
290
|
+
*
|
|
291
|
+
* @param {RotationRecipe|null} recipe
|
|
292
|
+
* @param {string} value
|
|
293
|
+
*/
|
|
294
|
+
export function valueLooksValid(recipe, value) {
|
|
295
|
+
if (!value || typeof value !== 'string') return false;
|
|
296
|
+
if (value.length < 12) return false;
|
|
297
|
+
if (recipe?.value_pattern) return recipe.value_pattern.test(value);
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { importHostModule } from './host-runtime.mjs';
|
|
2
|
+
const hostModule = await importHostModule('src/core/research-gate.mjs');
|
|
3
|
+
export const { AUTONOMOUS_DEFAULTS, resolveAutonomousConfig, loadAutonomousConfig, requestResearch, defaultCoverageCheck, proposeAutonomousResearch } = hostModule;
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { importHostModule } from './host-runtime.mjs';
|
|
2
|
+
const hostModule = await importHostModule('src/core/research-queue.mjs');
|
|
3
|
+
export const { compileResearchProjectSummary, syncResearchProjectNode, loadQueue, saveQueue, listQueue, RESEARCH_ORIGINS, RESEARCH_VIAS, normalizeTopic, addToQueue, updateQueueItem, removeFromQueue } = hostModule;
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rotation capability classification.
|
|
3
|
+
*
|
|
4
|
+
* Every secret in the vault resolves to exactly one rotation class, so that
|
|
5
|
+
* `secret rotate-auto` always has a defined method — or a defined reason why a
|
|
6
|
+
* human must act. Nothing is silently unrotatable.
|
|
7
|
+
*
|
|
8
|
+
* provider_api Provider exposes a key-management API. Fully automatic.
|
|
9
|
+
* provider_browser No API; drive the provider console in TR's browser profile.
|
|
10
|
+
* self_generated TR mints the value itself (JWT/app/db secrets). No provider.
|
|
11
|
+
* manual Human-only artifact (SSH keys, recovery codes, OAuth apps).
|
|
12
|
+
*
|
|
13
|
+
* Never log secret values.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import crypto from 'node:crypto';
|
|
17
|
+
import { getProvider, providerForKeyName } from './provider-catalog.mjs';
|
|
18
|
+
|
|
19
|
+
/** Keys TR mints itself — no provider console is involved. */
|
|
20
|
+
const SELF_GENERATED_PATTERNS = [
|
|
21
|
+
/^JWT_SECRET$/,
|
|
22
|
+
/^BETTER_AUTH_SECRET$/,
|
|
23
|
+
/^ADMIN_API_(SECRET|TOKEN)$/,
|
|
24
|
+
/^TEST_USER_API_TOKEN$/,
|
|
25
|
+
/^TR_MESH_SYNC_TOKEN$/,
|
|
26
|
+
/_WEBHOOK_SECRET$/,
|
|
27
|
+
/_SSO_SECRET$/,
|
|
28
|
+
/^(DB|REDIS|ARI|SIPUS)_PASSWORD$/,
|
|
29
|
+
/^ASTERISK_(ARI_PASSWORD|SIP_SECRET)$/,
|
|
30
|
+
/_ADMIN_PASSWORD$/,
|
|
31
|
+
/_TEST_ACCOUNT_PASSWORD$/,
|
|
32
|
+
/_WEBMAIL_PASSWORD$/,
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
/** Keys that can only ever be replaced by a human at a console. */
|
|
36
|
+
const MANUAL_PATTERNS = [
|
|
37
|
+
/SSH_KEY$/,
|
|
38
|
+
/_RECOVERY_CODE$/,
|
|
39
|
+
/_PRIVATE_KEY$/,
|
|
40
|
+
/OAUTH_CLIENT_SECRET$/,
|
|
41
|
+
/_LOGIN_EMAIL$/,
|
|
42
|
+
/_EMAIL$/,
|
|
43
|
+
/^TR_SECRETS_PASSWORD$/, // rotating this re-encrypts the vault — separate flow
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
/** Internal bookkeeping entries that are not credentials at all. */
|
|
47
|
+
const NON_SECRET_PATTERNS = [/^__/];
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Value shapes TR can mint for self_generated keys.
|
|
51
|
+
* @param {string} key
|
|
52
|
+
*/
|
|
53
|
+
export function selfGeneratedSpec(key) {
|
|
54
|
+
const k = String(key).toUpperCase();
|
|
55
|
+
if (/PASSWORD$/.test(k)) return { kind: 'password', bytes: 24 };
|
|
56
|
+
if (/^JWT_SECRET$|SECRET$/.test(k)) return { kind: 'hex', bytes: 48 };
|
|
57
|
+
return { kind: 'token', bytes: 32 };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Mint a new value for a self_generated secret.
|
|
62
|
+
* Returned in-memory only — callers must hand it straight to rotateSecretAndExport.
|
|
63
|
+
* @param {string} key
|
|
64
|
+
* @returns {string}
|
|
65
|
+
*/
|
|
66
|
+
export function generateSecretValue(key) {
|
|
67
|
+
const spec = selfGeneratedSpec(key);
|
|
68
|
+
const raw = crypto.randomBytes(spec.bytes);
|
|
69
|
+
if (spec.kind === 'hex') return raw.toString('hex');
|
|
70
|
+
if (spec.kind === 'password') {
|
|
71
|
+
// base64url avoids shell/env quoting hazards in .env projections
|
|
72
|
+
return raw.toString('base64url');
|
|
73
|
+
}
|
|
74
|
+
return raw.toString('base64url');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function matchesAny(key, patterns) {
|
|
78
|
+
return patterns.some((re) => re.test(key));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Classify a single secret key into its rotation plan.
|
|
83
|
+
*
|
|
84
|
+
* @param {string} key
|
|
85
|
+
* @param {{ provider?: string }} [meta] Stored metadata (provider override wins)
|
|
86
|
+
* @returns {{
|
|
87
|
+
* key: string,
|
|
88
|
+
* class: 'provider_api'|'provider_browser'|'self_generated'|'manual'|'non_secret',
|
|
89
|
+
* provider: string|null,
|
|
90
|
+
* console_url: string|null,
|
|
91
|
+
* automatable: boolean,
|
|
92
|
+
* high_risk: boolean,
|
|
93
|
+
* reason: string,
|
|
94
|
+
* }}
|
|
95
|
+
*/
|
|
96
|
+
export function getRotationPlan(key, meta = {}) {
|
|
97
|
+
const k = String(key || '');
|
|
98
|
+
const base = { key: k, provider: null, console_url: null, high_risk: false };
|
|
99
|
+
|
|
100
|
+
if (matchesAny(k, NON_SECRET_PATTERNS)) {
|
|
101
|
+
return { ...base, class: 'non_secret', automatable: false, reason: 'internal metadata, not a credential' };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Explicit manual artifacts win over any provider mapping — an OAuth client
|
|
105
|
+
// secret belongs to an app registration, not a rotatable API key.
|
|
106
|
+
if (matchesAny(k, MANUAL_PATTERNS)) {
|
|
107
|
+
const p = meta.provider ? getProvider(meta.provider) : providerForKeyName(k);
|
|
108
|
+
return {
|
|
109
|
+
...base,
|
|
110
|
+
class: 'manual',
|
|
111
|
+
provider: p?.id || null,
|
|
112
|
+
console_url: p?.console_url || null,
|
|
113
|
+
automatable: false,
|
|
114
|
+
reason: 'human-only artifact (key material, recovery code, or app registration)',
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (matchesAny(k, SELF_GENERATED_PATTERNS)) {
|
|
119
|
+
return {
|
|
120
|
+
...base,
|
|
121
|
+
class: 'self_generated',
|
|
122
|
+
automatable: true,
|
|
123
|
+
reason: 'TR mints this value directly; no provider console involved',
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const provider = meta.provider ? getProvider(meta.provider) : providerForKeyName(k);
|
|
128
|
+
if (!provider) {
|
|
129
|
+
return {
|
|
130
|
+
...base,
|
|
131
|
+
class: 'manual',
|
|
132
|
+
automatable: false,
|
|
133
|
+
reason: 'no provider mapping — add a catalog entry to enable rotation',
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const rot = provider.rotation || {};
|
|
138
|
+
if (rot.manual_keys?.some((mk) => mk.toUpperCase() === k.toUpperCase())) {
|
|
139
|
+
return {
|
|
140
|
+
...base,
|
|
141
|
+
class: 'manual',
|
|
142
|
+
provider: provider.id,
|
|
143
|
+
console_url: provider.console_url || null,
|
|
144
|
+
automatable: false,
|
|
145
|
+
reason: `${provider.name} issues this artifact once and cannot reissue it programmatically`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const cls = rot.class || (provider.console_url ? 'provider_browser' : 'manual');
|
|
150
|
+
return {
|
|
151
|
+
...base,
|
|
152
|
+
class: cls,
|
|
153
|
+
provider: provider.id,
|
|
154
|
+
console_url: provider.console_url || null,
|
|
155
|
+
high_risk: !!rot.high_risk,
|
|
156
|
+
automatable: cls === 'provider_api' || cls === 'provider_browser' || cls === 'self_generated',
|
|
157
|
+
reason:
|
|
158
|
+
cls === 'provider_api'
|
|
159
|
+
? `${provider.name} exposes a key-management API`
|
|
160
|
+
: cls === 'provider_browser'
|
|
161
|
+
? `${provider.name} has no key-minting API; console automation required`
|
|
162
|
+
: `${provider.name} has no console URL registered`,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Classify every key in a vault snapshot.
|
|
168
|
+
* @param {string[]} keys
|
|
169
|
+
* @param {Record<string, any>} [metaByKey]
|
|
170
|
+
*/
|
|
171
|
+
export function planAll(keys, metaByKey = {}) {
|
|
172
|
+
return keys.map((k) => getRotationPlan(k, metaByKey[k] || {}));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Summarise coverage — used by `secret rotation-status`.
|
|
177
|
+
* @param {ReturnType<typeof planAll>} plans
|
|
178
|
+
*/
|
|
179
|
+
export function summarizePlans(plans) {
|
|
180
|
+
const byClass = {};
|
|
181
|
+
for (const p of plans) byClass[p.class] = (byClass[p.class] || 0) + 1;
|
|
182
|
+
const automatable = plans.filter((p) => p.automatable).length;
|
|
183
|
+
return {
|
|
184
|
+
total: plans.length,
|
|
185
|
+
automatable,
|
|
186
|
+
manual: plans.length - automatable,
|
|
187
|
+
byClass,
|
|
188
|
+
};
|
|
189
|
+
}
|