@dotrino/identity 0.105.0 → 0.107.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.105.0",
3
+ "version": "0.107.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/vault/content.js CHANGED
@@ -106,12 +106,68 @@ export async function myContentKey ({ keyring, myPub, myEncPrivateKey }) {
106
106
  return null
107
107
  }
108
108
 
109
- /** Cifra con la CEK. Devuelve un sobre `{ gen, iv, ct }` (el `gen` dice con cuál se cifró). */
109
+ /**
110
+ * La marca de un sobre de la llave de la cuenta. La lleva todo sobre de `encryptWithCek`
111
+ * para que quien guarda datos ajenos —la bóveda con el almacén de las apps— lo RECONOZCA
112
+ * dentro de un JSON cualquiera sin adivinar por la forma, y pueda volver a cerrarlo con la
113
+ * generación vigente cuando la llave rota (`resealStale`).
114
+ */
115
+ export const CEK_ENVELOPE = 'dotrino-cek'
116
+
117
+ /** ¿Es un sobre de la llave de la cuenta? */
118
+ export const isCekEnvelope = (v) => !!v && typeof v === 'object' && v.t === CEK_ENVELOPE &&
119
+ Number.isInteger(v.gen) && typeof v.iv === 'string' && typeof v.ct === 'string'
120
+
121
+ /** Cifra con la CEK. Devuelve un sobre `{ t, gen, iv, ct }` (el `gen` dice con cuál se cifró). */
110
122
  export async function encryptWithCek ({ cek, gen, plaintext }) {
111
123
  const k = await subtle.importKey('raw', fromB64(cek), { name: 'AES-GCM' }, false, ['encrypt'])
112
124
  const iv = globalThis.crypto.getRandomValues(new Uint8Array(12))
113
125
  const ct = await subtle.encrypt({ name: 'AES-GCM', iv }, k, new TextEncoder().encode(plaintext))
114
- return { gen, iv: b64(iv), ct: b64(ct) }
126
+ return { t: CEK_ENVELOPE, gen, iv: b64(iv), ct: b64(ct) }
127
+ }
128
+
129
+ /**
130
+ * VUELVE A CERRAR con la generación vigente todo sobre de una generación anterior que haya
131
+ * dentro de `value`, a cualquier profundidad. Devuelve una copia (no toca el original) y
132
+ * cuántos sobres cambió.
133
+ *
134
+ * Es lo que hace la bóveda al abrirse después de una rotación (dueño, 2026-09-30): lo que
135
+ * sirve va SIEMPRE con la llave vigente, así que un aparato que entra después —que solo
136
+ * recibe esa generación— abre todo, y la llave vieja deja de abrir nada porque ya no se
137
+ * le sirve nada cerrado con ella.
138
+ *
139
+ * `reseal(envelope)` abre y vuelve a cerrar un sobre; lo pone quien tiene las llaves. Si
140
+ * un sobre no se puede abrir, se PARA: no se deja a medias ni se salta en silencio.
141
+ *
142
+ * @param {any} value
143
+ * @param {{ gen: number, reseal: (env: object) => Promise<object> }} o
144
+ * @returns {Promise<{ value: any, changed: number }>}
145
+ */
146
+ export async function resealStale (value, { gen, reseal }) {
147
+ if (!Number.isInteger(gen)) throw new Error('resealStale: the current generation is required')
148
+ let changed = 0
149
+ const walk = async (v) => {
150
+ if (isCekEnvelope(v)) {
151
+ if (v.gen === gen) return v
152
+ const nuevo = await reseal(v)
153
+ if (!isCekEnvelope(nuevo) || nuevo.gen !== gen) throw new Error(`resealStale: reseal did not return an envelope of generation ${gen}`)
154
+ changed++
155
+ return nuevo
156
+ }
157
+ if (Array.isArray(v)) {
158
+ const out = []
159
+ for (const x of v) out.push(await walk(x))
160
+ return out
161
+ }
162
+ if (v && typeof v === 'object') {
163
+ const out = {}
164
+ for (const [k, x] of Object.entries(v)) out[k] = await walk(x)
165
+ return out
166
+ }
167
+ return v
168
+ }
169
+ const out = await walk(value)
170
+ return { value: out, changed }
115
171
  }
116
172
 
