@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.
Files changed (78) hide show
  1. package/package.json +4 -1
  2. package/src/node.js +4 -180
  3. package/vault/core.js +66 -7
  4. package/vault/facade.js +200 -0
  5. package/vault/index.html +10 -0
  6. package/vault/remote.js +45 -5
  7. package/vault/vault.js +29 -58
  8. package/vault/vendor/compat/VERSION.txt +3 -0
  9. package/vault/vendor/compat/advisory.js +141 -0
  10. package/vault/vendor/compat/index.js +188 -0
  11. package/vault/vendor/compat/ranges.js +93 -0
  12. package/vault/vendor/opaque/VERSION.txt +1 -1
  13. package/vault/vendor/passmanager/VERSION.txt +3 -0
  14. package/vault/vendor/passmanager/crypto.js +167 -0
  15. package/vault/vendor/passmanager/fields.js +183 -0
  16. package/vault/vendor/passmanager/generate.js +74 -0
  17. package/vault/vendor/passmanager/import.js +156 -0
  18. package/vault/vendor/passmanager/index.js +27 -0
  19. package/vault/vendor/passmanager/match.js +137 -0
  20. package/vault/vendor/passmanager/model.js +332 -0
  21. package/vault/vendor/passmanager/pubkey.js +33 -0
  22. package/vault/vendor/passmanager/sealed/convert.js +72 -0
  23. package/vault/vendor/passmanager/sealed/device.js +211 -0
  24. package/vault/vendor/passmanager/sealed/entry.js +209 -0
  25. package/vault/vendor/passmanager/sealed/index.js +20 -0
  26. package/vault/vendor/passmanager/sealed/keys.js +120 -0
  27. package/vault/vendor/passmanager/sealed/recovery.js +136 -0
  28. package/vault/vendor/passmanager/sealed/store.js +508 -0
  29. package/vault/vendor/passmanager/session-cache.js +60 -0
  30. package/vault/vendor/passmanager/totp.js +111 -0
  31. package/vault/vendor/passmanager/transport/protocol.js +83 -0
  32. package/vault/vendor/passmanager/transport/proxy.js +112 -0
  33. package/vault/vendor/passmanager/transport/sealed.js +74 -0
  34. package/vault/vendor/passmanager/vault/approval.js +63 -0
  35. package/vault/vendor/passmanager/vault/errors.js +34 -0
  36. package/vault/vendor/passmanager/vault/guard.js +114 -0
  37. package/vault/vendor/passmanager/vault/interface.js +34 -0
  38. package/vault/vendor/passmanager/vault/local.js +213 -0
  39. package/vault/vendor/passmanager/vault/remote.js +85 -0
  40. package/vault/vendor/passmanager/vault/responder.js +192 -0
  41. package/vault/vendor/passmanager/vault/sealed-local.js +199 -0
  42. package/vault/vendor/passmanager/vault/sealed-responder.js +159 -0
  43. package/vault/vendor/passmanager/vault/sealed.js +362 -0
  44. package/vault/vendor/passmanager/webauthn.js +221 -0
  45. package/vault/vendor/proxy-client/VERSION.txt +1 -1
  46. package/vault/vendor/proxy-client/client.js +36 -0
  47. package/vault/vendor/roadmap/VERSION.txt +3 -0
  48. package/vault/vendor/roadmap/index.js +108 -0
  49. package/vault/vendor/roadmap/manifests.js +797 -0
  50. package/vault/vendor/store/VERSION.txt +3 -0
  51. package/vault/vendor/store/core.js +309 -0
  52. package/vault/vendor/vault/VERSION.txt +4 -1
  53. package/vault/vendor/vault/admin.js +437 -0
  54. package/vault/vendor/vault/browser.js +167 -0
  55. package/vault/vendor/vault/core/approvals.js +204 -0
  56. package/vault/vendor/vault/core/blocked.js +68 -0
  57. package/vault/vendor/vault/core/browserDisk.js +109 -0
  58. package/vault/vendor/vault/core/commKey.js +68 -0
  59. package/vault/vendor/vault/core/disk.js +65 -0
  60. package/vault/vendor/vault/core/libVersion.js +8 -0
  61. package/vault/vendor/vault/core/machines.js +66 -0
  62. package/vault/vendor/vault/core/notices.js +71 -0
  63. package/vault/vendor/vault/core/notifiable.js +73 -0
  64. package/vault/vendor/vault/core/passwords.js +125 -0
  65. package/vault/vendor/vault/core/sealKey.js +77 -0
  66. package/vault/vendor/vault/core/sealer.js +172 -0
  67. package/vault/vendor/vault/core/sealers.js +159 -0
  68. package/vault/vendor/vault/core/secretsStore.js +1326 -0
  69. package/vault/vendor/vault/core/sha256.js +69 -0
  70. package/vault/vendor/vault/core/store.js +68 -0
  71. package/vault/vendor/vault/core/subacta.js +101 -0
  72. package/vault/vendor/vault/core/threadStore.js +273 -0
  73. package/vault/vendor/vault/core/transport.js +270 -0
  74. package/vault/vendor/vault/core/vault.js +4414 -0
  75. package/vault/vendor/vault/enroll.js +8 -4
  76. package/vault/vendor/vault/procContext.js +97 -0
  77. package/vault/vendor/vault/revocation.js +67 -0
  78. package/vault/vendor/vault/sealed.js +84 -0
