@dotrino/vaultd 0.7.4 → 0.7.6
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 +6 -3
- package/lib/src/enroll.js +20 -4
- package/package.json +1 -1
- package/src/daemon.js +7 -3
- package/src/manager.js +37 -0
- package/src/tui/app.js +19 -4
- package/src/tui/i18n.js +6 -4
- package/src/vault.js +2 -0
- package/src/vaultControl.js +1 -1
package/README.md
CHANGED
|
@@ -100,8 +100,10 @@ raw mode).
|
|
|
100
100
|
los dispositivos/variables que estás viendo:
|
|
101
101
|
|
|
102
102
|
1. **Bóvedas** es la pantalla de entrada: lista tus perfiles (`↑↓` mover, `Enter`
|
|
103
|
-
**entrar** a uno — lo activa si no lo estaba). Ahí también
|
|
104
|
-
|
|
103
|
+
**entrar** a uno — lo activa si no lo estaba). Ahí también **conectas un
|
|
104
|
+
dispositivo** con `p` (sin entrar: activa la bóveda elegida y abre la pregunta
|
|
105
|
+
de a qué cuenta entra), creas una bóveda nueva, renombras, borras y
|
|
106
|
+
pones/quitas/usas la contraseña (candado).
|
|
105
107
|
2. Al entrar caes en sus **pestañas horizontales**, que cambias con `←→`:
|
|
106
108
|
- **Dispositivos (pares):** verlos, **emparejar** uno nuevo, **aprobar** con el
|
|
107
109
|
código que muestra el dispositivo, **rechazar** y **revocar**. Al emparejar, la
|
|
@@ -132,7 +134,8 @@ en español (solo se traduce la palabra que las explica en la barra de ayuda).
|
|
|
132
134
|
| `n` | new — bóveda nueva / variable nueva | Bóvedas · Scopes |
|
|
133
135
|
| `r` | rename (Bóvedas) · refresh (Dispositivos/Scopes) · restart (Emparejar) | — |
|
|
134
136
|
| `d` | delete — borrar la bóveda | Bóvedas |
|
|
135
|
-
| `p` |
|
|
137
|
+
| `p` | **pair — conectar un dispositivo** (desde Bóvedas entra directo, sin `Enter`) | Bóvedas · Dispositivos |
|
|
138
|
+
| `c` | change password — poner/cambiar la contraseña | Bóvedas |
|
|
136
139
|
| `x` | quitar: contraseña · dispositivo pendiente · variable/scope | todas |
|
|
137
140
|
| `u` / `k` | unlock / locK — candado de la bóveda | Bóvedas |
|
|
138
141
|
| `a` | approve — aprobar el dispositivo | Dispositivos · Emparejar |
|
package/lib/src/enroll.js
CHANGED
|
@@ -108,13 +108,29 @@ export function createEnrollDesk ({
|
|
|
108
108
|
const reply = (to, obj) => { try { send(to, obj) } catch (e) { log('[vault] no se pudo responder:', e.message) } }
|
|
109
109
|
const isFresh = (d) => typeof d?.ts === 'number' && Math.abs(Date.now() - d.ts) <= FRESH_WINDOW_MS
|
|
110
110
|
|
|
111
|
-
/**
|
|
112
|
-
|
|
111
|
+
/**
|
|
112
|
+
* Inicia un emparejamiento: token + nonce de sesión. NO firma nada todavía.
|
|
113
|
+
*
|
|
114
|
+
* `mode` y `account` son LO QUE LA BÓVEDA DECLARA que va a pasar, y viajan en el QR
|
|
115
|
+
* para que el aparato pueda **decirlo antes de hacerlo** en vez de emparejar a
|
|
116
|
+
* ciegas (decisión V9 de `docs/vinculacion-de-cuentas.md`: pregunta el vault, el
|
|
117
|
+
* dispositivo muestra el proceso y sus consecuencias):
|
|
118
|
+
*
|
|
119
|
+
* · `mode: 'join'` → el dispositivo estrena una cuenta suya y entra a la de la
|
|
120
|
+
* bóveda. Es lo único que existe hoy.
|
|
121
|
+
* · `mode: 'adopt'` → la bóveda se quedaría con la cuenta que trae el aparato
|
|
122
|
+
* (camino A). Reservado: todavía no hay protocolo.
|
|
123
|
+
* · `account` → cómo se llama la cuenta de la bóveda, para nombrarla en el
|
|
124
|
+
* aviso. Es ORIENTATIVO (un nombre que puso su dueño); la
|
|
125
|
+
* identidad de verdad de la cuenta es `iss`.
|
|
126
|
+
*/
|
|
127
|
+
function startPairing ({ scope = defaultScope, ttlMs = defaultTtlMs, label = '', mode = 'join', account = '' } = {}) {
|
|
113
128
|
pending.clear() // uno a la vez: una sesión nueva supersede a la anterior
|
|
114
129
|
const token = randToken()
|
|
115
130
|
const sn = randToken()
|
|
116
|
-
|
|
117
|
-
|
|
131
|
+
const acct = String(account || '').slice(0, 40)
|
|
132
|
+
pending.set(token, { token, exp: Date.now() + PAIRING_TTL_MS, scope, ttlMs, label, sn, mode, account: acct, state: 'AWAITING_ENROLL' })
|
|
133
|
+
return { token, qr: { v: 2, iss, proxy, token, sn, m: mode, ...(acct ? { acct } : {}) }, expiresInMs: PAIRING_TTL_MS }
|
|
118
134
|
}
|
|
119
135
|
|
|
120
136
|
function stopPairing (token) { pending.delete(token) }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vaultd",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.6",
|
|
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/daemon.js
CHANGED
|
@@ -117,11 +117,13 @@ export async function runDaemon () {
|
|
|
117
117
|
const isService = typeof pairReq?.service === 'string' && pairReq.service
|
|
118
118
|
const scope = isService ? ['vault:secrets:' + pairReq.service] : ['vault:sign', 'vault:read', 'vault:store']
|
|
119
119
|
const label = pairReq?.label || (isService ? 'servicio:' + pairReq.service : 'cli')
|
|
120
|
-
const { qr, expiresInMs } = vault.startPairing({ scope, label, ttlMs: DEVICE_TTL_MS })
|
|
121
120
|
// `profile`/`profileName`: la CUENTA del vault a la que entra el dispositivo.
|
|
122
121
|
// Con varias bóvedas en el mismo daemon, el QR sale de UNA y quien empareja
|
|
123
|
-
// tiene que verlo (lo muestran la TUI y `dotrino-vault pair`).
|
|
122
|
+
// tiene que verlo (lo muestran la TUI y `dotrino-vault pair`). El nombre viaja
|
|
123
|
+
// TAMBIÉN dentro del QR (`acct`) para que el dispositivo pueda anunciar qué va
|
|
124
|
+
// a pasar antes de hacerlo (V9 de docs/vinculacion-de-cuentas.md).
|
|
124
125
|
const profileName = mgr.profiles.get(profileId)?.name || ''
|
|
126
|
+
const { qr, expiresInMs } = vault.startPairing({ scope, label, ttlMs: DEVICE_TTL_MS, mode: 'join', account: profileName })
|
|
125
127
|
writeJson(pairFile, { v: 2, qr, expiresAt: Date.now() + expiresInMs, profile: profileId, profileName })
|
|
126
128
|
// El token es un secreto efímero: no debe quedar en disco más allá de su
|
|
127
129
|
// vida. Se borra al VENCER (aquí) y al APROBARSE (abajo, consumido).
|
|
@@ -217,7 +219,9 @@ export async function runDaemon () {
|
|
|
217
219
|
rm(profileReqFile) // lleva la contraseña: fuera del disco cuanto antes
|
|
218
220
|
let extra = {}
|
|
219
221
|
try { extra = await handleProfileRequest(preq) }
|
|
220
|
-
|
|
222
|
+
// `code`: la TUI es bilingüe y traduce por código (un freno como el D12 tiene
|
|
223
|
+
// que leerse en el idioma de quien lo lee, no en el del daemon).
|
|
224
|
+
catch (e) { extra = { error: e.message, ...(e.code ? { code: e.code } : {}) }; console.error('[vault] perfil: %s', e.message) }
|
|
221
225
|
dumpProfiles(extra)
|
|
222
226
|
} else {
|
|
223
227
|
dumpProfiles()
|
package/src/manager.js
CHANGED
|
@@ -13,6 +13,28 @@ import { openProfiles } from './profiles.js'
|
|
|
13
13
|
import { installNodeGlobals } from './node-globals.js'
|
|
14
14
|
import { dataDir, ensureDir } from './paths.js'
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* D12 (`docs/acta-de-perfil.md`): la bóveda **no** borra una cuenta que ella manda si
|
|
18
|
+
* quedan otros miembros — antes tiene que pasarle el acta a un dispositivo conectado.
|
|
19
|
+
* Es D6 ("perder el master es perder la cuenta") leído al derecho: con más miembros,
|
|
20
|
+
* el que borra no la pierde solo para él.
|
|
21
|
+
*
|
|
22
|
+
* Pura a propósito (recibe el veredicto ya calculado) para poder probar la regla sin
|
|
23
|
+
* levantar un vault entero.
|
|
24
|
+
*/
|
|
25
|
+
export function assertCanRemove ({ isMaster, memberCount, name = '' }) {
|
|
26
|
+
if (!isMaster || memberCount <= 1) return true
|
|
27
|
+
const otros = memberCount - 1
|
|
28
|
+
const e = new Error(
|
|
29
|
+
`la cuenta "${name}" la manda esta bóveda y tiene ${otros} dispositivo(s) más: ` +
|
|
30
|
+
'pásale primero el mando a uno que esté conectado. Si la borras así, se quedan ' +
|
|
31
|
+
'con su llave y sin nadie que pueda volver a firmar el acta.'
|
|
32
|
+
)
|
|
33
|
+
e.code = 'MASTER_WITH_MEMBERS'
|
|
34
|
+
e.members = memberCount
|
|
35
|
+
throw e
|
|
36
|
+
}
|
|
37
|
+
|
|
16
38
|
export async function startVaultManager ({ root = dataDir(), proxyUrl, log = console.log, onEnrollChallenge } = {}) {
|
|
17
39
|
ensureDir(root)
|
|
18
40
|
// El keypair de transporte del proxy-client es del PROCESO, no de la identidad:
|
|
@@ -76,6 +98,21 @@ export async function startVaultManager ({ root = dataDir(), proxyUrl, log = con
|
|
|
76
98
|
/** Borra el perfil: cierra su conexión y elimina su maestra y sus datos. */
|
|
77
99
|
async remove (ref) {
|
|
78
100
|
const id = profiles.resolve(ref)
|
|
101
|
+
// FRENO D12 (acta-de-perfil.md): si esta bóveda MANDA la cuenta y quedan otros
|
|
102
|
+
// miembros, borrarla los deja con su llave y sin nadie que pueda volver a sellar
|
|
103
|
+
// el acta: la cuenta muere para todos, en silencio. Primero se le pasa el mando
|
|
104
|
+
// a un dispositivo conectado. (En el dispositivo no hay tal freno: allí borrar
|
|
105
|
+
// se lleva su llave y su copia, y la cuenta sigue viva donde vive el master.)
|
|
106
|
+
const v = running.get(id)
|
|
107
|
+
if (v) {
|
|
108
|
+
const [soyMaster, acta] = await Promise.all([
|
|
109
|
+
v.isMaster().catch(() => false),
|
|
110
|
+
v.profileMembers().catch(() => ({ members: [] }))
|
|
111
|
+
])
|
|
112
|
+
assertCanRemove({ isMaster: soyMaster, memberCount: (acta?.members || []).length, name: profiles.get(id)?.name || id })
|
|
113
|
+
} else {
|
|
114
|
+
log('[vault] perfil %s no está abierto: se borra sin poder comprobar su acta', id)
|
|
115
|
+
}
|
|
79
116
|
const res = profiles.remove(id) // valida: no es el único, no está bloqueado
|
|
80
117
|
try { running.get(id)?.close() } catch (_) {}
|
|
81
118
|
running.delete(id)
|
package/src/tui/app.js
CHANGED
|
@@ -18,8 +18,9 @@
|
|
|
18
18
|
* LAS TECLAS NO CAMBIAN CON EL IDIOMA: son mnemónicos en INGLÉS y son las mismas
|
|
19
19
|
* en español (lo que se traduce es la palabra que las explica en la barra de
|
|
20
20
|
* ayuda). new · rename · delete · password · unlock · locK · pair · approve ·
|
|
21
|
-
* reject · reVoke · refresh · back · language · quit.
|
|
22
|
-
*
|
|
21
|
+
* reject · reVoke · refresh · back · language · quit. Una tecla significa LO MISMO
|
|
22
|
+
* en todas las pantallas: `p` es SIEMPRE emparejar (también en Bóvedas, sin tener
|
|
23
|
+
* que entrar antes), el candado es `k` (la `l` es el idioma) y la contraseña `c`.
|
|
23
24
|
*/
|
|
24
25
|
import { execFile } from 'node:child_process'
|
|
25
26
|
import { createTerm, widthOf } from './term.js'
|
|
@@ -50,7 +51,8 @@ function humanErr (e, st) {
|
|
|
50
51
|
NO_REPLY: t.errNoReply,
|
|
51
52
|
NOT_APPLIED: t.errNotApplied,
|
|
52
53
|
NOT_DELETED: t.errNotDeleted,
|
|
53
|
-
PAIR_FAILED: t.errPairFailed
|
|
54
|
+
PAIR_FAILED: t.errPairFailed,
|
|
55
|
+
MASTER_WITH_MEMBERS: t.errMasterWithMembers
|
|
54
56
|
}
|
|
55
57
|
return byCode[e?.code] || e?.message || String(e)
|
|
56
58
|
}
|
|
@@ -317,6 +319,19 @@ async function onKeyProfiles (term, st, key) {
|
|
|
317
319
|
}
|
|
318
320
|
st.screen = 'devices'
|
|
319
321
|
await refreshDevices(term, st)
|
|
322
|
+
} else if (ch === 'p' && cur) {
|
|
323
|
+
// Emparejar SIN tener que entrar antes: `p` significa lo mismo aquí que en la
|
|
324
|
+
// pestaña Dispositivos. Se activa la bóveda elegida (el QR sale de UNA, y las
|
|
325
|
+
// acciones siguientes —aprobar, revocar— miran a la activa) y se abre la
|
|
326
|
+
// pregunta de a qué cuenta entra el dispositivo.
|
|
327
|
+
if (!cur.current) {
|
|
328
|
+
const r = await guard(term, st, i.switchingVault, () => vc.useProfile(cur.id))
|
|
329
|
+
if (!r.ok) return true
|
|
330
|
+
await refreshAll(term, st)
|
|
331
|
+
}
|
|
332
|
+
st.sel.pairmode = 0
|
|
333
|
+
st.scroll.pairmode = { value: 0 }
|
|
334
|
+
st.screen = 'pairmode'
|
|
320
335
|
} else if (ch === 'n') {
|
|
321
336
|
setInput(st, {
|
|
322
337
|
label: i.newVaultLabel,
|
|
@@ -354,7 +369,7 @@ async function onKeyProfiles (term, st, key) {
|
|
|
354
369
|
},
|
|
355
370
|
onCancel: () => { st.input = null }
|
|
356
371
|
}))
|
|
357
|
-
} else if (ch === '
|
|
372
|
+
} else if (ch === 'c' && cur) { // change password (la `p` es emparejar, igual que en Dispositivos)
|
|
358
373
|
await ensureUnlocked(term, st, cur, (p = cur) => setInput(st, {
|
|
359
374
|
label: i.newPasswordLabel(p.name || p.id),
|
|
360
375
|
mask: true,
|
package/src/tui/i18n.js
CHANGED
|
@@ -151,7 +151,7 @@ const es = {
|
|
|
151
151
|
// INGLÉS: new/rename/delete/password/unlock/locK/pair/approve/revoke/refresh/
|
|
152
152
|
// language/quit); lo único que se traduce es la palabra que las explica.
|
|
153
153
|
// Segmentos, no una línea: el render recorta del medio si no caben.
|
|
154
|
-
helpProfiles: ['↑↓', 'Enter entrar', 'n nueva', 'r renombrar', 'd borrar', '
|
|
154
|
+
helpProfiles: ['↑↓', 'Enter entrar', 'p emparejar', 'n nueva', 'r renombrar', 'd borrar', 'c clave', 'x quitar-clave', 'u desbloq', 'k bloquear', 'l English', 'q salir'],
|
|
155
155
|
helpDevices: ['←→ pestaña', '↑↓', 'p emparejar', 'a aprobar', 'x rechazar', 'v revocar', 'r refrescar', 'Esc bóvedas', 'l English', 'q salir'],
|
|
156
156
|
helpSecrets: ['←→ pestaña', '↑↓', 'n nueva variable', 'x quitar (variable/scope)', 'r refrescar', 'Esc bóvedas', 'l English', 'q salir'],
|
|
157
157
|
helpPairing: ['a aprobar', 'x rechazar', 'r reiniciar', 'Esc atrás', 'l English'],
|
|
@@ -179,7 +179,8 @@ const es = {
|
|
|
179
179
|
errNoReply: 'El daemon no respondió.',
|
|
180
180
|
errNotApplied: 'El daemon no aplicó el cambio (revisa los logs del servicio).',
|
|
181
181
|
errNotDeleted: 'El daemon no borró la variable (revisa los logs del servicio).',
|
|
182
|
-
errPairFailed: 'El daemon no inició el emparejamiento.'
|
|
182
|
+
errPairFailed: 'El daemon no inició el emparejamiento.',
|
|
183
|
+
errMasterWithMembers: 'Esta cuenta la manda esta bóveda y tiene otros dispositivos: pásale primero el mando a uno que esté conectado. Si la borras así, se quedan con su llave y sin nadie que pueda volver a firmar el acta.'
|
|
183
184
|
}
|
|
184
185
|
|
|
185
186
|
// ---------------------------------- inglés ----------------------------------
|
|
@@ -307,7 +308,7 @@ const en = {
|
|
|
307
308
|
helpInput: 'Enter confirm · Esc cancel · Ctrl-U clear',
|
|
308
309
|
helpConfirm: 'y confirm · n/Esc cancel',
|
|
309
310
|
|
|
310
|
-
helpProfiles: ['↑↓', 'Enter open', 'n new', 'r rename', 'd delete', '
|
|
311
|
+
helpProfiles: ['↑↓', 'Enter open', 'p pair', 'n new', 'r rename', 'd delete', 'c password', 'x drop-password', 'u unlock', 'k lock', 'l Español', 'q quit'],
|
|
311
312
|
helpDevices: ['←→ tab', '↑↓', 'p pair', 'a approve', 'x reject', 'v revoke', 'r refresh', 'Esc vaults', 'l Español', 'q quit'],
|
|
312
313
|
helpSecrets: ['←→ tab', '↑↓', 'n new variable', 'x remove (variable/scope)', 'r refresh', 'Esc vaults', 'l Español', 'q quit'],
|
|
313
314
|
helpPairing: ['a approve', 'x reject', 'r restart', 'Esc back', 'l Español'],
|
|
@@ -333,7 +334,8 @@ const en = {
|
|
|
333
334
|
errNoReply: 'The daemon did not answer.',
|
|
334
335
|
errNotApplied: 'The daemon did not apply the change (check the service logs).',
|
|
335
336
|
errNotDeleted: 'The daemon did not delete the variable (check the service logs).',
|
|
336
|
-
errPairFailed: 'The daemon did not start the pairing.'
|
|
337
|
+
errPairFailed: 'The daemon did not start the pairing.',
|
|
338
|
+
errMasterWithMembers: 'This vault is in charge of this account and it has other devices: hand the lead over to one that is online first. If you delete it like this, they keep their key with nobody able to sign the record again.'
|
|
337
339
|
}
|
|
338
340
|
|
|
339
341
|
// --------------------------- selección y persistencia -----------------------
|
package/src/vault.js
CHANGED
|
@@ -296,6 +296,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
296
296
|
// Acta del perfil (quién es del perfil y qué puede cada uno): lo que muestran
|
|
297
297
|
// `dotrino-vault members` y la consola de vault.dotrino.com.
|
|
298
298
|
profileMembers: () => identity.profileMembers(),
|
|
299
|
+
// ¿Es ESTA bóveda la que sella el acta? Lo usa el freno de borrado (D12).
|
|
300
|
+
isMaster: () => identity.isMaster(),
|
|
299
301
|
setCaps: (pub, caps) => identity.setCaps(pub, caps),
|
|
300
302
|
revokeDevice: (nonce) => desk.revoke(nonce),
|
|
301
303
|
close () { try { client.close() } catch (_) {} identity.destroy() }
|
package/src/vaultControl.js
CHANGED
|
@@ -149,7 +149,7 @@ async function profileOp (op, { profile, name, password } = {}) {
|
|
|
149
149
|
signalOrCleanup('SIGUSR2', [F.profileReq])
|
|
150
150
|
const d = await waitFor(F.profilesList)
|
|
151
151
|
if (!d) throw coded('el daemon no respondió', 'NO_REPLY')
|
|
152
|
-
if (d.error) throw
|
|
152
|
+
if (d.error) throw coded(d.error, d.code) // p.ej. MASTER_WITH_MEMBERS (freno D12)
|
|
153
153
|
return d // { profiles:[{id,name,protected,locked,current,fingerprint,iss,createdAt}], current, done? }
|
|
154
154
|
}
|
|
155
155
|
|