@dotrino/vaultd 0.127.0 → 0.129.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/lib/src/service.js +20 -6
- package/package.json +1 -1
- package/src/approvals.js +17 -1
- package/src/vault.js +31 -22
package/lib/src/service.js
CHANGED
|
@@ -313,7 +313,7 @@ export async function sayHello (client, qr) {
|
|
|
313
313
|
const hello = await new Promise((resolve, reject) => {
|
|
314
314
|
const off = client.on('message', (_f, p) => {
|
|
315
315
|
if (p?.type === MSG.HELLO_OK) { finish(); verifyHello(p, qr.sn).then(resolve, reject) }
|
|
316
|
-
else if (p?.type === MSG.ERROR) { finish(); reject(
|
|
316
|
+
else if (p?.type === MSG.ERROR) { finish(); reject(vaultError(p)) }
|
|
317
317
|
})
|
|
318
318
|
const t = setTimeout(() => { finish(); reject(new Error('the vault did not answer: that code may have expired')) }, 15000)
|
|
319
319
|
const finish = () => { off(); clearTimeout(t) }
|
|
@@ -387,7 +387,7 @@ export async function enrollWithVault ({ qr, label = 'agent', expectedScope = nu
|
|
|
387
387
|
if (p.type === MSG.ENROLL_CHALLENGE) {
|
|
388
388
|
const show = onCode || (({ deviceId, code }) => console.log(`[vault] device ${deviceId} · approve it on the vault: dotrino-vault approve ${code}`))
|
|
389
389
|
show({ deviceId, code })
|
|
390
|
-
} else if (p.type === MSG.ENROLLED) { cleanup(); resolve(p) } else if (p.type === MSG.ERROR) { cleanup(); reject(
|
|
390
|
+
} else if (p.type === MSG.ENROLLED) { cleanup(); resolve(p) } else if (p.type === MSG.ERROR) { cleanup(); reject(vaultError(p)) }
|
|
391
391
|
})
|
|
392
392
|
const t = setTimeout(() => { cleanup(); reject(new Error('timeout waiting for approval on the vault')) }, approveTimeoutMs)
|
|
393
393
|
const cleanup = () => { off(); clearTimeout(t) }
|
|
@@ -507,7 +507,7 @@ export async function registerEncKey ({ dir, ns, proxyUrl, masterPubkey, device,
|
|
|
507
507
|
const pending = waitForMsg(client, (p) => p.type === MSG.SECRETS_RESULT || p.type === MSG.ERROR, timeoutMs)
|
|
508
508
|
client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data, signature, cert })
|
|
509
509
|
const res = await pending
|
|
510
|
-
if (res.type === MSG.ERROR) throw
|
|
510
|
+
if (res.type === MSG.ERROR) throw vaultError(res)
|
|
511
511
|
return { encPub, created, ok: true }
|
|
512
512
|
} finally { client.close() }
|
|
513
513
|
}
|
|
@@ -656,7 +656,7 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
656
656
|
try { res = await pending; break } catch (e) { ultimo = e }
|
|
657
657
|
}
|
|
658
658
|
if (!res) throw ultimo || new Error('nobody answered: neither the vault nor any replica')
|
|
659
|
-
if (res.type === MSG.ERROR) throw
|
|
659
|
+
if (res.type === MSG.ERROR) throw vaultError(res)
|
|
660
660
|
|
|
661
661
|
// CAJÓN CON APROBACIÓN: la bóveda contesta «pendiente» (firmado) y la respuesta de
|
|
662
662
|
// verdad llega cuando el aparato que aprueba firme — por esta misma conexión, que
|
|
@@ -668,7 +668,7 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
668
668
|
const until = typeof res.body.exp === 'number' ? Math.max(5000, res.body.exp - Date.now() + 5000) : approvalTimeoutMs
|
|
669
669
|
res = await waitForMsg(client, (p) => (p.type === MSG.SECRETS_RESULT && p.body?.op === 'secrets.result') || p.type === MSG.ERROR, Math.min(until, approvalTimeoutMs))
|
|
670
670
|
.catch((e) => { throw new Error(/timeout/.test(e.message) ? 'approval: nobody approved the request in time' : e.message) })
|
|
671
|
-
if (res.type === MSG.ERROR) throw
|
|
671
|
+
if (res.type === MSG.ERROR) throw vaultError(res)
|
|
672
672
|
}
|
|
673
673
|
|
|
674
674
|
// AUTENTICIDAD: la firma NO es de la maestra. El acta dice qué llave vale para esto
|
|
@@ -1244,9 +1244,23 @@ function fingerprintOf (secrets) {
|
|
|
1244
1244
|
*/
|
|
1245
1245
|
const FINAL_CODES = new Set([
|
|
1246
1246
|
'no-wrapping', // falta la envoltura, y solo la fabrica el dueño abriendo la bóveda
|
|
1247
|
-
'plaintext-var'
|
|
1247
|
+
'plaintext-var', // el valor está en claro en la bóveda: hay que volver a escribirlo
|
|
1248
|
+
// NADIE EN EL ACTA PUEDE APROBAR: reintentar no lo arregla, y callarlo es lo peor. Pasó el
|
|
1249
|
+
// 2026-09-24: la limpieza de aparatos muertos se llevó al único teléfono con `aprueba`, y
|
|
1250
|
+
// `dotrino-env` se quedó reintentando cada minuto sin decir nada mientras la bóveda
|
|
1251
|
+
// contestaba exactamente qué había que hacer.
|
|
1252
|
+
'no-approver'
|
|
1248
1253
|
])
|
|
1249
1254
|
|
|
1255
|
+
/**
|
|
1256
|
+
* Un `vault.error` como Error CON SU `code`. Antes se construía con el texto a secas, y el
|
|
1257
|
+
* código que la bóveda manda para distinguir los casos se perdía justo aquí — así que
|
|
1258
|
+
* `isFinal` no podía reconocer ninguno por `code` y todo se reintentaba.
|
|
1259
|
+
*/
|
|
1260
|
+
function vaultError (p) {
|
|
1261
|
+
return Object.assign(new Error(p?.error || 'vault error'), { code: p?.code || null })
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1250
1264
|
export function isFinal (e) {
|
|
1251
1265
|
if (FINAL_CODES.has(e?.code)) return true
|
|
1252
1266
|
return /not enrolled|invalid ns|unauthorized: (revoked|expired|scope|cn|untrusted-issuer|cert-device-mismatch|denied)/.test(e?.message || '')
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vaultd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.129.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Certificador personal de Dotrino: daemon headless que custodia la clave maestra y delega capacidades a tus dispositivos por el proxy. Tu CA propia.",
|
|
6
6
|
"bin": {
|
package/src/approvals.js
CHANGED
|
@@ -185,4 +185,20 @@ export function createGrants ({ now = Date.now, ttlMs = GRANT_TTL_MS } = {}) {
|
|
|
185
185
|
return api
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
-
|
|
188
|
+
/**
|
|
189
|
+
* ¿SE LLEVÓ UNA LIMPIEZA AL ÚLTIMO QUE APROBABA? Devuelve los aprobadores que se fueron si
|
|
190
|
+
* antes había alguno y ahora no queda ninguno; si no, una lista vacía.
|
|
191
|
+
*
|
|
192
|
+
* Una cuenta SIN aprobadores puede ser legítima (todo desatendido, se actualiza sola), así
|
|
193
|
+
* que el aviso no es «no hay aprobadores»: es «LOS HABÍA y acabas de perder el último».
|
|
194
|
+
*
|
|
195
|
+
* @param {string[]} before llaves con `approve` antes
|
|
196
|
+
* @param {string[]} after llaves con `approve` después
|
|
197
|
+
* @param {string[]} removed llaves que salieron del acta
|
|
198
|
+
*/
|
|
199
|
+
export function lastApproverGone (before, after, removed) {
|
|
200
|
+
if (!before.length || after.length) return []
|
|
201
|
+
return removed.filter((pub) => before.includes(pub))
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export default { createApprovals, createGrants, lastApproverGone, PENDING_TTL_MS, UPDATE_TTL_MS, GRANT_TTL_MS }
|
package/src/vault.js
CHANGED
|
@@ -37,7 +37,7 @@ import { loadManifest, brokenOf } from '@dotrino/roadmap'
|
|
|
37
37
|
// espera reventaba con un ReferenceError y la aprobación del mostrador de contraseñas no
|
|
38
38
|
// llegaba a existir. Solo se veía por ese camino —el único que lo usa—, y no había prueba
|
|
39
39
|
// que lo recorriera hasta que la hubo (dotrino-test, smoke:demonio, 2026-08-30).
|
|
40
|
-
import { createApprovals, createGrants, PENDING_TTL_MS, GRANT_TTL_MS } from './approvals.js'
|
|
40
|
+
import { createApprovals, createGrants, lastApproverGone, PENDING_TTL_MS, GRANT_TTL_MS } from './approvals.js'
|
|
41
41
|
import { readProcContext, sanitizeContext, commandFingerprint, commandLine } from '../lib/src/proc.js'
|
|
42
42
|
import { makeContentKey, encryptWithCek, wrapForMember } from '@dotrino/identity/content'
|
|
43
43
|
import { makeSealer } from './sealer.js'
|
|
@@ -152,6 +152,12 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
152
152
|
*/
|
|
153
153
|
function needsApproval (pub, record) {
|
|
154
154
|
if (!record) return true // sin acta no se decide que sí: se pide permiso
|
|
155
|
+
// SIN NADIE QUE APRUEBE, NO SE NECESITA APROBACIÓN (dueño, 2026-09-24: «la regla es, si
|
|
156
|
+
// no hay aprobador no se necesita aprobación»). Es la misma que ya seguía la
|
|
157
|
+
// actualización automática: esperar un sí que nadie puede dar solo deja al servicio
|
|
158
|
+
// colgado. Aquí se decide para las tres puertas —entregar claves, contraseñas y guardar
|
|
159
|
+
// variables— y por eso no hay un «nadie aprueba» aparte en ninguna de ellas.
|
|
160
|
+
if (!record.members?.some((m) => Acta.memberCan(record, m.pub, 'approve'))) return false
|
|
155
161
|
return !Acta.memberCan(record, pub, 'unattended')
|
|
156
162
|
}
|
|
157
163
|
|
|
@@ -1460,6 +1466,13 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1460
1466
|
// hace la BÓVEDA (`bundleFor({ publicOnly })`), no el que pide: si mandáramos todo y el
|
|
1461
1467
|
// cliente eligiera, pedir «solo públicas» sería la manera de saltarse la aprobación.
|
|
1462
1468
|
const publicOnly = p.data?.publicOnly === true
|
|
1469
|
+
// Entregar SIN aprobación porque no hay quien apruebe es la regla (ver `needsApproval`),
|
|
1470
|
+
// pero se deja dicho: quien mire el log tiene que poder ver por qué no sonó el teléfono.
|
|
1471
|
+
if (!publicOnly && record && !Acta.memberCan(record, chk.device, 'unattended') &&
|
|
1472
|
+
!record.members?.some((m) => Acta.memberCan(record, m.pub, 'approve'))) {
|
|
1473
|
+
audit('secrets.no-approver', { device: await deviceIdOf(chk.device).catch(() => null), ns })
|
|
1474
|
+
log(`[vault] ${ns}: served ${await deviceIdOf(chk.device).catch(() => '????-????')} without approval — nobody in the record can approve`)
|
|
1475
|
+
}
|
|
1463
1476
|
if (!publicOnly && needsApproval(chk.device, record)) {
|
|
1464
1477
|
const deviceId = await deviceIdOf(chk.device).catch(() => null)
|
|
1465
1478
|
const label = (record?.members || []).find((m) => m.pub === chk.device)?.label || ''
|
|
@@ -1484,27 +1497,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1484
1497
|
return reply(from, { type: MSG.ERROR, error: 'secrets: invalid ek' })
|
|
1485
1498
|
}
|
|
1486
1499
|
}
|
|
1487
|
-
//
|
|
1488
|
-
//
|
|
1489
|
-
// La bóveda ya lo sabía —lo escribía en su propio log, «rang 0 approver(s)»— y aun
|
|
1490
|
-
// así contestaba «pendiente» y dejaba al otro aguardando a algo que no podía pasar.
|
|
1491
|
-
// Eso es un fallo ruidoso convertido en uno mudo, que es exactamente lo que la norma
|
|
1492
|
-
// prohíbe (CLAUDE.md, «nada de repliegues»), y encima el que sabe es el ÚNICO que
|
|
1493
|
-
// puede enterar al otro (CONVENCIONES §14): el que pide no tiene el acta, así que no
|
|
1494
|
-
// hay forma de que lo averigüe por su cuenta.
|
|
1495
|
-
//
|
|
1496
|
-
// Lo pidió el dueño el 2026-09-05, después de mirar un `dotrino-env check` que parecía
|
|
1497
|
-
// colgado y era esto.
|
|
1498
|
-
const quienAprueba = (record?.members || []).filter((m) => Acta.memberCan(record, m.pub, 'approve'))
|
|
1499
|
-
if (!quienAprueba.length) {
|
|
1500
|
-
audit('rejected', { what: 'secrets', ns, reason: 'sin-aprobador' })
|
|
1501
|
-
log(`[vault] ${ns}: refused ${deviceId || '????-????'} — nobody in the record can approve`)
|
|
1502
|
-
return reply(from, {
|
|
1503
|
-
type: MSG.ERROR,
|
|
1504
|
-
code: 'no-approver',
|
|
1505
|
-
error: `approval: nobody in the record can approve — grant it with \`dotrino-vault caps <ID> +aprueba\`, or let this service run unattended with \`dotrino-vault caps ${deviceId || '<ID>'} +desatendido\``
|
|
1506
|
-
})
|
|
1507
|
-
}
|
|
1500
|
+
// (Aquí se rechazaba con `no-approver` cuando nadie podía aprobar. Desde 2026-09-24 ese
|
|
1501
|
+
// caso ya no llega: sin aprobadores, `needsApproval` dice que no hace falta aprobación.)
|
|
1508
1502
|
// `from` SE GUARDA: la respuesta llega más tarde —cuando alguien apruebe— y tiene que
|
|
1509
1503
|
// volver POR DONDE VINO. Si la pregunta entró por el mostrador local, mandarla por el
|
|
1510
1504
|
// proxio la deja en el vacío: quien preguntó está escuchando en el socket, no ahí.
|
|
@@ -3617,12 +3611,27 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
3617
3611
|
// Si falla no se arrastra —abrir no depende de esto—, pero se dice.
|
|
3618
3612
|
if (r?.locked === false) {
|
|
3619
3613
|
try {
|
|
3614
|
+
const antes = await refreshActa()
|
|
3615
|
+
const aprobaban = (antes?.members || []).filter((m) => Acta.memberCan(antes, m.pub, 'approve')).map((m) => m.pub)
|
|
3620
3616
|
const { removed = [], seq } = await identity.pruneExpiredDevices?.() || {}
|
|
3621
3617
|
for (const d of removed) {
|
|
3622
3618
|
const id = await deviceIdOf(d.pub).catch(() => null)
|
|
3623
3619
|
audit('pruned', { device: id, label: d.label || '', reason: 'expired', seq })
|
|
3624
3620
|
log(`[vault] unlock: removed ${id || '????-????'} ${d.label ? `(${d.label}) ` : ''}from the record: its certificate expired and cannot be renewed · record #${seq}`)
|
|
3625
3621
|
}
|
|
3622
|
+
// ¿SE LLEVÓ AL ÚLTIMO QUE APROBABA? Entonces se dice ALTO. Quitarlo es correcto —con
|
|
3623
|
+
// el papel muerto no podía firmar ninguna aprobación, estuviera o no en el acta—,
|
|
3624
|
+
// pero desde ese momento todo pedido de un aparato sin `unattended` se rechaza, y
|
|
3625
|
+
// el 2026-09-22 eso pasó en silencio: el teléfono se fue y nadie lo supo en dos días.
|
|
3626
|
+
const despues = await refreshActa()
|
|
3627
|
+
const quedan = (despues?.members || []).filter((m) => Acta.memberCan(despues, m.pub, 'approve')).map((m) => m.pub)
|
|
3628
|
+
const idosPub = lastApproverGone(aprobaban, quedan, removed.map((d) => d.pub))
|
|
3629
|
+
const idos = removed.filter((d) => idosPub.includes(d.pub))
|
|
3630
|
+
if (idos.length) {
|
|
3631
|
+
const quienes = (await Promise.all(idos.map(async (d) => `${await deviceIdOf(d.pub).catch(() => '????-????')}${d.label ? ` (${d.label})` : ''}`))).join(', ')
|
|
3632
|
+
audit('no-approver', { removed: idos.length, seq })
|
|
3633
|
+
log(`[vault] WARNING: nobody in the record can approve any more — ${quienes} was the last. From now on every device gets its keys WITHOUT approval, until you enrol one and grant it \`dotrino-vault caps <ID> +aprueba\``)
|
|
3634
|
+
}
|
|
3626
3635
|
} catch (e) { log(`[vault] unlock: could not remove expired devices: ${e.message}`) }
|
|
3627
3636
|
}
|
|
3628
3637
|
return { locked: r?.locked !== false }
|