@dotrino/vaultd 0.155.0 → 0.157.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/proc.js CHANGED
@@ -29,19 +29,10 @@
29
29
  */
30
30
  import fs from 'node:fs'
31
31
  import os from 'node:os'
32
- import crypto from 'node:crypto'
33
32
  import { cleanMachine } from '@dotrino/identity/machine'
33
+ import { MAX_PATH_BYTES, clip, trimArgv, sanitizeContext, commandFingerprint, sameCommand, commandLine } from './procContext.js'
34
34
 
35
- /** Topes. Un `argv` puede ser enorme y esto viaja en cada pedido y se pinta en un teléfono. */
36
- export const MAX_ARGV_BYTES = 4096
37
- export const MAX_PATH_BYTES = 1024
38
- /** Cuántos argumentos como mucho: más que esto no lo lee nadie. */
39
- export const MAX_ARGV_ITEMS = 64
40
-
41
- const clip = (s, max) => {
42
- if (typeof s !== 'string') return ''
43
- return Buffer.byteLength(s) <= max ? s : Buffer.from(s).subarray(0, max).toString('utf8')
44
- }
35
+ export { MAX_ARGV_BYTES, MAX_PATH_BYTES, MAX_ARGV_ITEMS, sanitizeContext, commandFingerprint, sameCommand, commandLine } from './procContext.js'
45
36
 
46
37
  /** ¿Hay `/proc`? Es lo que separa poder medir de tener que creer. */
47
38
  export const hasProc = () => { try { return fs.statSync('/proc/self').isDirectory() } catch (_) { return false } }
@@ -54,24 +45,6 @@ function readCmdline (pid) {
54
45
  return parts
55
46
  }
56
47
 
57
- /**
58
- * Recorta el `argv` a lo que se puede enseñar y transportar. Devuelve también si sobró
59
- * algo: un comando cortado que no dice que está cortado se lee como otro comando.
60
- */
61
- function trimArgv (argv) {
62
- const list = (Array.isArray(argv) ? argv : []).map((a) => String(a))
63
- let cut = list.length > MAX_ARGV_ITEMS
64
- const kept = list.slice(0, MAX_ARGV_ITEMS)
65
- let total = 0
66
- const out = []
67
- for (const a of kept) {
68
- const b = Buffer.byteLength(a)
69
- if (total + b > MAX_ARGV_BYTES) { cut = true; break }
70
- total += b
71
- out.push(a)
72
- }
73
- return { argv: out, truncated: cut }
74
- }
75
48
 
