@dotrino/vaultd 0.135.0 → 0.137.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/protocol.js +3 -3
- package/lib/src/service.js +14 -9
- package/package.json +1 -1
- package/src/vault.js +34 -13
package/lib/src/protocol.js
CHANGED
|
@@ -65,7 +65,7 @@ export const MSG = Object.freeze({
|
|
|
65
65
|
// empieza con el heap limpio.
|
|
66
66
|
// Va FIRMADO por la maestra y el agente lo verifica contra su `iss` pineada: un
|
|
67
67
|
// aviso de reinicio sin autenticar ES un ataque de denegación.
|
|
68
|
-
SECRETS_CHANGED: 'vault.secrets.changed', // vault → servicio: { body:{op,ns,ts},
|
|
68
|
+
SECRETS_CHANGED: 'vault.secrets.changed', // vault → servicio: { body:{op,ns,ts}, seal, acta }
|
|
69
69
|
// REPARTIR LA LLAVE DE UN CAJÓN A UN MIEMBRO NUEVO. Lo hace el SERVICIO y no la
|
|
70
70
|
// bóveda, porque la bóveda no puede abrir la llave sin la frase y el servicio ya la
|
|
71
71
|
// tiene: re-envolverla no le añade ningún poder. Ver `docs/secretos-sellados.md` §8.11.
|
|
@@ -74,7 +74,7 @@ export const MSG = Object.freeze({
|
|
|
74
74
|
// el ACTA que viene dentro (también firmada) y saca de ahí la llave pública del
|
|
75
75
|
// destinatario. Así, ni siquiera una bóveda comprometida puede hacerle envolver la
|
|
76
76
|
// llave para alguien que la maestra no haya metido en su propio cajón.
|
|
77
|
-
REWRAP: 'vault.rewrap', // vault → servicio: { body:{op,owner,gen,wrap,target,acta,ts},
|
|
77
|
+
REWRAP: 'vault.rewrap', // vault → servicio: { body:{op,owner,gen,wrap,target,acta,ts}, seal }
|
|
78
78
|
REWRAP_OK: 'vault.rewrap.ok', // servicio → vault: { data:{op,owner,gen,target,wrap,ts}, signature, cert }
|
|
79
79
|
// --- CONSOLA REMOTA (docs/consola-remota.md) — requiere cert `vault:admin` ---
|
|
80
80
|
// Un solo mensaje con `data.op`: pending · pair · approve · reject · revoke · audit.
|
|
@@ -85,7 +85,7 @@ export const MSG = Object.freeze({
|
|
|
85
85
|
// Aviso a TODOS los miembros de que el perfil cambió (alguien entró o salió). Es la
|
|
86
86
|
// contrapartida de administrar a distancia: sin esto, un enrolamiento remoto sería
|
|
87
87
|
// invisible para el resto de tus dispositivos.
|
|
88
|
-
ADMIN_EVENT: 'vault.admin.event', // vault → todos: { body:{ev,deviceId,by,ts},
|
|
88
|
+
ADMIN_EVENT: 'vault.admin.event', // vault → todos: { body:{ev,deviceId,by,ts}, acta } (pedidos: + seal)
|
|
89
89
|
// RÉPLICAS: la principal empuja lo que hay que servir (el acta y los sobres, que van
|
|
90
90
|
// firmados de antes y no se pueden falsificar) y la réplica acusa hasta qué `seq` tiene.
|
|
91
91
|
REPLICA_PUSH: 'vault.replica.push', // master → réplica: { body:{seq,acta,secrets,ts}, signature }
|
package/lib/src/service.js
CHANGED
|
@@ -1137,8 +1137,9 @@ export async function watchSecretsChanges ({
|
|
|
1137
1137
|
* NO SE FÍA DE LO QUE LE MANDAN, y esto es lo que hace que sea seguro incluso si la
|
|
1138
1138
|
* bóveda estuviera comprometida:
|
|
1139
1139
|
*
|
|
1140
|
-
* · la petición
|
|
1141
|
-
*
|
|
1140
|
+
* · la petición la firma la llave de SELLADO que nombra el acta (`verifyResponder`), no la
|
|
1141
|
+
* maestra: esto tiene que funcionar con la bóveda cerrada;
|
|
1142
|
+
* · el acta viaja dentro y la selló la maestra que este agente conoce;
|
|
1142
1143
|
* · **la llave pública del destinatario se saca del ACTA, nunca del mensaje** — si
|
|
1143
1144
|
* se cogiera del mensaje, quien lo mandara podría hacer que este agente envolviera
|
|
1144
1145
|
* la llave para una pública suya;
|
|
@@ -1150,9 +1151,9 @@ export async function watchSecretsChanges ({
|
|
|
1150
1151
|
if (!body || body.op !== 'rewrap') return
|
|
1151
1152
|
const mineOwners = [`ns:${ns}`, `dev:${saved.device.publickey}`]
|
|
1152
1153
|
if (!mineOwners.includes(body.owner)) return log('[vault] rewrap for a drawer that is not mine: ignored')
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
}
|
|
1154
|
+
try {
|
|
1155
|
+
await verifyResponder({ body, seal: payload.seal, acta: body.acta, masterPubkey: master, knownSeq: readServiceIdentity(dir)?.actaSeq ?? null })
|
|
1156
|
+
} catch (e) { return log(`[vault] rewrap request BADLY SIGNED: ignored (${e.message})`) }
|
|
1156
1157
|
const acta = body.acta
|
|
1157
1158
|
if (!acta || acta.sealedBy !== master || !(await verifyActa({ acta })).ok) {
|
|
1158
1159
|
return log('[vault] rewrap request without a valid record: ignored')
|
|
@@ -1192,11 +1193,15 @@ export async function watchSecretsChanges ({
|
|
|
1192
1193
|
// que un aviso falso con fecha lejana no pueda dejar fuera a los de verdad.
|
|
1193
1194
|
if (inFlight.has(body.ts)) return
|
|
1194
1195
|
inFlight.add(body.ts)
|
|
1195
|
-
|
|
1196
|
+
// FIRMADO COMO TODO LO QUE SIRVE LA BÓVEDA: la llave de sellado que nombra el acta, con
|
|
1197
|
+
// el acta al lado. Lo firmaba la maestra, y la bóveda vive CERRADA: el aviso reventaba
|
|
1198
|
+
// con «vault locked» antes de salir y el servicio no se enteraba de ningún cambio
|
|
1199
|
+
// (la maestra solo firma el acta y reenvuelve sobres, CLAUDE.md).
|
|
1200
|
+
let bad = null
|
|
1196
1201
|
try {
|
|
1197
|
-
|
|
1198
|
-
} finally { inFlight.delete(body.ts) }
|
|
1199
|
-
if (
|
|
1202
|
+
await verifyResponder({ body, seal: payload.seal, acta: payload.acta, masterPubkey: master, knownSeq: readServiceIdentity(dir)?.actaSeq ?? null })
|
|
1203
|
+
} catch (e) { bad = e } finally { inFlight.delete(body.ts) }
|
|
1204
|
+
if (bad) return log(`[vault] change notice BADLY SIGNED: ignored (${bad.message})`)
|
|
1200
1205
|
if (body.ts <= lastTs) return
|
|
1201
1206
|
lastTs = body.ts
|
|
1202
1207
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vaultd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.137.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/vault.js
CHANGED
|
@@ -1420,9 +1420,10 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1420
1420
|
await spreadKey(owner, (esPublica) => recipientsOf(owner, { public: esPublica }))
|
|
1421
1421
|
.catch((e) => log(`[vault] ${owner}: could not hand it the key: ${e.message}`))
|
|
1422
1422
|
}
|
|
1423
|
+
// Con la llave de sellado, como todo lo que sirve la bóveda: la maestra no firma con la
|
|
1424
|
+
// bóveda cerrada, y esto tiene que funcionar cerrada.
|
|
1423
1425
|
const body = { op: 'secrets.result', ns, enc: null, ok: true, ts: Date.now() }
|
|
1424
|
-
|
|
1425
|
-
reply(from, { type: MSG.SECRETS_RESULT, body, signature })
|
|
1426
|
+
reply(from, { type: MSG.SECRETS_RESULT, body, seal: await sealOrFail(body), acta: record })
|
|
1426
1427
|
} catch (e) {
|
|
1427
1428
|
reply(from, { type: MSG.ERROR, error: 'enckey: ' + e.message })
|
|
1428
1429
|
}
|
|
@@ -2080,10 +2081,20 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2080
2081
|
needsApproval: async (pub) => needsApproval(pub, await refreshActa()),
|
|
2081
2082
|
// El teléfono: se apunta el pedido, se le avisa y esta promesa espera su firma.
|
|
2082
2083
|
// Es el mismo camino que ya recorren los cajones de secretos.
|
|
2083
|
-
|
|
2084
|
+
// EL PEDIDO DICE QUÉ SE QUIERE LEER (dueño, 2026-09-30: «es importante que se sepa el
|
|
2085
|
+
// porqué»). Antes salía como `read` sin contexto, y el teléfono enseñaba «pide tus claves
|
|
2086
|
+
// de passwords · no dice qué está ejecutando», que parece un servicio desconocido cuando
|
|
2087
|
+
// es tu extensión pidiendo una contraseña. Ahora es `kind: 'passwords'` con la operación y
|
|
2088
|
+
// los CAMPOS (`password`, `totp`…) — nunca la entrada ni el sitio, que no se ven aquí.
|
|
2089
|
+
// Va sellado al que aprueba, como el comando de un pedido de claves.
|
|
2090
|
+
approve: async ({ pubkey, op, payload }) => {
|
|
2084
2091
|
let resolve
|
|
2085
2092
|
const espera = new Promise((r) => { resolve = r })
|
|
2086
|
-
|
|
2093
|
+
const fields = Array.isArray(payload?.keys) ? payload.keys.slice(0, 12).map((k) => String(k).slice(0, 40)) : null
|
|
2094
|
+
await awaitApproval({
|
|
2095
|
+
device: pubkey, ns: 'passwords', kind: 'passwords', what: 'passwords', op,
|
|
2096
|
+
ctx: { op: String(op || '').replace(/^pm2\./, ''), fields }, onAnswer: resolve
|
|
2097
|
+
})
|
|
2087
2098
|
return espera
|
|
2088
2099
|
},
|
|
2089
2100
|
audit,
|
|
@@ -2147,10 +2158,14 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2147
2158
|
} catch (e) { return log('[vault] could not list who to notify:', e.message) }
|
|
2148
2159
|
if (!targets.length) return
|
|
2149
2160
|
|
|
2161
|
+
// FIRMA LA LLAVE DE SELLADO, con el acta al lado (el servicio lo comprueba con
|
|
2162
|
+
// `verifyResponder`). Firmaba la maestra, y con la bóveda cerrada —que es como vive— el
|
|
2163
|
+
// aviso reventaba antes de salir: el servicio no se enteraba de ningún cambio.
|
|
2150
2164
|
const body = { op: 'secrets.changed', ns, ts: Date.now() }
|
|
2151
|
-
const
|
|
2165
|
+
const acta = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
2166
|
+
const seal = await sealOrFail(body)
|
|
2152
2167
|
for (const d of targets) {
|
|
2153
|
-
try { client.sendByPubkey(d.sub, { type: MSG.SECRETS_CHANGED, body,
|
|
2168
|
+
try { client.sendByPubkey(d.sub, { type: MSG.SECRETS_CHANGED, body, seal, acta }) } catch (_) {}
|
|
2154
2169
|
}
|
|
2155
2170
|
audit('secrets.changed', { ns, notified: targets.length })
|
|
2156
2171
|
log(`[vault] config for "${ns}" changed: notified ${targets.length} agent(s)`)
|
|
@@ -2164,14 +2179,15 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2164
2179
|
*/
|
|
2165
2180
|
async function notifyDeviceChange (pub) {
|
|
2166
2181
|
let cn = null
|
|
2182
|
+
let record = null
|
|
2167
2183
|
try {
|
|
2168
|
-
|
|
2184
|
+
record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
2169
2185
|
cn = (record?.members || []).find((m) => m.pub === pub)?.cn || null
|
|
2170
2186
|
} catch (e) { return log('[vault] could not look up who to notify:', e.message) }
|
|
2171
2187
|
if (!cn) return
|
|
2172
2188
|
const body = { op: 'secrets.changed', ns: cn, ts: Date.now() }
|
|
2173
|
-
const
|
|
2174
|
-
try { client.sendByPubkey(pub, { type: MSG.SECRETS_CHANGED, body,
|
|
2189
|
+
const seal = await sealOrFail(body)
|
|
2190
|
+
try { client.sendByPubkey(pub, { type: MSG.SECRETS_CHANGED, body, seal, acta: record }) } catch (_) {}
|
|
2175
2191
|
const device = await deviceIdOf(pub).catch(() => null)
|
|
2176
2192
|
audit('secrets.changed', { ns: cn, device, notified: 1 })
|
|
2177
2193
|
log(`[vault] config for device ${device} ("${cn}") changed: notified it`)
|
|
@@ -2225,8 +2241,11 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2225
2241
|
*/
|
|
2226
2242
|
async function notifyMembers (ev, info = {}) {
|
|
2227
2243
|
try {
|
|
2244
|
+
// SIN FIRMA, y no falta nada: quien lo usa es el ACTA que viaja al lado, que se
|
|
2245
|
+
// autentica sola (otra bóveda la adopta por `canAdopt`; el teléfono solo refresca y
|
|
2246
|
+
// pregunta). Lo firmaba la maestra, que no firma con la bóveda cerrada: el aviso
|
|
2247
|
+
// reventaba antes de salir y el acta nueva no llegaba a nadie.
|
|
2228
2248
|
const body = { ev, ...info, ts: Date.now() }
|
|
2229
|
-
const { signature } = await identity.signData(body)
|
|
2230
2249
|
// EL ACTA VIAJA CON EL AVISO. Sin esto, un miembro se enteraba de que «algo cambió»
|
|
2231
2250
|
// pero no de QUÉ, y no veía el acta nueva hasta renovar su cert — hasta 30 días.
|
|
2232
2251
|
// Para un aparato eso era lento; para OTRA BÓVEDA es fatal: se le concede `sella` y
|
|
@@ -2264,7 +2283,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2264
2283
|
// el perfil cambió puede esperar a que el aparato abra solo; timbrar por él hacía
|
|
2265
2284
|
// sonar el teléfono con «alguien pide tus claves» sin ningún pedido detrás.
|
|
2266
2285
|
// El timbre es para los pedidos (`notifyApprovers`), que sí esperan una mano.
|
|
2267
|
-
try { client.sendByPubkey(pub, { type: MSG.ADMIN_EVENT, body,
|
|
2286
|
+
try { client.sendByPubkey(pub, { type: MSG.ADMIN_EVENT, body, acta }, { quiet: true }) }
|
|
2268
2287
|
catch (e) { log(`[vault] could not notify ${pub.slice(0, 24)}… of "${ev}": ${e.message}`) }
|
|
2269
2288
|
}
|
|
2270
2289
|
for (const m of miembros) avisar(m.pub)
|
|
@@ -3112,8 +3131,10 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
3112
3131
|
|
|
3113
3132
|
let done = 0
|
|
3114
3133
|
for (const gen of pending) {
|
|
3134
|
+
// La llave de sellado firma (el servicio lo comprueba con `verifyResponder`): esto
|
|
3135
|
+
// tiene que funcionar con la bóveda cerrada, y la maestra entonces no firma.
|
|
3115
3136
|
const body = { op: 'rewrap', owner, gen: gen.gen, wrap: gen.mine, target: targetPub, acta: record, ts: Date.now() }
|
|
3116
|
-
const
|
|
3137
|
+
const seal = await sealOrFail(body)
|
|
3117
3138
|
const answer = new Promise((resolve) => {
|
|
3118
3139
|
const off = onRewrapOk((d) => {
|
|
3119
3140
|
if (d.owner !== owner || d.gen !== gen.gen || d.target !== targetPub) return
|
|
@@ -3121,7 +3142,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
3121
3142
|
})
|
|
3122
3143
|
setTimeout(() => { off(); resolve(null) }, timeoutMs).unref?.()
|
|
3123
3144
|
})
|
|
3124
|
-
try { client.sendByPubkey(helpers[0].pub, { type: MSG.REWRAP, body,
|
|
3145
|
+
try { client.sendByPubkey(helpers[0].pub, { type: MSG.REWRAP, body, seal }) } catch (e) {
|
|
3125
3146
|
log(`[vault] ${owner}: could not ask for the key to be handed out (${e.message})`)
|
|
3126
3147
|
continue
|
|
3127
3148
|
}
|