@dotrino/vaultd 0.111.1 → 0.112.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/README.md +1 -0
- package/lib/src/index.js +2 -3
- package/lib/src/localdesk.js +8 -0
- package/lib/src/service.js +2 -3
- package/package.json +2 -2
- package/src/client.js +2 -3
- package/src/replica.js +2 -2
- package/src/transport.js +9 -7
- package/src/tui/app.js +29 -18
- package/src/vault.js +30 -2
package/README.md
CHANGED
|
@@ -846,6 +846,7 @@ ed25519 en formato OpenSSH (sin frase: la bóveda es el candado) y RSA/P-256 en
|
|
|
846
846
|
- [`acta-de-perfil.md`](./docs/acta-de-perfil.md) — el modelo vigente: un perfil es un conjunto de llaves con un acta firmada por un solo sellador.
|
|
847
847
|
- [`pairing-protocol.md`](./docs/pairing-protocol.md) — el emparejamiento endurecido: por qué el token dejó de ser autoridad suficiente.
|
|
848
848
|
- [`vinculacion-de-cuentas.md`](./docs/vinculacion-de-cuentas.md) — los dos caminos al conectar un aparato, y por qué no existe fusionar cuentas.
|
|
849
|
+
- [`inicio-de-sesion.md`](./docs/inicio-de-sesion.md) — **entrar sin enrolar**: las sesiones (papel con vencimiento, respaldado por un aparato tuyo) y el orden hacia el inicio de sesión federado.
|
|
849
850
|
- [`store-identity-architecture.md`](./docs/store-identity-architecture.md) — por qué la bóveda del PC hace de store además de identidad.
|
|
850
851
|
|
|
851
852
|
Sin anuncios, sin cuentas, sin rastreo. MIT · parte de Dotrino.
|
package/lib/src/index.js
CHANGED
|
@@ -97,9 +97,8 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
|
|
|
97
97
|
const selfCert = await getSelfCert()
|
|
98
98
|
const identify = async () => {
|
|
99
99
|
if (!client.token) return
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
await client.identify({ data, signature, cert: selfCert })
|
|
100
|
+
// El sobre lo arma el pilar (`identifyAs`), que le pone el destinatario.
|
|
101
|
+
await client.identifyAs({ publickey: iss, sign: (d) => identity.signData(d), cert: selfCert })
|
|
103
102
|
}
|
|
104
103
|
await identify()
|
|
105
104
|
client.on('token', () => identify().catch(() => {}))
|
package/lib/src/localdesk.js
CHANGED
|
@@ -85,7 +85,15 @@ export async function connectLocal ({ masterId, timeoutMs = 3000 } = {}) {
|
|
|
85
85
|
// El mostrador local no pide identificarse: la puerta la abre el certificado que va
|
|
86
86
|
// dentro de cada mensaje, igual que por el proxio. Identificarse ahí sirve para ser
|
|
87
87
|
// DIRECCIONABLE, y aquí no hay a quién direccionar.
|
|
88
|
+
//
|
|
89
|
+
// LAS DOS FORMAS, porque quien llama no sabe por dónde salió. `freshClient` devuelve
|
|
90
|
+
// esto o un `WebSocketProxyClient` según haya socket o no, y el de arriba llama al
|
|
91
|
+
// método sin más: si aquí falta el que el pilar usa ahora, el escalón 1 del transporte
|
|
92
|
+
// —el más rápido, y el que corre en la máquina del dueño— muere con «client.identifyAs
|
|
93
|
+
// is not a function» mientras por el proxio todo va bien. Que sea un no-op no lo hace
|
|
94
|
+
// opcional: es la forma del cliente, y tiene que estar entera.
|
|
88
95
|
identify: async () => ({ ok: true }),
|
|
96
|
+
identifyAs: async () => ({ ok: true }),
|
|
89
97
|
on: (ev, fn) => {
|
|
90
98
|
if (ev !== 'message') return () => {}
|
|
91
99
|
oyentes.add(fn)
|
package/lib/src/service.js
CHANGED
|
@@ -199,9 +199,8 @@ async function freshClient (proxyUrl, connectTimeoutMs = 20000, master = null) {
|
|
|
199
199
|
|
|
200
200
|
/** Identifica la conexión bajo la pubkey del servicio (para ser direccionable). */
|
|
201
201
|
async function identifyAsService (client, device) {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
await client.identify({ data, signature })
|
|
202
|
+
// El sobre lo arma el pilar (`identifyAs`), que le pone el destinatario.
|
|
203
|
+
await client.identifyAs({ publickey: device.publickey, sign: (d) => signWithDevice({ privateJwk: device.privateJwk, data: d }) })
|
|
205
204
|
}
|
|
206
205
|
|
|
207
206
|
/** Cuánto espera un agente a que alguien apruebe (el pedido vive 5 min en la bóveda). */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vaultd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.112.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": {
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"@dotrino/compat": "0.4.0",
|
|
25
25
|
"@dotrino/identity": "0.81.0",
|
|
26
26
|
"@dotrino/passmanager": "^0.10.1",
|
|
27
|
-
"@dotrino/proxy-client": "0.
|
|
27
|
+
"@dotrino/proxy-client": "0.18.2",
|
|
28
28
|
"@dotrino/roadmap": "0.2.0",
|
|
29
29
|
"ws": "^8.18.0"
|
|
30
30
|
},
|
package/src/client.js
CHANGED
|
@@ -40,9 +40,8 @@ async function freshClient ({ proxyUrl, dir = '.dotrino-vault-device' }) {
|
|
|
40
40
|
/** Identifica la conexión bajo la pubkey de dispositivo D (para ser direccionable). */
|
|
41
41
|
async function identifyAsDevice (client, device) {
|
|
42
42
|
if (!client.token) return
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
await client.identify({ data, signature })
|
|
43
|
+
// El sobre lo arma el pilar (`identifyAs`), que le pone el destinatario.
|
|
44
|
+
await client.identifyAs({ publickey: device.publickey, sign: (d) => signWithDevice({ privateJwk: device.privateJwk, data: d }) })
|
|
46
45
|
}
|
|
47
46
|
|
|
48
47
|
function waitFor (client, predicate, timeoutMs = 30000) {
|
package/src/replica.js
CHANGED
|
@@ -132,8 +132,8 @@ export async function runReplica ({ dir = replicaDir(), proxyUrl, log = console.
|
|
|
132
132
|
|
|
133
133
|
const client = new WebSocketProxyClient({ url })
|
|
134
134
|
await client.connect()
|
|
135
|
-
|
|
136
|
-
await client.
|
|
135
|
+
// El sobre lo arma el pilar (`identifyAs`), que le pone el destinatario.
|
|
136
|
+
await client.identifyAs({ publickey: link.device.publickey, sign: (d) => firmar(d) })
|
|
137
137
|
log(`[replica] ${id} · connected to ${url}`)
|
|
138
138
|
log(`[replica] record #${store.seq}${store.acta ? '' : ' · no record yet: waiting for the vault'}`)
|
|
139
139
|
|
package/src/transport.js
CHANGED
|
@@ -87,10 +87,9 @@ export async function createTransport ({ identity, dir, url = DEFAULT_PROXY, com
|
|
|
87
87
|
const esMiembro = comm && (record?.members || []).some((m) => m?.pub === comm)
|
|
88
88
|
|
|
89
89
|
if (comm && esMiembro) {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
await client.identify({ data, signature, acta: record })
|
|
90
|
+
// El sobre lo arma el pilar (`identifyAs`), que le pone el destinatario.
|
|
91
|
+
try {
|
|
92
|
+
await client.identifyAs({ publickey: comm, sign: (d) => commKey.sign(d), acta: record })
|
|
94
93
|
// SE DICE QUE SE IDENTIFICÓ, y con qué llave. Este camino era MUDO: al pasar de la
|
|
95
94
|
// maestra a la llave de comunicación, el único rastro de «estoy alcanzable» que
|
|
96
95
|
// quedaba en el log era el del repliegue. Así que cuando la consola decía «no
|
|
@@ -98,6 +97,11 @@ export async function createTransport ({ identity, dir, url = DEFAULT_PROXY, com
|
|
|
98
97
|
// o no. Una línea por reconexión es barata; no tenerla cuesta una hora de sondeos.
|
|
99
98
|
log(`[vault] identified on the proxy with the communication key · record #${record?.seq ?? '?'}`)
|
|
100
99
|
return
|
|
100
|
+
} catch (e) {
|
|
101
|
+
// SOLO «esta llave no firma» cae al camino de abajo, y se distingue por el `code`.
|
|
102
|
+
// Un fallo de red no es eso: tragárselo aquí mandaría a la maestra a firmar por un
|
|
103
|
+
// problema pasajero, que es justo lo que la llave de comunicación vino a evitar.
|
|
104
|
+
if (e?.code !== 'no-signature') throw e
|
|
101
105
|
}
|
|
102
106
|
}
|
|
103
107
|
|
|
@@ -118,10 +122,8 @@ export async function createTransport ({ identity, dir, url = DEFAULT_PROXY, com
|
|
|
118
122
|
try {
|
|
119
123
|
const publickey = await masterPubkeyOf(identity)
|
|
120
124
|
if (!publickey) return
|
|
121
|
-
const data = { op: 'identify', publickey, token: client.token, ts: Date.now() }
|
|
122
|
-
const { signature } = await identity.signData(data)
|
|
123
125
|
log('[vault] identifying with the master key: this vault is not in its own record yet (open the profile once to fix it)')
|
|
124
|
-
await client.
|
|
126
|
+
await client.identifyAs({ publickey, sign: (d) => identity.signData(d), acta: record })
|
|
125
127
|
} catch (e) {
|
|
126
128
|
log(`[vault] cannot identify on the proxy yet: ${e.message} — the profile still opens; unlock it once and it gets its own communication key`)
|
|
127
129
|
}
|
package/src/tui/app.js
CHANGED
|
@@ -1333,7 +1333,9 @@ async function onKeySecrets (term, st, key) {
|
|
|
1333
1333
|
} else if (ch === 't' && cur?.key) {
|
|
1334
1334
|
await toggleVisibility(term, st, cur.public, () => vc.setSecretVisibility(cur.ns, cur.key, !cur.public, activeId(st), activePwd(st)))
|
|
1335
1335
|
} else if (ch === 'r' && cur?.key) {
|
|
1336
|
-
await
|
|
1336
|
+
// SIN `await`: el prompt solo ABRE el cuadro de entrada y vuelve. Esperarlo aquí
|
|
1337
|
+
// congelaba la TUI entera — ver `promptRenameVariable`.
|
|
1338
|
+
promptRenameVariable(term, st, cur)
|
|
1337
1339
|
} else if (key.name === 'f5') {
|
|
1338
1340
|
await refreshSecrets(term, st)
|
|
1339
1341
|
}
|
|
@@ -1349,24 +1351,33 @@ async function onKeySecrets (term, st, key) {
|
|
|
1349
1351
|
* cambio de configuración, así que se va a reiniciar. Si su código busca el nombre viejo,
|
|
1350
1352
|
* dejará de encontrarlo — y eso no lo puede saber la bóveda.
|
|
1351
1353
|
*/
|
|
1354
|
+
/**
|
|
1355
|
+
* ABRE EL CUADRO Y VUELVE. No devuelve una promesa que espere a que el usuario escriba, y
|
|
1356
|
+
* eso no es estilo: era un INTERBLOQUEO que congelaba la TUI entera.
|
|
1357
|
+
*
|
|
1358
|
+
* Quien escribe es `onInputKey`, y a `onInputKey` solo se llega desde el bucle de teclas.
|
|
1359
|
+
* Devolver aquí una promesa que solo resuelve en `onSubmit` dejaba al bucle esperándola —
|
|
1360
|
+
* o sea, esperando a una tecla que no iba a leer nunca, porque el que lee es él. A partir
|
|
1361
|
+
* de ahí no respondía a nada: ni Enter, ni Esc, ni Ctrl-C, que también se atienden ahí. La
|
|
1362
|
+
* única salida era cerrar la terminal, y es lo que le tocó hacer al dueño el 2026-09-05.
|
|
1363
|
+
*
|
|
1364
|
+
* Los otros veinte prompts de esta TUI ya eran así; este era el único que no.
|
|
1365
|
+
*/
|
|
1352
1366
|
function promptRenameVariable (term, st, cur) {
|
|
1353
1367
|
const i = L(st)
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
},
|
|
1368
|
-
onCancel: () => { st.input = null; listo() }
|
|
1369
|
-
})
|
|
1368
|
+
setInput(st, {
|
|
1369
|
+
label: i.renameLabel(cur.key),
|
|
1370
|
+
hint: i.renameHint,
|
|
1371
|
+
value: cur.key,
|
|
1372
|
+
onSubmit: async (nuevo) => {
|
|
1373
|
+
const nv = nuevo.trim()
|
|
1374
|
+
st.input = null
|
|
1375
|
+
if (!nv || nv === cur.key) return
|
|
1376
|
+
if (!KEY_RE.test(nv)) { flash(st, i.keyInvalid, 'danger'); return }
|
|
1377
|
+
const r = await guard(term, st, i.renaming, () => vc.renameSecret(cur.ns, cur.key, nv, activeId(st)))
|
|
1378
|
+
if (r.ok) { flash(st, i.renamed(cur.key, nv)); st.secrets = r.v }
|
|
1379
|
+
},
|
|
1380
|
+
onCancel: () => { st.input = null }
|
|
1370
1381
|
})
|
|
1371
1382
|
}
|
|
1372
1383
|
|
|
@@ -1971,4 +1982,4 @@ export async function runTui () {
|
|
|
1971
1982
|
}
|
|
1972
1983
|
|
|
1973
1984
|
// Solo para pruebas headless (render sin terminal real). No usar en runtime.
|
|
1974
|
-
export const __test = { render, activeLocked, autoLockedIds, autoLockWakeIn, forgetAutoLocked, autoLockMin, refreshAll, ensureUnlocked, profileRows, deviceRows, secretRows, devVarRows, meRows, capsRows, onKeyCaps, pairModeRows, pairingBody, scrollBody, fitHelp, toggleLang, mergeMembersAndCerts, seguirAqui, resetToque: () => { ultimoToque = 0 } }
|
|
1985
|
+
export const __test = { render, onKeySecrets, activeLocked, autoLockedIds, autoLockWakeIn, forgetAutoLocked, autoLockMin, refreshAll, ensureUnlocked, profileRows, deviceRows, secretRows, devVarRows, meRows, capsRows, onKeyCaps, pairModeRows, pairingBody, scrollBody, fitHelp, toggleLang, mergeMembersAndCerts, seguirAqui, resetToque: () => { ultimoToque = 0 } }
|
package/src/vault.js
CHANGED
|
@@ -306,7 +306,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
306
306
|
}
|
|
307
307
|
await ensureCommKeyInActa()
|
|
308
308
|
|
|
309
|
-
const { client } = await createTransport({ identity, dir, url: proxyUrl, commKey, log })
|
|
309
|
+
const { client, identify: reidentificar } = await createTransport({ identity, dir, url: proxyUrl, commKey, log })
|
|
310
310
|
|
|
311
311
|
// El registro público de cadenas de selladores: deposita, si hay a dónde, los eslabones
|
|
312
312
|
// que le dicen a un tercero si esta cuenta sigue sellada por quien él cree. Ver
|
|
@@ -1263,6 +1263,27 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1263
1263
|
if (!publicOnly && needsApproval(chk.device, record)) {
|
|
1264
1264
|
const deviceId = await deviceIdOf(chk.device).catch(() => null)
|
|
1265
1265
|
const label = (record?.members || []).find((m) => m.pub === chk.device)?.label || ''
|
|
1266
|
+
// NADIE PUEDE APROBAR = SE DICE AHORA, no se deja esperando cinco minutos.
|
|
1267
|
+
//
|
|
1268
|
+
// La bóveda ya lo sabía —lo escribía en su propio log, «rang 0 approver(s)»— y aun
|
|
1269
|
+
// así contestaba «pendiente» y dejaba al otro aguardando a algo que no podía pasar.
|
|
1270
|
+
// Eso es un fallo ruidoso convertido en uno mudo, que es exactamente lo que la norma
|
|
1271
|
+
// prohíbe (CLAUDE.md, «nada de repliegues»), y encima el que sabe es el ÚNICO que
|
|
1272
|
+
// puede enterar al otro (CONVENCIONES §14): el que pide no tiene el acta, así que no
|
|
1273
|
+
// hay forma de que lo averigüe por su cuenta.
|
|
1274
|
+
//
|
|
1275
|
+
// Lo pidió el dueño el 2026-09-05, después de mirar un `dotrino-env check` que parecía
|
|
1276
|
+
// colgado y era esto.
|
|
1277
|
+
const quienAprueba = (record?.members || []).filter((m) => Acta.memberCan(record, m.pub, 'approve'))
|
|
1278
|
+
if (!quienAprueba.length) {
|
|
1279
|
+
audit('rejected', { what: 'secrets', ns, reason: 'sin-aprobador' })
|
|
1280
|
+
log(`[vault] ${ns}: refused ${deviceId || '????-????'} — nobody in the record can approve`)
|
|
1281
|
+
return reply(from, {
|
|
1282
|
+
type: MSG.ERROR,
|
|
1283
|
+
code: 'no-approver',
|
|
1284
|
+
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\``
|
|
1285
|
+
})
|
|
1286
|
+
}
|
|
1266
1287
|
// `from` SE GUARDA: la respuesta llega más tarde —cuando alguien apruebe— y tiene que
|
|
1267
1288
|
// volver POR DONDE VINO. Si la pregunta entró por el mostrador local, mandarla por el
|
|
1268
1289
|
// proxio la deja en el vacío: quien preguntó está escuchando en el socket, no ahí.
|
|
@@ -3181,7 +3202,14 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
3181
3202
|
// ABRIR ES EL ÚNICO MOMENTO en que un perfil con contraseña está abierto, así que es
|
|
3182
3203
|
// aquí donde puede estrenar su llave de comunicación (ver `ensureCommKeyInActa`).
|
|
3183
3204
|
// Si falla, no se arrastra: abrir el perfil no puede depender de esto.
|
|
3184
|
-
|
|
3205
|
+
//
|
|
3206
|
+
// Y SE VUELVE A IDENTIFICAR EN EL ACTO: `identify` solo corre al conectar y al cambiar
|
|
3207
|
+
// el token, así que estrenar la llave no servía de nada hasta el siguiente arranque —
|
|
3208
|
+
// la bóveda seguía muda en el proxio con su llave ya admitida en el acta. Al dueño le
|
|
3209
|
+
// pasó el 2026-09-05: hizo lo que el log pedía y la consola siguió sin contestar.
|
|
3210
|
+
if (r?.locked === false) {
|
|
3211
|
+
try { if (await ensureCommKeyInActa()) await reidentificar?.() } catch (_) {}
|
|
3212
|
+
}
|
|
3185
3213
|
// Y ABSORBER LO PENDIENTE. La subacta ya lo estaba honrando con el perfil cerrado —de
|
|
3186
3214
|
// eso vive—, pero mientras siga ahí solo lo sabe esta máquina: al sellarlo, entra en
|
|
3187
3215
|
// el acta y lo respeta toda la cuenta. Si falla no se arrastra: abrir el perfil no
|