76
49
  /**
77
50
  * LA HORA DE ARRANQUE del proceso (campo 22 de `/proc/<pid>/stat`, en ticks). Sirve para
@@ -146,59 +119,4 @@ export function selfMachine () {
146
119
  const safeCwd = () => { try { return process.cwd() } catch (_) { return '' } }
147
120
  const userName = () => { try { return os.userInfo().username } catch (_) { return '' } }
148
121
 
149
- /**
150
- * LO QUE LLEGA POR LA RED NO SE CREE NI EN LA FORMA. Este dato lo manda otro proceso, se
151
- * guarda en memoria y se pinta en un teléfono: se recorta a lo que cabe y se tira lo que no
152
- * tiene el tipo que debería. Devuelve `null` si no queda nada que enseñar — que no es lo
153
- * mismo que un contexto vacío, y por eso no se devuelve un objeto a medias.
154
- */
155
- export function sanitizeContext (raw) {
156
- if (!raw || typeof raw !== 'object') return null
157
- const argvRaw = Array.isArray(raw.argv) ? raw.argv.filter((a) => typeof a === 'string') : []
158
- const { argv, truncated } = trimArgv(argvRaw)
159
- const exe = clip(typeof raw.exe === 'string' ? raw.exe : '', MAX_PATH_BYTES)
160
- const cwd = clip(typeof raw.cwd === 'string' ? raw.cwd : '', MAX_PATH_BYTES)
161
- if (!exe && !argv.length) return null
162
- return {
163
- pid: Number.isInteger(raw.pid) && raw.pid > 0 ? raw.pid : null,
164
- uid: Number.isInteger(raw.uid) ? raw.uid : null,
165
- exe,
166
- cwd,
167
- argv,
168
- truncated: truncated || raw.truncated === true,
169
- start: Number.isFinite(raw.start) ? raw.start : null,
170
- user: clip(typeof raw.user === 'string' ? raw.user : '', 64),
171
- host: clip(typeof raw.host === 'string' ? raw.host : '', 64)
172
- }
173
- }
174
-
175
- /**
176
- * LA HUELLA DEL COMANDO: qué binario, desde qué carpeta y con qué argumentos. Es la llave
177
- * de la concesión por tiempo — aprobar `node server.js` en `/srv/app` no aprueba otra cosa
178
- * ni la misma cosa en otro sitio.
179
- *
180
- * El pid NO entra: cambia en cada arranque y la gracia es justamente que el mismo comando
181
- * se reconozca la próxima vez.
182
- */
183
- export function commandFingerprint (ctx) {
184
- if (!ctx || typeof ctx !== 'object') return null
185
- const argv = Array.isArray(ctx.argv) ? ctx.argv : []
186
- if (!ctx.exe && !argv.length) return null
187
- const material = [ctx.exe || '', ctx.cwd || '', ...argv].join('\0')
188
- return crypto.createHash('sha256').update(material).digest('hex')
189
- }
190
-
191
- /** ¿Son el mismo comando? Mismo binario, misma carpeta, mismos argumentos. */
192
- export const sameCommand = (a, b) => {
193
- const fa = commandFingerprint(a)
194
- return !!fa && fa === commandFingerprint(b)
195
- }
196
-
197
- /** El comando en una línea, para un log o una pantalla estrecha. */
198
- export function commandLine (ctx, max = 160) {
199
- const argv = Array.isArray(ctx?.argv) ? ctx.argv : []
200
- const linea = argv.length ? argv.join(' ') : (ctx?.exe || '')
201
- return linea.length > max ? linea.slice(0, max - 1) + '…' : linea
202
- }
203
-
204
122
  export default { selfContext, readProcContext, sanitizeContext, commandFingerprint, sameCommand, commandLine, hasProc }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * LA PARTE PORTABLE DE `proc.js`: limpiar el contexto que declara quien pide, su huella y su
