@dotrino/identity 0.52.0 → 0.54.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 +3 -3
- package/src/index.js +3 -1
- package/src/node.js +3 -1
- package/vault/acta.js +103 -4
- package/vault/capabilities.js +39 -0
- package/vault/content.js +14 -1
- package/vault/core.js +53 -2
- package/src/types.d.ts +0 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/identity",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.54.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",
|
|
@@ -56,11 +56,11 @@
|
|
|
56
56
|
"url": "git+https://github.com/imdotrino/dotrino-identity.git"
|
|
57
57
|
},
|
|
58
58
|
"dependencies": {
|
|
59
|
-
"@dotrino/proxy-client": "0.10.
|
|
59
|
+
"@dotrino/proxy-client": "0.10.1"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
62
|
"fake-indexeddb": "^6.2.5",
|
|
63
|
-
"typescript": "
|
|
63
|
+
"typescript": "5.9.3",
|
|
64
64
|
"@types/node": "^22.0.0"
|
|
65
65
|
}
|
|
66
66
|
}
|
package/src/index.js
CHANGED
|
@@ -292,6 +292,8 @@ export class Identity {
|
|
|
292
292
|
|
|
293
293
|
/** Admite un miembro nuevo (solo el master). */
|
|
294
294
|
async admitMember (member) { return this._call('admitMember', member) }
|
|
295
|
+
/** Registra la llave de cifrado de un miembro ya admitido (evita re-enrolarlo). */
|
|
296
|
+
async setMemberEncPub (args) { return this._call('setMemberEncPub', args) }
|
|
295
297
|
|
|
296
298
|
/** Cambia las capacidades de un miembro (solo el master). */
|
|
297
299
|
async setCaps (pub, caps) { return this._call('setCaps', { pub, caps }) }
|
|
@@ -671,4 +673,4 @@ export class Identity {
|
|
|
671
673
|
|
|
672
674
|
// Helpers de capacidad SIN clave maestra (lado dispositivo + verificación), reutilizables
|
|
673
675
|
// por apps/bridges sin cargar el iframe del vault.
|
|
674
|
-
export { makeDeviceKey, signWithDevice, verifyDelegation, verifyChain, pubkeyId, deriveSAS, verifyDeviceSig, makePairingCode, commitCode, avatarSvg, avatarDataUri, MAX_DELEGATION_MS, DEFAULT_DELEGATION_MS } from '../vault/capabilities.js'
|
|
676
|
+
export { makeDeviceKey, makeDeviceEncKey, importDeviceEncKey, signWithDevice, verifyDelegation, verifyChain, pubkeyId, deriveSAS, verifyDeviceSig, makePairingCode, commitCode, avatarSvg, avatarDataUri, MAX_DELEGATION_MS, DEFAULT_DELEGATION_MS } from '../vault/capabilities.js'
|
package/src/node.js
CHANGED
|
@@ -184,6 +184,8 @@ export class Identity {
|
|
|
184
184
|
actaHistory (opts) { return this._h('actaHistory', opts || {}) }
|
|
185
185
|
isMaster () { return this._h('isMaster') }
|
|
186
186
|
admitMember (member) { return this._h('admitMember', member) }
|
|
187
|
+
/** Registra la llave de cifrado de un miembro ya admitido (evita re-enrolarlo). */
|
|
188
|
+
setMemberEncPub (args) { return this._h('setMemberEncPub', args) }
|
|
187
189
|
setCaps (pub, caps) { return this._h('setCaps', { pub, caps }) }
|
|
188
190
|
setLabel (pub, label) { return this._h('setLabel', { pub, label }) }
|
|
189
191
|
removeMember (pub) { return this._h('removeMember', { pub }) }
|
|
@@ -269,4 +271,4 @@ export default Identity
|
|
|
269
271
|
|
|
270
272
|
// Helpers de capacidad SIN clave maestra (lado dispositivo + verificación), para que
|
|
271
273
|
// un bridge/bot Node pueda crear su clave, firmar acciones y verificar cadenas D←P.
|
|
272
|
-
export { makeDeviceKey, signWithDevice, verifyDelegation, verifyChain, pubkeyId, deriveSAS, verifyDeviceSig, makePairingCode, commitCode, avatarSvg, avatarDataUri, MAX_DELEGATION_MS, DEFAULT_DELEGATION_MS } from '../vault/capabilities.js'
|
|
274
|
+
export { makeDeviceKey, makeDeviceEncKey, importDeviceEncKey, signWithDevice, verifyDelegation, verifyChain, pubkeyId, deriveSAS, verifyDeviceSig, makePairingCode, commitCode, avatarSvg, avatarDataUri, MAX_DELEGATION_MS, DEFAULT_DELEGATION_MS } from '../vault/capabilities.js'
|
package/vault/acta.js
CHANGED
|
@@ -30,7 +30,16 @@
|
|
|
30
30
|
import { canonicalStringify } from './core.js'
|
|
31
31
|
import { signWithDevice, verifyDeviceSig, pubkeyId } from './capabilities.js'
|
|
32
32
|
|
|
33
|
-
export const ACTA_V =
|
|
33
|
+
export const ACTA_V = 2
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Versiones de acta que se ACEPTAN al leer. La 1 sigue entrando porque una v1 en el disco
|
|
37
|
+
* es un perfil con sus aparatos dentro: rechazarla dejaría al vault sin poder verificar a
|
|
38
|
+
* nadie —y a los servicios sin configuración— por un campo que ni siquiera existía. Se
|
|
39
|
+
* asciende sola: el acta siguiente que selle la maestra ya sale v2 (ver `applyChanges`).
|
|
40
|
+
* La única diferencia es la llave de sellado (§8.9), que en una v1 simplemente no hay.
|
|
41
|
+
*/
|
|
42
|
+
const ACTA_LEIBLES = Object.freeze([1, 2])
|
|
34
43
|
|
|
35
44
|
/**
|
|
36
45
|
* Lista CERRADA de capacidades. Sellar y admitir no están: eso es ser el master.
|
|
@@ -82,6 +91,20 @@ export const CAP_SCOPE = Object.freeze({ sign: 'vault:sign', store: 'vault:store
|
|
|
82
91
|
const enc = (s) => new TextEncoder().encode(s)
|
|
83
92
|
const hex = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('')
|
|
84
93
|
const isPub = (v) => typeof v === 'string' && v.length > 0
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* La llave de CIFRADO de un miembro (`encPub`): JWK público de una ECDH P-256.
|
|
97
|
+
* Se valida de verdad —no basta con «es un string»— porque es lo que decide si a
|
|
98
|
+
* ese aparato se le puede envolver un secreto: una `encPub` mal formada no falla al
|
|
99
|
+
* escribir el acta, falla mucho después al intentar sellarle algo.
|
|
100
|
+
*/
|
|
101
|
+
const isEncPub = (v) => {
|
|
102
|
+
if (typeof v !== 'string' || !v) return false
|
|
103
|
+
try {
|
|
104
|
+
const j = JSON.parse(v)
|
|
105
|
+
return j?.kty === 'EC' && j?.crv === 'P-256' && typeof j?.x === 'string' && typeof j?.y === 'string'
|
|
106
|
+
} catch (_) { return false }
|
|
107
|
+
}
|
|
85
108
|
const cleanCaps = (caps) => [...new Set((Array.isArray(caps) ? caps : []).filter((c) => CAPS.includes(c)))].sort()
|
|
86
109
|
|
|
87
110
|
/**
|
|
@@ -112,7 +135,7 @@ export const isHandover = (acta) => !!acta && acta.sealer !== acta.sealedBy
|
|
|
112
135
|
* sellador, con todas las capacidades. `profileId` = su pubkey → el nombre del perfil es
|
|
113
136
|
* estable para siempre y coincide con la identidad que el usuario ya tenía (cero migración).
|
|
114
137
|
*/
|
|
115
|
-
export function genesisActa ({ pub, encPub = null, label = '', now = Date.now() }) {
|
|
138
|
+
export function genesisActa ({ pub, encPub = null, sealPub = null, label = '', now = Date.now() }) {
|
|
116
139
|
if (!isPub(pub)) throw new Error('genesisActa: missing genesis pubkey')
|
|
117
140
|
return {
|
|
118
141
|
v: ACTA_V,
|
|
@@ -127,6 +150,14 @@ export function genesisActa ({ pub, encPub = null, label = '', now = Date.now()
|
|
|
127
150
|
// Llavero del contenido: una entrada por generación, con la clave del perfil ENVUELTA
|
|
128
151
|
// a cada miembro (ver content.js). Envuelto es público: solo lo abre su destinatario.
|
|
129
152
|
keyring: [],
|
|
153
|
+
// LLAVE DE SELLADO (§8.9 de dotrino-vault/docs/secretos-sellados.md): con ella la
|
|
154
|
+
// bóveda FIRMA los sobres de los secretos, para que se sepa que salieron de ella.
|
|
155
|
+
// Vive aquí y no en un certificado aparte porque rota con el acta: cada acta nueva
|
|
156
|
+
// puede nombrar una llave nueva, y quien selle el acta es —siempre— la maestra.
|
|
157
|
+
// Nace en null: un perfil no tiene por qué sellar secretos.
|
|
158
|
+
sealPub: sealPub || null,
|
|
159
|
+
sealSince: sealPub ? 1 : 0,
|
|
160
|
+
sealKeys: [],
|
|
130
161
|
updatedAt: now
|
|
131
162
|
}
|
|
132
163
|
}
|
|
@@ -134,7 +165,7 @@ export function genesisActa ({ pub, encPub = null, label = '', now = Date.now()
|
|
|
134
165
|
/** Comprobaciones de FORMA (sin cripto): que el acta sea un acta. */
|
|
135
166
|
export function checkShape (acta) {
|
|
136
167
|
if (!acta || typeof acta !== 'object') return 'no-acta'
|
|
137
|
-
if (acta.v
|
|
168
|
+
if (!ACTA_LEIBLES.includes(acta.v)) return 'version'
|
|
138
169
|
if (!isPub(acta.profileId) || !isPub(acta.sealer) || !isPub(acta.sealedBy)) return 'shape'
|
|
139
170
|
if (!Number.isInteger(acta.seq) || acta.seq < 1) return 'seq'
|
|
140
171
|
if (acta.seq > 1 && typeof acta.prev !== 'string') return 'prev'
|
|
@@ -151,6 +182,29 @@ export function checkShape (acta) {
|
|
|
151
182
|
} else if (m.caps.includes('secrets')) {
|
|
152
183
|
return 'secretos-sin-cn'
|
|
153
184
|
}
|
|
185
|
+
// La llave de cifrado: por ahora se valida la FORMA y solo si viene. NO se exige
|
|
186
|
+
// todavía, aunque el destino sea exigírsela a quien recibe secretos.
|
|
187
|
+
//
|
|
188
|
+
// El motivo es que `checkShape` corre en CADA `verifyActa`, también sobre actas ya
|
|
189
|
+
// selladas: exigirla hoy invalidaría de golpe las actas en las que un servicio
|
|
190
|
+
// entró sin ella —que son todas las anteriores a esto— y el vault dejaría de
|
|
191
|
+
// arrancar en vez de avisar. Primero los servicios registran su llave (op
|
|
192
|
+
// `encpub`), y cuando no quede ninguno sin ella se aprieta aquí.
|
|
193
|
+
if (m.encPub != null && !isEncPub(m.encPub)) return 'encpub-invalido'
|
|
194
|
+
}
|
|
195
|
+
// LA LLAVE DE SELLADO Y SU REGISTRO. `sealPub` puede no estar (un perfil que no sella
|
|
196
|
+
// secretos), pero si está tiene que decir DESDE QUÉ acta manda: sin `sealSince` no se
|
|
197
|
+
// puede decidir con qué llave verificar un sobre viejo.
|
|
198
|
+
if (acta.v >= 2) {
|
|
199
|
+
if (acta.sealPub != null) {
|
|
200
|
+
if (!isPub(acta.sealPub)) return 'sealpub-invalido'
|
|
201
|
+
if (!Number.isInteger(acta.sealSince) || acta.sealSince < 1 || acta.sealSince > acta.seq) return 'sealsince'
|
|
202
|
+
} else if (acta.sealSince) return 'sealsince'
|
|
203
|
+
if (!Array.isArray(acta.sealKeys)) return 'sealkeys'
|
|
204
|
+
for (const k of acta.sealKeys) {
|
|
205
|
+
if (!isPub(k?.pub)) return 'sealkey-invalida'
|
|
206
|
+
if (!Number.isInteger(k.from) || !Number.isInteger(k.to) || k.from < 1 || k.to < k.from) return 'sealkey-rango'
|
|
207
|
+
}
|
|
154
208
|
}
|
|
155
209
|
if (new Set(acta.members.map((m) => m.pub)).size !== acta.members.length) return 'miembro-duplicado'
|
|
156
210
|
if (!acta.members.some((m) => m.pub === acta.sealer)) return 'sealer-no-es-miembro'
|
|
@@ -193,7 +247,7 @@ export async function verifyActa ({ acta, expectedProfileId } = {}) {
|
|
|
193
247
|
* Van en ARRAY porque hay combinaciones que deben ser atómicas: admitir al nuevo sellador y
|
|
194
248
|
* traspasarle el master ocurre en el MISMO `seq` (§2.1.3).
|
|
195
249
|
*/
|
|
196
|
-
export async function applyChanges (acta, changes, { by, now = Date.now() } = {}) {
|
|
250
|
+
export async function applyChanges (acta, changes, { by, now = Date.now(), sealPub = null } = {}) {
|
|
197
251
|
const shape = checkShape(acta)
|
|
198
252
|
if (shape) throw new Error('invalid record: ' + shape)
|
|
199
253
|
if (!by) throw new Error('applyChanges: missing `by` (who seals)')
|
|
@@ -204,6 +258,11 @@ export async function applyChanges (acta, changes, { by, now = Date.now() } = {}
|
|
|
204
258
|
|
|
205
259
|
const next = {
|
|
206
260
|
...acta,
|
|
261
|
+
// Asciende de v1 a v2 sin ceremonia: los campos de sellado nacen vacíos y la llave
|
|
262
|
+
// entra en cuanto quien sella pase una (`sealPub`).
|
|
263
|
+
v: ACTA_V,
|
|
264
|
+
sealPub: acta.sealPub || null,
|
|
265
|
+
sealSince: acta.sealPub ? acta.sealSince : 0,
|
|
207
266
|
sealedBy: by,
|
|
208
267
|
seq: acta.seq + 1,
|
|
209
268
|
prev: await actaHash(acta),
|
|
@@ -211,10 +270,24 @@ export async function applyChanges (acta, changes, { by, now = Date.now() } = {}
|
|
|
211
270
|
revoked: [...(acta.revoked || [])],
|
|
212
271
|
renounced: [...(acta.renounced || [])],
|
|
213
272
|
keyring: (acta.keyring || []).map((g) => ({ ...g, wraps: { ...g.wraps } })),
|
|
273
|
+
sealKeys: (acta.sealKeys || []).map((k) => ({ ...k })),
|
|
214
274
|
updatedAt: now
|
|
215
275
|
}
|
|
216
276
|
delete next.sig
|
|
217
277
|
|
|
278
|
+
// LA LLAVE DE SELLADO ROTA CON EL ACTA (§8.9 de `secretos-sellados.md`). Quien sella
|
|
279
|
+
// pasa la llave nueva; si no pasa ninguna, la de antes sigue mandando —un master que no
|
|
280
|
+
// sella secretos (el navegador) no tiene por qué inventarse una—.
|
|
281
|
+
//
|
|
282
|
+
// La anterior NO se tira: se guarda con el tramo de `seq` en el que estuvo en vigor,
|
|
283
|
+
// porque los sobres que firmó tienen que seguir verificando. Re-firmarlos al rotar
|
|
284
|
+
// sería recorrer todos los secretos en cada cambio de membresía.
|
|
285
|
+
if (sealPub && sealPub !== next.sealPub) {
|
|
286
|
+
if (next.sealPub) next.sealKeys.push({ pub: next.sealPub, from: next.sealSince, to: acta.seq })
|
|
287
|
+
next.sealPub = sealPub
|
|
288
|
+
next.sealSince = next.seq
|
|
289
|
+
}
|
|
290
|
+
|
|
218
291
|
const find = (pub) => next.members.find((m) => m.pub === pub)
|
|
219
292
|
|
|
220
293
|
for (const ch of list) {
|
|
@@ -239,6 +312,19 @@ export async function applyChanges (acta, changes, { by, now = Date.now() } = {}
|
|
|
239
312
|
})
|
|
240
313
|
break
|
|
241
314
|
}
|
|
315
|
+
// Registra (o reemplaza) la llave de CIFRADO de un miembro que YA está admitido.
|
|
316
|
+
//
|
|
317
|
+
// Existe para no tener que expulsar y volver a admitir a un servicio solo porque
|
|
318
|
+
// le falta la llave: re-enrolar le cambia la pubkey, y con ella pierde su cajón de
|
|
319
|
+
// variables —que va indexado por esa llave— y se queda sin configuración en
|
|
320
|
+
// silencio. Registrar la llave en el sitio no toca ni `pub`, ni `cn`, ni `caps`.
|
|
321
|
+
case 'encpub': {
|
|
322
|
+
const m = find(ch.pub)
|
|
323
|
+
if (!m) throw new Error('encpub: that member is not in the record')
|
|
324
|
+
if (!isEncPub(ch.encPub)) throw new Error('encpub: invalid encryption key (expected a P-256 public JWK)')
|
|
325
|
+
m.encPub = ch.encPub
|
|
326
|
+
break
|
|
327
|
+
}
|
|
242
328
|
case 'caps': {
|
|
243
329
|
const m = find(ch.pub)
|
|
244
330
|
if (!m) throw new Error('caps: that member is not in the record')
|
|
@@ -332,6 +418,19 @@ export async function applyChanges (acta, changes, { by, now = Date.now() } = {}
|
|
|
332
418
|
return next
|
|
333
419
|
}
|
|
334
420
|
|
|
421
|
+
/**
|
|
422
|
+
* La llave que firmaba los sobres cuando el acta iba por `seq`. Es lo que necesita quien
|
|
423
|
+
* VERIFICA un sobre: el sobre dice con qué acta se selló, y esto dice con qué llave hay
|
|
424
|
+
* que comprobarlo. `null` si no había ninguna (perfil que no sellaba) o si el `seq` viene
|
|
425
|
+
* del futuro, que es un sobre que no puede ser bueno.
|
|
426
|
+
*/
|
|
427
|
+
export function sealKeyAt (acta, seq) {
|
|
428
|
+
if (!acta || !Number.isInteger(seq) || seq < 1 || seq > acta.seq) return null
|
|
429
|
+
if (acta.sealPub && seq >= acta.sealSince) return acta.sealPub
|
|
430
|
+
for (const k of acta.sealKeys || []) if (seq >= k.from && seq <= k.to) return k.pub
|
|
431
|
+
return null
|
|
432
|
+
}
|
|
433
|
+
|
|
335
434
|
// ----- renuncia (§2.2): el único cambio que no pasa por el master -----
|
|
336
435
|
|
|
337
436
|
/** Crea el registro firmado con el que un miembro se QUITA capacidades a sí mismo. */
|
package/vault/capabilities.js
CHANGED
|
@@ -24,6 +24,7 @@ import { canonicalStringify, bufToBase64, base64ToBuf } from './core.js'
|
|
|
24
24
|
import { pubkeyId, keyLabel } from './keyid.js'
|
|
25
25
|
|
|
26
26
|
const ECDSA = { name: 'ECDSA', namedCurve: 'P-256' }
|
|
27
|
+
const ECDH = { name: 'ECDH', namedCurve: 'P-256' }
|
|
27
28
|
const SIGN = { name: 'ECDSA', hash: { name: 'SHA-256' } }
|
|
28
29
|
|
|
29
30
|
/** Tope DURO de vida de una delegación (aunque pidan más). */
|
|
@@ -120,6 +121,44 @@ export async function makeDeviceKey ({ label = '' } = {}) {
|
|
|
120
121
|
return { publickey, privateJwk, publicJwk, label: String(label || ''), createdAt: Date.now(), deviceId: await pubkeyId(publickey) }
|
|
121
122
|
}
|
|
122
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Genera la llave de CIFRADO del dispositivo (ECDH P-256): la hermana de
|
|
126
|
+
* `makeDeviceKey`, que es de FIRMA. Hacen falta las dos y no son intercambiables —
|
|
127
|
+
* con ECDSA no se puede cifrar.
|
|
128
|
+
*
|
|
129
|
+
* Es lo que permite escribirle a un aparato **por adelantado**, sin que esté
|
|
130
|
+
* conectado: su pública va al acta como `encPub` y cualquiera puede envolverle un
|
|
131
|
+
* secreto con `wrapForMember` (ver `./content.js`). La privada no sale de aquí.
|
|
132
|
+
*
|
|
133
|
+
* Sale EXTRAÍBLE porque un servicio headless la persiste en su archivo de identidad
|
|
134
|
+
* (cifrado en reposo). En el navegador la pareja del perfil vive como CryptoKey no
|
|
135
|
+
* extraíble en IndexedDB — eso lo hace `core.js`, no esto.
|
|
136
|
+
*/
|
|
137
|
+
export async function makeDeviceEncKey () {
|
|
138
|
+
const pair = await crypto.subtle.generateKey(ECDH, true, ['deriveBits'])
|
|
139
|
+
const encPrivateJwk = await crypto.subtle.exportKey('jwk', pair.privateKey)
|
|
140
|
+
const encPublicJwk = await crypto.subtle.exportKey('jwk', pair.publicKey)
|
|
141
|
+
return {
|
|
142
|
+
encPublickey: JSON.stringify({ kty: encPublicJwk.kty, crv: encPublicJwk.crv, x: encPublicJwk.x, y: encPublicJwk.y }),
|
|
143
|
+
encPrivateJwk,
|
|
144
|
+
encPublicJwk,
|
|
145
|
+
createdAt: Date.now()
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Reconstruye la llave privada de cifrado desde su JWK. Es lo que hay que pasarle a
|
|
151
|
+
* `openWrap({ myEncPrivateKey })` para abrir un sobre dirigido a este aparato.
|
|
152
|
+
*
|
|
153
|
+
* Existe para que nadie la importe a mano: `deriveBits` es el ÚNICO uso correcto, y
|
|
154
|
+
* pedir otro (`deriveKey`) hace fallar la importación en Node con un error que no
|
|
155
|
+
* dice nada útil.
|
|
156
|
+
*/
|
|
157
|
+
export async function importDeviceEncKey (encPrivateJwk) {
|
|
158
|
+
if (!encPrivateJwk || typeof encPrivateJwk !== 'object') throw new Error('importDeviceEncKey: falta el JWK de la privada')
|
|
159
|
+
return crypto.subtle.importKey('jwk', encPrivateJwk, ECDH, false, ['deriveBits'])
|
|
160
|
+
}
|
|
161
|
+
|
|
123
162
|
/**
|
|
124
163
|
* Firma un certificado de delegación con una `privateKey` (CryptoKey) cuyo pubkey
|
|
125
164
|
* es `iss`. Lo usa el handler del vault (con la clave maestra). Devuelve el cert
|
package/vault/content.js
CHANGED
|
@@ -111,6 +111,19 @@ export async function encryptWithCek ({ cek, gen, plaintext }) {
|
|
|
111
111
|
return { gen, iv: b64(iv), ct: b64(ct) }
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Descifra un sobre teniendo YA la CEK. Es el inverso exacto de `encryptWithCek`.
|
|
116
|
+
*
|
|
117
|
+
* Existe para quien administra: tiene la clave a mano (la sacó de donde la guarde) y no
|
|
118
|
+
* necesita el llavero ni ser miembro. El camino normal —el del aparato que solo tiene su
|
|
119
|
+
* propia llave— es `decryptWithKeyring`.
|
|
120
|
+
*/
|
|
121
|
+
export async function decryptWithCek ({ cek, envelope }) {
|
|
122
|
+
const k = await subtle.importKey('raw', fromB64(cek), { name: 'AES-GCM' }, false, ['decrypt'])
|
|
123
|
+
const pt = await subtle.decrypt({ name: 'AES-GCM', iv: fromB64(envelope.iv) }, k, fromB64(envelope.ct))
|
|
124
|
+
return new TextDecoder().decode(pt)
|
|
125
|
+
}
|
|
126
|
+
|
|
114
127
|
/**
|
|
115
128
|
* Descifra un sobre. Hay que darle el llavero porque el contenido viejo está cifrado con
|
|
116
129
|
* generaciones anteriores: por eso las CEK antiguas se conservan (32 bytes cada una) en vez
|
|
@@ -128,5 +141,5 @@ export async function decryptWithKeyring ({ envelope, keyring, myPub, myEncPriva
|
|
|
128
141
|
|
|
129
142
|
export default {
|
|
130
143
|
makeContentKey, wrapForMember, openWrap, makeGeneration, myContentKey,
|
|
131
|
-
encryptWithCek, decryptWithKeyring
|
|
144
|
+
encryptWithCek, decryptWithCek, decryptWithKeyring
|
|
132
145
|
}
|
package/vault/core.js
CHANGED
|
@@ -542,6 +542,13 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
542
542
|
/** ¿Es ESTE dispositivo el master (el único que puede sellar)? */
|
|
543
543
|
const amMaster = () => loadActa()?.sealer === publickeyJwkStr
|
|
544
544
|
|
|
545
|
+
/**
|
|
546
|
+
* Quien sella SOBRES (la bóveda) pone aquí una función que estrena una llave de sellado
|
|
547
|
+
* y devuelve su pública. Se llama en cada acta, porque esa llave rota con el acta.
|
|
548
|
+
* Un navegador no sella secretos: se queda en `null` y el acta no lleva llave.
|
|
549
|
+
*/
|
|
550
|
+
let sealKeyProvider = null
|
|
551
|
+
|
|
545
552
|
/** Sella con la llave del perfil (CryptoKey, puede ser no extractable). */
|
|
546
553
|
const seal = (acta) => Acta.sealActa({ acta, privateKey: keypair.privateKey })
|
|
547
554
|
|
|
@@ -552,7 +559,15 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
552
559
|
async function sealChanges (changes) {
|
|
553
560
|
const acta = loadActa()
|
|
554
561
|
if (!acta) throw new Error('this profile has no record yet')
|
|
555
|
-
|
|
562
|
+
// LA LLAVE DE SELLADO ROTA CON EL ACTA (§8.9 de `secretos-sellados.md`): si quien usa
|
|
563
|
+
// esta identidad sella secretos —la bóveda—, le pedimos una llave nueva para nombrarla
|
|
564
|
+
// aquí. Si no hay proveedor, o falla, el acta sale igual con la llave de antes: no
|
|
565
|
+
// admitir un aparato porque no se pudo estrenar una llave sería el peor de los canjes.
|
|
566
|
+
let sealPub = null
|
|
567
|
+
if (sealKeyProvider) {
|
|
568
|
+
try { sealPub = await sealKeyProvider() } catch (_) { sealPub = null }
|
|
569
|
+
}
|
|
570
|
+
const next = await Acta.applyChanges(acta, changes, { by: publickeyJwkStr, sealPub })
|
|
556
571
|
const sealed = await seal(next)
|
|
557
572
|
pushHistory(acta) // la que deja de ser vigente entra en la ventana de retención
|
|
558
573
|
saveActa(sealed)
|
|
@@ -1447,6 +1462,23 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1447
1462
|
return { acta, isMaster: amMaster(), myCaps: Acta.effectiveCaps(acta, publickeyJwkStr, loadRenounces()) }
|
|
1448
1463
|
},
|
|
1449
1464
|
|
|
1465
|
+
/**
|
|
1466
|
+
* Quien sella sobres de secretos (la bóveda) registra aquí cómo estrenar su LLAVE DE
|
|
1467
|
+
* SELLADO: una función que crea el par, se guarda la privada y devuelve la pública. Se
|
|
1468
|
+
* llama al sellar cada acta, que es cuando esa llave rota (§8.9).
|
|
1469
|
+
*
|
|
1470
|
+
* Es opt-in a propósito: una identidad de navegador no sella nada, y un acta sin llave
|
|
1471
|
+
* de sellado es perfectamente válida.
|
|
1472
|
+
*/
|
|
1473
|
+
setSealKeyProvider (fn) {
|
|
1474
|
+
sealKeyProvider = typeof fn === 'function' ? fn : null
|
|
1475
|
+
},
|
|
1476
|
+
|
|
1477
|
+
/** La llave con la que se firmaron los sobres de un acta dada. Para VERIFICARLOS. */
|
|
1478
|
+
sealKeyAt (seq) {
|
|
1479
|
+
return Acta.sealKeyAt(loadActa(), seq)
|
|
1480
|
+
},
|
|
1481
|
+
|
|
1450
1482
|
async profileMembers () {
|
|
1451
1483
|
const acta = loadActa()
|
|
1452
1484
|
if (!acta) return { members: [], profileId: null, seq: 0, sealer: null }
|
|
@@ -1460,7 +1492,13 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1460
1492
|
caps: Acta.effectiveCaps(acta, m.pub, pend),
|
|
1461
1493
|
addedAt: m.addedAt || null,
|
|
1462
1494
|
isMe: m.pub === publickeyJwkStr,
|
|
1463
|
-
isMaster: m.pub === acta.sealer
|
|
1495
|
+
isMaster: m.pub === acta.sealer,
|
|
1496
|
+
// La llave de CIFRADO del miembro y, derivado, si se le puede envolver algo.
|
|
1497
|
+
// Sale en la proyección porque sin ella ninguna interfaz (CLI, TUI, consola)
|
|
1498
|
+
// puede decir «a este aparato no se le puede escribir», y ese silencio es
|
|
1499
|
+
// justo el que hace que un secreto no llegue y nadie sepa por qué.
|
|
1500
|
+
encPub: m.encPub || null,
|
|
1501
|
+
canSeal: !!m.encPub
|
|
1464
1502
|
})))
|
|
1465
1503
|
return { members, profileId: acta.profileId, seq: acta.seq, sealer: acta.sealer, updatedAt: acta.updatedAt }
|
|
1466
1504
|
},
|
|
@@ -1481,6 +1519,19 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1481
1519
|
|
|
1482
1520
|
async isMaster () { return amMaster() },
|
|
1483
1521
|
|
|
1522
|
+
/**
|
|
1523
|
+
* Registra la llave de CIFRADO de un miembro ya admitido (solo el master).
|
|
1524
|
+
*
|
|
1525
|
+
* Es la alternativa a expulsarlo y volver a admitirlo: un servicio re-enrolado
|
|
1526
|
+
* estrena pubkey, y su cajón de variables va indexado por la vieja, así que
|
|
1527
|
+
* arrancaría sin configuración y sin decirlo. Esto le pone la llave sin moverle
|
|
1528
|
+
* nada más.
|
|
1529
|
+
*/
|
|
1530
|
+
async setMemberEncPub ({ pub, encPub } = {}) {
|
|
1531
|
+
const acta = await sealChanges([{ op: 'encpub', pub, encPub }])
|
|
1532
|
+
return { ok: true, seq: acta.seq }
|
|
1533
|
+
},
|
|
1534
|
+
|
|
1484
1535
|
/**
|
|
1485
1536
|
* Admite un miembro (solo el master). El cert lo emite quien llama, antes o después.
|
|
1486
1537
|
* `continuity`: si esa identidad ya existía por su cuenta, su puente firmado (F3).
|