@dotrino/identity 0.104.2 → 0.106.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 +1 -1
- package/src/index.js +2 -0
- package/src/node.js +1 -0
- package/vault/core.js +69 -20
- package/vault/peerSync.js +81 -0
- package/vault/vendor/proxy-client/VERSION.txt +1 -1
- package/vault/vendor/proxy-client/client.js +65 -8
- package/vault/vendor/proxy-client/sealing.js +14 -4
- package/vault/vendor/vault/VERSION.txt +1 -1
- package/vault/vendor/vault/enroll.js +23 -5
- package/vault/vendor/vault/protocol.js +3 -3
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -348,6 +348,8 @@ export class Identity {
|
|
|
348
348
|
|
|
349
349
|
/** Cambia las capacidades de un miembro (solo el master). */
|
|
350
350
|
async setCaps (pub, caps) { return this._call('setCaps', { pub, caps }) }
|
|
351
|
+
/** Cambia las capacidades de VARIOS miembros en una sola acta: `[{ pub, caps }]`. */
|
|
352
|
+
async setCapsMany (changes) { return this._call('setCapsMany', { changes }) }
|
|
351
353
|
async setLabel (pub, label) { return this._call('setLabel', { pub, label }) }
|
|
352
354
|
|
|
353
355
|
/** Expulsa a un miembro (solo el master; al master no se le puede expulsar). */
|
package/src/node.js
CHANGED
|
@@ -228,6 +228,7 @@ export class Identity {
|
|
|
228
228
|
/** Registra la llave de cifrado de un miembro ya admitido (evita re-enrolarlo). */
|
|
229
229
|
setMemberEncPub (args) { return this._h('setMemberEncPub', args) }
|
|
230
230
|
setCaps (pub, caps) { return this._h('setCaps', { pub, caps }) }
|
|
231
|
+
setCapsMany (changes) { return this._h('setCapsMany', { changes }) }
|
|
231
232
|
setLabel (pub, label) { return this._h('setLabel', { pub, label }) }
|
|
232
233
|
removeMember (pub) { return this._h('removeMember', { pub }) }
|
|
233
234
|
handoverMaster (to, member = null) { return this._h('handoverMaster', { to, member }) }
|
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'
|
|
@@ -973,19 +974,6 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
|
|
|
973
974
|
keyring: loadActa()?.keyring, myPub: publickeyJwkStr, myEncPrivateKey: encKeypair.privateKey
|
|
974
975
|
})
|
|
975
976
|
|
|
976
|
-
/** Envuelve la clave vigente para un miembro recién admitido (no hace falta rotar). */
|
|
977
|
-
async function wrapForNewMember (pub) {
|
|
978
|
-
const acta = loadActa()
|
|
979
|
-
const gen = (acta?.keyring || []).at(-1)
|
|
980
|
-
const m = acta?.members.find((x) => x.pub === pub)
|
|
981
|
-
if (!gen || !m?.encPub) return false
|
|
982
|
-
const mine = await myCek()
|
|
983
|
-
if (!mine) return false
|
|
984
|
-
const wrap = await Content.wrapForMember({ cek: mine.cek, memberEncPub: m.encPub })
|
|
985
|
-
await sealChanges([{ op: 'wrap', gen: mine.gen, pub, wrap }])
|
|
986
|
-
return true
|
|
987
|
-
}
|
|
988
|
-
|
|
989
977
|
/**
|
|
990
978
|
* QUITAR UN APARATO, entero y de una vez.
|
|
991
979
|
*
|
|
@@ -1463,7 +1451,10 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
|
|
|
1463
1451
|
const merged = { ...a }
|
|
1464
1452
|
const aSeen = a.lastSeen || 0
|
|
1465
1453
|
const bSeen = b.lastSeen || 0
|
|
1466
|
-
|
|
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
|
|
1467
1458
|
if (newer === b) {
|
|
1468
1459
|
if (b.nickname !== undefined) merged.nickname = b.nickname
|
|
1469
1460
|
if (b.notes !== undefined) merged.notes = b.notes
|
|
@@ -1473,7 +1464,10 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
|
|
|
1473
1464
|
}
|
|
1474
1465
|
merged.firstSeen = Math.min(a.firstSeen || aSeen || Date.now(), b.firstSeen || bSeen || Date.now())
|
|
1475
1466
|
merged.lastSeen = Math.max(aSeen, bSeen)
|
|
1476
|
-
|
|
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
|
|
1477
1471
|
const aMine = a.myRating
|
|
1478
1472
|
const bMine = b.myRating
|
|
1479
1473
|
if (bMine && (!aMine || (bMine.issuedAt || 0) > (aMine.issuedAt || 0))) {
|
|
@@ -2098,6 +2092,8 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
|
|
|
2098
2092
|
const rec = p[publickey]
|
|
2099
2093
|
if (!rec) return null
|
|
2100
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()
|
|
2101
2097
|
p[publickey] = rec
|
|
2102
2098
|
savePeers(p)
|
|
2103
2099
|
return rec
|
|
@@ -2571,12 +2567,21 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
|
|
|
2571
2567
|
// `cn`: si viene, este miembro es un SERVICIO y su único permiso es abrir el cajón de
|
|
2572
2568
|
// secretos de ESE nombre — no ve nada más del usuario. Sin `cn` es un dispositivo.
|
|
2573
2569
|
const finales = caps || (cn ? ['secrets'] : ['store', 'read'])
|
|
2574
|
-
const acta = await sealChanges([{ op: 'admit', member: { pub, encPub, label, cn, caps: finales, cert, continuity } }])
|
|
2575
2570
|
// Que entre al perfil incluye poder LEER lo que ya hay: se le envuelve la clave
|
|
2576
2571
|
// vigente (no hace falta rotar; rotar es para cuando alguien SALE).
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2572
|
+
//
|
|
2573
|
+
// EN LA MISMA ACTA que la admisión (dueño, 2026-09-26: nada de actas inútiles). Antes
|
|
2574
|
+
// se sellaba una para admitir y otra para envolver, y cada acta es un aviso a todos
|
|
2575
|
+
// los aparatos. La envoltura solo necesita la llave de cifrado del que entra, que ya
|
|
2576
|
+
// viene aquí. Si no se puede envolver (sin clave propia, sin `encPub`), entra igual y
|
|
2577
|
+
// `wrapped` lo dice — lo mismo que antes.
|
|
2578
|
+
let wrap = null
|
|
2579
|
+
try {
|
|
2580
|
+
const mine = encPub ? await myCek() : null
|
|
2581
|
+
if (mine) wrap = { op: 'wrap', gen: mine.gen, pub, wrap: await Content.wrapForMember({ cek: mine.cek, memberEncPub: encPub }) }
|
|
2582
|
+
} catch (_) { wrap = null }
|
|
2583
|
+
const acta = await sealChanges([{ op: 'admit', member: { pub, encPub, label, cn, caps: finales, cert, continuity } }, ...(wrap ? [wrap] : [])])
|
|
2584
|
+
return { ok: true, seq: acta.seq, wrapped: !!wrap }
|
|
2580
2585
|
},
|
|
2581
2586
|
|
|
2582
2587
|
async setCaps ({ pub, caps } = {}) {
|
|
@@ -2584,6 +2589,18 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
|
|
|
2584
2589
|
return { ok: true, seq: acta.seq }
|
|
2585
2590
|
},
|
|
2586
2591
|
|
|
2592
|
+
/**
|
|
2593
|
+
* LOS PERMISOS DE VARIOS APARATOS EN UNA SOLA ACTA (dueño, 2026-09-26). Editar tres
|
|
2594
|
+
* aparatos eran tres actas y tres avisos; ahora el borrador entero se sella de golpe.
|
|
2595
|
+
* `changes`: `[{ pub, caps }]`, cada `caps` la lista COMPLETA de ese aparato.
|
|
2596
|
+
* @param {{ changes?: Array<{ pub: string, caps: string[] }> }} [args]
|
|
2597
|
+
*/
|
|
2598
|
+
async setCapsMany ({ changes } = {}) {
|
|
2599
|
+
if (!Array.isArray(changes) || !changes.length) throw new Error('setCapsMany: no changes')
|
|
2600
|
+
const acta = await sealChanges(changes.map(({ pub, caps }) => ({ op: 'caps', pub, caps })))
|
|
2601
|
+
return { ok: true, seq: acta.seq }
|
|
2602
|
+
},
|
|
2603
|
+
|
|
2587
2604
|
/**
|
|
2588
2605
|
* RENOMBRA un miembro (el nombre con el que lo reconoces). Se escribe en el acta y
|
|
2589
2606
|
* TAMBIÉN en la delegación: son dos registros distintos —el acta dice quién es del
|
|
@@ -3487,8 +3504,40 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
|
|
|
3487
3504
|
applyMerged: applyMergedFromSync,
|
|
3488
3505
|
mergeFn: mergeForSync
|
|
3489
3506
|
})
|
|
3490
|
-
onDirty(() => { if (sync) sync.markDirty() })
|
|
3491
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?.()
|
|
3492
3541
|
|
|
3493
3542
|
// ----- gate del candado: TODO handler no exento exige perfil desbloqueado -----
|
|
3494
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
142
|
-
// esa persona
|
|
143
|
-
|
|
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.
|
|
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
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
* `docs/pairing-protocol.md`).
|
|
31
31
|
*/
|
|
32
32
|
import { verifyDeviceSig, pubkeyId, commitCode } from '@dotrino/identity/capabilities'
|
|
33
|
-
import { verifyContinuity, canSeal } from '@dotrino/identity/acta'
|
|
33
|
+
import { verifyContinuity, canSeal, DEVICE_CAPS } from '@dotrino/identity/acta'
|
|
34
34
|
|
|
35
35
|
/** Un token de emparejamiento vale 5 min. */
|
|
36
36
|
export const PAIRING_TTL_MS = 5 * 60 * 1000
|
|
@@ -159,8 +159,24 @@ export function createEnrollDesk ({
|
|
|
159
159
|
* · `account` → cómo se llama la cuenta de la bóveda, para nombrarla en el
|
|
160
160
|
* aviso. Es ORIENTATIVO (un nombre que puso su dueño); la
|
|
161
161
|
* identidad de verdad de la cuenta es `iss`.
|
|
162
|
+
* · `caps` → LOS PERMISOS con los que entra al acta, elegidos por quien
|
|
163
|
+
* empareja (dueño, 2026-09-26: «al emparejar debería escoger los
|
|
164
|
+
* permisos, caso contrario estoy obligado a hacer dos actas»). Sin
|
|
165
|
+
* esto el aparato entraba con lo que da el scope y cualquier otro
|
|
166
|
+
* permiso —`admin`, `approve`…— costaba una segunda acta. Se quedan
|
|
167
|
+
* AQUÍ, en la sesión de la bóveda: NO viajan en el QR, así que la
|
|
168
|
+
* invitación que circula no concede nada; lo concede quien teclea
|
|
169
|
+
* el código, igual que hacía `caps` después.
|
|
162
170
|
*/
|
|
163
|
-
async function startPairing ({ scope = defaultScope, ttlMs = defaultTtlMs, label = '', mode = 'join', account = '' } = {}) {
|
|
171
|
+
async function startPairing ({ scope = defaultScope, ttlMs = defaultTtlMs, label = '', mode = 'join', account = '', caps = null } = {}) {
|
|
172
|
+
if (caps != null) {
|
|
173
|
+
if (!Array.isArray(caps)) throw Object.assign(new Error('caps must be a list'), { code: 'bad-caps' })
|
|
174
|
+
const cn = scopeToCn(scope)
|
|
175
|
+
const bad = caps.filter((c) => !(DEVICE_CAPS.includes(c) || (c === 'secrets' && cn)))
|
|
176
|
+
if (bad.length) throw Object.assign(new Error('unknown permission(s): ' + bad.join(', ')), { code: 'bad-caps' })
|
|
177
|
+
if (!caps.length) throw Object.assign(new Error('the pairing grants no permission'), { code: 'empty-scope' })
|
|
178
|
+
caps = [...new Set(caps)]
|
|
179
|
+
}
|
|
164
180
|
pending.clear() // uno a la vez: una sesión nueva supersede a la anterior
|
|
165
181
|
const acct = String(account || '').slice(0, 40)
|
|
166
182
|
// INVITACIÓN CORTA: si sabemos cómo alcanzarnos, el QR lleva solo eso y el
|
|
@@ -176,12 +192,12 @@ export function createEnrollDesk ({
|
|
|
176
192
|
const conn = typeof connToken === 'function' ? await connToken() : connToken
|
|
177
193
|
if (conn) {
|
|
178
194
|
const sn = randToken(8)
|
|
179
|
-
pending.set(sn, { token: sn, exp: Date.now() + PAIRING_TTL_MS, scope, ttlMs, label, sn, mode, account: acct, state: 'AWAITING_ENROLL' })
|
|
195
|
+
pending.set(sn, { token: sn, exp: Date.now() + PAIRING_TTL_MS, scope, caps, ttlMs, label, sn, mode, account: acct, state: 'AWAITING_ENROLL' })
|
|
180
196
|
return { token: sn, qr: { v: 2, conn, sn, m: mode, proxy }, expiresInMs: PAIRING_TTL_MS }
|
|
181
197
|
}
|
|
182
198
|
const token = randToken(PAIR_TOKEN_BYTES)
|
|
183
199
|
const sn = randToken(PAIR_TOKEN_BYTES)
|
|
184
|
-
pending.set(token, { token, exp: Date.now() + PAIRING_TTL_MS, scope, ttlMs, label, sn, mode, account: acct, state: 'AWAITING_ENROLL' })
|
|
200
|
+
pending.set(token, { token, exp: Date.now() + PAIRING_TTL_MS, scope, caps, ttlMs, label, sn, mode, account: acct, state: 'AWAITING_ENROLL' })
|
|
185
201
|
return { token, qr: { v: 2, iss, proxy, token, sn, m: mode, ...(acct ? { acct } : {}) }, expiresInMs: PAIRING_TTL_MS }
|
|
186
202
|
}
|
|
187
203
|
|
|
@@ -364,8 +380,10 @@ export function createEnrollDesk ({
|
|
|
364
380
|
// PERMISOS, no tipos (2026-08-22): las capacidades son las del scope ENTERO. Un
|
|
365
381
|
// cajón (`secrets:<ns>`) suma `secrets` y fija el CN; no borra lo demás — un bot
|
|
366
382
|
// con `sign,secrets:eco` firma como aparato del acta Y lee solo su cajón.
|
|
383
|
+
// Si quien emparejó ELIGIÓ los permisos (`startPairing({ caps })`), entran esos y en
|
|
384
|
+
// esta misma acta; si no, los que da el scope.
|
|
367
385
|
const cn = scopeToCn(pend.scope)
|
|
368
|
-
const caps = [...new Set([...scopeToCaps(pend.scope), ...(cn ? ['secrets'] : [])])]
|
|
386
|
+
const caps = [...new Set([...(pend.caps || scopeToCaps(pend.scope)), ...(cn ? ['secrets'] : [])])]
|
|
369
387
|
if (!caps.length) {
|
|
370
388
|
throw Object.assign(new Error('the pairing scope grants no permission: no certificate was issued'), { code: 'empty-scope' })
|
|
371
389
|
}
|
|
@@ -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},
|
|
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},
|
|
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},
|
|
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 }
|