3
+ * línea. La usan el binario y la bóveda del navegador (que no tiene `/proc` y nunca mide,
4
+ * así que todo lo que le llega se queda como declarado). Leer `/proc` sigue en `proc.js`,
5
+ * que es solo de Node.
6
+ */
7
+ import { sha256Hex } from './core/sha256.js'
8
+
9
+ /** Topes. Un `argv` puede ser enorme y esto viaja en cada pedido y se pinta en un teléfono. */
10
+ export const MAX_ARGV_BYTES = 4096
11
+ export const MAX_PATH_BYTES = 1024
12
+ /** Cuántos argumentos como mucho: más que esto no lo lee nadie. */
13
+ export const MAX_ARGV_ITEMS = 64
14
+
15
+ const enc = new TextEncoder()
16
+ const byteLength = (s) => enc.encode(s).length
17
+ export const clip = (s, max) => {
18
+ if (typeof s !== 'string') return ''
19
+ const b = enc.encode(s)
20
+ return b.length <= max ? s : new TextDecoder().decode(b.subarray(0, max))
21
+ }
22
+
23
+ /**
24
+ * Recorta el `argv` a lo que se puede enseñar y transportar. Devuelve también si sobró
25
+ * algo: un comando cortado que no dice que está cortado se lee como otro comando.
26
+ */
27
+ export function trimArgv (argv) {
28
+ const list = (Array.isArray(argv) ? argv : []).map((a) => String(a))
29
+ let cut = list.length > MAX_ARGV_ITEMS
30
+ const kept = list.slice(0, MAX_ARGV_ITEMS)
31
+ let total = 0
32
+ const out = []
33
+ for (const a of kept) {
34
+ const b = byteLength(a)
35
+ if (total + b > MAX_ARGV_BYTES) { cut = true; break }
36
+ total += b
37
+ out.push(a)
38
+ }
39
+ return { argv: out, truncated: cut }
40
+ }
41
+
42
+ /**
43
+ * LO QUE LLEGA POR LA RED NO SE CREE NI EN LA FORMA. Este dato lo manda otro proceso, se
44
+ * guarda en memoria y se pinta en un teléfono: se recorta a lo que cabe y se tira lo que no
45
+ * tiene el tipo que debería. Devuelve `null` si no queda nada que enseñar — que no es lo
46
+ * mismo que un contexto vacío, y por eso no se devuelve un objeto a medias.
47
+ */
48
+ export function sanitizeContext (raw) {
49
+ if (!raw || typeof raw !== 'object') return null
50
+ const argvRaw = Array.isArray(raw.argv) ? raw.argv.filter((a) => typeof a === 'string') : []
51
+ const { argv, truncated } = trimArgv(argvRaw)
52
+ const exe = clip(typeof raw.exe === 'string' ? raw.exe : '', MAX_PATH_BYTES)
53
+ const cwd = clip(typeof raw.cwd === 'string' ? raw.cwd : '', MAX_PATH_BYTES)
54
+ if (!exe && !argv.length) return null
55
+ return {
56
+ pid: Number.isInteger(raw.pid) && raw.pid > 0 ? raw.pid : null,
57
+ uid: Number.isInteger(raw.uid) ? raw.uid : null,
58
+ exe,
59
+ cwd,
60
+ argv,
61
+ truncated: truncated || raw.truncated === true,
62
+ start: Number.isFinite(raw.start) ? raw.start : null,
63
+ user: clip(typeof raw.user === 'string' ? raw.user : '', 64),
64
+ host: clip(typeof raw.host === 'string' ? raw.host : '', 64)
65
+ }
66
+ }
67
+
68
+ /**
69
+ * LA HUELLA DEL COMANDO: qué binario, desde qué carpeta y con qué argumentos. Es la llave
70
+ * de la concesión por tiempo — aprobar `node server.js` en `/srv/app` no aprueba otra cosa
71
+ * ni la misma cosa en otro sitio.
72
+ *
73
+ * El pid NO entra: cambia en cada arranque y la gracia es justamente que el mismo comando
74
+ * se reconozca la próxima vez.
75
+ */
76
+ export function commandFingerprint (ctx) {
77
+ if (!ctx || typeof ctx !== 'object') return null
78
+ const argv = Array.isArray(ctx.argv) ? ctx.argv : []
79
+ if (!ctx.exe && !argv.length) return null
80
+ const material = [ctx.exe || '', ctx.cwd || '', ...argv].join('\0')
81
+ return sha256Hex(material)
82
+ }
83
+
84
+ /** ¿Son el mismo comando? Mismo binario, misma carpeta, mismos argumentos. */
85
+ export const sameCommand = (a, b) => {
86
+ const fa = commandFingerprint(a)
87
+ return !!fa && fa === commandFingerprint(b)
88
+ }
89
+
90
+ /** El comando en una línea, para un log o una pantalla estrecha. */
91
+ export function commandLine (ctx, max = 160) {
92
+ const argv = Array.isArray(ctx?.argv) ? ctx.argv : []
93
+ const linea = argv.length ? argv.join(' ') : (ctx?.exe || '')
94
+ return linea.length > max ? linea.slice(0, max - 1) + '…' : linea
95
+ }
96
+
97
+ export default { sanitizeContext, commandFingerprint, sameCommand, commandLine, trimArgv }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.155.0",
3
+ "version": "0.157.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,11 +22,11 @@
22
22
  },
