@dotrino/identity 0.62.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 +2 -2
- package/vault/acta.js +98 -18
- package/vault/capabilities.js +2 -2
- package/vault/content.js +2 -2
- package/vault/core.js +18 -18
- package/vault/remote.js +24 -24
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/identity",
|
|
3
|
-
"version": "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": [
|
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. **
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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.
|
|
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
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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('
|
|
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('
|
|
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
|
|
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
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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
|
}
|
package/vault/capabilities.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
58
|
-
if (typeof memberEncPub !== 'string' || !memberEncPub) throw new Error('wrapForMember:
|
|
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]
|
|
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
|
|
720
|
-
const continuity = (
|
|
721
|
-
? await Acta.makeContinuity({ member: publickeyJwkStr, from:
|
|
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
|
|
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(`
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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
|
|
1856
|
-
if (!
|
|
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:
|
|
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:
|
|
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
|
-
'
|
|
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]
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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) {
|
|
116
|
-
else if (p?.type === MSG.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(() => {
|
|
119
|
-
const
|
|
120
|
-
try { client.send(
|
|
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
|
|
165
|
-
if (
|
|
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
|
-
...(
|
|
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 &&
|
|
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 &&
|
|
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 (
|
|
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
|
|
214
|
-
if (res.cert.sub !== dev.publickey) throw new Error('cert
|
|
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('
|
|
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('
|
|
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
|
|
313
|
-
const
|
|
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 (_) {} ;
|
|
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)
|
|
323
|
-
else if (p.type === MSG.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(() =>
|
|
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
|
})
|