@dotrino/vaultd 0.108.0 → 0.109.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/package.json +2 -2
- package/src/ctl.js +5 -0
- package/src/subacta.js +102 -0
- package/src/vault.js +91 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vaultd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.109.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": {
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
24
|
"@dotrino/compat": "0.4.0",
|
|
25
|
-
"@dotrino/identity": "0.
|
|
25
|
+
"@dotrino/identity": "0.81.0",
|
|
26
26
|
"@dotrino/passmanager": "^0.10.1",
|
|
27
27
|
"@dotrino/proxy-client": "0.15.0",
|
|
28
28
|
"@dotrino/roadmap": "0.2.0",
|
package/src/ctl.js
CHANGED
|
@@ -598,6 +598,11 @@ async function cmdMembers () {
|
|
|
598
598
|
// QUÉ CORRE, y si cuadra con esta bóveda (CONVENCIONES §14). Sin esto una
|
|
599
599
|
// incompatibilidad de versiones se ve como que ese aparato «no responde», que es el
|
|
600
600
|
// fallo más caro que ha tenido el ecosistema. No bloquea nada: se enseña.
|
|
601
|
+
// RENUNCIAS SIN SELLAR. Ya no los tiene —la bóveda no se los reconoce— pero el acta
|
|
602
|
+
// que se lee arriba todavía los nombra, así que hay que decirlo aquí o la lista miente.
|
|
603
|
+
if (m.renounced?.length) {
|
|
604
|
+
console.log(' %s↓ renunció a %s (pendiente de sellar: abre la bóveda)%s', R, m.renounced.join(', '), Z)
|
|
605
|
+
}
|
|
601
606
|
if (m.running) {
|
|
602
607
|
const v = `${m.running.product} ${m.running.version}`
|
|
603
608
|
if (m.running.compatible) console.log(' %scorre %s%s', D, v, Z)
|
package/src/subacta.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* subacta.js — LO DECIDIDO QUE TODAVÍA NO SELLÓ EL ACTA.
|
|
3
|
+
*
|
|
4
|
+
* Idea del dueño (2026-09-04): *«una subacta efímera que se comparte entre todos los
|
|
5
|
+
* dispositivos y puede tener decisiones a futuro que se aceptan cuando el acta se abre»*.
|
|
6
|
+
*
|
|
7
|
+
* EL AGUJERO QUE TAPA. Sellar el acta es de la maestra, y con el perfil cerrado la maestra
|
|
8
|
+
* no está. Así que una renuncia —«me quito estos permisos»— no surtía NINGÚN efecto hasta
|
|
9
|
+
* que alguien iba a la máquina y tecleaba la contraseña. Justo el caso que la renuncia
|
|
10
|
+
* existe para cubrir: te roban el teléfono, renuncias desde él, y el ladrón sigue siendo
|
|
11
|
+
* admin hasta que llegues. El pilar ya tenía la pieza (`extraRenounces`) y no la pasaba
|
|
12
|
+
* nadie.
|
|
13
|
+
*
|
|
14
|
+
* LA REGLA DURA, y es la que sostiene todo lo demás:
|
|
15
|
+
*
|
|
16
|
+
* LA SUBACTA SOLO PUEDE QUITAR, NUNCA DAR.
|
|
17
|
+
*
|
|
18
|
+
* De ahí salen las tres propiedades que la hacen segura sin maestra:
|
|
19
|
+
*
|
|
20
|
+
* · **Se autentica sola.** Cada entrada la firma EL MIEMBRO AL QUE AFECTA. No hace falta
|
|
21
|
+
* sellador, así que no hay una segunda autoridad al lado del acta.
|
|
22
|
+
* · **Una entrada falsa o repetida cuesta disponibilidad, no autoridad.** Lo peor que
|
|
23
|
+
* consigue quien cuele una es quitarle permisos a alguien: molesto y reversible.
|
|
24
|
+
* · **Nadie puede quitarle nada a otro.** Si valiera la firma de cualquiera, un servicio
|
|
25
|
+
* con `secrets` en un VPS alquilado podría dejar sin `admin` a tu teléfono.
|
|
26
|
+
*
|
|
27
|
+
* Y por eso el catálogo de operaciones es una LISTA BLANCA de una sola: `renounce`. La
|
|
28
|
+
* lista es la regla — si algún día entra otra, tiene que quitar y venir firmada por su
|
|
29
|
+
* sujeto, y se escribe aquí a propósito, no se cuela.
|
|
30
|
+
*
|
|
31
|
+
* JUNTAR DOS ES UNIR. Dos entradas nunca se contradicen porque las dos quitan, así que no
|
|
32
|
+
* hay conflictos ni hace falta un `seq`. Y **solo salen al absorberse** en el acta: nunca
|
|
33
|
+
* por reloj, o un aparato apagado perdería justo la que lo protege.
|
|
34
|
+
*/
|
|
35
|
+
import path from 'node:path'
|
|
36
|
+
import { verifyRenounce } from '@dotrino/identity/acta'
|
|
37
|
+
import { readJson, writeJson } from './paths.js'
|
|
38
|
+
|
|
39
|
+
/** Las operaciones que caben. La lista ES la regla: todas quitan, todas las firma su sujeto. */
|
|
40
|
+
export const OPS = Object.freeze(['renounce'])
|
|
41
|
+
|
|
42
|
+
/** Identidad de una entrada, para no guardar dos veces la misma. */
|
|
43
|
+
const idOf = (e) => `${e.op}|${e.member}|${[...(e.caps || [])].sort().join(',')}|${e.ts}`
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* ¿Es una entrada que se puede aceptar? Comprueba la FORMA y la FIRMA; que quite es
|
|
47
|
+
* estructural (el catálogo) y no una comprobación aparte.
|
|
48
|
+
*/
|
|
49
|
+
export async function isAcceptable (e) {
|
|
50
|
+
if (!e || !OPS.includes(e.op)) return false
|
|
51
|
+
if (e.op === 'renounce') return verifyRenounce(e).catch(() => false)
|
|
52
|
+
return false
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* La subacta de un perfil. Vive en su directorio y va cifrada en reposo como todo lo
|
|
57
|
+
* demás: sus entradas dicen qué aparatos se quedaron sin qué, y eso es material de la
|
|
58
|
+
* cuenta aunque no sea un secreto.
|
|
59
|
+
*/
|
|
60
|
+
export function openSubacta (dir, atRest) {
|
|
61
|
+
const file = path.join(dir, 'subacta.json')
|
|
62
|
+
let data = readJson(file, null, atRest) || { v: 1, entries: [] }
|
|
63
|
+
const guardar = () => writeJson(file, data, atRest)
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
/** Las entradas vigentes, tal cual se le pasan a `memberCan` y compañía. */
|
|
67
|
+
entries () { return [...data.entries] },
|
|
68
|
+
get count () { return data.entries.length },
|
|
69
|
+
|
|
70
|
+
/** Las de UN miembro. Es lo que se mira al decidir sobre él. */
|
|
71
|
+
forMember (pub) { return data.entries.filter((e) => e.member === pub) },
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Acepta una entrada. Devuelve `{ok, reason}` — y un `false` aquí no es un fallo del
|
|
75
|
+
* que la manda: puede ser que ya la tuviéramos, que es el caso normal cuando dos
|
|
76
|
+
* aparatos se cuentan lo mismo.
|
|
77
|
+
*/
|
|
78
|
+
async add (e) {
|
|
79
|
+
if (!(await isAcceptable(e))) return { ok: false, reason: 'no-aceptable' }
|
|
80
|
+
const id = idOf(e)
|
|
81
|
+
if (data.entries.some((x) => idOf(x) === id)) return { ok: false, reason: 'ya-estaba' }
|
|
82
|
+
data.entries = [...data.entries, e]
|
|
83
|
+
guardar()
|
|
84
|
+
return { ok: true, reason: 'nueva' }
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* SACA TODO para absorberlo en el acta, y vacía. Se llama con la bóveda ABIERTA, que es
|
|
89
|
+
* la única que puede sellar. Vaciar aquí y fallar el sellado perdería la entrada, así
|
|
90
|
+
* que quien llama tiene que absorber primero y vaciar después (`clear`).
|
|
91
|
+
*/
|
|
92
|
+
drain () { return [...data.entries] },
|
|
93
|
+
|
|
94
|
+
/** Vacía lo ya absorbido. Se le pasa lo que se absorbió, no «todo»: mientras se sella
|
|
95
|
+
* puede haber entrado una nueva, y tirarla sería perder una renuncia sin aplicar. */
|
|
96
|
+
clear (absorbidas) {
|
|
97
|
+
const ids = new Set(absorbidas.map(idOf))
|
|
98
|
+
data.entries = data.entries.filter((e) => !ids.has(idOf(e)))
|
|
99
|
+
guardar()
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
package/src/vault.js
CHANGED
|
@@ -15,7 +15,7 @@ import nodeCrypto from 'node:crypto'
|
|
|
15
15
|
import path from 'node:path'
|
|
16
16
|
import { Identity } from '@dotrino/identity/node'
|
|
17
17
|
import { verifyChain as verifyChainRaw, pubkeyId, verifyDeviceSig } from '@dotrino/identity/capabilities'
|
|
18
|
-
import * as
|
|
18
|
+
import * as ActaPilar from '@dotrino/identity/acta'
|
|
19
19
|
import { createEnrollDesk, deviceIdOf, DEVICE_TTL_MS } from '../lib/src/enroll.js'
|
|
20
20
|
import { createAdminDesk, authorBody } from '../lib/src/admin.js'
|
|
21
21
|
import { shouldNotifyRevoked } from '../lib/src/revocation.js'
|
|
@@ -27,6 +27,7 @@ import { assertKeyOwnsDir } from './keyowner.js'
|
|
|
27
27
|
import { openStore } from './store.js'
|
|
28
28
|
import { openThreadStore, STORE_READ_METHODS, PROFILE_EDIT_METHODS } from './threadStore.js'
|
|
29
29
|
import { openSecretsStore, assertVar, RECOVERY as RECOVERY_WRAP, PROFILE_OWNER } from './secretsStore.js'
|
|
30
|
+
import { openSubacta } from './subacta.js'
|
|
30
31
|
import { VERSION } from './version.js'
|
|
31
32
|
import { declare as compatDeclare, check as compatCheck, annotate as compatAnnotate } from '@dotrino/compat'
|
|
32
33
|
import { loadManifest, brokenOf } from '@dotrino/roadmap'
|
|
@@ -372,6 +373,55 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
372
373
|
/** El dictamen de un aparato, para enseñarlo o para pegárselo a un error. */
|
|
373
374
|
const versionDe = (pub) => versiones.get(pub) || null
|
|
374
375
|
|
|
376
|
+
// ----- LA SUBACTA: lo decidido que todavía no selló el acta (`src/subacta.js`) -----
|
|
377
|
+
//
|
|
378
|
+
// Sellar es de la maestra, así que con el perfil CERRADO una renuncia no surtía ningún
|
|
379
|
+
// efecto: el aparato conservaba todos sus permisos hasta que alguien fuera a la máquina
|
|
380
|
+
// a teclear la contraseña. Justo el caso que la renuncia existe para cubrir.
|
|
381
|
+
//
|
|
382
|
+
// Se honra ya, sin sellar, y es seguro porque una entrada de la subacta SOLO PUEDE
|
|
383
|
+
// QUITAR: no concede nada a nadie, y va firmada por el miembro al que afecta.
|
|
384
|
+
const subacta = openSubacta(dir, atRestFor(dir))
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* EL ACTA, LEÍDA CON LA SUBACTA EN LA MANO.
|
|
388
|
+
*
|
|
389
|
+
* Envuelve al pilar en UN sitio en vez de pasar la lista en los diecisiete mostradores:
|
|
390
|
+
* una comprobación repetida diecisiete veces son dieciséis sitios donde olvidarla, y
|
|
391
|
+
* olvidarla aquí significa atender a un aparato que ya renunció.
|
|
392
|
+
*
|
|
393
|
+
* Solo se tocan las cinco que DECIDEN. El resto del pilar pasa tal cual.
|
|
394
|
+
*/
|
|
395
|
+
const Acta = {
|
|
396
|
+
...ActaPilar,
|
|
397
|
+
memberCan: (acta, pub, cap) => ActaPilar.memberCan(acta, pub, cap, subacta.forMember(pub)),
|
|
398
|
+
memberCanScope: (acta, pub, scope) => ActaPilar.memberCanScope(acta, pub, scope, subacta.forMember(pub)),
|
|
399
|
+
memberCanReadSecrets: (acta, pub, ns) => ActaPilar.memberCanReadSecrets(acta, pub, ns, subacta.forMember(pub)),
|
|
400
|
+
memberCanSign: (acta, pub, ns = null) => ActaPilar.memberCanSign(acta, pub, ns, subacta.forMember(pub)),
|
|
401
|
+
memberScopes: (acta, pub) => ActaPilar.memberScopes(acta, pub, subacta.forMember(pub)),
|
|
402
|
+
effectiveCaps: (acta, pub) => ActaPilar.effectiveCaps(acta, pub, subacta.forMember(pub))
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* ABSORBER lo pendiente en el acta. Solo con la bóveda abierta, que es la única que
|
|
407
|
+
* sella. Se vacía DESPUÉS y solo lo absorbido: mientras se sella puede entrar una nueva,
|
|
408
|
+
* y tirarla sería perder una renuncia sin aplicar.
|
|
409
|
+
*/
|
|
410
|
+
async function absorberSubacta (motivo = 'unlock') {
|
|
411
|
+
const pendientes = subacta.drain()
|
|
412
|
+
if (!pendientes.length) return 0
|
|
413
|
+
const hechas = []
|
|
414
|
+
for (const e of pendientes) {
|
|
415
|
+
try { await identity.absorbRenounce(e); hechas.push(e) }
|
|
416
|
+
catch (err) { log(`[vault] ${motivo}: could not absorb a pending renounce: ${err.message}`) }
|
|
417
|
+
}
|
|
418
|
+
if (hechas.length) {
|
|
419
|
+
subacta.clear(hechas)
|
|
420
|
+
log(`[vault] ${motivo}: ${hechas.length} pending renounce(s) absorbed into the record`)
|
|
421
|
+
}
|
|
422
|
+
return hechas.length
|
|
423
|
+
}
|
|
424
|
+
|
|
375
425
|
/**
|
|
376
426
|
* LA VERSIÓN SE APUNTA DONDE YA SE COMPRUEBA LA FIRMA, y por eso esto envuelve a
|
|
377
427
|
* `verifyChain` en vez de mirar el envoltorio del mensaje: `data` va firmado, así que
|
|
@@ -958,7 +1008,10 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
958
1008
|
deviceId: x.sub ? await deviceIdOf(x.sub) : null, sub: x.sub || null, label: x.label || '', scope: x.scope, exp: x.exp, nonce: x.nonce,
|
|
959
1009
|
running: x.sub ? versionDe(x.sub) : null
|
|
960
1010
|
})))
|
|
961
|
-
|
|
1011
|
+
// LA SUBACTA VIAJA CON EL ACTA. Es la mitad de «se comparte entre todos los
|
|
1012
|
+
// dispositivos»: sin esto, una renuncia sin sellar solo la respeta esta máquina.
|
|
1013
|
+
// Cada entrada va firmada por su sujeto, así que quien la recibe la verifica sola.
|
|
1014
|
+
reply(from, fitChain({ type: MSG.DEVICES_RESULT, devices, revoked, acta: record, chain, vault: MI_VERSION, subacta: subacta.entries() }))
|
|
962
1015
|
}
|
|
963
1016
|
|
|
964
1017
|
/**
|
|
@@ -2013,12 +2066,32 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2013
2066
|
return reply(from, { type: MSG.ERROR, error: 'renounce: that member no longer holds any of those capabilities' })
|
|
2014
2067
|
}
|
|
2015
2068
|
try {
|
|
2016
|
-
|
|
2069
|
+
// A LA SUBACTA PRIMERO, y aquí está el arreglo: desde este momento surte efecto,
|
|
2070
|
+
// esté la bóveda abierta o cerrada. Antes se intentaba sellar directamente, sellar es
|
|
2071
|
+
// de la maestra, y con el perfil cerrado la renuncia se perdía con un error — en el
|
|
2072
|
+
// único momento en que de verdad hace falta.
|
|
2073
|
+
const guardada = await subacta.add(record)
|
|
2074
|
+
if (!guardada.ok && guardada.reason === 'no-aceptable') {
|
|
2075
|
+
audit('rejected', { what: 'renounce', reason: 'no-aceptable' })
|
|
2076
|
+
return reply(from, { type: MSG.ERROR, error: 'renounce: the entry is not one this vault accepts' })
|
|
2077
|
+
}
|
|
2017
2078
|
const device = await deviceIdOf(record.member).catch(() => null)
|
|
2018
2079
|
audit('renounce', { device, caps: record.caps })
|
|
2019
|
-
log(`[vault] ${device} renounced: ${(record.caps || []).join(', ')}`)
|
|
2080
|
+
log(`[vault] ${device} renounced: ${(record.caps || []).join(', ')} — in effect now`)
|
|
2081
|
+
|
|
2082
|
+
// Y SE SELLA SI SE PUEDE. Con la bóveda abierta entra en el acta y lo respeta toda la
|
|
2083
|
+
// cuenta; cerrada se queda pendiente y lo absorbe el `unlock`. Las dos ramas son
|
|
2084
|
+
// correctas, así que el fallo al sellar NO es un error para quien renunció.
|
|
2085
|
+
let seq = null
|
|
2086
|
+
try {
|
|
2087
|
+
const r = await identity.absorbRenounce(record)
|
|
2088
|
+
subacta.clear([record])
|
|
2089
|
+
seq = r?.seq ?? null
|
|
2090
|
+
} catch (e) {
|
|
2091
|
+
log(`[vault] the renounce stays pending until the vault is opened (${e.message})`)
|
|
2092
|
+
}
|
|
2020
2093
|
await notifyMembers('renounce', { deviceId: device, caps: record.caps })
|
|
2021
|
-
reply(from, { type: MSG.RENOUNCE_RESULT, ok: true, seq:
|
|
2094
|
+
reply(from, { type: MSG.RENOUNCE_RESULT, ok: true, seq, pending: seq === null })
|
|
2022
2095
|
} catch (e) {
|
|
2023
2096
|
reply(from, { type: MSG.ERROR, error: 'renounce: ' + e.message })
|
|
2024
2097
|
}
|
|
@@ -3019,6 +3092,11 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
3019
3092
|
// aquí donde puede estrenar su llave de comunicación (ver `ensureCommKeyInActa`).
|
|
3020
3093
|
// Si falla, no se arrastra: abrir el perfil no puede depender de esto.
|
|
3021
3094
|
if (r?.locked === false) { try { await ensureCommKeyInActa() } catch (_) {} }
|
|
3095
|
+
// Y ABSORBER LO PENDIENTE. La subacta ya lo estaba honrando con el perfil cerrado —de
|
|
3096
|
+
// eso vive—, pero mientras siga ahí solo lo sabe esta máquina: al sellarlo, entra en
|
|
3097
|
+
// el acta y lo respeta toda la cuenta. Si falla no se arrastra: abrir el perfil no
|
|
3098
|
+
// puede depender de esto, y lo pendiente sigue surtiendo efecto igual.
|
|
3099
|
+
if (r?.locked === false) { try { await absorberSubacta('unlock') } catch (_) {} }
|
|
3022
3100
|
return { locked: r?.locked !== false }
|
|
3023
3101
|
},
|
|
3024
3102
|
// Forzar un empujón: lo usa `replica push` de la CLI y el smoke, para no depender
|
|
@@ -3117,7 +3195,14 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
3117
3195
|
return {
|
|
3118
3196
|
...r,
|
|
3119
3197
|
vault: MI_VERSION,
|
|
3120
|
-
|
|
3198
|
+
// Y LO QUE RENUNCIÓ Y NO SE HA SELLADO. Ya surte efecto —de eso vive la subacta—
|
|
3199
|
+
// pero si no se ve, el dueño mira la lista, lee los permisos del acta y cree que
|
|
3200
|
+
// ese aparato los tiene. Un freno que no se explica es otro silencio.
|
|
3201
|
+
members: (r?.members || []).map((m) => ({
|
|
3202
|
+
...m,
|
|
3203
|
+
running: versionDe(m.pub),
|
|
3204
|
+
renounced: subacta.forMember(m.pub).flatMap((e) => e.caps || [])
|
|
3205
|
+
}))
|
|
3121
3206
|
}
|
|
3122
3207
|
},
|
|
3123
3208
|
// Los namespaces que quedaron a deber una rotación (se fue un miembro y no se pudo
|