@dotrino/identity 0.99.0 → 0.101.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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/identity",
3
- "version": "0.99.0",
3
+ "version": "0.101.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",
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/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
- if (!certDesfasadoDelActa()) return
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 poda `loadDelegations`. El histórico retirado va aparte, en `revokedCerts`.
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/vault.js CHANGED
@@ -405,7 +405,7 @@ import { pubkeyId } from './capabilities.js'
405
405
  * la bóveda guarda un paquete que no puede abrir. Es el mismo camino que `logins add`
406
406
  * del binario, con la misma pieza compartida.
407
407
  */
408
- selfVaultLoginAdd: async ({ user, password, label = '', scope = null, unattended = false } = {}) => {
408
+ selfVaultLoginAdd: async ({ user, password, label = '', caps } = {}) => {
409
409
  const d = pidaDaemon()
410
410
  const { client: opaque } = await import('@dotrino/opaque')
411
411
  const { makeDeviceKey, makeDeviceEncKey } = await import('@dotrino/identity/capabilities')
@@ -422,7 +422,8 @@ import { pubkeyId } from './capabilities.js'
422
422
  const blob = await sealDeviceKeys(fin.exportKey, { sign: device.privateJwk, enc: enc.encPrivateJwk })
423
423
  const r = await d.loginRegisterFinish({
424
424
  user, upload: fin.upload, pub: device.publickey, encPub: enc.encPublickey,
425
- label: nombre, blob, ...(Array.isArray(scope) && scope.length ? { scope } : {}), unattended: !!unattended
425
+ // Los PERMISOS del acta, cualquiera de ellos: los traduce a scopes el pilar del vault.
426
+ label: nombre, blob, ...(caps ? { caps } : {})
426
427
  })
427
428
  return { ...r, address: loginAddress(user, await accountFingerprint(selfIdentity)) }
428
429
  },
@@ -1,4 +1,4 @@
1
- Copia vendorizada de @dotrino/vault@0.68.0 (dotrino-vault/lib/src/{index,enroll,protocol,passwordLogins,loginClient,b64}.js).
1
+ Copia vendorizada de @dotrino/vault@0.71.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
@@ -34,7 +34,7 @@
34
34
  * Si algo de eso falla, se PARA con un `code` propio. No hay repliegue: entrar «a medias»
35
35
  * en una cuenta es peor que no entrar.
36
36
  */
37
- import { client as opaque } from '@dotrino/opaque'
37
+ import { client as opaquePorDefecto } from '@dotrino/opaque'
38
38
  import { pubkeyId, verifyDeviceSig, signWithDevice } from '@dotrino/identity/capabilities'
39
39
  import { checkVaultReply } from '@dotrino/identity/acta'
40
40
  import { MSG } from './protocol.js'
@@ -82,11 +82,16 @@ const parseJson = (s) => { try { return JSON.parse(s) } catch (_) { return null
82
82
  * @param {string} opts.password
83
83
  * @param {string} [opts.label] de dónde se entra («el cyber de la esquina»): es lo que
84
84
  * el dueño va a leer en su consola para decidir si cerrarlo.
85
+ * @param {object} [opts.opaque] el OPAQUE a usar. Por defecto el de casa, que va en este
86
+ * mismo hilo. Se inyecta porque en una EXTENSIÓN el WASM vive en una página sandbox, al
87
+ * otro lado de un `postMessage`, y entonces sus métodos devuelven promesas — igual que en
88
+ * `createLoginDesk`, y por el mismo motivo. Aquí se espera siempre: esperar a algo que no
89
+ * es una promesa no cuesta nada, y dos firmas serían dos clientes.
85
90
  * @returns {Promise<{user:string,address:string,code:string,sid:string,cert:object,
86
91
  * iss:string,acta:object,vaultToken:string,publickey:string,encPublickey:(string|null),
87
92
  * keys:{sign:object,enc:(object|null)}}>}
88
93
  */
89
- export async function loginWithPassword ({ transport, address, password, label = '', timeoutMs = REPLY_TIMEOUT_MS } = {}) {
94
+ export async function loginWithPassword ({ transport, address, password, label = '', timeoutMs = REPLY_TIMEOUT_MS, opaque = opaquePorDefecto } = {}) {
90
95
  if (!transport || typeof transport.on !== 'function' || typeof transport.send !== 'function') {
91
96
  throw err('no-transport', 'loginWithPassword: a connected transport is required')
92
97
  }
@@ -104,7 +109,7 @@ export async function loginWithPassword ({ transport, address, password, label =
104
109
  let ultimo = null
105
110
  for (const token of tokens) {
106
111
  try {
107
- return await unIntento({ transport, token, user, code, dir, password, label, timeoutMs })
112
+ return await unIntento({ transport, token, user, code, dir, password, label, timeoutMs, opaque })
108
113
  } catch (e) {
109
114
  // Se para: son cosas del que entra, y probar con otra bóveda no las arregla.
110
115
  if (e?.code === 'login-failed' || e?.code === 'too-many-tries' || e?.code === 'wrong-account' ||
@@ -115,15 +120,15 @@ export async function loginWithPassword ({ transport, address, password, label =
115
120
  throw ultimo || err('no-vault', 'none of the vaults on that address could let you in')
116
121
  }
117
122
 
118
- async function unIntento ({ transport, token, user, code, dir, password, label, timeoutMs }) {
119
- const start = opaque.loginStart({ password })
123
+ async function unIntento ({ transport, token, user, code, dir, password, label, timeoutMs, opaque }) {
124
+ const start = await opaque.loginStart({ password })
120
125
  transport.send(token, { type: MSG.LOGIN_START, user, request: start.request })
121
126
  const r1 = await waitFor(transport, token, (p) => p.type === MSG.LOGIN_RESPONSE || p.type === MSG.ERROR, timeoutMs)
122
127
  if (r1.type === MSG.ERROR) throw deVuelta(r1)
123
128
 
124
129
  let fin
125
130
  try {
126
- fin = opaque.loginFinish({ state: start.state, response: r1.response, password })
131
+ fin = await opaque.loginFinish({ state: start.state, response: r1.response, password })
127
132
  } catch (_) {
128
133
  // OPAQUE comprueba a las dos partes: aquí se sabe que el otro lado NO conoce el
129
134
  // registro de este usuario. Contraseña equivocada, usuario que no existe o una bóveda
@@ -198,11 +203,26 @@ function deVuelta (p) {
198
203
  * quedarse bloqueado esperándola. La sesión sigue abierta allí hasta que se cierre desde la
199
204
  * consola, y eso ya está dicho en el diseño (§3.2).
200
205
  */
201
- export async function closeLogin ({ transport, token, user, sid, publickey, privateJwk, privateKey, timeoutMs = REPLY_TIMEOUT_MS } = {}) {
206
+ export async function closeLogin ({ transport, token, user, sid, publickey, privateJwk, privateKey, sign, timeoutMs = REPLY_TIMEOUT_MS } = {}) {
202
207
  if (!transport || typeof transport.send !== 'function') throw err('no-transport', 'closeLogin: transport required')
203
208
  if (!token || !user || !sid || !publickey) throw err('bad-input', 'closeLogin: token, user, sid and publickey are required')
204
209
  const data = { op: 'login.close', publickey, user, sid, ts: Date.now() }
205
- const { signature } = await signWithDevice({ privateJwk, privateKey, publickey, data })
210
+ // CON LA LLAVE O CON QUIEN LA TIENE. `sign(data)` es lo normal en el ecosistema —el
211
+ // transporte se identifica con `identity.signData` y no con una llave suelta— y aquí hace
212
+ // falta de verdad: en una extensión la llave vive en el service worker y quien tiene el
213
+ // socket es la página, así que o se firma a distancia o la llave privada tendría que
214
+ // cruzar para nada. Sin ninguna de las dos no se firma, y salir no se puede fingir.
215
+ //
216
+ // Se comprueba ANTES de firmar: sin nada con qué hacerlo, WebCrypto revienta con un error
217
+ // suyo que no dice qué falta («code: 0»), y el de arriba es el que se puede buscar.
218
+ if (typeof sign !== 'function' && !privateJwk && !privateKey) {
219
+ throw err('no-signature', 'closeLogin: give it the device key (privateJwk/privateKey) or a sign(data)')
220
+ }
221
+ const firmado = typeof sign === 'function'
222
+ ? await sign(data)
223
+ : await signWithDevice({ privateJwk, privateKey, publickey, data })
224
+ const signature = typeof firmado === 'string' ? firmado : firmado?.signature
225
+ if (typeof signature !== 'string') throw err('no-signature', 'closeLogin: sign(data) returned no signature')
206
226
  transport.send(token, { type: MSG.LOGIN_CLOSE, data, signature })
207
227
  try {
208
228
  const r = await waitFor(transport, token, (p) => p.type === MSG.LOGIN_CLOSED || p.type === MSG.ERROR, timeoutMs)
@@ -26,8 +26,8 @@
26
26
  */
27
27
  import { server as opaquePorDefecto, suiteId as suitePorDefecto } from '@dotrino/opaque'
28
28
  import { pubkeyId } from '@dotrino/identity/capabilities'
29
- import { deviceIdOf, scopeToCaps, scopeToCn } from './enroll.js'
30
- import { SCOPE } from './protocol.js'
29
+ import { DEVICE_CAPS, capScope } from '@dotrino/identity/acta'
30
+ import { deviceIdOf } from './enroll.js'
31
31
  import { bytesToB64url, b64urlToBytes } from './b64.js'
32
32
 
33
33
  /** Un usuario es la parte de antes de la `@` en `nombre@AB12-CD34-EF56`. */
@@ -445,24 +445,35 @@ export function createLoginDesk ({ load, save, now = () => Date.now(), opaque =
445
445
  * @param {object} opts
446
446
  * `identity` la identidad que firma (`signDelegation`, `admitMember`)
447
447
  * `logins` el escritorio de `createLoginDesk`
448
- * `scope` permisos del aparato; por defecto firmar, leer y guardar
449
- * `unattended` si además puede trabajar sin que nadie apruebe
448
+ * `caps` los PERMISOS del acta que lleva el aparato, cualquiera de `DEVICE_CAPS`;
449
+ * por defecto firmar, leer y guardar
450
450
  */
451
451
  export async function registerLogin ({
452
452
  identity, logins, user, upload, pub, encPub = null, label = '', blob,
453
- scope, unattended = false, replace = false
453
+ caps = ['sign', 'read', 'store'], scope, replace = false
454
454
  } = {}) {
455
455
  if (replace) {
456
456
  await logins.registerFinish({ user, upload, blob, replace: true })
457
457
  return { ok: true, user, replaced: true }
458
458
  }
459
- // PERMISOS, no tipos: los del scope, más `unattended` si quien lo crea lo eligió
460
- // (temporary-access.md §3.1). `passwords` no entra todavía — hasta que existan las
461
- // contraseñas selladas, este aparato se llevaría TODAS (sealed-passwords.md).
462
- const scopes = Array.isArray(scope) && scope.length ? scope : [SCOPE.SIGN, SCOPE.READ, SCOPE.STORE]
463
- if (scopes.includes(SCOPE.PASSWORDS)) {
464
- throw Object.assign(new Error('a password login cannot take `contrasenas` yet: the sealed passwords come first'), { code: 'passwords-not-yet' })
459
+ // CUALQUIER PERMISO, y todos por el mismo camino (dueño, 2026-09-21: «al crear debe
460
+ // poder asignársele cualquier permiso»; regla dura «simplificar los procesos»). Antes
461
+ // llegaban como scopes del certificado con `unattended` aparte, `passwords` se rechazaba
462
+ // con `passwords-not-yet` y `passkeys` —que no tiene scope— no se podía asignar.
463
+ //
464
+ // `scope` ya no se acepta, y se dice: ignorarlo daría al aparato los permisos por defecto
465
+ // en vez de los que se eligieron, y nadie lo notaría.
466
+ if (scope !== undefined) {
467
+ throw Object.assign(new Error('registerLogin takes `caps` (account record permissions), not `scope`'), { code: 'use-caps' })
465
468
  }
469
+ const pedidos = [...new Set(Array.isArray(caps) ? caps : [])]
470
+ const raros = pedidos.filter((c) => !DEVICE_CAPS.includes(c))
471
+ if (!pedidos.length || raros.length) {
472
+ throw Object.assign(new Error(`not device permissions: ${raros.join(', ') || '(none)'} — expected any of ${DEVICE_CAPS.join(', ')}`), { code: 'bad-caps' })
473
+ }
474
+ // El certificado lleva el scope de los permisos que lo tienen; los demás (`unattended`,
475
+ // `passkeys`) viven solo en el acta, que es quien decide.
476
+ const scopes = pedidos.map((c) => capScope(c)).filter(Boolean)
466
477
  if (typeof identity?.admitMember !== 'function') {
467
478
  throw Object.assign(new Error('this vault cannot add devices to the account record: no login was created'), { code: 'admit-unavailable' })
468
479
  }
@@ -470,11 +481,9 @@ export async function registerLogin ({
470
481
  await logins.registerFinish({ user, upload, pub, encPub, deviceId, label, blob })
471
482
  try {
472
483
  const { cert } = await identity.signDelegation(pub, scopes, { label: label || `login:${user}` })
473
- const cn = scopeToCn(scopes)
474
- const caps = [...new Set([...scopeToCaps(scopes), ...(unattended ? ['unattended'] : [])])]
475
- await identity.admitMember({ pub, encPub, label: label || `login:${user}`, cn, caps, cert })
484
+ await identity.admitMember({ pub, encPub, label: label || `login:${user}`, cn: null, caps: pedidos, cert })
476
485
  logins.setCert({ user, cert, iss: identity.me?.publickey || null })
477
- return { ok: true, user, deviceId, cert, caps }
486
+ return { ok: true, user, deviceId, cert, caps: pedidos }
478
487
  } catch (e) {
479
488
  // NADA DE MEDIAS ALTAS: si no entra en el acta, no queda un usuario que pueda entrar a
480
489
  // una cuenta que no lo reconoce. Se deshace y se dice.