@dotrino/identity 0.114.1 → 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 +7 -1
- package/src/index.js +9 -1
- package/src/node.js +4 -180
- package/vault/acta.js +5 -1
- package/vault/core.js +95 -15
- package/vault/facade.js +200 -0
- package/vault/index.html +10 -0
- package/vault/machine.js +95 -0
- package/vault/remote.js +54 -9
- 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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* El contrato que consumen la extensión, la consola web y la app nativa. Quién esté
|
|
3
|
+
* detrás (el vault del PC, el teléfono, o la caché local) es intercambiable — esa es
|
|
4
|
+
* la razón de que exista esta interfaz y no se lea el almacén directamente
|
|
5
|
+
* (DISENO §6.1).
|
|
6
|
+
*
|
|
7
|
+
* find(url) → [publicView] qué hay para este sitio. SIN secretos: sirve para
|
|
8
|
+
* elegir entre varias cuentas, no para usarlas.
|
|
9
|
+
* get(id, {keys}) → entrada abierta UNA credencial, y solo los campos pedidos.
|
|
10
|
+
* Pedir algo PRIVADO exige aprobación; lo público, no.
|
|
11
|
+
* put(entry) → publicView crear o actualizar. Exige una bóveda de verdad.
|
|
12
|
+
* patch(id, changes) → publicView cambiar unos campos SIN leer la entrada antes: la
|
|
13
|
+
* fusión la hace quien tiene el dato. No exige aprobación
|
|
14
|
+
* —no sale nada— y no puede perder lo que no se tocó.
|
|
15
|
+
* search(q) → [publicView] buscar por texto en TODA la bóveda, con tope. Exige un
|
|
16
|
+
* término que escribe una persona; no es `list`.
|
|
17
|
+
* remove(id) → void quitarla. La borra quien la tiene, no quien la pide.
|
|
18
|
+
* list() → [publicView] la bóveda entera. Exige la CEK.
|
|
19
|
+
*
|
|
20
|
+
* `list` no está para la extensión: si pudiera listarlo todo, el "pide de a una"
|
|
21
|
+
* (DISENO §2) sería decorativo.
|
|
22
|
+
*
|
|
23
|
+
* Toda implementación expone además:
|
|
24
|
+
* capabilities → { canWrite, canList, needsApproval }
|
|
25
|
+
*/
|
|
26
|
+
export const VaultSource = {
|
|
27
|
+
async find (_url) { throw new Error('not implemented') },
|
|
28
|
+
async get (_id, _opts) { throw new Error('not implemented') },
|
|
29
|
+
async put (_entry) { throw new Error('not implemented') },
|
|
30
|
+
async patch (_id, _changes) { throw new Error('not implemented') },
|
|
31
|
+
async search (_q, _opts) { throw new Error('not implemented') },
|
|
32
|
+
async remove (_id) { throw new Error('not implemented') },
|
|
33
|
+
async list () { throw new Error('not implemented') },
|
|
34
|
+
}
|