@dotrino/vault 0.92.0 → 0.93.1

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/service.js CHANGED
@@ -174,8 +174,8 @@ async function freshClient (proxyUrl, connectTimeoutMs = 20000, master = null) {
174
174
  // reventaría al negociar; en un navegador es nativo y es el camino directo que hay que
175
175
  // preferir. Se mira si está, en vez de apagarlo a mano para siempre: el día que este
176
176
  // código corra en un navegador —o que Node lo traiga— se enciende solo.
177
- const hayWebRTC = typeof globalThis.RTCPeerConnection === 'function'
178
- const client = new WebSocketProxyClient({ url: proxyUrl, enableWebRTC: hayWebRTC, autoReconnect: false })
177
+ const hasWebRTC = typeof globalThis.RTCPeerConnection === 'function'
178
+ const client = new WebSocketProxyClient({ url: proxyUrl, enableWebRTC: hasWebRTC, autoReconnect: false })
179
179
  // connect() del cliente solo se resuelve con 'connected' y solo rechaza en el
180
180
  // evento 'error' de transporte: si el socket cierra LIMPIO antes de 'connected'
181
181
  // (p.ej. el proxy banea la IP y envía close 1008), la promesa quedaría colgada
@@ -207,16 +207,40 @@ async function identifyAsService (client, device) {
207
207
  /** Cuánto espera un agente a que alguien apruebe (el pedido vive 5 min en la bóveda). */
208
208
  export const APPROVAL_TIMEOUT_MS = 5 * 60 * 1000 + 10 * 1000
209
209
 
210
- function waitForMsg (client, predicate, timeoutMs = 30000) {
210
+ /**
211
+ * Espera la respuesta de la bóveda. Con `guard` (las peticiones de SECRETOS), un `vault.error`
212
+ * solo cuenta si viene firmado con la llave de sellado que nombra el acta y atado a ESTA
213
+ * petición (`re`, la huella de su firma): lo que va a la cuenta lo reciben todas sus bóvedas,
214
+ * y la que no puede servir contestaba con un error que se tomaba por la respuesta (accounts se
215
+ * quedaba sin secretos, 2026-10-18). Un error que no se verifica no corta nada; si al acabar el
216
+ * plazo nadie contestó otra cosa, se dice cuál fue, y que no se pudo comprobar.
217
+ */
218
+ function waitForMsg (client, predicate, timeoutMs = 30000, guard = null) {
211
219
  return new Promise((resolve, reject) => {
212
- const off = client.on('message', (_from, payload) => {
213
- if (payload && typeof payload === 'object' && predicate(payload)) { cleanup(); resolve(payload) }
220
+ let unverified = null
221
+ const off = client.on('message', async (_from, payload) => {
222
+ if (!payload || typeof payload !== 'object' || !predicate(payload)) return
223
+ if (guard && payload.type === MSG.ERROR && !await isMyVaultError(payload, guard)) { unverified = payload; return }
224
+ cleanup(); resolve(payload)
214
225
  })
215
- const t = setTimeout(() => { cleanup(); reject(new Error('timeout waiting for the vault reply')) }, timeoutMs)
226
+ const t = setTimeout(() => {
227
+ cleanup()
228
+ if (unverified) reject(Object.assign(new Error(`no verifiable reply from the vault; an unverified one said: ${unverified.error}`), { code: 'unverified-error' }))
229
+ else reject(new Error('timeout waiting for the vault reply'))
230
+ }, timeoutMs)
216
231
  const cleanup = () => { off(); clearTimeout(t) }
217
232
  })
218
233
  }
219
234
 
235
+ /** La huella con la que la bóveda ata un error a la petición que lo causó (`re`). */
236
+ export const requestRef = (signature) => createHash('sha256').update(String(signature || '')).digest('hex')
237
+
238
+ /** ¿Este error lo firmó una llave de sellado del acta, y es para esta petición? */
239
+ export async function isMyVaultError (p, { re, masterPubkey, knownSeq = null }) {
240
+ if (!p?.signed || p.signed.re !== re || p.signed.op !== 'secrets.error' || p.signed.error !== p.error) return false
241
+ try { await verifyResponder({ body: p.signed, seal: p.seal, acta: p.acta, masterPubkey, knownSeq }); return true } catch (_) { return false }
242
+ }
243
+
220
244
  const identityFileOf = (dir) => path.join(dir, IDENTITY_FILE)
221
245
 
222
246
  /**
@@ -521,7 +545,7 @@ export async function registerEncKey ({ dir, ns, proxyUrl, masterPubkey, device,
521
545
  await identifyAsService(client, device)
522
546
  const data = { op: 'enckey', ns, encPub, publickey: device.publickey, ts: Date.now() }
523
547
  const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
524
- const pending = waitForMsg(client, (p) => p.type === MSG.SECRETS_RESULT || p.type === MSG.ERROR, timeoutMs)
548
+ const pending = waitForMsg(client, (p) => p.type === MSG.SECRETS_RESULT || p.type === MSG.ERROR, timeoutMs, { re: requestRef(signature), masterPubkey, knownSeq: saved?.actaSeq ?? null })
525
549
  client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data, signature, cert })
526
550
  const res = await pending
527
551
  if (res.type === MSG.ERROR) throw vaultError(res)
@@ -573,7 +597,7 @@ export async function storeVars ({ dir, ns, proxyUrl, masterPubkey, device, cert
573
597
  const ask = async (data) => {
574
598
  const signed = { ...data, ns, publickey: device.publickey, ts: Date.now() }
575
599
  const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data: signed })
576
- const pending = waitForMsg(client, (p) => p.type === MSG.SECRETS_RESULT || p.type === MSG.ERROR, timeoutMs)
600
+ const pending = waitForMsg(client, (p) => p.type === MSG.SECRETS_RESULT || p.type === MSG.ERROR, timeoutMs, { re: requestRef(signature), masterPubkey, knownSeq: saved?.actaSeq ?? null })
577
601
  client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data: signed, signature, cert })
578
602
  const res = await pending
579
603
  if (res.type === MSG.ERROR) throw vaultError(res)
@@ -627,7 +651,7 @@ export async function fetchDigest ({ dir, ns, proxyUrl, masterPubkey, device, ce
627
651
  await identifyAsService(client, device)
628
652
  const data = { op: 'digest', ns, publickey: device.publickey, ts: Date.now() }
629
653
  const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
630
- const pending = waitForMsg(client, (p) => p.type === MSG.ERROR || (p.type === MSG.SECRETS_RESULT && p.body?.op === 'secrets.digest'), timeoutMs)
654
+ const pending = waitForMsg(client, (p) => p.type === MSG.ERROR || (p.type === MSG.SECRETS_RESULT && p.body?.op === 'secrets.digest'), timeoutMs, { re: requestRef(signature), masterPubkey, knownSeq: saved?.actaSeq ?? null })
631
655
  client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data, signature, cert })
632
656
  const res = await pending
633
657
  if (res.type === MSG.ERROR) throw vaultError(res)
@@ -792,7 +816,7 @@ async function reportToVault (op, { product, version, from = null, dir, proxyUrl
792
816
  await identifyAsService(client, device)
793
817
  const data = { op, product, version, ...(typeof from === 'string' && from ? { from } : {}), publickey: device.publickey, ts: Date.now() }
794
818
  const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
795
- const pending = waitForMsg(client, (p) => p.type === MSG.ERROR || (p.type === MSG.SECRETS_RESULT && p.body?.op === op && p.body.product === product && p.body.version === version), timeoutMs)
819
+ const pending = waitForMsg(client, (p) => p.type === MSG.ERROR || (p.type === MSG.SECRETS_RESULT && p.body?.op === op && p.body.product === product && p.body.version === version), timeoutMs, { re: requestRef(signature), masterPubkey, knownSeq: saved?.actaSeq ?? null })
796
820
  client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data, signature, cert })
797
821
  const res = await pending.catch((e) => {
798
822
  if (/timeout/.test(e.message)) throw Object.assign(new Error(`${op}: the vault did not answer`), { code: 'vault-no-reply' })
@@ -844,7 +868,7 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
844
868
  * Eso pasó — renovar guardaba el papel nuevo y acto seguido apuntar el acta lo pisaba con
845
869
  * el viejo, que ya estaba revocado. Al siguiente arranque: «unauthorized: revoked».
846
870
  */
847
- const recordar = (patch) => {
871
+ const remember = (patch) => {
848
872
  if (!saved) return
849
873
  saved = { ...saved, ...patch }
850
874
  if (dir) { try { writeServiceIdentity(dir, saved) } catch (_) {} }
@@ -858,14 +882,14 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
858
882
  * ahí en adelante. Un pin que retrocede no es un pin — se llama `maxSeq` en el diseño
859
883
  * justo por esto (`acta-de-perfil.md` §2.3).
860
884
  */
861
- const anotarActa = (acta) => {
885
+ const recordActa = (acta) => {
862
886
  if (typeof acta?.seq !== 'number') return
863
887
  if (typeof saved?.actaSeq === 'number' && acta.seq <= saved.actaSeq) return
864
888
  // Y QUIÉNES REPARTEN. Sale del acta, que llega firmada en cada respuesta, así que no
865
889
  // hay una lista que mantener a mano ni un `.env` que se olvide: darle `replica` a un
866
890
  // aparato basta para que sus servicios sepan preguntarle cuando la bóveda no esté.
867
891
  const replicas = (acta.members || []).filter((m) => memberCan(acta, m.pub, 'replica')).map((m) => m.pub)
868
- recordar({ actaSeq: acta.seq, replicas })
892
+ remember({ actaSeq: acta.seq, replicas })
869
893
  }
870
894
 
871
895
  // DOS MOTIVOS PARA PEDIR PAPEL NUEVO:
@@ -874,15 +898,15 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
874
898
  // servicio. Sin esta rama la migración no terminaba nunca — el disparador comparaba
875
899
  // `seq` contra `seq`, y un papel viejo no tiene ninguno, así que no saltaba jamás.
876
900
  // · o el acta que hemos visto va por delante del papel: el dueño cambió permisos.
877
- const legado = !!cert && typeof cert.seq !== 'number'
878
- const atrasado = typeof saved?.actaSeq === 'number' && typeof cert?.seq === 'number' && saved.actaSeq > cert.seq
879
- const renovar = legado || atrasado
901
+ const legacy = !!cert && typeof cert.seq !== 'number'
902
+ const behind = typeof saved?.actaSeq === 'number' && typeof cert?.seq === 'number' && saved.actaSeq > cert.seq
903
+ const renew = legacy || behind
880
904
 
881
905
  // RENOVAR YA NO ES UNA TAREA DE CALENDARIO. El papel no vence: lo único que obliga a
882
906
  // pedir otro es que la bóveda diga que el nuestro se quedó atrás respecto del acta, y
883
907
  // eso lo contesta ella cuando lo pide (`acta-vieja`). Aquí se intenta una vez y no se
884
908
  // insiste: si falla, se sigue con el papel que hay — que probablemente valga.
885
- if (renovar) {
909
+ if (renew) {
886
910
  try {
887
911
  const data = { op: 'renew', publickey: device.publickey, ts: Date.now() }
888
912
  const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
@@ -897,7 +921,7 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
897
921
  (await checkVaultReply({ acta: res.acta, cert: res.cert, vault: masterPubkey, sub: device.publickey })).ok
898
922
  if (ok) {
899
923
  cert = res.cert
900
- recordar({ cert })
924
+ remember({ cert })
901
925
  // QUIEN NO PASA `dir` GUARDA ÉL. Un servicio que lleva su identidad en su propio
902
926
  // archivo (`link.json`) y nos pasa el enlace a mano no tiene `saved`, así que
903
927
  // `recordar` no escribe nada: sin esto renovaba en cada arranque y volvía a
@@ -937,18 +961,18 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
937
961
  // exactamente el caso que un replicador existe para cubrir.
938
962
  //
939
963
  // La bóveda va PRIMERO siempre: un replicador solo puede estar igual de al día o menos.
940
- const destinos = [masterPubkey, ...(saved?.replicas || []).filter((k) => k !== masterPubkey)]
964
+ const targets = [masterPubkey, ...(saved?.replicas || []).filter((k) => k !== masterPubkey)]
941
965
  // El plazo se reparte: no tiene sentido esperar el timeout entero a una bóveda apagada
942
966
  // y quedarse sin tiempo para preguntarle a quien sí está.
943
- const porDestino = Math.max(4000, Math.round(timeoutMs / destinos.length))
967
+ const byTarget = Math.max(4000, Math.round(timeoutMs / targets.length))
944
968
  let res = null
945
- let ultimo = null
946
- for (const destino of destinos) {
947
- const pending = waitForMsg(client, (p) => p.type === MSG.SECRETS_RESULT || p.type === MSG.ERROR, porDestino)
948
- client.sendByPubkey(destino, { type: MSG.SECRETS, data, signature, cert })
949
- try { res = await pending; break } catch (e) { ultimo = e }
969
+ let last = null
970
+ for (const target of targets) {
971
+ const pending = waitForMsg(client, (p) => p.type === MSG.SECRETS_RESULT || p.type === MSG.ERROR, byTarget, { re: requestRef(signature), masterPubkey, knownSeq: saved?.actaSeq ?? null })
972
+ client.sendByPubkey(target, { type: MSG.SECRETS, data, signature, cert })
973
+ try { res = await pending; break } catch (e) { last = e }
950
974
  }
951
- if (!res) throw ultimo || new Error('nobody answered: neither the vault nor any replica')
975
+ if (!res) throw last || new Error('nobody answered: neither the vault nor any replica')
952
976
  if (res.type === MSG.ERROR) throw vaultError(res)
953
977
 
954
978
  // CAJÓN CON APROBACIÓN: la bóveda contesta «pendiente» (firmado) y la respuesta de
@@ -956,10 +980,10 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
956
980
  // sigue identificada. Se espera lo que dura el pedido; denegado o vencido es error.
957
981
  if (res.body?.op === 'secrets.pending' && res.body.ns === ns) {
958
982
  await verifyResponder({ body: res.body, seal: res.seal, acta: res.acta, masterPubkey, knownSeq: saved?.actaSeq ?? null })
959
- anotarActa(res.acta)
983
+ recordActa(res.acta)
960
984
  try { onPending?.({ id: res.body.id, ns, exp: res.body.exp }) } catch (_) {}
961
985
  const until = typeof res.body.exp === 'number' ? Math.max(5000, res.body.exp - Date.now() + 5000) : approvalTimeoutMs
962
- res = await waitForMsg(client, (p) => (p.type === MSG.SECRETS_RESULT && p.body?.op === 'secrets.result') || p.type === MSG.ERROR, Math.min(until, approvalTimeoutMs))
986
+ res = await waitForMsg(client, (p) => (p.type === MSG.SECRETS_RESULT && p.body?.op === 'secrets.result') || p.type === MSG.ERROR, Math.min(until, approvalTimeoutMs), { re: requestRef(signature), masterPubkey, knownSeq: saved?.actaSeq ?? null })
963
987
  .catch((e) => {
964
988
  if (/timeout/.test(e.message)) throw Object.assign(new Error('approval: nobody approved the request in time'), { code: 'unanswered' })
965
989
  throw e
@@ -981,7 +1005,7 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
981
1005
  // dentro se usa hasta después de verificar.
982
1006
  const payload = await openSealed({ privateKey: eph.privateKey, enc: body.enc })
983
1007
  await verifyResponder({ body, seal: res.seal, acta: payload?.acta, masterPubkey, knownSeq: saved?.actaSeq ?? null })
984
- anotarActa(payload?.acta)
1008
+ recordActa(payload?.acta)
985
1009
  // La huella de lo que se acaba de recibir: con ella se compara después (`fetchDigest`)
986
1010
  // sin volver a pedir claves. Ya comprobada: va dentro del cuerpo firmado.
987
1011
  if (typeof body.digest === 'string') lastDigests.set(digestKey(ns, device.publickey), body.digest)
@@ -1117,10 +1141,10 @@ export async function makeSealCheck (acta, masterPubkey, log = console.log) {
1117
1141
  log('[vault] ⚠ the record does not come from the master this agent knows: envelope provenance NOT checked')
1118
1142
  return () => {}
1119
1143
  }
1120
- let avisado = false
1144
+ let warned = false
1121
1145
  return async (owner, key, gen, e, seal) => {
1122
1146
  if (!seal?.sig) {
1123
- if (!avisado) { avisado = true; log('[vault] ⚠ some envelopes carry no signature (sealed before this vault could sign)') }
1147
+ if (!warned) { warned = true; log('[vault] ⚠ some envelopes carry no signature (sealed before this vault could sign)') }
1124
1148
  return
1125
1149
  }
1126
1150
  const pub = sealKeyAt(acta, seal.seq)
@@ -1142,23 +1166,23 @@ async function openSealedBundle (sealed, ident, acta = null, masterPubkey = null
1142
1166
  // TODAS las envolturas de este aparato; se abren perezosamente, solo las que hagan
1143
1167
  // falta. `sealed.ns`/`sealed.dev` (una sola, la vigente) siguen entrando: es el bundle
1144
1168
  // de v4 y sirve para lo que ese vault selló.
1145
- const porGen = { ns: new Map(), dev: new Map() }
1146
- const añade = (cual, info) => { if (info?.wrap) porGen[cual].set(info.gen ?? 0, info.wrap) }
1169
+ const byGen = { ns: new Map(), dev: new Map() }
1170
+ const añade = (which, info) => { if (info?.wrap) byGen[which].set(info.gen ?? 0, info.wrap) }
1147
1171
  añade('ns', sealed.ns); añade('dev', sealed.dev)
1148
- for (const cual of ['ns', 'dev']) for (const info of sealed.wraps?.[cual] || []) añade(cual, info)
1172
+ for (const which of ['ns', 'dev']) for (const info of sealed.wraps?.[which] || []) añade(which, info)
1149
1173
 
1150
- const abiertas = { ns: new Map(), dev: new Map() }
1151
- const cekDe = async (cual, gen) => {
1152
- if (abiertas[cual].has(gen)) return abiertas[cual].get(gen)
1174
+ const opened = { ns: new Map(), dev: new Map() }
1175
+ const cekFrom = async (which, gen) => {
1176
+ if (opened[which].has(gen)) return opened[which].get(gen)
1153
1177
  // Un bundle de v4 no traía `gen` en la envoltura: si solo hay una, es esa.
1154
- const wrap = porGen[cual].get(gen) ?? (porGen[cual].size === 1 ? [...porGen[cual].values()][0] : null)
1178
+ const wrap = byGen[which].get(gen) ?? (byGen[which].size === 1 ? [...byGen[which].values()][0] : null)
1155
1179
  if (!wrap) return null
1156
1180
  const cek = await openWrap({ wrap, myEncPrivateKey: mine })
1157
- abiertas[cual].set(gen, cek)
1181
+ opened[which].set(gen, cek)
1158
1182
  return cek
1159
1183
  }
1160
1184
 
1161
- const comprobarFirma = await makeSealCheck(acta, masterPubkey)
1185
+ const checkSignature = await makeSealCheck(acta, masterPubkey)
1162
1186
 
1163
1187
  const out = {}
1164
1188
  for (const [key, e] of Object.entries(sealed.entries || {})) {
@@ -1177,10 +1201,10 @@ async function openSealedBundle (sealed, ident, acta = null, masterPubkey = null
1177
1201
  { code: 'plaintext-var' })
1178
1202
  }
1179
1203
  // `owner` dice de qué cajón salió, y `gen` con qué llave de ese cajón se abre.
1180
- const cual = String(e.owner || '').startsWith('dev:') ? 'dev' : 'ns'
1204
+ const which = String(e.owner || '').startsWith('dev:') ? 'dev' : 'ns'
1181
1205
  const gen = e.gen ?? e.e?.gen ?? 0
1182
- await comprobarFirma(e.owner, key, gen, e.e, e.seal)
1183
- const cek = await cekDe(cual, gen)
1206
+ await checkSignature(e.owner, key, gen, e.e, e.seal)
1207
+ const cek = await cekFrom(which, gen)
1184
1208
  if (!cek) {
1185
1209
  // NO ES UN TROPIEZO: ES UN ESTADO QUE HAY QUE ARREGLAR EN LA BÓVEDA.
1186
1210
  //
package/src/sshKeys.js CHANGED
@@ -108,9 +108,9 @@ export function signSsh (k, data, flags = 0) {
108
108
  if (k.type === 'ssh-rsa') {
109
109
  // Sin flags es el `ssh-rsa` (SHA-1) histórico, que los servidores modernos rechazan;
110
110
  // OpenSSH pide rsa-sha2-256/512 con las banderas del agente (RFC 8332).
111
- const algo = (flags & SSH_AGENT_RSA_SHA2_512) ? 'rsa-sha2-512' : (flags & SSH_AGENT_RSA_SHA2_256) ? 'rsa-sha2-256' : 'ssh-rsa'
112
- const hash = algo === 'rsa-sha2-512' ? 'sha512' : algo === 'rsa-sha2-256' ? 'sha256' : 'sha1'
113
- return Buffer.concat([sshString(Buffer.from(algo)), sshString(nodeSign(hash, Buffer.from(data), k.privateKey))])
111
+ const something = (flags & SSH_AGENT_RSA_SHA2_512) ? 'rsa-sha2-512' : (flags & SSH_AGENT_RSA_SHA2_256) ? 'rsa-sha2-256' : 'ssh-rsa'
112
+ const hash = something === 'rsa-sha2-512' ? 'sha512' : something === 'rsa-sha2-256' ? 'sha256' : 'sha1'
113
+ return Buffer.concat([sshString(Buffer.from(something)), sshString(nodeSign(hash, Buffer.from(data), k.privateKey))])
114
114
  }
115
115
  throw new Error('ssh: cannot sign with ' + k.type)
116
116
  }
package/src/version.js CHANGED
@@ -14,10 +14,10 @@ import fs from 'node:fs'
14
14
  import { dirname, join } from 'node:path'
15
15
  import { fileURLToPath } from 'node:url'
16
16
 
17
- function leer () {
17
+ function read () {
18
18
  try {
19
- const aqui = dirname(fileURLToPath(import.meta.url))
20
- for (const p of [join(aqui, '..', 'package.json'), join(aqui, '..', '..', 'package.json')]) {
19
+ const here = dirname(fileURLToPath(import.meta.url))
20
+ for (const p of [join(here, '..', 'package.json'), join(here, '..', '..', 'package.json')]) {
21
21
  try {
22
22
  const v = JSON.parse(fs.readFileSync(p, 'utf8'))
23
23
  if (v?.name === '@dotrino/vault' && typeof v.version === 'string') return v.version
@@ -27,4 +27,4 @@ function leer () {
27
27
  return '0.0.0'
28
28
  }
29
29
 
30
- export const PKG_VERSION = leer()
30
+ export const PKG_VERSION = read()