@dotrino/vaultd 0.139.0 → 0.140.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/package.json +2 -2
- package/src/ctl.js +157 -10
- package/src/daemon.js +40 -29
- package/src/hwkeys.js +247 -0
- package/src/manager.js +51 -3
- package/src/profiles.js +324 -64
- package/src/threadStore.js +8 -3
- package/src/tui/app.js +47 -8
- package/src/tui/i18n.js +28 -0
- package/src/vault.js +1 -0
- package/src/vaultControl.js +2 -1
package/src/manager.js
CHANGED
|
@@ -13,7 +13,7 @@ import { openProfiles } from './profiles.js'
|
|
|
13
13
|
import { installNodeGlobals } from './node-globals.js'
|
|
14
14
|
import { dataDir, ensureDir } from './paths.js'
|
|
15
15
|
import { Identity } from '@dotrino/identity/node'
|
|
16
|
-
import { atRestFor } from './atrest.js'
|
|
16
|
+
import { atRestFor, kekFor } from './atrest.js'
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* D12 (`docs/acta-de-perfil.md`): la bóveda **no** borra una cuenta que ella manda si
|
|
@@ -169,8 +169,8 @@ export async function startVaultManager ({ root = dataDir(), proxyUrl, log = con
|
|
|
169
169
|
* dice «abierto» sin estarlo es peor que uno cerrado, porque todo lo que venga detrás
|
|
170
170
|
* falla en otro sitio y por otro motivo.
|
|
171
171
|
*/
|
|
172
|
-
async function unlock (id,
|
|
173
|
-
const r = await profiles.unlock(id,
|
|
172
|
+
async function unlock (id, creds) {
|
|
173
|
+
const r = await profiles.unlock(id, creds)
|
|
174
174
|
try {
|
|
175
175
|
// El orden importa: `profiles.unlock` ya dejó la llave del perfil disponible
|
|
176
176
|
// (`openKey`), así que ahora `startVault` sí puede abrir su maestra.
|
|
@@ -186,6 +186,52 @@ export async function startVaultManager ({ root = dataDir(), proxyUrl, log = con
|
|
|
186
186
|
return r
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
+
/**
|
|
190
|
+
* PONER UNA PUERTA (contraseña o llave de hardware), sellando en el acto.
|
|
191
|
+
*
|
|
192
|
+
* Si el perfil no tenía llave —nunca tuvo candado—, `ensureKey` estrena una, y lo que hasta
|
|
193
|
+
* ahora estaba bajo la llave de la máquina pasa a ella: la copia de recuperación de los
|
|
194
|
+
* secretos y la maestra. El orden importa: primero la puerta (guardada), después volver a
|
|
195
|
+
* sellar. Al revés, un fallo entre medias dejaría la maestra sellada con una llave sin
|
|
196
|
+
* ninguna puerta que la abra, que es justo el fallo que las puertas vienen a quitar.
|
|
197
|
+
*/
|
|
198
|
+
async function addDoor (id, spec) {
|
|
199
|
+
const { K, fresh } = profiles.ensureKey(id, { password: spec.kind === 'password' ? spec.password : null })
|
|
200
|
+
// La puerta se fabrica (y se valida) ANTES de sellar nada con esta llave: si fallara
|
|
201
|
+
// después de sellar la maestra, quedaría sellada con una llave que se tira.
|
|
202
|
+
let prepared
|
|
203
|
+
try { prepared = profiles.prepareDoor(id, spec) } catch (e) { if (fresh) profiles.dropFreshKey(id); throw e }
|
|
204
|
+
let v = null
|
|
205
|
+
if (fresh) {
|
|
206
|
+
// ANTES DE GUARDAR NADA: la maestra tiene que abrir con esta llave. Si estaba sellada con
|
|
207
|
+
// otra (un perfil al que se le quitó la contraseña antes de las puertas), una puerta nueva
|
|
208
|
+
// daría un candado que no abre la maestra. Se para y se dice cómo recuperarla.
|
|
209
|
+
try {
|
|
210
|
+
v = running.get(id) || await open(id)
|
|
211
|
+
const r = await v.takeMasterKey()
|
|
212
|
+
if (r?.locked !== false) throw Object.assign(new Error('the master key is sealed with an earlier password: set that same password to recover it'), { code: 'MASTER_SEALED_ELSEWHERE' })
|
|
213
|
+
} catch (e) { profiles.dropFreshKey(id); throw e }
|
|
214
|
+
}
|
|
215
|
+
let door
|
|
216
|
+
try { door = profiles.addDoor(id, { prepared }) } catch (e) { if (fresh) profiles.dropFreshKey(id); throw e }
|
|
217
|
+
if (fresh) {
|
|
218
|
+
const maquina = new Uint8Array(kekFor(profiles.dirOf(id)))
|
|
219
|
+
try {
|
|
220
|
+
const r = await v.rekeySecrets?.(maquina, K)
|
|
221
|
+
if (r?.rekeyed) log('[vault] secrets recovery copy moved from the machine key to the profile key')
|
|
222
|
+
} catch (e) {
|
|
223
|
+
// No se arrastra: abrir el perfil vuelve a intentarlo (`migrarRecuperacionALaFrase`).
|
|
224
|
+
log('[vault] could not move the recovery copy to the profile key (it will be retried on unlock): %s', e.message)
|
|
225
|
+
} finally { maquina.fill(0) }
|
|
226
|
+
}
|
|
227
|
+
return door
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Quitar una puerta (por id, o `'password'`). Sin la última, el perfil queda sin candado. */
|
|
231
|
+
async function removeDoor (id, which) {
|
|
232
|
+
return profiles.removeDoor(id, which)
|
|
233
|
+
}
|
|
234
|
+
|
|
189
235
|
async function lock (id) {
|
|
190
236
|
const r = profiles.lock(id)
|
|
191
237
|
try { await running.get(id)?.dropMasterKey?.() } catch (_) {}
|
|
@@ -207,6 +253,8 @@ export async function startVaultManager ({ root = dataDir(), proxyUrl, log = con
|
|
|
207
253
|
// dentro y fuera de la memoria. Llamar a `profiles.lock` a secas deja la llave puesta.
|
|
208
254
|
unlock,
|
|
209
255
|
lock,
|
|
256
|
+
addDoor,
|
|
257
|
+
removeDoor,
|
|
210
258
|
running,
|
|
211
259
|
get,
|
|
212
260
|
list: () => profiles.list(),
|
package/src/profiles.js
CHANGED
|
@@ -119,6 +119,99 @@ async function derivePwd (password, saltB64, iter) {
|
|
|
119
119
|
return b64(bits)
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
/**
|
|
123
|
+
* LAS PUERTAS: de qué se saca la llave que abre cada una.
|
|
124
|
+
*
|
|
125
|
+
* · `password` scrypt(contraseña, salt) — el mismo molino que antes daba `K` directamente.
|
|
126
|
+
* · `fido2` HKDF(hmac-secret de la llave), con toque.
|
|
127
|
+
* · `chalresp` HKDF(respuesta HMAC-SHA1 de la ranura 2), sin toque.
|
|
128
|
+
* · las dos de hardware con `withPassword`: HKDF(scrypt(contraseña) ‖ secreto de la llave).
|
|
129
|
+
* Hacen falta las DOS cosas.
|
|
130
|
+
* · `machine` la llave de esta máquina — la de un perfil sin candado.
|
|
131
|
+
*
|
|
132
|
+
* El `info` de HKDF lleva el tipo: los bytes de una llave no pueden abrir la puerta de otro tipo.
|
|
133
|
+
*/
|
|
134
|
+
const HW_INFO = { fido2: 'dotrino-vault/door/fido2/v1', chalresp: 'dotrino-vault/door/chalresp/v1' }
|
|
135
|
+
const HW_KINDS = Object.keys(HW_INFO)
|
|
136
|
+
|
|
137
|
+
const scryptKey = (password, saltB64) => crypto2.scryptSync(String(password), Buffer.from(saltB64, 'base64'), SCRYPT.len, { N: SCRYPT.N, r: SCRYPT.r, p: SCRYPT.p })
|
|
138
|
+
|
|
139
|
+
/** La llave de una puerta con lo que se trae, o `null` si falta algo para esa puerta. */
|
|
140
|
+
function doorKey (door, { password = null, secret = null } = {}) {
|
|
141
|
+
if (door.kind === 'password') return password ? scryptKey(password, door.salt) : null
|
|
142
|
+
if (!HW_INFO[door.kind] || !secret?.length) return null
|
|
143
|
+
if (door.withPassword && !password) return null
|
|
144
|
+
const ikm = door.withPassword ? Buffer.concat([scryptKey(password, door.salt), Buffer.from(secret)]) : Buffer.from(secret)
|
|
145
|
+
const k = Buffer.from(crypto2.hkdfSync('sha256', ikm, Buffer.alloc(0), HW_INFO[door.kind] + (door.withPassword ? '+password' : ''), 32))
|
|
146
|
+
ikm.fill(0)
|
|
147
|
+
return k
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const newDoorId = () => crypto2.randomBytes(4).toString('hex')
|
|
151
|
+
const realDoors = (p) => (p.doors || []).filter((d) => d.kind !== 'machine')
|
|
152
|
+
/** Tiene candado: alguna puerta de verdad, o la contraseña de antes de las puertas. */
|
|
153
|
+
const isProtected = (p) => !!p.pwd || realDoors(p).length > 0
|
|
154
|
+
/** Ya tiene llave de perfil (con o sin candado). */
|
|
155
|
+
const hasKey = (p) => !!p.pwd || (p.doors || []).length > 0
|
|
156
|
+
|
|
157
|
+
function checkPassword (password) {
|
|
158
|
+
if (!password || String(password).length < PWD_MIN) {
|
|
159
|
+
throw Object.assign(new Error(`password must be at least ${PWD_MIN} characters: use several random words`),
|
|
160
|
+
{ code: 'PASSWORD_TOO_SHORT', min: PWD_MIN })
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Fabrica una puerta: el sobre de `K` y lo que hace falta para volver a pedírselo a la llave. */
|
|
165
|
+
function makeDoor (K, spec = {}, { checkMin = true } = {}) {
|
|
166
|
+
const kind = spec.kind
|
|
167
|
+
if (kind !== 'password' && !HW_INFO[kind]) throw Object.assign(new Error('unknown door kind: ' + kind), { code: 'BAD_DOOR' })
|
|
168
|
+
const withPassword = kind !== 'password' && !!spec.password
|
|
169
|
+
if ((kind === 'password' || withPassword) && checkMin) checkPassword(spec.password)
|
|
170
|
+
if ((kind === 'password' || withPassword) && !spec.password) throw Object.assign(new Error('a password is required'), { code: 'BAD_DOOR' })
|
|
171
|
+
const door = { id: newDoorId(), kind, createdAt: Date.now(), ...(spec.label ? { label: String(spec.label).slice(0, MAX_NAME) } : {}) }
|
|
172
|
+
if (kind === 'password' || withPassword) door.salt = b64(crypto2.randomBytes(32))
|
|
173
|
+
if (withPassword) door.withPassword = true
|
|
174
|
+
if (kind === 'fido2') {
|
|
175
|
+
if (!spec.credId || !spec.hsalt) throw Object.assign(new Error('a fido2 door needs credId and hsalt'), { code: 'BAD_DOOR' })
|
|
176
|
+
Object.assign(door, { credId: spec.credId, hsalt: spec.hsalt })
|
|
177
|
+
}
|
|
178
|
+
if (kind === 'chalresp') {
|
|
179
|
+
if (!/^[0-9a-f]{2,126}$/.test(spec.challenge || '')) throw Object.assign(new Error('a chalresp door needs a hex challenge'), { code: 'BAD_DOOR' })
|
|
180
|
+
Object.assign(door, { challenge: spec.challenge, ...(spec.serial ? { serial: String(spec.serial) } : {}) })
|
|
181
|
+
}
|
|
182
|
+
const secret = spec.secret ? Buffer.from(spec.secret) : null
|
|
183
|
+
if (HW_INFO[kind] && !secret?.length) throw Object.assign(new Error('the key gave nothing to seal the door with'), { code: 'BAD_DOOR' })
|
|
184
|
+
const k = doorKey(door, { password: spec.password, secret })
|
|
185
|
+
door.wrapped = encryptText(b64(K), k)
|
|
186
|
+
// Se comprueba ANTES de guardar: una puerta que no abre es peor que no tenerla.
|
|
187
|
+
const back = Buffer.from(decryptText(door.wrapped, k), 'base64')
|
|
188
|
+
k.fill(0)
|
|
189
|
+
if (!back.equals(Buffer.from(K))) throw Object.assign(new Error('the new door does not open: not saved'), { code: 'BAD_DOOR' })
|
|
190
|
+
return door
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Lo que se enseña de una puerta: nada secreto. `credId`/`hsalt`/`challenge` no abren nada sin la llave. */
|
|
194
|
+
const publicDoor = (d) => ({
|
|
195
|
+
id: d.id, kind: d.kind, createdAt: d.createdAt || null,
|
|
196
|
+
...(d.label ? { label: d.label } : {}), ...(d.withPassword ? { withPassword: true } : {}),
|
|
197
|
+
...(d.kind === 'fido2' ? { credId: d.credId, hsalt: d.hsalt } : {}),
|
|
198
|
+
...(d.kind === 'chalresp' ? { challenge: d.challenge, ...(d.serial ? { serial: d.serial } : {}) } : {})
|
|
199
|
+
})
|
|
200
|
+
const publicDoors = (p) => [
|
|
201
|
+
...(p.pwd ? [{ id: 'password', kind: 'password', legacy: true }] : []),
|
|
202
|
+
...realDoors(p).map(publicDoor)
|
|
203
|
+
]
|
|
204
|
+
|
|
205
|
+
/** Las credenciales en su forma: la contraseña sola (texto) se acepta por compatibilidad. */
|
|
206
|
+
function normCreds (c) {
|
|
207
|
+
if (typeof c === 'string' || c == null) return { password: c || null, door: null, secret: null }
|
|
208
|
+
return {
|
|
209
|
+
password: c.password || null,
|
|
210
|
+
door: c.door || null,
|
|
211
|
+
secret: c.secret ? (typeof c.secret === 'string' ? Buffer.from(c.secret, 'base64') : Buffer.from(c.secret)) : null
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
122
215
|
/**
|
|
123
216
|
* El nombre de la carpeta DE PASO, la que existe solo mientras se acuña la llave.
|
|
124
217
|
*
|
|
@@ -191,12 +284,17 @@ export function openProfiles (root = dataDir(), { autoLockMs = AUTO_LOCK_MS, onA
|
|
|
191
284
|
* estando abierta: lo que acota la exposición es el auto-candado, no el olvido.
|
|
192
285
|
*/
|
|
193
286
|
const llaves = new Map() // id -> Uint8Array (la llave derivada, solo si está abierto)
|
|
287
|
+
// La `K` de los perfiles SIN candado que la guardan bajo la llave de la máquina (`openKey`).
|
|
288
|
+
const porMaquina = new Map()
|
|
194
289
|
|
|
195
290
|
/** Borra el material de verdad antes de soltar la referencia. */
|
|
196
291
|
const olvidar = (id) => {
|
|
197
292
|
const k = llaves.get(id)
|
|
198
293
|
if (k) { try { k.fill(0) } catch (_) {} }
|
|
199
294
|
llaves.delete(id)
|
|
295
|
+
const m = porMaquina.get(id)
|
|
296
|
+
if (m) { try { m.fill(0) } catch (_) {} }
|
|
297
|
+
porMaquina.delete(id)
|
|
200
298
|
}
|
|
201
299
|
|
|
202
300
|
/** ¿Sigue abierto? Vence al MIRARLO, así que no hace falta ningún temporizador. */
|
|
@@ -222,11 +320,13 @@ export function openProfiles (root = dataDir(), { autoLockMs = AUTO_LOCK_MS, onA
|
|
|
222
320
|
id: p.id,
|
|
223
321
|
name: p.name || '',
|
|
224
322
|
createdAt: p.createdAt || null,
|
|
225
|
-
protected:
|
|
226
|
-
locked:
|
|
323
|
+
protected: isProtected(p),
|
|
324
|
+
locked: isProtected(p) && !isOpen(p.id),
|
|
325
|
+
// Con qué se abre: contraseña, YubiKey… Es DATO, y la CLI lo necesita para saber qué pedir.
|
|
326
|
+
doors: publicDoors(p),
|
|
227
327
|
// Hasta cuándo sigue abierto si nadie lo toca. Es DATO: la consola lo enseña para
|
|
228
328
|
// que el cierre no llegue por sorpresa.
|
|
229
|
-
...(p
|
|
329
|
+
...(isProtected(p) && isOpen(p.id) && autoLockMs > 0 ? { until: unlocked.get(p.id) } : {}),
|
|
230
330
|
current: p.id === data.current,
|
|
231
331
|
// Nació para adoptar la cuenta de un aparato (camino A) y todavía no lo ha hecho.
|
|
232
332
|
...(p.adopt ? { adopt: true } : {})
|
|
@@ -267,6 +367,65 @@ export function openProfiles (root = dataDir(), { autoLockMs = AUTO_LOCK_MS, onA
|
|
|
267
367
|
return tries
|
|
268
368
|
}
|
|
269
369
|
|
|
370
|
+
/**
|
|
371
|
+
* La `K` de un perfil con candado, a partir de lo que se trae. Prueba solo las puertas que
|
|
372
|
+
* encajan con eso. Cuando nada encaja (una contraseña para un perfil que solo abre con llave)
|
|
373
|
+
* se dice con su código y NO cuenta como intento: no es adivinar, es equivocarse de puerta.
|
|
374
|
+
*
|
|
375
|
+
* La contraseña de ANTES de las puertas (`p.pwd` + `p.kdf`, `K = scrypt(contraseña)`) se sigue
|
|
376
|
+
* aceptando, y al acertarla se pasa a puerta: es el único momento en que se tiene en la mano.
|
|
377
|
+
*/
|
|
378
|
+
async function resolveK (p, { password, door, secret }) {
|
|
379
|
+
const real = realDoors(p)
|
|
380
|
+
let cands
|
|
381
|
+
if (door) {
|
|
382
|
+
const d = real.find((x) => x.id === door)
|
|
383
|
+
if (!d) throw Object.assign(new Error('that key is not a door of this profile'), { code: 'NO_SUCH_DOOR' })
|
|
384
|
+
if (d.withPassword && !password) throw Object.assign(new Error('this key also needs the password'), { code: 'NEEDS_PASSWORD' })
|
|
385
|
+
cands = [d]
|
|
386
|
+
} else if (password) {
|
|
387
|
+
cands = real.filter((d) => d.kind === 'password')
|
|
388
|
+
if (!cands.length && !p.pwd) {
|
|
389
|
+
throw Object.assign(new Error('this profile does not open with a password alone: use its security key'), { code: 'NEEDS_SECURITY_KEY' })
|
|
390
|
+
}
|
|
391
|
+
} else {
|
|
392
|
+
throw Object.assign(new Error('nothing to open the profile with'), { code: 'NO_CREDENTIALS' })
|
|
393
|
+
}
|
|
394
|
+
const tries = frenar(p)
|
|
395
|
+
let K = null
|
|
396
|
+
for (const d of cands) {
|
|
397
|
+
const k = doorKey(d, { password, secret })
|
|
398
|
+
if (!k) continue
|
|
399
|
+
try { K = Buffer.from(decryptText(d.wrapped, k), 'base64') } catch (_) { K = null }
|
|
400
|
+
k.fill(0)
|
|
401
|
+
if (K?.length === 32) break
|
|
402
|
+
K = null
|
|
403
|
+
}
|
|
404
|
+
let legacy = false
|
|
405
|
+
if (!K && password && p.pwd && !door) {
|
|
406
|
+
const proof = p.pwd.v === 2 ? deriveScryptPwd(password, p.pwd.salt) : await derivePwd(password, p.pwd.salt, p.pwd.iter)
|
|
407
|
+
if (proof === p.pwd.verifier) {
|
|
408
|
+
if (!p.kdf) p.kdf = { v: 1, salt: b64(crypto.getRandomValues(new Uint8Array(32))) }
|
|
409
|
+
K = scryptKey(password, p.kdf.salt)
|
|
410
|
+
legacy = true
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
if (!K) {
|
|
414
|
+
p.tries = { n: tries.n + 1, at: Date.now() }
|
|
415
|
+
save()
|
|
416
|
+
throw Object.assign(new Error(door ? 'the key did not open this profile' : 'wrong password'), { code: 'WRONG_PASSWORD', tries: p.tries.n })
|
|
417
|
+
}
|
|
418
|
+
delete p.tries
|
|
419
|
+
if (legacy) {
|
|
420
|
+
// A PUERTA: la misma `K`, ahora en un sobre de la contraseña. El verificador y el salt de
|
|
421
|
+
// derivación directa se van — de aquí en adelante la contraseña es una puerta más.
|
|
422
|
+
p.doors = [...real, makeDoor(K, { kind: 'password', password }, { checkMin: false })]
|
|
423
|
+
delete p.pwd; delete p.kdf
|
|
424
|
+
}
|
|
425
|
+
save()
|
|
426
|
+
return K
|
|
427
|
+
}
|
|
428
|
+
|
|
270
429
|
const api = {
|
|
271
430
|
get root () { return root },
|
|
272
431
|
dirOf,
|
|
@@ -500,8 +659,8 @@ export function openProfiles (root = dataDir(), { autoLockMs = AUTO_LOCK_MS, onA
|
|
|
500
659
|
|
|
501
660
|
// ----- candado -----
|
|
502
661
|
|
|
503
|
-
isProtected: (id) =>
|
|
504
|
-
isLocked: (id) => { const p = find(id); return !!p
|
|
662
|
+
isProtected: (id) => { const p = find(id); return !!p && isProtected(p) },
|
|
663
|
+
isLocked: (id) => { const p = find(id); return !!p && isProtected(p) && !isOpen(id) },
|
|
505
664
|
/** Cuánto dura abierto el candado sin usarse (ms). 0 = no se cierra solo. */
|
|
506
665
|
get autoLockMs () { return autoLockMs },
|
|
507
666
|
/**
|
|
@@ -516,55 +675,151 @@ export function openProfiles (root = dataDir(), { autoLockMs = AUTO_LOCK_MS, onA
|
|
|
516
675
|
return true
|
|
517
676
|
},
|
|
518
677
|
assertUnlocked (id) {
|
|
519
|
-
if (api.isLocked(id)) throw new Error('profile locked: unlock it
|
|
678
|
+
if (api.isLocked(id)) throw Object.assign(new Error('profile locked: unlock it first (dotrino-vault unlock)'), { code: 'PROFILE_LOCKED' })
|
|
520
679
|
},
|
|
521
680
|
|
|
681
|
+
// ----- LAS PUERTAS (`docs/llaves-de-hardware.md` §2bis) -----
|
|
682
|
+
//
|
|
683
|
+
// La llave del perfil (`K`) es UNA y NO CAMBIA: con ella van selladas la maestra, la copia
|
|
684
|
+
// de recuperación de los secretos y las contraseñas. Lo que cambia son las PUERTAS, y cada
|
|
685
|
+
// una es un sobre de `K`: contraseña, YubiKey con toque, YubiKey sin toque, o YubiKey MÁS
|
|
686
|
+
// contraseña. Abre cualquiera. Poner o quitar una puerta no vuelve a sellar nada.
|
|
687
|
+
//
|
|
688
|
+
// Antes `K` SALÍA de la contraseña, y cambiarla —o quitarla— cambiaba `K` sin volver a
|
|
689
|
+
// sellar la maestra: después de reiniciar quedaba cerrada con una llave que ya no existía
|
|
690
|
+
// (reproducido el 2026-10-05). Con puertas eso no puede pasar.
|
|
691
|
+
|
|
692
|
+
/** Las puertas, sin nada secreto: tipo, nombre y lo que hace falta para pedirle a la llave. */
|
|
693
|
+
doors (id) { return publicDoors(assertExists(id)) },
|
|
694
|
+
|
|
522
695
|
/**
|
|
523
|
-
* La llave
|
|
524
|
-
*
|
|
525
|
-
*
|
|
526
|
-
* Es scrypt (mismo coste que `machineKey`) y NO reusa el verificador del candado:
|
|
527
|
-
* ese es PBKDF2 y vive en claro en este mismo archivo, así que sería el camino
|
|
528
|
-
* barato para atacarla. Aquí la prueba de que es correcta es el tag AES-GCM del
|
|
529
|
-
* propio sobre — si no cuadra, la contraseña no era.
|
|
696
|
+
* La llave del perfil a partir de credenciales, SIN abrirlo. Abierto, una copia de la que
|
|
697
|
+
* está en memoria. Pasa por el mismo freno de intentos que abrir.
|
|
530
698
|
*/
|
|
531
|
-
async
|
|
532
|
-
const p =
|
|
533
|
-
if (
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
699
|
+
async keyWith (id, creds = {}) {
|
|
700
|
+
const p = assertExists(id)
|
|
701
|
+
if (isOpen(id) && llaves.get(id)) return new Uint8Array(llaves.get(id))
|
|
702
|
+
return new Uint8Array(await resolveK(p, normCreds(creds)))
|
|
703
|
+
},
|
|
704
|
+
|
|
705
|
+
/** Compatibilidad: la llave del perfil con la contraseña. Es `keyWith({ password })`. */
|
|
706
|
+
async adminKey (id, password) { return api.keyWith(id, { password }) },
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* ABRIR. `creds`: la contraseña (texto, por compatibilidad) o `{ password, door, secret }`,
|
|
710
|
+
* donde `secret` son los bytes que devolvió la llave de hardware para la puerta `door`.
|
|
711
|
+
*/
|
|
712
|
+
async unlock (id, creds) {
|
|
713
|
+
const p = assertExists(id)
|
|
714
|
+
if (!isProtected(p)) { open(id); return { ok: true, locked: false } }
|
|
715
|
+
const K = await resolveK(p, normCreds(creds))
|
|
716
|
+
open(id)
|
|
717
|
+
// La llave se queda mientras el candado esté abierto: es lo que permite envolverle
|
|
718
|
+
// su cajón a un servicio que se enrola AHORA, sin volver a pedir la frase.
|
|
719
|
+
llaves.set(id, new Uint8Array(K))
|
|
720
|
+
K.fill(0)
|
|
721
|
+
return { ok: true, locked: false }
|
|
722
|
+
},
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* La llave del perfil para PONERLE UNA PUERTA. Si el perfil todavía no tiene (nunca tuvo
|
|
726
|
+
* candado), se estrena una al azar y `fresh` lo dice: quien llama tiene que volver a
|
|
727
|
+
* sellar con ella lo que estaba bajo la llave de la máquina (`manager.addDoor`).
|
|
728
|
+
*/
|
|
729
|
+
ensureKey (id, { password = null } = {}) {
|
|
730
|
+
const p = assertExists(id)
|
|
731
|
+
if (hasKey(p)) {
|
|
732
|
+
api.assertUnlocked(id)
|
|
733
|
+
const K = api.openKey(id)
|
|
734
|
+
if (!K) throw Object.assign(new Error('the profile key is not in memory: unlock the profile first'), { code: 'PROFILE_LOCKED' })
|
|
735
|
+
return { K, fresh: false }
|
|
537
736
|
}
|
|
538
|
-
|
|
539
|
-
|
|
737
|
+
// LA DE ANTES, SI QUEDÓ SU SALT. Un perfil al que se le quitó la contraseña antes de las
|
|
738
|
+
// puertas tiene la maestra sellada con `scrypt(aquella, kdf.salt)`: poniendo la MISMA
|
|
739
|
+
// contraseña se recupera. Si no era esa, `manager.addDoor` lo ve (la maestra no abre) y
|
|
740
|
+
// no guarda nada.
|
|
741
|
+
const K = p.kdf?.salt && password
|
|
742
|
+
? new Uint8Array(scryptKey(password, p.kdf.salt))
|
|
743
|
+
: new Uint8Array(crypto2.randomBytes(32))
|
|
744
|
+
llaves.set(id, K)
|
|
745
|
+
open(id)
|
|
746
|
+
return { K, fresh: true }
|
|
540
747
|
},
|
|
541
748
|
|
|
542
|
-
|
|
749
|
+
/** Deshace un `ensureKey` fresco que no llegó a tener puerta. */
|
|
750
|
+
dropFreshKey (id) {
|
|
751
|
+
const p = find(id)
|
|
752
|
+
if (p && !hasKey(p)) { unlocked.delete(id); olvidar(id) }
|
|
753
|
+
},
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Pone una puerta. `spec`:
|
|
757
|
+
* { kind: 'password', password } — sustituye a la contraseña que hubiera
|
|
758
|
+
* { kind: 'fido2', credId, hsalt, secret, label, password? }
|
|
759
|
+
* { kind: 'chalresp', challenge, serial, secret, label, password? }
|
|
760
|
+
* Con `password` en una de hardware, hacen falta las DOS cosas para abrir por ella.
|
|
761
|
+
*/
|
|
762
|
+
/**
|
|
763
|
+
* Fabrica la puerta SIN guardarla: así quien llama comprueba que es válida (contraseña con
|
|
764
|
+
* el mínimo, llave que devolvió algo) antes de sellar nada con esa llave.
|
|
765
|
+
*/
|
|
766
|
+
prepareDoor (id, spec) {
|
|
767
|
+
assertExists(id)
|
|
768
|
+
const K = api.openKey(id)
|
|
769
|
+
if (!K) throw Object.assign(new Error('unlock the profile first'), { code: 'PROFILE_LOCKED' })
|
|
770
|
+
return makeDoor(K, spec)
|
|
771
|
+
},
|
|
772
|
+
|
|
773
|
+
addDoor (id, spec) {
|
|
543
774
|
const p = assertExists(id)
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
const
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
if (
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
775
|
+
const K = api.openKey(id)
|
|
776
|
+
if (!K) throw Object.assign(new Error('unlock the profile first'), { code: 'PROFILE_LOCKED' })
|
|
777
|
+
const door = spec?.prepared || makeDoor(K, spec)
|
|
778
|
+
let doors = (p.doors || []).filter((d) => d.kind !== 'machine')
|
|
779
|
+
// UNA contraseña por perfil: poner otra la sustituye, también a la de antes de las puertas.
|
|
780
|
+
if (door.kind === 'password') { doors = doors.filter((d) => d.kind !== 'password'); delete p.pwd }
|
|
781
|
+
// El salt de derivación directa solo sirve para recuperar (ver `ensureKey`); con la llave
|
|
782
|
+
// ya en una puerta, sobra.
|
|
783
|
+
delete p.kdf
|
|
784
|
+
p.doors = [...doors, door]
|
|
785
|
+
delete p.tries
|
|
786
|
+
save()
|
|
787
|
+
// Si la llave venía de la de la máquina (perfil sin candado), ahora es la del perfil
|
|
788
|
+
// ABIERTO: tiene que estar donde la busca `openKey` con el candado puesto.
|
|
789
|
+
if (!llaves.get(id)) llaves.set(id, new Uint8Array(K))
|
|
790
|
+
open(id)
|
|
791
|
+
return publicDoor(door)
|
|
792
|
+
},
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Quita una puerta (por id) o la contraseña (`'password'`). Si no queda ninguna, el perfil
|
|
796
|
+
* se queda SIN candado: `K` pasa a guardarse con la llave de esta máquina, igual que el
|
|
797
|
+
* resto del disco, y se abre sola. Lo sellado con `K` sigue abriendo — no se toca nada.
|
|
798
|
+
*/
|
|
799
|
+
removeDoor (id, which) {
|
|
800
|
+
const p = assertExists(id)
|
|
801
|
+
api.assertUnlocked(id)
|
|
802
|
+
const K = api.openKey(id)
|
|
803
|
+
if (!K) throw Object.assign(new Error('unlock the profile first'), { code: 'PROFILE_LOCKED' })
|
|
804
|
+
const before = realDoors(p).length + (p.pwd ? 1 : 0)
|
|
805
|
+
if (which === 'password') {
|
|
806
|
+
p.doors = (p.doors || []).filter((d) => d.kind !== 'password')
|
|
807
|
+
delete p.pwd; delete p.kdf
|
|
808
|
+
} else {
|
|
809
|
+
if (!(p.doors || []).some((d) => d.id === which && d.kind !== 'machine')) {
|
|
810
|
+
throw Object.assign(new Error('that door does not exist: ' + which), { code: 'NO_SUCH_DOOR' })
|
|
811
|
+
}
|
|
812
|
+
p.doors = p.doors.filter((d) => d.id !== which)
|
|
553
813
|
}
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
p.pwd = { v: 2, salt, verifier: deriveScryptPwd(password, salt) }
|
|
814
|
+
const removed = before - realDoors(p).length
|
|
815
|
+
if (!realDoors(p).length) {
|
|
816
|
+
p.doors = [{ id: newDoorId(), kind: 'machine', createdAt: Date.now(), wrapped: encryptText(b64(K), Buffer.from(kekFor(dirOf(id)))) }]
|
|
817
|
+
// Sin candado no hay nada que abrir a distancia.
|
|
818
|
+
delete p.kdf2
|
|
560
819
|
}
|
|
561
|
-
delete p.tries
|
|
562
820
|
save()
|
|
563
821
|
open(id)
|
|
564
|
-
|
|
565
|
-
// su cajón a un servicio que se enrola AHORA, sin volver a pedir la frase.
|
|
566
|
-
llaves.set(id, await api.adminKey(id, password))
|
|
567
|
-
return { ok: true, locked: false }
|
|
822
|
+
return { removed, protected: isProtected(p) }
|
|
568
823
|
},
|
|
569
824
|
|
|
570
825
|
// ----- LA SEGUNDA CONTRASEÑA: la del admin (`docs/abrir-a-distancia.md`) -----
|
|
@@ -614,7 +869,7 @@ export function openProfiles (root = dataDir(), { autoLockMs = AUTO_LOCK_MS, onA
|
|
|
614
869
|
*/
|
|
615
870
|
setSecondary (id, derivada) {
|
|
616
871
|
const p = assertExists(id)
|
|
617
|
-
if (!p
|
|
872
|
+
if (!isProtected(p)) throw Object.assign(new Error('this profile has no lock: there is nothing to open remotely'), { code: 'NO_PASSWORD' })
|
|
618
873
|
const llave = api.openKey(id)
|
|
619
874
|
if (!llave) throw Object.assign(new Error('open the profile first (dotrino-vault unlock)'), { code: 'PROFILE_LOCKED' })
|
|
620
875
|
if (!(derivada instanceof Uint8Array) || derivada.length !== 32) throw new Error('the admin key must be 32 bytes')
|
|
@@ -668,32 +923,37 @@ export function openProfiles (root = dataDir(), { autoLockMs = AUTO_LOCK_MS, onA
|
|
|
668
923
|
* volver a pedir la frase. Cerrado —o nunca abierto— devuelve `null` y quien llame
|
|
669
924
|
* tendrá que pedirla, que es exactamente el comportamiento de antes.
|
|
670
925
|
*/
|
|
671
|
-
openKey (id) {
|
|
926
|
+
openKey (id) {
|
|
927
|
+
if (isOpen(id)) return llaves.get(id) || null
|
|
928
|
+
// SIN CANDADO PERO CON LLAVE: un perfil al que se le quitó la última puerta. Su `K` vive
|
|
929
|
+
// bajo la llave de la máquina y se abre sola, como el resto del disco.
|
|
930
|
+
const p = find(id)
|
|
931
|
+
const m = p && !isProtected(p) ? (p.doors || []).find((d) => d.kind === 'machine') : null
|
|
932
|
+
if (!m) return null
|
|
933
|
+
if (!porMaquina.has(id)) {
|
|
934
|
+
const K = Buffer.from(decryptText(m.wrapped, Buffer.from(kekFor(dirOf(id)))), 'base64')
|
|
935
|
+
if (K.length !== 32) throw Object.assign(new Error('the profile key under this machine key is malformed'), { code: 'BAD_PROFILE_KEY' })
|
|
936
|
+
porMaquina.set(id, new Uint8Array(K))
|
|
937
|
+
}
|
|
938
|
+
return porMaquina.get(id)
|
|
939
|
+
},
|
|
672
940
|
|
|
673
|
-
/**
|
|
941
|
+
/**
|
|
942
|
+
* Pone o cambia la contraseña (la puerta `password`). Atajo de `ensureKey` + `addDoor`: si
|
|
943
|
+
* el perfil no tenía llave se estrena una, y abrirlo después sella con ella la maestra y la
|
|
944
|
+
* copia de recuperación (`takeMasterKey`, `migrarRecuperacionALaFrase`). El camino completo,
|
|
945
|
+
* que vuelve a sellar en el acto, es `manager.addDoor`.
|
|
946
|
+
*/
|
|
674
947
|
async setPassword (id, password) {
|
|
675
|
-
const
|
|
676
|
-
api.
|
|
677
|
-
|
|
678
|
-
throw Object.assign(new Error(`password must be at least ${PWD_MIN} characters: use several random words`),
|
|
679
|
-
{ code: 'PASSWORD_TOO_SHORT', min: PWD_MIN })
|
|
680
|
-
}
|
|
681
|
-
const salt = b64(crypto.getRandomValues(new Uint8Array(16)))
|
|
682
|
-
p.pwd = { v: 2, salt, verifier: deriveScryptPwd(password, salt) }
|
|
683
|
-
delete p.tries
|
|
684
|
-
save()
|
|
685
|
-
open(id)
|
|
686
|
-
return entry(p)
|
|
948
|
+
const { fresh } = api.ensureKey(id)
|
|
949
|
+
try { api.addDoor(id, { kind: 'password', password }) } catch (e) { if (fresh) api.dropFreshKey(id); throw e }
|
|
950
|
+
return entry(find(id))
|
|
687
951
|
},
|
|
688
952
|
|
|
953
|
+
/** Quita la contraseña. Exige el perfil abierto. */
|
|
689
954
|
removePassword (id) {
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
delete p.pwd
|
|
693
|
-
delete p.tries
|
|
694
|
-
save()
|
|
695
|
-
open(id)
|
|
696
|
-
return entry(p)
|
|
955
|
+
api.removeDoor(id, 'password')
|
|
956
|
+
return entry(find(id))
|
|
697
957
|
}
|
|
698
958
|
}
|
|
699
959
|
|
package/src/threadStore.js
CHANGED
|
@@ -220,15 +220,19 @@ export function openThreadStore (dir) {
|
|
|
220
220
|
* sirve nada cerrado con ella (dueño, 2026-09-30). Cada entrada que cambia estrena `ts`,
|
|
221
221
|
* para que los aparatos la bajen en su siguiente sincronización.
|
|
222
222
|
*
|
|
223
|
+
* También MIGRA los sobres de la cuenta anteriores a la marca (`{ gen, iv, ct }` a secas,
|
|
224
|
+
* identity < 0.107): si abren con la llave de la cuenta se vuelven a cerrar marcados; si no,
|
|
225
|
+
* no eran de la cuenta y se cuentan en `skipped` sin tocarlos.
|
|
226
|
+
*
|
|
223
227
|
* La versión anterior NO se tira: queda en `resealed` con la fecha, por si algo sale mal
|
|
224
228
|
* (lo pidió el dueño: «podría retenerlos… pero no los envía»), y se guarda AL MENOS un
|
|
225
229
|
* año (`RESEALED_KEEP_MS`). No se sirve por ningún método.
|
|
226
230
|
*
|
|
227
231
|
* @param {{ gen: number, reseal: (env: object) => Promise<object> }} o
|
|
228
|
-
* @returns {Promise<{ changed: number, entries: number }>}
|
|
232
|
+
* @returns {Promise<{ changed: number, entries: number, skipped: number }>}
|
|
229
233
|
*/
|
|
230
234
|
async function resealStaleEnvelopes ({ gen, reseal }) {
|
|
231
|
-
let changed = 0; let entries = 0
|
|
235
|
+
let changed = 0; let entries = 0; let skipped = 0
|
|
232
236
|
const now = Date.now()
|
|
233
237
|
if (!data.resealed) data.resealed = []
|
|
234
238
|
// Solo sale lo que ya cumplió su año de respaldo.
|
|
@@ -238,6 +242,7 @@ export function openThreadStore (dir) {
|
|
|
238
242
|
for (const [key, arr] of Object.entries(data.threads)) {
|
|
239
243
|
for (let i = 0; i < arr.length; i++) {
|
|
240
244
|
const r = await resealStale(arr[i], { gen, reseal })
|
|
245
|
+
skipped += r.skipped || 0
|
|
241
246
|
if (!r.changed) continue
|
|
242
247
|
data.resealed.push({ threadKey: key, at: now, gen, before: arr[i] })
|
|
243
248
|
arr[i] = { ...r.value, ts: Math.max(now, (arr[i].ts || 0) + 1) }
|
|
@@ -245,7 +250,7 @@ export function openThreadStore (dir) {
|
|
|
245
250
|
}
|
|
246
251
|
}
|
|
247
252
|
if (entries || podados) save()
|
|
248
|
-
return { changed, entries }
|
|
253
|
+
return { changed, entries, skipped }
|
|
249
254
|
}
|
|
250
255
|
|
|
251
256
|
return { methods, raw: () => data, resealStaleEnvelopes }
|