@dotrino/vaultd 0.154.1 → 0.156.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/browser.js +167 -0
- package/{src → lib/src/core}/approvals.js +1 -1
- package/{src → lib/src/core}/blocked.js +7 -10
- package/lib/src/core/browserDisk.js +109 -0
- package/{src → lib/src/core}/commKey.js +5 -8
- package/lib/src/core/disk.js +65 -0
- package/lib/src/core/libVersion.js +8 -0
- package/lib/src/core/machines.js +66 -0
- package/{src → lib/src/core}/notices.js +8 -12
- package/{src → lib/src/core}/notifiable.js +7 -10
- package/{src → lib/src/core}/sealKey.js +5 -8
- package/{src → lib/src/core}/sealer.js +12 -10
- package/{src → lib/src/core}/secretsStore.js +8 -11
- package/lib/src/core/sha256.js +69 -0
- package/{src → lib/src/core}/store.js +6 -9
- package/{src → lib/src/core}/subacta.js +5 -6
- package/{src → lib/src/core}/threadStore.js +5 -8
- package/{src → lib/src/core}/transport.js +4 -5
- package/lib/src/core/vault.js +4414 -0
- package/lib/src/enroll.js +8 -4
- package/lib/src/proc.js +12 -82
- package/lib/src/procContext.js +97 -0
- package/lib/src/service.js +3 -3
- package/package.json +3 -3
- package/src/ctl.js +5 -2
- package/src/daemon.js +3 -3
- package/src/disk.js +54 -0
- package/src/platform.js +139 -0
- package/src/tui/app.js +13 -2
- package/src/vault.js +10 -4440
- /package/{src → lib/src/core}/passwords.js +0 -0
- /package/{src → lib/src/core}/sealers.js +0 -0
package/lib/src/enroll.js
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
*/
|
|
32
32
|
import { verifyDeviceSig, pubkeyId, commitCode } from '@dotrino/identity/capabilities'
|
|
33
33
|
import { verifyContinuity, canSeal, DEVICE_CAPS, VAULT_CN } from '@dotrino/identity/acta'
|
|
34
|
+
import { cleanMachine } from '@dotrino/identity/machine'
|
|
34
35
|
|
|
35
36
|
/** Un token de emparejamiento vale 5 min. */
|
|
36
37
|
export const PAIRING_TTL_MS = 5 * 60 * 1000
|
|
@@ -113,7 +114,7 @@ export async function deviceIdOf (pub) {
|
|
|
113
114
|
* @param {(pub:string, obj:object)=>void} opts.sendByPubkey dirigir por pubkey (cola offline 24 h).
|
|
114
115
|
* @param {(op:string, info?:object)=>void} [opts.audit]
|
|
115
116
|
* @param {(...a:any[])=>void} [opts.log]
|
|
116
|
-
* @param {(c:{deviceId:string, scope:any, label:string})=>void} [opts.onChallenge] un dispositivo espera aprobación.
|
|
117
|
+
* @param {(c:{deviceId:string, scope:any, label:string, machine?:any, mode?:string})=>void} [opts.onChallenge] un dispositivo espera aprobación.
|
|
117
118
|
* @param {()=>void} [opts.onPendingChange]
|
|
118
119
|
* @param {(sub:string)=>void} [opts.onDeviceRemoved] se quitó un aparato (fuera del acta y sin papeles):
|
|
119
120
|
* para que quien guarde algo indexado por esa llave lo suelte. Se avisa desde AQUÍ y no desde
|
|
@@ -210,7 +211,7 @@ export function createEnrollDesk ({
|
|
|
210
211
|
function listPending () {
|
|
211
212
|
return [...pending.values()]
|
|
212
213
|
.filter((p) => p.state === 'PENDING_CONFIRM')
|
|
213
|
-
.map((p) => ({ deviceId: p.deviceId, label: p.label || '', scope: p.scope }))
|
|
214
|
+
.map((p) => ({ deviceId: p.deviceId, label: p.label || '', scope: p.scope, machine: p.machine || null }))
|
|
214
215
|
}
|
|
215
216
|
|
|
216
217
|
function findPending (deviceId) {
|
|
@@ -314,12 +315,15 @@ export function createEnrollDesk ({
|
|
|
314
315
|
// cuál era cuál. Si en `pair` le diste un nombre, ese es el nombre; el del aparato
|
|
315
316
|
// sigue valiendo como propuesta cuando no dijiste nada.
|
|
316
317
|
if (d.label && !pend.label) pend.label = String(d.label).slice(0, 60)
|
|
318
|
+
// DÓNDE CORRE (`@dotrino/identity/machine`): entra al acta con el aparato, para que la
|
|
319
|
+
// lista lo distinga de los demás aunque todos se llamen igual.
|
|
320
|
+
pend.machine = cleanMachine(d.machine)
|
|
317
321
|
// Camino A: de qué cuenta estamos hablando. Se guarda para poder comprobar, cuando
|
|
318
322
|
// llegue el acta sellada, que es la que este dispositivo dijo que iba a entregar.
|
|
319
323
|
if (intent === 'adopt' && typeof d.profileId === 'string') pend.profileId = d.profileId
|
|
320
324
|
|
|
321
325
|
reply(from, { type: MSG_ENROLL_CHALLENGE, deviceId })
|
|
322
|
-
fire(onChallenge, { deviceId, scope: pend.scope, label: pend.label || '', mode: pend.mode || 'join' })
|
|
326
|
+
fire(onChallenge, { deviceId, scope: pend.scope, label: pend.label || '', machine: pend.machine || null, mode: pend.mode || 'join' })
|
|
323
327
|
fire(onPendingChange)
|
|
324
328
|
return { deviceId }
|
|
325
329
|
}
|
|
@@ -396,7 +400,7 @@ export function createEnrollDesk ({
|
|
|
396
400
|
const { cert } = await identity.signDelegation(pend.dpub, pend.scope, { ttlMs: pend.ttlMs, label: pend.label })
|
|
397
401
|
|
|
398
402
|
try {
|
|
399
|
-
await identity.admitMember({ pub: pend.dpub, encPub: pend.encPub || null, label: pend.label || '', cn, caps, cert, continuity: pend.continuity || null })
|
|
403
|
+
await identity.admitMember({ pub: pend.dpub, encPub: pend.encPub || null, label: pend.label || '', cn, caps, cert, continuity: pend.continuity || null, machine: pend.machine || null })
|
|
400
404
|
} catch (e) {
|
|
401
405
|
// El certificado ya está firmado y no se entrega: se revoca, para que no quede uno
|
|
402
406
|
// válido suelto. Si ni eso sale, se dice también.
|
package/lib/src/proc.js
CHANGED
|
@@ -29,18 +29,10 @@
|
|
|
29
29
|
*/
|
|
30
30
|
import fs from 'node:fs'
|
|
31
31
|
import os from 'node:os'
|
|
32
|
-
import
|
|
32
|
+
import { cleanMachine } from '@dotrino/identity/machine'
|
|
33
|
+
import { MAX_PATH_BYTES, clip, trimArgv, sanitizeContext, commandFingerprint, sameCommand, commandLine } from './procContext.js'
|
|
33
34
|
|
|
34
|
-
|
|
35
|
-
export const MAX_ARGV_BYTES = 4096
|
|
36
|
-
export const MAX_PATH_BYTES = 1024
|
|
37
|
-
/** Cuántos argumentos como mucho: más que esto no lo lee nadie. */
|
|
38
|
-
export const MAX_ARGV_ITEMS = 64
|
|
39
|
-
|
|
40
|
-
const clip = (s, max) => {
|
|
41
|
-
if (typeof s !== 'string') return ''
|
|
42
|
-
return Buffer.byteLength(s) <= max ? s : Buffer.from(s).subarray(0, max).toString('utf8')
|
|
43
|
-
}
|
|
35
|
+
export { MAX_ARGV_BYTES, MAX_PATH_BYTES, MAX_ARGV_ITEMS, sanitizeContext, commandFingerprint, sameCommand, commandLine } from './procContext.js'
|
|
44
36
|
|
|
45
37
|
/** ¿Hay `/proc`? Es lo que separa poder medir de tener que creer. */
|
|
46
38
|
export const hasProc = () => { try { return fs.statSync('/proc/self').isDirectory() } catch (_) { return false } }
|
|
@@ -53,24 +45,6 @@ function readCmdline (pid) {
|
|
|
53
45
|
return parts
|
|
54
46
|
}
|
|
55
47
|
|
|
56
|
-
/**
|
|
57
|
-
* Recorta el `argv` a lo que se puede enseñar y transportar. Devuelve también si sobró
|
|
58
|
-
* algo: un comando cortado que no dice que está cortado se lee como otro comando.
|
|
59
|
-
*/
|
|
60
|
-
function trimArgv (argv) {
|
|
61
|
-
const list = (Array.isArray(argv) ? argv : []).map((a) => String(a))
|
|
62
|
-
let cut = list.length > MAX_ARGV_ITEMS
|
|
63
|
-
const kept = list.slice(0, MAX_ARGV_ITEMS)
|
|
64
|
-
let total = 0
|
|
65
|
-
const out = []
|
|
66
|
-
for (const a of kept) {
|
|
67
|
-
const b = Buffer.byteLength(a)
|
|
68
|
-
if (total + b > MAX_ARGV_BYTES) { cut = true; break }
|
|
69
|
-
total += b
|
|
70
|
-
out.push(a)
|
|
71
|
-
}
|
|
72
|
-
return { argv: out, truncated: cut }
|
|
73
|
-
}
|
|
74
48
|
|
|
75
49
|
/**
|
|
76
50
|
* LA HORA DE ARRANQUE del proceso (campo 22 de `/proc/<pid>/stat`, en ticks). Sirve para
|
|
@@ -131,62 +105,18 @@ export function selfContext () {
|
|
|
131
105
|
}
|
|
132
106
|
}
|
|
133
107
|
|
|
134
|
-
const safeCwd = () => { try { return process.cwd() } catch (_) { return '' } }
|
|
135
|
-
const userName = () => { try { return os.userInfo().username } catch (_) { return '' } }
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* LO QUE LLEGA POR LA RED NO SE CREE NI EN LA FORMA. Este dato lo manda otro proceso, se
|
|
139
|
-
* guarda en memoria y se pinta en un teléfono: se recorta a lo que cabe y se tira lo que no
|
|
140
|
-
* tiene el tipo que debería. Devuelve `null` si no queda nada que enseñar — que no es lo
|
|
141
|
-
* mismo que un contexto vacío, y por eso no se devuelve un objeto a medias.
|
|
142
|
-
*/
|
|
143
|
-
export function sanitizeContext (raw) {
|
|
144
|
-
if (!raw || typeof raw !== 'object') return null
|
|
145
|
-
const argvRaw = Array.isArray(raw.argv) ? raw.argv.filter((a) => typeof a === 'string') : []
|
|
146
|
-
const { argv, truncated } = trimArgv(argvRaw)
|
|
147
|
-
const exe = clip(typeof raw.exe === 'string' ? raw.exe : '', MAX_PATH_BYTES)
|
|
148
|
-
const cwd = clip(typeof raw.cwd === 'string' ? raw.cwd : '', MAX_PATH_BYTES)
|
|
149
|
-
if (!exe && !argv.length) return null
|
|
150
|
-
return {
|
|
151
|
-
pid: Number.isInteger(raw.pid) && raw.pid > 0 ? raw.pid : null,
|
|
152
|
-
uid: Number.isInteger(raw.uid) ? raw.uid : null,
|
|
153
|
-
exe,
|
|
154
|
-
cwd,
|
|
155
|
-
argv,
|
|
156
|
-
truncated: truncated || raw.truncated === true,
|
|
157
|
-
start: Number.isFinite(raw.start) ? raw.start : null,
|
|
158
|
-
user: clip(typeof raw.user === 'string' ? raw.user : '', 64),
|
|
159
|
-
host: clip(typeof raw.host === 'string' ? raw.host : '', 64)
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
108
|
/**
|
|
164
|
-
*
|
|
165
|
-
* de
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
* El pid NO entra: cambia en cada arranque y la gracia es justamente que el mismo comando
|
|
169
|
-
* se reconozca la próxima vez.
|
|
109
|
+
* DÓNDE CORRE ESTE PROCESO (`@dotrino/identity/machine`): para que la bóveda lo reconozca en
|
|
110
|
+
* su lista de aparatos. Va en lo que el aparato firma al emparejarse y en cada pedido de
|
|
111
|
+
* secretos, así que se refresca solo cuando cambia de máquina o de sistema.
|
|
170
112
|
*/
|
|
171
|
-
export function
|
|
172
|
-
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
const material = [ctx.exe || '', ctx.cwd || '', ...argv].join('\0')
|
|
176
|
-
return crypto.createHash('sha256').update(material).digest('hex')
|
|
113
|
+
export function selfMachine () {
|
|
114
|
+
const tipo = os.type()
|
|
115
|
+
const sistema = tipo === 'Darwin' ? 'macOS' : tipo === 'Windows_NT' ? 'Windows' : tipo
|
|
116
|
+
return cleanMachine({ kind: 'node', os: `${sistema} ${os.release()}`, name: os.hostname() })
|
|
177
117
|
}
|
|
178
118
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
const fa = commandFingerprint(a)
|
|
182
|
-
return !!fa && fa === commandFingerprint(b)
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/** El comando en una línea, para un log o una pantalla estrecha. */
|
|
186
|
-
export function commandLine (ctx, max = 160) {
|
|
187
|
-
const argv = Array.isArray(ctx?.argv) ? ctx.argv : []
|
|
188
|
-
const linea = argv.length ? argv.join(' ') : (ctx?.exe || '')
|
|
189
|
-
return linea.length > max ? linea.slice(0, max - 1) + '…' : linea
|
|
190
|
-
}
|
|
119
|
+
const safeCwd = () => { try { return process.cwd() } catch (_) { return '' } }
|
|
120
|
+
const userName = () => { try { return os.userInfo().username } catch (_) { return '' } }
|
|
191
121
|
|
|
192
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/lib/src/service.js
CHANGED
|
@@ -32,7 +32,7 @@ import { makeEphemeralKey, openSealed } from './sealed.js'
|
|
|
32
32
|
import { parseInvite } from './invite.js'
|
|
33
33
|
import { atRestFor } from './atrest.js'
|
|
34
34
|
import { connectLocal, hasLocalVault } from './localdesk.js'
|
|
35
|
-
import { selfContext } from './proc.js'
|
|
35
|
+
import { selfContext, selfMachine } from './proc.js'
|
|
36
36
|
|
|
37
37
|
const IDENTITY_FILE = 'service-identity.json'
|
|
38
38
|
const FRESH_WINDOW_MS = 5 * 60 * 1000
|
|
@@ -378,7 +378,7 @@ export async function enrollWithVault ({ qr, label = 'agent', expectedScope = nu
|
|
|
378
378
|
// aprende solo cuando un humano lo tipea → aprobar exige TENER esta máquina.
|
|
379
379
|
const code = makePairingCode()
|
|
380
380
|
const commit = await commitCode({ code, dpub: device.publickey, sn: qr.sn })
|
|
381
|
-
const data = { op: 'enroll', intent: 'join', dpub: device.publickey, encPub: enc?.encPublickey || null, token: qr.token || qr.sn, sn: qr.sn, commit, label, ts: Date.now() }
|
|
381
|
+
const data = { op: 'enroll', intent: 'join', dpub: device.publickey, encPub: enc?.encPublickey || null, token: qr.token || qr.sn, sn: qr.sn, commit, label, machine: selfMachine(), ts: Date.now() }
|
|
382
382
|
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
|
|
383
383
|
|
|
384
384
|
const enrolled = new Promise((resolve, reject) => {
|
|
@@ -908,7 +908,7 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
908
908
|
const ctx = client.local ? selfContext() : null
|
|
909
909
|
// `publicOnly` VIAJA FIRMADO (va dentro de `data`), así que nadie lo puede quitar ni
|
|
910
910
|
// poner por el camino. Quien decide qué se manda es la bóveda; esto solo es la petición.
|
|
911
|
-
const data = { op: 'secrets', ns, ek: eph.ek, publickey: device.publickey, ts: Date.now(), v: MY_VERSION, ...(ctx ? { ctx } : {}), ...(publicOnly ? { publicOnly: true } : {}) }
|
|
911
|
+
const data = { op: 'secrets', ns, ek: eph.ek, publickey: device.publickey, ts: Date.now(), v: MY_VERSION, machine: selfMachine(), ...(ctx ? { ctx } : {}), ...(publicOnly ? { publicOnly: true } : {}) }
|
|
912
912
|
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
|
|
913
913
|
// A QUIÉN SE LE PREGUNTA: primero a la bóveda, y si no contesta, a los REPLICADORES
|
|
914
914
|
// que el acta nombraba la última vez que la vimos (`docs/replicas.md` §8: «el servicio
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vaultd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.156.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.
|
|
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.
|
|
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 '
|
|
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,8 @@ 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 '
|
|
42
|
+
import { COMM_CN } from '../lib/src/core/commKey.js'
|
|
43
|
+
import { machineLine } from '@dotrino/identity/machine'
|
|
43
44
|
|
|
44
45
|
const dir = dataDir()
|
|
45
46
|
const stateFile = path.join(dir, 'state.json')
|
|
@@ -673,6 +674,8 @@ async function cmdMembers () {
|
|
|
673
674
|
if (m.renounced?.length) {
|
|
674
675
|
console.log(' %s↓ renunció a %s (pendiente de sellar: abre la bóveda)%s', R, m.renounced.join(', '), Z)
|
|
675
676
|
}
|
|
677
|
+
// DÓNDE CORRE (`@dotrino/identity/machine`): con qué se reconoce cuál es cuál.
|
|
678
|
+
if (machineLine(m.machine)) console.log(' %s%s%s', D, machineLine(m.machine), Z)
|
|
676
679
|
if (m.running) {
|
|
677
680
|
const v = `${m.running.product} ${m.running.version}`
|
|
678
681
|
if (m.running.compatible) console.log(' %scorre %s%s', D, v, Z)
|
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 '
|
|
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)
|
|
@@ -87,8 +87,8 @@ export async function runDaemon () {
|
|
|
87
87
|
const pendingEnrollFile = path.join(dir, 'pending-enroll.json')
|
|
88
88
|
// Cuando un dispositivo pide enrolarse, exponemos su deviceId (y a QUÉ perfil
|
|
89
89
|
// quiere entrar) para que el dueño lo compare con el del dispositivo y apruebe.
|
|
90
|
-
const onEnrollChallenge = ({ deviceId, scope, profile, profileName }) => {
|
|
91
|
-
ipcWrite(pendingEnrollFile, { v: 2, at: Date.now(), deviceId, scope, profile, profileName })
|
|
90
|
+
const onEnrollChallenge = ({ deviceId, scope, profile, profileName, machine }) => {
|
|
91
|
+
ipcWrite(pendingEnrollFile, { v: 2, at: Date.now(), deviceId, scope, profile, profileName, machine: machine || null })
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
const mgr = await startVaultManager({ root: dir, proxyUrl, onEnrollChallenge })
|
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 }
|
package/src/platform.js
ADDED
|
@@ -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 }
|
package/src/tui/app.js
CHANGED
|
@@ -41,6 +41,7 @@ import * as hw from '../hwkeys.js'
|
|
|
41
41
|
import { VERSION } from '../version.js'
|
|
42
42
|
import * as up from '../update.js'
|
|
43
43
|
import { DEVICE_CAPS } from '@dotrino/identity/acta'
|
|
44
|
+
import { machineLine } from '@dotrino/identity/machine'
|
|
44
45
|
|
|
45
46
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
46
47
|
|
|
@@ -158,6 +159,9 @@ function mergeMembersAndCerts (members, issued) {
|
|
|
158
159
|
// QUÉ CORRE ESE APARATO (CONVENCIONES §14). Lo apunta la bóveda cuando le habla, así
|
|
159
160
|
// que aquí solo hay que dejarlo pasar — que es justo lo que le faltaba a `addedAt`.
|
|
160
161
|
running: m.running || null,
|
|
162
|
+
// DÓNDE CORRE (`@dotrino/identity/machine`): lo último que dijo firmado o, si no, lo
|
|
163
|
+
// que dijo al emparejarse. Es lo que distingue tres navegadores que se llaman igual.
|
|
164
|
+
machine: m.machine || cert?.machine || null,
|
|
161
165
|
// SI LE LLEGAN LOS AVISOS, solo en quien aprueba (`undefined` = no aprueba, no se pinta).
|
|
162
166
|
notifiedAt: (m.caps || []).includes('approve') && 'notifiedAt' in m ? m.notifiedAt : undefined
|
|
163
167
|
}
|
|
@@ -473,6 +477,9 @@ function deviceRows (st, t) {
|
|
|
473
477
|
}
|
|
474
478
|
for (const d of devices) {
|
|
475
479
|
const label = d.label || t.muted(i.noLabel)
|
|
480
|
+
// Dónde corre (`node · Linux 7.0 · mi-pc`), pegado al nombre: es con lo que se reconoce.
|
|
481
|
+
const maquina = machineLine(d.machine)
|
|
482
|
+
const donde = maquina ? t.muted(` ${maquina}`) : ''
|
|
476
483
|
const vars = devVarsOf(st, d.sub).length
|
|
477
484
|
const debt = debtOf(st, d.sub)
|
|
478
485
|
const desde = d.addedAt ? t.muted(` ${i.deviceSince}:${fmtExp(d.addedAt)}`) : ''
|
|
@@ -511,7 +518,7 @@ function deviceRows (st, t) {
|
|
|
511
518
|
? t.muted(i.thisVault)
|
|
512
519
|
: t.muted('scope:' + shortScope(d.scope)) + ' ' + t.muted('exp:' + fmtExp(d.exp)))
|
|
513
520
|
const marca = conCambios.has(d.sub) ? t.bold('*') : ' '
|
|
514
|
-
rows.push({ text: `${marca}${t.bold(d.deviceId)} ${label}${desde} ${status}${extra}`, sel: true, meta: d })
|
|
521
|
+
rows.push({ text: `${marca}${t.bold(d.deviceId)} ${label}${donde}${desde} ${status}${extra}`, sel: true, meta: d })
|
|
515
522
|
}
|
|
516
523
|
const revoked = st.devices?.revoked || []
|
|
517
524
|
if (revoked.length) {
|
|
@@ -2504,7 +2511,11 @@ function pairingBody (st, t, cols, height) {
|
|
|
2504
2511
|
lines.push('')
|
|
2505
2512
|
lines.push(t.danger(i.pairWarning))
|
|
2506
2513
|
lines.push('')
|
|
2507
|
-
if (st.pending)
|
|
2514
|
+
if (st.pending) {
|
|
2515
|
+
lines.push(t.warn(i.pairConnected(st.pending.deviceId)))
|
|
2516
|
+
// Dónde dice que corre, para comprobar que es el aparato que tienes delante.
|
|
2517
|
+
if (machineLine(st.pending.machine)) lines.push(t.muted(' ' + machineLine(st.pending.machine)))
|
|
2518
|
+
}
|
|
2508
2519
|
else lines.push(t.muted(i.pairWaiting))
|
|
2509
2520
|
return lines
|
|
2510
2521
|
}
|