@dotrino/vaultd 0.151.0 → 0.153.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/enroll.js +12 -7
- package/package.json +2 -2
- package/src/ctl.js +28 -11
- package/src/daemon.js +1 -1
- package/src/tui/app.js +93 -22
- package/src/tui/i18n.js +20 -4
- package/src/update.js +11 -0
package/lib/src/enroll.js
CHANGED
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
* `docs/pairing-protocol.md`).
|
|
31
31
|
*/
|
|
32
32
|
import { verifyDeviceSig, pubkeyId, commitCode } from '@dotrino/identity/capabilities'
|
|
33
|
-
import { verifyContinuity, canSeal, DEVICE_CAPS } from '@dotrino/identity/acta'
|
|
33
|
+
import { verifyContinuity, canSeal, DEVICE_CAPS, VAULT_CN } from '@dotrino/identity/acta'
|
|
34
34
|
|
|
35
35
|
/** Un token de emparejamiento vale 5 min. */
|
|
36
36
|
export const PAIRING_TTL_MS = 5 * 60 * 1000
|
|
@@ -169,12 +169,16 @@ export function createEnrollDesk ({
|
|
|
169
169
|
* el código, igual que hacía `caps` después.
|
|
170
170
|
*/
|
|
171
171
|
async function startPairing ({ scope = defaultScope, ttlMs = defaultTtlMs, label = '', mode = 'join', account = '', caps = null } = {}) {
|
|
172
|
+
// El nombre de la propia bóveda no es un servicio: su llave firma con el perfil cerrado
|
|
173
|
+
// y no puede abrir ningún cajón. Un servicio que entrara con ese nombre tampoco podría.
|
|
174
|
+
if (scopeToCn(scope) === VAULT_CN) throw Object.assign(new Error(`"${VAULT_CN}" is the vault's own name: a service cannot use it`), { code: 'reserved-cn' })
|
|
172
175
|
if (caps != null) {
|
|
173
176
|
if (!Array.isArray(caps)) throw Object.assign(new Error('caps must be a list'), { code: 'bad-caps' })
|
|
174
177
|
const cn = scopeToCn(scope)
|
|
175
|
-
const bad = caps.filter((c) => !
|
|
178
|
+
const bad = caps.filter((c) => !DEVICE_CAPS.includes(c))
|
|
176
179
|
if (bad.length) throw Object.assign(new Error('unknown permission(s): ' + bad.join(', ')), { code: 'bad-caps' })
|
|
177
|
-
|
|
180
|
+
// Un servicio puede entrar sin ningún permiso: su cajón lo abre el CN.
|
|
181
|
+
if (!caps.length && !cn) throw Object.assign(new Error('the pairing grants no permission'), { code: 'empty-scope' })
|
|
178
182
|
caps = [...new Set(caps)]
|
|
179
183
|
}
|
|
180
184
|
pending.clear() // uno a la vez: una sesión nueva supersede a la anterior
|
|
@@ -378,13 +382,14 @@ export function createEnrollDesk ({
|
|
|
378
382
|
throw Object.assign(new Error('this vault cannot add devices to the account record: no certificate was issued'), { code: 'admit-unavailable' })
|
|
379
383
|
}
|
|
380
384
|
// PERMISOS, no tipos (2026-08-22): las capacidades son las del scope ENTERO. Un
|
|
381
|
-
// cajón (`secrets:<ns>`)
|
|
382
|
-
// con `sign,secrets:eco` firma como
|
|
385
|
+
// cajón (`secrets:<ns>`) fija el CN, que es lo que lo abre —no hay permiso aparte
|
|
386
|
+
// (dueño, 2026-10-13)—, y no borra lo demás: un bot con `sign,secrets:eco` firma como
|
|
387
|
+
// aparato del acta Y lee solo su cajón.
|
|
383
388
|
// Si quien emparejó ELIGIÓ los permisos (`startPairing({ caps })`), entran esos y en
|
|
384
389
|
// esta misma acta; si no, los que da el scope.
|
|
385
390
|
const cn = scopeToCn(pend.scope)
|
|
386
|
-
const caps = [...new Set(
|
|
387
|
-
if (!caps.length) {
|
|
391
|
+
const caps = [...new Set(pend.caps || scopeToCaps(pend.scope))]
|
|
392
|
+
if (!caps.length && !cn) {
|
|
388
393
|
throw Object.assign(new Error('the pairing scope grants no permission: no certificate was issued'), { code: 'empty-scope' })
|
|
389
394
|
}
|
|
390
395
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vaultd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.153.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.111.0",
|
|
26
26
|
"@dotrino/opaque": "0.1.0",
|
|
27
27
|
"@dotrino/passmanager": "0.17.0",
|
|
28
28
|
"@dotrino/proxy-client": "0.30.1",
|
package/src/ctl.js
CHANGED
|
@@ -39,6 +39,7 @@ import { atRestFor, readConfig as readKekConfig, probe as probeKek, rekeyDir, en
|
|
|
39
39
|
import { VERSION } from './version.js'
|
|
40
40
|
import * as hw from './hwkeys.js'
|
|
41
41
|
import { isNewer } from './update.js'
|
|
42
|
+
import { COMM_CN } from './commKey.js'
|
|
42
43
|
|
|
43
44
|
const dir = dataDir()
|
|
44
45
|
const stateFile = path.join(dir, 'state.json')
|
|
@@ -614,7 +615,7 @@ async function cmdMembers () {
|
|
|
614
615
|
// español. Se resaltan como `admin` y `approve`: los cuatro cambian lo que ese aparato
|
|
615
616
|
// puede hacerle a la cuenta, y eso se lee de un vistazo o no se lee.
|
|
616
617
|
const CAP = {
|
|
617
|
-
sign: 'firma', store: 'guarda', read: 'lee',
|
|
618
|
+
sign: 'firma', store: 'guarda', read: 'lee',
|
|
618
619
|
admin: `${B}administra el perfil${Z}`, approve: `${B}aprueba pedidos${Z}`,
|
|
619
620
|
sealer: `${B}sella el acta${Z}`, passwords: `${B}pide contraseñas${Z}`,
|
|
620
621
|
// Resaltado como los otros cuatro: dice si esa máquina se lleva tus claves privadas
|
|
@@ -644,7 +645,8 @@ async function cmdMembers () {
|
|
|
644
645
|
m.isMe ? 'este dispositivo' : null,
|
|
645
646
|
m.cn ? `servicio «${m.cn}»` : null
|
|
646
647
|
].filter(Boolean)
|
|
647
|
-
|
|
648
|
+
// Un servicio sin permisos no está «sin nada»: su cajón lo abre el nombre, no un permiso.
|
|
649
|
+
const caps = m.caps.length ? m.caps.map((c) => CAP[c] || c).join(', ') : (m.cn && m.cn !== COMM_CN ? '(solo su cajón)' : '(sin permisos)')
|
|
648
650
|
// CUÁNDO ENTRÓ. El nombre lo pone el propio aparato al emparejarse y muchas veces no
|
|
649
651
|
// distingue nada —dos teléfonos con el mismo apodo, o «cli» a secas—, así que la fecha
|
|
650
652
|
// es lo que deja reconocer cuál es cuál y ver si hay uno que no recuerdas haber
|
|
@@ -656,13 +658,13 @@ async function cmdMembers () {
|
|
|
656
658
|
// selladas a esa llave. Se dice aquí, junto a él, porque es el único sitio donde
|
|
657
659
|
// se mira quién es quién — y en la lista de variables ya seria tarde.
|
|
658
660
|
//
|
|
659
|
-
//
|
|
660
|
-
//
|
|
661
|
-
//
|
|
661
|
+
// Menos la llave de comunicación de la bóveda: entra con `cn: 'vault'` y solo `firma`,
|
|
662
|
+
// y no tiene llave de cifrado a propósito: salía marcada en rojo «NO puede leer sus
|
|
663
|
+
// variables» cuando no tiene ninguna que leer.
|
|
662
664
|
// SI LE LLEGAN LOS AVISOS, en quien aprueba. No cambia a quién se le pide —se le pide a
|
|
663
665
|
// todos—: es para ver a cuál no le suena el pedido.
|
|
664
666
|
if (m.caps.includes('approve') && 'notifiedAt' in m) console.log(' %s%s%s', D, avisosTxt(m.notifiedAt), Z)
|
|
665
|
-
if (m.cn &&
|
|
667
|
+
if (m.cn && m.cn !== COMM_CN && !m.canSeal) console.log(' %ssin llave de cifrado: NO puede leer sus variables%s', R, Z)
|
|
666
668
|
// QUÉ CORRE, y si cuadra con esta bóveda (CONVENCIONES §14). Sin esto una
|
|
667
669
|
// incompatibilidad de versiones se ve como que ese aparato «no responde», que es el
|
|
668
670
|
// fallo más caro que ha tenido el ecosistema. No bloquea nada: se enseña.
|
|
@@ -1830,8 +1832,8 @@ async function waitForService (version, { timeoutMs = 4 * 60_000 } = {}) {
|
|
|
1830
1832
|
* marcador que deja el servicio cuando se actualiza solo. Si no se puede escribir se dice:
|
|
1831
1833
|
* la actualización está hecha, lo que se pierde es el aviso.
|
|
1832
1834
|
*/
|
|
1833
|
-
function noteUpdated (to) {
|
|
1834
|
-
try {
|
|
1835
|
+
async function noteUpdated (to) {
|
|
1836
|
+
try { (await import('./update.js')).noteUpdated(dir, { from: readState().version || VERSION, to }) } catch (e) {
|
|
1835
1837
|
console.error('No se pudo dejar el aviso de actualización para tus aprobadores: %s', e.message)
|
|
1836
1838
|
}
|
|
1837
1839
|
}
|
|
@@ -1881,8 +1883,23 @@ async function cmdUpdate (args = []) {
|
|
|
1881
1883
|
}
|
|
1882
1884
|
console.log('firmada por el release de %s ✓', 'imdotrino/dotrino-vault')
|
|
1883
1885
|
console.log('Instalada la %s.', res.version)
|
|
1884
|
-
noteUpdated(res.version)
|
|
1885
|
-
if (
|
|
1886
|
+
await noteUpdated(res.version)
|
|
1887
|
+
if (args.includes('--no-wait')) return
|
|
1888
|
+
// AL TERMINAR SE OFRECE REINICIAR (dueño, 2026-10-13). El servicio se reinicia solo al
|
|
1889
|
+
// ver el binario nuevo, pero espera a verlo quieto dos minutos: quien está delante puede
|
|
1890
|
+
// no querer esperar. Solo se pregunta con alguien delante y con systemd; si no, se espera.
|
|
1891
|
+
const s = ipcRead(stateFile, null)
|
|
1892
|
+
if (s && alive(s.pid) && s.version !== res.version && process.stdin.isTTY && process.platform === 'linux' && !IN_DOCKER) {
|
|
1893
|
+
const resp = (await askText('¿Reiniciar la bóveda ahora para usarla? Cierra las bóvedas abiertas. [s/N] ')).trim().toLowerCase()
|
|
1894
|
+
if (resp === 's' || resp === 'y') {
|
|
1895
|
+
try { execFileSync('systemctl', ['--user', 'restart', 'dotrino-vault'], { stdio: 'inherit' }) } catch (e) {
|
|
1896
|
+
console.error('No se pudo reiniciar: %s', e.message)
|
|
1897
|
+
console.error('Hazlo a mano: %s', RESTART_HINT)
|
|
1898
|
+
process.exit(1)
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
await waitForService(res.version)
|
|
1886
1903
|
return
|
|
1887
1904
|
}
|
|
1888
1905
|
|
|
@@ -1922,7 +1939,7 @@ async function cmdUpdate (args = []) {
|
|
|
1922
1939
|
B, file, r.version, Z)
|
|
1923
1940
|
return
|
|
1924
1941
|
}
|
|
1925
|
-
noteUpdated(r.version)
|
|
1942
|
+
await noteUpdated(r.version)
|
|
1926
1943
|
console.log('Listo. El servicio se reinicia solo; compruébalo con: dotrino-vault status')
|
|
1927
1944
|
}
|
|
1928
1945
|
|
package/src/daemon.js
CHANGED
|
@@ -245,7 +245,7 @@ export async function runDaemon () {
|
|
|
245
245
|
if (caps) {
|
|
246
246
|
caps = [...new Set(caps)]
|
|
247
247
|
const cn = scopeToCn(base)
|
|
248
|
-
const fromCaps = caps.map((c) => capScope(c
|
|
248
|
+
const fromCaps = caps.map((c) => capScope(c)).filter((x) => x && ALLOWED(x))
|
|
249
249
|
// Sin `--scope` el papel sale de los permisos; con él, se suman.
|
|
250
250
|
scope = [...new Set([...(asked?.length || isService ? base : []), ...fromCaps])]
|
|
251
251
|
if (!scope.length) {
|
package/src/tui/app.js
CHANGED
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
* que entrar antes), el candado es `k` (la `l` es el idioma) y la contraseña `c`.
|
|
31
31
|
*/
|
|
32
32
|
import fs from 'node:fs'
|
|
33
|
-
import { execFile } from 'node:child_process'
|
|
33
|
+
import { execFile, spawnSync } from 'node:child_process'
|
|
34
34
|
import { parseEnvInput } from '../../lib/src/envtext.js'
|
|
35
35
|
import { createTerm, widthOf } from './term.js'
|
|
36
36
|
import { qrToString } from '../qr.js'
|
|
@@ -39,6 +39,7 @@ import * as vc from '../vaultControl.js'
|
|
|
39
39
|
import { readSettings, writeSettings } from '../settings.js'
|
|
40
40
|
import * as hw from '../hwkeys.js'
|
|
41
41
|
import { VERSION } from '../version.js'
|
|
42
|
+
import * as up from '../update.js'
|
|
42
43
|
import { DEVICE_CAPS } from '@dotrino/identity/acta'
|
|
43
44
|
|
|
44
45
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
@@ -184,6 +185,65 @@ function lockGlyph (p) {
|
|
|
184
185
|
return p.locked ? '🔒' : '🔓'
|
|
185
186
|
}
|
|
186
187
|
|
|
188
|
+
function restartDaemonService () {
|
|
189
|
+
return new Promise((res, rej) => {
|
|
190
|
+
execFile('systemctl', ['--user', 'restart', 'dotrino-vault'], { timeout: 20000 }, (err, so, se) => {
|
|
191
|
+
if (err) rej(new Error(String(se || '').trim() || err.message)); else res()
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* AL ENTRAR SE DICE SI HAY VERSIÓN NUEVA, Y SE OFRECE INSTALARLA (dueño, 2026-10-13: como
|
|
198
|
+
* hace la app de Terminal). El servicio ya se actualiza solo; esto es para quien está
|
|
199
|
+
* mirando: enterarse ahí mismo, instalarla sin salir y, al terminar, reiniciar.
|
|
200
|
+
*
|
|
201
|
+
* Se mira EN SEGUNDO PLANO: la pantalla sale al momento y el aviso llega cuando el registro
|
|
202
|
+
* contesta. «No se pudo mirar» no es «estás al día»: no se dice nada y queda en `st.update`.
|
|
203
|
+
*/
|
|
204
|
+
function checkForUpdate (st, { latest = up.latestRelease } = {}) {
|
|
205
|
+
if (VERSION === 'dev') return
|
|
206
|
+
st.update = { checking: true }
|
|
207
|
+
Promise.resolve().then(() => latest()).then((r) => {
|
|
208
|
+
if (!r?.ok) { st.update = { failed: r?.reason || 'unknown' }; return }
|
|
209
|
+
st.update = up.isNewer(r.version, VERSION) ? { release: r, offer: true } : { current: true }
|
|
210
|
+
}).catch((e) => { st.update = { failed: e?.message || String(e) } })
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Pregunta UNA vez por sesión. Decir que no deja el aviso arriba, sin volver a preguntar. */
|
|
214
|
+
function offerUpdate (term, st) {
|
|
215
|
+
const i = L(st)
|
|
216
|
+
const r = st.update.release
|
|
217
|
+
st.update = { release: r }
|
|
218
|
+
setConfirm(st, { text: i.updateOffer(r.version, VERSION), onYes: () => installUpdate(term, st, r) })
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function installUpdate (term, st, release, { install = up.installUserRelease, userInstall = up.isUserInstall } = {}) {
|
|
222
|
+
const i = L(st)
|
|
223
|
+
// Solo la instalación de usuario se puede cambiar desde aquí. Un paquete del sistema es
|
|
224
|
+
// de root: no se pide la contraseña por dentro, se dice el comando.
|
|
225
|
+
if (!userInstall()) { flash(st, i.updateNeedsCommand, 'warn'); return }
|
|
226
|
+
const r = await guard(term, st, i.updateInstalling(release.version), () => install(release))
|
|
227
|
+
if (!r.ok) return
|
|
228
|
+
if (!r.v.ok) { flash(st, i.updateFailed(r.v.reason), 'danger'); return }
|
|
229
|
+
try { up.noteUpdated(vc.vaultDir(), { from: st.state?.version || VERSION, to: r.v.version }) } catch (e) {
|
|
230
|
+
flash(st, i.updateNoteFailed(e.message), 'warn')
|
|
231
|
+
}
|
|
232
|
+
st.update = { installed: r.v.version }
|
|
233
|
+
// Reiniciar se PREGUNTA: cierra las bóvedas abiertas y corta lo que estén haciendo los
|
|
234
|
+
// aparatos. Si dices que no, el servicio se reinicia solo en un par de minutos.
|
|
235
|
+
setConfirm(st, {
|
|
236
|
+
text: i.updateRestartOffer(r.v.version),
|
|
237
|
+
onYes: async () => {
|
|
238
|
+
const rr = await guard(term, st, i.updateRestarting, () => restartDaemonService())
|
|
239
|
+
if (!rr.ok) return
|
|
240
|
+
st.relaunch = true
|
|
241
|
+
st.quit = true
|
|
242
|
+
},
|
|
243
|
+
onNo: () => { flash(st, i.updateRestartLater(r.v.version)) }
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
|
|
187
247
|
function startDaemonService () {
|
|
188
248
|
return new Promise((res) => {
|
|
189
249
|
execFile('systemctl', ['--user', 'start', 'dotrino-vault'], { timeout: 8000 }, (err, so, se) => {
|
|
@@ -562,17 +622,12 @@ const CAPS_ORDER = [...ORDEN.filter((c) => DEVICE_CAPS.includes(c)),
|
|
|
562
622
|
...DEVICE_CAPS.filter((c) => !ORDEN.includes(c))]
|
|
563
623
|
|
|
564
624
|
/**
|
|
565
|
-
*
|
|
566
|
-
*
|
|
567
|
-
*
|
|
568
|
-
*
|
|
569
|
-
*
|
|
570
|
-
* igual que pasaba con `unattended`. Se enseña primero, que es su importancia real.
|
|
571
|
-
*
|
|
572
|
-
* A un aparato SIN `cn` no se le ofrece: no hay cajón que abrir, el acta lo filtra
|
|
573
|
-
* (`allowedCaps`) y enseñar una casilla que no hace nada es peor que no enseñarla.
|
|
625
|
+
* EL CAJÓN NO ES UNA CASILLA (dueño, 2026-10-13: «no entiendo por qué es un permiso el
|
|
626
|
+
* acceso a un cajón, el cn ya garantiza eso»). Antes un servicio traía aquí «secretos
|
|
627
|
+
* «passmanager»» al lado de «contrasenas», y parecían dos permisos sobre lo mismo. Un
|
|
628
|
+
* servicio abre el cajón de su nombre por ser ese servicio: se DICE arriba de la lista
|
|
629
|
+
* (`capsDrawer`), y no se marca ni se desmarca.
|
|
574
630
|
*/
|
|
575
|
-
const capsForMember = (member) => (member?.cn ? ['secrets', ...CAPS_ORDER] : CAPS_ORDER)
|
|
576
631
|
|
|
577
632
|
/** Con lo que entra un aparato si no eliges otra cosa: lo mismo que `pair` sin `--scope`. */
|
|
578
633
|
const PAIR_DEFAULT_CAPS = ['sign', 'read', 'store']
|
|
@@ -586,11 +641,10 @@ const PAIR_DEFAULT_CAPS = ['sign', 'read', 'store']
|
|
|
586
641
|
function capChecklist (i, t, { cn = null, has, real = has }) {
|
|
587
642
|
const rows = []
|
|
588
643
|
let tocados = 0
|
|
589
|
-
|
|
644
|
+
if (cn) rows.push({ text: t.muted(' ' + i.capsDrawer(cn)), sel: false }, { text: '', sel: false })
|
|
645
|
+
for (const cap of CAPS_ORDER) {
|
|
590
646
|
const mark = has.has(cap) ? '[x]' : '[ ]'
|
|
591
|
-
|
|
592
|
-
// «lee sus claves» te deja preguntándote cuáles.
|
|
593
|
-
const name = typeof i.capName[cap] === 'function' ? i.capName[cap](cn) : i.capName[cap]
|
|
647
|
+
const name = i.capName[cap]
|
|
594
648
|
// LO QUE CAMBIA SE SEÑALA. Sin esto el borrador es indistinguible del acta y no sabes
|
|
595
649
|
// qué vas a firmar — que es justo lo que hace peligroso acumular cambios.
|
|
596
650
|
const cambia = has.has(cap) !== real.has(cap)
|
|
@@ -599,8 +653,7 @@ function capChecklist (i, t, { cn = null, has, real = has }) {
|
|
|
599
653
|
// así que no hay que repetirlo al lado. Lo que hace va debajo.
|
|
600
654
|
const line = ` ${mark}${cambia ? t.bold('*') : ' '} ${cap === 'admin' ? t.bold(name) : name}`
|
|
601
655
|
rows.push({ text: line, sel: true, meta: { cap } })
|
|
602
|
-
|
|
603
|
-
rows.push({ text: t.muted(' ' + hint), sel: false })
|
|
656
|
+
rows.push({ text: t.muted(' ' + i.capHint[cap]), sel: false })
|
|
604
657
|
}
|
|
605
658
|
return { rows, tocados }
|
|
606
659
|
}
|
|
@@ -1344,7 +1397,8 @@ async function onKeyDevices (term, st, key) {
|
|
|
1344
1397
|
value: cur.label || '',
|
|
1345
1398
|
onSubmit: async (raw) => {
|
|
1346
1399
|
const name = String(raw || '').trim()
|
|
1347
|
-
|
|
1400
|
+
st.input = null
|
|
1401
|
+
if (!name) { flash(st, i.nameEmpty, 'danger'); return }
|
|
1348
1402
|
const r = await guard(term, st, i.renaming, () => vc.setDeviceLabel(cur.sub, name, activeId(st)))
|
|
1349
1403
|
if (r.ok) { applyDump(st, r.v); flash(st, i.deviceRenamed(name)) }
|
|
1350
1404
|
}
|
|
@@ -1415,6 +1469,7 @@ async function onKeyLogins (term, st, key, api = vc) {
|
|
|
1415
1469
|
mask: true,
|
|
1416
1470
|
onSubmit: async (pw2) => {
|
|
1417
1471
|
if (pw1 !== pw2) { flash(st, i.loginPassMismatch, 'warn'); return }
|
|
1472
|
+
st.input = null
|
|
1418
1473
|
const r = await guard(term, st, i.loginCreating,
|
|
1419
1474
|
() => api.addLogin({ user, password: pw1, label }))
|
|
1420
1475
|
if (r.ok) {
|
|
@@ -1442,6 +1497,7 @@ async function onKeyLogins (term, st, key, api = vc) {
|
|
|
1442
1497
|
mask: true,
|
|
1443
1498
|
onSubmit: async (nueva) => {
|
|
1444
1499
|
if (String(nueva || '').length < MIN_LOGIN_PASSWORD) { flash(st, i.loginPassShort, 'warn'); return }
|
|
1500
|
+
st.input = null
|
|
1445
1501
|
const r = await guard(term, st, i.loginChanging,
|
|
1446
1502
|
() => api.passwdLogin({ user: cur.user, oldPassword: vieja, newPassword: nueva }))
|
|
1447
1503
|
if (r.ok) { flash(st, i.loginChanged); await refreshLogins(term, st, api) }
|
|
@@ -1593,7 +1649,8 @@ async function onKeyPairCaps (term, st, key) {
|
|
|
1593
1649
|
return true
|
|
1594
1650
|
}
|
|
1595
1651
|
if (ch === 'g') {
|
|
1596
|
-
|
|
1652
|
+
// Un servicio puede entrar sin ningún permiso: su cajón lo abre el nombre.
|
|
1653
|
+
if (!pc.caps.length && !pc.service) { flash(st, i.pairCapsNone, 'warn'); return true }
|
|
1597
1654
|
const go = async () => {
|
|
1598
1655
|
st.pairCaps = null
|
|
1599
1656
|
const ok = await beginPairing(term, st, pc.profile, pc.service, pc.label, pc.caps)
|
|
@@ -1614,7 +1671,7 @@ async function onKeyPairCaps (term, st, key) {
|
|
|
1614
1671
|
|
|
1615
1672
|
/** Abre la pantalla de permisos del emparejamiento; el QR sale al pulsar G. */
|
|
1616
1673
|
function choosePairCaps (st, { profile, service = null, label = null, after = null }) {
|
|
1617
|
-
st.pairCaps = { profile, service, label, after, caps: service ? [
|
|
1674
|
+
st.pairCaps = { profile, service, label, after, caps: service ? [] : [...PAIR_DEFAULT_CAPS] }
|
|
1618
1675
|
st.sel.paircaps = 0
|
|
1619
1676
|
st.scroll.paircaps = { value: 0 }
|
|
1620
1677
|
st.screen = 'paircaps'
|
|
@@ -2505,6 +2562,8 @@ function render (term, st) {
|
|
|
2505
2562
|
? t.muted(i.updateWaiting(u.version, dia(u.askedAt + 24 * 60 * 60 * 1000)))
|
|
2506
2563
|
: t.warn(i.updateNotApproved(u.version, dia(u.askedAt))))
|
|
2507
2564
|
}
|
|
2565
|
+
// Hay versión nueva y se dijo que no (o todavía no se preguntó): se queda dicho aquí.
|
|
2566
|
+
if (!lines[2] && st.update?.release) lines[2] = ' ' + t.warn(i.updateAvailable(st.update.release.version))
|
|
2508
2567
|
// Dispositivos/Scopes son pestañas de la bóveda activa (se entra desde Bóvedas);
|
|
2509
2568
|
// el resto muestra su título simple.
|
|
2510
2569
|
lines[3] = INNER_TABS.includes(st.screen) ? ' ' + renderTabs(st, t) : ' ' + t.title('» ' + title(i, st.screen))
|
|
@@ -2645,11 +2704,14 @@ export async function runTui () {
|
|
|
2645
2704
|
st.state = vc.readState()
|
|
2646
2705
|
st.daemonUp = true
|
|
2647
2706
|
await refreshAll(term, st)
|
|
2707
|
+
checkForUpdate(st)
|
|
2648
2708
|
|
|
2649
2709
|
let running = true
|
|
2650
2710
|
while (running) {
|
|
2651
2711
|
st.state = vc.readState()
|
|
2652
2712
|
st.daemonUp = vc.daemonAlive()
|
|
2713
|
+
// El aviso de versión nueva sale cuando no hay otra cosa preguntando.
|
|
2714
|
+
if (st.update?.offer && !st.input && !st.confirm) offerUpdate(term, st)
|
|
2653
2715
|
// ¿Se cerró sola mientras nadie miraba? Olvida su contraseña y sal de su contenido.
|
|
2654
2716
|
await forgetAutoLocked(term, st)
|
|
2655
2717
|
// caducar el flash a los ~4 s
|
|
@@ -2659,7 +2721,10 @@ export async function runTui () {
|
|
|
2659
2721
|
const base = (st.screen === 'pairing' || st.screen === 'join' || (st.screen === 'devices' && !st.input && !st.confirm)) ? 800 : 0
|
|
2660
2722
|
// Despertar a tiempo del bloqueo automático, además del sondeo de la pantalla.
|
|
2661
2723
|
const wake = autoLockWakeIn(st)
|
|
2662
|
-
|
|
2724
|
+
let tick = base && wake ? Math.min(base, wake) : (base || wake)
|
|
2725
|
+
// Mientras se mira si hay versión nueva hay que despertar sin tecla, o el aviso no
|
|
2726
|
+
// saldría hasta que tocaras algo.
|
|
2727
|
+
if (st.update?.checking) tick = tick ? Math.min(tick, 500) : 500
|
|
2663
2728
|
const key = await term.readKey(tick)
|
|
2664
2729
|
|
|
2665
2730
|
if (key.name === 'resize') continue
|
|
@@ -2731,7 +2796,13 @@ export async function runTui () {
|
|
|
2731
2796
|
for (const id of st.unlockedHere) { try { await vc.lockProfile(id) } catch (_) {} }
|
|
2732
2797
|
term.close()
|
|
2733
2798
|
}
|
|
2799
|
+
// TRAS ACTUALIZAR Y REINICIAR, esta pantalla también se vuelve a abrir: es el binario
|
|
2800
|
+
// anterior el que sigue en memoria. Se lanza el nuevo en esta misma terminal.
|
|
2801
|
+
if (st.relaunch) {
|
|
2802
|
+
const r = spawnSync(process.execPath, process.argv.slice(1), { stdio: 'inherit' })
|
|
2803
|
+
process.exit(r.status ?? 0)
|
|
2804
|
+
}
|
|
2734
2805
|
}
|
|
2735
2806
|
|
|
2736
2807
|
// Solo para pruebas headless (render sin terminal real). No usar en runtime.
|
|
2737
|
-
export const __test = { render, settingsRows, onKeySettings, doorRows, onKeyDoors, pairCapsRows, onKeyPairCaps, onKeyDevices, draftChanges, saveCapsDrafts, onKeySecrets, loginRows, onKeyLogins, refreshLogins, activeLocked, autoLockedIds, autoLockWakeIn, forgetAutoLocked, autoLockMin, refreshAll, ensureUnlocked, profileRows, deviceRows, secretRows, devVarRows, meRows, capsRows, onKeyCaps, pairModeRows, pairingBody, scrollBody, fitHelp, wrapHelp, wrapWords, joinBody, onKeyJoin, promptJoin, onKeyProfiles, onInputKey, toggleLang, mergeMembersAndCerts, seguirAqui, resetToque: () => { ultimoToque = 0 } }
|
|
2808
|
+
export const __test = { checkForUpdate, offerUpdate, installUpdate, render, settingsRows, onKeySettings, doorRows, onKeyDoors, pairCapsRows, onKeyPairCaps, onKeyDevices, draftChanges, saveCapsDrafts, onKeySecrets, loginRows, onKeyLogins, refreshLogins, activeLocked, autoLockedIds, autoLockWakeIn, forgetAutoLocked, autoLockMin, refreshAll, ensureUnlocked, profileRows, deviceRows, secretRows, devVarRows, meRows, capsRows, onKeyCaps, pairModeRows, pairingBody, scrollBody, fitHelp, wrapHelp, wrapWords, joinBody, onKeyJoin, promptJoin, onKeyProfiles, onInputKey, toggleLang, mergeMembersAndCerts, seguirAqui, resetToque: () => { ultimoToque = 0 } }
|
package/src/tui/i18n.js
CHANGED
|
@@ -28,6 +28,15 @@ const es = {
|
|
|
28
28
|
daemonStopped: 'DETENIDO',
|
|
29
29
|
updateWaiting: (version, until) => `⬆ la ${version} espera tu aprobación hasta ${until} UTC: mira Pedidos en tu teléfono`,
|
|
30
30
|
updateNotApproved: (version, when) => `⬆ se pidió permiso para la ${version} el ${when} UTC y no se aprobó: no se vuelve a pedir · dotrino-vault update`,
|
|
31
|
+
updateAvailable: (version) => `⬆ hay la versión ${version}: instálala con dotrino-vault update`,
|
|
32
|
+
updateOffer: (version, current) => `Hay la versión ${version} y esta es la ${current}. ¿Instalarla ahora?`,
|
|
33
|
+
updateInstalling: (version) => `Bajando la ${version} y comprobando la firma…`,
|
|
34
|
+
updateFailed: (reason) => `No se instaló nada: ${reason}`,
|
|
35
|
+
updateNeedsCommand: 'Esta instalación no se puede cambiar desde aquí. Sal y corre: dotrino-vault update',
|
|
36
|
+
updateNoteFailed: (reason) => `Instalada, pero no se pudo dejar el aviso para tus aprobadores: ${reason}`,
|
|
37
|
+
updateRestartOffer: (version) => `Instalada la ${version}. ¿Reiniciar ahora la bóveda y esta pantalla? Cierra las bóvedas abiertas`,
|
|
38
|
+
updateRestarting: 'Reiniciando la bóveda…',
|
|
39
|
+
updateRestartLater: (version) => `La ${version} queda instalada: la bóveda se reinicia sola en un par de minutos`,
|
|
31
40
|
daemonStale: (running, installed) => `⚠ el servicio corre ${running} y el binario instalado es ${installed}: reinícialo (systemctl --user restart dotrino-vault)`,
|
|
32
41
|
activeVault: 'Bóveda activa: ',
|
|
33
42
|
noName: '(sin nombre)',
|
|
@@ -79,6 +88,7 @@ const es = {
|
|
|
79
88
|
// PERMISOS de un dispositivo (§9.1: se dice el beneficio, no el scope del cert).
|
|
80
89
|
titleCaps: 'Permisos del dispositivo',
|
|
81
90
|
capsFor: (id, name) => `Permisos de ${id}${name ? ' · ' + name : ''}`,
|
|
91
|
+
capsDrawer: (cn) => `Es el servicio «${cn}»: abre el cajón «${cn}» y ningún otro.`,
|
|
82
92
|
capsNoMember: ' (este dispositivo ya no está en el perfil)',
|
|
83
93
|
capsApplyHint: 'Enter marca o desmarca · G guarda · Esc vuelve a la lista sin perder lo marcado',
|
|
84
94
|
capsPending: (n) => `${n} cambio(s) sin guardar en este aparato (*) · G guarda todo en una sola acta`,
|
|
@@ -104,7 +114,6 @@ const es = {
|
|
|
104
114
|
// resultado fue una lista donde no se sabía qué permiso era cuál ni cómo nombrarlo para
|
|
105
115
|
// el CLI. Lo que hace cada uno va DEBAJO, que es su sitio.
|
|
106
116
|
capName: {
|
|
107
|
-
secrets: (cn) => `secretos «${cn}»`,
|
|
108
117
|
sign: 'firma',
|
|
109
118
|
store: 'guarda',
|
|
110
119
|
read: 'lee',
|
|
@@ -120,7 +129,6 @@ const es = {
|
|
|
120
129
|
// continuaba a «Conectar y quitar dispositivos»—; ahora el título es la palabra que se
|
|
121
130
|
// teclea, así que la pista tiene que decir la frase entera.
|
|
122
131
|
capHint: {
|
|
123
|
-
secrets: (cn) => `abrir el cajón de «${cn}» y ningún otro`,
|
|
124
132
|
sign: 'usar tu identidad en las apps del ecosistema',
|
|
125
133
|
store: 'escribir en tu bóveda: perfil, contenido, datos',
|
|
126
134
|
read: 'ver lo que guardaste',
|
|
@@ -517,6 +525,15 @@ const en = {
|
|
|
517
525
|
daemonStopped: 'STOPPED',
|
|
518
526
|
updateWaiting: (version, until) => `⬆ ${version} is waiting for your approval until ${until} UTC: check Requests on your phone`,
|
|
519
527
|
updateNotApproved: (version, when) => `⬆ approval for ${version} was asked on ${when} UTC and not given: it will not ask again · dotrino-vault update`,
|
|
528
|
+
updateAvailable: (version) => `⬆ version ${version} is out: install it with dotrino-vault update`,
|
|
529
|
+
updateOffer: (version, current) => `Version ${version} is out and this is ${current}. Install it now?`,
|
|
530
|
+
updateInstalling: (version) => `Downloading ${version} and checking its signature…`,
|
|
531
|
+
updateFailed: (reason) => `Nothing was installed: ${reason}`,
|
|
532
|
+
updateNeedsCommand: 'This install cannot be changed from here. Quit and run: dotrino-vault update',
|
|
533
|
+
updateNoteFailed: (reason) => `Installed, but the note for your approvers could not be written: ${reason}`,
|
|
534
|
+
updateRestartOffer: (version) => `${version} is installed. Restart the vault and this screen now? It locks the open vaults`,
|
|
535
|
+
updateRestarting: 'Restarting the vault…',
|
|
536
|
+
updateRestartLater: (version) => `${version} is installed: the vault restarts by itself in a couple of minutes`,
|
|
520
537
|
daemonStale: (running, installed) => `⚠ the service runs ${running} but the installed binary is ${installed}: restart it (systemctl --user restart dotrino-vault)`,
|
|
521
538
|
activeVault: 'Active vault: ',
|
|
522
539
|
tooSmall: 'Terminal too small',
|
|
@@ -564,6 +581,7 @@ const en = {
|
|
|
564
581
|
tabMe: 'Profile',
|
|
565
582
|
titleCaps: 'Device permissions',
|
|
566
583
|
capsFor: (id, name) => `Permissions for ${id}${name ? ' · ' + name : ''}`,
|
|
584
|
+
capsDrawer: (cn) => `It is the “${cn}” service: it opens the “${cn}” drawer and no other.`,
|
|
567
585
|
capsNoMember: ' (this device is no longer in the record)',
|
|
568
586
|
capsApplyHint: 'Enter ticks or unticks · G saves · Esc goes back to the list keeping your ticks',
|
|
569
587
|
capsPending: (n) => `${n} unsaved change(s) on this device (*) · G saves everything in ONE record`,
|
|
@@ -583,7 +601,6 @@ const en = {
|
|
|
583
601
|
// El título es el NOMBRE del permiso, el mismo que se teclea. Ver la nota en el bloque
|
|
584
602
|
// español: describirlo dejaba una lista donde no se sabía qué permiso era cuál.
|
|
585
603
|
capName: {
|
|
586
|
-
secrets: (cn) => `secrets «${cn}»`,
|
|
587
604
|
sign: 'sign',
|
|
588
605
|
store: 'store',
|
|
589
606
|
read: 'read',
|
|
@@ -597,7 +614,6 @@ const en = {
|
|
|
597
614
|
},
|
|
598
615
|
// Cada pista se sostiene sola: el título es la palabra, no una frase que continuar.
|
|
599
616
|
capHint: {
|
|
600
|
-
secrets: (cn) => `open the «${cn}» drawer and no other`,
|
|
601
617
|
sign: 'use your identity across the ecosystem apps',
|
|
602
618
|
store: 'write to your vault: profile, content, data',
|
|
603
619
|
read: 'see what you saved',
|
package/src/update.js
CHANGED
|
@@ -16,11 +16,22 @@ import { execFileSync } from 'node:child_process'
|
|
|
16
16
|
import { latestVersion, isNewer, CHECK_EVERY_MS } from '@dotrino/update'
|
|
17
17
|
import { pickAsset, download, verifyArtifact as verify, fetchVerified } from '@dotrino/update/fetch'
|
|
18
18
|
import { VERSION } from './version.js'
|
|
19
|
+
import { ipcWrite } from './ipc.js'
|
|
19
20
|
|
|
20
21
|
export const REPO = 'imdotrino/dotrino-vault'
|
|
21
22
|
export { isNewer, CHECK_EVERY_MS, download }
|
|
22
23
|
|
|
23
24
|
/** La última release del vault. `{ ok:false, reason }` si no se pudo mirar, que NO es «al día». */
|
|
25
|
+
/**
|
|
26
|
+
* DEJA DICHO QUE SE INSTALÓ una versión nueva, para que el servicio se lo cuente a quien
|
|
27
|
+
* aprueba cuando arranque con ella (`anunciarActualizacion` en daemon.js). Es el mismo
|
|
28
|
+
* marcador que deja el servicio cuando se actualiza solo, y lo dejan igual el comando
|
|
29
|
+
* `update` y la TUI.
|
|
30
|
+
*/
|
|
31
|
+
export function noteUpdated (dir, { from, to }) {
|
|
32
|
+
ipcWrite(path.join(dir, 'updated.json'), { v: 1, from, to, at: Date.now() })
|
|
33
|
+
}
|
|
34
|
+
|
|
24
35
|
export const latestRelease = (opts = {}) =>
|
|
25
36
|
latestVersion({ source: 'github', repo: REPO, product: 'dotrino-vault', version: VERSION, ...opts })
|
|
26
37
|
|