117
173
  /**
@@ -144,5 +200,5 @@ export async function decryptWithKeyring ({ envelope, keyring, myPub, myEncPriva
144
200
 
145
201
  export default {
146
202
  makeContentKey, wrapForMember, openWrap, makeGeneration, myContentKey,
147
- encryptWithCek, decryptWithCek, decryptWithKeyring
203
+ encryptWithCek, decryptWithCek, decryptWithKeyring, CEK_ENVELOPE, isCekEnvelope, resealStale
148
204
  }
package/vault/core.js CHANGED
@@ -21,6 +21,7 @@
21
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
+ import { syncPeers, stampOf } from './peerSync.js'
24
25
  import { assertionBody, cleanScopes, claimsAllowed, ASSERTION_DEFAULT_TTL_MS, ASSERTION_MAX_TTL_MS } from './assertion.js'
25
26
  import { pubkeyId as pubkeyIdOf, signWithDevice } from './capabilities.js'
26
27
  import { enrollDevice as remoteEnroll, requestSign as remoteSign, requestStore as remoteStore, requestDevices as remoteDevices, requestRenew as remoteRenew, requestAdmin as remoteAdmin, requestApproval as remoteApproval, requestRenounce as remoteRenounce, checkMembership as remoteCheck } from './remote.js'
@@ -1450,7 +1451,10 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
1450
1451
  const merged = { ...a }
1451
1452
  const aSeen = a.lastSeen || 0
1452
1453
  const bSeen = b.lastSeen || 0
1453
- const newer = bSeen > aSeen ? b : a
1454
+ // Gana la ficha que cambió MÁS TARDE (vista o tocada: `stampOf`), también para ser o no
1455
+ // contacto: antes bastaba con que una de las dos lo fuera, y un contacto que quitabas
1456
+ // volvía desde el otro aparato.
1457
+ const newer = stampOf(b) > stampOf(a) ? b : a
1454
1458
  if (newer === b) {
1455
1459
  if (b.nickname !== undefined) merged.nickname = b.nickname
1456
1460
  if (b.notes !== undefined) merged.notes = b.notes
@@ -1460,7 +1464,10 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
1460
1464
  }
1461
1465
  merged.firstSeen = Math.min(a.firstSeen || aSeen || Date.now(), b.firstSeen || bSeen || Date.now())
1462
1466
  merged.lastSeen = Math.max(aSeen, bSeen)
1463
- merged.isContact = !!(a.isContact || b.isContact)
1467
+ const changed2 = Math.max(Number(a.changedAt) || 0, Number(b.changedAt) || 0)
1468
+ if (changed2) merged.changedAt = changed2
1469
+ if (newer.isContact) merged.isContact = true; else delete merged.isContact
1470
+ if (!merged.card && b.card) merged.card = b.card
1464
1471
  const aMine = a.myRating
1465
1472
  const bMine = b.myRating
1466
1473
  if (bMine && (!aMine || (bMine.issuedAt || 0) > (aMine.issuedAt || 0))) {
@@ -2085,6 +2092,8 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
2085
2092
  const rec = p[publickey]
2086
2093
  if (!rec) return null
2087
2094
  delete rec.isContact
2095
+ // Quitarlo es un CAMBIO: con fecha, para que no vuelva desde otro aparato (`peerSync.js`).
2096
+ rec.changedAt = Date.now()
2088
2097
  p[publickey] = rec
2089
2098
  savePeers(p)
2090
2099
  return rec
@@ -3495,8 +3504,40 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
3495
3504
  applyMerged: applyMergedFromSync,
3496
3505
  mergeFn: mergeForSync
3497
3506
  })
3498
- onDirty(() => { if (sync) sync.markDirty() })
3499
3507
  }
3508
+ // Un cambio en el libro de contactos avisa a los dos respaldos: Drive (si está) y la bóveda.
3509
+ onDirty(() => { if (sync) sync.markDirty(); schedulePeerSync(1500) })
3510
+
3511
+ // ----- el libro de contactos en la bóveda (peerSync.js) -----
3512
+ //
3513
+ // Solo con bóveda y con la clave de contenido; una cuenta volátil no guarda contactos en ningún
3514
+ // sitio, tampoco en la bóveda. Sin bóveda no es un error: es el estado normal, y no se dice.
3515
+ let peerSyncTimer = null
3516
+ let peerSyncRunning = false
3517
+ function schedulePeerSync (delay) {
3518
+ if (volatilePids.has(currentPid)) return
3519
+ clearTimeout(peerSyncTimer)
3520
+ peerSyncTimer = setTimeout(runPeerSync, delay)
3521
+ peerSyncTimer?.unref?.()
3522
+ }
3523
+ async function runPeerSync () {
3524
+ if (peerSyncRunning) { schedulePeerSync(3000); return }
3525
+ if (!loadVaultCert()?.cert || volatilePids.has(currentPid)) return
3526
+ peerSyncRunning = true
3527
+ try {
3528
+ const { incoming } = await syncPeers({ peers: loadPeers(), call: (method, args) => handlers.vaultStore({ method, args }) })
3529
+ if (incoming.length) {
3530
+ const here = loadPeers()
3531
+ const { merged, changed } = await mergePeerMaps(here, Object.fromEntries(incoming.map((r) => [r.publickey, r])))
3532
+ // Fundido en su sitio, sin volver a marcar sucio (sería subirlo otra vez para nada).
3533
+ if (changed) setPeersDirect(merged)
3534
+ }
3535
+ } catch (e) {
3536
+ if (!['not-paired', 'no-content-key'].includes(e?.code)) console.warn(`[cc-identity] contact book ↔ vault failed (${e?.code || 'error'}): ${e?.message}`)
3537
+ } finally { peerSyncRunning = false }
3538
+ }
3539
+ schedulePeerSync(4000)
3540
+ setInterval(() => schedulePeerSync(0), 5 * 60_000)?.unref?.()
3500
3541
 
3501
3542
  // ----- gate del candado: TODO handler no exento exige perfil desbloqueado -----
3502
3543
  refreshLockState()
@@ -0,0 +1,81 @@
1
+ /**
2
+ * EL LIBRO DE CONTACTOS EN LA BÓVEDA: los mismos contactos en todos los aparatos del perfil.
3
+ *
4
+ * Hasta aquí el libro vivía en cada aparato (el navegador, la app de identidad del teléfono) y
5
+ * solo viajaba por Google Drive, que es opcional y no existe en las apps nativas: el mismo perfil
6
+ * veía sus conversaciones en otro aparato (el historial sí se respalda) pero no a sus contactos.
7
+ *
8
+ * Ahora el libro es UN HILO más del almacén de la bóveda (`identity.peers`), con una entrada por
9
+ * persona `{ id, ts, peer }`: `id` sale de su llave y `ts` es la última vez que su ficha cambió
10
+ * (`stampOf`). Viaja cifrado con la clave de contenido del perfil, por el mismo camino que el
11
+ * historial (`identity.vaultStore`). Se reconcilia por índice: se sube lo que aquí es más nuevo y
12
+ * se trae lo que allí lo es, y lo que llega se FUNDE con lo de aquí (`mergePeer`), no lo pisa.
13
+ *
14
+ * Quitar un contacto es un cambio más (`changedAt`): gana el más reciente, así un contacto que
15
+ * quitaste no vuelve desde otro aparato. Lo mismo hacen `PeerBookBackup` en Android e iOS.
16
+ */
17
+
18
+ export const PEERS_THREAD = 'identity.peers'
19
+
20
+ /** Tope de una tanda de subida (cifrada, con su sobre, queda bajo el 1 MB del proxio). */
21
+ const PUSH_BYTES = 350_000
22
+
23
+ /** La última vez que la ficha cambió: la vio (`lastSeen`) o la tocaste (`changedAt`). */
24
+ export function stampOf (rec) {
25
+ return Math.max(Number(rec?.lastSeen) || 0, Number(rec?.changedAt) || 0)
26
+ }
27
+
28
+ /** El id de la entrada: de su llave, estable y sin comillas (sha-256, 32 hex). */
29
+ export async function peerIdOf (publickey) {
30
+ const d = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(String(publickey)))
31
+ return [...new Uint8Array(d)].slice(0, 16).map((b) => b.toString(16).padStart(2, '0')).join('')
32
+ }
33
+
34
+ /**
35
+ * Reconciliar el libro con la bóveda. `peers` es el libro de aquí (`{ publickey: ficha }`),
36
+ * `call(method, args)` habla con el almacén de la bóveda. Devuelve las fichas que llegaron (para
37
+ * fundirlas aquí) y cuántas subieron.
38
+ */
39
+ export async function syncPeers ({ peers, call }) {
40
+ const remote = new Map()
41
+ let cursor = null
42
+ do {
43
+ const page = await call('getThreadIndexes', { keys: [PEERS_THREAD], cursor })
44
+ const idx = page?.indexes?.[PEERS_THREAD]
45
+ for (const [id, ts] of idx?.items || []) remote.set(String(id), Number(ts) || 0)
46
+ cursor = page?.next ?? null
47
+ } while (cursor != null)
48
+
49
+ const local = new Map()
50
+ for (const [pk, rec] of Object.entries(peers || {})) {
51
+ if (!rec || typeof rec !== 'object') continue
52
+ local.set(await peerIdOf(pk), { pk, rec, ts: stampOf(rec) })
53
+ }
54
+
55
+ // SUBIR primero lo que aquí es más nuevo, en tandas.
56
+ const up = [...local.entries()].filter(([id, l]) => !remote.has(id) || l.ts > remote.get(id))
57
+ let batch = []; let bytes = 0; let pushed = 0
58
+ const flush = async () => {
59
+ if (!batch.length) return
60
+ await call('importThreads', { threads: { [PEERS_THREAD]: batch }, tombs: {}, mode: 'merge' })
61
+ pushed += batch.length; batch = []; bytes = 0
62
+ }
63
+ for (const [id, l] of up) {
64
+ const entry = { id, ts: l.ts, peer: { ...l.rec, publickey: l.rec.publickey || l.pk } }
65
+ const size = JSON.stringify(entry).length
66
+ if (bytes + size > PUSH_BYTES) await flush()
67
+ batch.push(entry); bytes += size
68
+ }
69
+ await flush()
70
+
71
+ // TRAER lo que allí es más nuevo.
72
+ const down = [...remote.entries()].filter(([id, ts]) => !local.has(id) || ts > local.get(id).ts).map(([id]) => id)
73
+ const incoming = []
74
+ let refs = down.length ? { [PEERS_THREAD]: down } : null
75
+ while (refs) {
76
+ const page = await call('getEntries', { refs })
77
+ for (const e of page?.threads?.[PEERS_THREAD] || []) if (e?.peer?.publickey) incoming.push(e.peer)
78
+ refs = page?.rest && Object.keys(page.rest).length ? page.rest : null
79
+ }
80
+ return { incoming, pushed }
81
+ }
@@ -1,4 +1,4 @@
1
- Copia vendorizada de @dotrino/proxy-client@0.24.0 (dotrino-proxy-client/src/{index,client,signature,canonical,sealing,encpub,webrtc}.js).
1
+ Copia vendorizada de @dotrino/proxy-client@0.26.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.
@@ -153,6 +153,7 @@ export class WebSocketProxyClient {
153
153
  this._handlers = new Map()
154
154
  this._pending = new Map() // messageId -> { resolve, reject, timer }
155
155
  this._nextId = 1
156
+ this._tokenWatch = new Map() // messageId -> { token, sobre, peerPubkey, timer }
156
157
 
157
158
  this._rtc = this.enableWebRTC ? new WebRTCManager({
158
159
  getSelfToken: () => this.token,
@@ -453,7 +454,7 @@ export class WebSocketProxyClient {
453
454
  // Con la llave puesta a mano se respeta tal cual: quien la pasa está diciendo que ya
454
455
  // sabe de quién es (se emparejaron), y un sobre vale para todos los destinatarios.
455
456
  if (peerEncPub) {
456
- this._sendByPubkeyRaw(list, await this._seal(payload, peerEncPub), opts)
457
+ this._sendByPubkeyRaw(list, await this._seal(payload, peerEncPub, list.length === 1 ? list[0] : undefined), opts)
457
458
  return
458
459
  }
459
460
  // Sin ella, se averigua. UNA ENVOLTURA POR DESTINATARIO: cada uno tiene su llave, así
@@ -464,7 +465,7 @@ export class WebSocketProxyClient {
464
465
  // sin forma de saber cuál mitad.
465
466
  const llaves = await Promise.all(list.map(async (pk) => [pk, await this.encPubOf(pk)]))
466
467
  for (const [pk, encPub] of llaves) {
467
- this._sendByPubkeyRaw([pk], await this._seal(payload, encPub), opts)
468
+ this._sendByPubkeyRaw([pk], await this._seal(payload, encPub, pk), opts)
468
469
  }
469
470
  }
470
471
 
@@ -495,11 +496,55 @@ export class WebSocketProxyClient {
495
496
  }
496
497
  peerEncPub = await this.encPubOf(peerPubkey)
497
498
  }
498
- const sobre = await this._seal(payload, peerEncPub)
499
+ if (!peerPubkey && tokens.length === 1) peerPubkey = this.pubkeyOfToken(tokens[0]) || undefined
500
+ const sobre = await this._seal(payload, peerEncPub, peerPubkey)
501
+ if (tokens.length === 1 && peerPubkey) { this._sendToTokenOrQueue(tokens[0], sobre, peerPubkey); return }
499
502
  // Por `send`, no por `_sendRaw`: así sigue prefiriendo el canal directo si lo hay.
500
503
  this.send(tokens, sobre)
501
504
  }
502
505
 
506
+ /**
507
+ * UN TOKEN MUERTO NO SE TRAGA EL MENSAJE. Un token es una conexión, y cuando la otra punta
508
+ * reinicia la app su token deja de existir; la app que lo tenía apuntado sigue mandando ahí
509
+ * y el proxio contesta `message_sent` con ese token en `failed` (solo contesta si algo
510
+ * falla). Antes nadie escuchaba esa respuesta: el mensaje se daba por enviado y se perdía.
511
+ *
512
+ * Si sabemos de quién es el token, el MISMO sobre —ya va sellado a esa identidad— sale por
513
+ * su pubkey: la cola de 24 h del proxio, que además timbra su teléfono. Y se avisa con
514
+ * `token_gone` para que la app deje de usar ese token. No bloquea a nadie: se manda ya y
515
+ * el arreglo, si hace falta, llega detrás.
516
+ */
517
+ _sendToTokenOrQueue (token, sobre, peerPubkey) {
518
+ if (this.requireSealed && !this._isSealed(sobre)) {
519
+ throw errorCon('requireSealed: refusing to send a directed message in the clear — use sendSealedTo()', 'unsealed')
520
+ }
521
+ const messageStr = JSON.stringify(sobre)
522
+ if (this._rtc && this._rtc.trySend(token, messageStr)) return
523
+ const id = `msg_${this._nextId++}`
524
+ const timer = setTimeout(() => this._tokenWatch.delete(id), 15000)
525
+ timer.unref?.()
526
+ this._tokenWatch.set(id, { token, sobre, peerPubkey, timer })
527
+ try {
528
+ this._sendRaw({ to: [token], message: messageStr, id })
529
+ } catch (e) {
530
+ clearTimeout(timer)
531
+ this._tokenWatch.delete(id)
532
+ throw e
533
+ }
534
+ this._upgradeDirect([token])
535
+ }
536
+
537
+ /** `message_sent` con fallos: lo que iba a un token que ya no existe sale por pubkey. */
538
+ _tokenFailed (data) {
539
+ const w = data.id && this._tokenWatch.get(data.id)
540
+ if (!w) return
541
+ clearTimeout(w.timer)
542
+ this._tokenWatch.delete(data.id)
543
+ if (!Array.isArray(data.failed) || !data.failed.includes(w.token)) return
544
+ this._emit('token_gone', w.token, w.peerPubkey)
545
+ try { this._sendByPubkeyRaw([w.peerPubkey], w.sobre) } catch (e) { this._emit('error', { type: 'client', error: e.message, code: e.code || null }) }
546
+ }
547
+
503
548
  // ---------- el saludo: de quién es este token ----------
504
549
 
505
550
  /**
@@ -560,9 +605,11 @@ export class WebSocketProxyClient {
560
605
  }
561
606
 
562
607
  /** Sella con lo que haya: la bóveda de la app (`sealing`) o las primitivas del pilar. */
563
- async _seal (payload, peerEncPub) {
608
+ async _seal (payload, peerEncPub, peerPubkey) {
564
609
  if (!peerEncPub) throw errorCon('seal: missing peerEncPub', 'unsealed')
565
- return this.sealing ? this.sealing.seal(payload, peerEncPub) : seal(payload, peerEncPub)
610
+ return this.sealing
611
+ ? this.sealing.seal(payload, peerEncPub, { publickey: peerPubkey })
612
+ : seal(payload, peerEncPub)
566
613
  }
567
614
 
568
615
  // ---------- llaves de cifrado ajenas ----------
@@ -692,7 +739,10 @@ export class WebSocketProxyClient {
692
739
  const opened = this.sealing
693
740
  ? await this.sealing.open(payload, meta)
694
741
  : await open(payload, this.myEncPrivateKey)
695
- this._emit('message', from, opened, { ...meta, sealed: true })
742
+ // Quién selló (la llave de cifrado), si el sobre lo dice: es lo que autentica al
743
+ // remitente, porque solo quien tiene esa privada pudo armarlo.
744
+ const senderEncPub = this.sealing?.senderOf?.(payload) || null
745
+ this._emit('message', from, opened, { ...meta, sealed: true, ...(senderEncPub ? { senderEncPub } : {}) })
696
746
  } catch (e) {
697
747
  // Sealed to somebody else, or tampered with. Staying quiet is the point.
698
748
  this._emit('error', { type: 'undecipherable', from, error: e })
@@ -1292,8 +1342,11 @@ export class WebSocketProxyClient {
1292
1342
  case 'channels_list':
1293
1343
  case 'channel_count':
1294
1344
  case 'disconnect_confirmation':
1295
- case 'identified':
1296
1345
  case 'message_sent':
1346
+ this._tokenFailed(data)
1347
+ this._resolvePending(data, type)
1348
+ break
1349
+ case 'identified':
1297
1350
  case 'push-config':
1298
1351
  case 'push-subscribed':
1299
1352
  case 'push-unsubscribed':
@@ -1311,6 +1364,8 @@ export class WebSocketProxyClient {
1311
1364
  this._emit('error', {
1312
1365
  type: 'server',
1313
1366
  error: data.error,
1367
+ // El código es el contrato; la frase es para las personas (CONVENCIONES §8.1).
1368
+ code: data.code || null,
1314
1369
  id: data.id,
1315
1370
  messageId: data.messageId,
1316
1371
  limit_level: data.limit_level,
@@ -1378,7 +1433,9 @@ export class WebSocketProxyClient {
1378
1433
  const entry = this._pending.get(id)
1379
1434
  clearTimeout(entry.timer)
1380
1435
  this._pending.delete(id)
1381
- entry.reject(new Error(data.error || 'Server error'))
1436
+ // Con su `code`: quien llama distingue los casos por él, no por la frase (que el proxio
1437
+ // puede cambiar o traducir). Antes se perdía aquí y solo quedaba comparar el texto.
1438
+ entry.reject(errorCon(data.error || 'Server error', data.code || null))
1382
1439
  }
1383
1440
 
1384
1441
  _emit (event, ...args) {
@@ -136,11 +136,14 @@ export function identitySealing (identity, { app = 'dotrino' } = {}) {
136
136
  }
137
137
 
138
138
  return {
139
- async seal (msg, peerEncPub) {
139
+ async seal (msg, peerEncPub, { publickey } = {}) {
140
140
  if (!peerEncPub) throw errorCon('no encryption key for the other side', 'unsealed')
141
- // Destinatarios como OBJETOS: `encrypt` expande cada uno a todos los aparatos de
142
- // esa persona, y una llave suelta se le cae sin envolver nada.
143
- const sealed = await identity.encrypt([{ encryptionPubkey: peerEncPub }], JSON.stringify(msg))
141
+ // Destinatarios como OBJETOS, y CON SU `publickey`: `encrypt` expande cada uno a
142
+ // todos los aparatos de esa persona por su tarjeta de perfil, y la tarjeta se busca
143
+ // por la pubkey. Sin ella el sobre solo lo abría el aparato que anunció la llave, y
144
+ // los demás aparatos de esa persona recibían un mensaje que «no es para mí».
145
+ const recipient = publickey ? { publickey, encryptionPubkey: peerEncPub } : { encryptionPubkey: peerEncPub }
146
+ const sealed = await identity.encrypt([recipient], JSON.stringify(msg))
144
147
  // Y SE COMPRUEBA QUE ENVOLVIÓ A ALGUIEN. `encrypt` se salta en silencio al
145
148
  // destinatario cuya llave no puede importar, y devuelve un sobre con el llavero
146
149
  // VACÍO: cifrado de verdad, y que no abre nadie. Eso no es un sobre, es un mensaje
@@ -151,6 +154,13 @@ export function identitySealing (identity, { app = 'dotrino' } = {}) {
151
154
  return { app, sealed, from: await myEncPub() }
152
155
  },
153
156
  async open (env) { return JSON.parse(await openEnvelope(env.from, env.sealed)) },
157
+ /**
158
+ * La llave de cifrado de QUIEN SELLÓ. El sobre de la bóveda es ECDH entre la llave de
159
+ * quien sella y la mía, así que solo lo pudo armar quien tiene la privada de `from`:
160
+ * es lo que dice de quién viene de verdad (el token no lo dice, y el saludo no
161
+ * autentica). La app la compara con la que conoce de su contacto.
162
+ */
163
+ senderOf: (env) => (typeof env?.from === 'string' ? env.from : null),
154
164
  isSealed: (m) => !!m && m.app === app && !!m.sealed,
155
165
  }
156
166
  }
@@ -1,4 +1,4 @@
1
- Copia vendorizada de @dotrino/vault@0.74.0 (dotrino-vault/lib/src/{index,enroll,protocol,passwordLogins,loginClient,b64}.js).
1
+ Copia vendorizada de @dotrino/vault@0.79.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
@@ -65,7 +65,7 @@ export const MSG = Object.freeze({
65
65
  // empieza con el heap limpio.
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
- SECRETS_CHANGED: 'vault.secrets.changed', // vault → servicio: { body:{op,ns,ts}, signature }
68
+ SECRETS_CHANGED: 'vault.secrets.changed', // vault → servicio: { body:{op,ns,ts}, seal, acta }
69
69
  // REPARTIR LA LLAVE DE UN CAJÓN A UN MIEMBRO NUEVO. Lo hace el SERVICIO y no la
70
70
  // bóveda, porque la bóveda no puede abrir la llave sin la frase y el servicio ya la
71
71
  // tiene: re-envolverla no le añade ningún poder. Ver `docs/secretos-sellados.md` §8.11.
@@ -74,7 +74,7 @@ export const MSG = Object.freeze({
74
74
  // el ACTA que viene dentro (también firmada) y saca de ahí la llave pública del
75
75
  // destinatario. Así, ni siquiera una bóveda comprometida puede hacerle envolver la
76
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 }
77
+ REWRAP: 'vault.rewrap', // vault → servicio: { body:{op,owner,gen,wrap,target,acta,ts}, seal }
78
78
  REWRAP_OK: 'vault.rewrap.ok', // servicio → vault: { data:{op,owner,gen,target,wrap,ts}, signature, cert }
79
79
  // --- CONSOLA REMOTA (docs/consola-remota.md) — requiere cert `vault:admin` ---
80
80
  // Un solo mensaje con `data.op`: pending · pair · approve · reject · revoke · audit.
@@ -85,7 +85,7 @@ export const MSG = Object.freeze({
85
85
  // Aviso a TODOS los miembros de que el perfil cambió (alguien entró o salió). Es la
86
86
  // contrapartida de administrar a distancia: sin esto, un enrolamiento remoto sería
87
87
  // invisible para el resto de tus dispositivos.
88
- ADMIN_EVENT: 'vault.admin.event', // vault → todos: { body:{ev,deviceId,by,ts}, signature }
88
+ ADMIN_EVENT: 'vault.admin.event', // vault → todos: { body:{ev,deviceId,by,ts}, acta } (pedidos: + seal)
89
89
  // RÉPLICAS: la principal empuja lo que hay que servir (el acta y los sobres, que van
90
90
  // firmados de antes y no se pueden falsificar) y la réplica acusa hasta qué `seq` tiene.
91
91
  REPLICA_PUSH: 'vault.replica.push', // master → réplica: { body:{seq,acta,secrets,ts}, signature }