@dotrino/vaultd 0.133.0 → 0.135.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/env.js +6 -2
- package/lib/src/service.js +142 -23
- package/package.json +1 -1
- package/src/vault.js +159 -14
package/lib/src/env.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
import fs from 'node:fs'
|
|
22
22
|
import os from 'node:os'
|
|
23
23
|
import path from 'node:path'
|
|
24
|
-
import { fetchSecrets, waitForSecrets, readServiceIdentity, watchSecretsChanges } from './service.js'
|
|
24
|
+
import { fetchSecrets, waitForSecrets, readServiceIdentity, watchSecretsChanges, lastDigestOf } from './service.js'
|
|
25
25
|
import { isValidSecretsNs } from './protocol.js'
|
|
26
26
|
|
|
27
27
|
/** Raíz donde viven las identidades de servicio de esta máquina/usuario. */
|
|
@@ -276,10 +276,14 @@ export async function watchEnv ({ ns, vault, dir, applied = lastApplied ?? undef
|
|
|
276
276
|
process.exit(reason === 'revoked' ? 1 : exitCode)
|
|
277
277
|
}
|
|
278
278
|
|
|
279
|
+
// La HUELLA de lo que este proceso puso a correr (la trajo el cajón al pedirlo). Con ella la
|
|
280
|
+
// primera comparación ya detecta un cambio ocurrido entre pedir y escuchar, sin volver a
|
|
281
|
+
// pedir las claves — que a un servicio con aprobación le hacía sonar el teléfono al dueño.
|
|
282
|
+
const pub = readServiceIdentity(dir)?.device?.publickey
|
|
279
283
|
return watchSecretsChanges({
|
|
280
284
|
dir,
|
|
281
285
|
ns,
|
|
282
|
-
applied,
|
|
286
|
+
digest: applied !== undefined && pub ? lastDigestOf(ns, pub) : null,
|
|
283
287
|
log: say,
|
|
284
288
|
onChange: ({ ts, via }) => (onUpdate ? onUpdate({ ns, ts, reason: 'changed', via }) : exitNow('changed')),
|
|
285
289
|
// Un cert revocado sale con código de FALLO a propósito: si el supervisor lo
|
package/lib/src/service.js
CHANGED
|
@@ -512,6 +512,114 @@ export async function registerEncKey ({ dir, ns, proxyUrl, masterPubkey, device,
|
|
|
512
512
|
} finally { client.close() }
|
|
513
513
|
}
|
|
514
514
|
|
|
515
|
+
/**
|
|
516
|
+
* GUARDA VARIABLES EN EL CAJÓN DE ESTE SERVICIO (dueño, 2026-09-29: «para facilitar la
|
|
517
|
+
* migración de ENVs a Dotrino»). Es lo que hay detrás de `dotrino-env import`.
|
|
518
|
+
*
|
|
519
|
+
* Los valores se sellan AQUÍ, en la máquina del servicio, para quien diga el acta; la
|
|
520
|
+
* bóveda recibe sobres que no abre. La lista de destinatarios la firma la bóveda y se
|
|
521
|
+
* comprueba (`verifyResponder`): si el proxio pudiera cambiarla por sus llaves, abriría las
|
|
522
|
+
* privadas al pasar.
|
|
523
|
+
*
|
|
524
|
+
* Lo que decide la bóveda, no este cliente:
|
|
525
|
+
* · si hay quien apruebe, se pide aprobación siempre, y al aprobarse puede EDITAR;
|
|
526
|
+
* · si nadie puede aprobar, solo AÑADE lo que falta (una que ya exista falla con `exists`);
|
|
527
|
+
* · BORRAR no existe por aquí.
|
|
528
|
+
*
|
|
529
|
+
* @param {object} o
|
|
530
|
+
* @param {Array<{ key:string, value:string, public?:boolean }>} o.vars
|
|
531
|
+
* @param {string} [o.dir] identidad de `dotrino-env` (`service-identity.json`); o, en su
|
|
532
|
+
* lugar, la identidad por parámetros (`ns`, `proxyUrl`, `masterPubkey`, `device`, `cert`),
|
|
533
|
+
* que es como la trae un agente de remote-agent.
|
|
534
|
+
* @returns {Promise<{ keys: string[], pending: string|null, approval: boolean }>}
|
|
535
|
+
* `pending`: el id del pedido de aprobación (se guarda al aprobar); `keys`: lo guardado ya.
|
|
536
|
+
*/
|
|
537
|
+
export async function storeVars ({ dir, ns, proxyUrl, masterPubkey, device, cert, vars, timeoutMs = 30000 } = {}) {
|
|
538
|
+
const saved = dir ? readServiceIdentity(dir) : null
|
|
539
|
+
ns = ns || saved?.ns
|
|
540
|
+
proxyUrl = proxyUrl || saved?.proxy
|
|
541
|
+
masterPubkey = masterPubkey || saved?.iss
|
|
542
|
+
device = device || saved?.device
|
|
543
|
+
cert = cert || saved?.cert
|
|
544
|
+
if (!isValidSecretsNs(ns)) throw new Error('invalid ns')
|
|
545
|
+
if (!proxyUrl || !masterPubkey || !device || !cert) throw new Error('service not enrolled')
|
|
546
|
+
if (!Array.isArray(vars) || !vars.length) throw new Error('storeVars: no variables to store')
|
|
547
|
+
for (const v of vars) {
|
|
548
|
+
if (typeof v?.key !== 'string' || !v.key) throw new Error('storeVars: every variable needs a key')
|
|
549
|
+
if (typeof v.value !== 'string' || !v.value) throw Object.assign(new Error(`storeVars: "${v.key}" has no value`), { code: 'novalue' })
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const client = await freshClient(proxyUrl, 20000, masterPubkey)
|
|
553
|
+
try {
|
|
554
|
+
await identifyAsService(client, device)
|
|
555
|
+
const ask = async (data) => {
|
|
556
|
+
const signed = { ...data, ns, publickey: device.publickey, ts: Date.now() }
|
|
557
|
+
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data: signed })
|
|
558
|
+
const pending = waitForMsg(client, (p) => p.type === MSG.SECRETS_RESULT || p.type === MSG.ERROR, timeoutMs)
|
|
559
|
+
client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data: signed, signature, cert })
|
|
560
|
+
const res = await pending
|
|
561
|
+
if (res.type === MSG.ERROR) throw vaultError(res)
|
|
562
|
+
await verifyResponder({ body: res.body, seal: res.seal, acta: res.acta, masterPubkey, knownSeq: saved?.actaSeq ?? null })
|
|
563
|
+
if (res.body?.op !== data.op.replace(/^store$/, 'store.result') || res.body?.ns !== ns) throw new Error('the vault answered something else')
|
|
564
|
+
return res.body
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const { recipients, approval } = await ask({ op: 'store.recipients' })
|
|
568
|
+
const { buildSealedVar, authorFromDeviceKey } = await import('./admin.js')
|
|
569
|
+
const author = authorFromDeviceKey(device)
|
|
570
|
+
const items = []
|
|
571
|
+
for (const v of vars) {
|
|
572
|
+
const sealed = await buildSealedVar({
|
|
573
|
+
recipients: v.public ? recipients.public : recipients.private,
|
|
574
|
+
owner: `ns:${ns}`, key: v.key, value: v.value, author
|
|
575
|
+
})
|
|
576
|
+
items.push({ key: v.key, sealed, public: !!v.public })
|
|
577
|
+
}
|
|
578
|
+
const nonce = [...crypto.getRandomValues(new Uint8Array(16))].map((x) => x.toString(16).padStart(2, '0')).join('')
|
|
579
|
+
const r = await ask({ op: 'store', items, nonce })
|
|
580
|
+
return { keys: r.keys || [], pending: r.pending || null, approval: !!approval }
|
|
581
|
+
} finally { client.close() }
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** La huella del último cajón recibido, por `ns` + aparato (ver `fetchDigest`). */
|
|
585
|
+
const lastDigests = new Map()
|
|
586
|
+
const digestKey = (ns, pub) => ns + String.fromCharCode(0) + pub
|
|
587
|
+
/** La huella del cajón que este proceso recibió por última vez, o `null`. */
|
|
588
|
+
export function lastDigestOf (ns, publickey) { return lastDigests.get(digestKey(ns, publickey)) ?? null }
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* ¿CAMBIÓ LA CONFIGURACIÓN? Pregunta la HUELLA del cajón —nombres, visibilidad y generación,
|
|
592
|
+
* ningún valor— y por eso no pide aprobación (dueño, 2026-09-30: «revisa que no se pidan
|
|
593
|
+
* aprobaciones sin motivo»). Es lo que usa `watchSecretsChanges` al reconectarse; antes
|
|
594
|
+
* pedía el cajón entero y a un servicio con aprobación le sonaba el teléfono al dueño en
|
|
595
|
+
* cada despliegue del proxio.
|
|
596
|
+
* @returns {Promise<string>} la huella
|
|
597
|
+
*/
|
|
598
|
+
export async function fetchDigest ({ dir, ns, proxyUrl, masterPubkey, device, cert, timeoutMs = 20000 } = {}) {
|
|
599
|
+
const saved = dir ? readServiceIdentity(dir) : null
|
|
600
|
+
ns = ns || saved?.ns
|
|
601
|
+
proxyUrl = proxyUrl || saved?.proxy
|
|
602
|
+
masterPubkey = masterPubkey || saved?.iss
|
|
603
|
+
device = device || saved?.device
|
|
604
|
+
cert = cert || saved?.cert
|
|
605
|
+
if (!isValidSecretsNs(ns)) throw new Error('invalid ns')
|
|
606
|
+
if (!proxyUrl || !masterPubkey || !device || !cert) throw new Error('service not enrolled')
|
|
607
|
+
const client = await freshClient(proxyUrl, 20000, masterPubkey)
|
|
608
|
+
try {
|
|
609
|
+
await identifyAsService(client, device)
|
|
610
|
+
const data = { op: 'digest', ns, publickey: device.publickey, ts: Date.now() }
|
|
611
|
+
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
|
|
612
|
+
const pending = waitForMsg(client, (p) => p.type === MSG.ERROR || (p.type === MSG.SECRETS_RESULT && p.body?.op === 'secrets.digest'), timeoutMs)
|
|
613
|
+
client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data, signature, cert })
|
|
614
|
+
const res = await pending
|
|
615
|
+
if (res.type === MSG.ERROR) throw vaultError(res)
|
|
616
|
+
await verifyResponder({ body: res.body, seal: res.seal, acta: res.acta, masterPubkey, knownSeq: saved?.actaSeq ?? null })
|
|
617
|
+
if (res.body.ns !== ns || typeof res.body.digest !== 'string') throw new Error('malformed digest reply')
|
|
618
|
+
if (typeof res.body.ts !== 'number' || Math.abs(Date.now() - res.body.ts) > FRESH_WINDOW_MS) throw new Error('stale digest reply')
|
|
619
|
+
return res.body.digest
|
|
620
|
+
} finally { client.close() }
|
|
621
|
+
}
|
|
622
|
+
|
|
515
623
|
/**
|
|
516
624
|
* Pide los secretos del ns al vault (una petición puntual; lanza si falla).
|
|
517
625
|
* Usa la identidad persistida por `enrollService` salvo que se pase explícita.
|
|
@@ -667,7 +775,10 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
667
775
|
try { onPending?.({ id: res.body.id, ns, exp: res.body.exp }) } catch (_) {}
|
|
668
776
|
const until = typeof res.body.exp === 'number' ? Math.max(5000, res.body.exp - Date.now() + 5000) : approvalTimeoutMs
|
|
669
777
|
res = await waitForMsg(client, (p) => (p.type === MSG.SECRETS_RESULT && p.body?.op === 'secrets.result') || p.type === MSG.ERROR, Math.min(until, approvalTimeoutMs))
|
|
670
|
-
.catch((e) => {
|
|
778
|
+
.catch((e) => {
|
|
779
|
+
if (/timeout/.test(e.message)) throw Object.assign(new Error('approval: nobody approved the request in time'), { code: 'unanswered' })
|
|
780
|
+
throw e
|
|
781
|
+
})
|
|
671
782
|
if (res.type === MSG.ERROR) throw vaultError(res)
|
|
672
783
|
}
|
|
673
784
|
|
|
@@ -686,6 +797,9 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
686
797
|
const payload = await openSealed({ privateKey: eph.privateKey, enc: body.enc })
|
|
687
798
|
await verifyResponder({ body, seal: res.seal, acta: payload?.acta, masterPubkey, knownSeq: saved?.actaSeq ?? null })
|
|
688
799
|
anotarActa(payload?.acta)
|
|
800
|
+
// La huella de lo que se acaba de recibir: con ella se compara después (`fetchDigest`)
|
|
801
|
+
// sin volver a pedir claves. Ya comprobada: va dentro del cuerpo firmado.
|
|
802
|
+
if (typeof body.digest === 'string') lastDigests.set(digestKey(ns, device.publickey), body.digest)
|
|
689
803
|
|
|
690
804
|
// DOS CAPAS DE SOBRE, y hacen cosas distintas:
|
|
691
805
|
// · la de fuera (`ek` efímera, recién abierta) tapa el TRAMO — el proxio no ve
|
|
@@ -941,11 +1055,10 @@ async function openSealedBundle (sealed, ident, acta = null, masterPubkey = null
|
|
|
941
1055
|
* `via` dice por dónde se enteró: `notice` (llegó el aviso) o `reconcile` (nadie
|
|
942
1056
|
* avisó y la comparación al reconectar encontró otra configuración).
|
|
943
1057
|
* @param {(info:{nonce:string})=>void} [opts.onRevoked] Cert revocado: apagar YA.
|
|
944
|
-
* @param {
|
|
945
|
-
*
|
|
946
|
-
*
|
|
947
|
-
*
|
|
948
|
-
* limita a tomar la referencia.
|
|
1058
|
+
* @param {string} [opts.digest] La huella del cajón EN USO (`lastDigestOf(ns, pub)` tras
|
|
1059
|
+
* pedirlo). Con ella se detecta ya en la primera conexión un cambio ocurrido entre que el
|
|
1060
|
+
* agente pidió su configuración y logró ponerse a escuchar; sin ella la primera
|
|
1061
|
+
* comparación solo toma la referencia.
|
|
949
1062
|
* @param {number} [opts.graceMs=30000] No obedecer avisos durante los primeros N ms.
|
|
950
1063
|
* La comparación también lo respeta, pero **aplazándose** (el aviso sí se descarta):
|
|
951
1064
|
* es lo que impide que un fallo sistemático se convierta en un ciclo de reinicios,
|
|
@@ -961,7 +1074,7 @@ async function openSealedBundle (sealed, ident, acta = null, masterPubkey = null
|
|
|
961
1074
|
* encontró un cambio.
|
|
962
1075
|
*/
|
|
963
1076
|
export async function watchSecretsChanges ({
|
|
964
|
-
dir, ns, onChange, onRevoked,
|
|
1077
|
+
dir, ns, onChange, onRevoked, digest = null, graceMs = 30000, minIntervalMs = 60000, jitterMs = 5000,
|
|
965
1078
|
reconcileMinMs = 30000, log = () => {}
|
|
966
1079
|
} = {}) {
|
|
967
1080
|
const saved = dir ? readServiceIdentity(dir) : null
|
|
@@ -979,7 +1092,10 @@ export async function watchSecretsChanges ({
|
|
|
979
1092
|
let retryTimer = null
|
|
980
1093
|
// Huella de la configuración EN USO. Comparar huellas y no valores es lo que permite
|
|
981
1094
|
// decir «esto no es lo que está corriendo» sin volver a manejar los secretos.
|
|
982
|
-
|
|
1095
|
+
// La referencia es la HUELLA del cajón que está EN USO (la bóveda la manda con cada entrega:
|
|
1096
|
+
// `lastDigestOf`). La pasa quien sabe qué aplicó; sin ella, la primera comparación solo la
|
|
1097
|
+
// toma. Antes era el bundle entero (`applied`), y compararlo obligaba a volver a pedirlo.
|
|
1098
|
+
let fingerprint = typeof digest === 'string' ? digest : null
|
|
983
1099
|
let firstConnection = true
|
|
984
1100
|
let lastReconcile = 0
|
|
985
1101
|
let reconciling = false
|
|
@@ -1137,9 +1253,11 @@ export async function watchSecretsChanges ({
|
|
|
1137
1253
|
return false
|
|
1138
1254
|
}
|
|
1139
1255
|
reconciling = true
|
|
1140
|
-
let
|
|
1256
|
+
let current = null
|
|
1141
1257
|
try {
|
|
1142
|
-
|
|
1258
|
+
// LA HUELLA, NO EL CAJÓN: preguntar si cambió no es pedir las claves, y no puede
|
|
1259
|
+
// hacer sonar el teléfono de nadie.
|
|
1260
|
+
current = await fetchDigest({ dir, ns })
|
|
1143
1261
|
} catch (e) {
|
|
1144
1262
|
// El cert revocado mientras estaba incomunicado: el `REVOKED` firmado se perdió
|
|
1145
1263
|
// igual que el aviso, y esta es la única otra forma de enterarse. Lo demás (la
|
|
@@ -1156,8 +1274,7 @@ export async function watchSecretsChanges ({
|
|
|
1156
1274
|
reconciling = false
|
|
1157
1275
|
lastReconcile = Date.now()
|
|
1158
1276
|
}
|
|
1159
|
-
|
|
1160
|
-
if (fingerprint === null) { fingerprint = current; return false } // primera vez: solo tomar referencia
|
|
1277
|
+
if (fingerprint === null) { fingerprint = current; return false } // sin referencia: solo tomarla
|
|
1161
1278
|
if (current === fingerprint) return false
|
|
1162
1279
|
fingerprint = current
|
|
1163
1280
|
lastObeyed = Date.now()
|
|
@@ -1210,16 +1327,6 @@ export async function watchSecretsChanges ({
|
|
|
1210
1327
|
}
|
|
1211
1328
|
}
|
|
1212
1329
|
|
|
1213
|
-
/**
|
|
1214
|
-
* Huella de un bundle. Las claves van ORDENADAS: el bundle se arma mezclando el cajón
|
|
1215
|
-
* del scope con el del aparato, así que el mismo contenido puede llegar en otro orden y
|
|
1216
|
-
* un cambio de orden no es un cambio de configuración.
|
|
1217
|
-
*/
|
|
1218
|
-
function fingerprintOf (secrets) {
|
|
1219
|
-
const pairs = Object.entries(secrets || {}).map(([k, v]) => [k, String(v)]).sort((a, b) => (a[0] < b[0] ? -1 : 1))
|
|
1220
|
-
return createHash('sha256').update(JSON.stringify(pairs)).digest('hex')
|
|
1221
|
-
}
|
|
1222
|
-
|
|
1223
1330
|
/**
|
|
1224
1331
|
* Bucle de arranque de un servicio: pide los secretos y, si el vault no está
|
|
1225
1332
|
* disponible, REINTENTA para siempre (con backoff hasta `maxRetryMs`). El
|
|
@@ -1283,6 +1390,12 @@ export function isFinal (e) {
|
|
|
1283
1390
|
* @param {number} approvalTimeoutMs lo que dura un pedido de aprobación
|
|
1284
1391
|
* @param {number} now
|
|
1285
1392
|
*/
|
|
1393
|
+
/** Cuánto esperar para volver a pedir tras `n` pedidos sin contestar: 0, 5, 10, 20… min, hasta 6 h. */
|
|
1394
|
+
export function unansweredDelay (n) {
|
|
1395
|
+
if (!n) return 0
|
|
1396
|
+
return Math.min(6 * 60 * 60 * 1000, 5 * 60 * 1000 * 2 ** (n - 1))
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1286
1399
|
export function retryDelay (backoffMs, rangAt, approvalTimeoutMs = APPROVAL_TIMEOUT_MS, now = Date.now()) {
|
|
1287
1400
|
if (!rangAt) return backoffMs
|
|
1288
1401
|
return Math.max(backoffMs, approvalTimeoutMs - (now - rangAt))
|
|
@@ -1291,13 +1404,19 @@ export function retryDelay (backoffMs, rangAt, approvalTimeoutMs = APPROVAL_TIME
|
|
|
1291
1404
|
export async function waitForSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, retryMs = 5000, maxRetryMs = 60000, approvalTimeoutMs = APPROVAL_TIMEOUT_MS, onRetry, onPending, onCert, publicOnly = false } = {}) {
|
|
1292
1405
|
let backoff = retryMs
|
|
1293
1406
|
let rangAt = 0 // cuándo timbró ESTE intento; 0 = no hubo aprobación de por medio
|
|
1407
|
+
let unanswered = 0
|
|
1294
1408
|
const notePending = (info) => { rangAt = Date.now(); onPending?.(info) }
|
|
1295
1409
|
for (;;) {
|
|
1296
1410
|
try {
|
|
1297
1411
|
return await fetchSecrets({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, approvalTimeoutMs, onPending: notePending, onCert, publicOnly })
|
|
1298
1412
|
} catch (e) {
|
|
1299
1413
|
if (isFinal(e)) throw e
|
|
1300
|
-
|
|
1414
|
+
// UN PEDIDO QUE NADIE CONTESTÓ NO SE REPITE CADA CINCO MINUTOS (dueño, 2026-09-30:
|
|
1415
|
+
// «que no se pidan aprobaciones sin motivo»). Volver a pedir en cuanto vence hacía sonar
|
|
1416
|
+
// el teléfono toda la noche por un servicio que arrancó de madrugada: el dueño ya lo vio
|
|
1417
|
+
// y no lo aprobó. Se espacia —5, 10, 20, 40 min… hasta 6 h— y se sigue esperando.
|
|
1418
|
+
if (e?.code === 'unanswered') unanswered++
|
|
1419
|
+
const wait = Math.max(retryDelay(backoff, rangAt, approvalTimeoutMs), unansweredDelay(unanswered))
|
|
1301
1420
|
rangAt = 0
|
|
1302
1421
|
try { onRetry?.(e, wait) } catch (_) {}
|
|
1303
1422
|
await new Promise((r) => setTimeout(r, wait))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vaultd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.135.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": {
|
package/src/vault.js
CHANGED
|
@@ -150,6 +150,11 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
150
150
|
* permisos como los demás, y se quita quitándolo — sin acordarse de un registro escondido
|
|
151
151
|
* en una máquina.
|
|
152
152
|
*/
|
|
153
|
+
/** ¿Hay en el acta alguien que pueda aprobar? Sin nadie, no se pide aprobación. */
|
|
154
|
+
function hasApprover (record) {
|
|
155
|
+
return !!record?.members?.some((m) => Acta.memberCan(record, m.pub, 'approve'))
|
|
156
|
+
}
|
|
157
|
+
|
|
153
158
|
function needsApproval (pub, record) {
|
|
154
159
|
if (!record) return true // sin acta no se decide que sí: se pide permiso
|
|
155
160
|
// SIN NADIE QUE APRUEBE, NO SE NECESITA APROBACIÓN (dueño, 2026-09-24: «la regla es, si
|
|
@@ -1423,6 +1428,124 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1423
1428
|
}
|
|
1424
1429
|
}
|
|
1425
1430
|
|
|
1431
|
+
/**
|
|
1432
|
+
* UN SERVICIO GUARDA VARIABLES EN SU PROPIO CAJÓN (dueño, 2026-09-29: «para facilitar la
|
|
1433
|
+
* migración de ENVs a Dotrino»). Es lo que usa `dotrino-env import`: el `.env` que ya
|
|
1434
|
+
* existe en la máquina del servicio sube tal cual, sin copiarlo a mano en la consola.
|
|
1435
|
+
*
|
|
1436
|
+
* Va por el canal del servicio —el mismo que lee el cajón— y con SUS fronteras: el cert
|
|
1437
|
+
* con `vault:secrets:<ns>` y el CN del acta. Así un servicio solo escribe en su cajón y
|
|
1438
|
+
* nunca en el de otro. La guarda la hace la MISMA pieza que la consola (`varsDesk`): la
|
|
1439
|
+
* firma del autor, las envolturas, la espera de aprobación. Aquí no hay otra puerta.
|
|
1440
|
+
*
|
|
1441
|
+
* Los límites (dueño, 2026-09-29):
|
|
1442
|
+
* · si hay alguien que aprueba, SIEMPRE se pide aprobación —`unattended` no exime— y con
|
|
1443
|
+
* el sí puede también EDITAR lo que ya existe;
|
|
1444
|
+
* · si nadie puede aprobar, no se pide y solo puede AÑADIR lo que falta;
|
|
1445
|
+
* · BORRAR, nunca: eso es de la consola y del CLI de la bóveda.
|
|
1446
|
+
*
|
|
1447
|
+
* Dos operaciones: `store.recipients` (para quién se sella: solo públicas, nada que ocultar)
|
|
1448
|
+
* y `store` (los sobres ya hechos; la bóveda no los abre).
|
|
1449
|
+
*/
|
|
1450
|
+
/**
|
|
1451
|
+
* LA HUELLA DE UN CAJÓN, tal como lo recibiría este aparato: nombres y GENERACIÓN de cada
|
|
1452
|
+
* variable, sin un solo valor. La visibilidad NO entra: taparla no cambia lo que el servicio
|
|
1453
|
+
* lee (y no sube la generación ni avisa). La generación la sube el almacén al
|
|
1454
|
+
* escribir, así que la huella cambia cuando cambia la configuración y no cuando la
|
|
1455
|
+
* bóveda solo rehace envolturas al abrirse. (Una rotación de llaves —al quitar un
|
|
1456
|
+
* aparato— sí la sube: el servicio se reinicia una vez, que es raro y no hace daño.)
|
|
1457
|
+
*/
|
|
1458
|
+
function drawerDigest (ns, devicePub) {
|
|
1459
|
+
const b = secrets.bundleFor(ns, devicePub)
|
|
1460
|
+
const filas = Object.entries(b.entries || {})
|
|
1461
|
+
.map(([k, e]) => [k, b.legacy ? nodeCrypto.createHash('sha256').update(String(e.v)).digest('hex') : (e.gen ?? null)])
|
|
1462
|
+
.sort((a, c) => (a[0] < c[0] ? -1 : 1))
|
|
1463
|
+
return nodeCrypto.createHash('sha256').update(JSON.stringify(filas)).digest('hex')
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
/**
|
|
1467
|
+
* ¿CAMBIÓ MI CONFIGURACIÓN? Sin llevarse nada (dueño, 2026-09-30: «revisa que no se pidan
|
|
1468
|
+
* aprobaciones sin motivo»).
|
|
1469
|
+
*
|
|
1470
|
+
* El servicio lo pregunta al reconectarse, por si se perdió un aviso. Antes lo preguntaba
|
|
1471
|
+
* pidiendo el cajón ENTERO —privadas incluidas— para sacarle una huella, y eso es un
|
|
1472
|
+
* pedido de claves: a un servicio que pide aprobación le sonaba el teléfono al dueño en
|
|
1473
|
+
* cada despliegue del proxio, para aprobar algo que no entregaba nada que el servicio no
|
|
1474
|
+
* tuviera ya. La huella no revela nada, así que no se aprueba: quien pregunta tiene que
|
|
1475
|
+
* ser igualmente el servicio de ese cajón (cert + CN del acta).
|
|
1476
|
+
*/
|
|
1477
|
+
async function handleDigest (from, p) {
|
|
1478
|
+
const ns = p.data?.ns
|
|
1479
|
+
if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: 'digest: invalid namespace' })
|
|
1480
|
+
const chk = await verifyChain({
|
|
1481
|
+
data: p.data, signature: p.signature, cert: p.cert,
|
|
1482
|
+
expectedScope: secretsScope(ns), ...(await contextoActa()), revoked: await revocationSet()
|
|
1483
|
+
})
|
|
1484
|
+
if (!chk.ok) return denyChain(from, chk, p, 'digest')
|
|
1485
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
1486
|
+
if (!record || !Acta.memberCanReadSecrets(record, chk.device, ns)) {
|
|
1487
|
+
audit('rejected', { what: 'digest', ns, reason: record ? 'cn' : 'sin-acta' })
|
|
1488
|
+
return reply(from, { type: MSG.ERROR, error: `unauthorized: cn — the record does not recognise this member as the "${ns}" service` })
|
|
1489
|
+
}
|
|
1490
|
+
const body = { op: 'secrets.digest', ns, digest: drawerDigest(ns, chk.device), ts: Date.now() }
|
|
1491
|
+
reply(from, { type: MSG.SECRETS_RESULT, body, seal: await sealOrFail(body), acta: record })
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
const storeNonces = new Map()
|
|
1495
|
+
async function handleVarStore (from, p) {
|
|
1496
|
+
const ns = p.data?.ns
|
|
1497
|
+
const op = p.data?.op
|
|
1498
|
+
if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: `${op}: invalid namespace` })
|
|
1499
|
+
const chk = await verifyChain({
|
|
1500
|
+
data: p.data, signature: p.signature, cert: p.cert,
|
|
1501
|
+
expectedScope: secretsScope(ns), ...(await contextoActa()), revoked: await revocationSet()
|
|
1502
|
+
})
|
|
1503
|
+
if (!chk.ok) return denyChain(from, chk, p, op)
|
|
1504
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
1505
|
+
if (!record || !Acta.memberCanReadSecrets(record, chk.device, ns)) {
|
|
1506
|
+
audit('rejected', { what: op, ns, reason: record ? 'cn' : 'sin-acta' })
|
|
1507
|
+
return reply(from, { type: MSG.ERROR, error: `unauthorized: cn — the record does not recognise this member as the "${ns}" service` })
|
|
1508
|
+
}
|
|
1509
|
+
const deviceId = await deviceIdOf(chk.device).catch(() => null)
|
|
1510
|
+
// FIRMADA COMO TODO LO QUE SIRVE LA BÓVEDA (con la llave de sellado que nombra el acta, y
|
|
1511
|
+
// el acta al lado): `store.recipients` dice PARA QUIÉN se sellan los valores, y si el
|
|
1512
|
+
// proxio pudiera cambiar esa lista por sus llaves, abriría las privadas al pasar.
|
|
1513
|
+
const answer = async (body) => reply(from, { type: MSG.SECRETS_RESULT, body, seal: await sealOrFail(body), acta: record })
|
|
1514
|
+
try {
|
|
1515
|
+
if (op === 'store.recipients') {
|
|
1516
|
+
return await answer({
|
|
1517
|
+
op: 'store.recipients', ns, ts: Date.now(),
|
|
1518
|
+
recipients: { public: await varsDesk.recipients({ ns, public: true }), private: await varsDesk.recipients({ ns, public: false }) },
|
|
1519
|
+
// Para que quien importa sepa de antemano qué va a pasar con lo que ya existe.
|
|
1520
|
+
approval: hasApprover(record)
|
|
1521
|
+
})
|
|
1522
|
+
}
|
|
1523
|
+
// CAMBIA ESTADO: un solo uso. La frescura (±5 min) no basta — repetir el mismo sobre
|
|
1524
|
+
// dentro de esa ventana volvería a timbrar el teléfono de quien aprueba.
|
|
1525
|
+
const nonce = p.data?.nonce
|
|
1526
|
+
if (typeof nonce !== 'string' || nonce.length < 16) return reply(from, { type: MSG.ERROR, code: 'no-nonce', error: 'store: missing single-use nonce' })
|
|
1527
|
+
const now = Date.now()
|
|
1528
|
+
for (const [n, exp] of storeNonces) if (exp <= now) storeNonces.delete(n)
|
|
1529
|
+
if (storeNonces.has(nonce)) { audit('rejected', { what: 'store', ns, reason: 'replay' }); return reply(from, { type: MSG.ERROR, code: 'replay', error: 'store: nonce already used' }) }
|
|
1530
|
+
storeNonces.set(nonce, now + 10 * 60 * 1000)
|
|
1531
|
+
|
|
1532
|
+
const items = p.data?.items
|
|
1533
|
+
if (!Array.isArray(items) || !items.length) return reply(from, { type: MSG.ERROR, error: 'store: needs `items`, each with its key and its sealed envelope' })
|
|
1534
|
+
const result = await varsDesk.setMany({ ns, items, caller: chk.device, by: deviceId, fromService: true })
|
|
1535
|
+
if (result?.pending) {
|
|
1536
|
+
audit('store.pending', { device: deviceId, ns, id: result.pending, count: items.length })
|
|
1537
|
+
log(`[vault] ${ns}: ${deviceId || '????-????'} wants to store ${items.length} variable(s) — waiting for approval (${result.pending})`)
|
|
1538
|
+
} else {
|
|
1539
|
+
audit('store', { device: deviceId, ns, keys: result?.keys || [] })
|
|
1540
|
+
log(`[vault] ${ns}: ${deviceId || '????-????'} stored ${(result?.keys || []).length} variable(s) (nobody can approve: add only)`)
|
|
1541
|
+
await notifyMembers('vars', { by: deviceId, keys: result?.keys || [], ns })
|
|
1542
|
+
}
|
|
1543
|
+
return await answer({ op: 'store.result', ns, ts: Date.now(), keys: result?.keys || [], pending: result?.pending || null })
|
|
1544
|
+
} catch (e) {
|
|
1545
|
+
return reply(from, { type: MSG.ERROR, ...(e.code ? { code: e.code } : {}), error: `${op}: ${e.message}` })
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1426
1549
|
async function handleSecrets (from, p) {
|
|
1427
1550
|
if (!isFresh(p.data)) { audit('rejected', { what: 'secrets', reason: 'stale' }); return staleReply(from) }
|
|
1428
1551
|
// REGISTRAR LA LLAVE DE CIFRADO de un servicio ya enrolado. Va por aquí, y no por
|
|
@@ -1433,6 +1556,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1433
1556
|
// da acceso a nada por sí solo. Quien firma esta petición ya tiene la llave de firma
|
|
1434
1557
|
// del servicio y su cert, o sea que ya lee ese namespace. No hay escalada.
|
|
1435
1558
|
if (p.data?.op === 'enckey') return handleEncKey(from, p)
|
|
1559
|
+
if (p.data?.op === 'store.recipients' || p.data?.op === 'store') return handleVarStore(from, p)
|
|
1560
|
+
if (p.data?.op === 'digest') return handleDigest(from, p)
|
|
1436
1561
|
if (['approvals', 'approve', 'deny', 'grants', 'grant-revoke'].includes(p.data?.op)) return handleApproval(from, p)
|
|
1437
1562
|
const ns = p.data?.ns
|
|
1438
1563
|
if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: 'secrets: invalid namespace' })
|
|
@@ -1556,7 +1681,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1556
1681
|
}
|
|
1557
1682
|
}
|
|
1558
1683
|
const enc = await seal({ ek, payload })
|
|
1559
|
-
|
|
1684
|
+
// La huella va con cada entrega: es la referencia con la que el servicio compara después
|
|
1685
|
+
// (`digest`) sin volver a pedir claves.
|
|
1686
|
+
const body = { op: 'secrets.result', ns, enc, digest: drawerDigest(ns, devicePub), ts: Date.now() }
|
|
1560
1687
|
// LA MAESTRA NO FIRMA ESTO. Su trabajo es sellar el acta y reenvolver sobres; servir
|
|
1561
1688
|
// no es suyo. Quien firma es la LLAVE DE SELLADO que el acta nombra (`sealPub`), la
|
|
1562
1689
|
// misma que ya firma cada sobre — y por eso esto se puede servir con el perfil
|
|
@@ -1734,7 +1861,10 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1734
1861
|
*/
|
|
1735
1862
|
async function notifyApprovers (pend, record) {
|
|
1736
1863
|
try {
|
|
1737
|
-
|
|
1864
|
+
// `kind` dice QUÉ se pide (llevarse claves, guardar variables, actualizar…): es lo que la
|
|
1865
|
+
// app necesita para que el aviso diga el porqué sin tener que abrir la lista. Los
|
|
1866
|
+
// detalles (comando, carpeta, nombres) NO van aquí: van sellados en la lista.
|
|
1867
|
+
const body = { ev: 'approval', id: pend.id, ns: pend.ns, kind: pend.kind || 'read', deviceId: pend.deviceId, label: pend.label, exp: pend.exp, ts: Date.now() }
|
|
1738
1868
|
const seal = await sealOrFail(body)
|
|
1739
1869
|
const who = (record?.members || []).filter((m) => Acta.memberCan(record, m.pub, 'approve')).map((m) => m.pub)
|
|
1740
1870
|
if (!who.length) log(`[vault] ${pend.ns}: nobody can approve (grant it with: dotrino-vault caps <ID> +aprueba)`)
|
|
@@ -2323,8 +2453,10 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2323
2453
|
// PÚBLICA Y PRIVADA VAN IGUAL (dueño, 2026-09-02: «la única diferencia es si se
|
|
2324
2454
|
// despachan o no, son políticas; dales el mismo tratamiento de seguridad»). La
|
|
2325
2455
|
// marca solo decide si se entrega sin aprobación.
|
|
2456
|
+
// Si esto va a pedir aprobación, al aprobarse puede EDITAR; si no, solo añadir.
|
|
2457
|
+
const ask = needsApproval(caller, await refreshActa())
|
|
2326
2458
|
const apply = async () => {
|
|
2327
|
-
await checkAuthor(owner, key, sealed, caller)
|
|
2459
|
+
await checkAuthor(owner, key, sealed, caller, { allowOverwrite: ask })
|
|
2328
2460
|
await checkRecipients(owner, sealed.wraps, !!isPublic)
|
|
2329
2461
|
await secrets.putSealed(owner, key, sealed, { by: who, public: isPublic })
|
|
2330
2462
|
audit('secret.set', { ns: ns || null, key, sealed: true }); scheduleNotice(ns)
|
|
@@ -2332,9 +2464,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2332
2464
|
}
|
|
2333
2465
|
// Comprobado ANTES de dejarlo en espera: un sobre malo falla al guardarlo, no cinco
|
|
2334
2466
|
// minutos después en la pantalla de quien aprueba. `apply` lo repite al aprobar.
|
|
2335
|
-
await checkAuthor(owner, key, sealed, caller)
|
|
2467
|
+
await checkAuthor(owner, key, sealed, caller, { allowOverwrite: ask })
|
|
2336
2468
|
await checkRecipients(owner, sealed.wraps, !!isPublic)
|
|
2337
|
-
const pending = await holdWrite({ caller, ns, pub, keys: [key], apply })
|
|
2469
|
+
const pending = await holdWrite({ caller, ns, pub, keys: [key], apply, ask })
|
|
2338
2470
|
if (pending) return { ok: true, key, pending }
|
|
2339
2471
|
await apply()
|
|
2340
2472
|
return { ok: true, key }
|
|
@@ -2362,7 +2494,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2362
2494
|
* Los NOMBRES también viajan dentro del sobre —no solo los valores—: el proxy
|
|
2363
2495
|
* transporta y no tiene por qué aprender cómo se llama la configuración de un servicio.
|
|
2364
2496
|
*/
|
|
2365
|
-
async setMany ({ ns, pub, items, caller = null, by: who = null }) {
|
|
2497
|
+
async setMany ({ ns, pub, items, caller = null, by: who = null, fromService = false }) {
|
|
2366
2498
|
// CADA VARIABLE VIENE EN SU PROPIO SOBRE, ya hecho. La bóveda no abre ninguno: solo
|
|
2367
2499
|
// comprueba quién los firma y a quién envuelven, igual que en `set`. Aquí estaba el
|
|
2368
2500
|
// `enc` que las traía todas juntas selladas AL PERFIL, y abrirlo era lo que obligaba
|
|
@@ -2370,16 +2502,24 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2370
2502
|
if (!Array.isArray(items) || !items.length) throw new Error('var.setMany: no variables came')
|
|
2371
2503
|
const owner = ns ? `ns:${ns}` : `dev:${pub}`
|
|
2372
2504
|
const keys = []
|
|
2505
|
+
// ¿SE PIDE APROBACIÓN? Desde la mesa del admin, lo de siempre (`needsApproval`: quien
|
|
2506
|
+
// tiene `unattended` escribe solo). Desde el propio SERVICIO que guarda en su cajón, se
|
|
2507
|
+
// pide SIEMPRE que haya quien apruebe, tenga o no `unattended` (dueño, 2026-09-29:
|
|
2508
|
+
// «el unattended también pediría aprobación»): `unattended` es para llevarse claves al
|
|
2509
|
+
// arrancar, no para reescribir su propia configuración. Sin nadie que apruebe no se
|
|
2510
|
+
// pide —la regla de siempre— y entonces solo puede añadir.
|
|
2511
|
+
const record = await refreshActa()
|
|
2512
|
+
const ask = fromService ? hasApprover(record) : needsApproval(caller, record)
|
|
2373
2513
|
for (const it of items) {
|
|
2374
2514
|
if (!it?.key || !it?.sealed) throw new Error('var.setMany: each variable needs its key and its sealed envelope')
|
|
2375
|
-
await checkAuthor(owner, it.key, it.sealed, caller)
|
|
2515
|
+
await checkAuthor(owner, it.key, it.sealed, caller, { allowOverwrite: ask })
|
|
2376
2516
|
await checkRecipients(owner, it.sealed.wraps, !!it.public)
|
|
2377
2517
|
}
|
|
2378
2518
|
// Se comprueban TODAS antes de escribir NINGUNA: media carga aplicada es una
|
|
2379
2519
|
// configuración que nadie quiso, y el servicio se reinicia con ella.
|
|
2380
2520
|
const apply = async () => {
|
|
2381
2521
|
for (const it of items) {
|
|
2382
|
-
await checkAuthor(owner, it.key, it.sealed, caller)
|
|
2522
|
+
await checkAuthor(owner, it.key, it.sealed, caller, { allowOverwrite: ask })
|
|
2383
2523
|
await checkRecipients(owner, it.sealed.wraps, !!it.public)
|
|
2384
2524
|
}
|
|
2385
2525
|
for (const it of items) {
|
|
@@ -2389,7 +2529,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2389
2529
|
audit('secret.setMany', { ns: ns || null, keys }); scheduleNotice(ns)
|
|
2390
2530
|
await settleDebts(owner)
|
|
2391
2531
|
}
|
|
2392
|
-
const pending = await holdWrite({ caller, ns, pub, keys: items.map((it) => it.key), apply })
|
|
2532
|
+
const pending = await holdWrite({ caller, ns, pub, keys: items.map((it) => it.key), apply, ask })
|
|
2393
2533
|
if (pending) return { ok: true, keys: [], pending }
|
|
2394
2534
|
await apply()
|
|
2395
2535
|
return { ok: true, keys }
|
|
@@ -2410,8 +2550,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2410
2550
|
*
|
|
2411
2551
|
* Devuelve el id del pedido si la escritura quedó en espera, o `null` si se puede hacer ya.
|
|
2412
2552
|
*/
|
|
2413
|
-
async function holdWrite ({ caller, ns, pub, keys, apply }) {
|
|
2414
|
-
if (!needsApproval(caller, await refreshActa())) return null
|
|
2553
|
+
async function holdWrite ({ caller, ns, pub, keys, apply, ask }) {
|
|
2554
|
+
if (!(ask ?? needsApproval(caller, await refreshActa()))) return null
|
|
2415
2555
|
// Quien llama ya comprobó el sobre; `apply` lo vuelve a comprobar al aprobar, porque el
|
|
2416
2556
|
// acta pudo cambiar en medio.
|
|
2417
2557
|
const target = ns || await deviceIdOf(pub).catch(() => null) || '?'
|
|
@@ -3062,7 +3202,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
3062
3202
|
* relevo: alguien con `admin` colando un sobre que fabricó un tercero, sin que ninguna de
|
|
3063
3203
|
* las dos comprobaciones lo note.
|
|
3064
3204
|
*/
|
|
3065
|
-
async function checkAuthor (owner, key, sealed, caller) {
|
|
3205
|
+
async function checkAuthor (owner, key, sealed, caller, { allowOverwrite = false } = {}) {
|
|
3066
3206
|
const a = sealed?.author
|
|
3067
3207
|
if (!a || typeof a.pub !== 'string' || typeof a.sig !== 'string' || typeof a.ts !== 'number') {
|
|
3068
3208
|
throw new Error('var.set: the envelope must say WHO made it (`author: { pub, sig, ts }`)')
|
|
@@ -3088,12 +3228,17 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
3088
3228
|
// un sobre existente; solamente el admin y el vault editan sobres, los otros pueden
|
|
3089
3229
|
// crear los que faltan nomás»).
|
|
3090
3230
|
//
|
|
3231
|
+
// SALVO QUE ALGUIEN LO APRUEBE (dueño, 2026-09-29: «si requiere aprobación hagamos que
|
|
3232
|
+
// pueda también editar; si no hay approval, solamente agregar — la aprobación nos da el
|
|
3233
|
+
// nivel de seguridad aceptable»). Un sí de quien aprueba es justo la decisión humana que
|
|
3234
|
+
// faltaba para reemplazar; sin él, sigue siendo solo rellenar.
|
|
3235
|
+
//
|
|
3091
3236
|
// Es la misma regla que `putWrap` («solo añade, nunca pisa») y por el mismo motivo: un
|
|
3092
3237
|
// servicio que pudiera reemplazar un sobre podría dejar sin leer a otro miembro con uno
|
|
3093
3238
|
// basura — denegación de servicio disfrazada de escritura. Rellenar lo que falta no
|
|
3094
3239
|
// quita nada a nadie; reemplazar sí, y eso es de quien administra.
|
|
3095
|
-
if (m.cn && secrets.has?.(owner, key)) {
|
|
3096
|
-
throw new Error(`var.set: "${m.cn}" cannot overwrite "${key}" — a service only fills in what is missing; replacing is for who administers`)
|
|
3240
|
+
if (m.cn && !allowOverwrite && secrets.has?.(owner, key)) {
|
|
3241
|
+
throw Object.assign(new Error(`var.set: "${m.cn}" cannot overwrite "${key}" — without an approval a service only fills in what is missing; replacing is for who administers`), { code: 'exists' })
|
|
3097
3242
|
}
|
|
3098
3243
|
const cuerpo = authorBody(owner, key, sealed.e, a.ts)
|
|
3099
3244
|
if (!(await verifyDeviceSig({ publickey: a.pub, data: cuerpo, signature: a.sig }))) {
|