@dotrino/vaultd 0.126.0 → 0.128.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/admin.js +10 -0
- package/lib/src/service.js +20 -6
- package/package.json +2 -2
- package/src/approvals.js +26 -5
- package/src/vault.js +121 -34
package/lib/src/admin.js
CHANGED
|
@@ -284,6 +284,12 @@ export function createAdminDesk ({
|
|
|
284
284
|
by
|
|
285
285
|
})
|
|
286
286
|
const keys = result?.keys || []
|
|
287
|
+
// EN ESPERA DE APROBACIÓN: todavía no cambió nada, así que no hay de qué avisar. El
|
|
288
|
+
// aviso sale cuando se aprueba y se escribe de verdad.
|
|
289
|
+
if (result?.pending) {
|
|
290
|
+
audit('admin.var.pending', { by, ns: toScope ? data.ns : null, id: result.pending, count: data.items.length })
|
|
291
|
+
return { ok: true, result }
|
|
292
|
+
}
|
|
287
293
|
audit('admin.var.set', { by, ns: toScope ? data.ns : null, device: toDevice ? await deviceIdOf(data.pub).catch(() => null) : null, keys })
|
|
288
294
|
await notify('vars', { by, keys, ns: toScope ? data.ns : null })
|
|
289
295
|
return { ok: true, result: result || { ok: true } }
|
|
@@ -308,6 +314,10 @@ export function createAdminDesk ({
|
|
|
308
314
|
caller: chk.device,
|
|
309
315
|
by
|
|
310
316
|
})
|
|
317
|
+
if (result?.pending) {
|
|
318
|
+
audit('admin.var.pending', { by, ns: toScope ? data.ns : null, id: result.pending, count: 1 })
|
|
319
|
+
return { ok: true, result }
|
|
320
|
+
}
|
|
311
321
|
audit('admin.var.set', { by, ns: toScope ? data.ns : null, device: toDevice ? await deviceIdOf(data.pub).catch(() => null) : null, key: data.key })
|
|
312
322
|
// Cambiar la configuración de un servicio a distancia no puede ser invisible: es
|
|
313
323
|
// la contrapartida de delegar (F3 de docs/consola-remota.md).
|
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.128.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": {
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"@dotrino/identity": "0.101.0",
|
|
26
26
|
"@dotrino/opaque": "0.1.0",
|
|
27
27
|
"@dotrino/passmanager": "0.17.0",
|
|
28
|
-
"@dotrino/proxy-client": "0.
|
|
28
|
+
"@dotrino/proxy-client": "0.24.0",
|
|
29
29
|
"@dotrino/roadmap": "0.3.7",
|
|
30
30
|
"@dotrino/store": "0.11.0",
|
|
31
31
|
"@dotrino/update": "0.1.1",
|
package/src/approvals.js
CHANGED
|
@@ -73,7 +73,9 @@ export function createApprovals ({ now = Date.now, pendingTtlMs = PENDING_TTL_MS
|
|
|
73
73
|
* de un sobre (`handleApproval`). Si mañana aparece otro sitio que publique esta lista,
|
|
74
74
|
* esto es lo que hay que recordar.
|
|
75
75
|
*/
|
|
76
|
-
|
|
76
|
+
// `kind` dice QUÉ se pide: `read` (llevarse las claves, lo de siempre) o `write` (guardar
|
|
77
|
+
// variables). La pantalla del teléfono no puede decir «pide tus claves» a una escritura.
|
|
78
|
+
const publicOf = (p) => ({ id: p.id, ns: p.ns, kind: p.kind, deviceId: p.deviceId, label: p.label, ts: p.ts, exp: p.exp, ctx: p.ctx || null })
|
|
77
79
|
|
|
78
80
|
return {
|
|
79
81
|
/** Apunta un pedido. Uno por cajón y aparato: pedir otra vez reemplaza al anterior. */
|
|
@@ -82,12 +84,15 @@ export function createApprovals ({ now = Date.now, pendingTtlMs = PENDING_TTL_MS
|
|
|
82
84
|
* apruebe— y tiene que volver por el mismo sitio: desde que la bóveda atiende también
|
|
83
85
|
* por un socket local, mandarla siempre por el proxio la dejaba en el vacío.
|
|
84
86
|
*/
|
|
85
|
-
request ({ ns, device, deviceId, label = '', ek, ctx = null, from = null, ttlMs = null }) {
|
|
86
|
-
|
|
87
|
+
request ({ ns, device, deviceId, label = '', ek, ctx = null, from = null, ttlMs = null, kind = 'read' }) {
|
|
88
|
+
// Pedir las claves otra vez reemplaza al pedido anterior: es la misma pregunta. Una
|
|
89
|
+
// ESCRITURA no: cada una lleva su propio valor, y reemplazarla tiraría la anterior
|
|
90
|
+
// sin que nadie dijera que no.
|
|
91
|
+
if (kind !== 'write') for (const [id, p] of pending) if (p.kind !== 'write' && p.ns === ns && p.device === device) pending.delete(id)
|
|
87
92
|
const ts = now()
|
|
88
93
|
// `ttlMs` lo pone quien pide: una actualización aguanta un día y un comando cinco
|
|
89
94
|
// minutos, y la mesa no tiene por qué saber cuál es cuál (ver `PENDING_TTL_MS`).
|
|
90
|
-
const p = { id: rnd(), ns, device, deviceId, label, ek, ctx, ts, exp: ts + (ttlMs || pendingTtlMs), from }
|
|
95
|
+
const p = { id: rnd(), ns, kind, device, deviceId, label, ek, ctx, ts, exp: ts + (ttlMs || pendingTtlMs), from }
|
|
91
96
|
pending.set(p.id, p)
|
|
92
97
|
return publicOf(p)
|
|
93
98
|
},
|
|
@@ -180,4 +185,20 @@ export function createGrants ({ now = Date.now, ttlMs = GRANT_TTL_MS } = {}) {
|
|
|
180
185
|
return api
|
|
181
186
|
}
|
|
182
187
|
|
|
183
|
-
|
|
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'
|
|
@@ -155,6 +155,31 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
155
155
|
return !Acta.memberCan(record, pub, 'unattended')
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
/**
|
|
159
|
+
* APUNTA UN PEDIDO QUE NO ENTREGA UN CAJÓN, avisa a quien aprueba y deja la respuesta en
|
|
160
|
+
* `onAnswer(sí|no)`. Lo usan la bóveda de contraseñas (un aparato espera su sí) y guardar
|
|
161
|
+
* variables (la escritura se aplica al aprobar). Si nadie contesta, vence solo con un no:
|
|
162
|
+
* nada se queda colgado para siempre.
|
|
163
|
+
*/
|
|
164
|
+
async function awaitApproval ({ device, ns, kind, what, op = null, ctx = null, onAnswer }) {
|
|
165
|
+
const deviceId = await deviceIdOf(device).catch(() => null)
|
|
166
|
+
const record = await refreshActa()
|
|
167
|
+
const label = (record?.members || []).find((m) => m.pub === device)?.label || ''
|
|
168
|
+
const pend = approvals.request({ ns, device, deviceId, label, ek: '', ctx, kind })
|
|
169
|
+
audit(`${what}.pending`, { device: deviceId, ns, id: pend.id, op })
|
|
170
|
+
log(`[vault] ${what}: ${deviceId || '????-????'} is waiting for approval (${pend.id})`)
|
|
171
|
+
waiters.set(pend.id, onAnswer)
|
|
172
|
+
const t = setTimeout(() => {
|
|
173
|
+
if (waiters.delete(pend.id)) {
|
|
174
|
+
audit(`${what}.expired`, { device: deviceId, ns, id: pend.id })
|
|
175
|
+
onAnswer(false)
|
|
176
|
+
}
|
|
177
|
+
}, PENDING_TTL_MS)
|
|
178
|
+
t.unref?.()
|
|
179
|
+
await notifyApprovers(pend, record)
|
|
180
|
+
return pend
|
|
181
|
+
}
|
|
182
|
+
|
|
158
183
|
/**
|
|
159
184
|
* QUÉ COMANDO ESTÁ PIDIENDO LAS CLAVES, Y DESDE QUÉ CARPETA — medido, no creído.
|
|
160
185
|
*
|
|
@@ -1595,16 +1620,23 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1595
1620
|
const id = typeof p.data?.id === 'string' ? p.data.id : ''
|
|
1596
1621
|
const pend = approvals.take(id)
|
|
1597
1622
|
if (!pend) return reply(from, { type: MSG.ERROR, error: 'approval: unknown or expired request' })
|
|
1598
|
-
// PEDIDOS QUE NO
|
|
1599
|
-
// de tocar `resultFor`, que asume un cajón y una `ek`: aquí no hay nada
|
|
1600
|
-
// solo una promesa esperando un sí o un no.
|
|
1623
|
+
// PEDIDOS QUE NO ENTREGAN UN CAJÓN (la bóveda de contraseñas, guardar variables). Se
|
|
1624
|
+
// resuelven ANTES de tocar `resultFor`, que asume un cajón y una `ek`: aquí no hay nada
|
|
1625
|
+
// que sellar, solo una promesa esperando un sí o un no.
|
|
1601
1626
|
if (waiters.has(id)) {
|
|
1602
1627
|
const resolver = waiters.get(id)
|
|
1603
1628
|
waiters.delete(id)
|
|
1604
1629
|
const ok = op === 'approve'
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1630
|
+
const what = pend.kind === 'write' ? 'vars' : 'passwords'
|
|
1631
|
+
audit(ok ? `${what}.approved` : `${what}.denied`, { device: pend.deviceId, ns: pend.ns, id, by })
|
|
1632
|
+
log(`[vault] ${what}: request ${pend.id} of ${pend.deviceId} ${ok ? 'approved' : 'DENIED'} by ${by}`)
|
|
1633
|
+
// Lo aprobado SE HACE AQUÍ (una escritura se aplica ahora), y si falla quien aprobó
|
|
1634
|
+
// tiene que enterarse: decirle «hecho» a algo que no se guardó es el peor resultado.
|
|
1635
|
+
try { await resolver(ok) } catch (e) {
|
|
1636
|
+
log(`[vault] ${what}: ${pend.id} was approved but could not be applied: ${e.message}`)
|
|
1637
|
+
audit(`${what}.apply-failed`, { id, reason: e.message })
|
|
1638
|
+
return reply(from, { type: MSG.ERROR, error: `approval: approved, but it could not be applied: ${e.message}` })
|
|
1639
|
+
}
|
|
1608
1640
|
return answer({ op: `${op}.result`, id, ok: true })
|
|
1609
1641
|
}
|
|
1610
1642
|
if (op === 'deny') {
|
|
@@ -1917,24 +1949,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1917
1949
|
// El teléfono: se apunta el pedido, se le avisa y esta promesa espera su firma.
|
|
1918
1950
|
// Es el mismo camino que ya recorren los cajones de secretos.
|
|
1919
1951
|
approve: async ({ pubkey, op }) => {
|
|
1920
|
-
|
|
1921
|
-
const
|
|
1922
|
-
|
|
1923
|
-
audit('passwords.pending', { device: deviceId, id: pend.id, op })
|
|
1924
|
-
log(`[vault] passwords: ${deviceId || '????-????'} is waiting for approval (${pend.id})`)
|
|
1925
|
-
const espera = new Promise((resolve) => {
|
|
1926
|
-
waiters.set(pend.id, resolve)
|
|
1927
|
-
// Si nadie contesta, vence solo: la promesa no se queda colgada para siempre
|
|
1928
|
-
// y el aparato recibe un no en vez de esperar sin fin.
|
|
1929
|
-
const t = setTimeout(() => {
|
|
1930
|
-
if (waiters.delete(pend.id)) {
|
|
1931
|
-
audit('passwords.expired', { device: deviceId, id: pend.id })
|
|
1932
|
-
resolve(false)
|
|
1933
|
-
}
|
|
1934
|
-
}, PENDING_TTL_MS)
|
|
1935
|
-
t.unref?.()
|
|
1936
|
-
})
|
|
1937
|
-
await notifyApprovers(pend, actaCache)
|
|
1952
|
+
let resolve
|
|
1953
|
+
const espera = new Promise((r) => { resolve = r })
|
|
1954
|
+
await awaitApproval({ device: pubkey, ns: 'passwords', kind: 'read', what: 'passwords', op, onAnswer: resolve })
|
|
1938
1955
|
return espera
|
|
1939
1956
|
},
|
|
1940
1957
|
audit,
|
|
@@ -2111,7 +2128,11 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2111
2128
|
// El fallo NO se traga. Que un aviso no salga es exactamente lo que deja a dos
|
|
2112
2129
|
// bóvedas con actas distintas sin que nadie se entere, y encontrarlo sin una línea
|
|
2113
2130
|
// de log cuesta días (la última vez, tres).
|
|
2114
|
-
|
|
2131
|
+
// CALLADO (`quiet`): se encola igual, pero no toca el timbre push. Un aviso de que
|
|
2132
|
+
// el perfil cambió puede esperar a que el aparato abra solo; timbrar por él hacía
|
|
2133
|
+
// sonar el teléfono con «alguien pide tus claves» sin ningún pedido detrás.
|
|
2134
|
+
// El timbre es para los pedidos (`notifyApprovers`), que sí esperan una mano.
|
|
2135
|
+
try { client.sendByPubkey(pub, { type: MSG.ADMIN_EVENT, body, signature, acta }, { quiet: true }) }
|
|
2115
2136
|
catch (e) { log(`[vault] could not notify ${pub.slice(0, 24)}… of "${ev}": ${e.message}`) }
|
|
2116
2137
|
}
|
|
2117
2138
|
for (const m of miembros) avisar(m.pub)
|
|
@@ -2300,11 +2321,20 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2300
2321
|
// PÚBLICA Y PRIVADA VAN IGUAL (dueño, 2026-09-02: «la única diferencia es si se
|
|
2301
2322
|
// despachan o no, son políticas; dales el mismo tratamiento de seguridad»). La
|
|
2302
2323
|
// marca solo decide si se entrega sin aprobación.
|
|
2324
|
+
const apply = async () => {
|
|
2325
|
+
await checkAuthor(owner, key, sealed, caller)
|
|
2326
|
+
await checkRecipients(owner, sealed.wraps, !!isPublic)
|
|
2327
|
+
await secrets.putSealed(owner, key, sealed, { by: who, public: isPublic })
|
|
2328
|
+
audit('secret.set', { ns: ns || null, key, sealed: true }); scheduleNotice(ns)
|
|
2329
|
+
await settleDebts(owner)
|
|
2330
|
+
}
|
|
2331
|
+
// Comprobado ANTES de dejarlo en espera: un sobre malo falla al guardarlo, no cinco
|
|
2332
|
+
// minutos después en la pantalla de quien aprueba. `apply` lo repite al aprobar.
|
|
2303
2333
|
await checkAuthor(owner, key, sealed, caller)
|
|
2304
2334
|
await checkRecipients(owner, sealed.wraps, !!isPublic)
|
|
2305
|
-
await
|
|
2306
|
-
|
|
2307
|
-
await
|
|
2335
|
+
const pending = await holdWrite({ caller, ns, pub, keys: [key], apply })
|
|
2336
|
+
if (pending) return { ok: true, key, pending }
|
|
2337
|
+
await apply()
|
|
2308
2338
|
return { ok: true, key }
|
|
2309
2339
|
}
|
|
2310
2340
|
// NO HAY OTRO CAMINO (dueño, 2026-09-02: «no dejes caminos viejos ni fallbacks; no hay
|
|
@@ -2345,16 +2375,58 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2345
2375
|
}
|
|
2346
2376
|
// Se comprueban TODAS antes de escribir NINGUNA: media carga aplicada es una
|
|
2347
2377
|
// configuración que nadie quiso, y el servicio se reinicia con ella.
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2378
|
+
const apply = async () => {
|
|
2379
|
+
for (const it of items) {
|
|
2380
|
+
await checkAuthor(owner, it.key, it.sealed, caller)
|
|
2381
|
+
await checkRecipients(owner, it.sealed.wraps, !!it.public)
|
|
2382
|
+
}
|
|
2383
|
+
for (const it of items) {
|
|
2384
|
+
await secrets.putSealed(owner, it.key, it.sealed, { by: who, public: !!it.public })
|
|
2385
|
+
keys.push(it.key)
|
|
2386
|
+
}
|
|
2387
|
+
audit('secret.setMany', { ns: ns || null, keys }); scheduleNotice(ns)
|
|
2388
|
+
await settleDebts(owner)
|
|
2351
2389
|
}
|
|
2352
|
-
|
|
2353
|
-
|
|
2390
|
+
const pending = await holdWrite({ caller, ns, pub, keys: items.map((it) => it.key), apply })
|
|
2391
|
+
if (pending) return { ok: true, keys: [], pending }
|
|
2392
|
+
await apply()
|
|
2354
2393
|
return { ok: true, keys }
|
|
2355
2394
|
}
|
|
2356
2395
|
}
|
|
2357
2396
|
|
|
2397
|
+
/**
|
|
2398
|
+
* GUARDAR VARIABLES TAMBIÉN PIDE PERMISO (dueño, 2026-09-23: «si el admin no es
|
|
2399
|
+
* unattended, sí debería pedir permiso para almacenar variables»).
|
|
2400
|
+
*
|
|
2401
|
+
* Es el mismo permiso que ya decide si un aparato se lleva las claves solo: quien tiene
|
|
2402
|
+
* `unattended` escribe sin preguntar; quien no, deja la escritura en espera y el teléfono
|
|
2403
|
+
* decide. Un permiso, una regla, para leer y para escribir.
|
|
2404
|
+
*
|
|
2405
|
+
* No se espera DENTRO de la llamada: la consola corta a los 20 s y aprobar puede tardar
|
|
2406
|
+
* cinco minutos. Se contesta «pendiente» en el acto, el sobre se queda en memoria (va
|
|
2407
|
+
* sellado, la bóveda no lo abre) y se escribe al aprobar. Denegado o vencido, se tira.
|
|
2408
|
+
*
|
|
2409
|
+
* Devuelve el id del pedido si la escritura quedó en espera, o `null` si se puede hacer ya.
|
|
2410
|
+
*/
|
|
2411
|
+
async function holdWrite ({ caller, ns, pub, keys, apply }) {
|
|
2412
|
+
if (!needsApproval(caller, await refreshActa())) return null
|
|
2413
|
+
// Quien llama ya comprobó el sobre; `apply` lo vuelve a comprobar al aprobar, porque el
|
|
2414
|
+
// acta pudo cambiar en medio.
|
|
2415
|
+
const target = ns || await deviceIdOf(pub).catch(() => null) || '?'
|
|
2416
|
+
const pend = await awaitApproval({
|
|
2417
|
+
device: caller, ns: target, kind: 'write', what: 'vars',
|
|
2418
|
+
// LOS NOMBRES van en el contexto, que viaja SELLADO al que aprueba: el proxio no
|
|
2419
|
+
// tiene por qué aprender cómo se llama la configuración de un servicio.
|
|
2420
|
+
ctx: { keys },
|
|
2421
|
+
onAnswer: async (ok) => {
|
|
2422
|
+
if (!ok) return
|
|
2423
|
+
await apply()
|
|
2424
|
+
await notifyMembers('vars', { by: await deviceIdOf(caller).catch(() => null), keys, ns: ns || null })
|
|
2425
|
+
}
|
|
2426
|
+
})
|
|
2427
|
+
return pend.id
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2358
2430
|
const admin = createAdminDesk({
|
|
2359
2431
|
desk,
|
|
2360
2432
|
deviceIdOf,
|
|
@@ -3545,12 +3617,27 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
3545
3617
|
// Si falla no se arrastra —abrir no depende de esto—, pero se dice.
|
|
3546
3618
|
if (r?.locked === false) {
|
|
3547
3619
|
try {
|
|
3620
|
+
const antes = await refreshActa()
|
|
3621
|
+
const aprobaban = (antes?.members || []).filter((m) => Acta.memberCan(antes, m.pub, 'approve')).map((m) => m.pub)
|
|
3548
3622
|
const { removed = [], seq } = await identity.pruneExpiredDevices?.() || {}
|
|
3549
3623
|
for (const d of removed) {
|
|
3550
3624
|
const id = await deviceIdOf(d.pub).catch(() => null)
|
|
3551
3625
|
audit('pruned', { device: id, label: d.label || '', reason: 'expired', seq })
|
|
3552
3626
|
log(`[vault] unlock: removed ${id || '????-????'} ${d.label ? `(${d.label}) ` : ''}from the record: its certificate expired and cannot be renewed · record #${seq}`)
|
|
3553
3627
|
}
|
|
3628
|
+
// ¿SE LLEVÓ AL ÚLTIMO QUE APROBABA? Entonces se dice ALTO. Quitarlo es correcto —con
|
|
3629
|
+
// el papel muerto no podía firmar ninguna aprobación, estuviera o no en el acta—,
|
|
3630
|
+
// pero desde ese momento todo pedido de un aparato sin `unattended` se rechaza, y
|
|
3631
|
+
// el 2026-09-22 eso pasó en silencio: el teléfono se fue y nadie lo supo en dos días.
|
|
3632
|
+
const despues = await refreshActa()
|
|
3633
|
+
const quedan = (despues?.members || []).filter((m) => Acta.memberCan(despues, m.pub, 'approve')).map((m) => m.pub)
|
|
3634
|
+
const idosPub = lastApproverGone(aprobaban, quedan, removed.map((d) => d.pub))
|
|
3635
|
+
const idos = removed.filter((d) => idosPub.includes(d.pub))
|
|
3636
|
+
if (idos.length) {
|
|
3637
|
+
const quienes = (await Promise.all(idos.map(async (d) => `${await deviceIdOf(d.pub).catch(() => '????-????')}${d.label ? ` (${d.label})` : ''}`))).join(', ')
|
|
3638
|
+
audit('no-approver', { removed: idos.length, seq })
|
|
3639
|
+
log(`[vault] WARNING: nobody in the record can approve any more — ${quienes} was the last. Requests from devices without \`unattended\` will be refused until you enrol a device and grant it \`dotrino-vault caps <ID> +aprueba\``)
|
|
3640
|
+
}
|
|
3554
3641
|
} catch (e) { log(`[vault] unlock: could not remove expired devices: ${e.message}`) }
|
|
3555
3642
|
}
|
|
3556
3643
|
return { locked: r?.locked !== false }
|