@dotrino/identity 0.115.0 → 0.116.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/package.json +4 -1
- package/src/node.js +4 -180
- package/vault/core.js +66 -7
- package/vault/facade.js +200 -0
- package/vault/index.html +10 -0
- package/vault/remote.js +45 -5
- package/vault/vault.js +29 -58
- package/vault/vendor/compat/VERSION.txt +3 -0
- package/vault/vendor/compat/advisory.js +141 -0
- package/vault/vendor/compat/index.js +188 -0
- package/vault/vendor/compat/ranges.js +93 -0
- package/vault/vendor/opaque/VERSION.txt +1 -1
- package/vault/vendor/passmanager/VERSION.txt +3 -0
- package/vault/vendor/passmanager/crypto.js +167 -0
- package/vault/vendor/passmanager/fields.js +183 -0
- package/vault/vendor/passmanager/generate.js +74 -0
- package/vault/vendor/passmanager/import.js +156 -0
- package/vault/vendor/passmanager/index.js +27 -0
- package/vault/vendor/passmanager/match.js +137 -0
- package/vault/vendor/passmanager/model.js +332 -0
- package/vault/vendor/passmanager/pubkey.js +33 -0
- package/vault/vendor/passmanager/sealed/convert.js +72 -0
- package/vault/vendor/passmanager/sealed/device.js +211 -0
- package/vault/vendor/passmanager/sealed/entry.js +209 -0
- package/vault/vendor/passmanager/sealed/index.js +20 -0
- package/vault/vendor/passmanager/sealed/keys.js +120 -0
- package/vault/vendor/passmanager/sealed/recovery.js +136 -0
- package/vault/vendor/passmanager/sealed/store.js +508 -0
- package/vault/vendor/passmanager/session-cache.js +60 -0
- package/vault/vendor/passmanager/totp.js +111 -0
- package/vault/vendor/passmanager/transport/protocol.js +83 -0
- package/vault/vendor/passmanager/transport/proxy.js +112 -0
- package/vault/vendor/passmanager/transport/sealed.js +74 -0
- package/vault/vendor/passmanager/vault/approval.js +63 -0
- package/vault/vendor/passmanager/vault/errors.js +34 -0
- package/vault/vendor/passmanager/vault/guard.js +114 -0
- package/vault/vendor/passmanager/vault/interface.js +34 -0
- package/vault/vendor/passmanager/vault/local.js +213 -0
- package/vault/vendor/passmanager/vault/remote.js +85 -0
- package/vault/vendor/passmanager/vault/responder.js +192 -0
- package/vault/vendor/passmanager/vault/sealed-local.js +199 -0
- package/vault/vendor/passmanager/vault/sealed-responder.js +159 -0
- package/vault/vendor/passmanager/vault/sealed.js +362 -0
- package/vault/vendor/passmanager/webauthn.js +221 -0
- package/vault/vendor/proxy-client/VERSION.txt +1 -1
- package/vault/vendor/proxy-client/client.js +36 -0
- package/vault/vendor/roadmap/VERSION.txt +3 -0
- package/vault/vendor/roadmap/index.js +108 -0
- package/vault/vendor/roadmap/manifests.js +797 -0
- package/vault/vendor/store/VERSION.txt +3 -0
- package/vault/vendor/store/core.js +309 -0
- package/vault/vendor/vault/VERSION.txt +4 -1
- package/vault/vendor/vault/admin.js +437 -0
- package/vault/vendor/vault/browser.js +167 -0
- package/vault/vendor/vault/core/approvals.js +204 -0
- package/vault/vendor/vault/core/blocked.js +68 -0
- package/vault/vendor/vault/core/browserDisk.js +109 -0
- package/vault/vendor/vault/core/commKey.js +68 -0
- package/vault/vendor/vault/core/disk.js +65 -0
- package/vault/vendor/vault/core/libVersion.js +8 -0
- package/vault/vendor/vault/core/machines.js +66 -0
- package/vault/vendor/vault/core/notices.js +71 -0
- package/vault/vendor/vault/core/notifiable.js +73 -0
- package/vault/vendor/vault/core/passwords.js +125 -0
- package/vault/vendor/vault/core/sealKey.js +77 -0
- package/vault/vendor/vault/core/sealer.js +172 -0
- package/vault/vendor/vault/core/sealers.js +159 -0
- package/vault/vendor/vault/core/secretsStore.js +1326 -0
- package/vault/vendor/vault/core/sha256.js +69 -0
- package/vault/vendor/vault/core/store.js +68 -0
- package/vault/vendor/vault/core/subacta.js +101 -0
- package/vault/vendor/vault/core/threadStore.js +273 -0
- package/vault/vendor/vault/core/transport.js +270 -0
- package/vault/vendor/vault/core/vault.js +4414 -0
- package/vault/vendor/vault/enroll.js +8 -4
- package/vault/vendor/vault/procContext.js +97 -0
- package/vault/vendor/vault/revocation.js +67 -0
- package/vault/vendor/vault/sealed.js +84 -0
|
@@ -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.
|
|
@@ -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 }
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* revocation.js — CUÁNDO se le dice a un aparato que ya no es de casa.
|
|
3
|
+
*
|
|
4
|
+
* Módulo PURO (sin red, sin disco, sin `node:*`), como `enroll.js` y `admin.js`: la regla
|
|
5
|
+
* vive en un solo sitio, se lee entera y se prueba sin levantar nada.
|
|
6
|
+
*
|
|
7
|
+
* EL PROBLEMA QUE RESUELVE. Un dispositivo al que quitaron del perfil no se entera por su
|
|
8
|
+
* cuenta: lo único que le borra la cuenta es un aviso FIRMADO por la maestra
|
|
9
|
+
* (`vault.revoked`). Un «unauthorized» suelto no vale y no debe valer — no va firmado, así
|
|
10
|
+
* que cualquiera podría destruir datos ajenos con un mensaje (el wipe-DoS de
|
|
11
|
+
* `docs/pairing-protocol.md §2.3`). Por eso la bóveda tiene que **atender** la conexión de
|
|
12
|
+
* un aparato que fue suyo, aunque su papel ya no sirva, precisamente para poder mandarlo a
|
|
13
|
+
* paseo. Si no lo hace, el aparato se queda enseñando una cuenta que ya no existe.
|
|
14
|
+
*
|
|
15
|
+
* LA REGLA, en una línea: papel nuestro + ya no está en el acta ⇒ se le avisa.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Motivos de rechazo de `verifyChain` que significan «este papel ya no sirve».
|
|
20
|
+
*
|
|
21
|
+
* Los tres se producen DESPUÉS de que `verifyChain` haya comprobado la firma del propio
|
|
22
|
+
* aparato y la del certificado, así que quien llega hasta aquí demostró tener la llave que
|
|
23
|
+
* el certificado nombra. El resto de motivos (`shape`, `bad-signature`,
|
|
24
|
+
* `bad-action-signature`, `cert-device-mismatch`, `untrusted-issuer`…) son ruido o gente
|
|
25
|
+
* ajena: ahí no hay a quién avisar de nada.
|
|
26
|
+
*/
|
|
27
|
+
export const STALE_PAPER = Object.freeze(['revoked', 'expired', 'scope'])
|
|
28
|
+
const STALE = new Set(STALE_PAPER)
|
|
29
|
+
|
|
30
|
+
/** ¿El motivo del rechazo es «tu papel ya no sirve»? */
|
|
31
|
+
export const isStalePaper = (reason) => STALE.has(reason)
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* ¿Hay que mandarle el aviso firmado de expulsión?
|
|
35
|
+
*
|
|
36
|
+
* @param {Object} o
|
|
37
|
+
* @param {string} o.reason por qué falló `verifyChain`.
|
|
38
|
+
* @param {string} o.pubkey la llave del aparato que acaba de escribir.
|
|
39
|
+
* @param {string} o.master la maestra de ESTA bóveda.
|
|
40
|
+
* @param {string|null} [o.certIss] quién firmó el certificado que presentó.
|
|
41
|
+
* @param {Array<{pub:string}>|null} [o.members] miembros del acta; `null` = no hay acta.
|
|
42
|
+
* @param {boolean} [o.knownRevoked] consta explícitamente como revocado en las
|
|
43
|
+
* delegaciones. Es el único criterio que queda cuando no hay acta.
|
|
44
|
+
* @returns {boolean}
|
|
45
|
+
*/
|
|
46
|
+
export function shouldNotifyRevoked ({ reason, pubkey, master, certIss = null, members = null, knownRevoked = false } = {}) {
|
|
47
|
+
if (typeof pubkey !== 'string' || !pubkey) return false
|
|
48
|
+
if (!isStalePaper(reason)) return false
|
|
49
|
+
// Que el papel sea NUESTRO. No hace falta que siga siendo válido —justo por eso estamos
|
|
50
|
+
// aquí— pero sí que lo haya firmado esta maestra: es lo que separa a un aparato que fue
|
|
51
|
+
// de casa de uno que pasaba por ahí.
|
|
52
|
+
if (certIss && master && certIss !== master) return false
|
|
53
|
+
// El aparato no puede echarse a sí mismo, y la maestra tampoco se echa.
|
|
54
|
+
if (master && pubkey === master) return false
|
|
55
|
+
|
|
56
|
+
// EL ACTA MANDA, y es lo único que manda. Un certificado retirado o vencido NO significa
|
|
57
|
+
// «estás fuera»: renovar retira el anterior y cambiar permisos obliga a renovar, así que
|
|
58
|
+
// un aparato de casa con un papel viejo solo tiene que renovar. Decirle «estás fuera»
|
|
59
|
+
// ahí lo borraba solo: dabas «administra» y el dispositivo desaparecía.
|
|
60
|
+
if (Array.isArray(members)) return !members.some((m) => m?.pub === pubkey)
|
|
61
|
+
|
|
62
|
+
// Sin acta (bóveda anterior al acta) no se puede saber quién es del perfil: solo se
|
|
63
|
+
// avisa a quien conste explícitamente como revocado.
|
|
64
|
+
return !!knownRevoked
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export default { STALE_PAPER, isStalePaper, shouldNotifyRevoked }
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sobres SELLADOS para respuestas del vault con contenido sensible (secretos).
|
|
3
|
+
*
|
|
4
|
+
* El proxy transporta pero NUNCA debe ver el contenido. Como la llave de
|
|
5
|
+
* dispositivo `D` es ECDSA (solo firma), el que PIDE genera una llave ECDH
|
|
6
|
+
* EFÍMERA por petición (`ek`) y la manda dentro del sobre firmado por `D`
|
|
7
|
+
* (→ ek queda autenticada por la cadena D←maestra). El vault sella la
|
|
8
|
+
* respuesta a esa ek: ECDH efímero propio + AES-256-GCM. Reproducir una
|
|
9
|
+
* respuesta vieja es inerte: cada petición usa una ek nueva y el replay no
|
|
10
|
+
* se puede descifrar.
|
|
11
|
+
*
|
|
12
|
+
* La AUTENTICIDAD de la respuesta no la da este módulo sino la firma de la
|
|
13
|
+
* maestra sobre el cuerpo (el dispositivo la verifica contra la `iss` pineada).
|
|
14
|
+
*
|
|
15
|
+
* WebCrypto puro (browser y Node ≥20). Cripto alineada al ecosistema:
|
|
16
|
+
* P-256 + AES-GCM, llaves como JWK string.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const subtle = globalThis.crypto.subtle
|
|
20
|
+
|
|
21
|
+
function b64 (buf) {
|
|
22
|
+
const bytes = new Uint8Array(buf)
|
|
23
|
+
let s = ''
|
|
24
|
+
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i])
|
|
25
|
+
return btoa(s)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function fromB64 (str) {
|
|
29
|
+
const bin = atob(str)
|
|
30
|
+
const out = new Uint8Array(bin.length)
|
|
31
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
|
|
32
|
+
return out
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function deriveAesKey (privateKey, publicJwkStr) {
|
|
36
|
+
const pub = await subtle.importKey(
|
|
37
|
+
'jwk', JSON.parse(publicJwkStr),
|
|
38
|
+
{ name: 'ECDH', namedCurve: 'P-256' }, false, []
|
|
39
|
+
)
|
|
40
|
+
const bits = await subtle.deriveBits({ name: 'ECDH', public: pub }, privateKey, 256)
|
|
41
|
+
return subtle.importKey('raw', bits, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt'])
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Genera la llave ECDH EFÍMERA del solicitante (una por petición).
|
|
46
|
+
* `ek` (JWK string pública) viaja en el sobre firmado; `privateKey` se queda
|
|
47
|
+
* en memoria para abrir la respuesta.
|
|
48
|
+
*/
|
|
49
|
+
export async function makeEphemeralKey () {
|
|
50
|
+
const kp = await subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, false, ['deriveBits'])
|
|
51
|
+
const jwk = await subtle.exportKey('jwk', kp.publicKey)
|
|
52
|
+
return { ek: JSON.stringify({ kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y }), privateKey: kp.privateKey }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Sella `payload` (cualquier JSON) hacia la ek del solicitante.
|
|
57
|
+
* @returns {Promise<{epk:string, iv:string, ct:string}>}
|
|
58
|
+
*/
|
|
59
|
+
export async function seal ({ ek, payload }) {
|
|
60
|
+
const eph = await subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, false, ['deriveBits'])
|
|
61
|
+
const key = await deriveAesKey(eph.privateKey, ek)
|
|
62
|
+
const iv = globalThis.crypto.getRandomValues(new Uint8Array(12))
|
|
63
|
+
const pt = new TextEncoder().encode(JSON.stringify(payload))
|
|
64
|
+
const ct = await subtle.encrypt({ name: 'AES-GCM', iv }, key, pt)
|
|
65
|
+
const epkJwk = await subtle.exportKey('jwk', eph.publicKey)
|
|
66
|
+
return {
|
|
67
|
+
epk: JSON.stringify({ kty: epkJwk.kty, crv: epkJwk.crv, x: epkJwk.x, y: epkJwk.y }),
|
|
68
|
+
iv: b64(iv),
|
|
69
|
+
ct: b64(ct)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Abre un sobre sellado con la privada efímera de `makeEphemeralKey()`.
|
|
75
|
+
* Lanza si el sobre no es para esa llave (AES-GCM autentica el contenido).
|
|
76
|
+
*/
|
|
77
|
+
export async function openSealed ({ privateKey, enc }) {
|
|
78
|
+
if (!enc || typeof enc.epk !== 'string' || typeof enc.iv !== 'string' || typeof enc.ct !== 'string') {
|
|
79
|
+
throw new Error('invalid sealed envelope')
|
|
80
|
+
}
|
|
81
|
+
const key = await deriveAesKey(privateKey, enc.epk)
|
|
82
|
+
const pt = await subtle.decrypt({ name: 'AES-GCM', iv: fromB64(enc.iv) }, key, fromB64(enc.ct))
|
|
83
|
+
return JSON.parse(new TextDecoder().decode(pt))
|
|
84
|
+
}
|