@dotrino/vault 0.62.1 → 0.64.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 CHANGED
@@ -127,6 +127,29 @@ Para procesos que no son Node, el CLI los inyecta en el entorno de un hijo:
127
127
  dotrino-env run --ns miapp -- ./mi-binario
128
128
  ```
129
129
 
130
+ ### El pedido dice QUÉ COMANDO pide, y desde dónde
131
+
132
+ Cuando el cajón exige aprobación, el pedido que te llega al teléfono lleva **el comando
133
+ entero y la carpeta** desde la que corre — `node server.js --port 8080` en `/srv/mi-app`—,
134
+ no solo qué aparato pide qué cajón. En `dotrino-env run -- node server.js` el comando de
135
+ destino va dentro de la propia línea de `dotrino-env`, así que sale medido y no hace falta
136
+ creerle nada aparte.
137
+
138
+ Dos límites que se dicen y no se esconden:
139
+
140
+ - **Solo viaja por el mostrador local.** El pedido de secretos va firmado pero en claro
141
+ (CONVENCIONES §4.1), así que mandar el `argv` y el `cwd` por el proxio le enseñaría a quien
142
+ lo opere las rutas de tu disco. Desde otra máquina el pedido no lleva comando — y sin
143
+ comando no hay concesión: ese pide aprobación cada vez.
144
+ - **No es una credencial.** La bóveda lo comprueba contra el proceso de verdad cuando está en
145
+ su misma máquina, pero cualquier proceso de tu mismo usuario puede leer el
146
+ `service-identity.json` y pedir con esa llave. Sirve para decidir mirando lo que pasa y
147
+ para dejar rastro, no para parar a quien ya tiene la llave.
148
+
149
+ **Aprobar vale una hora y se renueva con cada uso**, así que un servicio que sigue pidiendo
150
+ lo mismo no vuelve a timbrarte. Cambiar un argumento o la carpeta es otro comando: pregunta
151
+ otra vez.
152
+
130
153
  ### Un agente tiene UNA identidad, y se la da el vault
131
154
 
132
155
  Un **aparato** puede llevar varios perfiles, y hasta meter su propia cuenta al vault
@@ -23,10 +23,26 @@ import { loadEnv, serviceDir, serviceRoot, listEnrolled, resolveNs, resolveVault
23
23
  // llegó a funcionar, y por eso el único servicio enrolado del ecosistema (el
24
24
  // proxy) lo hizo con su propio `enroll-vault.js` en vez de con esta CLI.
25
25
  import { parseInvite as sharedParseInvite } from '../src/invite.js'
26
+ import { selfContext, commandLine } from '../src/proc.js'
26
27
 
27
28
  const argv = process.argv.slice(2)
28
29
  const flag = (name) => { const i = argv.indexOf('--' + name); return i >= 0 ? argv[i + 1] : undefined }
29
30
 
31
+ /**
32
+ * QUÉ VA A VER EN EL TELÉFONO, dicho aquí también.
33
+ *
34
+ * El pedido que llega al aparato que aprueba enseña el comando y la carpeta; si por aquí solo
35
+ * se imprime «waiting for approval», no hay forma de emparejar lo que uno acaba de lanzar con
36
+ * lo que suena en el bolsillo — y con dos ventanas abiertas eso es exactamente lo que hay que
37
+ * distinguir antes de decir que sí.
38
+ */
39
+ function esperandoAprobacion () {
40
+ const ctx = selfContext()
41
+ console.error('[dotrino-env] the vault asks for approval — approve it on a device with «aprueba» (up to 5 min)')
42
+ console.error('[dotrino-env] %s', commandLine(ctx, 160))
43
+ console.error('[dotrino-env] from %s', ctx.cwd || '?')
44
+ }
45
+
30
46
  function help () {
31
47
  console.log(`dotrino-env — credenciales del vault en vez del .env
32
48
 
