@dotrino/vaultd 0.38.0 → 0.49.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 +48 -3
- package/lib/README.md +7 -0
- package/lib/src/admin.js +4 -0
- package/lib/src/atrest.js +0 -0
- package/lib/src/enroll.js +12 -3
- package/lib/src/env.js +2 -2
- package/lib/src/protocol.js +12 -1
- package/lib/src/service.js +355 -54
- package/lib/src/sshAgent.js +100 -0
- package/lib/src/sshKeys.js +76 -0
- package/package.json +6 -6
- package/src/approvals.js +69 -0
- package/src/ctl.js +323 -22
- package/src/daemon.js +174 -15
- package/src/manager.js +6 -0
- package/src/profiles.js +82 -9
- package/src/sealKey.js +80 -0
- package/src/sealer.js +170 -0
- package/src/secretsStore.js +805 -90
- package/src/sshAgent.js +2 -0
- package/src/sshKeys.js +2 -0
- package/src/store.js +3 -1
- package/src/tui/app.js +124 -10
- package/src/tui/i18n.js +33 -6
- package/src/vault.js +839 -35
- package/src/vaultControl.js +50 -14
package/src/daemon.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import fs from 'node:fs'
|
|
18
18
|
import path from 'node:path'
|
|
19
19
|
import { startVaultManager } from './manager.js'
|
|
20
|
+
import { startSshAgent, defaultSocketPath } from './sshAgent.js'
|
|
20
21
|
import { dataDir, writeJson, readJson } from './paths.js'
|
|
21
22
|
import { VERSION } from './version.js'
|
|
22
23
|
|
|
@@ -63,6 +64,14 @@ export async function runDaemon () {
|
|
|
63
64
|
|
|
64
65
|
const mgr = await startVaultManager({ root: dir, proxyUrl, onEnrollChallenge })
|
|
65
66
|
|
|
67
|
+
// --- agente SSH (la llave vive en el teléfono; ver sshAgent.js). `DOTRINO_VAULT_SSH_AGENT=0`
|
|
68
|
+
// lo apaga; cualquier otro valor es la ruta del socket.
|
|
69
|
+
let sshAgent = null
|
|
70
|
+
if (process.env.DOTRINO_VAULT_SSH_AGENT !== '0' && process.platform !== 'win32') {
|
|
71
|
+
const socketPath = process.env.DOTRINO_VAULT_SSH_AGENT || defaultSocketPath(dir)
|
|
72
|
+
try { sshAgent = startSshAgent({ socketPath, vault: () => mgr.current(), log: console.log }) } catch (e) { console.error('[vault] ssh-agent could not start: %s', e.message) }
|
|
73
|
+
}
|
|
74
|
+
|
|
66
75
|
// --- state.json ---
|
|
67
76
|
const stateFile = path.join(dir, 'state.json')
|
|
68
77
|
const daemonVersion = VERSION
|
|
@@ -73,7 +82,7 @@ export async function runDaemon () {
|
|
|
73
82
|
const cur = mgr.summary().find((p) => p.current) || {}
|
|
74
83
|
writeJson(stateFile, {
|
|
75
84
|
v: 2, version: daemonVersion, fingerprint: cur.fingerprint || null, iss: cur.iss || null,
|
|
76
|
-
proxy: proxyUrl, pid: process.pid, startedAt: new Date().toISOString(),
|
|
85
|
+
proxy: proxyUrl, pid: process.pid, startedAt: new Date().toISOString(), sshAgent: sshAgent?.socketPath || null,
|
|
77
86
|
current: mgr.currentId(), profiles: mgr.summary()
|
|
78
87
|
})
|
|
79
88
|
}
|
|
@@ -143,7 +152,20 @@ export async function runDaemon () {
|
|
|
143
152
|
if (!vault) return
|
|
144
153
|
const profileId = pairReq?.profile ? mgr.resolve(pairReq.profile) : mgr.currentId()
|
|
145
154
|
const isService = typeof pairReq?.service === 'string' && pairReq.service
|
|
146
|
-
|
|
155
|
+
// PERMISOS, no tipos (2026-08-22): el cert lleva lo que pidió `pair --scope`, y si
|
|
156
|
+
// no pidió nada, el juego de siempre. `--service <ns>` sigue siendo el atajo de
|
|
157
|
+
// `vault:secrets:<ns>`. Se valida aquí porque es la maestra la que firma: nada
|
|
158
|
+
// que no esté en esta lista entra en un cert, y `vault:admin` / `vault:approve` nunca
|
|
159
|
+
// por este camino (se conceden a mano con `caps`).
|
|
160
|
+
const ALLOWED = (x) => x === 'vault:sign' || x === 'vault:read' || x === 'vault:store' || /^vault:secrets:[a-z0-9-]{1,32}$/.test(x)
|
|
161
|
+
const asked = Array.isArray(pairReq?.scope) ? pairReq.scope.filter((x) => typeof x === 'string') : null
|
|
162
|
+
if (asked && asked.some((x) => !ALLOWED(x))) {
|
|
163
|
+
writeJson(pairFile, { v: 2, at: Date.now(), error: 'scope not allowed: ' + asked.filter((x) => !ALLOWED(x)).join(',') })
|
|
164
|
+
return console.error('[vault] pairing refused: scope not allowed (%s)', asked.join(','))
|
|
165
|
+
}
|
|
166
|
+
const scope = asked?.length
|
|
167
|
+
? [...new Set(asked)]
|
|
168
|
+
: isService ? ['vault:secrets:' + pairReq.service] : ['vault:sign', 'vault:read', 'vault:store']
|
|
147
169
|
const label = pairReq?.label || (isService ? 'service:' + pairReq.service : 'cli')
|
|
148
170
|
// `profile`/`profileName`: la CUENTA del vault a la que entra el dispositivo.
|
|
149
171
|
// Con varias bóvedas en el mismo daemon, el QR sale de UNA y quien empareja
|
|
@@ -181,6 +203,21 @@ export async function runDaemon () {
|
|
|
181
203
|
const revokeReqFile = path.join(dir, 'revoke-request.json')
|
|
182
204
|
const secretReqFile = path.join(dir, 'secret-request.json')
|
|
183
205
|
const secretsListFile = path.join(dir, 'secrets-list.json')
|
|
206
|
+
/**
|
|
207
|
+
* Por qué falló la última orden de variables. La consola pide el cambio y el volcado
|
|
208
|
+
* por señales distintas, así que el motivo no cabe en la respuesta: se guarda aquí y
|
|
209
|
+
* viaja en el volcado siguiente. Sin esto, una contraseña equivocada se leía como
|
|
210
|
+
* «El daemon no aplicó el cambio», que no dice qué hacer.
|
|
211
|
+
*/
|
|
212
|
+
let lastSecretError = null
|
|
213
|
+
/**
|
|
214
|
+
* El VALOR que se acaba de destapar, esperando al volcado siguiente (mismo camino que
|
|
215
|
+
* `lastSecretError`: la orden y el volcado son señales distintas). Vive en memoria un
|
|
216
|
+
* instante y se va con el volcado — quien lo lee borra el archivo enseguida.
|
|
217
|
+
*/
|
|
218
|
+
let lastSecretValue = null
|
|
219
|
+
/** Y las versiones anteriores que se acaban de pedir. Mismo camino, mismo volcado. */
|
|
220
|
+
let lastSecretHistory = null
|
|
184
221
|
const profileReqFile = path.join(dir, 'profile-request.json')
|
|
185
222
|
const dumpReqFile = path.join(dir, 'dump-request.json')
|
|
186
223
|
const meReqFile = path.join(dir, 'me-request.json')
|
|
@@ -200,6 +237,19 @@ export async function runDaemon () {
|
|
|
200
237
|
* 0700 del vault y se BORRA al consumirla — mismo camino que ya usan los
|
|
201
238
|
* secretos, y así nunca pasa por `ps` ni por el historial de la shell.
|
|
202
239
|
*/
|
|
240
|
+
/**
|
|
241
|
+
* Vuelve a cerrar la copia maestra de los secretos con otra llave. Se llama al poner
|
|
242
|
+
* o quitar la contraseña del perfil: los sobres de las variables NO se tocan (siguen
|
|
243
|
+
* sellados a la llave de cada aparato), solo cambia con qué se abre el llavero de
|
|
244
|
+
* administración. Sin esto, cambiar la contraseña dejaría los secretos ilegibles.
|
|
245
|
+
*/
|
|
246
|
+
async function rekey (id, vieja, nueva) {
|
|
247
|
+
const v = mgr.get(id)
|
|
248
|
+
if (!v?.rekeySecrets) return
|
|
249
|
+
const r = await v.rekeySecrets(vieja, nueva)
|
|
250
|
+
if (r?.rekeyed) console.log('[vault] secrets master re-sealed (%d drawer(s))', r.drawers)
|
|
251
|
+
}
|
|
252
|
+
|
|
203
253
|
async function handleProfileRequest (req) {
|
|
204
254
|
const ref = () => mgr.resolve(req.profile || mgr.currentId())
|
|
205
255
|
switch (req.op) {
|
|
@@ -210,10 +260,54 @@ export async function runDaemon () {
|
|
|
210
260
|
case 'rm': { const r = await mgr.remove(req.profile); return { done: `perfil borrado: ${r.name || r.id}` } }
|
|
211
261
|
case 'rename': { const p = mgr.profiles.rename(ref(), req.name); return { done: `perfil renombrado: ${p.name}` } }
|
|
212
262
|
case 'use': { const p = mgr.profiles.setCurrent(ref()); return { done: `perfil activo: ${p.name || p.id}` } }
|
|
213
|
-
|
|
263
|
+
// ABRIR LA BÓVEDA SALDA LO QUE SE DEBE. Un aparato que entró después de escrita
|
|
264
|
+
// una variable no puede abrirla —envolverle su llave exige abrir la CEK, y eso
|
|
265
|
+
// pide la frase—, así que se queda en deuda y se ve en la consola y en la TUI.
|
|
266
|
+
// Este es el único momento en que la frase está delante, así que es aquí donde se
|
|
267
|
+
// paga; el dueño no tiene que acordarse de un comando aparte.
|
|
268
|
+
case 'unlock': {
|
|
269
|
+
const id = ref()
|
|
270
|
+
await mgr.profiles.unlock(id, req.password)
|
|
271
|
+
let note = ''
|
|
272
|
+
try {
|
|
273
|
+
const ak = await mgr.profiles.adminKey(id, req.password)
|
|
274
|
+
// REHACER el llavero, no solo saldar: con la frase delante se puede dejar cada
|
|
275
|
+
// cajón envuelto para exactamente quien dice el acta — creando lo que falta,
|
|
276
|
+
// reemplazando lo que alguien metiera mal y quitando lo que sobre.
|
|
277
|
+
const r = await mgr.get(id)?.resealAll?.(ak)
|
|
278
|
+
if (r?.wrapped) console.log('[vault] keyring rebuilt on unlock: %d wrap(s) in %d drawer(s)%s',
|
|
279
|
+
r.wrapped, r.drawers, r.dropped ? `, ${r.dropped} stale one(s) dropped` : '')
|
|
280
|
+
if (r?.dropped) note = ` · llavero al día (${r.dropped} envoltura(s) de más retirada(s))`
|
|
281
|
+
else if (r?.wrapped) note = ' · llavero al día'
|
|
282
|
+
} catch (e) { console.error('[vault] could not rebuild the keyring on unlock:', e.message) }
|
|
283
|
+
return { done: 'perfil desbloqueado' + note }
|
|
284
|
+
}
|
|
214
285
|
case 'lock': { mgr.profiles.lock(ref()); return { done: 'perfil bloqueado' } }
|
|
215
|
-
|
|
216
|
-
|
|
286
|
+
// PONER contraseña: los secretos pasan de abrirse con la llave de la máquina a
|
|
287
|
+
// abrirse con la frase. Hay que volver a cerrar la copia maestra con la nueva, o
|
|
288
|
+
// quedarían ilegibles. Si el perfil YA tenía contraseña, hace falta la vieja para
|
|
289
|
+
// poder abrirla: por eso el camino normal para cambiarla es quitarla y ponerla.
|
|
290
|
+
case 'password-set': {
|
|
291
|
+
const id = ref()
|
|
292
|
+
const tenia = !!mgr.profiles.get(id)?.protected
|
|
293
|
+
if (tenia && !req.current) throw new Error('this profile already has a password: remove it first (`profile password --rm`) and then set the new one')
|
|
294
|
+
const vieja = tenia ? await mgr.profiles.adminKey(id, req.current) : null
|
|
295
|
+
await mgr.profiles.setPassword(id, req.password)
|
|
296
|
+
const nueva = await mgr.profiles.adminKey(id, req.password)
|
|
297
|
+
await rekey(id, vieja, nueva)
|
|
298
|
+
return { done: 'contraseña guardada' }
|
|
299
|
+
}
|
|
300
|
+
// QUITARLA: al revés. Se abre la copia maestra con la frase y se vuelve a cerrar
|
|
301
|
+
// con la llave de la máquina, que es la protección de siempre — el disco sigue
|
|
302
|
+
// cifrado, pero su material vive en ese mismo disco.
|
|
303
|
+
case 'password-rm': {
|
|
304
|
+
const id = ref()
|
|
305
|
+
if (!req.password) throw new Error('removing the password needs the current one: the secrets must be re-sealed before it goes')
|
|
306
|
+
const vieja = await mgr.profiles.adminKey(id, req.password)
|
|
307
|
+
await rekey(id, vieja, null)
|
|
308
|
+
mgr.profiles.removePassword(id)
|
|
309
|
+
return { done: 'contraseña quitada · los secretos ahora se abren con la llave de esta máquina' }
|
|
310
|
+
}
|
|
217
311
|
default: throw new Error('unknown profile operation: ' + req.op)
|
|
218
312
|
}
|
|
219
313
|
}
|
|
@@ -278,25 +372,76 @@ export async function runDaemon () {
|
|
|
278
372
|
// 0700 del vault y se borra al consumir.
|
|
279
373
|
const sec = readJsonSafe(secretReqFile)
|
|
280
374
|
if (sec?.op) {
|
|
281
|
-
rm(secretReqFile)
|
|
375
|
+
rm(secretReqFile) // puede llevar la contraseña: fuera del disco cuanto antes
|
|
376
|
+
lastSecretError = null
|
|
282
377
|
try {
|
|
283
378
|
const vault = targetOf(sec)
|
|
284
379
|
// Carga en GRUPO (`secret set ns K=v K2=v2`, `secret import`): todas las
|
|
285
380
|
// variables entran de una vez y sale UN solo aviso de cambio, para que el
|
|
286
381
|
// servicio no se reinicie a media carga y arranque con la mitad puesta.
|
|
287
|
-
|
|
382
|
+
// La CONTRASEÑA, si vino, se convierte en la llave que abre la copia de
|
|
383
|
+
// RECUPERACIÓN, y no se guarda en ningún sitio: se usa y se suelta. Desde v5
|
|
384
|
+
// solo la piden las operaciones que LEEN —ver un valor, cambiar su visibilidad,
|
|
385
|
+
// convertir el archivo, rotar re-cifrando—: escribir no (§8.1). Sin ella se cae
|
|
386
|
+
// a la llave de la máquina, que es la protección de antes de esto (y el vault lo
|
|
387
|
+
// avisa al arrancar).
|
|
388
|
+
const ak = sec.password ? await mgr.profiles.adminKey(sec.profile ? mgr.resolve(sec.profile) : mgr.currentId(), sec.password) : undefined
|
|
389
|
+
if (sec.op === 'migrate') {
|
|
390
|
+
// Sin lista a mano: la pone el vault, y es la MISMA que usa cualquier
|
|
391
|
+
// escritura (servicios del cajón + aparatos que administran). Con una lista
|
|
392
|
+
// propia aquí, lo convertido quedaba sellado solo a los servicios y el dueño
|
|
393
|
+
// no podía ver desde su consola nada de lo que ya tenía.
|
|
394
|
+
const r = await vault.migrateSecrets(null, ak)
|
|
395
|
+
if (!r.migrated) console.log('[vault] nothing to migrate: %s', r.reason)
|
|
396
|
+
else {
|
|
397
|
+
console.log('[vault] secrets SEALED (v%d -> v5). Backup left at secrets.json.v%d.bak', r.from, r.from)
|
|
398
|
+
for (const [owner, sin] of Object.entries(r.sinLlave || {})) {
|
|
399
|
+
console.log('[vault] WARNING %s: %d member(s) without an encryption key will NOT read their variables', owner, sin.length)
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
} else if (sec.op === 'batch') {
|
|
288
403
|
const changed = sec.pub
|
|
289
|
-
? await vault.applyDeviceSecrets(sec.pub, sec.items)
|
|
290
|
-
: vault.applySecrets(sec.ns, sec.items)
|
|
404
|
+
? await vault.applyDeviceSecrets(sec.pub, sec.items, { by: null })
|
|
405
|
+
: await vault.applySecrets(sec.ns, sec.items, { by: null })
|
|
291
406
|
console.log('[vault] %d secret(s) applied in one go: %s', changed.length, sec.pub ? 'device' : sec.ns)
|
|
292
|
-
} else if (sec.op === 'set') { vault.setSecret(sec.ns, sec.key, sec.value, sec.public); console.log('[vault] secret saved: %s/%s', sec.ns, sec.key) }
|
|
293
|
-
else if (sec.op === 'rm') { vault.deleteSecret(sec.ns, sec.key); console.log('[vault] secret deleted: %s/%s', sec.ns, sec.key) }
|
|
407
|
+
} else if (sec.op === 'set') { await vault.setSecret(sec.ns, sec.key, sec.value, sec.public); console.log('[vault] secret saved: %s/%s', sec.ns, sec.key) }
|
|
408
|
+
else if (sec.op === 'rm') { await vault.deleteSecret(sec.ns, sec.key); console.log('[vault] secret deleted: %s/%s', sec.ns, sec.key) }
|
|
409
|
+
else if (sec.op === 'policy') { const r = await vault.setSecretPolicy(sec.ns, { approval: !!sec.approval }); console.log('[vault] %s: approval %s', sec.ns, r.approval ? 'REQUIRED (15 min window)' : 'off') }
|
|
294
410
|
else if (sec.op === 'dev-set') { await vault.setDeviceSecret(sec.pub, sec.key, sec.value, sec.public); console.log('[vault] device secret saved: %s', sec.key) }
|
|
295
411
|
else if (sec.op === 'dev-rm') { await vault.deleteDeviceSecret(sec.pub, sec.key); console.log('[vault] device secret deleted: %s', sec.key) }
|
|
412
|
+
// Saldar lo que quedó a deber: heredarle a un aparato nuevo lo ya guardado y
|
|
413
|
+
// rotar de verdad el cajón del que salió alguien. Las dos cosas abren, así que
|
|
414
|
+
// van con la frase — y por eso se hacen aquí y no al escribir.
|
|
415
|
+
else if (sec.op === 'settle') {
|
|
416
|
+
const r = await vault.settleSecretDebts(ak)
|
|
417
|
+
const n = Object.keys(r).length
|
|
418
|
+
console.log(n ? `[vault] ${n} pending drawer(s) settled` : '[vault] nothing pending')
|
|
419
|
+
}
|
|
420
|
+
// Ver el valor de una privada: lo único que la frase guarda (§8.3).
|
|
421
|
+
else if (sec.op === 'reveal') {
|
|
422
|
+
const value = await vault.revealSecret(sec.owner, sec.key, ak)
|
|
423
|
+
lastSecretValue = { owner: sec.owner, key: sec.key, value }
|
|
424
|
+
}
|
|
425
|
+
// Qué versiones anteriores hay (sin valores: son sobres).
|
|
426
|
+
else if (sec.op === 'history') {
|
|
427
|
+
lastSecretHistory = { owner: sec.owner || null, key: sec.key || null, items: vault.secretHistory(sec.owner || null, sec.key || null) }
|
|
428
|
+
}
|
|
429
|
+
// REVERTIR: abrir la versión vieja (frase) y volver a guardarla (nada).
|
|
430
|
+
else if (sec.op === 'revert') {
|
|
431
|
+
const ok = await vault.revertSecret(sec.owner, sec.key, sec.ts, { adminKey: ak })
|
|
432
|
+
if (!ok) throw new Error('that version is not in the history any more')
|
|
433
|
+
console.log('[vault] secret reverted: %s/%s', sec.owner, sec.key)
|
|
434
|
+
}
|
|
296
435
|
// Visibilidad: si el valor puede salir hacia la consola remota. No toca el valor.
|
|
297
|
-
else if (sec.op === 'vis') { vault.setSecretVisibility(sec.ns, sec.key, sec.public); console.log('[vault] secret visibility: %s/%s → %s', sec.ns, sec.key, sec.public ? 'public' : 'private') }
|
|
298
|
-
else if (sec.op === 'dev-vis') { await vault.setDeviceSecretVisibility(sec.pub, sec.key, sec.public); console.log('[vault] device secret visibility: %s → %s', sec.key, sec.public ? 'public' : 'private') }
|
|
299
|
-
} catch (e) {
|
|
436
|
+
else if (sec.op === 'vis') { await vault.setSecretVisibility(sec.ns, sec.key, sec.public, ak); console.log('[vault] secret visibility: %s/%s → %s', sec.ns, sec.key, sec.public ? 'public' : 'private') }
|
|
437
|
+
else if (sec.op === 'dev-vis') { await vault.setDeviceSecretVisibility(sec.pub, sec.key, sec.public, ak); console.log('[vault] device secret visibility: %s → %s', sec.key, sec.public ? 'public' : 'private') }
|
|
438
|
+
} catch (e) {
|
|
439
|
+
lastSecretError = {
|
|
440
|
+
error: e.message,
|
|
441
|
+
code: e.code || (/wrong password/i.test(e.message) ? 'WRONG_PASSWORD' : 'SECRET_FAILED')
|
|
442
|
+
}
|
|
443
|
+
console.error('[vault] secret failed:', e.message)
|
|
444
|
+
}
|
|
300
445
|
}
|
|
301
446
|
// Perfiles / candado.
|
|
302
447
|
const preq = readJsonSafe(profileReqFile)
|
|
@@ -376,8 +521,21 @@ export async function runDaemon () {
|
|
|
376
521
|
writeJson(secretsListFile, {
|
|
377
522
|
v: 2, at: Date.now(), req: reqId, profile: t.id,
|
|
378
523
|
ns: t.vault.listSecrets(),
|
|
379
|
-
dev: await t.vault.listDeviceSecrets()
|
|
524
|
+
dev: await t.vault.listDeviceSecrets(),
|
|
525
|
+
// Lo que quedó a deber un sellado. Va en el volcado porque si no se ve, no se
|
|
526
|
+
// salda: son cajones cuyos miembros NO están leyendo sus variables.
|
|
527
|
+
pending: await t.vault.secretDebts(),
|
|
528
|
+
// Y quién NO puede abrir lo suyo. Es lo mismo visto desde el aparato, que es
|
|
529
|
+
// como lo mira quien administra: «este servicio está en el acta y aun así no
|
|
530
|
+
// arranca». Sin esto solo se veía en el log del propio servicio.
|
|
531
|
+
incomplete: await t.vault.incompleteMembers(),
|
|
532
|
+
...(lastSecretError ? { secretError: lastSecretError } : {}),
|
|
533
|
+
...(lastSecretValue ? { revealed: lastSecretValue } : {}),
|
|
534
|
+
...(lastSecretHistory ? { history: lastSecretHistory } : {})
|
|
380
535
|
})
|
|
536
|
+
lastSecretError = null
|
|
537
|
+
lastSecretValue = null
|
|
538
|
+
lastSecretHistory = null
|
|
381
539
|
writeJson(devFile, { v: 1, at: Date.now(), req: reqId, profile: t.id, ...(await t.vault.listDevices()) })
|
|
382
540
|
// Acta del perfil: quién es del perfil y qué puede hacer cada uno (`members`/`caps`).
|
|
383
541
|
try { writeJson(path.join(dir, 'acta.json'), { v: 1, at: Date.now(), req: reqId, profile: t.id, ...(await t.vault.profileMembers()) }) } catch (_) {}
|
|
@@ -458,6 +616,7 @@ export async function runDaemon () {
|
|
|
458
616
|
const shutdown = (sig) => {
|
|
459
617
|
console.log(`\n[vault] ${sig} → deteniendo…`)
|
|
460
618
|
rm(pairFile); rm(pendingEnrollFile)
|
|
619
|
+
try { sshAgent?.close() } catch (_) {}
|
|
461
620
|
try { mgr.close() } catch (_) {}
|
|
462
621
|
process.exit(0)
|
|
463
622
|
}
|
package/src/manager.js
CHANGED
|
@@ -56,6 +56,12 @@ export async function startVaultManager ({ root = dataDir(), proxyUrl, log = con
|
|
|
56
56
|
proxyUrl,
|
|
57
57
|
log: (...a) => log(`[${tag}]`, ...a),
|
|
58
58
|
isLocked: () => profiles.isLocked(id),
|
|
59
|
+
// Para poder DECIR que este perfil no tiene contraseña, y por tanto que sus
|
|
60
|
+
// variables privadas se abren con material que vive en este mismo disco.
|
|
61
|
+
hasPassword: () => !!profiles.get(id)?.protected,
|
|
62
|
+
// Para la consola remota: la contraseña llega dentro del sobre firmado y hay que
|
|
63
|
+
// convertirla en la llave que abre la copia maestra de los secretos.
|
|
64
|
+
deriveAdminKey: (password) => profiles.adminKey(id, password),
|
|
59
65
|
// Camino A: nació para adoptar la cuenta de un aparato (ver profiles.add).
|
|
60
66
|
forAdoption: !!p?.adopt,
|
|
61
67
|
// Ya adoptó: la marca se consume (no vuelve a estar «a la espera»).
|
package/src/profiles.js
CHANGED
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
* <root>/transport.json keypair del proxy-client (a nivel PROCESO, no por perfil)
|
|
12
12
|
* <root>/p/<id>/… los datos de cada perfil (incluida su maestra)
|
|
13
13
|
*
|
|
14
|
-
* CONTRASEÑA (opcional, por perfil): es un VERIFICADOR
|
|
15
|
-
*
|
|
14
|
+
* CONTRASEÑA (opcional, por perfil): es un VERIFICADOR scrypt (v2; v1 era PBKDF2 y se
|
|
15
|
+
* asciende al desbloquear) que NO cifra nada en reposo.
|
|
16
16
|
* Y solo bloquea EDITAR el perfil: el daemon sigue firmando y sirviendo a los
|
|
17
17
|
* dispositivos ya enrolados aunque el perfil esté bloqueado, para que un reinicio
|
|
18
18
|
* del PC no deje las apps muertas hasta que alguien teclee la contraseña.
|
|
@@ -21,12 +21,27 @@
|
|
|
21
21
|
* cifrado en reposo, ver `paths.js`).
|
|
22
22
|
*/
|
|
23
23
|
import fs from 'node:fs'
|
|
24
|
+
import crypto2 from 'node:crypto'
|
|
24
25
|
import path from 'node:path'
|
|
25
26
|
import { dataDir, ensureDir, readJson, writeJson } from './paths.js'
|
|
27
|
+
import { atRestFor, migrateFile, machineKey } from './atrest.js'
|
|
26
28
|
|
|
27
29
|
const REGISTRY = 'profiles.json'
|
|
28
|
-
const PWD_ITER = 300000 //
|
|
30
|
+
const PWD_ITER = 300000 // PBKDF2 del verificador v1 (heredado); v2 usa scrypt
|
|
29
31
|
const MAX_NAME = 40
|
|
32
|
+
/**
|
|
33
|
+
* Lo MÍNIMO que se acepta al poner una contraseña.
|
|
34
|
+
*
|
|
35
|
+
* Eran 4 caracteres, y eso se quedó corto el día que los secretos pasaron a sellarse:
|
|
36
|
+
* desde entonces la contraseña no bloquea una consola, **es la llave** que abre la
|
|
37
|
+
* copia maestra, y todo el cifrado vale lo que valga ella. Cuatro dígitos son 10.000
|
|
38
|
+
* combinaciones — se prueban enteras en un rato aunque la derivación sea cara.
|
|
39
|
+
*
|
|
40
|
+
* No se piden mayúsculas ni símbolos a propósito: hacen la frase difícil de recordar
|
|
41
|
+
* y fácil de adivinar. Lo que da fuerza es la LONGITUD y que no la elija un humano;
|
|
42
|
+
* por eso lo que se pide en pantalla son varias palabras al azar.
|
|
43
|
+
*/
|
|
44
|
+
const PWD_MIN = 12
|
|
30
45
|
|
|
31
46
|
/**
|
|
32
47
|
* Cuánto tarda el freno en OLVIDAR los fallos. Sin esto la cuenta solo subía —solo la
|
|
@@ -48,7 +63,24 @@ const LEGACY_FILES = ['identity.json', 'peers.json', 'vault.json', 'threads.json
|
|
|
48
63
|
|
|
49
64
|
const b64 = (buf) => Buffer.from(new Uint8Array(buf)).toString('base64')
|
|
50
65
|
|
|
51
|
-
/**
|
|
66
|
+
/**
|
|
67
|
+
* El verificador del candado.
|
|
68
|
+
*
|
|
69
|
+
* v2 es **scrypt, con el mismo coste que `adminKey`**, y no por gusto: este valor vive
|
|
70
|
+
* EN CLARO en `profiles.json`, así que quien tenga el disco lo ataca fuera de línea. Si
|
|
71
|
+
* es más barato que la llave de verdad, se convierte en el camino corto para llegar a
|
|
72
|
+
* ella — que es exactamente lo que pasaba con PBKDF2 al lado de un scrypt.
|
|
73
|
+
*
|
|
74
|
+
* v1 (PBKDF2) se sigue aceptando porque hay perfiles con él en el disco, y se ASCIENDE
|
|
75
|
+
* a v2 en el primer desbloqueo correcto: es el único momento en que se tiene la
|
|
76
|
+
* contraseña en la mano.
|
|
77
|
+
*/
|
|
78
|
+
function deriveScryptPwd (password, saltB64) {
|
|
79
|
+
const salt = Buffer.from(saltB64, 'base64')
|
|
80
|
+
return b64(crypto2.scryptSync(String(password || ''), salt, 32, { N: 16384, r: 8, p: 1 }))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** PBKDF2-SHA256 → verificador base64 (v1, heredado). */
|
|
52
84
|
async function derivePwd (password, saltB64, iter) {
|
|
53
85
|
const salt = Buffer.from(saltB64, 'base64')
|
|
54
86
|
const km = await crypto.subtle.importKey('raw', new TextEncoder().encode(String(password)), 'PBKDF2', false, ['deriveBits'])
|
|
@@ -61,9 +93,18 @@ const cleanName = (name) => String(name || '').slice(0, MAX_NAME)
|
|
|
61
93
|
|
|
62
94
|
export function openProfiles (root = dataDir()) {
|
|
63
95
|
const file = path.join(root, REGISTRY)
|
|
64
|
-
|
|
96
|
+
// CIFRADO EN REPOSO, como todo lo demás. Era el único archivo del vault sin códec, y
|
|
97
|
+
// lleva dentro el verificador del candado. No protege de quien tenga el disco entero
|
|
98
|
+
// —el material de la llave vive en ese mismo disco, y eso está dicho en voz alta en
|
|
99
|
+
// `docs/secretos-sellados.md`— pero sí de que el registro viaje en claro en un
|
|
100
|
+
// respaldo o en una carpeta compartida por descuido, que es lo que el códec cubre
|
|
101
|
+
// para el resto. La migración verifica antes de reemplazar y es de una sola vez.
|
|
102
|
+
ensureDir(root)
|
|
103
|
+
try { migrateFile(file, machineKey(root)) } catch (_) {}
|
|
104
|
+
const atRest = atRestFor(root)
|
|
105
|
+
let data = readJson(file, null, atRest)
|
|
65
106
|
if (!data || !Array.isArray(data.profiles)) data = { v: 1, current: null, profiles: [] }
|
|
66
|
-
const save = () => writeJson(file, data)
|
|
107
|
+
const save = () => writeJson(file, data, atRest)
|
|
67
108
|
|
|
68
109
|
// Perfiles DESBLOQUEADOS en esta ejecución del daemon (en memoria: un reinicio
|
|
69
110
|
// vuelve a bloquear, igual que cerrar la pestaña en el navegador).
|
|
@@ -195,6 +236,26 @@ export function openProfiles (root = dataDir()) {
|
|
|
195
236
|
if (api.isLocked(id)) throw new Error('profile locked: unlock it with your password (dotrino-vault unlock)')
|
|
196
237
|
},
|
|
197
238
|
|
|
239
|
+
/**
|
|
240
|
+
* La llave con la que se abre la copia MAESTRA de los secretos, derivada de la
|
|
241
|
+
* contraseña del perfil. Va por operación: quien la pide la usa y la suelta.
|
|
242
|
+
*
|
|
243
|
+
* Es scrypt (mismo coste que `machineKey`) y NO reusa el verificador del candado:
|
|
244
|
+
* ese es PBKDF2 y vive en claro en este mismo archivo, así que sería el camino
|
|
245
|
+
* barato para atacarla. Aquí la prueba de que es correcta es el tag AES-GCM del
|
|
246
|
+
* propio sobre — si no cuadra, la contraseña no era.
|
|
247
|
+
*/
|
|
248
|
+
async adminKey (id, password) {
|
|
249
|
+
const p = find(id)
|
|
250
|
+
if (!p) throw new Error('profile not found')
|
|
251
|
+
if (!p.kdf) {
|
|
252
|
+
p.kdf = { v: 1, salt: b64(crypto.getRandomValues(new Uint8Array(32))) }
|
|
253
|
+
save()
|
|
254
|
+
}
|
|
255
|
+
const salt = Buffer.from(p.kdf.salt, 'base64')
|
|
256
|
+
return new Uint8Array(crypto2.scryptSync(String(password || ''), salt, 32, { N: 16384, r: 8, p: 1 }))
|
|
257
|
+
},
|
|
258
|
+
|
|
198
259
|
async unlock (id, password) {
|
|
199
260
|
const p = assertExists(id)
|
|
200
261
|
if (!p.pwd) { unlocked.add(id); return { ok: true, locked: false } }
|
|
@@ -218,12 +279,21 @@ export function openProfiles (root = dataDir()) {
|
|
|
218
279
|
throw Object.assign(new Error(`too many tries: wait ${Math.ceil(left / 1000)} s`),
|
|
219
280
|
{ code: 'TOO_MANY_TRIES', waitSec: Math.ceil(left / 1000) })
|
|
220
281
|
}
|
|
221
|
-
const proof =
|
|
282
|
+
const proof = p.pwd.v === 2
|
|
283
|
+
? deriveScryptPwd(password, p.pwd.salt)
|
|
284
|
+
: await derivePwd(password, p.pwd.salt, p.pwd.iter)
|
|
222
285
|
if (proof !== p.pwd.verifier) {
|
|
223
286
|
p.tries = { n: tries.n + 1, at: Date.now() }
|
|
224
287
|
save()
|
|
225
288
|
throw Object.assign(new Error('wrong password'), { code: 'WRONG_PASSWORD', tries: p.tries.n })
|
|
226
289
|
}
|
|
290
|
+
// ASCENSO v1 → v2. Aquí, y solo aquí, se tiene la contraseña correcta en la mano:
|
|
291
|
+
// es el momento de dejar de guardar el verificador barato. No cambia la
|
|
292
|
+
// contraseña ni toca los secretos — el `adminKey` sale de `p.kdf`, que es otro.
|
|
293
|
+
if (p.pwd.v !== 2) {
|
|
294
|
+
const salt = b64(crypto.getRandomValues(new Uint8Array(16)))
|
|
295
|
+
p.pwd = { v: 2, salt, verifier: deriveScryptPwd(password, salt) }
|
|
296
|
+
}
|
|
227
297
|
delete p.tries
|
|
228
298
|
save()
|
|
229
299
|
unlocked.add(id)
|
|
@@ -236,9 +306,12 @@ export function openProfiles (root = dataDir()) {
|
|
|
236
306
|
async setPassword (id, password) {
|
|
237
307
|
const p = assertExists(id)
|
|
238
308
|
api.assertUnlocked(id)
|
|
239
|
-
if (!password || String(password).length <
|
|
309
|
+
if (!password || String(password).length < PWD_MIN) {
|
|
310
|
+
throw Object.assign(new Error(`password must be at least ${PWD_MIN} characters: use several random words`),
|
|
311
|
+
{ code: 'PASSWORD_TOO_SHORT', min: PWD_MIN })
|
|
312
|
+
}
|
|
240
313
|
const salt = b64(crypto.getRandomValues(new Uint8Array(16)))
|
|
241
|
-
p.pwd = { v:
|
|
314
|
+
p.pwd = { v: 2, salt, verifier: deriveScryptPwd(password, salt) }
|
|
242
315
|
delete p.tries
|
|
243
316
|
save()
|
|
244
317
|
unlocked.add(id)
|
package/src/sealKey.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* La LLAVE DE SELLADO de esta bóveda: con ella FIRMA los sobres de los secretos, para
|
|
3
|
+
* que se sepa que salieron de aquí. Diseño: `docs/secretos-sellados.md` §8.8 y §8.9.
|
|
4
|
+
*
|
|
5
|
+
* Tres cosas que la hacen distinta de todo lo demás que hay en este directorio:
|
|
6
|
+
*
|
|
7
|
+
* · **Firmar no es leer.** Esta llave no abre ningún valor. Por eso puede vivir
|
|
8
|
+
* cifrada en reposo con la llave de la máquina y usarse **sin la frase** — que es
|
|
9
|
+
* justo lo que hace posible administrar sin contraseña.
|
|
10
|
+
* · **Su autoridad la da el ACTA**, que la nombra (`sealPub`) y que sella únicamente
|
|
11
|
+
* la maestra. Ella no se autoriza a sí misma.
|
|
12
|
+
* · **Rota con el acta.** Cada acta nueva puede estrenar una, así que aquí se guarda
|
|
13
|
+
* un puñado: la vigente para firmar, y las anteriores porque un sobre se firmó con
|
|
14
|
+
* la que mandaba entonces y hay que poder seguir firmando… no, seguir
|
|
15
|
+
* **identificando** cuál era. Verificar se hace con la pública que dice el acta.
|
|
16
|
+
*
|
|
17
|
+
* No guarda ninguna correspondencia con `seq`: quién mandaba y cuándo lo dice el acta
|
|
18
|
+
* (`sealKeys` con su tramo). Aquí solo están las privadas, indexadas por su pública.
|
|
19
|
+
*/
|
|
20
|
+
import path from 'node:path'
|
|
21
|
+
import { readJson, writeJson } from './paths.js'
|
|
22
|
+
import { atRestFor } from './atrest.js'
|
|
23
|
+
import { signWithDevice } from '@dotrino/identity/capabilities'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Cuántas llaves anteriores se conservan. Firmar solo usa la vigente; las viejas se
|
|
27
|
+
* guardan por si hay que re-firmar algo sellado con ellas (recoger el histórico), y
|
|
28
|
+
* porque tirarlas no ahorra nada: son cuatro líneas de JSON.
|
|
29
|
+
*/
|
|
30
|
+
const MAX_KEYS = 8
|
|
31
|
+
|
|
32
|
+
/** Abre (o estrena) el llavero de sellado del perfil. */
|
|
33
|
+
export function openSealKeys (dir) {
|
|
34
|
+
const file = path.join(dir, 'sealkeys.json')
|
|
35
|
+
const atRest = atRestFor(dir)
|
|
36
|
+
const data = readJson(file, null, atRest) || { v: 1, keys: [] }
|
|
37
|
+
if (!Array.isArray(data.keys)) data.keys = []
|
|
38
|
+
|
|
39
|
+
const save = () => writeJson(file, data, atRest)
|
|
40
|
+
const find = (pub) => data.keys.find((k) => k.pub === pub) || null
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
/** La pública de la llave vigente (la última estrenada), o `null` si no hay ninguna. */
|
|
44
|
+
current: () => data.keys[data.keys.length - 1]?.pub || null,
|
|
45
|
+
|
|
46
|
+
/** ¿Tenemos la privada de esta pública? Es lo que decide si podemos firmar por ella. */
|
|
47
|
+
has: (pub) => !!find(pub),
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Estrena una llave y devuelve su PÚBLICA, para que el acta la nombre. La privada
|
|
51
|
+
* se queda aquí; no sale de esta máquina ni viaja a ninguna parte.
|
|
52
|
+
*
|
|
53
|
+
* Es lo que se le pasa a `identity.setSealKeyProvider`, así que se llama una vez
|
|
54
|
+
* por acta sellada.
|
|
55
|
+
*/
|
|
56
|
+
async mint () {
|
|
57
|
+
const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'])
|
|
58
|
+
const pub = JSON.stringify(await crypto.subtle.exportKey('jwk', pair.publicKey))
|
|
59
|
+
const priv = await crypto.subtle.exportKey('jwk', pair.privateKey)
|
|
60
|
+
data.keys.push({ pub, priv, createdAt: Date.now() })
|
|
61
|
+
if (data.keys.length > MAX_KEYS) data.keys = data.keys.slice(-MAX_KEYS)
|
|
62
|
+
save()
|
|
63
|
+
return pub
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Firma un cuerpo con la llave que el acta nombra. Devuelve `null` si esa llave no es
|
|
68
|
+
* nuestra —el disco se restauró, o el acta la puso otro master—: entonces el sobre
|
|
69
|
+
* sale SIN firma, que es peor que firmado pero mucho mejor que no poder guardar.
|
|
70
|
+
*/
|
|
71
|
+
async sign (sealPub, body) {
|
|
72
|
+
const k = sealPub ? find(sealPub) : null
|
|
73
|
+
if (!k) return null
|
|
74
|
+
const { signature } = await signWithDevice({ privateJwk: k.priv, publickey: k.pub, data: body })
|
|
75
|
+
return signature
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export default { openSealKeys }
|