@dotrino/identity 0.100.0 → 0.102.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
CHANGED
package/src/node.js
CHANGED
|
@@ -149,6 +149,8 @@ export class Identity {
|
|
|
149
149
|
sealMasterKey () { return this._core?.sealMasterKey?.() }
|
|
150
150
|
/** Vuelve a cargar el par tras abrir el candado, sin reabrir la identidad. */
|
|
151
151
|
reloadMasterKey () { return this._core?.reloadMasterKey?.() }
|
|
152
|
+
/** Ver `core.js`: al abrir, los aparatos muertos salen del acta en una sola. */
|
|
153
|
+
pruneExpiredDevices (now) { return this._core?.pruneExpiredDevices?.(now) }
|
|
152
154
|
|
|
153
155
|
_h (method, params = {}) {
|
|
154
156
|
if (!this._core) throw new Error('Identity not ready — call ready()/connect() first')
|
package/vault/capabilities.js
CHANGED
|
@@ -210,7 +210,18 @@ export async function signDelegationWith (privateKey, iss, { sub, scope, iat, se
|
|
|
210
210
|
* Firma datos con la clave de DISPOSITIVO (formato byte-idéntico a `signData` del
|
|
211
211
|
* vault → lo que el dispositivo/bridge usa para firmar cada pin/acción).
|
|
212
212
|
*/
|
|
213
|
-
export async function signWithDevice ({ privateJwk, privateKey, publickey, data }) {
|
|
213
|
+
export async function signWithDevice ({ privateJwk, privateKey, publickey, data, sign }) {
|
|
214
|
+
// UNA LLAVE QUE NO VIVE AQUÍ (la del chip del teléfono, en la app nativa): se le pasa el
|
|
215
|
+
// texto canónico y devuelve la firma P1363 en base64. La privada no entra en este proceso
|
|
216
|
+
// en ningún momento. Con firmador externo es obligatorio decir de quién es la llave.
|
|
217
|
+
if (typeof sign === 'function') {
|
|
218
|
+
if (!publickey) throw new Error('signWithDevice: publickey is required with an external sign()')
|
|
219
|
+
const signature = await sign(canonicalStringify(data))
|
|
220
|
+
if (typeof signature !== 'string' || !signature) {
|
|
221
|
+
throw Object.assign(new Error('signWithDevice: the external sign() returned no signature'), { code: 'no-signature' })
|
|
222
|
+
}
|
|
223
|
+
return { signature, publickey }
|
|
224
|
+
}
|
|
214
225
|
// `privateKey` (CryptoKey, posiblemente NO extractable) tiene prioridad: firma
|
|
215
226
|
// sin tocar bytes de la privada. Con CryptoKey es obligatorio pasar `publickey`.
|
|
216
227
|
if (privateKey) {
|
package/vault/core.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* vault, compartida por todos los runtimes.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { signDelegationWith } from './capabilities.js'
|
|
21
|
+
import { signDelegationWith, LEGACY_CERTS_UNTIL } from './capabilities.js'
|
|
22
22
|
import * as Acta from './acta.js'
|
|
23
23
|
import * as Content from './content.js'
|
|
24
24
|
import { assertionBody, cleanScopes, claimsAllowed, ASSERTION_DEFAULT_TTL_MS, ASSERTION_MAX_TTL_MS } from './assertion.js'
|
|
@@ -1007,6 +1007,62 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
|
|
|
1007
1007
|
return { gen, sinLlave }
|
|
1008
1008
|
}
|
|
1009
1009
|
|
|
1010
|
+
/**
|
|
1011
|
+
* LOS APARATOS MUERTOS SALEN DEL ACTA al abrir la bóveda (dueño, 2026-09-22: *«si se abre
|
|
1012
|
+
* el vault y hay un aparato expirado debe quitarlo del acta en una nueva acta»*).
|
|
1013
|
+
*
|
|
1014
|
+
* Muerto es un aparato cuyo papel no tiene vuelta atrás: todos los certificados que ESTA
|
|
1015
|
+
* bóveda le dio son del modelo viejo (sin `seq`) y ya no valen — vencidos, o pasado
|
|
1016
|
+
* `LEGACY_CERTS_UNTIL`, que los retira a todos. Renovar tampoco lo salva: la renovación
|
|
1017
|
+
* viaja firmada con ese mismo papel, y la bóveda lo rechaza (`unauthorized: expired`).
|
|
1018
|
+
* Hasta ahora se quedaban en el acta para siempre, como miembros que nadie podía usar.
|
|
1019
|
+
*
|
|
1020
|
+
* Lo que NO se toca, porque no hay datos para juzgarlo:
|
|
1021
|
+
* · un miembro sin certificados de esta bóveda (se los pudo dar otra);
|
|
1022
|
+
* · un papel viejo sin `exp` antes del corte;
|
|
1023
|
+
* · esta misma llave.
|
|
1024
|
+
*
|
|
1025
|
+
* Todo sale en UNA acta: las bajas y la clave de contenido nueva van en el mismo sello,
|
|
1026
|
+
* así que la bajada de `seq` es una y no una por aparato. Solo con la maestra en memoria:
|
|
1027
|
+
* cerrada no firma nada (`CLAUDE.md`, «la maestra tiene dos trabajos»), y cambiar el acta
|
|
1028
|
+
* al abrir es justo uno de ellos.
|
|
1029
|
+
*/
|
|
1030
|
+
async function pruneExpiredDevices (now = Date.now()) {
|
|
1031
|
+
if (!keypair?.privateKey) return { removed: [], seq: null }
|
|
1032
|
+
const acta = loadActa()
|
|
1033
|
+
if (!acta) return { removed: [], seq: null }
|
|
1034
|
+
const store = loadDelegations(); const rev = loadRevocations()
|
|
1035
|
+
const porSub = new Map()
|
|
1036
|
+
for (const d of Object.values(store)) {
|
|
1037
|
+
if (!d?.sub || d.revokedAt || rev[d.nonce]) continue
|
|
1038
|
+
if (!porSub.has(d.sub)) porSub.set(d.sub, [])
|
|
1039
|
+
porSub.get(d.sub).push(d)
|
|
1040
|
+
}
|
|
1041
|
+
const muerto = (d) => typeof d.seq !== 'number' &&
|
|
1042
|
+
(now > LEGACY_CERTS_UNTIL || (typeof d.exp === 'number' && now > d.exp))
|
|
1043
|
+
const muertos = [...porSub]
|
|
1044
|
+
.filter(([sub, ds]) => sub !== publickeyJwkStr && ds.every(muerto))
|
|
1045
|
+
.map(([sub, ds]) => ({ pub: sub, label: ds[0]?.label || '' }))
|
|
1046
|
+
if (!muertos.length) return { removed: [], seq: acta.seq }
|
|
1047
|
+
|
|
1048
|
+
const fuera = new Set(muertos.map((m) => m.pub))
|
|
1049
|
+
const miembros = (acta.members || []).filter((m) => fuera.has(m.pub)).map((m) => m.pub)
|
|
1050
|
+
let seq = acta.seq
|
|
1051
|
+
if (miembros.length) {
|
|
1052
|
+
// La clave de contenido rota en la misma acta: quien sale no se lleva lo que venga.
|
|
1053
|
+
const quedan = acta.members.filter((m) => !fuera.has(m.pub))
|
|
1054
|
+
const gen = ((acta.keyring || []).at(-1)?.gen || 0) + 1
|
|
1055
|
+
const { generation } = await Content.makeGeneration({ members: quedan, gen })
|
|
1056
|
+
const sealed = await sealChanges([
|
|
1057
|
+
...miembros.map((pub) => ({ op: 'remove', pub })),
|
|
1058
|
+
{ op: 'keyring', generation },
|
|
1059
|
+
])
|
|
1060
|
+
seq = sealed.seq
|
|
1061
|
+
}
|
|
1062
|
+
for (const { pub } of muertos) revokePriorCertsFor(pub, null)
|
|
1063
|
+
return { removed: muertos, seq }
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1010
1066
|
// ----- ENTRAR CON USUARIO Y CONTRASEÑA (`temporary-access.md` §3.4) -----
|
|
1011
1067
|
//
|
|
1012
1068
|
// Lo que llega de `@dotrino/vault/login-client` es un APARATO entero: sus dos llaves
|
|
@@ -1310,7 +1366,13 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
|
|
|
1310
1366
|
// él se va la última razón por la que la maestra tenía que estar disponible sin nadie
|
|
1311
1367
|
// delante. Renovar pasa a ocurrir justo cuando ya hay una selladora abierta, porque
|
|
1312
1368
|
// cambiar el acta ES tenerla abierta.
|
|
1313
|
-
|
|
1369
|
+
// Y LA MIGRACIÓN: un papel del modelo viejo (sin `seq`) que todavía vale se cambia por
|
|
1370
|
+
// uno nuevo. Sin esto moría en su fecha aunque el aparato se usara a diario, y ya no
|
|
1371
|
+
// tenía arreglo — el teléfono que aprueba se quedó así el 2026-09-22. Caduca sola: a
|
|
1372
|
+
// partir de `LEGACY_CERTS_UNTIL` no queda ningún papel viejo que valga.
|
|
1373
|
+
const legadoVivo = typeof v.cert.seq !== 'number' && typeof v.cert.exp === 'number' &&
|
|
1374
|
+
now < v.cert.exp && now < LEGACY_CERTS_UNTIL
|
|
1375
|
+
if (!legadoVivo && !certDesfasadoDelActa()) return
|
|
1314
1376
|
if (now - renewLastTry < RENEW_RETRY_MS) return
|
|
1315
1377
|
renovarCert().catch(() => {}) // best-effort: el cert vigente sigue sirviendo mientras tanto
|
|
1316
1378
|
} catch (_) {}
|
|
@@ -1847,6 +1909,9 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
|
|
|
1847
1909
|
kv.removeItem('dotrino.identity.pwd.tries')
|
|
1848
1910
|
try { sessionKv?.setItem(_scoped(PWD_SESSION), proof) } catch (_) {}
|
|
1849
1911
|
locked = false
|
|
1912
|
+
// Abrir es cuando se limpia el acta de aparatos muertos. Por detrás: abrir no puede
|
|
1913
|
+
// esperar a sellar, y si falla se dice, no se calla.
|
|
1914
|
+
pruneExpiredDevices().catch((e) => console.warn('[identity] could not remove expired devices:', e.message))
|
|
1850
1915
|
return { ok: true, locked: false }
|
|
1851
1916
|
},
|
|
1852
1917
|
// Poner/cambiar contraseña (requiere estar desbloqueado; cambiar exige la actual vía unlock previo).
|
|
@@ -2206,7 +2271,7 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
|
|
|
2206
2271
|
// `issued` = lo que HOY sirve para entrar. Antes devolvía el almacén entero, revocados
|
|
2207
2272
|
// incluidos (revocar solo estampa `revokedAt`), así que la consola seguía pintando como
|
|
2208
2273
|
// activo un cert ya retirado: pulsabas «quitar» y la fila no se movía. Los caducados ya
|
|
2209
|
-
// los
|
|
2274
|
+
// los quita del acta `pruneExpiredDevices` al abrir la bóveda. El histórico retirado va aparte, en `revokedCerts`.
|
|
2210
2275
|
async listDelegations () {
|
|
2211
2276
|
const store = loadDelegations(); const rev = loadRevocations()
|
|
2212
2277
|
const all = Object.values(store).sort((a, b) => (b.iat || 0) - (a.iat || 0))
|
|
@@ -3404,6 +3469,8 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
|
|
|
3404
3469
|
get masterLocked () { return !keypair?.privateKey },
|
|
3405
3470
|
/** Echa el candado a la maestra que ya existía (al abrir el perfil). Idempotente. */
|
|
3406
3471
|
sealMasterKey,
|
|
3472
|
+
/** Quita del acta, en una sola, los aparatos cuyo papel ya no tiene vuelta atrás. */
|
|
3473
|
+
pruneExpiredDevices,
|
|
3407
3474
|
/** Recarga el par tras abrir el candado, sin reabrir la identidad entera. */
|
|
3408
3475
|
async reloadMasterKey () {
|
|
3409
3476
|
keypair = await loadOrCreateKeypair()
|
package/vault/remote.js
CHANGED
|
@@ -75,7 +75,7 @@ export async function isAuthenticRevoke ({ body, signature, master, devicePubkey
|
|
|
75
75
|
async function identifyAsDevice (client, device, { cert = null, acta = null } = {}) {
|
|
76
76
|
if (!client.token) return
|
|
77
77
|
const data = { op: 'identify', publickey: device.publickey, token: client.token, ts: Date.now() }
|
|
78
|
-
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, privateKey: device.privateKey, publickey: device.publickey, data })
|
|
78
|
+
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, privateKey: device.privateKey, publickey: device.publickey, sign: device.sign, data })
|
|
79
79
|
// cert → el proxy enruta lo dirigido a la maestra; acta → lo dirigido a la PERSONA.
|
|
80
80
|
await client.identify({ data, signature, cert, acta })
|
|
81
81
|
}
|
|
@@ -169,7 +169,8 @@ export async function enrollDevice ({ qr, device, onChallenge, label = '', conti
|
|
|
169
169
|
...(adopting && profileId ? { profileId } : {}),
|
|
170
170
|
...(continuity ? { continuity } : {}), ...(encPub ? { encPub } : {})
|
|
171
171
|
}
|
|
172
|
-
|
|
172
|
+
// `dev.sign`: la llave vive fuera (el chip del teléfono) y solo se le pide la firma.
|
|
173
|
+
const { signature } = await signWithDevice({ privateJwk: dev.privateJwk, privateKey: dev.privateKey, publickey: dev.publickey, sign: dev.sign, data })
|
|
173
174
|
|
|
174
175
|
const enrolled = new Promise((resolve, reject) => {
|
|
175
176
|
let sellando = false
|
|
@@ -272,14 +273,14 @@ function vaultError (p) {
|
|
|
272
273
|
}
|
|
273
274
|
|
|
274
275
|
async function vaultRpc ({ master, proxy, device, cert, acta = null, sendType, okType, data, onRevoked, timeoutMs = 15000 }) {
|
|
275
|
-
if (!master || !proxy || !(device?.privateJwk || device?.privateKey) || !cert) throw new Error('missing pairing data')
|
|
276
|
+
if (!master || !proxy || !(device?.privateJwk || device?.privateKey || typeof device?.sign === 'function') || !cert) throw new Error('missing pairing data')
|
|
276
277
|
const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
|
|
277
278
|
const client = new WebSocketProxyClient({ url: proxy, enableWebRTC: false, autoReconnect: false })
|
|
278
279
|
await client.connect()
|
|
279
280
|
try {
|
|
280
281
|
try { await identifyAsDevice(client, device, { cert, acta }) } catch (_) { /* sin identify seguimos: solo perdemos la cola */ }
|
|
281
282
|
const signed = { ...data, publickey: device.publickey, ts: Date.now() }
|
|
282
|
-
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, privateKey: device.privateKey, publickey: device.publickey, data: signed })
|
|
283
|
+
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, privateKey: device.privateKey, publickey: device.publickey, sign: device.sign, data: signed })
|
|
283
284
|
const pending = new Promise((resolve, reject) => {
|
|
284
285
|
let graceTimer = null
|
|
285
286
|
const off = client.on('message', (_f, p) => {
|
|
@@ -325,7 +326,7 @@ async function vaultRpc ({ master, proxy, device, cert, acta = null, sendType, o
|
|
|
325
326
|
* nada, como cualquier otro mensaje sin firma.
|
|
326
327
|
*/
|
|
327
328
|
export async function checkMembership ({ master, proxy, device, onRevoked, timeoutMs = 12000 } = {}) {
|
|
328
|
-
if (!master || !proxy || !(device?.privateJwk || device?.privateKey)) throw new Error('missing device data')
|
|
329
|
+
if (!master || !proxy || !(device?.privateJwk || device?.privateKey || typeof device?.sign === 'function')) throw new Error('missing device data')
|
|
329
330
|
const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
|
|
330
331
|
const client = new WebSocketProxyClient({ url: proxy, enableWebRTC: false, autoReconnect: false })
|
|
331
332
|
await client.connect()
|
|
@@ -334,7 +335,7 @@ export async function checkMembership ({ master, proxy, device, onRevoked, timeo
|
|
|
334
335
|
// estaba apagado, si todavía está dentro de las 24 h).
|
|
335
336
|
try { await identifyAsDevice(client, device) } catch (_) {}
|
|
336
337
|
const data = { op: 'check', publickey: device.publickey, ts: Date.now() }
|
|
337
|
-
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, privateKey: device.privateKey, publickey: device.publickey, data })
|
|
338
|
+
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, privateKey: device.privateKey, publickey: device.publickey, sign: device.sign, data })
|
|
338
339
|
const res = await new Promise((resolve) => {
|
|
339
340
|
let settled = false
|
|
340
341
|
const done = (v) => { if (!settled) { settled = true; cleanup(); resolve(v) } }
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
Copia vendorizada de @dotrino/proxy-client@0.
|
|
1
|
+
Copia vendorizada de @dotrino/proxy-client@0.24.0 (dotrino-proxy-client/src/{index,client,signature,canonical,sealing,encpub,webrtc}.js).
|
|
2
2
|
NO se edita a mano: la escribe `node vendor.mjs` y la vigila test/vendor-up-to-date.test.mjs.
|
|
3
3
|
sealing.js resuelve @dotrino/identity/content de forma PEREZOSA (= ../../content.js
|
|
4
4
|
por el import map): solo se carga si de verdad se sella algo.
|
|
@@ -437,6 +437,12 @@ export class WebSocketProxyClient {
|
|
|
437
437
|
* señalización WebRTC, presencia—: entregar eso mañana no es tarde, es
|
|
438
438
|
* incorrecto (reinicia negociaciones imposibles y muestra movimientos fuera de
|
|
439
439
|
* contexto). NO lo uses para mensajes de chat, que sí quieren esperar.
|
|
440
|
+
*
|
|
441
|
+
* `opts.quiet` hace lo contrario de despertar: el mensaje SE GUARDA en la cola igual,
|
|
442
|
+
* pero el proxio no toca el timbre push del destinatario. Úsalo para lo que puede
|
|
443
|
+
* esperar a que la otra punta abra por su cuenta —un aviso de que algo cambió—. Sin
|
|
444
|
+
* él, cada aviso hace sonar el teléfono, y un timbre que no trae nada que hacer
|
|
445
|
+
* enseña a ignorar el siguiente, que sí lo trae.
|
|
440
446
|
*/
|
|
441
447
|
/**
|
|
442
448
|
* Seal a payload towards a peer's encryption key and send it. This is what an app
|
|
@@ -708,6 +714,7 @@ export class WebSocketProxyClient {
|
|
|
708
714
|
message: typeof payload === 'string' ? payload : JSON.stringify(payload)
|
|
709
715
|
}
|
|
710
716
|
if (opts.ephemeral) msg.ephemeral = true
|
|
717
|
+
if (opts.quiet) msg.quiet = true
|
|
711
718
|
this._sendRaw(msg)
|
|
712
719
|
}
|
|
713
720
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
Copia vendorizada de @dotrino/vault@0.
|
|
1
|
+
Copia vendorizada de @dotrino/vault@0.72.0 (dotrino-vault/lib/src/{index,enroll,protocol,passwordLogins,loginClient,b64}.js).
|
|
2
2
|
NO se edita a mano: la escribe `node vendor.mjs` y la vigila test/vendor-up-to-date.test.mjs.
|
|
3
3
|
index.js importa ./enroll.js y ./protocol.js (relativos, van en esta misma copia),
|
|
4
4
|
@dotrino/identity/{capabilities,acta} (= ../../{capabilities,acta}.js) y
|