@@ -223,7 +239,7 @@ async function cmdCheck () {
223
239
  // bóveda contestó, que lo que falta es un permiso, ni cuánto va a esperar. `run` sí lo
224
240
  // decía; `check`, que es el comando de DIAGNÓSTICO, era el único que no. Le pasó al dueño
225
241
  // el 2026-09-05 con un cajón cuyo servicio no tenía a nadie que pudiera aprobarle.
226
- const onPending = () => console.error('[dotrino-env] la bóveda pide aprobación: apruébalo en un aparato con «aprueba» (hasta 5 min)')
242
+ const onPending = () => esperandoAprobacion()
227
243
  const secrets = await fetchSecrets({ dir: serviceDir(etiquetaBoveda(), ns), ns, publicOnly, onPending })
228
244
  const keys = Object.keys(secrets)
229
245
  console.log('ns "%s": %d %s%s', ns, keys.length,
@@ -240,7 +256,7 @@ async function cmdRun () {
240
256
  const sep = argv.indexOf('--')
241
257
  const cmd = sep >= 0 ? argv.slice(sep + 1) : []
242
258
  if (!cmd.length) { console.error('uso: dotrino-env run [--vault <bóveda>] [--ns <ns>] [--public] -- <cmd> [args…]'); process.exit(2) }
243
- const onPending = () => console.error('[dotrino-env] waiting for approval on your phone…')
259
+ const onPending = () => esperandoAprobacion()
244
260
  // POR QUÉ SE REINTENTA, SIEMPRE. Sin esto la espera es muda: la bóveda contestaba, la
245
261
  // respuesta se caía por un motivo concreto —un aparato sin sobre en su cajón, por
246
262
  // ejemplo— y por fuera solo se veía «waiting for approval» una y otra vez, aunque el
@@ -280,7 +296,7 @@ async function cmdSshAgent () {
280
296
  const dir = flag('dir') || serviceDir(etiquetaBoveda(), ns)
281
297
  const socketPath = flag('socket') || defaultSocketPath(dir)
282
298
  console.error('[dotrino-env] asking the vault for the keys of «%s»…', ns)
283
- const secrets = await waitForSecrets({ dir, ns, onPending: () => console.error('[dotrino-env] waiting for approval on your phone…') })
299
+ const secrets = await waitForSecrets({ dir, ns, onPending: () => esperandoAprobacion() })
284
300
  const keys = []
285
301
  for (const [name, value] of Object.entries(secrets)) {
286
302
  if (!/^SSH_KEY_[A-Z0-9_]*$/.test(name)) continue
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vault",
3
- "version": "0.62.1",
3
+ "version": "0.64.0",
4
4
  "description": "Usa ESTE dispositivo (navegador) como bóveda/CA del ecosistema Dotrino: atiende enrolamientos por el proxy y firma certificados de delegación a tus máquinas. Incluye el cliente de SERVICIO (Node): un proyecto se enrola una vez y jala sus credenciales del vault en vez del .env (`import '@dotrino/vault/config'`).",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -50,7 +50,7 @@
50
50
  "pairing"
51
51
  ],
52
52
  "peerDependencies": {
53
- "@dotrino/identity": ">=0.73.1",
53
+ "@dotrino/identity": ">=0.92.0",
54
54
  "@dotrino/proxy-client": ">=0.18.0"
55
55
  },
56
56
  "license": "MIT",
package/src/proc.js ADDED
@@ -0,0 +1,192 @@
1
+ /**
2
+ * proc.js — QUIÉN ESTÁ PIDIENDO LOS SECRETOS: qué comando, desde qué carpeta.
3
+ *
4
+ * Hasta ahora un pedido de aprobación decía «el aparato 904C-1002 pide el cajón proxy» y
5
+ * nada más. Con eso no se puede decidir: el dueño no sabe si es el arranque que acaba de
6
+ * lanzar o cualquier otra cosa de la misma máquina. Lo que hace falta es el **comando** y
7
+ * el **path** (dueño, 2026-09-11).
8
+ *
9
+ * DOS FUENTES, Y NO VALEN LO MISMO:
10
+ *
11
+ * · `selfContext()` — lo que un proceso dice de sí mismo. Es lo que viaja en el pedido.
12
+ * · `readProcContext(pid)` — lo que el KERNEL dice de ese pid, leyendo `/proc`. Solo la
13
+ * puede usar quien está en la misma máquina, y es la que manda.
14
+ *
15
+ * Cuando el pedido entra por el mostrador local, la bóveda lee la segunda y la compara con
16
+ * la primera: si no cuadran, no es un matiz, es una mentira, y se deniega. Cuando entra por
17
+ * el proxio (un servicio en otra máquina) no hay nada que leer, y entonces lo declarado se
18
+ * marca como declarado — que es distinto de comprobado, y se enseña distinto.
19
+ *
20
+ * EL LÍMITE, DICHO EN VOZ ALTA: esto no es una credencial. Cualquier proceso del MISMO
21
+ * usuario puede leer `service-identity.json` y pedir los secretos con esa llave, y también
22
+ * puede llamarse como quiera (`exec -a`) y arrancar desde donde quiera. La frontera de
23
+ * verdad es el usuario, no el proceso. Esto existe para que quien aprueba decida con lo que
24
+ * está pasando delante, y para que quede rastro — no para parar a quien ya tiene la llave.
25
+ *
26
+ * En `dotrino-env run -- node server.js` el comando de destino ya va DENTRO de la línea de
27
+ * comandos del propio `dotrino-env`, así que se mide igual que todo lo demás: no hay que
28
+ * creerle nada aparte.
29
+ */
30
+ import fs from 'node:fs'
31
+ import os from 'node:os'
32
+ import crypto from 'node:crypto'
33
+
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
+ }
44
+
45
+ /** ¿Hay `/proc`? Es lo que separa poder medir de tener que creer. */
46
+ export const hasProc = () => { try { return fs.statSync('/proc/self').isDirectory() } catch (_) { return false } }
47
+
48
+ /** `/proc/<pid>/cmdline` viene separado por NUL y termina en NUL. */
49
+ function readCmdline (pid) {
50
+ const raw = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8')
51
+ const parts = raw.split('\0')
52
+ if (parts.length && parts[parts.length - 1] === '') parts.pop()
53
+ return parts
54
+ }
55
+
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
+
75
+ /**
76
+ * LA HORA DE ARRANQUE del proceso (campo 22 de `/proc/<pid>/stat`, en ticks). Sirve para
77
+ * saber que el pid no se recicló entre que pidió y que se miró: el nombre del proceso va
78
+ * entre paréntesis y puede llevar espacios, así que se corta por el ÚLTIMO `)`.
79
+ */
80
+ function readStart (pid) {
81
+ try {
82
+ const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8')
83
+ const campos = stat.slice(stat.lastIndexOf(')') + 2).split(' ')
84
+ const t = Number(campos[19])
85
+ return Number.isFinite(t) ? t : null
86
+ } catch (_) { return null }
87
+ }
88
+
89
+ /**
90
+ * Lo que el KERNEL dice del proceso `pid`. `null` si no hay `/proc`, si el proceso ya no
91
+ * está o si no se puede mirar (otro usuario). No se inventa nada: no poder medir se
92
+ * contesta como no poder medir.
93
+ */
94
+ export function readProcContext (pid) {
95
+ if (!hasProc() || !Number.isInteger(pid) || pid <= 0) return null
96
+ try {
97
+ const st = fs.statSync(`/proc/${pid}`)
98
+ const { argv, truncated } = trimArgv(readCmdline(pid))
99
+ return {
100
+ pid,
101
+ uid: st.uid,
102
+ exe: clip(fs.readlinkSync(`/proc/${pid}/exe`), MAX_PATH_BYTES),
103
+ cwd: clip(fs.readlinkSync(`/proc/${pid}/cwd`), MAX_PATH_BYTES),
104
+ argv,
105
+ truncated,
106
+ start: readStart(pid)
107
+ }
108
+ } catch (_) { return null }
109
+ }
110
+
111
+ /**
112
+ * Lo que ESTE proceso dice de sí mismo. En Linux sale de `/proc/self`, o sea de la misma
113
+ * fuente que va a leer la bóveda: así lo declarado y lo medido coinciden byte a byte y una
114
+ * diferencia significa lo que tiene que significar. Fuera de Linux se arma con lo que da
115
+ * Node, y entonces no hay nada que comprobar al otro lado.
116
+ */
117
+ export function selfContext () {
118
+ const medido = readProcContext(process.pid)
119
+ if (medido) return { ...medido, user: userName(), host: os.hostname() }
120
+ const { argv, truncated } = trimArgv([process.execPath, ...process.argv.slice(1)])
121
+ return {
122
+ pid: process.pid,
123
+ uid: typeof process.getuid === 'function' ? process.getuid() : null,
124
+ exe: clip(process.execPath, MAX_PATH_BYTES),
125
+ cwd: clip(safeCwd(), MAX_PATH_BYTES),
126
+ argv,
127
+ truncated,
128
+ start: null,
129
+ user: userName(),
130
+ host: os.hostname()
131
+ }
132
+ }
133
+
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
+ /**
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.
170
+ */
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')
177
+ }
178
+
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
+ }
191
+
192
+ export default { selfContext, readProcContext, sanitizeContext, commandFingerprint, sameCommand, commandLine, hasProc }
package/src/service.js CHANGED
@@ -22,16 +22,17 @@ import fs from 'node:fs'
22
22
  import path from 'node:path'
23
23
  import { createHash } from 'node:crypto'
24
24
  import {
25
- makeDeviceKey, makeDeviceEncKey, importDeviceEncKey, signWithDevice, verifyDelegation,
25
+ makeDeviceKey, makeDeviceEncKey, importDeviceEncKey, signWithDevice,
26
26
  verifyDeviceSig, makePairingCode, commitCode, pubkeyId } from '@dotrino/identity/capabilities'
27
27
  import { openWrap, wrapForMember, decryptWithCek } from '@dotrino/identity/content'
28
- import { verifyActa, sealKeyAt, sealersOf, memberCan } from '@dotrino/identity/acta'
28
+ import { verifyActa, sealKeyAt, memberCan, checkVaultReply } from '@dotrino/identity/acta'
29
29
  import { MSG, secretsScope, isValidSecretsNs, VAULT_PROTOCOL, VAULT_SPEAKS } from './protocol.js'
30
30
  import { PKG_VERSION } from './version.js'
31
31
  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
36
 
36
37
  const IDENTITY_FILE = 'service-identity.json'
37
38
  const FRESH_WINDOW_MS = 5 * 60 * 1000
@@ -399,18 +400,15 @@ export async function enrollWithVault ({ qr, label = 'agent', expectedScope = nu
399
400
  // por reloj, dice el `seq` del acta con el que se emitió, y quien lo emitió tiene que
400
401
  // ser SELLADORA de ese acta. Sin acta no se puede comprobar nada de eso, y entrar a
401
402
  // ciegas en un perfil es justo lo que no se hace.
402
- const acta = res.acta
403
- if (!acta) throw new Error('the vault did not send its record: cannot check who signed this cert')
404
- // Y el acta tiene que ser la DEL PERFIL DEL QR. Antes esto se comprobaba mirando que el
405
- // cert lo firmara `qr.iss`; con varias selladoras el emisor puede ser otra llave del
406
- // mismo perfil, así que lo que se fija es el perfil, no la llave.
407
- if (acta.profileId !== qr.iss) throw new Error('the record is from a profile other than the one in the QR')
408
- const v = await verifyDelegation({
409
- cert: res.cert, expectedSub: device.publickey,
410
- actaSeq: acta.seq, sealers: sealersOf(acta),
411
- ...(expectedScope ? { expectedScope } : {})
403
+ //
404
+ // Se juzga contra la llave de LA BÓVEDA del QR, con la regla del pilar. Antes se exigía
405
+ // `acta.profileId === qr.iss`, que solo es verdad en una cuenta que nació en esa bóveda:
406
+ // un servicio no podía emparejarse con una segunda bóveda ni con una que adoptó la cuenta.
407
+ const chk = await checkVaultReply({
408
+ acta: res.acta, cert: res.cert, vault: qr.iss, sub: device.publickey,
409
+ scope: expectedScope || null, justSealed: true
412
410
  })
413
- if (!v.ok) throw new Error('invalid cert: ' + v.reason)
411
+ if (!chk.ok) throw new Error('the vault reply does not check out: ' + chk.reason)
414
412
 
415
413
  return { device, enc: enc ? { publickey: enc.encPublickey, privateJwk: enc.encPrivateJwk } : null, cert: res.cert, iss: qr.iss, proxy: qr.proxy || 'wss://proxy.dotrino.com' }
416
414
  } finally { client.close() }
@@ -598,7 +596,13 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
598
596
  const pending = waitForMsg(client, (p) => p.type === MSG.RENEWED || p.type === MSG.ERROR, 15000)
599
597
  client.sendByPubkey(masterPubkey, { type: MSG.RENEW, data, signature, cert })
600
598
  const res = await pending
601
- if (res.type === MSG.RENEWED && res.cert?.sub === device.publickey) {
599
+ // SE VERIFICA ANTES DE GUARDARLO. Aquí solo se miraba que fuera para esta llave: un
600
+ // papel cualquiera, sin firma que valga, sustituía al bueno y el servicio se quedaba
601
+ // fuera hasta el siguiente arranque. Misma regla que al enrolarse, sin `justSealed`:
602
+ // la última acta pudo sellarla otra selladora de la cuenta.
603
+ const ok = res.type === MSG.RENEWED &&
604
+ (await checkVaultReply({ acta: res.acta, cert: res.cert, vault: masterPubkey, sub: device.publickey })).ok
605
+ if (ok) {
602
606
  cert = res.cert
603
607
  recordar({ cert })
604
608
  // QUIEN NO PASA `dir` GUARDA ÉL. Un servicio que lleva su identidad en su propio
@@ -611,9 +615,24 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
611
615
  }
612
616
 
613
617
  const eph = await makeEphemeralKey()
618
+ /**
619
+ * QUÉ COMANDO Y DESDE QUÉ CARPETA — pero SOLO por el mostrador local.
620
+ *
621
+ * Quien aprueba necesita saber qué está pidiendo las claves: «el aparato 904C-1002 pide
622
+ * el cajón proxy» no alcanza para decidir nada (dueño, 2026-09-11). Así que el pedido
623
+ * lleva el comando y el path, y la bóveda los comprueba contra `/proc`.
624
+ *
625
+ * POR EL PROXIO NO VA, y no es un olvido: el pedido de secretos viaja firmado pero EN
626
+ * CLARO (CONVENCIONES §4.1 — el transporte no cifra), así que meter ahí el `argv` y el
627
+ * `cwd` le enseñaría al proxio las rutas de tu disco y los argumentos de tus comandos,
628
+ * que es justo lo que este ecosistema promete que no pasa. Sin contexto no hay huella,
629
+ * y sin huella no hay ventana: un servicio que pide desde otra máquina sigue pidiendo
630
+ * aprobación cada vez. Se abrirá cuando el pedido vaya sellado (la deuda del §4.1).
631
+ */
632
+ const ctx = client.local ? selfContext() : null
614
633
  // `publicOnly` VIAJA FIRMADO (va dentro de `data`), así que nadie lo puede quitar ni
615
634
  // poner por el camino. Quien decide qué se manda es la bóveda; esto solo es la petición.
616
- const data = { op: 'secrets', ns, ek: eph.ek, publickey: device.publickey, ts: Date.now(), v: MY_VERSION, ...(publicOnly ? { publicOnly: true } : {}) }
635
+ const data = { op: 'secrets', ns, ek: eph.ek, publickey: device.publickey, ts: Date.now(), v: MY_VERSION, ...(ctx ? { ctx } : {}), ...(publicOnly ? { publicOnly: true } : {}) }
617
636
  const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
618
637
  // A QUIÉN SE LE PREGUNTA: primero a la bóveda, y si no contesta, a los REPLICADORES
619
638
  // que el acta nombraba la última vez que la vimos (`docs/replicas.md` §8: «el servicio