@dotrino/vaultd 0.53.0 → 0.56.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/src/daemon.js CHANGED
@@ -16,8 +16,10 @@
16
16
  */
17
17
  import fs from 'node:fs'
18
18
  import path from 'node:path'
19
+ import { takeLock } from '../lib/src/lock.js'
19
20
  import { startVaultManager } from './manager.js'
20
21
  import { dataDir, writeJson, readJson } from './paths.js'
22
+ import { parseInvite } from '../lib/src/invite.js'
21
23
  import { VERSION } from './version.js'
22
24
 
23
25
  const readJsonSafe = (f) => readJson(f, null)
@@ -32,21 +34,33 @@ const rm = (f) => { try { fs.rmSync(f, { force: true }) } catch (_) {} }
32
34
  * datos, los dos con tu identidad y los dos conectados al proxy; el segundo pisaba el pid
33
35
  * de `state.json`, así que el CLI solo le hablaba a uno y el otro quedaba de fantasma.
34
36
  *
35
- * El candado es el propio `state.json`: si el pid que hay sigue vivo, no arrancamos. Un
36
- * pid muerto (se cortó la luz) no estorba. Con `DOTRINO_VAULT_DIR` distintos conviven
37
- * cuantas quieras: lo que colisiona es el directorio, no el programa.
37
+ * El candado vive en `vault.lock` (ver `lib/src/lock.js`) y funciona TAMBIÉN entre
38
+ * máquinas: `O_EXCL` para la exclusión y un latido para que un corte de luz no deje el
39
+ * directorio bloqueado para siempre. Antes era un pid, y un pid no cruza contenedores ni
40
+ * hosts — dos contenedores sobre el mismo volumen arrancaban los dos.
41
+ *
42
+ * Con `DOTRINO_VAULT_DIR` distintos conviven cuantas quieras, y es así como se ponen
43
+ * varias bóvedas en un mismo disco: cada una con su directorio entero, sin nada
44
+ * compartido. Lo que colisiona es el directorio, no el programa.
38
45
  */