@@ -0,0 +1,60 @@
1
+ // Recuerdo de lo YA entregado, durante esta sesión del navegador.
2
+ //
3
+ // No es la caché que se descartó (DISENO §3.1): aquella era una copia de la bóveda
4
+ // entera, y para abrirla hacía falta la llave. Esto guarda solo las credenciales que
5
+ // la bóveda ya entregó, en memoria de sesión, y muere al cerrar el navegador.
6
+ //
7
+ // Para qué: entrar tres veces al mismo sitio en una tarde no debería ser tres
8
+ // aprobaciones en el teléfono. Lo que nunca pediste, nunca estuvo aquí.
9
+
10
+ const DEFAULT_TTL = 5 * 60 * 1000
11
+
12
+ export class SessionCache {
13
+ /**
14
+ * @param {object} store almacén de SESIÓN `{ get(k), set(k, v) }` — en la
15
+ * extensión, `chrome.storage.session`: memoria, nunca disco.
16
+ * @param {object} opts `{ ttlMs, now }`
17
+ */
18
+ constructor (store, { ttlMs = DEFAULT_TTL, now = () => Date.now() } = {}) {
19
+ this.store = store
20
+ this.ttlMs = ttlMs
21
+ this.now = now
22
+ this.key = 'passmanager/session/v1'
23
+ }
24
+
25
+ async #all () {
26
+ return (await this.store.get(this.key)) || {}
27
+ }
28
+
29
+ /** Devuelve la entrada si sigue vigente. Una caducada se tira al pasar por ella. */
30
+ async get (id) {
31
+ const all = await this.#all()
32
+ const hit = all[id]
33
+ if (!hit) return null
34
+ if (this.now() - hit.ts > this.ttlMs) {
35
+ delete all[id]
36
+ await this.store.set(this.key, all)
37
+ return null
38
+ }
39
+ return hit.entry
40
+ }
41
+
42
+ /** Guarda lo entregado. */
43
+ async put (id, entry) {
44
+ if (!entry) return false
45
+ const all = await this.#all()
46
+ all[id] = { entry, ts: this.now() }
47
+ await this.store.set(this.key, all)
48
+ return true
49
+ }
50
+
51
+ async forget (id) {
52
+ const all = await this.#all()
53
+ if (id) delete all[id]
54
+ await this.store.set(this.key, id ? all : {})
55
+ }
56
+
57
+ async size () {
58
+ return Object.keys(await this.#all()).length
59
+ }
60
+ }
@@ -0,0 +1,111 @@
1
+ // Códigos de dos pasos (TOTP, RFC 6238 / HOTP, RFC 4226).
2
+ //
3
+ // Se guardan como URI `otpauth://`, que es lo que exportan e importan todos los
4
+ // demás y lo que lleva un QR. El secreto viaja cifrado con la CEK como cualquier
5
+ // otro campo (ver crypto.js); aquí solo se calcula el código.
6
+
7
+ const B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
8
+
9
+ export function base32Decode (input) {
10
+ const s = String(input || '').toUpperCase().replace(/=+$/, '').replace(/\s+/g, '')
11
+ let bits = 0
12
+ let value = 0
13
+ const out = []
14
+ for (const ch of s) {
15
+ const idx = B32.indexOf(ch)
16
+ if (idx === -1) throw new Error('base32: carácter inválido')
17
+ value = (value << 5) | idx
18
+ bits += 5
19
+ if (bits >= 8) {
20
+ bits -= 8
21
+ out.push((value >>> bits) & 0xff)
22
+ }
23
+ }
24
+ return new Uint8Array(out)
25
+ }
26
+
27
+ /**
28
+ * Bitwarden (y algún otro) exporta el TOTP como el secreto base32 pelado, sin URI.
29
+ * Se acepta y se envuelve, para que el resto del código vea siempre un `otpauth://`.
30
+ */
31
+ export function normalizeOtpauth (input, label = 'dotrino') {
32
+ const s = String(input || '').trim()
33
+ if (!s) return ''
34
+ if (s.toLowerCase().startsWith('otpauth://')) return s
35
+ return `otpauth://totp/${encodeURIComponent(label)}?secret=${encodeURIComponent(s.replace(/\s+/g, ''))}`
36
+ }
37
+
38
+ /** Parsea `otpauth://totp/Emisor:cuenta?secret=...&digits=6&period=30&algorithm=SHA1`. */
39
+ export function parseOtpauth (uri) {
40
+ const u = new URL(normalizeOtpauth(uri))
41
+ if (u.protocol !== 'otpauth:') throw new Error('otpauth: esquema inválido')
42
+ const type = u.hostname.toLowerCase()
43
+ if (type !== 'totp' && type !== 'hotp') throw new Error('otpauth: tipo no soportado')
44
+ const q = u.searchParams
45
+ const secret = q.get('secret')
46
+ if (!secret) throw new Error('otpauth: falta secret')
47
+
48
+ const label = decodeURIComponent(u.pathname.replace(/^\//, ''))
49
+ const [maybeIssuer, account] = label.includes(':') ? label.split(/:(.+)/) : [null, label]
50
+
51
+ return {
52
+ type,
53
+ secret,
54
+ issuer: q.get('issuer') || maybeIssuer || '',
55
+ account: (account || '').trim(),
56
+ digits: clampDigits(q.get('digits')),
57
+ period: clampPeriod(q.get('period')),
58
+ algorithm: (q.get('algorithm') || 'SHA1').toUpperCase(),
59
+ counter: Number(q.get('counter') || 0),
60
+ }
61
+ }
62
+
63
+ function clampDigits (v) {
64
+ const n = Number(v || 6)
65
+ return Number.isInteger(n) && n >= 6 && n <= 10 ? n : 6
66
+ }
67
+
68
+ function clampPeriod (v) {
69
+ const n = Number(v || 30)
70
+ return Number.isInteger(n) && n > 0 && n <= 300 ? n : 30
71
+ }
72
+
73
+ const HASHES = { SHA1: 'SHA-1', SHA256: 'SHA-256', SHA512: 'SHA-512' }
74
+
75
+ async function hotp (secretBytes, counter, digits, algorithm) {
76
+ const hash = HASHES[algorithm]
77
+ if (!hash) throw new Error('otp: algoritmo no soportado: ' + algorithm)
78
+
79
+ const buf = new ArrayBuffer(8)
80
+ const view = new DataView(buf)
81
+ view.setBigUint64(0, BigInt(counter), false)
82
+
83
+ const key = await globalThis.crypto.subtle.importKey(
84
+ 'raw', secretBytes, { name: 'HMAC', hash }, false, ['sign'])
85
+ const mac = new Uint8Array(await globalThis.crypto.subtle.sign('HMAC', key, buf))
86
+
87
+ const offset = mac[mac.length - 1] & 0x0f
88
+ const code = ((mac[offset] & 0x7f) << 24) | (mac[offset + 1] << 16) |
89
+ (mac[offset + 2] << 8) | mac[offset + 3]
90
+ return String(code % 10 ** digits).padStart(digits, '0')
91
+ }
92
+
93
+ /**
94
+ * Código actual y cuántos segundos le quedan.
95
+ * `now` en milisegundos, para poder probarlo con vectores fijos.
96
+ */
97
+ export async function totpNow (uri, now = Date.now()) {
98
+ const cfg = parseOtpauth(uri)
99
+ const secret = base32Decode(cfg.secret)
100
+ const seconds = Math.floor(now / 1000)
101
+
102
+ if (cfg.type === 'hotp') {
103
+ return { code: await hotp(secret, cfg.counter, cfg.digits, cfg.algorithm), expiresIn: null, cfg }
104
+ }
105
+ const counter = Math.floor(seconds / cfg.period)
106
+ return {
107
+ code: await hotp(secret, counter, cfg.digits, cfg.algorithm),
108
+ expiresIn: cfg.period - (seconds % cfg.period),
109
+ cfg,
110
+ }
111
+ }
@@ -0,0 +1,83 @@
1
+ // El protocolo entre quien pide una credencial y quien la custodia.
2
+ //
3
+ // Viaja por @dotrino/proxy-client (`sendByPubkey`), que es el transporte del
4
+ // ecosistema — no se abre un socket propio ni se reimplementa nada de esto.
5
+ //
6
+ // El contenido lo sella el TRANSPORTE (`@dotrino/proxy-client` ≥ 0.13.0), no esto:
7
+ // aquí solo se define qué se pide y cómo se contesta. Ojo con la suposición fácil —
8
+ // el proxio enruta pero NO cifra, y darlo por hecho fue el agujero del §2.2.
9
+
10
+ export const TYPE = 'dotrino.passmanager/1'
11
+ export const REPLY = 'dotrino.passmanager.reply/1'
12
+
13
+ /**
14
+ * Lo que un aparato puede pedir. `list` NO está, y esa ausencia es el diseño: si un
15
+ * aparato pudiera pedir la bóveda entera, el «de a una» no significaría nada.
16
+ *
17
+ * Y no lo puede una consola tampoco. Fue la tentación al diseñar `pass.dotrino.com`:
18
+ * darle `list` «porque es la consola» habría abierto el mismo agujero con otro nombre
19
+ * — bastaría con llamarse consola. Listar es de quien tiene la llave.
20
+ */
21
+ export const REMOTE_OPS = ['find', 'get', 'put', 'patch', 'search', 'remove', 'sites']
22
+
23
+ /**
24
+ * LAS OPERACIONES DEL FORMATO SELLADO (`docs/sealed-passwords.md`).
25
+ *
26
+ * Son OTROS NOMBRES, no los mismos con otro payload, y eso es a propósito: un aparato
27
+ * viejo que hable con una bóveda nueva recibe «operación no permitida» —un error que se
28
+ * ve— en vez de mandar un `find` con una URL a algo que espera huellas y quedarse mirando
29
+ * una respuesta vacía. Las incompatibilidades tienen que doler al instante
30
+ * (`CONVENCIONES-APPS.md` §14).
31
+ *
32
+ * `pm2.recipients` a quién hay que envolverle cada llave (sale del acta) + la pública
33
+ * de recuperación. Se pide ANTES de guardar nada.
34
+ * `pm2.profile` la llave del perfil sellada, de la que salen el índice y los resúmenes
35
+ * `pm2.views` las vistas selladas que este aparato puede abrir (sustituye a `list`,
36
+ * `search` y `sites`: la bóveda ya no puede buscar por texto)
37
+ * `pm2.find` las vistas cuyo índice casa con estas huellas de sitio
38
+ * `pm2.passkey` lo mismo para una passkey (por sitio o por credencial)
39
+ * `pm2.get` los sobres de los campos pedidos, con la envoltura de quien pregunta
40
+ * `pm2.put` guardar una entrada entera, ya cerrada y firmada
41
+ * `pm2.patch` cambiar unos campos sin leer los demás
42
+ * `pm2.remove` quitarla
43
+ */
44
+ export const SEALED_OPS = [
45
+ 'pm2.recipients', 'pm2.profile', 'pm2.views', 'pm2.find', 'pm2.passkey',
46
+ 'pm2.get', 'pm2.put', 'pm2.patch', 'pm2.remove'
47
+ ]
48
+
49
+ // `sites` entró el 2026-08-29, decidido por el dueño para que el gestor abra con los
50
+ // dominios en vez de con un buscador vacío. NO es `list` con otro nombre —de ahí no sale
51
+ // ni un id ni un nombre, solo el dominio, que ya va en claro (§5)—, pero sí contesta de
52
+ // una vez lo que antes había que preguntar sitio por sitio. Queda dicho a propósito.
53
+
54
+ // `search` estaba implementado en las dos puntas y NO en esta lista, así que por el
55
+ // proxio se contestaba «operación no permitida»: traerse la cuenta de otro dominio
56
+ // funcionaba con la bóveda de dentro y no con una conectada. Corregido el 2026-08-29.
57
+
58
+ /**
59
+ * Administración de APARATOS: qué aparatos hay y retirar uno. No toca credenciales.
60
+ * Va aparte porque exige aprobación siempre — quitar un aparato es tan delicado como
61
+ * entregar una contraseña, y en el otro sentido.
62
+ */
63
+ export const ADMIN_OPS = ['devices', 'unlink']
64
+
65
+ export function isRequest (msg) {
66
+ return !!msg && msg.type === TYPE && typeof msg.rid === 'string' && typeof msg.op === 'string'
67
+ }
68
+
69
+ export function isReply (msg) {
70
+ return !!msg && msg.type === REPLY && typeof msg.rid === 'string'
71
+ }
72
+
73
+ export function request (rid, op, payload) {
74
+ return { type: TYPE, rid, op, payload: payload || {} }
75
+ }
76
+
77
+ export function reply (rid, result) {
78
+ return { type: REPLY, rid, result }
79
+ }
80
+
81
+ export function replyError (rid, code, message) {
82
+ return { type: REPLY, rid, error: { code, message } }
83
+ }
@@ -0,0 +1,112 @@
1
+ // Transporte del lado que PIDE (la extensión, la app).
2
+ //
3
+ // Cumple `{ request(op, payload) }`, que es lo único que `RemoteVault` necesita. El
4
+ // cliente del proxio se recibe ya construido y conectado: así esta pieza se prueba
5
+ // con un cliente falso y no arrastra la librería entera a los tests.
6
+
7
+ import { TYPE, REPLY, request as mkRequest, isReply } from './protocol.js'
8
+ import { VaultError, CODES } from '../vault/errors.js'
9
+
10
+ let counter = 0
11
+
12
+ function newRid () {
13
+ const r = globalThis.crypto?.randomUUID
14
+ ? globalThis.crypto.randomUUID()
15
+ : String(++counter) + '-' + Math.floor(performance.now())
16
+ return r
17
+ }
18
+
19
+ export class ProxyTransport {
20
+ /**
21
+ * @param {object} opts
22
+ * `client` cliente de @dotrino/proxy-client ya conectado e identificado
23
+ * `peerPubkey` pubkey de FIRMA de la bóveda: por ahí la enruta el proxio
24
+ * `peerEncPub` pubkey de CIFRADO de la bóveda: a ella se sella el contenido
25
+ * `myEncPrivateKey` mi privada de cifrado, para abrir lo que me devuelvan
26
+ * `timeoutMs` cuánto se espera. Generoso a propósito: al otro lado puede
27
+ * haber alguien poniendo el dedo en el teléfono.
28
+ */
29
+ constructor ({ client, peerPubkey, peerEncPub, myEncPrivateKey, timeoutMs = 90_000 }) {
30
+ this.client = client
31
+ this.peerPubkey = peerPubkey
32
+ this.peerEncPub = peerEncPub
33
+ this.myEncPrivateKey = myEncPrivateKey
34
+ this.timeoutMs = timeoutMs
35
+ this.pending = new Map()
36
+
37
+ this.client.on('message', (_from, payload, meta) => {
38
+ this.#onMessage(payload, meta).catch(() => {})
39
+ })
40
+ }
41
+
42
+ async #onMessage (payload, meta) {
43
+ // **Ninguna respuesta en claro se acepta.** El cliente del pilar es quien abre los
44
+ // sobres (`requireSealed` + `myEncPrivateKey`) y marca `meta.sealed`; aquí solo se
45
+ // exige esa marca. Si se aceptara texto plano, cualquiera podría contestar por la
46
+ // bóveda: no podría leer lo que se pidió, pero sí colar una credencial falsa en un
47
+ // formulario.
48
+ if (!isReply(payload)) return
49
+ if (meta?.sealed !== true) {
50
+ this.#fail(payload.rid, CODES.UNSEALED, 'respuesta sin cifrar')
51
+ return
52
+ }
53
+
54
+ const p = this.pending.get(payload.rid)
55
+ if (!p) return // respuesta a algo que ya venció; se ignora
56
+ this.pending.delete(payload.rid)
57
+ clearTimeout(p.timer)
58
+ if (payload.error) {
59
+ p.reject(new VaultError(payload.error.code || 'error', payload.error.message))
60
+ } else {
61
+ p.resolve(payload.result)
62
+ }
63
+ }
64
+
65
+ #fail (rid, code, message) {
66
+ const p = this.pending.get(rid)
67
+ if (!p) return
68
+ this.pending.delete(rid)
69
+ clearTimeout(p.timer)
70
+ p.reject(new VaultError(code, message))
71
+ }
72
+
73
+ request (op, payload) {
74
+ const rid = newRid()
75
+ return new Promise((resolve, reject) => {
76
+ const timer = setTimeout(() => {
77
+ this.pending.delete(rid)
78
+ // No es "no hay nadie": es que nadie contestó. Suele ser el teléfono sin
79
+ // que nadie lo mire, y el código lo distingue para poder decirlo bien.
80
+ reject(new VaultError(CODES.APPROVAL_TIMEOUT, 'nadie respondió a tiempo'))
81
+ }, this.timeoutMs)
82
+
83
+ this.pending.set(rid, { resolve, reject, timer })
84
+
85
+ // Sale SELLADO, y de eso se encarga el pilar. Por pubkey y no por token: así
86
+ // entra la cola offline del proxio y la petición sobrevive a que la bóveda esté
87
+ // reconectando.
88
+ this.client
89
+ .sendSealed([this.peerPubkey], mkRequest(rid, op, payload), { peerEncPub: this.peerEncPub })
90
+ .catch(e => {
91
+ this.pending.delete(rid)
92
+ clearTimeout(timer)
93
+ // Se CONSERVA el código del error. Envolverlo todo en `unreachable` haría que
94
+ // «no tengo la llave del otro lado» y «se cayó la red» se vieran igual desde
95
+ // arriba, y son cosas distintas: una se arregla enlazando, la otra esperando.
96
+ if (e instanceof VaultError) return reject(e)
97
+ reject(new VaultError(e?.code || CODES.UNREACHABLE, e?.message || 'no se pudo enviar'))
98
+ })
99
+ })
100
+ }
101
+
102
+ /** Cancela lo que esté esperando. Se llama al cerrar el popup o al desenlazar. */
103
+ close (code = CODES.UNREACHABLE) {
104
+ for (const [rid, p] of this.pending) {
105
+ clearTimeout(p.timer)
106
+ p.reject(new VaultError(code, 'transporte cerrado'))
107
+ this.pending.delete(rid)
108
+ }
109
+ }
110
+ }
111
+
112
+ export { TYPE, REPLY }
@@ -0,0 +1,74 @@
1
+ // El sellado extremo a extremo vive en el PILAR del transporte
2
+ // (`@dotrino/proxy-client/sealing`, desde 0.13.0), no aquí.
3
+ //
4
+ // Se escribió primero en este repo y se movió en cuanto quedó claro que la garantía
5
+ // —que lo del usuario no llegue a los servidores de Dotrino, ni de paso— no puede ser
6
+ // de una sola app. Este módulo queda como el punto por el que pasa el gestor, para no
7
+ // repetir el import por todos lados.
8
+
9
+ export {
10
+ seal, open, isSealed,
11
+ makeEncKeypair, importEncPrivate, exportEncPrivate,
12
+ setSealingPrimitives,
13
+ } from '@dotrino/proxy-client/sealing'
14
+
15
+ /**
16
+ * EL SOBRE que se pasan la bóveda y quien pide, en UN solo sitio.
17
+ *
18
+ * Estaba escrito dos veces —en la extensión y en la bóveda-en-pestaña de
19
+ * `vault.dotrino.com/vault`—, y son las dos puntas del MISMO sobre: si una cambia de
20
+ * forma, la otra deja de abrirlo. No falla ruidosamente, que es lo peor: la petición sale,
21
+ * al otro lado «no es para mí», y desde fuera se ve como que nadie respondió.
22
+ *
23
+ * Lo que sí cambia entre las dos es el DIALECTO de `@dotrino/identity`, porque no es el
24
+ * mismo objeto: la clase `Identity` (la que habla con el iframe) expone
25
+ * `getEncryptionPubkey()` y un `decrypt(remitente, miToken, sobre)` que devuelve
26
+ * `{ plaintext }`; el núcleo que corre dentro del service worker de la extensión expone
27
+ * `encryptionPubkey()` y un `decrypt(remitente, sobre)` que devuelve la cadena. Los dos
28
+ * son correctos y los dos se quedan; lo que no puede haber es dos ideas del sobre.
29
+ *
30
+ * @param {object} identity cualquiera de los dos dialectos
31
+ * @param {object} [opts]
32
+ * @param {string} [opts.app='passmanager'] la marca del sobre: quien recibe lo que no es
33
+ * suyo lo descarta por aquí.
34
+ * @returns {{seal:Function, open:Function, isSealed:Function}} el `sealing` de
35
+ * `@dotrino/proxy-client` (≥ 0.13.0)
36
+ */
37
+ export function identitySealing (identity, { app = 'passmanager' } = {}) {
38
+ // El dialecto se decide UNA vez, por lo que el objeto expone, y no por el resultado de
39
+ // cada llamada: así, si mañana llega un tercero que no es ninguno de los dos, revienta
40
+ // aquí y con nombre, en vez de devolver sobres que nadie abre.
41
+ const iframe = typeof identity?.getEncryptionPubkey === 'function'
42
+ if (!iframe && typeof identity?.encryptionPubkey !== 'function') {
43
+ throw new Error('identitySealing: this identity exposes neither getEncryptionPubkey() nor encryptionPubkey()')
44
+ }
45
+
46
+ const myEncPub = () => (iframe ? identity.getEncryptionPubkey() : identity.encryptionPubkey())
47
+ const openEnvelope = async (from, envelope) => {
48
+ const r = iframe
49
+ ? await identity.decrypt(from, null, envelope)
50
+ : await identity.decrypt(from, envelope)
51
+ // Un dialecto devuelve `{ plaintext }` y el otro la cadena. Nada más se admite: si
52
+ // llega otra cosa, se para — un `?? ''` aquí sería un sobre vacío haciéndose pasar
53
+ // por un mensaje.
54
+ if (typeof r === 'string') return r
55
+ if (typeof r?.plaintext === 'string') return r.plaintext
56
+ throw new Error('identitySealing: decrypt returned neither a string nor { plaintext }')
57
+ }
58
+
59
+ return {
60
+ async seal (msg, peerEncPub) {
61
+ if (!peerEncPub) throw Object.assign(new Error('no encryption key for the other side'), { code: 'unsealed' })
62
+ return {
63
+ app,
64
+ // Destinatarios como OBJETOS: `encrypt` expande cada uno a todos los aparatos de
65
+ // esa persona, y una llave suelta se le cae sin envolver nada — el sobre salía
66
+ // vacío, sin error, y al otro lado era «no es para mí».
67
+ sealed: await identity.encrypt([{ encryptionPubkey: peerEncPub }], JSON.stringify(msg)),
68
+ from: await myEncPub(),
69
+ }
70
+ },
71
+ async open (env) { return JSON.parse(await openEnvelope(env.from, env.sealed)) },
72
+ isSealed: (m) => !!m && m.app === app && !!m.sealed,
73
+ }
74
+ }
@@ -0,0 +1,63 @@
1
+ // LA APROBACIÓN, en un solo sitio.
2
+ //
3
+ // La piden dos bóvedas que parecen distintas y no lo son: la que responde por el proxio
4
+ // (`VaultResponder` — el daemon, la pestaña de `vault.dotrino.com/vault`) y la que la
5
+ // extensión lleva dentro (`GuardedVault`). Tienen que comportarse igual; que la de
6
+ // dentro fuera la excepción y entregara sin preguntar es justo lo que el dueño mandó
7
+ // arreglar el 2026-08-29.
8
+ //
9
+ // Lo que la puerta garantiza, venga de donde venga la petición (DISENO §2.0):
10
+ //
11
+ // · una NEGATIVA no queda recordada como aprobación: la siguiente vuelve a preguntar
12
+ // · dos peticiones a la vez producen UN aviso, no dos
13
+ // · lo aprobado vive en memoria y muere con la bóveda: no se guarda en ninguna parte
14
+
15
+ export class ApprovalGate {
16
+ /**
17
+ * @param {object} opts
18
+ * `ask({ op, payload, ... })` → Promise<boolean> cómo se le pregunta al humano.
19
+ * Por defecto NO: una puerta sin quien conteste no deja pasar.
20
+ * `scope(req)` → string|null con qué llave se recuerda un sí, y con cuál se juntan
21
+ * dos peticiones simultáneas. El responder pasa el APARATO, porque ahí la
22
+ * aprobación es del aparato y no de cada credencial.
23
+ * `remember` si un sí vale para los siguientes. En `false` la puerta sigue
24
+ * juntando las simultáneas, pero cada petición nueva vuelve a preguntar.
25
+ */
26
+ constructor ({ ask, scope, remember = true } = {}) {
27
+ this.ask = ask || (async () => false)
28
+ this.scope = scope || (() => null)
29
+ this.remember = remember !== false
30
+ this._granted = new Set()
31
+ this._asking = new Map()
32
+ }
33
+
34
+ /** ¿Pasa? Pregunta si hace falta, y recuerda el sí si le toca recordarlo. */
35
+ async allow (req = {}) {
36
+ const key = this.scope(req)
37
+ if (this.remember && key != null && this._granted.has(key)) return true
38
+
39
+ // Dos pestañas abriendo el mismo sitio no deben hacer sonar el teléfono dos veces.
40
+ const junta = key == null ? `${req.op || ''}:${req.payload?.id || ''}` : String(key)
41
+ if (this._asking.has(junta)) return this._asking.get(junta)
42
+
43
+ const p = Promise.resolve(this.ask(req))
44
+ .then(ok => {
45
+ if (ok && this.remember && key != null) this._granted.add(key)
46
+ return !!ok
47
+ })
48
+ .finally(() => this._asking.delete(junta))
49
+
50
+ this._asking.set(junta, p)
51
+ return p
52
+ }
53
+
54
+ granted (key) {
55
+ return this._granted.has(key)
56
+ }
57
+
58
+ /** Retirar lo aprobado sin apagar la bóveda. Sin argumento, todo. */
59
+ revoke (key) {
60
+ if (key == null) this._granted.clear()
61
+ else this._granted.delete(key)
62
+ }
63
+ }
@@ -0,0 +1,34 @@
1
+ // Los errores cruzan procesos (extensión ↔ vault ↔ teléfono), así que se comprueban
2
+ // por `e.code`, NUNCA por el texto: traducir un mensaje que alguien empareja por
3
+ // regex lo rompe en silencio. El mensaje es para el humano; el código, para el código.
4
+
5
+ export class VaultError extends Error {
6
+ constructor (code, message) {
7
+ super(message || code)
8
+ this.name = 'VaultError'
9
+ this.code = code
10
+ }
11
+ }
12
+
13
+ export const CODES = {
14
+ LOCKED: 'locked', // la bóveda está cerrada
15
+ NO_KEY: 'no-key', // este aparato no tiene la CEK (extensión, por diseño)
16
+ READ_ONLY: 'read-only', // caché local: puede leer, no escribir
17
+ NOT_FOUND: 'not-found',
18
+ DENIED: 'denied', // la política dijo que no
19
+ NOT_APPROVED: 'not-approved', // el humano dijo que no. NO es lo mismo que `denied`:
20
+ // uno se arregla dando permiso al aparato, el otro
21
+ // volviendo a pedirlo y pulsando que sí
22
+ APPROVAL_TIMEOUT: 'approval-timeout', // nadie aprobó desde el teléfono
23
+ UNREACHABLE: 'unreachable', // no hay bóveda al otro lado
24
+ NO_LINK: 'no-link', // este perfil no está emparejado con ninguna bóveda
25
+ BAD_INVITE: 'bad-invite', // eso que pegaste no es una invitación de bóveda
26
+ UNSEALED: 'unsealed', // llegó (o iba a salir) sin cifrar: NO se acepta
27
+ UNDECIPHERABLE: 'undecipherable', // sellado, pero no para mí
28
+ // Estos tres se usaban desde el principio y NO estaban aquí: `CODES.NOT_ALLOWED` valía
29
+ // `undefined`, el error cruzaba sin código y quien pedía lo convertía en `unreachable`.
30
+ // O sea, «la bóveda te contestó que no» se leía en pantalla como «no hay bóveda».
31
+ NOT_ALLOWED: 'not-allowed', // esta punta no tiene llave para eso (no se la envolvieron)
32
+ NOT_SEALED: 'not-sealed', // la bóveda sigue en el formato viejo: hay que convertirla
33
+ UNKNOWN: 'unknown', // falló algo que no trae código propio
34
+ }
@@ -0,0 +1,114 @@
1
+ // Una bóveda con la puerta puesta: la misma de dentro, pero pidiendo autorización antes
2
+ // de soltar lo privado.
3
+ //
4
+ // Existe porque las tres bóvedas del ecosistema tienen que funcionar igual (dueño,
5
+ // 2026-08-29). Las dos de fuera —el daemon y la pestaña de `vault.dotrino.com/vault`—
6
+ // ya la tenían: se la pone `VaultResponder` al atender por el proxio. La de dentro de la
7
+ // extensión no pasaba por ahí, porque no hay transporte de por medio, y acababa siendo
8
+ // la única que entregaba una llave privada sin preguntar.
9
+ //
10
+ // **La política vive aquí, no en quien pide.** Un aparato que pide no decide nada, y en
11
+ // la extensión «quien pide» es el mismo proceso — razón de más para que la puerta esté
12
+ // delante de la bóveda y no repartida por cada pantalla que llama.
13
+
14
+ import { VaultError, CODES } from './errors.js'
15
+ import { privateKeysOf } from '../fields.js'
16
+ import { projectEntry } from '../model.js'
17
+
18
+ export class GuardedVault {
19
+ /**
20
+ * @param {object} inner la bóveda de verdad (normalmente un `LocalVault` abierto)
21
+ * @param {object} opts
22
+ * `gate` una `ApprovalGate`
23
+ */
24
+ constructor (inner, { gate } = {}) {
25
+ this.inner = inner
26
+ this.gate = gate
27
+ }
28
+
29
+ /**
30
+ * `needsApproval: true` no es cosmético: es lo que hace que el resto del gestor trate
31
+ * a esta bóveda como a una de fuera —esperar a que el usuario pida ver un valor en vez
32
+ * de abrirlo solo— sin una sola rama nueva.
33
+ */
34
+ get capabilities () {
35
+ return { ...(this.inner.capabilities || {}), needsApproval: true }
36
+ }
37
+
38
+ async find (url) {
39
+ return this.inner.find(url)
40
+ }
41
+
42
+ /**
43
+ * UNA credencial, y **solo lo que se pide**.
44
+ *
45
+ * La regla, en una frase del dueño (2026-08-29): *«se pide autorización únicamente para
46
+ * intentar llenar un dato privado»*. Así que:
47
+ *
48
+ * - se abre la entrada **aquí dentro**, que es la bóveda mirándose a sí misma;
49
+ * - se mira qué de lo pedido es privado —la contraseña, el código de dos pasos, las
50
+ * notas, la llave de una passkey, y los campos que el usuario marcó (§4.2)—;
51
+ * - si no hay nada privado entre lo pedido, **no se pregunta nada**: rellenar tu nombre
52
+ * en un formulario no es sacar un secreto de ningún sitio;
53
+ * - y lo que sale es **solo lo pedido**. Sin esto, pedir el nombre devolvía la entrada
54
+ * entera y la contraseña viajaba de propina.
55
+ *
56
+ * Sin `keys` se pide todo, así que se autoriza todo: es lo que hace falta para firmar
57
+ * una passkey o para copiar una contraseña desde el popup.
58
+ */
59
+ async get (id, opts = {}) {
60
+ const open = await this.inner.get(id)
61
+ const privadas = privateKeysOf(open)
62
+ const keys = Array.isArray(opts.keys) ? opts.keys : null
63
+ const pedidasPrivadas = keys ? keys.filter(k => privadas.has(k)) : [...privadas]
64
+
65
+ if (pedidasPrivadas.length) {
66
+ const ok = await this.gate.allow({ op: 'get', payload: { id, keys: pedidasPrivadas } })
67
+ if (!ok) throw new VaultError(CODES.NOT_APPROVED, 'no aprobado')
68
+ }
69
+ return projectEntry(open, keys)
70
+ }
71
+
72
+ /** Escribir no saca nada de la bóveda, así que no se pregunta. */
73
+ async put (entry) {
74
+ return this.inner.put(entry)
75
+ }
76
+
77
+ /**
78
+ * Cambiar unos campos tampoco: la fusión la hace la bóveda y no sale ni un valor. Es
79
+ * además lo que evita el fallo que tenía guardar —leer la entrada entera para escribir
80
+ * un campo, y perderla si esa lectura no salía—.
81
+ */
82
+ async patch (id, changes) {
83
+ return this.inner.patch(id, changes)
84
+ }
85
+
86
+ async search (q, opts) {
87
+ return this.inner.search(q, opts)
88
+ }
89
+
90
+ /** Los dominios ya viajan en claro: preguntarlos no saca nada que estuviera guardado. */
91
+ async sites () {
92
+ return this.inner.sites()
93
+ }
94
+
95
+ async remove (id) {
96
+ return this.inner.remove(id)
97
+ }
98
+
99
+ async list () {
100
+ return this.inner.list()
101
+ }
102
+
103
+ /**
104
+ * EL RESUMEN DE UN VALOR, que existe justamente para no tener que abrir nada: dice si lo
105
+ * que tienes escrito delante ya está guardado igual. Por eso no pasa por la puerta —no
106
+ * saca de la bóveda ningún valor, solo compara— y por eso hay que reenviarlo: sin esto,
107
+ * la bóveda sellada tenía el método y el gestor no lo veía, así que «¿ya está guardado
108
+ * igual?» se contestaba que no siempre y el aviso de guardar salía en cada formulario.
109
+ */
110
+ async digest (id, key, value) {
111
+ if (typeof this.inner.digest !== 'function') return null
112
+ return this.inner.digest(id, key, value)
113
+ }
114
+ }