@dotrino/vaultd 0.6.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 +301 -0
- package/bin/dotrino-vault-tui.js +28 -0
- package/bin/dotrino-vault.js +23 -0
- package/bin/dotrino-vaultd.js +29 -0
- package/bin/sea-entry.js +29 -0
- package/lib/README.md +139 -0
- package/lib/src/config.js +26 -0
- package/lib/src/enroll.js +293 -0
- package/lib/src/env.js +95 -0
- package/lib/src/index.js +166 -0
- package/lib/src/protocol.js +53 -0
- package/lib/src/sealed.js +84 -0
- package/lib/src/service.js +258 -0
- package/package.json +41 -0
- package/src/atrest.js +0 -0
- package/src/client.js +149 -0
- package/src/ctl.js +597 -0
- package/src/daemon.js +217 -0
- package/src/manager.js +88 -0
- package/src/node-globals.js +37 -0
- package/src/paths.js +47 -0
- package/src/profiles.js +214 -0
- package/src/protocol.js +6 -0
- package/src/qr.js +61 -0
- package/src/secretsStore.js +61 -0
- package/src/store.js +64 -0
- package/src/threadStore.js +111 -0
- package/src/transport.js +64 -0
- package/src/tui/app.js +722 -0
- package/src/tui/term.js +278 -0
- package/src/vault.js +303 -0
- package/src/vaultControl.js +296 -0
- package/vendor/qrcode-generator.cjs +2297 -0
package/src/tui/term.js
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* term.js — toolkit de terminal a pantalla completa, SIN dependencias.
|
|
3
|
+
*
|
|
4
|
+
* Dibuja con escapes ANSI y lee el teclado en raw mode (mismo enfoque que el
|
|
5
|
+
* lector de contraseña de la CLI, `ctl.js`). No usamos librerías de terceros: el
|
|
6
|
+
* vault custodia la maestra y su superficie de dependencias se mantiene mínima
|
|
7
|
+
* (regla de cadena de suministro, CONVENCIONES §1.1).
|
|
8
|
+
*
|
|
9
|
+
* API:
|
|
10
|
+
* const term = createTerm()
|
|
11
|
+
* term.size() -> { cols, rows }
|
|
12
|
+
* await term.readKey() -> { name, ch? } (up/down/left/right/enter/backspace/
|
|
13
|
+
* escape/tab/char/ctrl-c/…/resize)
|
|
14
|
+
* term.render(lines) // lines: string[]; posiciona, recorta y limpia el resto
|
|
15
|
+
* term.close() // restaura el terminal SIEMPRE (idempotente)
|
|
16
|
+
* term.t // helpers de estilo/ancho (ver `theme`)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { StringDecoder } from 'node:string_decoder'
|
|
20
|
+
|
|
21
|
+
const ESC = '\x1b'
|
|
22
|
+
const CSI = ESC + '['
|
|
23
|
+
|
|
24
|
+
const supportsColor = () =>
|
|
25
|
+
process.stdout.isTTY && process.env.NO_COLOR == null && process.env.TERM !== 'dumb'
|
|
26
|
+
|
|
27
|
+
// -------- ancho visible (ignora escapes ANSI, cuenta emojis/CJK como 2) --------
|
|
28
|
+
|
|
29
|
+
const ANSI_RE = /\x1b\[[0-9;?]*[A-Za-z]/g
|
|
30
|
+
export const stripAnsi = (s) => String(s).replace(ANSI_RE, '')
|
|
31
|
+
|
|
32
|
+
function charWidth (cp) {
|
|
33
|
+
if (cp === 0) return 0
|
|
34
|
+
if (cp < 32 || (cp >= 0x7f && cp < 0xa0)) return 0
|
|
35
|
+
if (cp >= 0x300 && cp <= 0x36f) return 0 // combinantes
|
|
36
|
+
if (cp === 0x200d || cp === 0xfe0f || cp === 0xfe0e) return 0 // ZWJ + selectores de variación
|
|
37
|
+
if (
|
|
38
|
+
(cp >= 0x1100 && cp <= 0x115f) ||
|
|
39
|
+
(cp >= 0x231a && cp <= 0x231b) || // ⌚⌛
|
|
40
|
+
(cp >= 0x23e9 && cp <= 0x23fa) || // ⏩…⏺ (incluye ⏳ reloj de arena)
|
|
41
|
+
(cp >= 0x2e80 && cp <= 0xa4cf && cp !== 0x303f) ||
|
|
42
|
+
(cp >= 0xac00 && cp <= 0xd7a3) ||
|
|
43
|
+
(cp >= 0xf900 && cp <= 0xfaff) ||
|
|
44
|
+
(cp >= 0xfe30 && cp <= 0xfe6f) ||
|
|
45
|
+
(cp >= 0xff00 && cp <= 0xff60) ||
|
|
46
|
+
(cp >= 0xffe0 && cp <= 0xffe6) ||
|
|
47
|
+
(cp >= 0x1f000 && cp <= 0x1faff) ||
|
|
48
|
+
(cp >= 0x2600 && cp <= 0x27bf) // símbolos misc + dingbats (✓ ✗ ⚠ …)
|
|
49
|
+
) return 2
|
|
50
|
+
return 1
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Ancho visible de una string (sin contar escapes ANSI). */
|
|
54
|
+
export function widthOf (s) {
|
|
55
|
+
let w = 0
|
|
56
|
+
for (const ch of stripAnsi(s)) w += charWidth(ch.codePointAt(0))
|
|
57
|
+
return w
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Recorta a `max` columnas visibles, preservando escapes y cerrando color. */
|
|
61
|
+
export function trunc (s, max) {
|
|
62
|
+
if (max <= 0) return ''
|
|
63
|
+
let out = ''
|
|
64
|
+
let w = 0
|
|
65
|
+
let hadStyle = false
|
|
66
|
+
let i = 0
|
|
67
|
+
const str = String(s)
|
|
68
|
+
while (i < str.length) {
|
|
69
|
+
if (str[i] === '\x1b') {
|
|
70
|
+
const m = str.slice(i).match(/^\x1b\[[0-9;?]*[A-Za-z]/)
|
|
71
|
+
if (m) { out += m[0]; hadStyle = true; i += m[0].length; continue }
|
|
72
|
+
}
|
|
73
|
+
const cp = str.codePointAt(i)
|
|
74
|
+
const ch = String.fromCodePoint(cp)
|
|
75
|
+
const cw = charWidth(cp)
|
|
76
|
+
if (w + cw > max) { out += hadStyle ? CSI + '0m' : ''; return out }
|
|
77
|
+
out += ch; w += cw; i += ch.length
|
|
78
|
+
}
|
|
79
|
+
return out
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Rellena con espacios hasta `width` columnas visibles (para barras sólidas). */
|
|
83
|
+
export function padEnd (s, width) {
|
|
84
|
+
const w = widthOf(s)
|
|
85
|
+
return w >= width ? trunc(s, width) : s + ' '.repeat(width - w)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ------------------------------- tema/estilos -------------------------------
|
|
89
|
+
|
|
90
|
+
export function makeTheme () {
|
|
91
|
+
const on = supportsColor()
|
|
92
|
+
const sgr = (...c) => (on ? CSI + c.join(';') + 'm' : '')
|
|
93
|
+
const R = on ? CSI + '0m' : ''
|
|
94
|
+
const wrap = (open) => (s) => on ? open + s + R : s
|
|
95
|
+
return {
|
|
96
|
+
on,
|
|
97
|
+
reset: R,
|
|
98
|
+
bold: wrap(sgr(1)),
|
|
99
|
+
dim: wrap(sgr(2)),
|
|
100
|
+
accent: wrap(sgr(38, 5, 44)), // cian
|
|
101
|
+
ok: wrap(sgr(38, 5, 114)), // verde
|
|
102
|
+
warn: wrap(sgr(38, 5, 214)), // ámbar
|
|
103
|
+
danger: wrap(sgr(38, 5, 203)), // rojo
|
|
104
|
+
muted: wrap(sgr(38, 5, 244)), // gris
|
|
105
|
+
title: wrap(sgr(1) + sgr(38, 5, 81)),
|
|
106
|
+
/**
|
|
107
|
+
* Barra sólida a todo lo ancho (header/ayuda). Se le quita el color interno:
|
|
108
|
+
* un `\x1b[0m` intermedio cortaría el fondo a media línea.
|
|
109
|
+
*/
|
|
110
|
+
bar: (text, cols) => {
|
|
111
|
+
const body = padEnd(' ' + stripAnsi(text), cols) // padEnd RECORTA si excede (ancho visible)
|
|
112
|
+
return on ? sgr(48, 5, 236) + sgr(38, 5, 252) + body + R : body
|
|
113
|
+
},
|
|
114
|
+
/**
|
|
115
|
+
* Fila seleccionada de una lista: fondo uniforme a todo lo ancho. Se quita el
|
|
116
|
+
* color interno del texto para que ningún reset intermedio corte el resaltado.
|
|
117
|
+
*/
|
|
118
|
+
sel: (text, cols) => {
|
|
119
|
+
const body = padEnd(stripAnsi(text), cols)
|
|
120
|
+
return on ? sgr(48, 5, 24) + sgr(38, 5, 231) + body + R : CSI + '7m' + body + CSI + '0m'
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// --------------------------------- teclado ----------------------------------
|
|
126
|
+
|
|
127
|
+
function parseChunk (s, push) {
|
|
128
|
+
let i = 0
|
|
129
|
+
const arrow = { A: 'up', B: 'down', C: 'right', D: 'left', H: 'home', F: 'end' }
|
|
130
|
+
while (i < s.length) {
|
|
131
|
+
const c = s[i]
|
|
132
|
+
if (c === '\x1b') {
|
|
133
|
+
const n = s[i + 1]
|
|
134
|
+
if ((n === '[' || n === 'O')) {
|
|
135
|
+
const third = s[i + 2]
|
|
136
|
+
if (arrow[third]) { push({ name: arrow[third] }); i += 3; continue }
|
|
137
|
+
if (/[0-9]/.test(third)) {
|
|
138
|
+
let j = i + 2; let num = ''
|
|
139
|
+
while (j < s.length && /[0-9;]/.test(s[j])) { num += s[j]; j++ }
|
|
140
|
+
const fin = s[j]
|
|
141
|
+
const seq = { 3: 'delete', 5: 'pageup', 6: 'pagedown', 1: 'home', 4: 'end' }
|
|
142
|
+
if (fin === '~' && seq[num]) { push({ name: seq[num] }); i = j + 1; continue }
|
|
143
|
+
i = (fin ? j + 1 : s.length); continue
|
|
144
|
+
}
|
|
145
|
+
i += 3; continue
|
|
146
|
+
}
|
|
147
|
+
push({ name: 'escape' }); i += 1; continue
|
|
148
|
+
}
|
|
149
|
+
if (c === '\r' || c === '\n') { push({ name: 'enter' }); i += 1; if (c === '\r' && s[i] === '\n') i += 1; continue }
|
|
150
|
+
if (c === '\x7f' || c === '\b') { push({ name: 'backspace' }); i += 1; continue }
|
|
151
|
+
if (c === '\t') { push({ name: 'tab' }); i += 1; continue }
|
|
152
|
+
if (c === '\x03') { push({ name: 'ctrl-c' }); i += 1; continue }
|
|
153
|
+
if (c === '\x04') { push({ name: 'ctrl-d' }); i += 1; continue }
|
|
154
|
+
if (c === '\x15') { push({ name: 'ctrl-u' }); i += 1; continue }
|
|
155
|
+
if (c === '\x17') { push({ name: 'ctrl-w' }); i += 1; continue }
|
|
156
|
+
if (c.charCodeAt(0) < 32) { i += 1; continue }
|
|
157
|
+
const cp = s.codePointAt(i)
|
|
158
|
+
const ch = String.fromCodePoint(cp)
|
|
159
|
+
push({ name: 'char', ch }); i += ch.length
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// -------------------------------- terminal ----------------------------------
|
|
164
|
+
|
|
165
|
+
export function createTerm () {
|
|
166
|
+
const out = process.stdout
|
|
167
|
+
const inp = process.stdin
|
|
168
|
+
const t = makeTheme()
|
|
169
|
+
|
|
170
|
+
// Cola de teclas + notificador. Las teclas SIEMPRE se encolan (no se pierden
|
|
171
|
+
// aunque venza un tick): `readKey(ms)` drena la cola o despierta por timeout.
|
|
172
|
+
const queue = []
|
|
173
|
+
let notify = null
|
|
174
|
+
const push = (k) => { queue.push(k); if (notify) { const n = notify; notify = null; n() } }
|
|
175
|
+
|
|
176
|
+
// Entrada de teclado robusta ante fragmentación (SSH / ptys lentas):
|
|
177
|
+
// · StringDecoder reensambla caracteres UTF-8 multibyte partidos entre chunks.
|
|
178
|
+
// · Una secuencia de escape incompleta al final del chunk se GUARDA y se
|
|
179
|
+
// antepone al siguiente; un ESC solitario se emite tras un breve timeout.
|
|
180
|
+
const decoder = new StringDecoder('utf8')
|
|
181
|
+
let pendingEsc = ''
|
|
182
|
+
let escTimer = null
|
|
183
|
+
const clearEscTimer = () => { if (escTimer) { clearTimeout(escTimer); escTimer = null } }
|
|
184
|
+
// Devuelve el índice donde empieza una secuencia de escape INCOMPLETA al final
|
|
185
|
+
// de `s` (para retenerla), o -1 si no hay nada que retener.
|
|
186
|
+
const incompleteTailStart = (s) => {
|
|
187
|
+
const k = s.lastIndexOf('\x1b')
|
|
188
|
+
if (k < 0) return -1
|
|
189
|
+
const tail = s.slice(k)
|
|
190
|
+
if (tail === '\x1b') return k // ESC pelado
|
|
191
|
+
if (tail[1] === '[') return /[A-Za-z~]/.test(tail.slice(2)) ? -1 : k // CSI sin byte final
|
|
192
|
+
if (tail[1] === 'O') return tail.length >= 3 ? -1 : k // SS3 sin byte final
|
|
193
|
+
return -1 // ESC + otra cosa: que lo maneje parseChunk ya mismo
|
|
194
|
+
}
|
|
195
|
+
const handleInput = (str) => {
|
|
196
|
+
clearEscTimer()
|
|
197
|
+
let s = pendingEsc + str
|
|
198
|
+
pendingEsc = ''
|
|
199
|
+
const hold = incompleteTailStart(s)
|
|
200
|
+
if (hold >= 0) { pendingEsc = s.slice(hold); s = s.slice(0, hold) }
|
|
201
|
+
if (s) parseChunk(s, push)
|
|
202
|
+
if (pendingEsc) {
|
|
203
|
+
// Si nada más llega pronto, era un ESC de verdad (tecla Escape).
|
|
204
|
+
escTimer = setTimeout(() => { escTimer = null; if (pendingEsc) { pendingEsc = ''; push({ name: 'escape' }) } }, 50)
|
|
205
|
+
escTimer.unref?.()
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const onData = (buf) => handleInput(decoder.write(buf))
|
|
210
|
+
const onResize = () => push({ name: 'resize' })
|
|
211
|
+
|
|
212
|
+
let closed = false
|
|
213
|
+
const write = (s) => { try { out.write(s) } catch (_) {} }
|
|
214
|
+
|
|
215
|
+
function open () {
|
|
216
|
+
write(CSI + '?1049h') // pantalla alterna
|
|
217
|
+
write(CSI + '?25l') // ocultar cursor
|
|
218
|
+
write(CSI + '2J' + CSI + 'H')
|
|
219
|
+
if (inp.isTTY) inp.setRawMode(true)
|
|
220
|
+
inp.resume()
|
|
221
|
+
inp.on('data', onData)
|
|
222
|
+
out.on('resize', onResize)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function close () {
|
|
226
|
+
if (closed) return
|
|
227
|
+
closed = true
|
|
228
|
+
clearEscTimer()
|
|
229
|
+
try { inp.off('data', onData) } catch (_) {}
|
|
230
|
+
try { out.off('resize', onResize) } catch (_) {}
|
|
231
|
+
if (inp.isTTY) { try { inp.setRawMode(false) } catch (_) {} }
|
|
232
|
+
try { inp.pause() } catch (_) {}
|
|
233
|
+
write(CSI + '?25h') // mostrar cursor
|
|
234
|
+
write(CSI + '?1049l') // salir de pantalla alterna
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Red de seguridad: restaurar el terminal pase lo que pase (salida normal,
|
|
238
|
+
// señales de terminación y cierre del terminal —SIGHUP—). En raw mode Ctrl-C
|
|
239
|
+
// llega como \x03 y lo maneja el loop; el manejador de SIGINT es un respaldo.
|
|
240
|
+
const onExit = () => close()
|
|
241
|
+
process.on('exit', onExit)
|
|
242
|
+
const onKill = (sig, code) => { close(); process.exit(code) }
|
|
243
|
+
process.on('SIGTERM', () => onKill('SIGTERM', 0))
|
|
244
|
+
process.on('SIGHUP', () => onKill('SIGHUP', 129))
|
|
245
|
+
process.on('SIGINT', () => onKill('SIGINT', 130))
|
|
246
|
+
|
|
247
|
+
open()
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
t,
|
|
251
|
+
size () { return { cols: out.columns || 80, rows: out.rows || 24 } },
|
|
252
|
+
/**
|
|
253
|
+
* Espera la próxima tecla. Con `ms`, despierta también por timeout devolviendo
|
|
254
|
+
* `{ name: 'tick' }` (para refrescar pantallas que esperan un evento externo).
|
|
255
|
+
*/
|
|
256
|
+
async readKey (ms = 0) {
|
|
257
|
+
if (queue.length) return queue.shift()
|
|
258
|
+
let timer = null
|
|
259
|
+
await new Promise((res) => {
|
|
260
|
+
notify = res
|
|
261
|
+
if (ms > 0) timer = setTimeout(() => { if (notify) { notify = null; res() } }, ms)
|
|
262
|
+
})
|
|
263
|
+
if (timer) clearTimeout(timer)
|
|
264
|
+
return queue.length ? queue.shift() : { name: 'tick' }
|
|
265
|
+
},
|
|
266
|
+
/** Dibuja `lines` desde arriba; recorta al ancho y limpia lo que sobre. */
|
|
267
|
+
render (lines) {
|
|
268
|
+
const { cols } = this.size()
|
|
269
|
+
let s = CSI + 'H'
|
|
270
|
+
for (let i = 0; i < lines.length; i++) {
|
|
271
|
+
s += CSI + (i + 1) + ';1H' + trunc(lines[i] ?? '', cols) + t.reset + CSI + 'K'
|
|
272
|
+
}
|
|
273
|
+
s += CSI + 'J' // limpia de la última línea hacia abajo
|
|
274
|
+
write(s)
|
|
275
|
+
},
|
|
276
|
+
close
|
|
277
|
+
}
|
|
278
|
+
}
|
package/src/vault.js
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dotrino-vault — núcleo del certificador personal (daemon headless).
|
|
3
|
+
*
|
|
4
|
+
* Custodia la clave MAESTRA del usuario (vía `@dotrino/identity`) y la expone como
|
|
5
|
+
* CA propia. EMPAREJAMIENTO ENDURECIDO (ver docs/pairing-protocol.md): el token de
|
|
6
|
+
* 5 min ya NO es autoridad suficiente — para obtener un cert el dispositivo debe
|
|
7
|
+
* (1) PROBAR posesión de su llave D firmando el ENROLL, y (2) el dueño debe APROBAR
|
|
8
|
+
* en el PC tras comparar un SAS (código de 6 dígitos) entre las dos pantallas. La
|
|
9
|
+
* maestra solo firma el cert DESPUÉS de esa aprobación humana.
|
|
10
|
+
*
|
|
11
|
+
* Toda la cripto es de `@dotrino/identity`. Este módulo solo orquesta.
|
|
12
|
+
*/
|
|
13
|
+
import fs from 'node:fs'
|
|
14
|
+
import path from 'node:path'
|
|
15
|
+
import { Identity } from '@dotrino/identity/node'
|
|
16
|
+
import { verifyChain, pubkeyId } from '@dotrino/identity/capabilities'
|
|
17
|
+
import * as Acta from '@dotrino/identity/acta'
|
|
18
|
+
import { createEnrollDesk, deviceIdOf } from '../lib/src/enroll.js'
|
|
19
|
+
import { createTransport, masterPubkeyOf } from './transport.js'
|
|
20
|
+
import { openStore } from './store.js'
|
|
21
|
+
import { openThreadStore, STORE_READ_METHODS, PROFILE_EDIT_METHODS } from './threadStore.js'
|
|
22
|
+
import { openSecretsStore } from './secretsStore.js'
|
|
23
|
+
import { seal } from '../lib/src/sealed.js'
|
|
24
|
+
import { dataDir, ensureDir } from './paths.js'
|
|
25
|
+
import { atRestFor, machineKey, migrateFile } from './atrest.js'
|
|
26
|
+
import { MSG, SCOPE, secretsScope, isValidSecretsNs } from './protocol.js'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Abre UN perfil del vault (una maestra, un dir, una conexión al proxy). El
|
|
30
|
+
* daemon multi-perfil (`manager.js`) levanta uno de estos por perfil.
|
|
31
|
+
*
|
|
32
|
+
* @param {Object} [opts]
|
|
33
|
+
* @param {string} [opts.dir] Dir de datos de ESTE perfil.
|
|
34
|
+
* @param {() => boolean} [opts.isLocked] Candado del perfil (contraseña opcional).
|
|
35
|
+
* Solo bloquea EDITAR el perfil (`profileSet`): firmar/leer y el resto del store
|
|
36
|
+
* siguen sirviendo a los dispositivos enrolados aunque esté bloqueado.
|
|
37
|
+
*/
|
|
38
|
+
export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log, onEnrollChallenge, isLocked = () => false } = {}) {
|
|
39
|
+
ensureDir(dir)
|
|
40
|
+
// CIFRADO EN REPOSO ligado a esta máquina: `identity.json` deja de estar en claro, así
|
|
41
|
+
// que copiarlo a otro equipo no sirve de nada. No protege contra quien ya tiene ESTA
|
|
42
|
+
// máquina (puede leer el mismo material); es subir el listón, no una imposibilidad.
|
|
43
|
+
// La migración verifica antes de reemplazar: si algo falla, el original queda intacto.
|
|
44
|
+
try {
|
|
45
|
+
const r = migrateFile(path.join(dir, 'identity.json'), machineKey(dir))
|
|
46
|
+
if (r === 'migrado') log('[vault] identidad cifrada en reposo (ligada a esta máquina)')
|
|
47
|
+
} catch (e) { log('[vault] no se pudo cifrar la identidad en reposo:', e.message) }
|
|
48
|
+
const identity = await Identity.connect({ dir, atRest: atRestFor(dir) })
|
|
49
|
+
if (!identity.me?.publickey) await identity.setMyNickname('')
|
|
50
|
+
|
|
51
|
+
const store = openStore(dir)
|
|
52
|
+
const threads = openThreadStore(dir)
|
|
53
|
+
const secrets = openSecretsStore(dir)
|
|
54
|
+
const master = await masterPubkeyOf(identity)
|
|
55
|
+
const fp = (await pubkeyId(master)).slice(0, 16)
|
|
56
|
+
|
|
57
|
+
const { client } = await createTransport({ identity, dir, url: proxyUrl })
|
|
58
|
+
|
|
59
|
+
async function revocationSet () {
|
|
60
|
+
const { revoked } = await identity.listDelegations()
|
|
61
|
+
return new Set(revoked.map((r) => r.nonce))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const reply = (to, obj) => {
|
|
65
|
+
try { client.send(to, obj) } catch (e) { log('[vault] no se pudo responder:', e.message) }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// FRESCURA anti-replay: toda petición firmada debe traer `data.ts` dentro de una
|
|
69
|
+
// ventana de ±5 min (mismo criterio que el identify del proxy). Sin esto, un
|
|
70
|
+
// relay malicioso podía REPRODUCIR mensajes firmados viejos (re-pedir firmas,
|
|
71
|
+
// abrir renovaciones…) durante toda la vida del cert.
|
|
72
|
+
// AUDITORÍA: bitácora de actividad de seguridad (activity.log, JSONL) — qué
|
|
73
|
+
// dispositivo firmó/renovó/enroló y qué se rechazó. `dotrino-vault activity`
|
|
74
|
+
// la muestra. Sin contenido de payloads (privacidad): solo op, dispositivo, hora.
|
|
75
|
+
const activityFile = path.join(dir, 'activity.log')
|
|
76
|
+
const audit = (op, info = {}) => {
|
|
77
|
+
try {
|
|
78
|
+
fs.appendFileSync(activityFile, JSON.stringify({ ts: Date.now(), op, ...info }) + '\n')
|
|
79
|
+
// rotación simple: si pasa de ~1 MB, conservar la última mitad
|
|
80
|
+
const st = fs.statSync(activityFile)
|
|
81
|
+
if (st.size > 1024 * 1024) {
|
|
82
|
+
const lines = fs.readFileSync(activityFile, 'utf8').split('\n')
|
|
83
|
+
fs.writeFileSync(activityFile, lines.slice(Math.floor(lines.length / 2)).join('\n'))
|
|
84
|
+
}
|
|
85
|
+
} catch (_) {}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const FRESH_WINDOW_MS = 5 * 60 * 1000
|
|
89
|
+
const isFresh = (d) => typeof d?.ts === 'number' && Math.abs(Date.now() - d.ts) <= FRESH_WINDOW_MS
|
|
90
|
+
const staleReply = (from) => reply(from, { type: MSG.ERROR, error: 'petición vencida: ts fuera de la ventana ±5 min (posible replay, o el reloj del dispositivo está desfasado)' })
|
|
91
|
+
|
|
92
|
+
// --- ENROLL / aprobación / revocación: núcleo COMPARTIDO (lib/src/enroll.js) ---
|
|
93
|
+
// El mismo módulo lo usan «este dispositivo es bóveda» (@dotrino/vault) y la copia
|
|
94
|
+
// vendorizada del iframe de identidad: un solo sitio donde vive el flujo, y por lo
|
|
95
|
+
// tanto un solo sitio donde se comprueba el código antes de firmar el cert.
|
|
96
|
+
const desk = createEnrollDesk({
|
|
97
|
+
identity,
|
|
98
|
+
iss: master,
|
|
99
|
+
proxy: client.url,
|
|
100
|
+
send: (to, obj) => reply(to, obj),
|
|
101
|
+
sendByPubkey: (pub, obj) => client.sendByPubkey(pub, obj),
|
|
102
|
+
audit,
|
|
103
|
+
log,
|
|
104
|
+
defaultScope: [SCOPE.READ],
|
|
105
|
+
onChallenge ({ deviceId, scope }) {
|
|
106
|
+
log(`\n[vault] Un dispositivo quiere conectarse:`)
|
|
107
|
+
log(` deviceId: ${deviceId}`)
|
|
108
|
+
log(` Ingresa el código que MUESTRA el dispositivo:`)
|
|
109
|
+
log(` dotrino-vault approve <código> (o rechaza: dotrino-vault reject ${deviceId})\n`)
|
|
110
|
+
try { onEnrollChallenge?.({ deviceId, scope }) } catch (_) {}
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
// --- handleSign / handleGet: idénticos (verifyChain de la cadena D←maestra) ---
|
|
115
|
+
async function handleSign (from, p) {
|
|
116
|
+
if (!isFresh(p.data)) { audit('rejected', { what: 'sign', reason: 'stale' }); return staleReply(from) }
|
|
117
|
+
const chk = await verifyChain({
|
|
118
|
+
data: p.data, signature: p.signature, cert: p.cert,
|
|
119
|
+
expectedScope: SCOPE.SIGN, trustedIssuer: master, revoked: await revocationSet()
|
|
120
|
+
})
|
|
121
|
+
if (!chk.ok) { audit('rejected', { what: 'sign', reason: chk.reason }); return reply(from, { type: MSG.ERROR, error: 'no autorizado: ' + chk.reason }) }
|
|
122
|
+
const toSign = p.data?.payload
|
|
123
|
+
if (toSign == null) return reply(from, { type: MSG.ERROR, error: 'data.payload requerido' })
|
|
124
|
+
const { signature, publickey } = await identity.signData(toSign)
|
|
125
|
+
audit('sign', { device: await deviceIdOf(chk.device) })
|
|
126
|
+
reply(from, { type: MSG.SIGNED, signature, publickey, device: chk.device })
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function handleGet (from, p) {
|
|
130
|
+
if (!isFresh(p.data)) return staleReply(from)
|
|
131
|
+
const chk = await verifyChain({
|
|
132
|
+
data: p.data, signature: p.signature, cert: p.cert,
|
|
133
|
+
expectedScope: SCOPE.READ, trustedIssuer: master, revoked: await revocationSet()
|
|
134
|
+
})
|
|
135
|
+
if (!chk.ok) return reply(from, { type: MSG.ERROR, error: 'no autorizado: ' + chk.reason })
|
|
136
|
+
const id = p.data?.id || 'root'
|
|
137
|
+
reply(from, { type: MSG.DATA, id, node: store.getNode(id) })
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Store de hilos+aperturas (Fase 3): escrituras requieren vault:store; lecturas
|
|
141
|
+
// aceptan vault:store o vault:read. Cada op va firmada por D + cert (cadena D←maestra).
|
|
142
|
+
async function handleStore (from, p) {
|
|
143
|
+
const d = p.data
|
|
144
|
+
if (!d || typeof d.method !== 'string' || !threads.methods[d.method]) {
|
|
145
|
+
return reply(from, { type: MSG.ERROR, error: 'store: método inválido' })
|
|
146
|
+
}
|
|
147
|
+
if (!isFresh(d)) return staleReply(from)
|
|
148
|
+
// CANDADO del perfil (contraseña opcional): solo frena EDITAR el perfil. Un
|
|
149
|
+
// dispositivo enrolado puede seguir firmando, leyendo y guardando contenido;
|
|
150
|
+
// lo que no puede es reescribir quién sos mientras el perfil está bloqueado.
|
|
151
|
+
if (PROFILE_EDIT_METHODS.has(d.method) && isLocked()) {
|
|
152
|
+
audit('rejected', { what: 'store', method: d.method, reason: 'locked' })
|
|
153
|
+
return reply(from, { type: MSG.ERROR, error: 'perfil bloqueado: desbloquéalo en el PC del vault (dotrino-vault unlock) para editarlo' })
|
|
154
|
+
}
|
|
155
|
+
const revoked = await revocationSet()
|
|
156
|
+
let chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, expectedScope: SCOPE.STORE, trustedIssuer: master, revoked })
|
|
157
|
+
if (!chk.ok && STORE_READ_METHODS.has(d.method)) {
|
|
158
|
+
chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, expectedScope: SCOPE.READ, trustedIssuer: master, revoked })
|
|
159
|
+
}
|
|
160
|
+
if (!chk.ok) return reply(from, { type: MSG.ERROR, error: 'no autorizado: ' + chk.reason })
|
|
161
|
+
try {
|
|
162
|
+
// CIFRADO de punta a punta con la clave de contenido del perfil: el proxy transporta
|
|
163
|
+
// pero no ve nada de lo que el usuario guarda. Si el dispositivo mandó `enc`, se abre
|
|
164
|
+
// aquí con la clave de la bóveda (que también es miembro) y la respuesta vuelve igual.
|
|
165
|
+
let args = d.args || {}
|
|
166
|
+
let cek = null
|
|
167
|
+
if (d.enc) {
|
|
168
|
+
cek = await identity.contentKey?.().catch(() => null)
|
|
169
|
+
if (!cek) return reply(from, { type: MSG.ERROR, error: 'store: esta bóveda no tiene la clave de contenido del perfil' })
|
|
170
|
+
args = JSON.parse(await identity.openContent(d.enc))
|
|
171
|
+
}
|
|
172
|
+
const result = await threads.methods[d.method](args)
|
|
173
|
+
if (cek) {
|
|
174
|
+
const enc = await identity.sealContent(JSON.stringify(result ?? null))
|
|
175
|
+
return reply(from, { type: MSG.STORE_RESULT, method: d.method, result: { __enc: enc } })
|
|
176
|
+
}
|
|
177
|
+
reply(from, { type: MSG.STORE_RESULT, method: d.method, result })
|
|
178
|
+
} catch (e) { reply(from, { type: MSG.ERROR, error: e.message }) }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Lista (solo lectura) de dispositivos enrolados, para un panel en el navegador.
|
|
182
|
+
// Cualquier cert válido tuyo puede verla; REVOCAR sigue siendo solo desde el PC.
|
|
183
|
+
async function handleDevices (from, p) {
|
|
184
|
+
if (!isFresh(p.data)) return staleReply(from)
|
|
185
|
+
const chk = await verifyChain({ data: p.data, signature: p.signature, cert: p.cert, trustedIssuer: master, revoked: await revocationSet() })
|
|
186
|
+
if (!chk.ok) return reply(from, { type: MSG.ERROR, error: 'no autorizado: ' + chk.reason })
|
|
187
|
+
const { issued, revoked } = await identity.listDelegations()
|
|
188
|
+
// El acta viaja con la lista: así cada dispositivo se entera de los cambios de
|
|
189
|
+
// política (quién manda, quién puede qué) sin un canal aparte.
|
|
190
|
+
const acta = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
191
|
+
// Si el dispositivo estuvo apagado y viene con un `seq` viejo, se le manda la CADENA
|
|
192
|
+
// que falta (ventana de retención, §1.3) para que compruebe el encadenamiento en vez
|
|
193
|
+
// de tragarse un salto a ciegas. Si se salió de la ventana, llega vacía y toca
|
|
194
|
+
// re-emparejar — que es justo lo que debe pasar.
|
|
195
|
+
const chain = typeof p.data?.sinceSeq === 'number'
|
|
196
|
+
? (await identity.actaHistory({ sinceSeq: p.data.sinceSeq }).catch(() => null))?.chain || null
|
|
197
|
+
: null
|
|
198
|
+
// `sub` (pubkey completa) va incluida: es la DIRECCIÓN de cada dispositivo en el
|
|
199
|
+
// proxy → permite a las apps AUTODESCUBRIR tus máquinas (p. ej. la terminal
|
|
200
|
+
// lista tus agentes sin pegar nada). Solo la ve quien presenta un cert tuyo válido.
|
|
201
|
+
const devices = await Promise.all(issued.map(async (x) => ({
|
|
202
|
+
deviceId: x.sub ? await deviceIdOf(x.sub) : null, sub: x.sub || null, label: x.label || '', scope: x.scope, exp: x.exp, nonce: x.nonce
|
|
203
|
+
})))
|
|
204
|
+
reply(from, { type: MSG.DEVICES_RESULT, devices, revoked, acta, chain })
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// RENOVACIÓN automática: un dispositivo con cert VIGENTE y no revocado pide un
|
|
208
|
+
// cert fresco (misma sub-clave y scope) sin QR ni aprobación — sigue siendo el
|
|
209
|
+
// mismo dispositivo enrolado, solo extiende la ventana. Un cert vencido o
|
|
210
|
+
// revocado NO puede renovarse (ahí sí toca re-emparejar con aprobación).
|
|
211
|
+
const RENEW_TTL_MS = 30 * 24 * 60 * 60 * 1000
|
|
212
|
+
async function handleRenew (from, p) {
|
|
213
|
+
if (!isFresh(p.data)) { audit('rejected', { what: 'renew', reason: 'stale' }); return staleReply(from) }
|
|
214
|
+
const chk = await verifyChain({ data: p.data, signature: p.signature, cert: p.cert, trustedIssuer: master, revoked: await revocationSet() })
|
|
215
|
+
if (!chk.ok) { audit('rejected', { what: 'renew', reason: chk.reason }); return reply(from, { type: MSG.ERROR, error: 'no autorizado: ' + chk.reason }) }
|
|
216
|
+
// Reusar el label del cert original (si sigue registrado en delegations).
|
|
217
|
+
const { issued } = await identity.listDelegations()
|
|
218
|
+
const prev = (issued || []).find((x) => x.nonce === p.cert.nonce)
|
|
219
|
+
const { cert } = await identity.signDelegation(p.cert.sub, p.cert.scope, { ttlMs: RENEW_TTL_MS, label: prev?.label || '' })
|
|
220
|
+
audit('renew', { device: await deviceIdOf(p.cert.sub), label: prev?.label || '' })
|
|
221
|
+
log(`[vault] cert renovado para ${await deviceIdOf(p.cert.sub)} (30 días)`)
|
|
222
|
+
reply(from, { type: MSG.RENEWED, cert })
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// SECRETOS de servicios: un servicio enrolado (cert `vault:secrets:<ns>`)
|
|
226
|
+
// pide el bundle de su namespace. La respuesta va SELLADA a la llave ECDH
|
|
227
|
+
// efímera `ek` que vino en el sobre firmado (el proxy transporta pero no
|
|
228
|
+
// puede leer los valores) y el cuerpo va FIRMADO por la maestra (el
|
|
229
|
+
// servicio verifica contra su iss pineada — un relay no puede inyectar
|
|
230
|
+
// secretos falsos). Replay inerte: cada petición usa una ek nueva.
|
|
231
|
+
// data: { op:'secrets', ns, ek, publickey, ts }
|
|
232
|
+
async function handleSecrets (from, p) {
|
|
233
|
+
if (!isFresh(p.data)) { audit('rejected', { what: 'secrets', reason: 'stale' }); return staleReply(from) }
|
|
234
|
+
const ns = p.data?.ns
|
|
235
|
+
if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: 'secrets: namespace inválido' })
|
|
236
|
+
if (typeof p.data?.ek !== 'string') return reply(from, { type: MSG.ERROR, error: 'secrets: falta ek (llave efímera del solicitante)' })
|
|
237
|
+
const chk = await verifyChain({
|
|
238
|
+
data: p.data, signature: p.signature, cert: p.cert,
|
|
239
|
+
expectedScope: secretsScope(ns), trustedIssuer: master, revoked: await revocationSet()
|
|
240
|
+
})
|
|
241
|
+
if (!chk.ok) { audit('rejected', { what: 'secrets', ns, reason: chk.reason }); return reply(from, { type: MSG.ERROR, error: 'no autorizado: ' + chk.reason }) }
|
|
242
|
+
// FRONTERA DEL CN (acta): además del scope del cert, el acta tiene que decir que este
|
|
243
|
+
// miembro es el servicio `ns`. Así el límite no depende solo de qué cert se emitió: la
|
|
244
|
+
// llave del proxy no ve nada que no sea del proxy, y está escrito donde se puede comprobar.
|
|
245
|
+
const acta = (await identity.profileActa?.().catch(() => null))?.acta
|
|
246
|
+
if (acta && !Acta.memberCanReadSecrets(acta, chk.device, ns)) {
|
|
247
|
+
audit('rejected', { what: 'secrets', ns, reason: 'cn' })
|
|
248
|
+
return reply(from, { type: MSG.ERROR, error: `no autorizado: cn — el acta no reconoce a este miembro como el servicio «${ns}»` })
|
|
249
|
+
}
|
|
250
|
+
let enc
|
|
251
|
+
try {
|
|
252
|
+
enc = await seal({ ek: p.data.ek, payload: { secrets: secrets.get(ns) } })
|
|
253
|
+
} catch (e) {
|
|
254
|
+
return reply(from, { type: MSG.ERROR, error: 'secrets: ek inválida' })
|
|
255
|
+
}
|
|
256
|
+
const body = { op: 'secrets.result', ns, enc, ts: Date.now() }
|
|
257
|
+
const { signature } = await identity.signData(body)
|
|
258
|
+
audit('secrets', { device: await deviceIdOf(chk.device), ns })
|
|
259
|
+
reply(from, { type: MSG.SECRETS_RESULT, body, signature })
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
client.on('message', async (from, payload) => {
|
|
263
|
+
if (!payload || typeof payload !== 'object') return
|
|
264
|
+
try {
|
|
265
|
+
if (payload.type === MSG.ENROLL) return await desk.handleEnroll(from, payload)
|
|
266
|
+
if (payload.type === MSG.SIGN) return await handleSign(from, payload)
|
|
267
|
+
if (payload.type === MSG.GET) return await handleGet(from, payload)
|
|
268
|
+
if (payload.type === MSG.STORE) return await handleStore(from, payload)
|
|
269
|
+
if (payload.type === MSG.DEVICES) return await handleDevices(from, payload)
|
|
270
|
+
if (payload.type === MSG.RENEW) return await handleRenew(from, payload)
|
|
271
|
+
if (payload.type === MSG.SECRETS) return await handleSecrets(from, payload)
|
|
272
|
+
} catch (e) {
|
|
273
|
+
reply(from, { type: MSG.ERROR, error: e.message })
|
|
274
|
+
}
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
log(`[vault] listo · id ${fp} · ${store.getTree().children.length} nodos`)
|
|
278
|
+
|
|
279
|
+
// ----- API local (CLI/UI de control) -----
|
|
280
|
+
// Emparejar / aprobar / rechazar / revocar viven en el núcleo compartido (`desk`).
|
|
281
|
+
|
|
282
|
+
// API local de secretos (solo CLI/UI del dueño; audita cada cambio).
|
|
283
|
+
function setSecret (ns, key, value) { secrets.set(ns, key, value); audit('secret.set', { ns, key }) }
|
|
284
|
+
function deleteSecret (ns, key) { const ok = secrets.delete(ns, key); if (ok) audit('secret.rm', { ns, key }); return ok }
|
|
285
|
+
function listSecrets () { return secrets.list() }
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
identity, client, store, threads, secrets, master, fingerprint: fp,
|
|
289
|
+
startPairing: desk.startPairing,
|
|
290
|
+
stopPairing: desk.stopPairing,
|
|
291
|
+
listPending: desk.listPending,
|
|
292
|
+
approveDevice: (code) => desk.approve(code),
|
|
293
|
+
rejectDevice: (deviceId) => desk.reject(deviceId),
|
|
294
|
+
setSecret, deleteSecret, listSecrets,
|
|
295
|
+
listDevices: () => identity.listDelegations(),
|
|
296
|
+
// Acta del perfil (quién es del perfil y qué puede cada uno): lo que muestran
|
|
297
|
+
// `dotrino-vault members` y la consola de vault.dotrino.com.
|
|
298
|
+
profileMembers: () => identity.profileMembers(),
|
|
299
|
+
setCaps: (pub, caps) => identity.setCaps(pub, caps),
|
|
300
|
+
revokeDevice: (nonce) => desk.revoke(nonce),
|
|
301
|
+
close () { try { client.close() } catch (_) {} identity.destroy() }
|
|
302
|
+
}
|
|
303
|
+
}
|