@dotrino/vaultd 0.25.1 → 0.26.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 +13 -4
- package/lib/src/env.js +8 -8
- package/lib/src/index.js +8 -2
- package/lib/src/revocation.js +67 -0
- package/package.json +2 -2
- package/src/tui/app.js +61 -8
- package/src/tui/i18n.js +9 -2
- package/src/vault.js +53 -32
- package/src/vaultControl.js +18 -6
package/lib/src/admin.js
CHANGED
|
@@ -127,13 +127,22 @@ export function createAdminDesk ({
|
|
|
127
127
|
return { ok: true, result: { ok: true } }
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
-
//
|
|
131
|
-
//
|
|
130
|
+
// QUITAR UN DISPOSITIVO se hace por `sub` (su llave): sale del acta Y se le retiran
|
|
131
|
+
// todos los certificados. Las dos cosas o ninguna.
|
|
132
|
+
//
|
|
133
|
+
// `certNonce` retira UN PAPEL y nada más — el aparato sigue siendo miembro. Sirve
|
|
134
|
+
// para eso y solo para eso, y se conserva por compatibilidad, pero no es «quitar»:
|
|
135
|
+
// usarlo para quitar dejaba un miembro sin certificados al que la bóveda ya nunca le
|
|
136
|
+
// mandaba el aviso de expulsión (mientras siga en el acta, un papel retirado
|
|
137
|
+
// significa «renueva»). Ese era el dispositivo fantasma.
|
|
132
138
|
if (data.op === 'revoke') {
|
|
133
139
|
if (data.sub) {
|
|
140
|
+
const deviceId = await deviceIdOf(String(data.sub)).catch(() => null)
|
|
134
141
|
const r = await desk.revokeDevice(String(data.sub))
|
|
135
|
-
audit('admin.revoke-device', { by, certs: r?.nonces?.length ?? null })
|
|
136
|
-
|
|
142
|
+
audit('admin.revoke-device', { by, device: deviceId, certs: r?.nonces?.length ?? null })
|
|
143
|
+
// Con el `deviceId`: el aviso a los demás dispositivos tiene que decir a QUIÉN
|
|
144
|
+
// quitaron, o no se puede saber si el que sobra eres tú.
|
|
145
|
+
await notify('revoked', { deviceId, by })
|
|
137
146
|
return { ok: true, result: r || { ok: true } }
|
|
138
147
|
}
|
|
139
148
|
const r = await desk.revoke(String(data.certNonce || ''))
|
package/lib/src/env.js
CHANGED
|
@@ -172,7 +172,7 @@ export function applyEnv (secrets, override = overrideByDefault()) {
|
|
|
172
172
|
* @param {Object} [opts]
|
|
173
173
|
* @param {string} [opts.ns]
|
|
174
174
|
* @param {string} [opts.dir]
|
|
175
|
-
* @param {(info:{ns:string, ts:number,
|
|
175
|
+
* @param {(info:{ns:string, ts:number, reason:'changed'|'revoked'})=>void} [opts.onUpdate]
|
|
176
176
|
* Reemplaza la salida por defecto. Úsalo cuando terminar el proceso no sea una
|
|
177
177
|
* opción — el caso del proxio, cuyo reinicio corta el transporte de todos.
|
|
178
178
|
* @param {number} [opts.exitCode=0] Salida LIMPIA: systemd con `Restart=on-failure`
|
|
@@ -183,24 +183,24 @@ export function applyEnv (secrets, override = overrideByDefault()) {
|
|
|
183
183
|
export async function watchEnv ({ ns, dir, onUpdate, exitCode = 0, quiet = false, ...resto } = {}) {
|
|
184
184
|
ns = resolveNs(ns)
|
|
185
185
|
dir = dir || serviceDir(ns)
|
|
186
|
-
const
|
|
186
|
+
const say = (m) => { if (!quiet) console.error(m) }
|
|
187
187
|
|
|
188
|
-
const
|
|
189
|
-
|
|
188
|
+
const exitNow = (reason) => {
|
|
189
|
+
say(`[dotrino-env] ${reason === 'revoked'
|
|
190
190
|
? 'la bóveda REVOCÓ este agente: terminando (no volverá a arrancar)'
|
|
191
191
|
: 'configuración nueva en la bóveda: terminando para que el supervisor lo levante limpio'}`)
|
|
192
|
-
process.exit(
|
|
192
|
+
process.exit(reason === 'revoked' ? 1 : exitCode)
|
|
193
193
|
}
|
|
194
194
|
|
|
195
195
|
return watchSecretsChanges({
|
|
196
196
|
dir,
|
|
197
197
|
ns,
|
|
198
|
-
log:
|
|
199
|
-
onChange: ({ ts }) => (onUpdate ? onUpdate({ ns, ts,
|
|
198
|
+
log: say,
|
|
199
|
+
onChange: ({ ts }) => (onUpdate ? onUpdate({ ns, ts, reason: 'changed' }) : exitNow('changed')),
|
|
200
200
|
// Un cert revocado sale con código de FALLO a propósito: si el supervisor lo
|
|
201
201
|
// levanta, va a morir otra vez al no poder leer sus secretos, y el contador de
|
|
202
202
|
// reinicios fallidos es lo que hace que se note en vez de girar en silencio.
|
|
203
|
-
onRevoked: () => (onUpdate ? onUpdate({ ns, ts: Date.now(),
|
|
203
|
+
onRevoked: () => (onUpdate ? onUpdate({ ns, ts: Date.now(), reason: 'revoked' }) : exitNow('revoked')),
|
|
204
204
|
...resto
|
|
205
205
|
})
|
|
206
206
|
}
|
package/lib/src/index.js
CHANGED
|
@@ -206,8 +206,14 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
|
|
|
206
206
|
reject: (deviceId) => desk.reject(deviceId),
|
|
207
207
|
listPending: desk.listPending,
|
|
208
208
|
listMachines,
|
|
209
|
-
//
|
|
210
|
-
//
|
|
209
|
+
// QUITA LA MÁQUINA entera (por su llave): fuera del acta y sin ningún certificado
|
|
210
|
+
// vigente, que son las dos caras del mismo acto. Y AVISA con un REVOKED firmado para
|
|
211
|
+
// que se auto-borre (ahora si está online, o al reaparecer vía handleDevices).
|
|
212
|
+
revokeDevice: (sub) => desk.revokeDevice(sub),
|
|
213
|
+
// Retira UN certificado. No es quitar la máquina: sigue siendo miembro del acta, y
|
|
214
|
+
// quien queda así ya no recibe el aviso de expulsión (mientras siga en el acta, un
|
|
215
|
+
// papel retirado significa «renueva»). Usar `revokeDevice` salvo que quieras
|
|
216
|
+
// exactamente esto.
|
|
211
217
|
revoke: (nonce) => desk.revoke(nonce),
|
|
212
218
|
getSelfCert,
|
|
213
219
|
onPendingChange (fn) { _onPendingChange = fn || (() => {}) },
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* revocation.js — CUÁNDO se le dice a un aparato que ya no es de casa.
|
|
3
|
+
*
|
|
4
|
+
* Módulo PURO (sin red, sin disco, sin `node:*`), como `enroll.js` y `admin.js`: la regla
|
|
5
|
+
* vive en un solo sitio, se lee entera y se prueba sin levantar nada.
|
|
6
|
+
*
|
|
7
|
+
* EL PROBLEMA QUE RESUELVE. Un dispositivo al que quitaron del perfil no se entera por su
|
|
8
|
+
* cuenta: lo único que le borra la cuenta es un aviso FIRMADO por la maestra
|
|
9
|
+
* (`vault.revoked`). Un «unauthorized» suelto no vale y no debe valer — no va firmado, así
|
|
10
|
+
* que cualquiera podría destruir datos ajenos con un mensaje (el wipe-DoS de
|
|
11
|
+
* `docs/pairing-protocol.md §2.3`). Por eso la bóveda tiene que **atender** la conexión de
|
|
12
|
+
* un aparato que fue suyo, aunque su papel ya no sirva, precisamente para poder mandarlo a
|
|
13
|
+
* paseo. Si no lo hace, el aparato se queda enseñando una cuenta que ya no existe.
|
|
14
|
+
*
|
|
15
|
+
* LA REGLA, en una línea: papel nuestro + ya no está en el acta ⇒ se le avisa.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Motivos de rechazo de `verifyChain` que significan «este papel ya no sirve».
|
|
20
|
+
*
|
|
21
|
+
* Los tres se producen DESPUÉS de que `verifyChain` haya comprobado la firma del propio
|
|
22
|
+
* aparato y la del certificado, así que quien llega hasta aquí demostró tener la llave que
|
|
23
|
+
* el certificado nombra. El resto de motivos (`shape`, `bad-signature`,
|
|
24
|
+
* `bad-action-signature`, `cert-device-mismatch`, `untrusted-issuer`…) son ruido o gente
|
|
25
|
+
* ajena: ahí no hay a quién avisar de nada.
|
|
26
|
+
*/
|
|
27
|
+
export const STALE_PAPER = Object.freeze(['revoked', 'expired', 'scope'])
|
|
28
|
+
const STALE = new Set(STALE_PAPER)
|
|
29
|
+
|
|
30
|
+
/** ¿El motivo del rechazo es «tu papel ya no sirve»? */
|
|
31
|
+
export const isStalePaper = (reason) => STALE.has(reason)
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* ¿Hay que mandarle el aviso firmado de expulsión?
|
|
35
|
+
*
|
|
36
|
+
* @param {Object} o
|
|
37
|
+
* @param {string} o.reason por qué falló `verifyChain`.
|
|
38
|
+
* @param {string} o.pubkey la llave del aparato que acaba de escribir.
|
|
39
|
+
* @param {string} o.master la maestra de ESTA bóveda.
|
|
40
|
+
* @param {string|null} [o.certIss] quién firmó el certificado que presentó.
|
|
41
|
+
* @param {Array<{pub:string}>|null} [o.members] miembros del acta; `null` = no hay acta.
|
|
42
|
+
* @param {boolean} [o.knownRevoked] consta explícitamente como revocado en las
|
|
43
|
+
* delegaciones. Es el único criterio que queda cuando no hay acta.
|
|
44
|
+
* @returns {boolean}
|
|
45
|
+
*/
|
|
46
|
+
export function shouldNotifyRevoked ({ reason, pubkey, master, certIss = null, members = null, knownRevoked = false } = {}) {
|
|
47
|
+
if (typeof pubkey !== 'string' || !pubkey) return false
|
|
48
|
+
if (!isStalePaper(reason)) return false
|
|
49
|
+
// Que el papel sea NUESTRO. No hace falta que siga siendo válido —justo por eso estamos
|
|
50
|
+
// aquí— pero sí que lo haya firmado esta maestra: es lo que separa a un aparato que fue
|
|
51
|
+
// de casa de uno que pasaba por ahí.
|
|
52
|
+
if (certIss && master && certIss !== master) return false
|
|
53
|
+
// El aparato no puede echarse a sí mismo, y la maestra tampoco se echa.
|
|
54
|
+
if (master && pubkey === master) return false
|
|
55
|
+
|
|
56
|
+
// EL ACTA MANDA, y es lo único que manda. Un certificado retirado o vencido NO significa
|
|
57
|
+
// «estás fuera»: renovar retira el anterior y cambiar permisos obliga a renovar, así que
|
|
58
|
+
// un aparato de casa con un papel viejo solo tiene que renovar. Decirle «estás fuera»
|
|
59
|
+
// ahí lo borraba solo: dabas «administra» y el dispositivo desaparecía.
|
|
60
|
+
if (Array.isArray(members)) return !members.some((m) => m?.pub === pubkey)
|
|
61
|
+
|
|
62
|
+
// Sin acta (bóveda anterior al acta) no se puede saber quién es del perfil: solo se
|
|
63
|
+
// avisa a quien conste explícitamente como revocado.
|
|
64
|
+
return !!knownRevoked
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export default { STALE_PAPER, isStalePaper, shouldNotifyRevoked }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vaultd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.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": {
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"node": ">=20"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@dotrino/identity": "^0.
|
|
22
|
+
"@dotrino/identity": "^0.47.0",
|
|
23
23
|
"@dotrino/proxy-client": "^0.10.0",
|
|
24
24
|
"ws": "^8.18.0"
|
|
25
25
|
},
|
package/src/tui/app.js
CHANGED
|
@@ -88,6 +88,36 @@ function groupByDevice (issued) {
|
|
|
88
88
|
return [...by.values()]
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/**
|
|
92
|
+
* LA lista de dispositivos: el acta, con el certificado de cada uno pegado.
|
|
93
|
+
*
|
|
94
|
+
* El acta es quien dice de quién es el perfil; los certificados son el reflejo de esa
|
|
95
|
+
* decisión y pueden faltar (retirados, vencidos). Un miembro sin certificado sale igual,
|
|
96
|
+
* marcado como «sin acceso», porque es exactamente el que hay que poder quitar.
|
|
97
|
+
*
|
|
98
|
+
* Si todavía no hay acta (bóveda anterior al acta, o sin volcar), se cae a los
|
|
99
|
+
* certificados: peor lista, pero lista.
|
|
100
|
+
*/
|
|
101
|
+
function mergeMembersAndCerts (members, issued) {
|
|
102
|
+
const certs = new Map()
|
|
103
|
+
for (const d of groupByDevice(issued)) certs.set(d.sub || d.deviceId || d.nonce, d)
|
|
104
|
+
if (!Array.isArray(members) || !members.length) return [...certs.values()]
|
|
105
|
+
return members.map((m) => {
|
|
106
|
+
const cert = certs.get(m.pub)
|
|
107
|
+
return {
|
|
108
|
+
...(cert || {}),
|
|
109
|
+
sub: m.pub,
|
|
110
|
+
deviceId: m.id || cert?.deviceId || '????-????',
|
|
111
|
+
label: m.label || cert?.label || '',
|
|
112
|
+
isMaster: !!m.isMaster,
|
|
113
|
+
cn: m.cn || null,
|
|
114
|
+
// El master es la propia bóveda: no tiene (ni necesita) certificado. Un servicio
|
|
115
|
+
// tampoco lleva uno de dispositivo. Marcarlos «sin acceso» sería una alarma falsa.
|
|
116
|
+
noAccess: !cert && !m.isMaster && !m.cn
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
91
121
|
function activeProfile (st) {
|
|
92
122
|
const list = st.profiles?.profiles || []
|
|
93
123
|
return list.find((p) => p.current) || list[0] || null
|
|
@@ -193,15 +223,27 @@ function deviceRows (st, t) {
|
|
|
193
223
|
// UNA FILA POR APARATO, no por certificado. Antes se pintaba `issued` tal cual y un
|
|
194
224
|
// aparato con dos certs (el viejo + el de la renovación) salía dos veces: parecían dos
|
|
195
225
|
// máquinas. Se agrupa por llave y se muestra el cert vigente más largo.
|
|
196
|
-
|
|
197
|
-
|
|
226
|
+
//
|
|
227
|
+
// Y la lista sale del ACTA, no de los certificados: el acta dice quién es del perfil, y
|
|
228
|
+
// los certificados son su reflejo. Un miembro sin certificado vigente —porque le
|
|
229
|
+
// retiraron el papel pero no lo sacaron del acta, o porque se le venció— no salía en
|
|
230
|
+
// ninguna pantalla del PC: invisible aquí, presente en la del navegador, y sin forma de
|
|
231
|
+
// quitarlo más que adivinando su ID para el `revoke` de la línea de comandos.
|
|
232
|
+
const devices = mergeMembersAndCerts(st.members, st.devices?.issued || [])
|
|
233
|
+
if (!devices.length) {
|
|
198
234
|
rows.push({ text: t.muted(i.noDevices), sel: false })
|
|
199
235
|
}
|
|
200
|
-
for (const d of
|
|
236
|
+
for (const d of devices) {
|
|
201
237
|
const label = d.label || t.muted(i.noLabel)
|
|
202
238
|
const extra = d.certCount > 1 ? t.muted(` certs:${d.certCount}`) : ''
|
|
203
|
-
|
|
204
|
-
|
|
239
|
+
// SIN ACCESO: está en el acta y no puede entrar. Es un aviso, no un adorno, así que va
|
|
240
|
+
// en el color de aviso y en el sitio donde estaría su vencimiento.
|
|
241
|
+
const estado = d.noAccess
|
|
242
|
+
? t.warn(i.deviceNoAccess)
|
|
243
|
+
: d.isMaster
|
|
244
|
+
? t.muted(i.thisVault)
|
|
245
|
+
: t.muted('scope:' + shortScope(d.scope)) + ' ' + t.muted('exp:' + fmtExp(d.exp))
|
|
246
|
+
rows.push({ text: ` ${t.bold(d.deviceId)} ${label} ${estado}${extra}`, sel: true, meta: d })
|
|
205
247
|
}
|
|
206
248
|
const revoked = st.devices?.revoked || []
|
|
207
249
|
if (revoked.length) {
|
|
@@ -342,9 +384,12 @@ async function guard (term, st, msg, fn) {
|
|
|
342
384
|
async function refreshAll (term, st) {
|
|
343
385
|
const r = await guard(term, st, L(st).loading, () => vc.snapshot(activeId(st)))
|
|
344
386
|
if (!r.ok) return
|
|
345
|
-
const { devices, secrets, profiles } = r.v
|
|
387
|
+
const { devices, secrets, profiles, acta } = r.v
|
|
346
388
|
if (profiles) st.profiles = profiles
|
|
347
389
|
if (secrets) st.secrets = secrets.ns || {}
|
|
390
|
+
// El ACTA entra en el volcado normal: es de donde sale la lista de dispositivos (ver
|
|
391
|
+
// `mergeMembersAndCerts`). Antes solo se pedía al abrir la pantalla de permisos.
|
|
392
|
+
if (acta) st.members = acta.members || []
|
|
348
393
|
if (devices) {
|
|
349
394
|
const issued = (devices.issued || devices.active || devices.delegations || [])
|
|
350
395
|
st.devices = { issued: await Promise.all(issued.map(async (d) => ({ ...d, deviceId: d.sub ? await vc.deviceIdOf(d.sub) : '????-????' }))), revoked: devices.revoked || [] }
|
|
@@ -353,7 +398,11 @@ async function refreshAll (term, st) {
|
|
|
353
398
|
|
|
354
399
|
async function refreshDevices (term, st) {
|
|
355
400
|
const r = await guard(term, st, L(st).loadingDevices, () => vc.listDevices(activeId(st)))
|
|
356
|
-
if (r.ok)
|
|
401
|
+
if (!r.ok) return
|
|
402
|
+
st.devices = r.v
|
|
403
|
+
// La lista se pinta desde el acta; traerla aparte dejaba la pantalla con los miembros de
|
|
404
|
+
// hace dos operaciones (quitar uno no lo quitaba de la vista).
|
|
405
|
+
if (Array.isArray(r.v.members)) st.members = r.v.members
|
|
357
406
|
}
|
|
358
407
|
async function refreshSecrets (term, st) {
|
|
359
408
|
const r = await guard(term, st, L(st).loadingSecrets, () => vc.listSecrets(activeId(st)))
|
|
@@ -545,6 +594,10 @@ async function onKeyDevices (term, st, key) {
|
|
|
545
594
|
if (!st.pending) { flash(st, i.noPendingToReject, 'warn'); return true }
|
|
546
595
|
const r = await guard(term, st, i.rejecting, () => vc.rejectPending(st.pending.deviceId, activeId(st)))
|
|
547
596
|
if (r.ok) { flash(st, i.deviceRejected); st.pending = null }
|
|
597
|
+
} else if ((ch === 'v' || key.name === 'delete') && cur?.isMaster) {
|
|
598
|
+
// La bóveda no se echa a sí misma: el acta la necesita para poder sellarse. Se dice
|
|
599
|
+
// en vez de mandar la orden y enseñar el error del daemon, que no explica nada.
|
|
600
|
+
flash(st, i.cantRemoveMaster, 'warn')
|
|
548
601
|
} else if ((ch === 'v' || key.name === 'delete') && (cur?.sub || cur?.nonce != null)) { // quitar el aparato seleccionado
|
|
549
602
|
setConfirm(st, {
|
|
550
603
|
text: i.revokeConfirm(cur.deviceId),
|
|
@@ -1111,4 +1164,4 @@ export async function runTui () {
|
|
|
1111
1164
|
}
|
|
1112
1165
|
|
|
1113
1166
|
// Solo para pruebas headless (render sin terminal real). No usar en runtime.
|
|
1114
|
-
export const __test = { render, profileRows, deviceRows, secretRows, meRows, capsRows, pairModeRows, pairingBody, scrollBody, fitHelp, toggleLang }
|
|
1167
|
+
export const __test = { render, profileRows, deviceRows, secretRows, meRows, capsRows, pairModeRows, pairingBody, scrollBody, fitHelp, toggleLang, mergeMembersAndCerts }
|
package/src/tui/i18n.js
CHANGED
|
@@ -128,13 +128,17 @@ const es = {
|
|
|
128
128
|
pendingHint: ' — pulsa A para aprobar, X para rechazar',
|
|
129
129
|
noDevices: ' (sin dispositivos enrolados — pulsa P para emparejar uno)',
|
|
130
130
|
noLabel: '(sin etiqueta)',
|
|
131
|
+
// Está en el acta y no puede entrar: o le retiraron el certificado, o se le venció.
|
|
132
|
+
deviceNoAccess: 'SIN ACCESO — está en el acta pero no puede entrar',
|
|
133
|
+
thisVault: 'esta bóveda (manda ella)',
|
|
134
|
+
cantRemoveMaster: 'Esta bóveda es la que manda: no se quita a sí misma.',
|
|
131
135
|
revokedCount: (n) => ` Revocados: ${n}`,
|
|
132
136
|
startingPairing: 'Iniciando emparejamiento…',
|
|
133
137
|
noPending: 'No hay ningún dispositivo pendiente',
|
|
134
138
|
noPendingToReject: 'No hay ningún dispositivo pendiente para rechazar',
|
|
135
139
|
rejecting: 'Rechazando…',
|
|
136
140
|
deviceRejected: 'Dispositivo rechazado',
|
|
137
|
-
revokeConfirm: (id) => `¿
|
|
141
|
+
revokeConfirm: (id) => `¿Quitar ${id} del perfil? Sale del acta, se le retiran los certificados y se le ordena autoborrarse al reconectar.`,
|
|
138
142
|
revoking: 'Revocando…',
|
|
139
143
|
deviceRevoked: (id) => `Revocado ${id}`,
|
|
140
144
|
approveLabel: (id) => `Código que MUESTRA el dispositivo ${id}`,
|
|
@@ -351,13 +355,16 @@ const en = {
|
|
|
351
355
|
pendingHint: ' — press A to approve, X to reject',
|
|
352
356
|
noDevices: ' (no devices enrolled — press P to pair one)',
|
|
353
357
|
noLabel: '(no label)',
|
|
358
|
+
deviceNoAccess: 'NO ACCESS — it is on the record but cannot get in',
|
|
359
|
+
thisVault: 'this vault (it is the Master)',
|
|
360
|
+
cantRemoveMaster: 'This vault is the Master: it does not remove itself.',
|
|
354
361
|
revokedCount: (n) => ` Revoked: ${n}`,
|
|
355
362
|
startingPairing: 'Starting pairing…',
|
|
356
363
|
noPending: 'No device is waiting',
|
|
357
364
|
noPendingToReject: 'No device is waiting to be rejected',
|
|
358
365
|
rejecting: 'Rejecting…',
|
|
359
366
|
deviceRejected: 'Device rejected',
|
|
360
|
-
revokeConfirm: (id) => `
|
|
367
|
+
revokeConfirm: (id) => `Remove ${id} from the profile? It leaves the record, its certificates are withdrawn and it is told to erase itself on reconnect.`,
|
|
361
368
|
revoking: 'Revoking…',
|
|
362
369
|
deviceRevoked: (id) => `Revoked ${id}`,
|
|
363
370
|
approveLabel: (id) => `Code SHOWN by device ${id}`,
|
package/src/vault.js
CHANGED
|
@@ -17,6 +17,7 @@ import { verifyChain, pubkeyId } from '@dotrino/identity/capabilities'
|
|
|
17
17
|
import * as Acta from '@dotrino/identity/acta'
|
|
18
18
|
import { createEnrollDesk, deviceIdOf, DEVICE_TTL_MS } from '../lib/src/enroll.js'
|
|
19
19
|
import { createAdminDesk } from '../lib/src/admin.js'
|
|
20
|
+
import { shouldNotifyRevoked } from '../lib/src/revocation.js'
|
|
20
21
|
import { createTransport, masterPubkeyOf } from './transport.js'
|
|
21
22
|
import { openStore } from './store.js'
|
|
22
23
|
import { openThreadStore, STORE_READ_METHODS, PROFILE_EDIT_METHODS } from './threadStore.js'
|
|
@@ -145,7 +146,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
145
146
|
data: p.data, signature: p.signature, cert: p.cert,
|
|
146
147
|
expectedScope: SCOPE.SIGN, trustedIssuer: master, revoked: await revocationSet()
|
|
147
148
|
})
|
|
148
|
-
if (!chk.ok)
|
|
149
|
+
if (!chk.ok) return denyChain(from, chk, p, 'sign')
|
|
149
150
|
const toSign = p.data?.payload
|
|
150
151
|
if (toSign == null) return reply(from, { type: MSG.ERROR, error: 'data.payload required' })
|
|
151
152
|
const { signature, publickey } = await identity.signData(toSign)
|
|
@@ -159,7 +160,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
159
160
|
data: p.data, signature: p.signature, cert: p.cert,
|
|
160
161
|
expectedScope: SCOPE.READ, trustedIssuer: master, revoked: await revocationSet()
|
|
161
162
|
})
|
|
162
|
-
if (!chk.ok) return
|
|
163
|
+
if (!chk.ok) return denyChain(from, chk, p, 'get')
|
|
163
164
|
const id = p.data?.id || 'root'
|
|
164
165
|
reply(from, { type: MSG.DATA, id, node: store.getNode(id) })
|
|
165
166
|
}
|
|
@@ -187,7 +188,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
187
188
|
if (!chk.ok && STORE_READ_METHODS.has(d.method)) {
|
|
188
189
|
chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, expectedScope: SCOPE.READ, trustedIssuer: master, revoked })
|
|
189
190
|
}
|
|
190
|
-
if (!chk.ok) return
|
|
191
|
+
if (!chk.ok) return denyChain(from, chk, p, 'store')
|
|
191
192
|
try {
|
|
192
193
|
// CIFRADO de punta a punta con la clave de contenido del perfil: el proxy transporta
|
|
193
194
|
// pero no ve nada de lo que el usuario guarda. Si el dispositivo mandó `enc`, se abre
|
|
@@ -227,41 +228,55 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
227
228
|
* — y hasta ahora el daemon no lo reemitía nunca. Si el aparato estaba apagado cuando lo
|
|
228
229
|
* quitaste, no se enteraba jamás: seguía enseñando el perfil como si nada.
|
|
229
230
|
*/
|
|
230
|
-
async function
|
|
231
|
+
async function notifyIfRevoked (pubkey, nonce = null, certIss = null, reason = 'revoked') {
|
|
231
232
|
if (typeof pubkey !== 'string') return
|
|
232
233
|
try {
|
|
233
|
-
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
const revocados = await revocationSet()
|
|
247
|
-
const candidatos = [...(dele.revokedCerts || []), ...(dele.issued || [])]
|
|
248
|
-
const suyas = candidatos.filter((x) => x.sub === pubkey && (x.revokedAt || revocados.has(x.nonce)))
|
|
249
|
-
if (suyas.length) {
|
|
250
|
-
// El aviso nombra el certificado que el aparato ACABA de presentar: es el que
|
|
251
|
-
// tiene en la mano, y es contra ese contra el que comprueba antes de borrarse.
|
|
252
|
-
await desk.emitRevoke(pubkey, nonce || suyas[0].nonce)
|
|
253
|
-
audit('revoke.notified', { device: await deviceIdOf(pubkey).catch(() => null) })
|
|
234
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
235
|
+
// Sin acta (bóveda anterior al acta) hay que mirar las delegaciones, que es lo único
|
|
236
|
+
// que queda. OJO con dónde se buscan: desde identity 0.42 `issued` es «lo que HOY
|
|
237
|
+
// sirve para entrar», así que los retirados NO están ahí — viven en `revokedCerts`.
|
|
238
|
+
let knownRevoked = false
|
|
239
|
+
let fallbackNonce = null
|
|
240
|
+
if (!record) {
|
|
241
|
+
const delegations = await identity.listDelegations()
|
|
242
|
+
const revoked = await revocationSet()
|
|
243
|
+
const candidates = [...(delegations.revokedCerts || []), ...(delegations.issued || [])]
|
|
244
|
+
const theirs = candidates.filter((x) => x.sub === pubkey && (x.revokedAt || revoked.has(x.nonce)))
|
|
245
|
+
knownRevoked = theirs.length > 0
|
|
246
|
+
fallbackNonce = theirs[0]?.nonce || null
|
|
254
247
|
}
|
|
248
|
+
if (!shouldNotifyRevoked({ reason, pubkey, master, certIss, members: record?.members || null, knownRevoked })) return
|
|
249
|
+
// El aviso nombra el certificado que el aparato ACABA de presentar: es el que tiene
|
|
250
|
+
// en la mano, y es contra ese contra el que comprueba antes de borrarse.
|
|
251
|
+
await desk.emitRevoke(pubkey, nonce || fallbackNonce)
|
|
252
|
+
audit('revoke.notified', { device: await deviceIdOf(pubkey).catch(() => null), reason })
|
|
255
253
|
} catch (e) { log('[vault] could not re-emit the revocation:', e.message) }
|
|
256
254
|
}
|
|
257
255
|
|
|
256
|
+
/**
|
|
257
|
+
* Rechaza una petición y, si el aparato ya no es del perfil, SE LO DICE con el aviso
|
|
258
|
+
* firmado — sea cual sea la operación que estuviera intentando.
|
|
259
|
+
*
|
|
260
|
+
* Un dispositivo que fue tuyo tiene que poder llegar hasta aquí precisamente para que se
|
|
261
|
+
* le pueda mandar a paseo: es el único mensaje que le borra la cuenta, porque es el único
|
|
262
|
+
* que va firmado por la maestra (un «unauthorized» suelto no borra nada y no debe: sería
|
|
263
|
+
* destruir datos ajenos con un mensaje, el wipe-DoS de `docs/pairing-protocol.md §2.3`).
|
|
264
|
+
*
|
|
265
|
+
* Antes esto solo pasaba en `devices` —la pantalla de dispositivos— y solo si el papel
|
|
266
|
+
* estaba REVOCADO. Un aparato quitado que estuviera guardando notas nunca preguntaba por
|
|
267
|
+
* ahí, y uno al que simplemente se le venció el certificado no entraba en el caso: los
|
|
268
|
+
* dos se quedaban enseñando la cuenta indefinidamente.
|
|
269
|
+
*/
|
|
270
|
+
async function denyChain (from, chk, p, what) {
|
|
271
|
+
await notifyIfRevoked(p.data?.publickey, p.cert?.nonce || null, p.cert?.iss || null, chk.reason)
|
|
272
|
+
if (what) audit('rejected', { what, reason: chk.reason })
|
|
273
|
+
return reply(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason })
|
|
274
|
+
}
|
|
275
|
+
|
|
258
276
|
async function handleDevices (from, p) {
|
|
259
277
|
if (!isFresh(p.data)) return staleReply(from)
|
|
260
278
|
const chk = await verifyChain({ data: p.data, signature: p.signature, cert: p.cert, trustedIssuer: master, revoked: await revocationSet() })
|
|
261
|
-
if (!chk.ok)
|
|
262
|
-
if (chk.reason === 'revoked') await avisarSiRevocado(p.data?.publickey, p.cert?.nonce || null)
|
|
263
|
-
return reply(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason })
|
|
264
|
-
}
|
|
279
|
+
if (!chk.ok) return denyChain(from, chk, p, null)
|
|
265
280
|
const { issued, revoked } = await identity.listDelegations()
|
|
266
281
|
// El acta viaja con la lista: así cada dispositivo se entera de los cambios de
|
|
267
282
|
// política (quién manda, quién puede qué) sin un canal aparte.
|
|
@@ -297,7 +312,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
297
312
|
async function handleRenew (from, p) {
|
|
298
313
|
if (!isFresh(p.data)) { audit('rejected', { what: 'renew', reason: 'stale' }); return staleReply(from) }
|
|
299
314
|
const chk = await verifyChain({ data: p.data, signature: p.signature, cert: p.cert, trustedIssuer: master, revoked: await revocationSet() })
|
|
300
|
-
if (!chk.ok)
|
|
315
|
+
if (!chk.ok) return denyChain(from, chk, p, 'renew')
|
|
301
316
|
// Reusar el label del cert original (si sigue registrado en delegations).
|
|
302
317
|
const { issued } = await identity.listDelegations()
|
|
303
318
|
const prev = (issued || []).find((x) => x.nonce === p.cert.nonce)
|
|
@@ -338,7 +353,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
338
353
|
data: p.data, signature: p.signature, cert: p.cert,
|
|
339
354
|
expectedScope: secretsScope(ns), trustedIssuer: master, revoked: await revocationSet()
|
|
340
355
|
})
|
|
341
|
-
if (!chk.ok)
|
|
356
|
+
if (!chk.ok) return denyChain(from, chk, p, 'secrets')
|
|
342
357
|
// FRONTERA DEL CN (acta): además del scope del cert, el acta tiene que decir que este
|
|
343
358
|
// miembro es el servicio `ns`. Así el límite no depende solo de qué cert se emitió: la
|
|
344
359
|
// llave del proxy no ve nada que no sea del proxy, y está escrito donde se puede comprobar.
|
|
@@ -491,7 +506,13 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
491
506
|
data, signature, cert,
|
|
492
507
|
expectedScope: SCOPE.ADMIN, trustedIssuer: master, revoked: await revocationSet()
|
|
493
508
|
})
|
|
494
|
-
|
|
509
|
+
// Quitarse a UNO MISMO desde la consola remota entra por aquí: la segunda petición
|
|
510
|
+
// que mande el aparato ya llega con el certificado retirado. Que se entere con el
|
|
511
|
+
// aviso firmado, en vez de con un error suelto que no puede borrar nada.
|
|
512
|
+
if (!chk.ok) {
|
|
513
|
+
await notifyIfRevoked(data?.publickey, cert?.nonce || null, cert?.iss || null, chk.reason)
|
|
514
|
+
return chk
|
|
515
|
+
}
|
|
495
516
|
const acta = (await identity.profileActa?.().catch(() => null))?.acta
|
|
496
517
|
if (acta && !Acta.memberCan(acta, chk.device, 'admin')) return { ok: false, reason: 'acta' }
|
|
497
518
|
return chk
|
package/src/vaultControl.js
CHANGED
|
@@ -179,13 +179,17 @@ export const removeProfilePassword = (profile) => profileOp('password-rm', { pro
|
|
|
179
179
|
*/
|
|
180
180
|
export async function snapshot (profile) {
|
|
181
181
|
requireAlive()
|
|
182
|
-
rm(F.devices); rm(F.secretsList); rm(F.profilesList)
|
|
182
|
+
rm(F.devices); rm(F.secretsList); rm(F.profilesList); rm(F.acta)
|
|
183
183
|
writeReq(F.dumpReq, {}, profile)
|
|
184
184
|
signalOrCleanup('SIGUSR2', [F.dumpReq])
|
|
185
|
-
|
|
186
|
-
|
|
185
|
+
// El ACTA entra en el volcado normal: es la lista de dispositivos de verdad, y las
|
|
186
|
+
// delegaciones son su reflejo. Sin ella, un miembro sin certificados (revocado a medias,
|
|
187
|
+
// o con el papel caducado) no salía en ninguna pantalla del PC — invisible y, por lo
|
|
188
|
+
// tanto, imposible de quitar desde aquí.
|
|
189
|
+
const [devices, secrets, profiles, acta] = await Promise.all([
|
|
190
|
+
waitFor(F.devices), waitFor(F.secretsList), waitFor(F.profilesList), waitFor(F.acta)
|
|
187
191
|
])
|
|
188
|
-
return { devices, secrets, profiles }
|
|
192
|
+
return { devices, secrets, profiles, acta }
|
|
189
193
|
}
|
|
190
194
|
|
|
191
195
|
/**
|
|
@@ -193,14 +197,22 @@ export async function snapshot (profile) {
|
|
|
193
197
|
* `issued` viene de identity.listDelegations(); el deviceId se deriva del `sub`.
|
|
194
198
|
*/
|
|
195
199
|
export async function listDevices (profile) {
|
|
196
|
-
const { devices } = await snapshot(profile)
|
|
200
|
+
const { devices, acta } = await snapshot(profile)
|
|
197
201
|
if (!devices) throw coded('the daemon did not reply', 'NO_REPLY')
|
|
198
202
|
const issued = devices.issued || devices.active || devices.delegations || []
|
|
199
203
|
const revoked = devices.revoked || []
|
|
200
204
|
const withIds = await Promise.all(issued.map(async (d) => ({
|
|
201
205
|
...d, deviceId: d.sub ? await deviceIdOf(d.sub) : '????-????'
|
|
202
206
|
})))
|
|
203
|
-
|
|
207
|
+
// Los MIEMBROS viajan con la lista: quién es del perfil lo dice el acta, y los
|
|
208
|
+
// certificados son su reflejo. Quien pinte la lista los necesita a la vez, o acaba
|
|
209
|
+
// enseñando solo a los que tienen papel — y el que hay que quitar es justo el que no.
|
|
210
|
+
return {
|
|
211
|
+
issued: agruparPorAparato(withIds, revoked),
|
|
212
|
+
revoked,
|
|
213
|
+
members: acta?.members || [],
|
|
214
|
+
profile: devices.profile || null
|
|
215
|
+
}
|
|
204
216
|
}
|
|
205
217
|
|
|
206
218
|
/**
|