@dotrino/vault 0.84.0 → 0.86.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/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/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 crypto from 'node:crypto'
32
+ import { cleanMachine } from '@dotrino/identity/machine'
33
+ import { MAX_PATH_BYTES, clip, trimArgv, sanitizeContext, commandFingerprint, sameCommand, commandLine } from './procContext.js'
33
34
 
34
- /** Topes. Un `argv` puede ser enorme y esto viaja en cada pedido y se pinta en un teléfono. */
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
- * LA HUELLA DEL COMANDO: qué binario, desde qué carpeta y con qué argumentos. Es la llave
165
- * de la concesión por tiempo — aprobar `node server.js` en `/srv/app` no aprueba otra cosa
166
- * ni la misma cosa en otro sitio.
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 commandFingerprint (ctx) {
172
- if (!ctx || typeof ctx !== 'object') return null
173
- const argv = Array.isArray(ctx.argv) ? ctx.argv : []
174
- if (!ctx.exe && !argv.length) return null
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
- /** ¿Son el mismo comando? Mismo binario, misma carpeta, mismos argumentos. */
180
- export const sameCommand = (a, b) => {
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/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