23
23
  "dependencies": {
24
24
  "@dotrino/compat": "0.4.0",
25
- "@dotrino/identity": "^0.115.0",
25
+ "@dotrino/identity": "^0.116.0",
26
26
  "@dotrino/opaque": "0.1.0",
27
27
  "@dotrino/passmanager": "0.17.0",
28
28
  "@dotrino/proxy-client": "^0.32.2",
29
- "@dotrino/roadmap": "0.3.19",
29
+ "@dotrino/roadmap": "0.4.13",
30
30
  "@dotrino/store": "0.11.0",
31
31
  "@dotrino/update": "0.5.1",
32
32
  "ws": "^8.18.0"
package/src/ctl.js CHANGED
@@ -30,7 +30,7 @@ import { pubkeyId } from '@dotrino/identity/capabilities'
30
30
  import { dataDir } from './paths.js'
31
31
  // EL CANAL LOCAL VA CIFRADO (ver `ipc.js`): por aquí salen la contraseña y los valores.
32
32
  import { ipcRead, ipcWrite } from './ipc.js'
33
- import { assertVar } from './secretsStore.js'
33
+ import { assertVar } from '../lib/src/core/secretsStore.js'
34
34
  import { isValidSecretsNs } from './protocol.js'
35
35
  import { parseEnvText, PAIR_RE } from '../lib/src/envtext.js'
36
36
  import { qrToString } from './qr.js'
@@ -39,7 +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
+ import { COMM_CN } from '../lib/src/core/commKey.js'
43
43
  import { machineLine } from '@dotrino/identity/machine'
44
44
 
45
45
  const dir = dataDir()
@@ -102,7 +102,7 @@ const START_HINT = IN_DOCKER
102
102
  ? 'docker start dotrino-vault'
103
103
  : process.platform === 'linux'
104
104
  ? 'systemctl --user start dotrino-vault'
105
- : 'dotrino-vaultd (o: npx -y @dotrino/vaultd)'
105
+ : 'dotrino-vault service start (o: npx -y @dotrino/vaultd)'
106
106
  const RESTART_HINT = IN_DOCKER
107
107
  ? 'docker restart dotrino-vault'
108
108
  : process.platform === 'linux'
@@ -2240,6 +2240,36 @@ function cmdLogs () {
2240
2240
  }
2241
2241
 
2242
2242
  // Import dinámico: así `dotrino-vault status` (el caso común) no carga la TUI.
2243
+ /**
2244
+ * `service start | autostart [on|off]`: arrancar el daemon y dejarlo arrancando al iniciar
2245
+ * sesión, en los tres sistemas (`src/service.js`). En Linux es la unidad de systemd; en
2246
+ * Windows una entrada de inicio de sesión; en macOS un LaunchAgent.
2247
+ */
2248
+ async function cmdService (rest) {
2249
+ const sv = await import('./service.js')
2250
+ const [sub, val] = rest
2251
+ if (sub === 'start') {
2252
+ // `readState()` sale si no hay state.json, y sin state.json es justo cuando hay que arrancar.
2253
+ const s = ipcRead(stateFile, null)
2254
+ if (s && alive(s.pid)) { console.log('ya está corriendo (pid ' + s.pid + ')'); return }
2255
+ const r = await sv.startDaemonService()
2256
+ if (!r.ok) { console.error('no se pudo arrancar: ' + r.err); process.exit(1) }
2257
+ console.log('arrancado · registro: ' + sv.daemonLogFile())
2258
+ return
2259
+ }
2260
+ if (sub === 'autostart') {
2261
+ if (val === 'on' || val === 'off') {
2262
+ const r = val === 'on' ? await sv.enableAutostart() : await sv.disableAutostart()
2263
+ if (!r.ok) { console.error('no se pudo: ' + r.err); process.exit(1) }
2264
+ } else if (val !== undefined) { console.error('uso: dotrino-vault service autostart [on|off]'); process.exit(2) }
2265
+ const st = await sv.autostartStatus()
2266
+ console.log('arranca al iniciar sesión: ' + (st.on === null ? 'no se sabe en este sistema' : st.on ? 'sí' : 'no') + (st.how !== 'none' ? ` (${st.how})` : ''))
2267
+ return
2268
+ }
2269
+ console.error('uso: dotrino-vault service start | autostart [on|off]')
2270
+ process.exit(2)
2271
+ }
2272
+
2243
2273
  async function cmdTui () {
2244
2274
  if (!process.stdout.isTTY) { console.error('la TUI necesita un terminal interactivo (TTY).'); process.exit(2) }
2245
2275
  const { runTui } = await import('./tui/app.js')
@@ -2250,6 +2280,10 @@ function help () {
2250
2280
  console.log(`dotrino-vault — control del certificador personal
2251
2281
 
2252
2282
  tui interfaz de terminal a pantalla completa (bóvedas, pares, secretos)
2283
+ service start arranca el servicio en segundo plano (sin ventana)
2284
+ service autostart [on|off]
2285
+ que arranque solo al iniciar sesión (systemd en Linux, una entrada
2286
+ de inicio en Windows, un LaunchAgent en macOS) · sin valor, dice cómo está
2253
2287
  status | info estado del servicio + fingerprint + el id de cada perfil
2254
2288
  update [--check] trae la versión nueva (verificando la firma) y espera a que el
2255
2289
  servicio la arranque · --check solo mira · --no-wait no espera
@@ -2485,6 +2519,10 @@ export async function runCtl (argv) {
2485
2519
  if (rest.includes('--help') || rest.includes('-h')) return help()
2486
2520
  switch (cmd) {
2487
2521
  case 'tui': return cmdTui()
2522
+ // El canal de la ventana de escritorio (`desktop/`). No es para personas: habla JSON por
2523
+ // stdin/stdout. Ver `src/tui/gui.js`.
2524
+ case 'gui-bridge': { await (await import('./tui/gui.js')).runGuiBridge(); process.exit(0) }
2525
+ case 'service': return cmdService(rest)
2488
2526
  case 'profile': return cmdProfile(rest)
2489
2527
  case 'unlock': return cmdUnlock(rest)
2490
2528
  case 'lock': return cmdLock()
package/src/daemon.js CHANGED
@@ -29,7 +29,7 @@ import { latestRelease, isNewer, CHECK_EVERY_MS, isUserInstall, installUserRelea
29
29
  import { VERSION } from './version.js'
30
30
  import { readSettings } from './settings.js'
31
31
  import { approversOfAll, firstYes, readAsked, writeAsked, clearAsked, shouldAsk, resultOfNo, readRootTold, writeRootTold, shouldTellRoot } from './updateApproval.js'
32
- import { UPDATE_TTL_MS } from './approvals.js'
32
+ import { UPDATE_TTL_MS } from '../lib/src/core/approvals.js'
33
33
  import crypto from 'node:crypto'
34
34
 
35
35
  const readJsonSafe = (f) => ipcRead(f, null)
package/src/disk.js ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * EL DISCO DEL BINARIO: archivos de verdad bajo el directorio del perfil, en modo 0600 y
3
+ * cifrados en reposo con la llave de esta máquina. Es la implementación de la interfaz de
4
+ * `lib/src/core/disk.js` que usa el demonio; el navegador pone la suya.
5
+ *
6
+ * Todo lo que es de Node y de un sistema de archivos con permisos (el `chmod`, escribir
7
+ * aparte y renombrar) vive aquí, y no en el núcleo, para que el núcleo sea el mismo código
8
+ * en las tres bóvedas.
9
+ */
10
+ import fs from 'node:fs'
11
+ import path from 'node:path'
12
+ import { atRestFor, kekFor } from './atrest.js'
13
+ import { ensureDir } from './paths.js'
14
+
15
+ /** El modo de un archivo que existía con otro más abierto (las bitácoras de antes de 0.89). */
16
+ function narrow (file) {
17
+ try { if ((fs.statSync(file).mode & 0o077) !== 0) fs.chmodSync(file, 0o600) } catch (_) {}
18
+ }
19
+
20
+ export function nodeDisk (dir, { atRest = null } = {}) {
21
+ ensureDir(dir)
22
+ const at = (name) => path.join(dir, name)
23
+ return {
24
+ dir,
25
+ read (name) {
26
+ try { return fs.readFileSync(at(name), 'utf8') } catch (e) {
27
+ if (e.code === 'ENOENT') return null
28
+ throw e
29
+ }
30
+ },
31
+ write (name, text) {
32
+ const file = at(name)
33
+ ensureDir(path.dirname(file))
34
+ const tmp = file + '.tmp'
35
+ fs.writeFileSync(tmp, text, { mode: 0o600 })
36
+ fs.renameSync(tmp, file)
37
+ narrow(file)
38
+ },
39
+ append (name, text) {
40
+ const file = at(name)
41
+ fs.appendFileSync(file, text, { mode: 0o600 })
42
+ // El `mode` de `appendFileSync` solo aplica al CREAR.
43
+ narrow(file)
44
+ },
45
+ exists: (name) => fs.existsSync(at(name)),
46
+ remove: (name) => { fs.rmSync(at(name), { force: true }) },
47
+ rename: (from, to) => { fs.renameSync(at(from), at(to)) },
48
+ size: (name) => { try { return fs.statSync(at(name)).size } catch (_) { return 0 } },
49
+ atRest: atRest || atRestFor(dir),
50
+ machineKey: () => new Uint8Array(kekFor(dir))
51
+ }
52
+ }
53
+
54
+ export default { nodeDisk }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * LO QUE EL BINARIO PONE PARA CORRER LA BÓVEDA (`lib/src/core/vault.js`): el disco del
3
+ * perfil, la identidad leída de su directorio, `/proc`, el nombre de la máquina y el
4
+ * mostrador local por socket de archivo. El navegador pone lo suyo en
5
+ * `lib/src/core/browserPlatform.js`; el núcleo es el mismo.
6
+ */
7
+ import fs from 'node:fs'
8
+ import net from 'node:net'
9
+ import os from 'node:os'
10
+ import path from 'node:path'
11
+ import { Identity } from '@dotrino/identity/node'
12
+ import { MSG } from '../lib/src/protocol.js'
13
+ import { readProcContext } from '../lib/src/proc.js'
14
+ import { localSocketPath, socketDir } from '../lib/src/localdesk.js'
15
+ import { kekFor, migrateFile, encryptText, decryptText, atRestFor } from './atrest.js'
16
+ import { assertKeyOwnsDir } from './keyowner.js'
17
+ import { installNodeGlobals } from './node-globals.js'
18
+ import { nodeDisk } from './disk.js'
19
+ import { VERSION } from './version.js'
20
+
21
+ /**
22
+ * EL MOSTRADOR LOCAL: el mismo protocolo, sin salir de la máquina.
23
+ *
24
+ * Un servicio en ESTA máquina hablaba con la bóveda dando la vuelta por
25
+ * `proxy.dotrino.com`: sale a internet, vuelve, y si el proxio tiene un mal momento el
26
+ * arranque se retrasa cinco segundos por nada. Absurdo cuando los dos procesos comparten
27
+ * disco (dueño, 2026-09-03: «estando en la misma máquina debería ser inmediato»).
28
+ *
29
+ * Es EL MISMO PROTOCOLO y el mismo enrutador: llega `{type, data, signature, cert}` y se
30
+ * comprueba igual, contra el certificado y contra el acta. **Alcanzar el socket no
31
+ * autoriza nada** — es la misma puerta por un camino más corto, no una puerta de atrás.
32
+ * Por eso no hay un solo `if` de «viene de local» en ningún handler, y no debe haberlo.
33
+ *
34
+ * Y no es un puerto: es un socket de archivo, en modo 0600 y bajo el directorio del
35
+ * usuario. No se alcanza desde otra máquina ni desde otro usuario, así que la promesa
36
+ * del `docker-compose` —«la bóveda no escucha nada»— sigue siendo cierta donde importa.
37
+ */
38
+ function openLocalDesk ({ name, onMessage, channels: canales, maxBytes: MAX_REPLY_BYTES, log }) {
39
+ const socketPath = localSocketPath(name)
40
+ let n = 0
41
+ const server = net.createServer((sock) => {
42
+ const id = 'local:' + (++n)
43
+ let buf = ''
44
+ const enviar = (obj) => { try { sock.write(JSON.stringify(obj) + '\n') } catch (_) {} }
45
+ canales.set(id, enviar)
46
+ sock.on('data', (chunk) => {
47
+ buf += chunk
48
+ // UN TOPE, y por lo mismo que en las respuestas: sin él, quien se conecte puede
49
+ // hacer crecer este buffer hasta tumbar la bóveda mandando y sin saltos de línea.
50
+ if (buf.length > MAX_REPLY_BYTES) { buf = ''; enviar({ type: MSG.ERROR, error: 'request too big' }); return }
51
+ let i
52
+ while ((i = buf.indexOf('\n')) >= 0) {
53
+ const linea = buf.slice(0, i); buf = buf.slice(i + 1)
54
+ if (!linea.trim()) continue
55
+ let p = null
56
+ try { p = JSON.parse(linea) } catch (_) { enviar({ type: MSG.ERROR, error: 'malformed request' }); continue }
57
+ onMessage(id, p).catch((e) => enviar({ type: MSG.ERROR, error: e.message }))
58
+ }
59
+ })
60
+ const cerrar = () => canales.delete(id)
61
+ sock.on('close', cerrar)
62
+ sock.on('error', cerrar)
63
+ })
64
+ server.on('error', (e) => log('[vault] the local desk could not open (' + e.message + '): services will use the proxy'))
65
+ // EL DIRECTORIO ES EL CONTROL, no el modo del socket.
66
+ //
67
+ // `mkdir` con `mode` NO corrige un directorio que ya existe, y sin `XDG_RUNTIME_DIR`
68
+ // —un servicio de sistema, un contenedor— esto cae en `/tmp`, donde cualquiera puede
69
+ // haberlo creado antes con permiso de escritura para todos. Quien sea dueño del
70
+ // directorio puede borrar el socket y poner el suyo: los servicios de esta máquina
71
+ // acabarían hablando con él. No leería nada (la respuesta va firmada por una llave que
72
+ // nombra el acta y el cliente la comprueba), pero los deja sin arrancar.
73
+ //
74
+ // Y el `chmod 0600` de abajo llega DESPUÉS de `listen`, así que por sí solo deja una
75
+ // rendija. Lo que la cierra de verdad es que el directorio sea nuestro y solo nuestro.
76
+ const dir = socketDir()
77
+ try { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }) } catch (_) {}
78
+ try {
79
+ const st = fs.statSync(dir)
80
+ const ajeno = typeof process.getuid === 'function' && st.uid !== process.getuid()
81
+ const abierto = (st.mode & 0o077) !== 0
82
+ if (ajeno || abierto) {
83
+ return log(`[vault] the local desk stays closed: ${dir} is ${ajeno ? 'owned by another user' : 'writable by others'} ` +
84
+ '— services on this machine will use the proxy instead')
85
+ }
86
+ } catch (e) {
87
+ return log('[vault] the local desk stays closed: could not check ' + dir + ' (' + e.message + ')')
88
+ }
89
+ try { fs.rmSync(socketPath, { force: true }) } catch (_) {}
90
+ server.listen(socketPath, () => {
91
+ try { fs.chmodSync(socketPath, 0o600) } catch (_) {}
92
+ log('[vault] local desk at ' + socketPath + ' (same machine, no round trip)')
93
+ })
94
+ server.unref?.()
95
+ return server
96
+ }
97
+
98
+ /**
99
+ * @param {string} dir el directorio del perfil
100
+ */
101
+ export function nodePlatform (dir) {
102
+ const disk = nodeDisk(dir)
103
+ // El transporte del ecosistema guarda su par en `localStorage`, y en Node no lo hay.
104
+ installNodeGlobals(dir)
105
+ return {
106
+ disk,
107
+ product: 'vaultd',
108
+ version: VERSION,
109
+ hostname: () => os.hostname(),
110
+ readProcContext,
111
+ openLocalDesk,
112
+ noticeGroupMs: Number(process.env.DOTRINO_VAULT_NOTICE_MS) || 3000,
113
+ async openIdentity ({ openKey = null, log = console.log } = {}) {
114
+ // CIFRADO EN REPOSO ligado a esta máquina: ningún archivo del dir queda en claro, así
115
+ // que copiarlos a otro equipo no sirve de nada. La identidad se migra AQUÍ (verificando
116
+ // antes de reemplazar); el resto lo hace su propio almacén al abrirse.
117
+ try {
118
+ const r = migrateFile(path.join(dir, 'identity.json'), kekFor(dir))
119
+ if (r === 'migrado') log('[vault] identity encrypted at rest (bound to this machine)')
120
+ } catch (e) { log('[vault] could not encrypt the identity at rest:', e.message) }
121
+ /**
122
+ * EL CANDADO DE LA MAESTRA. La mitad privada se guarda sellada con la llave que sale de
123
+ * la contraseña del perfil (`openKey`), no con la de la máquina: cerrada, la maestra no
124
+ * está en memoria y no hay con qué sacarla del disco. Un perfil sin contraseña no tiene
125
+ * `openKey`: se queda bajo la llave de máquina, y la consola lo dice en voz alta.
126
+ */
127
+ const keyLock = {
128
+ seal: async (texto) => { const k = openKey?.(); return k ? encryptText(texto, k) : null },
129
+ open: async (blob) => { const k = openKey?.(); if (!k) return null; try { return decryptText(blob, k) } catch (_) { return null } }
130
+ }
131
+ const identity = await Identity.connect({ dir, atRest: atRestFor(dir), keyLock })
132
+ // ESTE DIRECTORIO ES DE ESTA LLAVE: varias bóvedas en un disco, cada una con el suyo.
133
+ assertKeyOwnsDir(dir, identity.me?.publickey || null)
134
+ return identity
135
+ }
136
+ }
137
+ }
138
+
139
+ export default { nodePlatform }