@dotrino/vaultd 0.7.3 → 0.7.5
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/ctl.js +17 -0
- package/src/daemon.js +10 -4
- package/src/manager.js +37 -0
- package/src/tui/app.js +85 -8
- package/src/tui/i18n.js +29 -3
- package/src/vault.js +2 -0
- package/src/vaultControl.js +1 -1
package/README.md
CHANGED
|
@@ -103,9 +103,12 @@ los dispositivos/variables que estás viendo:
|
|
|
103
103
|
**entrar** a uno — lo activa si no lo estaba). Ahí también creas una bóveda
|
|
104
104
|
nueva, renombras, borras y pones/quitas/usas la contraseña (candado).
|
|
105
105
|
2. Al entrar caes en sus **pestañas horizontales**, que cambias con `←→`:
|
|
106
|
-
- **Dispositivos (pares):** verlos, **emparejar** uno nuevo
|
|
107
|
-
|
|
108
|
-
|
|
106
|
+
- **Dispositivos (pares):** verlos, **emparejar** uno nuevo, **aprobar** con el
|
|
107
|
+
código que muestra el dispositivo, **rechazar** y **revocar**. Al emparejar, la
|
|
108
|
+
bóveda **pregunta primero a qué cuenta entra el dispositivo** —a esta, o a una
|
|
109
|
+
cuenta nueva que se estrena para él— y recién después muestra el QR, que además
|
|
110
|
+
dice de qué cuenta salió. (En la CLI: `dotrino-vault pair --new-account
|
|
111
|
+
[nombre]`.)
|
|
109
112
|
- **Scopes y variables (secretos):** ver los scopes y sus variables (nunca los
|
|
110
113
|
valores), **agregar** una variable (con su scope) y **quitar** una variable o
|
|
111
114
|
un scope entero.
|
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.5",
|
|
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/ctl.js
CHANGED
|
@@ -153,6 +153,20 @@ async function cmdPair (args = []) {
|
|
|
153
153
|
console.error('uso: dotrino-vault pair --service <ns> (ns en minúsculas, p.ej. proxy)'); process.exit(2)
|
|
154
154
|
}
|
|
155
155
|
}
|
|
156
|
+
// `--new-account [nombre]`: la otra respuesta a «¿a qué cuenta entra?». En vez de
|
|
157
|
+
// meter el dispositivo en una cuenta que ya vive aquí, se ESTRENA una (vacía) y
|
|
158
|
+
// entra a ella; las demás no se tocan. En la TUI esto es una pregunta con sus
|
|
159
|
+
// opciones; en la CLI es una bandera, para que siga sirviendo en un script.
|
|
160
|
+
const naIdx = args.findIndex((a) => a === '--new-account')
|
|
161
|
+
if (naIdx >= 0) {
|
|
162
|
+
const next = args[naIdx + 1]
|
|
163
|
+
const name = (next && !next.startsWith('-')) ? next : `cuenta ${new Date().toISOString().slice(0, 10)}`
|
|
164
|
+
const d = await profileRequest('add', { name })
|
|
165
|
+
if (d.error) { console.error('%s', d.error); process.exit(1) }
|
|
166
|
+
if (!d.id) { console.error('El daemon no dijo qué cuenta creó.'); process.exit(1) }
|
|
167
|
+
PROFILE = d.id // el emparejamiento y los comandos siguientes apuntan a ELLA
|
|
168
|
+
console.log('Cuenta nueva: %s (%s)', name, d.id)
|
|
169
|
+
}
|
|
156
170
|
// La petición se escribe SIEMPRE (aunque no haya --service): lleva a qué perfil
|
|
157
171
|
// se empareja el dispositivo.
|
|
158
172
|
writeReq('pair-request.json', service ? { service } : {})
|
|
@@ -539,6 +553,9 @@ function help () {
|
|
|
539
553
|
tui interfaz de terminal a pantalla completa (bóvedas, pares, secretos)
|
|
540
554
|
status estado del servicio + fingerprint
|
|
541
555
|
pair [--save <f>] inicia un emparejamiento (QR + espera); --save escribe la invitación (.dpair)
|
|
556
|
+
pair --new-account [nombre]
|
|
557
|
+
estrena una cuenta VACÍA en este vault y mete ahí al dispositivo
|
|
558
|
+
(sin la bandera entra a la cuenta activa, o a la de --profile)
|
|
542
559
|
pair --service <ns> empareja un SERVICIO (proxy, geo…) con acceso SOLO a sus secretos
|
|
543
560
|
secret set <ns> <CLAVE> <valor> guarda un secreto para el servicio <ns>
|
|
544
561
|
secret rm <ns> <CLAVE> borra un secreto
|
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).
|
|
@@ -156,7 +158,9 @@ export async function runDaemon () {
|
|
|
156
158
|
const ref = () => mgr.resolve(req.profile || mgr.currentId())
|
|
157
159
|
switch (req.op) {
|
|
158
160
|
case 'list': return {} // el volcado de perfiles ya se hace abajo
|
|
159
|
-
|
|
161
|
+
// `id`: quien la crea necesita saber CUÁL quedó, no adivinar por nombre (dos
|
|
162
|
+
// cuentas pueden llamarse igual). Lo usa «emparejar en una cuenta nueva».
|
|
163
|
+
case 'add': { const p = await mgr.add(req.name); return { done: `perfil creado: ${p.name || p.id}`, id: p.id } }
|
|
160
164
|
case 'rm': { const r = await mgr.remove(req.profile); return { done: `perfil borrado: ${r.name || r.id}` } }
|
|
161
165
|
case 'rename': { const p = mgr.profiles.rename(ref(), req.name); return { done: `perfil renombrado: ${p.name}` } }
|
|
162
166
|
case 'use': { const p = mgr.profiles.setCurrent(ref()); return { done: `perfil activo: ${p.name || p.id}` } }
|
|
@@ -215,7 +219,9 @@ export async function runDaemon () {
|
|
|
215
219
|
rm(profileReqFile) // lleva la contraseña: fuera del disco cuanto antes
|
|
216
220
|
let extra = {}
|
|
217
221
|
try { extra = await handleProfileRequest(preq) }
|
|
218
|
-
|
|
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) }
|
|
219
225
|
dumpProfiles(extra)
|
|
220
226
|
} else {
|
|
221
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
|
@@ -50,7 +50,8 @@ function humanErr (e, st) {
|
|
|
50
50
|
NO_REPLY: t.errNoReply,
|
|
51
51
|
NOT_APPLIED: t.errNotApplied,
|
|
52
52
|
NOT_DELETED: t.errNotDeleted,
|
|
53
|
-
PAIR_FAILED: t.errPairFailed
|
|
53
|
+
PAIR_FAILED: t.errPairFailed,
|
|
54
|
+
MASTER_WITH_MEMBERS: t.errMasterWithMembers
|
|
54
55
|
}
|
|
55
56
|
return byCode[e?.code] || e?.message || String(e)
|
|
56
57
|
}
|
|
@@ -174,6 +175,30 @@ function deviceRows (st, t) {
|
|
|
174
175
|
return rows
|
|
175
176
|
}
|
|
176
177
|
|
|
178
|
+
/**
|
|
179
|
+
* LA PREGUNTA DEL EMPAREJAMIENTO. La decisión es del vault (es quien lo inicia) y
|
|
180
|
+
* este daemon puede tener varias cuentas: antes de mostrar el QR hay que decir a
|
|
181
|
+
* cuál entra el dispositivo. Hoy se responde con las dos formas que existen —una
|
|
182
|
+
* cuenta que ya vive aquí, o una nueva que se estrena para él—; la tercera
|
|
183
|
+
* («adoptar la que trae el aparato») necesita el protocolo de adopción y se
|
|
184
|
+
* muestra desactivada para no prometer lo que todavía no hace
|
|
185
|
+
* (docs/vinculacion-de-cuentas.md §5).
|
|
186
|
+
*/
|
|
187
|
+
function pairModeRows (st, t) {
|
|
188
|
+
const i = L(st)
|
|
189
|
+
const ap = activeProfile(st)
|
|
190
|
+
const rows = [{ text: t.muted(' ' + i.pairModeIntro), sel: false }, { text: '', sel: false }]
|
|
191
|
+
rows.push({ text: ` ${t.bold(i.pairModeHere(ap?.name || ap?.id || '—'))}`, sel: true, meta: { mode: 'here' } })
|
|
192
|
+
rows.push({ text: t.muted(' ' + i.pairModeHereHint), sel: false })
|
|
193
|
+
rows.push({ text: '', sel: false })
|
|
194
|
+
rows.push({ text: ` ${t.bold(i.pairModeNew)}`, sel: true, meta: { mode: 'new' } })
|
|
195
|
+
rows.push({ text: t.muted(' ' + i.pairModeNewHint), sel: false })
|
|
196
|
+
rows.push({ text: '', sel: false })
|
|
197
|
+
rows.push({ text: ' ' + t.muted(i.pairModeAdopt), sel: false })
|
|
198
|
+
rows.push({ text: t.muted(' (' + i.pairModeAdoptSoon + ')'), sel: false })
|
|
199
|
+
return rows
|
|
200
|
+
}
|
|
201
|
+
|
|
177
202
|
function secretRows (st, t) {
|
|
178
203
|
const i = L(st)
|
|
179
204
|
const ns = st.secrets || {}
|
|
@@ -381,9 +406,10 @@ async function onKeyDevices (term, st, key) {
|
|
|
381
406
|
const cur = sels[Math.min(st.sel.devices, sels.length - 1)]
|
|
382
407
|
const ch = key.name === 'char' ? key.ch.toLowerCase() : null
|
|
383
408
|
|
|
384
|
-
if (ch === 'p') { // pair
|
|
385
|
-
|
|
386
|
-
|
|
409
|
+
if (ch === 'p') { // pair → primero LA PREGUNTA (a qué cuenta entra), luego el QR
|
|
410
|
+
st.sel.pairmode = 0
|
|
411
|
+
st.scroll.pairmode = { value: 0 }
|
|
412
|
+
st.screen = 'pairmode'
|
|
387
413
|
} else if (ch === 'a') { // aprobar el pendiente
|
|
388
414
|
if (!st.pending) { flash(st, i.noPending, 'warn'); return true }
|
|
389
415
|
promptApprove(term, st)
|
|
@@ -407,6 +433,50 @@ async function onKeyDevices (term, st, key) {
|
|
|
407
433
|
return true
|
|
408
434
|
}
|
|
409
435
|
|
|
436
|
+
/** Abre el emparejamiento contra `profile` y salta a la pantalla del QR. */
|
|
437
|
+
async function beginPairing (term, st, profile) {
|
|
438
|
+
const r = await guard(term, st, L(st).startingPairing, () => vc.startPairing({ profile }))
|
|
439
|
+
if (r.ok) { st.pairing = r.v; st.pending = null; st.screen = 'pairing' }
|
|
440
|
+
return r.ok
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async function onKeyPairMode (term, st, key) {
|
|
444
|
+
const i = L(st)
|
|
445
|
+
const rows = pairModeRows(st, term.t)
|
|
446
|
+
const sels = rows.filter((r) => r.sel).map((r) => r.meta)
|
|
447
|
+
moveSel(st, key, 'pairmode', sels.length)
|
|
448
|
+
const cur = sels[Math.min(st.sel.pairmode, sels.length - 1)]
|
|
449
|
+
const ch = key.name === 'char' ? key.ch.toLowerCase() : null
|
|
450
|
+
|
|
451
|
+
if (key.name === 'escape' || ch === 'b') { st.screen = 'devices'; return true }
|
|
452
|
+
if (key.name !== 'enter' || !cur) return true
|
|
453
|
+
|
|
454
|
+
if (cur.mode === 'here') { await beginPairing(term, st, activeId(st)); return true }
|
|
455
|
+
|
|
456
|
+
// Cuenta nueva: se crea aquí, se ACTIVA (así aprobar/rechazar y las listas miran
|
|
457
|
+
// a la misma que el QR) y recién entonces se abre el emparejamiento contra ella.
|
|
458
|
+
setInput(st, {
|
|
459
|
+
label: i.newAccountLabel,
|
|
460
|
+
hint: i.newAccountHint,
|
|
461
|
+
onSubmit: async (name) => {
|
|
462
|
+
st.input = null
|
|
463
|
+
const nombre = name.trim()
|
|
464
|
+
if (!nombre) { flash(st, i.nameEmpty, 'danger'); return }
|
|
465
|
+
const r = await guard(term, st, i.creatingVault, () => vc.addProfile(nombre))
|
|
466
|
+
if (!r.ok) return
|
|
467
|
+
const nuevo = r.v?.id || (r.v?.profiles || []).find((p) => p.name === nombre)?.id
|
|
468
|
+
if (!nuevo) { flash(st, i.errNoReply, 'danger'); return }
|
|
469
|
+
const u = await guard(term, st, i.switchingVault, () => vc.useProfile(nuevo))
|
|
470
|
+
if (!u.ok) return
|
|
471
|
+
await refreshAll(term, st)
|
|
472
|
+
flash(st, i.accountCreated(nombre))
|
|
473
|
+
await beginPairing(term, st, nuevo)
|
|
474
|
+
},
|
|
475
|
+
onCancel: () => { st.input = null }
|
|
476
|
+
})
|
|
477
|
+
return true
|
|
478
|
+
}
|
|
479
|
+
|
|
410
480
|
function promptApprove (term, st) {
|
|
411
481
|
const i = L(st)
|
|
412
482
|
setInput(st, {
|
|
@@ -554,10 +624,15 @@ const helpSegs = (i, screen) => ({
|
|
|
554
624
|
profiles: i.helpProfiles,
|
|
555
625
|
devices: i.helpDevices,
|
|
556
626
|
secrets: i.helpSecrets,
|
|
557
|
-
pairing: i.helpPairing
|
|
627
|
+
pairing: i.helpPairing,
|
|
628
|
+
pairmode: i.helpPairMode
|
|
558
629
|
})[screen] || []
|
|
559
630
|
|
|
560
|
-
const title = (i, screen) => (
|
|
631
|
+
const title = (i, screen) => ({
|
|
632
|
+
profiles: i.titleProfiles,
|
|
633
|
+
pairing: i.titlePairing,
|
|
634
|
+
pairmode: i.titlePairMode
|
|
635
|
+
})[screen] || ''
|
|
561
636
|
|
|
562
637
|
/** Barra de pestañas horizontal (Dispositivos | Scopes y variables) de la bóveda entrada. */
|
|
563
638
|
function renderTabs (st, t) {
|
|
@@ -639,6 +714,7 @@ function render (term, st) {
|
|
|
639
714
|
if (st.screen === 'profiles') body = renderList(profileRows(st, t), st.sel.profiles, contentH, cols, t, scrollRef)
|
|
640
715
|
else if (st.screen === 'devices') body = renderList(deviceRows(st, t), st.sel.devices, contentH, cols, t, scrollRef)
|
|
641
716
|
else if (st.screen === 'secrets') body = renderList(secretRows(st, t), st.sel.secrets, contentH, cols, t, scrollRef)
|
|
717
|
+
else if (st.screen === 'pairmode') body = renderList(pairModeRows(st, t), st.sel.pairmode, contentH, cols, t, scrollRef)
|
|
642
718
|
else if (st.screen === 'pairing') {
|
|
643
719
|
const pb = pairingBody(st, t, cols, contentH)
|
|
644
720
|
body = pb.slice(0, contentH)
|
|
@@ -724,7 +800,7 @@ export async function runTui () {
|
|
|
724
800
|
const st = {
|
|
725
801
|
screen: 'profiles', // se arranca en la lista de bóvedas: hay que ENTRAR a una
|
|
726
802
|
lang: loadLang(), // es/en — se conmuta con `l` y se recuerda en prefs.json
|
|
727
|
-
sel: { profiles: 0, devices: 0, secrets: 0 },
|
|
803
|
+
sel: { profiles: 0, devices: 0, secrets: 0, pairmode: 0 },
|
|
728
804
|
scroll: {},
|
|
729
805
|
profiles: null,
|
|
730
806
|
devices: null,
|
|
@@ -788,6 +864,7 @@ export async function runTui () {
|
|
|
788
864
|
if (st.screen === 'profiles') running = await onKeyProfiles(term, st, key)
|
|
789
865
|
else if (st.screen === 'devices') running = await onKeyDevices(term, st, key)
|
|
790
866
|
else if (st.screen === 'secrets') running = await onKeySecrets(term, st, key)
|
|
867
|
+
else if (st.screen === 'pairmode') running = await onKeyPairMode(term, st, key)
|
|
791
868
|
else if (st.screen === 'pairing') running = await onKeyPairing(term, st, key)
|
|
792
869
|
}
|
|
793
870
|
} finally {
|
|
@@ -796,4 +873,4 @@ export async function runTui () {
|
|
|
796
873
|
}
|
|
797
874
|
|
|
798
875
|
// Solo para pruebas headless (render sin terminal real). No usar en runtime.
|
|
799
|
-
export const __test = { render, profileRows, deviceRows, secretRows, pairingBody, fitHelp, toggleLang }
|
|
876
|
+
export const __test = { render, profileRows, deviceRows, secretRows, pairModeRows, pairingBody, fitHelp, toggleLang }
|
package/src/tui/i18n.js
CHANGED
|
@@ -37,6 +37,7 @@ const es = {
|
|
|
37
37
|
tabsHint: ' (←→ cambiar)',
|
|
38
38
|
titleProfiles: 'Bóvedas',
|
|
39
39
|
titlePairing: 'Emparejar un dispositivo',
|
|
40
|
+
titlePairMode: 'Emparejar: ¿a qué cuenta entra?',
|
|
40
41
|
|
|
41
42
|
// bóvedas (perfiles)
|
|
42
43
|
noPassword: 'sin clave',
|
|
@@ -122,7 +123,17 @@ const es = {
|
|
|
122
123
|
savingVar: 'Guardando variable…',
|
|
123
124
|
varSaved: (ns, key) => `Guardado ${ns}/${key}`,
|
|
124
125
|
|
|
125
|
-
// emparejamiento
|
|
126
|
+
// emparejamiento — la PREGUNTA es del vault, que es quien lo inicia
|
|
127
|
+
pairModeIntro: 'Un dispositivo puede entrar a una cuenta que ya vive aquí, o estrenar una.',
|
|
128
|
+
pairModeHere: (name) => `Entrar a esta cuenta: ${name}`,
|
|
129
|
+
pairModeHereHint: 'el dispositivo pasa a ver y firmar lo de esta cuenta',
|
|
130
|
+
pairModeNew: 'Estrenar una cuenta nueva en este vault',
|
|
131
|
+
pairModeNewHint: 'se crea aquí, vacía, y el dispositivo entra a ELLA (las otras no se tocan)',
|
|
132
|
+
pairModeAdopt: 'Adoptar la cuenta que trae el dispositivo',
|
|
133
|
+
pairModeAdoptSoon: 'todavía no: el dispositivo aún no sabe entregar la suya',
|
|
134
|
+
newAccountLabel: 'Nombre de la cuenta nueva',
|
|
135
|
+
newAccountHint: 'nace vacía; el dispositivo será su primer invitado',
|
|
136
|
+
accountCreated: (name) => `Cuenta creada: ${name}`,
|
|
126
137
|
pairAccount: (name) => `Cuenta que se comparte: ${name}`,
|
|
127
138
|
pairValid: (min) => `Válido ~${min} min. Escanéalo o abre la URL en el dispositivo.`,
|
|
128
139
|
pairUrl: 'URL: ',
|
|
@@ -144,6 +155,7 @@ const es = {
|
|
|
144
155
|
helpDevices: ['←→ pestaña', '↑↓', 'p emparejar', 'a aprobar', 'x rechazar', 'v revocar', 'r refrescar', 'Esc bóvedas', 'l English', 'q salir'],
|
|
145
156
|
helpSecrets: ['←→ pestaña', '↑↓', 'n nueva variable', 'x quitar (variable/scope)', 'r refrescar', 'Esc bóvedas', 'l English', 'q salir'],
|
|
146
157
|
helpPairing: ['a aprobar', 'x rechazar', 'r reiniciar', 'Esc atrás', 'l English'],
|
|
158
|
+
helpPairMode: ['↑↓', 'Enter elegir', 'Esc atrás', 'l English', 'q salir'],
|
|
147
159
|
|
|
148
160
|
// pantalla "daemon caído"
|
|
149
161
|
downTitle: 'El daemon del vault no está corriendo.',
|
|
@@ -167,7 +179,8 @@ const es = {
|
|
|
167
179
|
errNoReply: 'El daemon no respondió.',
|
|
168
180
|
errNotApplied: 'El daemon no aplicó el cambio (revisa los logs del servicio).',
|
|
169
181
|
errNotDeleted: 'El daemon no borró la variable (revisa los logs del servicio).',
|
|
170
|
-
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.'
|
|
171
184
|
}
|
|
172
185
|
|
|
173
186
|
// ---------------------------------- inglés ----------------------------------
|
|
@@ -190,6 +203,7 @@ const en = {
|
|
|
190
203
|
tabsHint: ' (←→ switch)',
|
|
191
204
|
titleProfiles: 'Vaults',
|
|
192
205
|
titlePairing: 'Pair a device',
|
|
206
|
+
titlePairMode: 'Pairing: which account does it join?',
|
|
193
207
|
|
|
194
208
|
noPassword: 'no password',
|
|
195
209
|
locked: '🔒 locked',
|
|
@@ -272,6 +286,16 @@ const en = {
|
|
|
272
286
|
savingVar: 'Saving variable…',
|
|
273
287
|
varSaved: (ns, key) => `Saved ${ns}/${key}`,
|
|
274
288
|
|
|
289
|
+
pairModeIntro: 'A device can join an account that already lives here, or start a new one.',
|
|
290
|
+
pairModeHere: (name) => `Join this account: ${name}`,
|
|
291
|
+
pairModeHereHint: 'the device gets to see and sign for this account',
|
|
292
|
+
pairModeNew: 'Start a new account in this vault',
|
|
293
|
+
pairModeNewHint: 'created here, empty, and the device joins THAT one (the others are untouched)',
|
|
294
|
+
pairModeAdopt: 'Adopt the account the device brings',
|
|
295
|
+
pairModeAdoptSoon: 'not yet: the device cannot hand its own over',
|
|
296
|
+
newAccountLabel: 'Name of the new account',
|
|
297
|
+
newAccountHint: 'born empty; the device will be its first guest',
|
|
298
|
+
accountCreated: (name) => `Account created: ${name}`,
|
|
275
299
|
pairAccount: (name) => `Account being shared: ${name}`,
|
|
276
300
|
pairValid: (min) => `Valid ~${min} min. Scan it or open the URL on the device.`,
|
|
277
301
|
pairUrl: 'URL: ',
|
|
@@ -288,6 +312,7 @@ const en = {
|
|
|
288
312
|
helpDevices: ['←→ tab', '↑↓', 'p pair', 'a approve', 'x reject', 'v revoke', 'r refresh', 'Esc vaults', 'l Español', 'q quit'],
|
|
289
313
|
helpSecrets: ['←→ tab', '↑↓', 'n new variable', 'x remove (variable/scope)', 'r refresh', 'Esc vaults', 'l Español', 'q quit'],
|
|
290
314
|
helpPairing: ['a approve', 'x reject', 'r restart', 'Esc back', 'l Español'],
|
|
315
|
+
helpPairMode: ['↑↓', 'Enter choose', 'Esc back', 'l Español', 'q quit'],
|
|
291
316
|
|
|
292
317
|
downTitle: 'The vault daemon is not running.',
|
|
293
318
|
downBody1: 'The TUI gives orders to the daemon (the keeper of your key). Without it',
|
|
@@ -309,7 +334,8 @@ const en = {
|
|
|
309
334
|
errNoReply: 'The daemon did not answer.',
|
|
310
335
|
errNotApplied: 'The daemon did not apply the change (check the service logs).',
|
|
311
336
|
errNotDeleted: 'The daemon did not delete the variable (check the service logs).',
|
|
312
|
-
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.'
|
|
313
339
|
}
|
|
314
340
|
|
|
315
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
|
|