39
46
  function assertSingleInstance (dir) {
40
- let s = null
41
- try { s = JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8')) } catch (_) { return }
42
- const pid = Number(s?.pid)
43
- if (!pid || pid === process.pid) return
44
- try { process.kill(pid, 0) } catch (_) { return } // no existe: el candado es de un muerto
45
- console.error('A vault is already running on this data (process %d).', pid)
46
- console.error(' data: %s', dir)
47
- console.error('Two vaults on the same directory step on each other: stop the other one,')
48
- console.error('or use DOTRINO_VAULT_DIR to give this one its own directory.')
49
- process.exit(3)
47
+ try {
48
+ const { release } = takeLock(dir)
49
+ // Se suelta al salir por las buenas. Si el proceso muere de golpe el candado se queda,
50
+ // y por eso caduca por latido: uno inmortal sería peor que ninguno.
51
+ const soltar = () => { try { release() } catch (_) {} }
52
+ process.once('exit', soltar)
53
+ for (const sig of ['SIGINT', 'SIGTERM']) process.once(sig, () => { soltar(); process.exit(0) })
54
+ } catch (e) {
55
+ if (e?.code !== 'vault-locked') throw e
56
+ console.error('A vault is already running on this data.')
57
+ console.error(' %s', e.message)
58
+ console.error(' data: %s', dir)
59
+ console.error('Two vaults on the same directory are not two vaults: they are the SAME one')
60
+ console.error('running twice — same master key, both sealing records as the same sealer.')
61
+ console.error('Stop the other one, or use DOTRINO_VAULT_DIR to give this one its own.')
62
+ process.exit(3)
63
+ }
50
64
  }
51
65
 
52
66
  export async function runDaemon () {
@@ -129,6 +143,19 @@ export async function runDaemon () {
129
143
  // --- SIGUSR1: iniciar emparejamiento ---
130
144
  const pairFile = path.join(dir, 'pair.json')
131
145
  let pendingApproval = false // lo pidió `pair --approval`; se aplica al aprobar
146
+ /**
147
+ * Lo pidió `pair --admin`: el aparato que entre por ESTA invitación podrá administrar.
148
+ *
149
+ * Sigue en pie la regla de que **ningún QR concede administración**: el QR no lleva
150
+ * nada. Lo que hay es una nota LOCAL de esta bóveda, y el permiso se aplica en el
151
+ * mismo gesto que ya era la puerta —aprobar con el código tecleado aquí—, exactamente
152
+ * igual que `--approval`. Es el mismo `caps <ID> +administra` que harías a mano un
153
+ * segundo después, sin tener que ir a buscar el ID.
154
+ *
155
+ * Existe por el contenedor: allí cada paso cuesta un `docker exec`, y el primer aparato
156
+ * de una bóveda recién desplegada es SIEMPRE la consola.
157
+ */
158
+ let pendingAdmin = false
132
159
  const pairReqFile = path.join(dir, 'pair-request.json')
133
160
  async function handlePairingRequest () {
134
161
  try {
@@ -174,6 +201,7 @@ export async function runDaemon () {
174
201
  // `pair --approval`: el aparato que entre por esta invitación pedirá el visto bueno del
175
202
  // teléfono cada vez que reciba claves privadas (se aplica al aprobarlo, abajo).
176
203
  pendingApproval = !!pairReq?.approval
204
+ pendingAdmin = !!pairReq?.admin
177
205
  // `profile`/`profileName`: la CUENTA del vault a la que entra el dispositivo.
178
206
  // Con varias bóvedas en el mismo daemon, el QR sale de UNA y quien empareja
179
207
  // tiene que verlo (lo muestran la TUI y `dotrino-vault pair`). El nombre viaja
@@ -209,6 +237,10 @@ export async function runDaemon () {
209
237
  const rejectReqFile = path.join(dir, 'reject-request.json')
210
238
  const revokeReqFile = path.join(dir, 'revoke-request.json')
211
239
  const secretReqFile = path.join(dir, 'secret-request.json')
240
+ // MULTIVAULT: esta bóveda se UNE a la cuenta de otra. Va por aquí y no por `pair` porque
241
+ // es el papel contrario — aquí no se invita a nadie, se acepta una invitación ajena.
242
+ const joinReqFile = path.join(dir, 'join-request.json')
243
+ const joinResFile = path.join(dir, 'join.json')
212
244
  const secretsListFile = path.join(dir, 'secrets-list.json')
213
245
  /**
214
246
  * Por qué falló la última orden de variables. La consola pide el cambio y el volcado
@@ -279,7 +311,7 @@ export async function runDaemon () {
279
311
  case 'list': return {} // el volcado de perfiles ya se hace abajo
280
312
  // `id`: quien la crea necesita saber CUÁL quedó, no adivinar por nombre (dos
281
313
  // cuentas pueden llamarse igual). Lo usa «emparejar en una cuenta nueva».
282
- case 'add': { const p = await mgr.add(req.name, { adopt: !!req.adopt }); return { done: `perfil creado: ${p.name || p.id}`, id: p.id, adopt: !!req.adopt } }
314
+ case 'add': { const p = await mgr.add(req.name, { adopt: !!req.adopt, kek: req.kek || null }); return { done: `perfil creado: ${p.name || p.id}${req.kek ? ' (clave del disco en el KMS)' : ''}`, id: p.id, adopt: !!req.adopt } }
283
315
  case 'rm': { const r = await mgr.remove(req.profile); return { done: `perfil borrado: ${r.name || r.id}` } }
284
316
  case 'rename': { const p = mgr.profiles.rename(ref(), req.name); return { done: `perfil renombrado: ${p.name}` } }
285
317
  case 'use': { const p = mgr.profiles.setCurrent(ref()); return { done: `perfil activo: ${p.name || p.id}` } }
@@ -354,6 +386,18 @@ export async function runDaemon () {
354
386
  const r = await vault.approveDevice(appr.code); rm(pendingEnrollFile); rm(pairFile)
355
387
  if (pendingApproval && r?.cert?.sub) { try { await vault.setApproval(r.cert.sub, true); console.log('[vault] the new device will need approval on every key request') } catch (e) { console.error('[vault] could not flag approval: %s', e.message) } }
356
388
  pendingApproval = false
389
+ // `pair --admin`: se le SUMA `admin` a lo que ya tiene, no se le reescriben los
390
+ // permisos — el aparato acaba de entrar con el scope que pidió la invitación.
391
+ if (pendingAdmin && r?.deviceId) {
392
+ try {
393
+ const rec = await vault.profileMembers()
394
+ const m = (rec?.members || []).find((x) => x.id === r.deviceId)
395
+ if (!m?.pub) throw new Error('the device is not in the record yet')
396
+ await vault.setCaps(m.pub, [...new Set([...(m.caps || []), 'admin'])])
397
+ console.log('[vault] the new device can ADMINISTER this account (console)')
398
+ } catch (e) { console.error('[vault] could not grant admin: %s', e.message) }
399
+ }
400
+ pendingAdmin = false
357
401
  console.log('[vault] aprobado %s', r.deviceId)
358
402
  answer({ ok: true, deviceId: r.deviceId || null })
359
403
  } catch (e) {
@@ -394,6 +438,62 @@ export async function runDaemon () {
394
438
  } catch (e) { console.error('[vault] revocation failed:', e.message) }
395
439
  rm(revokeReqFile)
396
440
  }
441
+ /**
442
+ * UNIRSE A LA CUENTA DE OTRA BÓVEDA (multivault). La llave que entra como miembro es
443
+ * la de ESTA bóveda —no una de aparato inventada—, que es lo que después permite
444
+ * darle `+sella` y que sea el respaldo de verdad de la otra.
445
+ *
446
+ * El código de confirmación lo genera esta bóveda y hay que TIPEARLO en la otra: la
447
+ * misma defensa de siempre, para que una invitación interceptada no baste.
448
+ */
449
+ const join = readJsonSafe(joinReqFile)
450
+ if (join?.qr) {
451
+ rm(joinReqFile)
452
+ rm(joinResFile)
453
+ // LA CUENTA AJENA VA EN UN PERFIL DEL GESTOR, no en una cuenta interna de la
454
+ // identidad. Antes esto usaba `enrollDevice(…, { join: 'new' })`, que crea una
455
+ // cuenta más DENTRO de la identidad de un perfil que ya existía —y el gestor no se
456
+ // enteraba—: no había instancia de bóveda para ella, nadie se identificaba en el
457
+ // proxio con esa llave, y el aviso de la otra bóveda —con el acta donde acababa de
458
+ // conceder `sella`— no llegaba a ninguna parte. Se unía y no servía para nada.
459
+ //
460
+ // Un perfil nace vacío y `adopt: true`, que es la marca que deja a `joinProfile`
461
+ // cambiar su acta recién nacida por la que traiga la otra bóveda; sin ella, unirse
462
+ // sería pisar una cuenta con datos y se rechaza, que es lo correcto por defecto.
463
+ let nacido = null
464
+ try {
465
+ const p = await mgr.add(join.name || 'cuenta de la otra bóveda', { adopt: true, kek: join.kek || null })
466
+ nacido = p.id
467
+ const id = mgr.get(p.id)?.identity
468
+ if (!id) throw new Error('no identity for the new profile')
469
+ const off = id.onVault?.((e) => {
470
+ if (e?.phase === 'challenge' && e.code) {
471
+ console.log('[vault] type this code in the other vault: %s', e.code)
472
+ writeJson(joinResFile, { at: Date.now(), code: e.code, state: 'waiting', profile: p.id })
473
+ }
474
+ })
475
+ // `'current'` y no `'new'`: el perfil que se acaba de crear ES el sitio, y su
476
+ // llave recién hecha es la que entra en el acta de la otra bóveda. Crear ahí
477
+ // dentro otra cuenta más sería el bug de arriba otra vez, un nivel más abajo.
478
+ const r = await id.enrollDevice(join.qr, { label: join.label || 'bóveda', join: 'current' })
479
+ off?.()
480
+ // LA CUENTA RECIÉN ADOPTADA PASA A SER LA ACTIVA, y no es un capricho: es la
481
+ // razón por la que se hizo el `join`. Sin esto, el `Perfil 1` vacío que nace en
482
+ // el primer arranque sigue siendo el destino por defecto y todo lo que hagas
483
+ // después —emparejar un aparato, aprobarlo— entra en la cuenta equivocada sin
484
+ // decir nada. En un contenedor que arranca para respaldar una cuenta, ese perfil
485
+ // vacío no es más que un accidente del primer arranque.
486
+ try { mgr.profiles.setCurrent(p.id) } catch (_) {}
487
+ console.log('[vault] joined the account of the other vault (record #%s) as profile %s (now the active one)', r?.acta?.seq ?? '?', p.id)
488
+ writeJson(joinResFile, { at: Date.now(), state: 'done', seq: r?.acta?.seq ?? null, profile: p.id })
489
+ } catch (e) {
490
+ console.error('[vault] could not join:', e.message)
491
+ // El perfil nació para esto y está vacío: si el intento no llegó a término se va
492
+ // con él. Si no, cada reintento dejaba una cuenta fantasma en el conmutador.
493
+ if (nacido) { try { await mgr.remove(nacido) } catch (_) {} }
494
+ writeJson(joinResFile, { at: Date.now(), state: 'error', error: e.message })
495
+ }
496
+ }
397
497
  // Secretos: `secret set/rm` (por SCOPE) y `secret device set/rm` (por APARATO),
398
498
  // del CLI o de la TUI. El archivo con el valor vive un instante en el mismo dir
399
499
  // 0700 del vault y se borra al consumir.
@@ -650,6 +750,59 @@ export async function runDaemon () {
650
750
  process.on('SIGTERM', () => shutdown('SIGTERM'))
651
751
  process.on('SIGINT', () => shutdown('SIGINT'))
652
752
 
753
+ /**
754
+ * ARRANCAR YA UNIDO A UNA CUENTA — lo que hace desplegable un contenedor.
755
+ *
756
+ * El problema del contenedor no es la bóveda: es el PRIMER APARATO. Una bóveda recién
757
+ * levantada tiene que **enseñar** una invitación y **recibir** de vuelta un código
758
+ * tecleado, y un contenedor no tiene pantalla ni teclado. Con una sola bóveda no hay
759
+ * salida: alguien tiene que entrar (`docker exec`, `kubectl exec`, ECS Exec).
760
+ *
761
+ * Con dos, sí la hay, y es este camino: **la invitación la hace la bóveda que TIENE un
762
+ * humano delante** (tu PC) y el contenedor solo la acepta. Entonces todo lo interactivo
763
+ * pasa del lado donde hay alguien —el código se teclea allí— y el contenedor no
764
+ * necesita más que dos cosas que ya tiene: la invitación al arrancar, y su registro.
765
+ *
766
+ * docker run -e DOTRINO_JOIN="$(dotrino-vault pair --quiet)" …
767
+ * docker logs -f dotrino-vault → «type this code in the other vault: 123456»
768
+ * dotrino-vault approve 123456 (en tu PC)
769
+ *
770
+ * `DOTRINO_JOIN_FILE` es lo mismo apuntando a un archivo, y es lo que hay que usar en
771
+ * serio: una variable de entorno la ve cualquiera con `docker inspect`, y aunque la
772
+ * invitación caduque y sea de un solo uso, no tiene por qué quedar ahí escrita.
773
+ *
774
+ * SE HACE UNA VEZ. La invitación consumida se anota y no se vuelve a intentar aunque la
775
+ * variable siga puesta: si no, cada reinicio del contenedor pediría entrar otra vez.
776
+ */
777
+ function bootstrapJoin () {
778
+ const raw = process.env.DOTRINO_JOIN_FILE
779
+ ? (() => { try { return fs.readFileSync(process.env.DOTRINO_JOIN_FILE, 'utf8') } catch (e) { console.error('[vault] could not read DOTRINO_JOIN_FILE: %s', e.message); return '' } })()
780
+ : (process.env.DOTRINO_JOIN || '')
781
+ const texto = String(raw).trim()
782
+ if (!texto) return
783
+
784
+ let qr = null
785
+ try { qr = parseInvite(texto) } catch (_) {}
786
+ if (!qr?.sn || !qr?.iss || !qr?.proxy) {
787
+ console.error('[vault] the invitation in DOTRINO_JOIN is not valid; ignoring it')
788
+ return
789
+ }
790
+ const marca = path.join(dir, 'bootstrap.json')
791
+ const hecho = readJsonSafe(marca)
792
+ if (hecho?.sn === qr.sn) return // ya se usó: un reinicio no vuelve a pedir entrar
793
+
794
+ writeJson(marca, { at: Date.now(), sn: qr.sn, iss: qr.iss })
795
+ writeJson(path.join(dir, 'join-request.json'), {
796
+ qr,
797
+ label: 'bóveda',
798
+ ...(process.env.DOTRINO_JOIN_NAME ? { name: process.env.DOTRINO_JOIN_NAME } : {})
799
+ })
800
+ console.log('[vault] joining the account of another vault (invitation from the deployment)…')
801
+ console.log('[vault] the code to type in the OTHER vault will appear below')
802
+ serve()
803
+ }
804
+ bootstrapJoin()
805
+
653
806
  console.log('[vault] servicio listo.')
654
807
  return mgr
655
808
  }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * keyowner.js — ESTE DIRECTORIO ES DE ESTA LLAVE, Y DE NINGUNA OTRA.
3
+ *
4
+ * Es lo único que hay que proteger (dueño, 2026-08-30): *«cada proceso tenga un directorio
5
+ * alusivo a su llave para que nunca se mezclen»*.
6
+ *
7
+ * Y lo que hay detrás es que **no hay datos compartidos**. Cada bóveda tiene su directorio
8
+ * entero —su identidad, sus perfiles, sus sobres— así que dos procesos nunca escriben el
9
+ * mismo archivo. No hace falta fusionar nada ni repartir carpetas por dentro: hace falta
10
+ * que nadie acabe, por accidente, arrancando con la identidad de otro.
11
+ *
12
+ * Cómo se accidenta uno: un `docker compose` duplicado, un `DOTRINO_VAULT_DIR` copiado de
13
+ * otro servicio, un respaldo restaurado encima. Sin esto, el proceso arranca tan tranquilo
14
+ * con una maestra que no es la suya y se identifica en el proxio como otro.
15
+ *
16
+ * La marca va EN CLARO a propósito: es una pubkey, no un secreto, y tiene que poder
17
+ * leerse antes de abrir nada.
18
+ */
19
+ import fs from 'node:fs'
20
+ import path from 'node:path'
21
+ import { pubkeyId } from '@dotrino/identity/capabilities'
22
+
23
+ export const OWNER_FILE = 'key.json'
24
+
25
+ /**
26
+ * EL NOMBRE DE LA CARPETA SALE DE LA LLAVE.
27
+ *
28
+ * Una llave, una carpeta (dueño, 2026-08-30): un proceso con varios perfiles tiene varias
29
+ * llaves maestras y, por tanto, varias carpetas. Con el nombre derivado de la llave eso
30
+ * deja de ser algo que se comprueba y pasa a ser imposible por construcción — dos llaves
31
+ * no pueden caer en la misma carpeta porque no se llaman igual.
32
+ *
33
+ * Empieza por la huella que se le enseña a una persona (`AB12-CD34`, la misma que sale en
34
+ * `members` y en `profile ls`), para poder mirar el disco y reconocer de quién es cada
35
+ * una; y sigue con 16 hex más, porque para *garantizar* que no chocan hacen falta más bits
36
+ * de los que caben en algo que se lee en voz alta. Quitándole los guiones, el nombre
37
+ * EMPIEZA por el mismo id que la bóveda imprime al arrancar (`listo · id 0571465f61a2fac8`),
38
+ * así que se puede casar el log con la carpeta de un vistazo.
39
+ *
40
+ * Ojo con la palabra «maestra»: cuando esta bóveda se une a la cuenta de otra (`join`), su
41
+ * llave sigue siendo la misma pero pasa a ser MIEMBRO del acta ajena, no su master. Lo que
42
+ * nombra la carpeta es la llave que esta bóveda tiene para ese perfil, que no cambia
43
+ * nunca; lo que cambia es su papel.
44
+ */
45
+ export async function keyDirName (pub) {
46
+ const h = await pubkeyId(pub) // SHA-256 del JWK canónico, 64 hex
47
+ const label = h.slice(0, 8).toUpperCase()
48
+ return `${label.slice(0, 4)}-${label.slice(4, 8)}-${h.slice(8, 24)}`
49
+ }
50
+
51
+ /** De quién es este directorio, o `null` si todavía no lo dice. */
52
+ export function keyOwnerOf (dir) {
53
+ try { return JSON.parse(fs.readFileSync(path.join(dir, OWNER_FILE), 'utf8'))?.pub || null } catch (_) { return null }
54
+ }
55
+
56
+ /**
57
+ * Comprueba que este directorio es de esta llave, o lo marca si está por estrenar.
58
+ * Lanza con `code: 'key-mismatch'` si es de otra — sin tocar nada.
59
+ */
60
+ export function assertKeyOwnsDir (dir, pub) {
61
+ if (!pub) return null // sin llave todavía: no hay nada que comparar
62
+ const previo = keyOwnerOf(dir)
63
+
64
+ if (previo && previo !== pub) {
65
+ const e = new Error(
66
+ 'this data directory belongs to a DIFFERENT identity. Two vaults must never share one: ' +
67
+ 'each process needs its own directory, or one ends up running with the other\'s master key. ' +
68
+ 'Point this one at its own DOTRINO_VAULT_DIR. Nothing was modified.'
69
+ )
70
+ e.code = 'key-mismatch'
71
+ throw e
72
+ }
73
+
74
+ if (!previo) {
75
+ try {
76
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
77
+ fs.writeFileSync(path.join(dir, OWNER_FILE), JSON.stringify({ pub, at: Date.now() }, null, 2) + '\n', { mode: 0o600 })
78
+ } catch (_) { /* si no se puede marcar, no se bloquea el arranque */ }
79
+ }
80
+ return pub
81
+ }
82
+
83
+ export default { assertKeyOwnsDir, keyOwnerOf, keyDirName, OWNER_FILE }
package/src/manager.js CHANGED
@@ -12,6 +12,8 @@ import { startVault } from './vault.js'
12
12
  import { openProfiles } from './profiles.js'
13
13
  import { installNodeGlobals } from './node-globals.js'
14
14
  import { dataDir, ensureDir } from './paths.js'
15
+ import { Identity } from '@dotrino/identity/node'
16
+ import { atRestFor } from './atrest.js'
15
17
 
16
18
  /**
17
19
  * D12 (`docs/acta-de-perfil.md`): la bóveda **no** borra una cuenta que ella manda si
@@ -49,7 +51,26 @@ export async function startVaultManager ({ root = dataDir(), proxyUrl, log = con
49
51
  onAutoLock: (id) => log('[vault] profile %s locked itself after %d min idle (the console; devices keep working)',
50
52
  id, Math.round(profiles.autoLockMs / 60000))
51
53
  })
52
- const migrated = profiles.migrate()
54
+ /**
55
+ * ACUÑAR (o simplemente LEER) la llave de una carpeta, y cerrarla enseguida.
56
+ *
57
+ * Existe porque el nombre de la carpeta sale de la llave y hay que saber cuál es antes
58
+ * de poder ponérselo. La llave se genera en memoria y se escribe acto seguido —no hay
59
+ * costura entre las dos cosas en `Identity.connect`—, así que se acuña donde sea y
60
+ * después se mueve la carpeta.
61
+ *
62
+ * Se CIERRA (`destroy`) antes de devolver: quien llama va a renombrar la carpeta, y una
63
+ * identidad abierta se quedó con la ruta vieja como texto.
64
+ */
65
+ async function mintKey (dir) {
66
+ const identity = await Identity.connect({ dir, atRest: atRestFor(dir) })
67
+ try {
68
+ if (!identity.me?.publickey) await identity.setMyNickname('')
69
+ return identity.me?.publickey || null
70
+ } finally { try { identity.destroy() } catch (_) {} }
71
+ }
72
+
73
+ const migrated = await profiles.migrate(mintKey)
53
74
  if (migrated?.migrated) log('[vault] identidad mono-perfil migrada al perfil %s', migrated.id)
54
75
 
55
76
  const running = new Map() // id -> instancia de startVault
@@ -79,7 +100,15 @@ export async function startVaultManager ({ root = dataDir(), proxyUrl, log = con
79
100
  }
80
101
 
81
102
  for (const p of profiles.list()) {
82
- try { await open(p.id) } catch (e) { log('[vault] no se pudo abrir el perfil %s: %s', p.id, e.message) }
103
+ // ANTES de abrir: la carpeta tiene que llamarse como su llave. Es toda la migración
104
+ // (mover la data a la carpeta que le toca) y va aquí porque es el único momento en que
105
+ // la identidad está cerrada — renombrarla abierta la deja escribiendo en la ruta vieja.
106
+ let id = p.id
107
+ try {
108
+ id = await profiles.ensureNamedByKey(p.id, mintKey)
109
+ if (id !== p.id) log('[vault] profile %s renamed to %s (the folder is named after its key)', p.id, id)
110
+ } catch (e) { log('[vault] could not name the folder of %s after its key: %s', p.id, e.message) }
111
+ try { await open(id) } catch (e) { log('[vault] no se pudo abrir el perfil %s: %s', id, e.message) }
83
112
  }
84
113
 
85
114
  const get = (id) => {
@@ -109,8 +138,8 @@ export async function startVaultManager ({ root = dataDir(), proxyUrl, log = con
109
138
  * (camino A): la bóveda pone el sitio y la llave que entrará como miembro, pero la
110
139
  * cuenta la trae el dispositivo y se adopta al emparejar.
111
140
  */
112
- async add (name, { adopt = false } = {}) {
113
- const p = profiles.add(name, { adopt })
141
+ async add (name, { adopt = false, kek = null } = {}) {
142
+ const p = await profiles.add(name, { adopt, kek, mintKey })
114
143
  await open(p.id)
115
144
  log('[vault] perfil creado%s: %s (%s)', adopt ? ' (a la espera de adoptar una cuenta)' : '', p.name || '(sin nombre)', p.id)
116
145
  return profiles.get(p.id)
package/src/passwords.js CHANGED
@@ -27,7 +27,8 @@ export const PASSWORDS_CAP = 'passwords'
27
27
  * `cek` la llave de la bóveda de contraseñas (CryptoKey AES-GCM)
28
28
  * `isAllowed(pubkey)` qué aparatos pueden pedir — lo decide el acta, no esto
29
29
  * `encPubOf(pubkey)` su llave de cifrado, para sellarle la respuesta
30
- * `needsApproval(pubkey)` si ese aparato está marcado para aprobar
30
+ * `needsApproval(pubkey)` si ese aparato está marcado para aprobar. Se compone con el
31
+ * criterio del propio protocolo: solo lo PRIVADO pregunta
31
32
  * `approve({ pubkey, op })` pide el visto bueno (el teléfono) y espera
32
33
  * `devices()` / `unlink(pubkey)` administración, opcional
33
34
  * `audit(op, info)` la bitácora del vault
@@ -53,9 +54,19 @@ export function createPasswordDesk (opts = {}) {
53
54
  vault,
54
55
  isAllowed,
55
56
  encPubOf,
56
- // La aprobación es del APARATO y dura mientras el vault siga encendido, igual que
57
- // los cajones de secretos: una por arranque, sin ventana que vigilar.
58
- needsApproval: (op, _payload, pubkey) => op === 'get' && needsApproval(pubkey),
57
+ // Dos condiciones, y las dos tienen que darse:
58
+ //
59
+ // · que ese APARATO esté marcado para aprobar (la política del vault: se pide una
60
+ // vez y dura mientras el vault siga encendido, igual que los cajones de secretos);
61
+ // · y que lo que pide sea PRIVADO — una contraseña, un código de dos pasos, o un
62
+ // campo que el usuario marcó. Rellenar un nombre no es sacar un secreto, y pedir
63
+ // permiso para todo enseña a decir que sí sin mirar.
64
+ //
65
+ // El segundo criterio se toma del propio responder (`wantsPrivate`) en vez de
66
+ // reescribirlo aquí: si cada bóveda tuviera su idea de qué es privado, serían tres
67
+ // bóvedas distintas otra vez.
68
+ needsApproval: async (op, payload, pubkey) =>
69
+ needsApproval(pubkey) && await responder.wantsPrivate(op, payload),
59
70
  approve,
60
71
  admin: devices && unlink ? { devices, unlink } : null,
61
72
  onRequest: (r) => {