@dotrino/identity 0.60.3 → 0.62.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/vault/acta.js +10 -3
- package/vault/vault.js +17 -1
- package/vault/vendor/vault/VERSION.txt +1 -1
- package/vault/vendor/vault/enroll.js +12 -3
- package/vault/vendor/vault/index.js +124 -7
- package/vault/vendor/vault/protocol.js +10 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/identity",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.62.0",
|
|
4
4
|
"description": "Identidad y rating de usuarios compartidos entre apps de Dotrino (vault iframe + postMessage)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -55,12 +55,12 @@
|
|
|
55
55
|
"type": "git",
|
|
56
56
|
"url": "git+https://github.com/imdotrino/dotrino-identity.git"
|
|
57
57
|
},
|
|
58
|
-
"dependencies": {
|
|
59
|
-
"@dotrino/proxy-client": "^0.11.0"
|
|
60
|
-
},
|
|
61
58
|
"devDependencies": {
|
|
62
59
|
"fake-indexeddb": "^6.2.5",
|
|
63
60
|
"typescript": "5.9.3",
|
|
64
61
|
"@types/node": "^22.0.0"
|
|
62
|
+
},
|
|
63
|
+
"peerDependencies": {
|
|
64
|
+
"@dotrino/proxy-client": ">=0.13.1"
|
|
65
65
|
}
|
|
66
66
|
}
|
package/vault/acta.js
CHANGED
|
@@ -53,15 +53,21 @@ const ACTA_LEIBLES = Object.freeze([1, 2])
|
|
|
53
53
|
* el rol de master, que no se delega. Así un dispositivo con `admin` robado hace daño
|
|
54
54
|
* acotado y **reversible** (se le revoca), en vez de poder dejarte fuera de tu cuenta.
|
|
55
55
|
*/
|
|
56
|
-
export const CAPS = Object.freeze(['sign', 'store', 'read', 'secrets', 'admin', 'approve'])
|
|
56
|
+
export const CAPS = Object.freeze(['sign', 'store', 'read', 'secrets', 'admin', 'approve', 'passwords'])
|
|
57
57
|
|
|
58
58
|
/** Capacidades de un DISPOSITIVO (sin CN): acceso a todo lo del usuario. */
|
|
59
59
|
/**
|
|
60
60
|
* `approve` es el aparato que APRUEBA: cuando un cajón exige aprobación por uso, el
|
|
61
61
|
* vault le avisa y solo su firma libera los secretos (normalmente el teléfono). Como
|
|
62
62
|
* `admin`, no viaja en un QR: se concede a mano (`dotrino-vault caps <ID> +approve`).
|
|
63
|
+
*
|
|
64
|
+
* `passwords` es el aparato que puede PEDIR CREDENCIALES de la bóveda de contraseñas
|
|
65
|
+
* (el gestor: la extensión del navegador, la app del teléfono). Pide de a una y por
|
|
66
|
+
* dominio; nunca lista la bóveda. Va aquí y no en una lista aparte porque quién puede
|
|
67
|
+
* pedirle algo a la bóveda es exactamente lo que decide el acta — tener dos registros
|
|
68
|
+
* de lo mismo obliga a acordarse de los dos al quitar un aparato.
|
|
63
69
|
*/
|
|
64
|
-
export const DEVICE_CAPS = Object.freeze(['sign', 'store', 'read', 'admin', 'approve'])
|
|
70
|
+
export const DEVICE_CAPS = Object.freeze(['sign', 'store', 'read', 'admin', 'approve', 'passwords'])
|
|
65
71
|
|
|
66
72
|
/**
|
|
67
73
|
* Lo que recibe un dispositivo recién emparejado. `admin` **no está**: no se
|
|
@@ -93,12 +99,13 @@ export function capScope (cap, cn = null) {
|
|
|
93
99
|
if (cap === 'read') return 'vault:read'
|
|
94
100
|
if (cap === 'admin') return 'vault:admin'
|
|
95
101
|
if (cap === 'approve') return 'vault:approve'
|
|
102
|
+
if (cap === 'passwords') return 'vault:passwords'
|
|
96
103
|
if (cap === 'secrets') return isValidCn(cn) ? 'vault:secrets:' + cn : null
|
|
97
104
|
return null
|
|
98
105
|
}
|
|
99
106
|
|
|
100
107
|
/** Compat: el mapa directo, para las capacidades de dispositivo. */
|
|
101
|
-
export const CAP_SCOPE = Object.freeze({ sign: 'vault:sign', store: 'vault:store', read: 'vault:read', admin: 'vault:admin', approve: 'vault:approve' })
|
|
108
|
+
export const CAP_SCOPE = Object.freeze({ sign: 'vault:sign', store: 'vault:store', read: 'vault:read', admin: 'vault:admin', approve: 'vault:approve', passwords: 'vault:passwords' })
|
|
102
109
|
|
|
103
110
|
const enc = (s) => new TextEncoder().encode(s)
|
|
104
111
|
const hex = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('')
|
package/vault/vault.js
CHANGED
|
@@ -105,6 +105,22 @@ import { pubkeyId } from './capabilities.js'
|
|
|
105
105
|
// libera y otra pestaña visible lo toma. Así varias apps abiertas no compiten.
|
|
106
106
|
const SELF_FLAG = 'dotrino.self-vault.enabled' // persistido en localStorage (kv)
|
|
107
107
|
const SELF_LOCK = 'dotrino-self-vault'
|
|
108
|
+
/**
|
|
109
|
+
* Proxio del mostrador, SOLO en localhost (`?proxy=ws://…` en la URL de este iframe).
|
|
110
|
+
*
|
|
111
|
+
* En producción es el del ecosistema y no hay nada que elegir. Existe porque las
|
|
112
|
+
* pruebas de punta a punta levantan su propio proxio y prometen no tocar producción:
|
|
113
|
+
* sin esto, el mostrador de una bóveda-en-pestaña marcaba a `proxy.dotrino.com` desde
|
|
114
|
+
* el banco de pruebas. Es el mismo permiso que ya tiene `?vault=` en la consola.
|
|
115
|
+
*/
|
|
116
|
+
const selfProxyUrl = (() => {
|
|
117
|
+
try {
|
|
118
|
+
const u = new URL(location.href)
|
|
119
|
+
if (!/^(localhost|127\.0\.0\.1|\[::1\])$/.test(u.hostname)) return null
|
|
120
|
+
const p = u.searchParams.get('proxy')
|
|
121
|
+
return /^wss?:\/\//.test(p || '') ? p : null
|
|
122
|
+
} catch (_) { return null }
|
|
123
|
+
})()
|
|
108
124
|
let daemon = null // handle de startDeviceVault cuando ESTE iframe es el activo
|
|
109
125
|
let _lockResolver = null // resolver del callback del lock (libera al resolverlo)
|
|
110
126
|
|
|
@@ -133,7 +149,7 @@ import { pubkeyId } from './capabilities.js'
|
|
|
133
149
|
// Import dinámico: aísla fallos del vendor del arranque del vault (cargado por
|
|
134
150
|
// todas las apps). El import map de index.html resuelve @dotrino/vault.
|
|
135
151
|
const { startDeviceVault } = await import('@dotrino/vault')
|
|
136
|
-
daemon = await startDeviceVault(selfIdentity)
|
|
152
|
+
daemon = await startDeviceVault(selfIdentity, selfProxyUrl ? { proxyUrl: selfProxyUrl } : undefined)
|
|
137
153
|
daemon.onPendingChange(() => broadcast('selfVault', { pending: daemon.listPending() }))
|
|
138
154
|
broadcast('selfVault', { running: true })
|
|
139
155
|
} catch (e) { daemon = null; broadcast('selfVault', { error: e?.message || String(e) }) }
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
Copia vendorizada de @dotrino/vault@0.
|
|
1
|
+
Copia vendorizada de @dotrino/vault@0.34.0 (lib/src/{index,enroll,protocol}.js, sin dependencias).
|
|
2
2
|
El iframe de identity se sirve estatico (vanilla, sin build); asi startDeviceVault
|
|
3
3
|
resuelve en el navegador sin bundler. index.js importa ./enroll.js y ./protocol.js
|
|
4
4
|
(relativos, se vendorizan tambien) y @dotrino/identity/capabilities (=../../capabilities.js)
|
|
@@ -261,9 +261,15 @@ export function createEnrollDesk ({
|
|
|
261
261
|
pend.dpub = d.dpub
|
|
262
262
|
pend.deviceId = deviceId
|
|
263
263
|
pend.commit = d.commit
|
|
264
|
-
// Llave de CIFRADO del dispositivo: con ella se le envuelve la clave
|
|
265
|
-
//
|
|
264
|
+
// Llave de CIFRADO del dispositivo: con ella se le envuelve la clave del cajón al
|
|
265
|
+
// admitirlo. Un SERVICIO sin ella entraría al acta y no podría leer NUNCA ninguna
|
|
266
|
+
// variable —las privadas van selladas a esta llave—, así que se corta aquí en vez
|
|
267
|
+
// de admitirlo y dejar que falle más tarde y en otro sitio. Un dispositivo de
|
|
268
|
+
// persona sí puede entrar sin ella: no lee variables de servicio.
|
|
266
269
|
if (typeof d.encPub === 'string') pend.encPub = d.encPub
|
|
270
|
+
if (!pend.encPub && scopeToCn(pend.scope)) {
|
|
271
|
+
return reply(from, { type: MSG_ERROR, error: 'a service must send its encryption key (update @dotrino/vault on the service)' })
|
|
272
|
+
}
|
|
267
273
|
// Certificado de continuidad (opcional): lo firma la identidad que se une, con su
|
|
268
274
|
// propia llave. Se comprueba aquí y se guarda con el miembro al aprobar.
|
|
269
275
|
if (d.continuity) {
|
|
@@ -335,8 +341,11 @@ export function createEnrollDesk ({
|
|
|
335
341
|
let record = null
|
|
336
342
|
try {
|
|
337
343
|
if (typeof identity.admitMember === 'function') {
|
|
344
|
+
// PERMISOS, no tipos (2026-08-22): las capacidades son las del scope ENTERO. Un
|
|
345
|
+
// cajón (`secrets:<ns>`) suma `secrets` y fija el CN; no borra lo demás — un bot
|
|
346
|
+
// con `sign,secrets:eco` firma como aparato del acta Y lee solo su cajón.
|
|
338
347
|
const cn = scopeToCn(pend.scope)
|
|
339
|
-
const caps = cn ? ['secrets'] :
|
|
348
|
+
const caps = [...new Set([...scopeToCaps(pend.scope), ...(cn ? ['secrets'] : [])])]
|
|
340
349
|
if (caps.length) await identity.admitMember({ pub: pend.dpub, encPub: pend.encPub || null, label: pend.label || '', cn, caps, cert, continuity: pend.continuity || null })
|
|
341
350
|
}
|
|
342
351
|
record = (await identity.profileActa?.())?.acta || null
|
|
@@ -25,10 +25,10 @@
|
|
|
25
25
|
* (`identity.signDelegation`). Transporte: `@dotrino/proxy-client` (import perezoso).
|
|
26
26
|
* No reimplementa nada del ecosistema.
|
|
27
27
|
*/
|
|
28
|
-
import { verifyChain } from '@dotrino/identity/capabilities'
|
|
28
|
+
import { verifyChain, verifyDeviceSig } from '@dotrino/identity/capabilities'
|
|
29
29
|
import { createEnrollDesk, deviceIdOf, DEVICE_TTL_MS, FRESH_WINDOW_MS } from './enroll.js'
|
|
30
30
|
// Las constantes del protocolo salen del MISMO módulo que usa el daemon: si la lista
|
|
31
|
-
// local se queda corta, el dispositivo deja de
|
|
31
|
+
// local se queda corta, el dispositivo deja de handle mensajes sin que nadie lo note.
|
|
32
32
|
import { MSG, SCOPE } from './protocol.js'
|
|
33
33
|
|
|
34
34
|
const SIGN_SCOPE = SCOPE.SIGN
|
|
@@ -168,15 +168,132 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
|
|
|
168
168
|
if (mine) desk.emitRevoke(chk.device, mine.nonce)
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Lo que el daemon comprueba antes de cualquier operación firmada, en un solo sitio:
|
|
173
|
+
* frescura (anti-replay), cadena de certs, scope esperado y revocaciones.
|
|
174
|
+
*
|
|
175
|
+
* Estaba repetido en `handleRenew` y `handleDevices` con matices distintos; al añadir
|
|
176
|
+
* el resto de operaciones eso habría sido cuatro copias divergiendo.
|
|
177
|
+
*/
|
|
178
|
+
async function authorise (from, p, expectedScope) {
|
|
179
|
+
const d = p?.data
|
|
180
|
+
if (!d || !p.signature || !p.cert) {
|
|
181
|
+
send(from, { type: MSG.ERROR, error: 'invalid request' })
|
|
182
|
+
return null
|
|
183
|
+
}
|
|
184
|
+
if (typeof d.ts !== 'number' || Math.abs(Date.now() - d.ts) > FRESH_WINDOW_MS) {
|
|
185
|
+
send(from, { type: MSG.ERROR, error: 'stale request: ts outside the ±5 min window (possible replay, or a clock out of sync)' })
|
|
186
|
+
return null
|
|
187
|
+
}
|
|
188
|
+
const chk = await verifyChain({
|
|
189
|
+
data: d, signature: p.signature, cert: p.cert,
|
|
190
|
+
...(expectedScope ? { expectedScope } : {}),
|
|
191
|
+
trustedIssuer: iss, revoked: await revocationSet(),
|
|
192
|
+
})
|
|
193
|
+
if (!chk.ok) {
|
|
194
|
+
send(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason })
|
|
195
|
+
return null
|
|
196
|
+
}
|
|
197
|
+
return chk
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* FIRMAR en name de la identidad. Es la razón de ser de una bóveda, y faltaba: un
|
|
202
|
+
* aparato enrolado contra este dispositivo podía renovar su cert y listar aparatos,
|
|
203
|
+
* pero no pedir la única cosa para la que se enroló.
|
|
204
|
+
*/
|
|
205
|
+
async function handleSign (from, p) {
|
|
206
|
+
const chk = await authorise(from, p, SCOPE.SIGN)
|
|
207
|
+
if (!chk) return
|
|
208
|
+
const toSign = p.data?.payload
|
|
209
|
+
if (toSign == null) return send(from, { type: MSG.ERROR, error: 'data.payload required' })
|
|
210
|
+
const { signature, publickey } = await identity.signData(toSign)
|
|
211
|
+
send(from, { type: MSG.SIGNED, signature, publickey, device: chk.device })
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Leer del almacén del perfil. Mismo scope que en el daemon: `read`. */
|
|
215
|
+
async function handleGet (from, p) {
|
|
216
|
+
const chk = await authorise(from, p, SCOPE.READ)
|
|
217
|
+
if (!chk) return
|
|
218
|
+
const id = p.data?.id || 'root'
|
|
219
|
+
try {
|
|
220
|
+
const node = await identity.getNode?.(id)
|
|
221
|
+
send(from, { type: MSG.DATA, id, node: node ?? null })
|
|
222
|
+
} catch (e) {
|
|
223
|
+
send(from, { type: MSG.ERROR, error: 'get: ' + e.message })
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Escribir en el almacén. Se pasa por `vaultStore`, que es el mismo camino que usa
|
|
229
|
+
* un aparato contra el daemon — no se reimplementa el store aquí.
|
|
230
|
+
*/
|
|
231
|
+
async function handleStore (from, p) {
|
|
232
|
+
const d = p?.data
|
|
233
|
+
if (!d || typeof d.method !== 'string') {
|
|
234
|
+
return send(from, { type: MSG.ERROR, error: 'store: invalid method' })
|
|
235
|
+
}
|
|
236
|
+
const chk = await authorise(from, p, SCOPE.STORE)
|
|
237
|
+
if (!chk) return
|
|
238
|
+
try {
|
|
239
|
+
const result = await identity.vaultStore?.(d.method, d.args || [])
|
|
240
|
+
send(from, { type: MSG.DATA, id: d.method, node: result ?? null })
|
|
241
|
+
} catch (e) {
|
|
242
|
+
send(from, { type: MSG.ERROR, error: 'store: ' + e.message })
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* ¿Sigue este aparato dentro del acta? Lo pregunta un aparato al arrancar, y por eso
|
|
248
|
+
* NO va firmado con cert: va firmado con su propia llave. Un aparato revocado tiene
|
|
249
|
+
* que poder enterarse de que lo está.
|
|
250
|
+
*/
|
|
251
|
+
async function handleCheck (from, p) {
|
|
252
|
+
const d = p?.data
|
|
253
|
+
if (!d || typeof d.ts !== 'number' || Math.abs(Date.now() - d.ts) > FRESH_WINDOW_MS) return
|
|
254
|
+
const pub = d.publickey
|
|
255
|
+
if (typeof pub !== 'string') return send(from, { type: MSG.ERROR, error: 'unauthorized: shape' })
|
|
256
|
+
if (!(await verifyDeviceSig({ publickey: pub, data: d, signature: p.signature }))) {
|
|
257
|
+
return send(from, { type: MSG.ERROR, error: 'unauthorized: bad-signature' })
|
|
258
|
+
}
|
|
259
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
260
|
+
const inside = (record?.members || []).some((m) => m?.pub === pub)
|
|
261
|
+
if (inside) return send(from, { type: MSG.CHECKED, in: true })
|
|
262
|
+
|
|
263
|
+
// Fuera del acta: se le dice, y además se le re-emite el aviso firmado si consta
|
|
264
|
+
// revocado — para que se apague solo en vez de quedarse creyendo que sigue dentro.
|
|
265
|
+
const { revokedCerts, issued } = await identity.listDelegations()
|
|
266
|
+
const mine = (revokedCerts || issued || []).find((x) => x.sub === pub && x.revokedAt)
|
|
267
|
+
if (mine) desk.emitRevoke(pub, mine.nonce)
|
|
268
|
+
send(from, { type: MSG.CHECKED, in: false })
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Un fallo dentro de un handler NO se traga.
|
|
273
|
+
*
|
|
274
|
+
* El router llevaba `.catch(() => {})` en cada rama: si algo reventaba, el aparato del
|
|
275
|
+
* otro lado se quedaba esperando para siempre y aquí no quedaba rastro. Ahora se
|
|
276
|
+
* contesta el error — que es lo que permite depurarlo desde el lado que pregunta.
|
|
277
|
+
*/
|
|
278
|
+
const handle = (name, promise, from) => Promise.resolve(promise).catch((e) => {
|
|
279
|
+
send(from, { type: MSG.ERROR, error: `${name}: ${e?.message || e}` })
|
|
280
|
+
})
|
|
281
|
+
|
|
171
282
|
client.on('message', (_from, p) => {
|
|
172
283
|
if (!p || typeof p !== 'object') return
|
|
173
284
|
// El QR corto no lleva la llave: el aparato la pide con un HELLO presentando el `sn`.
|
|
174
|
-
if (p.type === MSG.HELLO)
|
|
175
|
-
else if (p.type === MSG.ENROLL) desk.handleEnroll(_from, p)
|
|
285
|
+
if (p.type === MSG.HELLO) handle('hello', desk.handleHello(_from, p), _from)
|
|
286
|
+
else if (p.type === MSG.ENROLL) handle('enroll', desk.handleEnroll(_from, p), _from)
|
|
176
287
|
// Camino A: el aparato devuelve su acta sellada admitiendo a esta bóveda.
|
|
177
|
-
else if (p.type === MSG.ACTA_SEALED)
|
|
178
|
-
else if (p.type === MSG.RENEW) handleRenew(_from, p)
|
|
179
|
-
else if (p.type === MSG.DEVICES) handleDevices(_from, p)
|
|
288
|
+
else if (p.type === MSG.ACTA_SEALED) handle('acta', desk.handleActaSealed(_from, p), _from)
|
|
289
|
+
else if (p.type === MSG.RENEW) handle('renew', handleRenew(_from, p), _from)
|
|
290
|
+
else if (p.type === MSG.DEVICES) handle('devices', handleDevices(_from, p), _from)
|
|
291
|
+
// Lo que faltaba para que un aparato enrolado aquí pueda hacer lo mismo que contra
|
|
292
|
+
// el daemon del PC: firmar, leer, guardar y comprobar que sigue dentro.
|
|
293
|
+
else if (p.type === MSG.SIGN) handle('sign', handleSign(_from, p), _from)
|
|
294
|
+
else if (p.type === MSG.GET) handle('get', handleGet(_from, p), _from)
|
|
295
|
+
else if (p.type === MSG.STORE) handle('store', handleStore(_from, p), _from)
|
|
296
|
+
else if (p.type === MSG.CHECK) handle('check', handleCheck(_from, p), _from)
|
|
180
297
|
})
|
|
181
298
|
|
|
182
299
|
/**
|
|
@@ -66,6 +66,16 @@ export const MSG = Object.freeze({
|
|
|
66
66
|
// Va FIRMADO por la maestra y el agente lo verifica contra su `iss` pineada: un
|
|
67
67
|
// aviso de reinicio sin autenticar ES un ataque de denegación.
|
|
68
68
|
SECRETS_CHANGED: 'vault.secrets.changed', // vault → servicio: { body:{op,ns,ts}, signature }
|
|
69
|
+
// REPARTIR LA LLAVE DE UN CAJÓN A UN MIEMBRO NUEVO. Lo hace el SERVICIO y no la
|
|
70
|
+
// bóveda, porque la bóveda no puede abrir la llave sin la frase y el servicio ya la
|
|
71
|
+
// tiene: re-envolverla no le añade ningún poder. Ver `docs/secretos-sellados.md` §8.11.
|
|
72
|
+
//
|
|
73
|
+
// El servicio NO se fía de lo que le manden: comprueba la firma de la maestra, comprueba
|
|
74
|
+
// el ACTA que viene dentro (también firmada) y saca de ahí la llave pública del
|
|
75
|
+
// destinatario. Así, ni siquiera una bóveda comprometida puede hacerle envolver la
|
|
76
|
+
// llave para alguien que la maestra no haya metido en su propio cajón.
|
|
77
|
+
REWRAP: 'vault.rewrap', // vault → servicio: { body:{op,owner,gen,wrap,target,acta,ts}, signature }
|
|
78
|
+
REWRAP_OK: 'vault.rewrap.ok', // servicio → vault: { data:{op,owner,gen,target,wrap,ts}, signature, cert }
|
|
69
79
|
// --- CONSOLA REMOTA (docs/consola-remota.md) — requiere cert `vault:admin` ---
|
|
70
80
|
// Un solo mensaje con `data.op`: pending · pair · approve · reject · revoke · audit.
|
|
71
81
|
// Admitir y expulsar, nada más: cambiar permisos, traspasar el mando y los secretos
|