@dotrino/identity 0.61.0 → 0.63.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.61.0",
3
+ "version": "0.63.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",
@@ -39,7 +39,7 @@
39
39
  "LICENSE"
40
40
  ],
41
41
  "scripts": {
42
- "test": "node --test test/*.test.js",
42
+ "test": "node --test \"test/*.test.js\" \"test/*.test.mjs\"",
43
43
  "type-check": "tsc --noEmit"
44
44
  },
45
45
  "keywords": [
@@ -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
@@ -6,9 +6,16 @@
6
6
  * Diseño completo y decisiones en `dotrino-vault/docs/acta-de-perfil.md`.
7
7
  *
8
8
  * Las cuatro reglas que este módulo hace cumplir:
9
- * 1. **Un solo sellador** (el «master»). Solo el sellador vigente puede producir la
10
- * siguiente acta. Como las llaves son intransferibles no se puede clonar → dos actas
11
- * legítimas con el mismo `seq` son imposibles: no hay bifurcaciones que resolver.
9
+ * 1. **Solo sella quien el acta nombra.** Normalmente uno —`sealer`, el master—, y
10
+ * entonces dos actas legítimas con el mismo `seq` son imposibles: no hay
11
+ * bifurcaciones que resolver.
12
+ * **MULTIVAULT** (dueño, 2026-08-30): se pueden nombrar selladores adicionales en
13
+ * `cosealers`, para que perder una bóveda no se lleve la cuenta por delante. Ahí la
14
+ * imposibilidad pasa de criptográfica a práctica —«no suelen estar las dos abiertas
15
+ * a la vez»—, así que el empate deja de ser imposible y pasa a ser raro. Lo resuelve
16
+ * `canAdopt` con las reglas que ya existían: gana el traspaso, y si no, la de hash
17
+ * menor. Determinista, sin relojes y sin votación. Lo que NO resuelve: el que pierde
18
+ * el desempate **pierde su cambio**, y quien lo selló tiene que enterarse (§2.4.1.5).
12
19
  * 2. **`seq` monotónico + `prev`** (hash del acta anterior): cadena verificable.
13
20
  * 3. **Nunca dejar el perfil sin quien firme**: un cambio que quite el último `sign` se
14
21
  * rechaza.
@@ -42,7 +49,15 @@ export const ACTA_V = 2
42
49
  const ACTA_LEIBLES = Object.freeze([1, 2])
43
50
 
44
51
  /**
45
- * Lista CERRADA de capacidades. Sellar y admitir no están: eso es ser el master.
52
+ * Lista CERRADA de capacidades.
53
+ *
54
+ * `sealer` es SELLAR EL ACTA, y desde 2026-08-30 es un permiso como los demás — se da y se
55
+ * quita con `caps <ID> +sella`. Antes no existía: sellar era «ser el master», y punto. Se
56
+ * abrió para el **multivault**: que una segunda bóveda pueda sellar hace que perder una
57
+ * máquina no se lleve la cuenta por delante. Cuidado con no confundirlo con el CAMPO
58
+ * `acta.sealer`, que es otra cosa: el campo dice **quién manda** (y solo cambia en un
59
+ * traspaso); el permiso dice **quién puede firmar el acta**. Admitir sigue sin ser un
60
+ * permiso: se admite sellando.
46
61
  *
47
62
  * `secrets` es distinta de las otras: solo la pueden tener los miembros con **CN**
48
63
  * (los servicios), y lo que abre es únicamente el cajón de SU nombre. Ver `cn` abajo.
@@ -53,7 +68,7 @@ const ACTA_LEIBLES = Object.freeze([1, 2])
53
68
  * el rol de master, que no se delega. Así un dispositivo con `admin` robado hace daño
54
69
  * acotado y **reversible** (se le revoca), en vez de poder dejarte fuera de tu cuenta.
55
70
  */
56
- export const CAPS = Object.freeze(['sign', 'store', 'read', 'secrets', 'admin', 'approve', 'passwords'])
71
+ export const CAPS = Object.freeze(['sign', 'store', 'read', 'secrets', 'admin', 'approve', 'passwords', 'sealer'])
57
72
 
58
73
  /** Capacidades de un DISPOSITIVO (sin CN): acceso a todo lo del usuario. */
59
74
  /**
@@ -67,7 +82,7 @@ export const CAPS = Object.freeze(['sign', 'store', 'read', 'secrets', 'admin',
67
82
  * pedirle algo a la bóveda es exactamente lo que decide el acta — tener dos registros
68
83
  * de lo mismo obliga a acordarse de los dos al quitar un aparato.
69
84
  */
70
- export const DEVICE_CAPS = Object.freeze(['sign', 'store', 'read', 'admin', 'approve', 'passwords'])
85
+ export const DEVICE_CAPS = Object.freeze(['sign', 'store', 'read', 'admin', 'approve', 'passwords', 'sealer'])
71
86
 
72
87
  /**
73
88
  * Lo que recibe un dispositivo recién emparejado. `admin` **no está**: no se
@@ -100,12 +115,13 @@ export function capScope (cap, cn = null) {
100
115
  if (cap === 'admin') return 'vault:admin'
101
116
  if (cap === 'approve') return 'vault:approve'
102
117
  if (cap === 'passwords') return 'vault:passwords'
118
+ if (cap === 'sealer') return 'vault:sealer'
103
119
  if (cap === 'secrets') return isValidCn(cn) ? 'vault:secrets:' + cn : null
104
120
  return null
105
121
  }
106
122
 
107
123
  /** Compat: el mapa directo, para las capacidades de dispositivo. */
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' })
124
+ export const CAP_SCOPE = Object.freeze({ sign: 'vault:sign', store: 'vault:store', read: 'vault:read', admin: 'vault:admin', approve: 'vault:approve', passwords: 'vault:passwords', sealer: 'vault:sealer' })
109
125
 
110
126
  const enc = (s) => new TextEncoder().encode(s)
111
127
  const hex = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('')
@@ -147,6 +163,44 @@ export async function memberId (pub) {
147
163
  }
148
164
 
149
165
  /** ¿Esta acta es un traspaso? (la firmó uno y nombra master a otro) */
166
+ /**
167
+ * QUIÉNES PUEDEN SELLAR ESTA ACTA: quien manda (`sealer`) más todo miembro con el permiso
168
+ * `sealer`. Normalmente solo el primero, y entonces dos actas legítimas con el mismo `seq`
169
+ * son imposibles (D4) — de ahí salía «no hay merge, ni precedencia, ni votación».
170
+ *
171
+ * **MULTIVAULT** (dueño, 2026-08-30): dándole el permiso a otra bóveda, perder una máquina
172
+ * deja de llevarse la cuenta. El precio es que la imposibilidad baja de criptográfica a
173
+ * práctica —«no suelen estar las dos abiertas a la vez»—, así que el empate ya no es
174
+ * imposible: es raro. Lo resuelve `canAdopt` con las reglas que ya existían: gana el
175
+ * traspaso, y si no, la de hash menor. Determinista, sin relojes y sin votación. Lo que NO
176
+ * resuelve: el que pierde el desempate **pierde su cambio**, y quien selló tiene que
177
+ * enterarse (§2.4.1 punto 5).
178
+ *
179
+ * Es un PERMISO y no un campo aparte, y eso se nota en todo lo que ya no hace falta
180
+ * escribir: se da y se quita con `caps`, quitar al miembro se lo lleva, y no hay forma de
181
+ * nombrar sellador a quien no está en la cuenta.
182
+ */
183
+ export const sealersOf = (acta, renounces = []) => {
184
+ if (!acta) return []
185
+ const extra = (acta.members || [])
186
+ .filter((m) => m.pub !== acta.sealer && memberCan(acta, m.pub, 'sealer', renounces))
187
+ .map((m) => m.pub)
188
+ return [acta.sealer, ...extra]
189
+ }
190
+
191
+ /** ¿Puede `pub` sellar la siguiente acta de este perfil? */
192
+ export const canSeal = (acta, pub, renounces = []) =>
193
+ !!acta && (pub === acta.sealer || memberCan(acta, pub, 'sealer', renounces))
194
+
195
+ /**
196
+ * El acta la firmó alguien DISTINTO del sellador que ella misma nombra.
197
+ *
198
+ * ⚠️ Desde el multivault esto ya NO significa «es un traspaso», y confundirlo costó un
199
+ * desempate mal resuelto: cuando sella la segunda bóveda, el acta sigue nombrando
200
+ * sellador a la primera y la firma la segunda, así que esto da `true` sin que nadie
201
+ * traspase nada. Es condición NECESARIA de un traspaso, no suficiente — lo que lo
202
+ * distingue es que un traspaso CAMBIA a quién nombra (ver `canAdopt`).
203
+ */
150
204
  export const isHandover = (acta) => !!acta && acta.sealer !== acta.sealedBy
151
205
 
152
206
  /**
@@ -262,6 +316,7 @@ export async function verifyActa ({ acta, expectedProfileId } = {}) {
262
316
  *
263
317
  * Cambios: `{op:'admit', member}` · `{op:'caps', pub, caps}` · `{op:'remove', pub}` ·
264
318
  * `{op:'handover', to}` · `{op:'revoke', nonce, until}` · `{op:'renounce', record}`.
319
+ * Sellar se concede con `{op:'caps'}` como cualquier otro permiso.
265
320
  * Van en ARRAY porque hay combinaciones que deben ser atómicas: admitir al nuevo sellador y
266
321
  * traspasarle el master ocurre en el MISMO `seq` (§2.1.3).
267
322
  */
@@ -269,13 +324,15 @@ export async function applyChanges (acta, changes, { by, now = Date.now(), sealP
269
324
  const shape = checkShape(acta)
270
325
  if (shape) throw new Error('invalid record: ' + shape)
271
326
  if (!by) throw new Error('applyChanges: missing `by` (who seals)')
272
- if (by !== acta.sealer) throw new Error('only the master can change the record; this device is not the master')
327
+ // El mensaje conserva «not the master» a propósito: es una cadena que hay código y
328
+ // pruebas emparejando, y cambiarla en silencio es justo lo que rompe cosas sin ruido.
329
+ if (!canSeal(acta, by)) throw new Error('only a sealer named by the record can change it: this device is not the master, nor a co-sealer')
273
330
 
274
331
  const list = (Array.isArray(changes) ? changes : [changes]).filter(Boolean)
275
332
  // Estrenar la llave de sellado ES un cambio del acta, aunque no toque a ningún miembro:
276
333
  // rota con el acta (§8.9) y el acta es lo que le da autoridad. Por eso una lista vacía
277
334
  // se acepta si viene con llave nueva, y solo entonces.
278
- if (list.length === 0 && !sealPub) throw new Error('applyChanges: no hay cambios')
335
+ if (list.length === 0 && !sealPub) throw new Error('applyChanges: no changes')
279
336
 
280
337
  const next = {
281
338
  ...acta,
@@ -315,7 +372,7 @@ export async function applyChanges (acta, changes, { by, now = Date.now(), sealP
315
372
  switch (ch?.op) {
316
373
  case 'admit': {
317
374
  const m = ch.member
318
- if (!isPub(m?.pub)) throw new Error('admit: falta la pubkey del miembro')
375
+ if (!isPub(m?.pub)) throw new Error('admit: missing member pubkey')
319
376
  if (find(m.pub)) throw new Error('admit: that member is already in the record')
320
377
  const cn = m.cn != null ? String(m.cn) : null
321
378
  if (cn !== null && !isValidCn(cn)) throw new Error('admit: invalid CN (lowercase, digits and hyphens)')
@@ -380,6 +437,7 @@ export async function applyChanges (acta, changes, { by, now = Date.now(), sealP
380
437
  const i = next.members.findIndex((m) => m.pub === ch.pub)
381
438
  if (i < 0) throw new Error('remove: that member is not in the record')
382
439
  if (next.members[i].pub === next.sealer) throw new Error('remove: cannot remove the master; hand the sealing over first')
440
+ // Quitar al miembro se lleva su permiso de sellar: no hay lista aparte que limpiar.
383
441
  const fuera = next.members.splice(i, 1)[0]
384
442
  // Sus envolturas se van con él: sin ellas no puede abrir ninguna generación. (El
385
443
  // acceso al contenido FUTURO se corta rotando, ver content.js; lo ya leído no vuelve.)
@@ -409,7 +467,7 @@ export async function applyChanges (acta, changes, { by, now = Date.now(), sealP
409
467
  break
410
468
  }
411
469
  case 'revoke': {
412
- if (typeof ch.nonce !== 'string') throw new Error('revoke: falta el nonce')
470
+ if (typeof ch.nonce !== 'string') throw new Error('revoke: missing nonce')
413
471
  next.revoked.push({ nonce: ch.nonce, until: ch.until || (now + 30 * 24 * 60 * 60 * 1000) })
414
472
  break
415
473
  }
@@ -424,7 +482,7 @@ export async function applyChanges (acta, changes, { by, now = Date.now(), sealP
424
482
  break
425
483
  }
426
484
  default:
427
- throw new Error('cambio desconocido: ' + ch?.op)
485
+ throw new Error('unknown change: ' + ch?.op)
428
486
  }
429
487
  }
430
488
 
@@ -459,7 +517,7 @@ export function sealKeyAt (acta, seq) {
459
517
  /** Crea el registro firmado con el que un miembro se QUITA capacidades a sí mismo. */
460
518
  export async function makeRenounce ({ member, caps, privateKey, privateJwk, now = Date.now() }) {
461
519
  const body = { op: 'renounce', member, caps: cleanCaps(caps), ts: now }
462
- if (body.caps.length === 0) throw new Error('renuncia: no hay capacidades que quitar')
520
+ if (body.caps.length === 0) throw new Error('resign: no capabilities to remove')
463
521
  const { signature } = await signWithDevice({ privateKey, privateJwk, publickey: member, data: body })
464
522
  return { ...body, sig: signature }
465
523
  }
@@ -629,7 +687,7 @@ export async function canAdopt ({ candidate, current }) {
629
687
  if (!current) return { adopt: true, reason: 'sin-acta-previa' }
630
688
 
631
689
  if (candidate.seq > current.seq) {
632
- if (candidate.sealedBy !== current.sealer) return { adopt: false, reason: 'sellador-no-autorizado' }
690
+ if (!canSeal(current, candidate.sealedBy)) return { adopt: false, reason: 'sellador-no-autorizado' }
633
691
  if (candidate.seq === current.seq + 1 && candidate.prev !== await actaHash(current)) {
634
692
  return { adopt: false, reason: 'no-encadena' }
635
693
  }
@@ -639,9 +697,31 @@ export async function canAdopt ({ candidate, current }) {
639
697
  if (candidate.seq === current.seq) {
640
698
  const [hCan, hCur] = [await actaHash(candidate), await actaHash(current)]
641
699
  if (hCan === hCur) return { adopt: false, reason: 'misma-acta' }
642
- if (candidate.sealedBy !== current.sealedBy) return { adopt: false, reason: 'otro-sellador' }
643
- const canT = isHandover(candidate)
644
- const curT = isHandover(current)
700
+ // A igual `seq`, con multivault, las dos ramas pueden venir de selladores DISTINTOS y
701
+ // ser las dos legítimas. Antes era imposible y se rechazaba de plano; ahora se
702
+ // desempata con las reglas de abajo, que ya estaban escritas.
703
+ //
704
+ // Se conserva primero la comparación de siempre (`mismo sealedBy`) y no se sustituye:
705
+ // en un TRASPASO las dos ramas las firmó el saliente, y `current` ya nombra al
706
+ // entrante como `sealer` — así que preguntarle a `current` si el saliente puede
707
+ // sellar da «no», y se perdería el desempate «gana la que traspasa». La pregunta
708
+ // correcta es por el acta PADRE, que aquí no se tiene; tener el mismo `sealedBy` es
709
+ // exactamente lo que dice que las dos salieron de ella.
710
+ const mismoFirmante = candidate.sealedBy === current.sealedBy
711
+ if (!mismoFirmante && !canSeal(current, candidate.sealedBy)) return { adopt: false, reason: 'otro-sellador' }
712
+ // UN TRASPASO ES EL QUE CAMBIA A QUIÉN NOMBRA, no el que firma otro. Con una sola
713
+ // bóveda daba igual —solo un traspaso podía firmar sin ser el sellador—, pero con dos
714
+ // la segunda firma sin serlo cada vez que sella, y entonces TODA acta suya se leía
715
+ // como un traspaso y ganaba el desempate por la puerta de atrás.
716
+ //
717
+ // Las dos ramas salen del mismo padre, así que comparar a quién nombra cada una
718
+ // resuelve los tres casos: si nombran al mismo (dos coselladores trabajando), no hay
719
+ // traspaso y decide el hash; si una nombra a otro, esa es la que traspasa y gana; y
720
+ // si las dos traspasan a destinos distintos, vuelven a empatar y decide el hash,
721
+ // que es lo que §2.4.1 ya decía.
722
+ const mismoSellador = candidate.sealer === current.sealer
723
+ const canT = isHandover(candidate) && !mismoSellador
724
+ const curT = isHandover(current) && !mismoSellador
645
725
  if (canT && !curT) return { adopt: true, reason: 'traspaso-gana' }
646
726
  if (!canT && curT) return { adopt: false, reason: 'traspaso-gana' }
647
727
  return hCan < hCur ? { adopt: true, reason: 'desempate-hash' } : { adopt: false, reason: 'desempate-hash' }
@@ -654,6 +734,6 @@ export default {
654
734
  ACTA_V, CAPS, CAP_SCOPE, genesisActa, actaBody, actaHash, memberId, checkShape, isHandover,
655
735
  sealActa, verifyActa, applyChanges, makeRenounce, verifyRenounce,
656
736
  makeContinuity, verifyContinuity,
657
- cardBody, makeProfileCard, verifyProfileCard, canAdoptCard,
737
+ cardBody, makeProfileCard, verifyProfileCard, canAdoptCard, sealersOf, canSeal,
658
738
  effectiveCaps, memberCan, memberCanReadSecrets, memberScopes, isService, capScope, isValidCn, canAdopt
659
739
  }
@@ -155,7 +155,7 @@ export async function makeDeviceEncKey () {
155
155
  * dice nada útil.
156
156
  */
157
157
  export async function importDeviceEncKey (encPrivateJwk) {
158
- if (!encPrivateJwk || typeof encPrivateJwk !== 'object') throw new Error('importDeviceEncKey: falta el JWK de la privada')
158
+ if (!encPrivateJwk || typeof encPrivateJwk !== 'object') throw new Error('importDeviceEncKey: missing private JWK')
159
159
  return crypto.subtle.importKey('jwk', encPrivateJwk, ECDH, false, ['deriveBits'])
160
160
  }
161
161
 
@@ -178,7 +178,7 @@ export async function signWithDevice ({ privateJwk, privateKey, publickey, data
178
178
  // `privateKey` (CryptoKey, posiblemente NO extractable) tiene prioridad: firma
179
179
  // sin tocar bytes de la privada. Con CryptoKey es obligatorio pasar `publickey`.
180
180
  if (privateKey) {
181
- if (!publickey) throw new Error('signWithDevice: con privateKey (CryptoKey) se requiere publickey')
181
+ if (!publickey) throw new Error('signWithDevice: publickey is required when privateKey is a CryptoKey')
182
182
  const signature = await rawSign(privateKey, enc(canonicalStringify(data)))
183
183
  return { signature, publickey }
184
184
  }
package/vault/content.js CHANGED
@@ -54,8 +54,8 @@ export async function makeContentKey () {
54
54
  * @returns {Promise<{epk:string, iv:string, ct:string}>}
55
55
  */
56
56
  export async function wrapForMember ({ cek, memberEncPub }) {
57
- if (typeof cek !== 'string' || !cek) throw new Error('wrapForMember: falta la clave de contenido')
58
- if (typeof memberEncPub !== 'string' || !memberEncPub) throw new Error('wrapForMember: el miembro no tiene llave de cifrado')
57
+ if (typeof cek !== 'string' || !cek) throw new Error('wrapForMember: missing content key')
58
+ if (typeof memberEncPub !== 'string' || !memberEncPub) throw new Error('wrapForMember: member has no encryption key')
59
59
  const eph = await subtle.generateKey(ECDH, false, ['deriveBits'])
60
60
  const key = await sharedKey(eph.privateKey, memberEncPub)
61
61
  const iv = globalThis.crypto.getRandomValues(new Uint8Array(12))
package/vault/core.js CHANGED
@@ -590,7 +590,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
590
590
  try {
591
591
  const { generation } = await Content.makeGeneration({ members: base.members, gen: 1 })
592
592
  base.keyring = [generation]
593
- } catch (e) { console.warn('[identity] no se pudo crear la clave de contenido:', e.message) }
593
+ } catch (e) { console.warn('[identity] could not create the content key:', e.message) }
594
594
  saveActa(await seal(base))
595
595
  return loadActa()
596
596
  }
@@ -716,9 +716,9 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
716
716
  // firmado por ella misma: es el puente para que su reputación previa siga contando.
717
717
  // Solo si esta llave tenía vida propia. Una recién creada para adoptar (camino B) no
718
718
  // tiene pasado que salvar: mandarle un puente de continuidad sería puro ruido.
719
- const mio = loadActa()
720
- const continuity = (mio && mio.members.length === 1 && !isPendingJoin())
721
- ? await Acta.makeContinuity({ member: publickeyJwkStr, from: mio.profileId, privateKey: keypair.privateKey })
719
+ const mine = loadActa()
720
+ const continuity = (mine && mine.members.length === 1 && !isPendingJoin())
721
+ ? await Acta.makeContinuity({ member: publickeyJwkStr, from: mine.profileId, privateKey: keypair.privateKey })
722
722
  : null
723
723
  const res = await remoteEnroll({ qr, device, continuity, encPub: encPublickeyJwkStr, label: label || me?.nickname || '', onChallenge: (c) => emitVault({ phase: 'challenge', deviceId: c.deviceId, code: c.code }) })
724
724
  kv.setItem(VAULT_DEVICE_STORAGE, JSON.stringify({ useIdentityKey: true, publickey: publickeyJwkStr }))
@@ -1022,7 +1022,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1022
1022
  * maestra que dice la propia acta (`sealer`). Si sigue dentro, no pasa nada. Si no, la
1023
1023
  * bóveda contesta con el aviso FIRMADO y aquí se ejecuta el borrado.
1024
1024
  */
1025
- async function preguntarSiSigoDentro () {
1025
+ async function askIfStillAMember () {
1026
1026
  try {
1027
1027
  const acta = loadActa()
1028
1028
  if (!acta || acta.sealer === publickeyJwkStr) return // no hay cuenta ajena que confirmar: mando yo
@@ -1124,7 +1124,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1124
1124
  const tries = (() => { try { return JSON.parse(kv.getItem('dotrino.identity.pwd.tries') || 'null') } catch (_) { return null } })() || { n: 0, at: 0 }
1125
1125
  const waitMs = tries.n >= 5 ? Math.min(2 ** (tries.n - 4) * 1000, 5 * 60 * 1000) : 0
1126
1126
  const left = tries.at + waitMs - Date.now()
1127
- if (left > 0) throw new Error(`demasiados intentos: espera ${Math.ceil(left / 1000)} s`)
1127
+ if (left > 0) throw new Error(`too many attempts: wait ${Math.ceil(left / 1000)} s`)
1128
1128
  const proof = await derivePwd(password, pwd.salt, pwd.iter)
1129
1129
  if (proof !== pwd.verifier) {
1130
1130
  kv.setItem('dotrino.identity.pwd.tries', JSON.stringify({ n: tries.n + 1, at: Date.now() }))
@@ -1137,7 +1137,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1137
1137
  },
1138
1138
  // Poner/cambiar contraseña (requiere estar desbloqueado; cambiar exige la actual vía unlock previo).
1139
1139
  async setProfilePassword ({ password }) {
1140
- if (locked) throw new Error('perfil bloqueado')
1140
+ if (locked) throw new Error('profile locked')
1141
1141
  if (!password || String(password).length < 4) throw new Error('password must be at least 4 characters')
1142
1142
  const salt = b64(crypto.getRandomValues(new Uint8Array(16)))
1143
1143
  const verifier = await derivePwd(password, salt, PWD_ITER)
@@ -1146,7 +1146,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1146
1146
  return { ok: true }
1147
1147
  },
1148
1148
  async removeProfilePassword () {
1149
- if (locked) throw new Error('perfil bloqueado')
1149
+ if (locked) throw new Error('profile locked')
1150
1150
  kv.removeItem(PWD_STORAGE)
1151
1151
  try { sessionKv?.removeItem(_scoped(PWD_SESSION)) } catch (_) {}
1152
1152
  return { ok: true }
@@ -1431,13 +1431,13 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1431
1431
  return { id: pid, name: me.nickname, pubkey: publickeyJwkStr, pendingJoin: !!forVault }
1432
1432
  },
1433
1433
  async switchProfile ({ id } = {}) {
1434
- if (!loadProfiles().find((p) => p.id === id)) throw new Error('perfil no existe')
1434
+ if (!loadProfiles().find((p) => p.id === id)) throw new Error('profile does not exist')
1435
1435
  rawKv.setItem(CURRENT_STORAGE, id) // la app recarga la página → re-init con el nuevo perfil
1436
1436
  return { id }
1437
1437
  },
1438
1438
  async renameProfile ({ id, name } = {}) {
1439
1439
  const list = loadProfiles(); const e = list.find((p) => p.id === (id || currentPid))
1440
- if (!e) throw new Error('perfil no existe')
1440
+ if (!e) throw new Error('profile does not exist')
1441
1441
  e.name = String(name || '').slice(0, 40); saveProfiles(list)
1442
1442
  if (e.id === currentPid) { me = { ...(me || {}), nickname: e.name }; saveMe(me) }
1443
1443
  return { id: e.id, name: e.name }
@@ -1448,7 +1448,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1448
1448
  // dejarte sin ninguna de un clic. La expulsión no pasa por aquí (ver `purgeProfile`):
1449
1449
  // ahí sí se va la última, porque no es un descuido sino que te echaron.
1450
1450
  if (list.length <= 1) throw new Error('cannot delete the only profile')
1451
- if (!list.find((p) => p.id === id)) throw new Error('perfil no existe')
1451
+ if (!list.find((p) => p.id === id)) throw new Error('profile does not exist')
1452
1452
  return purgeProfile(id)
1453
1453
  },
1454
1454
 
@@ -1852,8 +1852,8 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1852
1852
  * que manda hoy.
1853
1853
  */
1854
1854
  async vaultAdopt ({ qr, label = '' } = {}) {
1855
- const mio = loadActa()
1856
- if (!mio) throw new Error('this device has no account to hand over yet')
1855
+ const mine = loadActa()
1856
+ if (!mine) throw new Error('this device has no account to hand over yet')
1857
1857
  if (!amMaster()) throw new Error('not-the-master: another device or vault is in charge of this account; the handover is done from there')
1858
1858
 
1859
1859
  const device = { publickey: publickeyJwkStr, privateKey: keypair.privateKey }
@@ -1861,7 +1861,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1861
1861
  qr,
1862
1862
  device,
1863
1863
  intent: 'adopt',
1864
- profileId: mio.profileId,
1864
+ profileId: mine.profileId,
1865
1865
  encPub: encPublickeyJwkStr,
1866
1866
  label: label || me?.nickname || '',
1867
1867
  onChallenge: (c) => emitVault({ phase: 'challenge', deviceId: c.deviceId, code: c.code }),
@@ -1890,7 +1890,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1890
1890
  const ok = r.adopted || r.reason === 'misma-acta'
1891
1891
  emitVault({ phase: 'adopted', master: res.master, seq: res.acta?.seq, ok })
1892
1892
  if (!ok) throw new Error('the vault returned a record that does not fit: ' + r.reason)
1893
- return { ok: true, adopted: true, profileId: mio.profileId, seq: r.seq ?? res.acta?.seq, master: res.master, deviceId: res.deviceId }
1893
+ return { ok: true, adopted: true, profileId: mine.profileId, seq: r.seq ?? res.acta?.seq, master: res.master, deviceId: res.deviceId }
1894
1894
  },
1895
1895
 
1896
1896
  /**
@@ -2179,7 +2179,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
2179
2179
  const raw = kv.getItem(KEY_STORAGE)
2180
2180
  if (!raw) {
2181
2181
  throw new Error('This profile stores its key as NON-exportable (theft protection). ' +
2182
- 'Para usar tu identidad en otro navegador, conecta ese navegador a tu bóveda (vault) desde profile.dotrino.com.')
2182
+ 'To use this identity in another browser, link that browser to your vault from profile.dotrino.com.')
2183
2183
  }
2184
2184
  const keys = JSON.parse(raw)
2185
2185
  const encRaw = kv.getItem(ENC_KEY_STORAGE)
@@ -2314,13 +2314,13 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
2314
2314
  kv.setItem(ME_STORAGE, JSON.stringify(me))
2315
2315
  }
2316
2316
  // Acta de perfil: si no existe, nace ahora (un miembro, este dispositivo, que es el master).
2317
- try { await ensureActa() } catch (e) { console.warn('[identity] no se pudo crear el acta de perfil:', e.message) }
2317
+ try { await ensureActa() } catch (e) { console.warn('[identity] could not create the profile record:', e.message) }
2318
2318
 
2319
2319
  // Perfil compartido: jalar del vault en background (gana el más nuevo).
2320
2320
  pullProfileFromVault()
2321
2321
  // Y, si a este aparato no le queda papel con el que llamar, preguntar si sigue siendo de
2322
2322
  // la casa. Es lo único que le queda por hacer, y hasta ahora no lo hacía nadie.
2323
- preguntarSiSigoDentro()
2323
+ askIfStillAMember()
2324
2324
 
2325
2325
  // Registrar el pubkey (y nombre) del perfil activo en su meta → para avatar/listado sin abrir cada perfil.
2326
2326
  {
package/vault/remote.js CHANGED
@@ -87,7 +87,7 @@ async function identifyAsDevice (client, device, { cert = null, acta = null } =
87
87
  * que esto no dice que sea TU bóveda — eso lo dice el código de 6 dígitos, que solo
88
88
  * aprende la bóveda donde tú lo tecleas.
89
89
  */
90
- async function verificarHola (p, sn) {
90
+ async function verifyHello (p, sn) {
91
91
  const b = p?.body
92
92
  if (!b?.iss || b.sn !== sn) throw new Error('the vault answered a different pairing')
93
93
  if (!(await verifyDeviceSig({ publickey: b.iss, data: b, signature: p.signature }))) {
@@ -101,7 +101,7 @@ async function verificarHola (p, sn) {
101
101
  * Canjea la CITA del QR: devuelve la dirección real de la conexión de la bóveda. El
102
102
  * código se quema al usarse, así que esto va una sola vez por emparejamiento.
103
103
  */
104
- async function canjearCita (client, code) {
104
+ async function redeemCode (client, code) {
105
105
  const r = await client.redeemPairingCode(code)
106
106
  if (!r?.ok || !r.instance) throw new Error(r?.error || 'that pairing code is no longer valid')
107
107
  return r.instance
@@ -109,15 +109,15 @@ async function canjearCita (client, code) {
109
109
 
110
110
  /** «¿Quién eres?» del QR corto: devuelve `{ iss, proxy, acct, m }` de la bóveda. */
111
111
  async function askVault (client, qr) {
112
- const destino = await canjearCita(client, qr.conn)
112
+ const target = await redeemCode(client, qr.conn)
113
113
  return new Promise((resolve, reject) => {
114
114
  const off = client.on('message', (_from, p) => {
115
- if (p?.type === MSG.HELLO_OK) { fin(); verificarHola(p, qr.sn).then((b) => resolve({ iss: b.iss, proxy: b.proxy || qr.proxy, acct: b.acct || '', m: b.m || qr.m }), reject) }
116
- else if (p?.type === MSG.ERROR) { fin(); reject(new Error(p.error)) }
115
+ if (p?.type === MSG.HELLO_OK) { done(); verifyHello(p, qr.sn).then((b) => resolve({ iss: b.iss, proxy: b.proxy || qr.proxy, acct: b.acct || '', m: b.m || qr.m }), reject) }
116
+ else if (p?.type === MSG.ERROR) { done(); reject(new Error(p.error)) }
117
117
  })
118
- const t = setTimeout(() => { fin(); reject(new Error('the vault did not answer: that code may have expired')) }, 15000)
119
- const fin = () => { off(); clearTimeout(t) }
120
- try { client.send(destino, { type: MSG.HELLO, sn: qr.sn }) } catch (e) { fin(); reject(e) }
118
+ const t = setTimeout(() => { done(); reject(new Error('the vault did not answer: that code may have expired')) }, 15000)
119
+ const done = () => { off(); clearTimeout(t) }
120
+ try { client.send(target, { type: MSG.HELLO, sn: qr.sn }) } catch (e) { done(); reject(e) }
121
121
  })
122
122
  }
123
123
 
@@ -161,11 +161,11 @@ export async function enrollDevice ({ qr, device, onChallenge, label = '', conti
161
161
  // rechaza el que no coincida con el modo con el que ella abrió el emparejamiento. Así
162
162
  // ninguno de los dos puede hacer, a mitad de camino, algo distinto de lo que el humano
163
163
  // vio anunciado en las dos pantallas.
164
- const adoptar = intent === 'adopt'
165
- if (adoptar && typeof onAdopt !== 'function') throw new Error('enrollDevice(adopt): falta onAdopt')
164
+ const adopting = intent === 'adopt'
165
+ if (adopting && typeof onAdopt !== 'function') throw new Error('enrollDevice(adopt): missing onAdopt')
166
166
  const data = {
167
167
  op: 'enroll', dpub: dev.publickey, token: qr.token || qr.sn, sn: qr.sn, commit, label, ts: Date.now(), intent,
168
- ...(adoptar && profileId ? { profileId } : {}),
168
+ ...(adopting && profileId ? { profileId } : {}),
169
169
  ...(continuity ? { continuity } : {}), ...(encPub ? { encPub } : {})
170
170
  }
171
171
  const { signature } = await signWithDevice({ privateJwk: dev.privateJwk, privateKey: dev.privateKey, publickey: dev.publickey, data })
@@ -181,7 +181,7 @@ export async function enrollDevice ({ qr, device, onChallenge, label = '', conti
181
181
  // CAMINO A · la bóveda dice quién es (con el código de vuelta, misma defensa que el
182
182
  // ENROLLED): este aparato la admite en SU acta, le envuelve la clave de contenido y
183
183
  // le traspasa el mando, todo en un solo `seq`, y le manda el acta sellada.
184
- else if (p.type === MSG.ENROLL_ADOPT && adoptar) {
184
+ else if (p.type === MSG.ENROLL_ADOPT && adopting) {
185
185
  if (p.code !== code || sellando) return
186
186
  sellando = true
187
187
  Promise.resolve(onAdopt({ pub: p.pub, encPub: p.encPub || null, label: p.label || '' }))
@@ -190,7 +190,7 @@ export async function enrollDevice ({ qr, device, onChallenge, label = '', conti
190
190
  }
191
191
  // La bóveda ya se vio como sellador y devuelve el acta definitiva (con los certs
192
192
  // re-emitidos, §D9). Es la que este aparato adopta.
193
- else if (p.type === MSG.ACTA_ADOPTED && adoptar) { cleanup(); resolve(p) }
193
+ else if (p.type === MSG.ACTA_ADOPTED && adopting) { cleanup(); resolve(p) }
194
194
  else if (p.type === MSG.ERROR) { cleanup(); reject(new Error(p.error)) }
195
195
  })
196
196
  const t = setTimeout(() => { cleanup(); reject(new Error('timeout waiting for approval at the vault')) }, approveTimeoutMs)
@@ -201,7 +201,7 @@ export async function enrollDevice ({ qr, device, onChallenge, label = '', conti
201
201
 
202
202
  // Camino A: aquí no hay cert que validar — este aparato NO delega su identidad, sigue
203
203
  // siendo la cuenta. Lo que vuelve es el acta ya sellada por la bóveda.
204
- if (adoptar) {
204
+ if (adopting) {
205
205
  if (!res.acta) throw new Error('the vault did not return the adopted record')
206
206
  if (res.acta.sealer !== qr.iss) throw new Error('the record is sealed by a vault other than the one you saw')
207
207
  return { device: dev, cert: null, master: qr.iss, proxy: qr.proxy, deviceId, acta: res.acta, adopted: true }
@@ -210,8 +210,8 @@ export async function enrollDevice ({ qr, device, onChallenge, label = '', conti
210
210
  // Validación estricta antes de guardar (cierra inyección de cert / sustitución de maestra).
211
211
  const v = await verifyDelegation({ cert: res.cert, expectedSub: dev.publickey })
212
212
  if (!v.ok) throw new Error('invalid cert: ' + v.reason)
213
- if (res.cert.iss !== qr.iss) throw new Error('cert firmado por una maestra distinta a la que viste')
214
- if (res.cert.sub !== dev.publickey) throw new Error('cert emitido para otro dispositivo')
213
+ if (res.cert.iss !== qr.iss) throw new Error('cert signed by a master key different from the one you saw')
214
+ if (res.cert.sub !== dev.publickey) throw new Error('cert issued for a different device')
215
215
  return { device: dev, cert: res.cert, master: qr.iss, proxy: qr.proxy, deviceId, acta: res.acta || null }
216
216
  } finally { try { client.close() } catch (_) {} }
217
217
  }
@@ -245,7 +245,7 @@ const REVOKE_GRACE_MS = 1500
245
245
  * ejecuta el autoborrado (`onRevoked`) tras verificar la firma contra la maestra pineada.
246
246
  */
247
247
  async function vaultRpc ({ master, proxy, device, cert, acta = null, sendType, okType, data, onRevoked, timeoutMs = 15000 }) {
248
- if (!master || !proxy || !(device?.privateJwk || device?.privateKey) || !cert) throw new Error('faltan datos de emparejamiento')
248
+ if (!master || !proxy || !(device?.privateJwk || device?.privateKey) || !cert) throw new Error('missing pairing data')
249
249
  const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
250
250
  const client = new WebSocketProxyClient({ url: proxy, enableWebRTC: false, autoReconnect: false })
251
251
  await client.connect()
@@ -298,7 +298,7 @@ async function vaultRpc ({ master, proxy, device, cert, acta = null, sendType, o
298
298
  * nada, como cualquier otro mensaje sin firma.
299
299
  */
300
300
  export async function checkMembership ({ master, proxy, device, onRevoked, timeoutMs = 12000 } = {}) {
301
- if (!master || !proxy || !(device?.privateJwk || device?.privateKey)) throw new Error('faltan datos del dispositivo')
301
+ if (!master || !proxy || !(device?.privateJwk || device?.privateKey)) throw new Error('missing device data')
302
302
  const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
303
303
  const client = new WebSocketProxyClient({ url: proxy, enableWebRTC: false, autoReconnect: false })
304
304
  await client.connect()
@@ -309,20 +309,20 @@ export async function checkMembership ({ master, proxy, device, onRevoked, timeo
309
309
  const data = { op: 'check', publickey: device.publickey, ts: Date.now() }
310
310
  const { signature } = await signWithDevice({ privateJwk: device.privateJwk, privateKey: device.privateKey, publickey: device.publickey, data })
311
311
  const res = await new Promise((resolve) => {
312
- let hecho = false
313
- const fin = (v) => { if (!hecho) { hecho = true; cleanup(); resolve(v) } }
312
+ let settled = false
313
+ const done = (v) => { if (!settled) { settled = true; cleanup(); resolve(v) } }
314
314
  const off = client.on('message', (_f, p) => {
315
315
  if (!p || typeof p !== 'object') return
316
316
  if (p.type === MSG.REVOKED) {
317
317
  isAuthenticRevoke({ body: p.body, signature: p.signature, master, devicePubkey: device.publickey, currentNonce: null })
318
- .then((ok) => { if (ok) { try { onRevoked?.() } catch (_) {} ; fin({ in: false, wiped: true }) } })
318
+ .then((ok) => { if (ok) { try { onRevoked?.() } catch (_) {} ; done({ in: false, wiped: true }) } })
319
319
  .catch(() => {})
320
320
  return
321
321
  }
322
- if (p.type === MSG.CHECKED) fin({ in: !!p.in })
323
- else if (p.type === MSG.ERROR) fin({ error: p.error })
322
+ if (p.type === MSG.CHECKED) done({ in: !!p.in })
323
+ else if (p.type === MSG.ERROR) done({ error: p.error })
324
324
  })
325
- const t = setTimeout(() => fin({ error: 'the vault did not reply' }), timeoutMs)
325
+ const t = setTimeout(() => done({ error: 'the vault did not reply' }), timeoutMs)
326
326
  const cleanup = () => { off(); clearTimeout(t) }
327
327
  client.sendByPubkey(master, { type: MSG.CHECK, data, signature })
328
328
  })