@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,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QUÉ APROBADORES PUEDEN RECIBIR UN AVISO: lo dice cada uno, y caduca.
|
|
3
|
+
*
|
|
4
|
+
* Un aparato con `approve` que no recibe avisos es un aprobador solo en el papel: el pedido
|
|
5
|
+
* no le suena y solo lo ve si abre la app por su cuenta.
|
|
6
|
+
*
|
|
7
|
+
* Así que el aprobador lo declara: al pedir su lista de pedidos (`op: 'approvals'`, que ya
|
|
8
|
+
* manda firmado cada vez que se abre) dice `notify: true` si tiene los avisos encendidos.
|
|
9
|
+
* Aquí se apunta CUÁNDO lo dijo por última vez, y vale `NOTIFIABLE_TTL_MS`: una app que se
|
|
10
|
+
* desinstala deja de renovarlo.
|
|
11
|
+
*
|
|
12
|
+
* NO DECIDE NADA: SE ENSEÑA (dueño, 2026-10-08). Un día decidió si la bóveda pedía permiso
|
|
13
|
+
* para actualizarse —sin nadie que lo hubiera dicho, se actualizaba sola— y se quitó: era
|
|
14
|
+
* una condición más, y el silencio de un aprobador abría la puerta. Quién aprueba lo dice
|
|
15
|
+
* el acta y nada más; los avisos salen a todos los que tienen `approve`, lo hayan dicho o
|
|
16
|
+
* no. Esto sirve para VER a cuál no le está sonando el pedido (`members`, `status`, la
|
|
17
|
+
* consola).
|
|
18
|
+
*
|
|
19
|
+
* Y NO SE LE PREGUNTA AL PROXIO, que es quien guarda las suscripciones: es la pieza de la
|
|
20
|
+
* que el diseño no se fía, y creerle sería dejarle decidir cuándo no hace falta permiso.
|
|
21
|
+
*
|
|
22
|
+
* Cifrado en reposo como lo demás. Un archivo que existe y no se puede leer NO es «nadie
|
|
23
|
+
* puede recibir avisos»: se lanza.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export const NOTIFIABLE_FILE = 'notifiable.json'
|
|
27
|
+
/** Cuánto vale un «puedo recibir avisos» sin renovarse. */
|
|
28
|
+
export const NOTIFIABLE_TTL_MS = 30 * 24 * 60 * 60 * 1000
|
|
29
|
+
|
|
30
|
+
const fail = (code, message) => Object.assign(new Error(message), { code })
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {{ disk: object, now?: () => number }} opts
|
|
34
|
+
*/
|
|
35
|
+
export function openNotifiable ({ disk, now = Date.now }) {
|
|
36
|
+
const file = NOTIFIABLE_FILE
|
|
37
|
+
const atRest = disk.atRest
|
|
38
|
+
/** pub → ms de la última vez que dijo que sí */
|
|
39
|
+
const items = load()
|
|
40
|
+
|
|
41
|
+
function load () {
|
|
42
|
+
let raw
|
|
43
|
+
try { raw = disk.read(file) } catch (e) {
|
|
44
|
+
throw fail('notifiable-unreadable', `cannot read ${NOTIFIABLE_FILE}: ${e.message}`)
|
|
45
|
+
}
|
|
46
|
+
if (raw == null) return new Map()
|
|
47
|
+
let d
|
|
48
|
+
try { d = JSON.parse(atRest.decrypt(raw)) } catch (e) { throw fail('notifiable-unreadable', `${NOTIFIABLE_FILE} cannot be opened: ${e.message}`) }
|
|
49
|
+
if (d?.v !== 1 || !d.items || typeof d.items !== 'object') throw fail('notifiable-unreadable', `${NOTIFIABLE_FILE} has an unknown shape`)
|
|
50
|
+
return new Map(Object.entries(d.items))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function save () {
|
|
54
|
+
disk.write(file, atRest.encrypt(JSON.stringify({ v: 1, items: Object.fromEntries(items) })))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
/**
|
|
59
|
+
* Lo que acaba de decir un aprobador. `true` lo apunta (o lo renueva); `false` lo quita
|
|
60
|
+
* ya, sin esperar a que caduque. Cualquier otra cosa no dice nada y no cambia nada: un
|
|
61
|
+
* cliente viejo, que no manda el campo, no borra lo que dijo uno nuevo.
|
|
62
|
+
*/
|
|
63
|
+
declare (pub, canNotify) {
|
|
64
|
+
if (typeof pub !== 'string' || !pub) throw fail('bad-pub', 'declare: a device key is required')
|
|
65
|
+
if (canNotify === true) { items.set(pub, now()); save() } else if (canNotify === false && items.delete(pub)) save()
|
|
66
|
+
},
|
|
67
|
+
/** Cuándo lo dijo por última vez, o `null` si nunca o si ya caducó. */
|
|
68
|
+
since (pub) {
|
|
69
|
+
const t = items.get(pub)
|
|
70
|
+
return typeof t === 'number' && now() - t < NOTIFIABLE_TTL_MS ? t : null
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LA BÓVEDA DE CONTRASEÑAS del dueño, atendida por el vault — Y QUE EL VAULT NO PUEDE LEER.
|
|
3
|
+
*
|
|
4
|
+
* Hasta 0.122 esto tenía una llave propia (`passwordsKey()`) guardada **dentro del mismo
|
|
5
|
+
* archivo que las entradas**, cifrada con la llave de la máquina. O sea: el demonio con el
|
|
6
|
+
* perfil CERRADO seguía descifrando contraseñas, y cualquiera con una copia del disco las
|
|
7
|
+
* leía todas. Era el mismo esquema que tuvo la maestra hasta que se selló.
|
|
8
|
+
*
|
|
9
|
+
* Ahora la bóveda es un cartero también para las contraseñas
|
|
10
|
+
* (`dotrino-passmanager/docs/sealed-passwords.md`): guarda sobres dirigidos a los aparatos
|
|
11
|
+
* y envolturas que no puede abrir. Lo que sí hace, y es lo que sostiene el modelo:
|
|
12
|
+
*
|
|
13
|
+
* · dice A QUIÉN hay que envolver cada llave —sale del acta, y **la bóveda nunca está en
|
|
14
|
+
* esa lista**: ni su maestra, ni su llave de comunicación;
|
|
15
|
+
* · comprueba que quien escribe sea un aparato del acta con `passwords`, por su firma;
|
|
16
|
+
* · filtra por destinatario al entregar, y decide si hace falta un dedo encima.
|
|
17
|
+
*
|
|
18
|
+
* Lo que SIGUE igual: el protocolo es de a una credencial, `list` no existe en remoto, y la
|
|
19
|
+
* aprobación es la misma del resto del vault (`caps +aprueba`).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { SealedStore, SealedResponder } from '@dotrino/passmanager'
|
|
23
|
+
import { verifyDeviceSig } from '@dotrino/identity/capabilities'
|
|
24
|
+
|
|
25
|
+
export const PASSWORDS_CAP = 'passwords'
|
|
26
|
+
export const PASSKEYS_CAP = 'passkeys'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {object} opts
|
|
30
|
+
* `client` cliente del proxio del vault, ya identificado
|
|
31
|
+
* `store` `{ get(k), set(k,v) }` donde viven los sobres
|
|
32
|
+
* `members(kind)` → `[{ pub, encPub }]` a quién envolverle cada llave (`main`/`passkeys`),
|
|
33
|
+
* SIN la bóveda. Lo pone `vault.js`, que es quien lee el acta.
|
|
34
|
+
* `recoveryPub()` la pública de la copia de recuperación (la que abre la frase)
|
|
35
|
+
* `canWrite(pubkey)` ¿ese aparato puede ESCRIBIR contraseñas? (acta: `passwords`)
|
|
36
|
+
* `isAllowed(pubkey)` ¿puede pedir? — lo decide el acta, no esto
|
|
37
|
+
* `encPubOf(pubkey)` su llave de cifrado, para sellarle la respuesta
|
|
38
|
+
* `needsApproval(pubkey)` (async) si ese aparato tiene que pedir aprobación (`unattended`)
|
|
39
|
+
* `approve({ pubkey, op })` pide el visto bueno (el teléfono) y espera
|
|
40
|
+
* `audit(op, info)` la bitácora del vault
|
|
41
|
+
*/
|
|
42
|
+
export function createPasswordDesk (opts = {}) {
|
|
43
|
+
const {
|
|
44
|
+
client, store,
|
|
45
|
+
members = () => [],
|
|
46
|
+
recoveryPub = () => null,
|
|
47
|
+
canWrite = () => false,
|
|
48
|
+
isAllowed = () => false,
|
|
49
|
+
encPubOf = () => null,
|
|
50
|
+
needsApproval = () => true,
|
|
51
|
+
approve = async () => false,
|
|
52
|
+
audit = () => {},
|
|
53
|
+
log = () => {},
|
|
54
|
+
} = opts
|
|
55
|
+
|
|
56
|
+
const sealed = new SealedStore(store, {
|
|
57
|
+
recipients: (kind) => members(kind),
|
|
58
|
+
/**
|
|
59
|
+
* ¿LA FIRMÓ QUIEN DICE, Y PUEDE ESCRIBIR? Es lo único que la bóveda puede comprobar
|
|
60
|
+
* sobre una entrada cuyo contenido no ve. Las dos mitades importan: la firma ata el
|
|
61
|
+
* sobre a una llave, y el acta dice si esa llave tiene algo que hacer aquí.
|
|
62
|
+
*/
|
|
63
|
+
verifyAuthor: async ({ body, author }) => {
|
|
64
|
+
if (!canWrite(author?.pub)) return false
|
|
65
|
+
return verifyDeviceSig({ publickey: author.pub, data: body, signature: author.sig })
|
|
66
|
+
},
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const responder = new SealedResponder({
|
|
70
|
+
client,
|
|
71
|
+
store: sealed,
|
|
72
|
+
recipients: async () => ({
|
|
73
|
+
recoveryPub: recoveryPub(),
|
|
74
|
+
main: await members('main'),
|
|
75
|
+
passkeys: await members('passkeys'),
|
|
76
|
+
}),
|
|
77
|
+
isAllowed,
|
|
78
|
+
encPubOf,
|
|
79
|
+
// Dos condiciones, y las dos tienen que darse: que ese APARATO esté marcado para
|
|
80
|
+
// aprobar, y que lo que pide sea PRIVADO. Rellenar un nombre no es sacar un secreto, y
|
|
81
|
+
// pedir permiso para todo enseña a decir que sí sin mirar.
|
|
82
|
+
needsApproval: async (pubkey) => await needsApproval(pubkey),
|
|
83
|
+
approve,
|
|
84
|
+
onRequest: (r) => {
|
|
85
|
+
// La bitácora NO lleva qué credencial se pidió: eso es contenido del usuario.
|
|
86
|
+
audit('passwords', { op: r.op, outcome: r.outcome, device: (r.from || '').slice(0, 24) })
|
|
87
|
+
if (r.outcome !== 'ok') log(`[vault] passwords: ${r.op} ${r.outcome}`)
|
|
88
|
+
},
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
start () { responder.start(); return this },
|
|
93
|
+
stop () { responder.stop() },
|
|
94
|
+
/**
|
|
95
|
+
* EL CANDADO DEL PERFIL YA NO PINTA NADA AQUÍ, y eso es la noticia: esta bóveda no
|
|
96
|
+
* tiene ninguna llave que cerrar. Se conserva el método porque quien lo llama no tiene
|
|
97
|
+
* por qué saberlo, y porque decirlo en la bitácora ayuda a entender el cambio.
|
|
98
|
+
*/
|
|
99
|
+
lock () { audit('passwords.lock', { sealed: true }) },
|
|
100
|
+
unlock () {},
|
|
101
|
+
/** Para la consola local y las pruebas. Aquí tampoco se abre nada. */
|
|
102
|
+
sealed,
|
|
103
|
+
responder,
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* CONVERTIR LO QUE HAY (§2.7). Es un acto único y es **segundo trabajo de la maestra**: se
|
|
109
|
+
* hace al ABRIR la bóveda, que es cuando la frase está a mano y hay con qué reescribirlo
|
|
110
|
+
* todo.
|
|
111
|
+
*
|
|
112
|
+
* El trabajo está en `@dotrino/passmanager/sealed` y no aquí, porque lo hacen las CUATRO
|
|
113
|
+
* bóvedas: esta, la pestaña, la de dentro de la extensión y `passmanager serve`. Cuatro
|
|
114
|
+
* copias serían cuatro formas sutilmente distintas de reescribir lo mismo, y la que se
|
|
115
|
+
* quedara atrás no fallaría al convertir — fallaría al leerlo otra bóveda, meses después.
|
|
116
|
+
*
|
|
117
|
+
* Lo que sí es de aquí: `dropOldKey`, porque dónde vive la llave vieja lo sabe cada una.
|
|
118
|
+
*/
|
|
119
|
+
export async function convertPasswords ({ store, sealed, cek, recipients, author, dropOldKey, log = () => {} } = {}) {
|
|
120
|
+
const { convertToSealed } = await import('@dotrino/passmanager/sealed')
|
|
121
|
+
return convertToSealed({
|
|
122
|
+
store, sealed, cek, recipients, author, dropOldKey,
|
|
123
|
+
log: (m) => log('[vault] ' + m)
|
|
124
|
+
})
|
|
125
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* La LLAVE DE SELLADO de esta bóveda: con ella FIRMA los sobres de los secretos, para
|
|
3
|
+
* que se sepa que salieron de aquí. Diseño: `docs/secretos-sellados.md` §8.8 y §8.9.
|
|
4
|
+
*
|
|
5
|
+
* Tres cosas que la hacen distinta de todo lo demás que hay en este directorio:
|
|
6
|
+
*
|
|
7
|
+
* · **Firmar no es leer.** Esta llave no abre ningún valor. Por eso puede vivir
|
|
8
|
+
* cifrada en reposo con la llave de la máquina y usarse **sin la frase** — que es
|
|
9
|
+
* justo lo que hace posible administrar sin contraseña.
|
|
10
|
+
* · **Su autoridad la da el ACTA**, que la nombra (`sealPub`) y que sella únicamente
|
|
11
|
+
* la maestra. Ella no se autoriza a sí misma.
|
|
12
|
+
* · **Rota con el acta.** Cada acta nueva puede estrenar una, así que aquí se guarda
|
|
13
|
+
* un puñado: la vigente para firmar, y las anteriores porque un sobre se firmó con
|
|
14
|
+
* la que mandaba entonces y hay que poder seguir firmando… no, seguir
|
|
15
|
+
* **identificando** cuál era. Verificar se hace con la pública que dice el acta.
|
|
16
|
+
*
|
|
17
|
+
* No guarda ninguna correspondencia con `seq`: quién mandaba y cuándo lo dice el acta
|
|
18
|
+
* (`sealKeys` con su tramo). Aquí solo están las privadas, indexadas por su pública.
|
|
19
|
+
*/
|
|
20
|
+
import { readJson, writeJson } from './disk.js'
|
|
21
|
+
import { signWithDevice } from '@dotrino/identity/capabilities'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Cuántas llaves anteriores se conservan. Firmar solo usa la vigente; las viejas se
|
|
25
|
+
* guardan por si hay que re-firmar algo sellado con ellas (recoger el histórico), y
|
|
26
|
+
* porque tirarlas no ahorra nada: son cuatro líneas de JSON.
|
|
27
|
+
*/
|
|
28
|
+
const MAX_KEYS = 8
|
|
29
|
+
|
|
30
|
+
/** Abre (o estrena) el llavero de sellado del perfil. */
|
|
31
|
+
export function openSealKeys (disk) {
|
|
32
|
+
const file = 'sealkeys.json'
|
|
33
|
+
const data = readJson(disk, file, null) || { v: 1, keys: [] }
|
|
34
|
+
if (!Array.isArray(data.keys)) data.keys = []
|
|
35
|
+
|
|
36
|
+
const save = () => writeJson(disk, file, data)
|
|
37
|
+
const find = (pub) => data.keys.find((k) => k.pub === pub) || null
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
/** La pública de la llave vigente (la última estrenada), o `null` si no hay ninguna. */
|
|
41
|
+
current: () => data.keys[data.keys.length - 1]?.pub || null,
|
|
42
|
+
|
|
43
|
+
/** ¿Tenemos la privada de esta pública? Es lo que decide si podemos firmar por ella. */
|
|
44
|
+
has: (pub) => !!find(pub),
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Estrena una llave y devuelve su PÚBLICA, para que el acta la nombre. La privada
|
|
48
|
+
* se queda aquí; no sale de esta máquina ni viaja a ninguna parte.
|
|
49
|
+
*
|
|
50
|
+
* Es lo que se le pasa a `identity.setSealKeyProvider`, así que se llama una vez
|
|
51
|
+
* por acta sellada.
|
|
52
|
+
*/
|
|
53
|
+
async mint () {
|
|
54
|
+
const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'])
|
|
55
|
+
const pub = JSON.stringify(await crypto.subtle.exportKey('jwk', pair.publicKey))
|
|
56
|
+
const priv = await crypto.subtle.exportKey('jwk', pair.privateKey)
|
|
57
|
+
data.keys.push({ pub, priv, createdAt: Date.now() })
|
|
58
|
+
if (data.keys.length > MAX_KEYS) data.keys = data.keys.slice(-MAX_KEYS)
|
|
59
|
+
save()
|
|
60
|
+
return pub
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Firma un cuerpo con la llave que el acta nombra. Devuelve `null` si esa llave no es
|
|
65
|
+
* nuestra —el disco se restauró, o el acta la puso otro master—: entonces el sobre
|
|
66
|
+
* sale SIN firma, que es peor que firmado pero mucho mejor que no poder guardar.
|
|
67
|
+
*/
|
|
68
|
+
async sign (sealPub, body) {
|
|
69
|
+
const k = sealPub ? find(sealPub) : null
|
|
70
|
+
if (!k) return null
|
|
71
|
+
const { signature } = await signWithDevice({ privateJwk: k.priv, publickey: k.pub, data: body })
|
|
72
|
+
return signature
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export default { openSealKeys }
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* El SELLADOR: lo único de los secretos que toca criptografía.
|
|
3
|
+
*
|
|
4
|
+
* `secretsStore.js` guarda la forma y las reglas y no sabe cifrar; le inyecta este
|
|
5
|
+
* puerto y le pide sobres. Así el store se prueba con un sellador falso y este archivo
|
|
6
|
+
* se lee entero de una sentada.
|
|
7
|
+
*
|
|
8
|
+
* QUÉ HACE. Cada cajón (un namespace, o el cajón propio de un aparato) tiene una CEK
|
|
9
|
+
* AES-256-GCM. Las variables privadas se cifran con ella. La CEK viaja de dos formas,
|
|
10
|
+
* y esa dualidad es todo el diseño:
|
|
11
|
+
*
|
|
12
|
+
* · **hacia los miembros** — envuelta a la llave de cifrado (`encPub`) de cada
|
|
13
|
+
* aparato que deba leer ese cajón, con ECDH efímero: quien abre solo necesita su
|
|
14
|
+
* propia privada. Es lo que va en el llavero del archivo y lo que viaja al agente.
|
|
15
|
+
* · **hacia quien administra** — en la COPIA MAESTRA, cifrada con la llave derivada
|
|
16
|
+
* de la contraseña del perfil. Es lo que permite sellar una variable nueva o
|
|
17
|
+
* re-envolver la CEK a un aparato que entra.
|
|
18
|
+
*
|
|
19
|
+
* Y hay una tercera forma que NO existe a propósito: **la CEK nunca se envuelve a la
|
|
20
|
+
* llave de esta bóveda**. Si se hiciera, el daemon podría abrir todo por su cuenta y
|
|
21
|
+
* esto no valdría nada — que es exactamente la situación de la que venimos.
|
|
22
|
+
*
|
|
23
|
+
* La contraseña no está en el disco, así que una copia del disco no abre las privadas.
|
|
24
|
+
* Lo que sí abre es todo lo demás; los límites honestos están en
|
|
25
|
+
* `docs/secretos-sellados.md` §3 y conviene leerlos antes de confiar de más.
|
|
26
|
+
*/
|
|
27
|
+
import {
|
|
28
|
+
makeContentKey, makeGeneration, encryptWithCek, decryptWithCek, wrapForMember, openWrap
|
|
29
|
+
} from '@dotrino/identity/content'
|
|
30
|
+
|
|
31
|
+
const b64 = (b) => { let s = ''; for (const x of new Uint8Array(b)) s += String.fromCharCode(x); return btoa(s) }
|
|
32
|
+
const un64 = (s) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0))
|
|
33
|
+
/** AES-256-GCM con WebCrypto: el mismo en Node y en el navegador. */
|
|
34
|
+
const aesKey = (raw, uses) => crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, false, uses)
|
|
35
|
+
|
|
36
|
+
/** La copia maestra va cifrada con AES-256-GCM bajo la llave derivada de la contraseña. */
|
|
37
|
+
const MASTER_V = 1
|
|
38
|
+
|
|
39
|
+
/** Se pidió abrir la copia maestra sin llave, o con la equivocada. */
|
|
40
|
+
export class WrongPassword extends Error {
|
|
41
|
+
constructor (msg = 'wrong password') {
|
|
42
|
+
super(msg)
|
|
43
|
+
this.code = 'WRONG_PASSWORD'
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function assertKey (adminKey) {
|
|
48
|
+
if (!(adminKey instanceof Uint8Array) || adminKey.length !== 32) {
|
|
49
|
+
throw new WrongPassword('the derived key is missing or malformed (32 bytes expected)')
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Crea el sellador. No guarda estado ni recuerda la contraseña: cada operación recibe
|
|
55
|
+
* la llave derivada y la suelta. Que no se cachee es la mitad del valor de todo esto —
|
|
56
|
+
* una llave que se queda en memoria para siempre deja el disco protegido y la máquina
|
|
57
|
+
* igual de expuesta que antes.
|
|
58
|
+
*/
|
|
59
|
+
export function makeSealer () {
|
|
60
|
+
return {
|
|
61
|
+
/**
|
|
62
|
+
* Abre la copia maestra: `{ "ns:<ns>": cek, "dev:<pub>": cek }`. Un `blob` vacío
|
|
63
|
+
* devuelve un mapa vacío — es el primer arranque, no un error.
|
|
64
|
+
*/
|
|
65
|
+
async openMaster (blob, adminKey) {
|
|
66
|
+
assertKey(adminKey)
|
|
67
|
+
if (!blob) return {}
|
|
68
|
+
if (blob.v !== MASTER_V) throw new Error(`master copy: unknown format v${blob.v}`)
|
|
69
|
+
try {
|
|
70
|
+
// WebCrypto quiere la etiqueta pegada al final del cifrado; en disco van aparte.
|
|
71
|
+
const ct = un64(blob.ct); const tag = un64(blob.tag)
|
|
72
|
+
const both = new Uint8Array(ct.length + tag.length); both.set(ct); both.set(tag, ct.length)
|
|
73
|
+
const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: un64(blob.iv) }, await aesKey(adminKey, ['decrypt']), both)
|
|
74
|
+
return JSON.parse(new TextDecoder().decode(plain))
|
|
75
|
+
} catch (_) {
|
|
76
|
+
// El tag de AES-GCM ES el verificador: si no cuadra, la contraseña no era.
|
|
77
|
+
// Por eso no hace falta guardar aparte un verificador de la contraseña, que
|
|
78
|
+
// sería un camino más barato para atacarla desde una copia del disco.
|
|
79
|
+
throw new WrongPassword()
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
async sealMaster (obj, adminKey) {
|
|
84
|
+
assertKey(adminKey)
|
|
85
|
+
const iv = crypto.getRandomValues(new Uint8Array(12))
|
|
86
|
+
const both = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, await aesKey(adminKey, ['encrypt']), new TextEncoder().encode(JSON.stringify(obj))))
|
|
87
|
+
return { v: MASTER_V, iv: b64(iv), ct: b64(both.subarray(0, both.length - 16)), tag: b64(both.subarray(both.length - 16)) }
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
/** La CEK de un cajón, creándola si es la primera variable que entra ahí. */
|
|
91
|
+
async cekFor (master, owner) {
|
|
92
|
+
if (!master[owner]) master[owner] = await makeContentKey()
|
|
93
|
+
return master[owner]
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
/** Una CEK NUEVA para el cajón, tirando la anterior. Rotar es esto. */
|
|
97
|
+
async newCek (master, owner) {
|
|
98
|
+
master[owner] = await makeContentKey()
|
|
99
|
+
return master[owner]
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
/** Cifra un valor con la CEK del cajón. */
|
|
103
|
+
async encrypt (cek, plaintext, gen = 0) {
|
|
104
|
+
const { iv, ct } = await encryptWithCek({ cek, gen, plaintext })
|
|
105
|
+
return { iv, ct }
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
/** Descifra con la CEK que corresponda al cajón. Solo lo usa quien administra. */
|
|
109
|
+
async decrypt (master, envelope, owner) {
|
|
110
|
+
const cek = master[owner]
|
|
111
|
+
if (!cek) throw new Error(`master copy: no key for drawer ${owner}`)
|
|
112
|
+
return decryptWithCek({ cek, envelope })
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Una CEK NUEVA, sin llavero de por medio. Es lo que usa cada escritura desde v5: la
|
|
117
|
+
* bóveda no puede reutilizar la de antes porque **no puede abrir ninguna envoltura
|
|
118
|
+
* para recuperarla** — sellar es una capacidad pública, abrir no. Una generación por
|
|
119
|
+
* escritura es la consecuencia, no un capricho (§8.7).
|
|
120
|
+
*/
|
|
121
|
+
async newKey () {
|
|
122
|
+
return makeContentKey()
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* El par de RECUPERACIÓN: su pública se guarda en claro —para poder envolverle sin
|
|
127
|
+
* saber nada— y su privada va sellada bajo la frase. Es la copia que se abre el día
|
|
128
|
+
* que no queda ningún aparato, y la que abre la TUI cuando el dueño pide ver un valor.
|
|
129
|
+
*/
|
|
130
|
+
async makeRecoveryPair () {
|
|
131
|
+
const pair = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveKey', 'deriveBits'])
|
|
132
|
+
return {
|
|
133
|
+
pub: JSON.stringify(await crypto.subtle.exportKey('jwk', pair.publicKey)),
|
|
134
|
+
priv: await crypto.subtle.exportKey('jwk', pair.privateKey)
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
/** Envuelve una CEK a UNA pública suelta (la de recuperación, que no es un miembro). */
|
|
139
|
+
async wrapForKey (cek, encPub) {
|
|
140
|
+
return wrapForMember({ cek, memberEncPub: encPub })
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Abre una envoltura con una privada ECDH **en JWK**. Lo hace quien PUEDE leer.
|
|
145
|
+
*
|
|
146
|
+
* La privada llega como JWK porque así se guarda (sellada bajo la frase, en un JSON),
|
|
147
|
+
* y `openWrap` quiere una `CryptoKey`: importarla es el puente, y va aquí porque este
|
|
148
|
+
* es el único archivo que toca criptografía.
|
|
149
|
+
*/
|
|
150
|
+
async openWrapWith (privJwk, wrap) {
|
|
151
|
+
const key = await crypto.subtle.importKey('jwk', privJwk, { name: 'ECDH', namedCurve: 'P-256' }, false, ['deriveBits'])
|
|
152
|
+
return openWrap({ wrap, myEncPrivateKey: key })
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
/** Descifra un sobre con la CEK ya abierta. */
|
|
156
|
+
async openValue (cek, envelope) {
|
|
157
|
+
return decryptWithCek({ cek, envelope })
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Envuelve la CEK a cada miembro. Los que no tengan llave de cifrado salen en
|
|
162
|
+
* `sinLlave` en vez de reventar: quien administra tiene que poder VERLO, porque el
|
|
163
|
+
* síntoma sería un servicio arrancando sin configuración y sin decir por qué.
|
|
164
|
+
*/
|
|
165
|
+
async wrapFor (cek, members) {
|
|
166
|
+
const { generation, sinLlave } = await makeGeneration({ members, cek })
|
|
167
|
+
return { wraps: generation.wraps, sinLlave }
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export default { makeSealer, WrongPassword }
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sealers.js — depositar la cadena de selladores en el registro público.
|
|
3
|
+
*
|
|
4
|
+
* POR QUÉ ESTO EXISTE. Un acta que alguien guardó el año pasado no puede responder la única
|
|
5
|
+
* pregunta que importa al leer una firma vieja: *la llave que firmó esto, ¿sigue pudiendo
|
|
6
|
+
* sellar?* Si a una bóveda le quitaron el permiso, quien tenga el acta vieja no se entera
|
|
7
|
+
* nunca — y seguiría creyéndole. El registro es el sitio donde mirar.
|
|
8
|
+
*
|
|
9
|
+
* QUÉ SE MANDA: los ESLABONES (`sealerLinkOf`), nunca las actas. Y un eslabón no es un acta
|
|
10
|
+
* recortada: es un documento propio de ocho campos, firmado aparte y metido DENTRO del acta
|
|
11
|
+
* antes de firmarla (identity >= 0.72). Costó una vuelta descubrirlo — la primera cadena que
|
|
12
|
+
* llegó al registro se fue con los `label` y los `cn` de cada aparato, porque «un eslabón»
|
|
13
|
+
* era un acta entera. Un acta lleva el inventario de tu casa: no se publica, ni recortada.
|
|
14
|
+
*
|
|
15
|
+
* POR QUÉ SIN SELLAR, y no es un descuido (§4.1 pide sellar los mensajes dirigidos): lo que
|
|
16
|
+
* viaja aquí va a acabar en un repo PÚBLICO, que es su propósito. Es el mismo caso que los
|
|
17
|
+
* canales `publish`/`list` del proxio, exentos por la misma razón: no hay nada que ocultar.
|
|
18
|
+
* Y no filtra de más — quién lo manda ya lo sabe el proxio por el `identify`. Sellarlo,
|
|
19
|
+
* además, obligaría a conocer la llave de cifrado del testigo, y depositar tiene que poder
|
|
20
|
+
* hacerlo cualquiera sin pedirle nada a nadie.
|
|
21
|
+
*
|
|
22
|
+
* Va por `sendByPubkey` y no por `send`: al testigo se le conoce por su pubkey, y así lo
|
|
23
|
+
* depositado entra en la cola de 24 h del proxio si estuviera caído.
|
|
24
|
+
*
|
|
25
|
+
* CUÁNDO: al arrancar y después de cada acta. Publicar de más no cuesta nada (el registro
|
|
26
|
+
* contesta «ya estaba») y así no hay que llevar la cuenta de qué se depositó — un estado
|
|
27
|
+
* que se desincroniza en cuanto se restaura un disco.
|
|
28
|
+
*/
|
|
29
|
+
import * as Acta from '@dotrino/identity/acta'
|
|
30
|
+
import { verifyDelegation } from '@dotrino/identity/capabilities'
|
|
31
|
+
|
|
32
|
+
export const OP = 'sealers.publish'
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* LA IDENTIDAD DEL TESTIGO DE DOTRINO — no su aparato (dueño, 2026-08-31: «¿no me puedes
|
|
36
|
+
* quemar un device en el código?»).
|
|
37
|
+
*
|
|
38
|
+
* Tenía razón y era el mismo fallo que ya costó caro con el `nodeId` del proxio: la pubkey
|
|
39
|
+
* de un APARATO muere en cuanto lo revocas o lo re-enrolas, y todo lo que la tuviera
|
|
40
|
+
* apuntada se queda hablando con una dirección muerta. Lo que no cambia nunca es la
|
|
41
|
+
* IDENTIDAD: el `profileId` es el nombre de la cuenta y es para siempre.
|
|
42
|
+
*
|
|
43
|
+
* Así que aquí va la identidad, y el aparato se DESCUBRE: el testigo se anuncia en el canal
|
|
44
|
+
* con su cert, y se acepta al que traiga uno emitido por esta identidad. Re-enrolarlo da un
|
|
45
|
+
* cert nuevo de la misma identidad y todo sigue solo; revocarlo lo saca del acta y su
|
|
46
|
+
* anuncio deja de valer.
|
|
47
|
+
*
|
|
48
|
+
* Y nadie configura nada: esto es una dirección pública, como `wss://proxy.dotrino.com`.
|
|
49
|
+
*/
|
|
50
|
+
export const DOTRINO_REGISTRY_ID = "{\"key_ops\":[\"verify\"],\"ext\":true,\"kty\":\"EC\",\"x\":\"McyhKieRw_Jcl3TY9EvLovL-ALzGoWqMOrfnFjT7XpA\",\"y\":\"V7tfpDWN37tPnhXnIFUlMaTepcTzzxPWTj_72PkpKPw\",\"crv\":\"P-256\"}"
|
|
51
|
+
|
|
52
|
+
/** El canal donde el testigo se anuncia (ver `@dotrino/sealers`). */
|
|
53
|
+
export const CANAL = 'dotrino.sealers'
|
|
54
|
+
|
|
55
|
+
/** Se le pregunta a cada anunciado quién es; contesta con su cert (ver `@dotrino/sealers`). */
|
|
56
|
+
export const WHOIS = 'sealers.whois'
|
|
57
|
+
|
|
58
|
+
export function startSealersPublisher ({ identity, client, log = console.log, registryId = DOTRINO_REGISTRY_ID, canal = CANAL } = {}) {
|
|
59
|
+
if (!registryId) return () => {}
|
|
60
|
+
|
|
61
|
+
/** Una respuesta a `sealers.whois`, o null si no contesta a tiempo. */
|
|
62
|
+
function preguntarQuienEs (token, timeoutMs = 8000) {
|
|
63
|
+
return new Promise((resolve) => {
|
|
64
|
+
const fin = (v) => { try { off() } catch (_) {} ; clearTimeout(t); resolve(v) }
|
|
65
|
+
const off = client.on('message', (_f, p) => { if (p?.op === WHOIS + '.result') fin(p) })
|
|
66
|
+
const t = setTimeout(() => fin(null), timeoutMs)
|
|
67
|
+
t.unref?.()
|
|
68
|
+
try { client.send(token, { op: WHOIS }) } catch (_) { fin(null) }
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* ¿A QUIÉN LE MANDO ESTO? Al que esté anunciado en el canal y demuestre ser de la
|
|
74
|
+
* IDENTIDAD que llevamos en el código.
|
|
75
|
+
*
|
|
76
|
+
* El canal dice DÓNDE mirar y nada más: `list` del proxio devuelve tokens sueltos — el
|
|
77
|
+
* `extraData` del `publish` no vuelve, comprobado contra el servidor. Así que a cada uno
|
|
78
|
+
* se le pregunta quién es (`sealers.whois`) y contesta con su cert. Que el cert venga del
|
|
79
|
+
* propio testigo y no del anuncio es además mejor: llega fresco.
|
|
80
|
+
*
|
|
81
|
+
* Sin comprobar el cert, cualquiera se anuncia ahí y se queda con los depósitos. No
|
|
82
|
+
* filtraría nada —el eslabón es público— pero se los tragaría EN SILENCIO, que es peor
|
|
83
|
+
* que un error.
|
|
84
|
+
*/
|
|
85
|
+
async function testigo () {
|
|
86
|
+
let tokens
|
|
87
|
+
try { tokens = await client.list(canal) } catch (_) { return null }
|
|
88
|
+
for (const token of tokens || []) {
|
|
89
|
+
const r = await preguntarQuienEs(token)
|
|
90
|
+
const cert = r?.cert
|
|
91
|
+
if (!cert?.sub || cert.iss !== registryId) continue
|
|
92
|
+
// AQUÍ SOMOS EXTRAÑOS: no tenemos el acta del registro, así que no podemos preguntarle
|
|
93
|
+
// quién sella. Lo que sí tenemos es su `profileId` quemado en el código —inmutable, a
|
|
94
|
+
// diferencia de la dirección de un aparato— y eso es lo que se ancla: aceptamos un
|
|
95
|
+
// papel firmado POR ESA LLAVE, para el acta que él diga.
|
|
96
|
+
//
|
|
97
|
+
// Es exactamente la misma fuerza que antes (`cert.iss === registryId` + firma válida),
|
|
98
|
+
// dicho con la forma nueva. Y tiene un límite que conviene decir: si el registro
|
|
99
|
+
// llegara a tener una SEGUNDA selladora, sus papeles no entrarían por aquí hasta que
|
|
100
|
+
// presente su cadena de eslabones — que es, con gracia, justo lo que este registro
|
|
101
|
+
// publica.
|
|
102
|
+
const v = await verifyDelegation({
|
|
103
|
+
cert, expectedSub: cert.sub, actaSeq: cert.seq, sealers: [registryId]
|
|
104
|
+
}).catch(() => ({ ok: false }))
|
|
105
|
+
if (v?.ok) return cert.sub
|
|
106
|
+
}
|
|
107
|
+
return null
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let ultima = null
|
|
111
|
+
|
|
112
|
+
let avisadoDeOtroTestigo = false
|
|
113
|
+
|
|
114
|
+
async function publicar (motivo) {
|
|
115
|
+
let actas
|
|
116
|
+
try { actas = await identity.sealerChain?.() } catch (_) { return }
|
|
117
|
+
if (!Array.isArray(actas)) return
|
|
118
|
+
|
|
119
|
+
// SI LA CUENTA DECLARÓ SU PROPIO TESTIGO, no se deposita en el de Dotrino. No es que
|
|
120
|
+
// hacerlo filtre algo —el eslabón es público— sino que el `chainUrl` del génesis es la
|
|
121
|
+
// forma que tiene una cuenta de decir dónde vive su cadena, y decidir por ella sería
|
|
122
|
+
// exactamente lo que ese campo existe para impedir. Se dice una vez y se calla.
|
|
123
|
+
const genesis = actas[0]
|
|
124
|
+
if (genesis?.chainUrl && registryId === DOTRINO_REGISTRY_ID) {
|
|
125
|
+
if (!avisadoDeOtroTestigo) {
|
|
126
|
+
avisadoDeOtroTestigo = true
|
|
127
|
+
log(`[sealers] this account names its own registry (${genesis.chainUrl}): not depositing into Dotrino's`)
|
|
128
|
+
}
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
// De cada acta sale su eslabón, y las que no cambiaron el sellador no dan ninguno.
|
|
132
|
+
const chain = actas.map((a) => Acta.sealerLinkOf(a)).filter(Boolean)
|
|
133
|
+
// Una cuenta de una sola bóveda no tiene nada que refrescar: su conjunto de selladores
|
|
134
|
+
// no puede cambiar. El registro la rechazaría, así que ni se molesta en mandarla.
|
|
135
|
+
// Una cuenta anterior a identity 0.72 tampoco tiene eslabones, y es el mismo caso: no
|
|
136
|
+
// aparece en el registro hasta que selle un acta nueva.
|
|
137
|
+
if (chain.length < 2) return
|
|
138
|
+
|
|
139
|
+
const cabeza = chain[chain.length - 1]?.seq
|
|
140
|
+
if (cabeza === ultima) return
|
|
141
|
+
try {
|
|
142
|
+
const a = await testigo()
|
|
143
|
+
if (!a) { log('[sealers] no witness announced in ' + canal + ': the chain stays for the next time'); return }
|
|
144
|
+
await client.sendByPubkey(a, { op: OP, chain })
|
|
145
|
+
ultima = cabeza
|
|
146
|
+
log(`[sealers] chain up to #${cabeza} deposited (${motivo})`)
|
|
147
|
+
} catch (e) {
|
|
148
|
+
// Que no se pueda depositar no rompe nada de lo que la bóveda hace: el registro es
|
|
149
|
+
// una comodidad para terceros, no una dependencia. Se reintenta en la próxima acta.
|
|
150
|
+
log('[sealers] could not deposit the chain:', e.message)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
publicar('startup').catch(() => {})
|
|
155
|
+
const off = identity.onVault?.((e) => { if (e?.phase === 'acta') publicar(`record #${e.seq}`).catch(() => {}) })
|
|
156
|
+
return () => { try { off?.() } catch (_) {} }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export default { startSealersPublisher, OP, DOTRINO_REGISTRY_ID, CANAL }
|