@dotrino/identity 0.29.0 → 0.31.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 +5 -0
- package/src/node.js +5 -0
- package/vault/acta.js +147 -10
- package/vault/core.js +65 -11
- package/vault/vendor/vault/enroll.js +16 -2
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -299,6 +299,11 @@ export class Identity {
|
|
|
299
299
|
|
|
300
300
|
/** Adopta un acta recibida de otro miembro (gana el seq mayor; a igual seq, el traspaso). */
|
|
301
301
|
async adoptActa (acta) { return this._call('adoptActa', { acta }) }
|
|
302
|
+
/** MI tarjeta de perfil: lo mínimo que un contacto necesita para cifrarme a todos mis
|
|
303
|
+
* dispositivos (perfil, versión y llaves). Sin etiquetas ni permisos. */
|
|
304
|
+
async profileCard () { return this._call('profileCard') }
|
|
305
|
+
/** Guarda la tarjeta de otra persona en su ficha de contacto (verificándola). */
|
|
306
|
+
async adoptPeerCard (card) { return this._call('adoptPeerCard', { card }) }
|
|
302
307
|
/** La clave de contenido del perfil, abierta con la llave de cifrado de este dispositivo. */
|
|
303
308
|
async contentKey () { return this._call('contentKey') }
|
|
304
309
|
/** Cifra con la clave de contenido del perfil (la privada de cifrado no sale del vault). */
|
package/src/node.js
CHANGED
|
@@ -173,6 +173,11 @@ export class Identity {
|
|
|
173
173
|
renounceCaps (caps) { return this._h('renounceCaps', { caps }) }
|
|
174
174
|
absorbRenounce (record) { return this._h('absorbRenounce', { record }) }
|
|
175
175
|
adoptActa (acta) { return this._h('adoptActa', { acta }) }
|
|
176
|
+
/** MI tarjeta de perfil: lo mínimo que un contacto necesita para cifrarme a todos mis
|
|
177
|
+
* dispositivos (perfil, versión y llaves). Sin etiquetas ni permisos. */
|
|
178
|
+
profileCard () { return this._h('profileCard') }
|
|
179
|
+
/** Guarda la tarjeta de otra persona en su ficha de contacto (verificándola). */
|
|
180
|
+
adoptPeerCard (card) { return this._h('adoptPeerCard', { card }) }
|
|
176
181
|
/** La clave de contenido del perfil, abierta con la llave de cifrado de este dispositivo. */
|
|
177
182
|
contentKey () { return this._h('contentKey') }
|
|
178
183
|
/** Cifra con la clave de contenido del perfil (la privada de cifrado no sale del vault). */
|
package/vault/acta.js
CHANGED
|
@@ -32,10 +32,37 @@ import { signWithDevice, verifyDeviceSig, pubkeyId } from './capabilities.js'
|
|
|
32
32
|
|
|
33
33
|
export const ACTA_V = 1
|
|
34
34
|
|
|
35
|
-
/**
|
|
36
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Lista CERRADA de capacidades. Sellar y admitir no están: eso es ser el master.
|
|
37
|
+
*
|
|
38
|
+
* `secrets` es distinta de las otras tres: solo la pueden tener los miembros con **CN**
|
|
39
|
+
* (los servicios), y lo que abre es únicamente el cajón de SU nombre. Ver `cn` abajo.
|
|
40
|
+
*/
|
|
41
|
+
export const CAPS = Object.freeze(['sign', 'store', 'read', 'secrets'])
|
|
42
|
+
|
|
43
|
+
/** Capacidades de un DISPOSITIVO (sin CN): acceso a todo lo del usuario. */
|
|
44
|
+
export const DEVICE_CAPS = Object.freeze(['sign', 'store', 'read'])
|
|
45
|
+
|
|
46
|
+
/** Capacidades de un SERVICIO (con CN): solo su propio cajón de secretos. */
|
|
47
|
+
export const SERVICE_CAPS = Object.freeze(['secrets'])
|
|
48
|
+
|
|
49
|
+
/** Un CN válido: minúsculas, números y guiones (igual que el namespace de secretos). */
|
|
50
|
+
export const isValidCn = (cn) => typeof cn === 'string' && /^[a-z0-9-]{1,32}$/.test(cn)
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Scope del cert que corresponde a cada capacidad. `secrets` necesita el CN para
|
|
54
|
+
* completarse: el miembro `proxy` obtiene `vault:secrets:proxy` y nada más — no existe un
|
|
55
|
+
* scope de secretos «de todos».
|
|
56
|
+
*/
|
|
57
|
+
export function capScope (cap, cn = null) {
|
|
58
|
+
if (cap === 'sign') return 'vault:sign'
|
|
59
|
+
if (cap === 'store') return 'vault:store'
|
|
60
|
+
if (cap === 'read') return 'vault:read'
|
|
61
|
+
if (cap === 'secrets') return isValidCn(cn) ? 'vault:secrets:' + cn : null
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
37
64
|
|
|
38
|
-
/**
|
|
65
|
+
/** Compat: el mapa directo, para las tres capacidades de dispositivo. */
|
|
39
66
|
export const CAP_SCOPE = Object.freeze({ sign: 'vault:sign', store: 'vault:store', read: 'vault:read' })
|
|
40
67
|
|
|
41
68
|
const enc = (s) => new TextEncoder().encode(s)
|
|
@@ -43,9 +70,12 @@ const hex = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart
|
|
|
43
70
|
const isPub = (v) => typeof v === 'string' && v.length > 0
|
|
44
71
|
const cleanCaps = (caps) => [...new Set((Array.isArray(caps) ? caps : []).filter((c) => CAPS.includes(c)))].sort()
|
|
45
72
|
|
|
46
|
-
/**
|
|
73
|
+
/**
|
|
74
|
+
* El acta SIN la firma ni la tarjeta: es lo que se sella y sobre lo que se calcula el hash.
|
|
75
|
+
* La `card` va aparte porque lleva su propia firma y se comparte sola (ver `makeProfileCard`).
|
|
76
|
+
*/
|
|
47
77
|
export function actaBody (acta) {
|
|
48
|
-
const { sig, ...body } = acta || {}
|
|
78
|
+
const { sig, card, ...body } = acta || {}
|
|
49
79
|
return body
|
|
50
80
|
}
|
|
51
81
|
|
|
@@ -77,7 +107,7 @@ export function genesisActa ({ pub, encPub = null, label = '', now = Date.now()
|
|
|
77
107
|
sealedBy: pub,
|
|
78
108
|
seq: 1,
|
|
79
109
|
prev: null,
|
|
80
|
-
members: [{ pub, encPub, label: String(label || '').slice(0, 60), caps: [...
|
|
110
|
+
members: [{ pub, encPub, label: String(label || '').slice(0, 60), cn: null, caps: [...DEVICE_CAPS], addedAt: now, cert: null }],
|
|
81
111
|
revoked: [],
|
|
82
112
|
renounced: [],
|
|
83
113
|
// Llavero del contenido: una entrada por generación, con la clave del perfil ENVUELTA
|
|
@@ -98,6 +128,15 @@ export function checkShape (acta) {
|
|
|
98
128
|
for (const m of acta.members) {
|
|
99
129
|
if (!isPub(m?.pub) || !Array.isArray(m?.caps)) return 'member'
|
|
100
130
|
if (m.caps.some((c) => !CAPS.includes(c))) return 'cap-desconocida'
|
|
131
|
+
// El CN es la frontera: un SERVICIO solo puede abrir su propio cajón, y un
|
|
132
|
+
// DISPOSITIVO no tiene cajón que abrir. Que no se pueda escribir un acta que
|
|
133
|
+
// mezcle las dos cosas es lo que hace que el límite sea real y no una costumbre.
|
|
134
|
+
if (m.cn != null) {
|
|
135
|
+
if (!isValidCn(m.cn)) return 'cn-invalido'
|
|
136
|
+
if (m.caps.some((c) => !SERVICE_CAPS.includes(c))) return 'servicio-con-capacidades-de-dispositivo'
|
|
137
|
+
} else if (m.caps.includes('secrets')) {
|
|
138
|
+
return 'secretos-sin-cn'
|
|
139
|
+
}
|
|
101
140
|
}
|
|
102
141
|
if (new Set(acta.members.map((m) => m.pub)).size !== acta.members.length) return 'miembro-duplicado'
|
|
103
142
|
if (!acta.members.some((m) => m.pub === acta.sealer)) return 'sealer-no-es-miembro'
|
|
@@ -110,7 +149,11 @@ export async function sealActa ({ acta, privateKey, privateJwk }) {
|
|
|
110
149
|
const shape = checkShape(acta)
|
|
111
150
|
if (shape) throw new Error('acta inválida: ' + shape)
|
|
112
151
|
const { signature } = await signWithDevice({ privateKey, privateJwk, publickey: acta.sealedBy, data: actaBody(acta) })
|
|
113
|
-
|
|
152
|
+
const sealed = { ...acta, sig: signature }
|
|
153
|
+
// La TARJETA se firma en el mismo gesto y viaja con el acta: así cualquier miembro puede
|
|
154
|
+
// entregársela a un contacto sin ser el master y sin contarle nada de más.
|
|
155
|
+
sealed.card = await makeProfileCard({ acta: sealed, privateKey, privateJwk })
|
|
156
|
+
return sealed
|
|
114
157
|
}
|
|
115
158
|
|
|
116
159
|
/**
|
|
@@ -166,11 +209,15 @@ export async function applyChanges (acta, changes, { by, now = Date.now() } = {}
|
|
|
166
209
|
const m = ch.member
|
|
167
210
|
if (!isPub(m?.pub)) throw new Error('admit: falta la pubkey del miembro')
|
|
168
211
|
if (find(m.pub)) throw new Error('admit: ese miembro ya está en el acta')
|
|
212
|
+
const cn = m.cn != null ? String(m.cn) : null
|
|
213
|
+
if (cn !== null && !isValidCn(cn)) throw new Error('admit: CN inválido (minúsculas, números y guiones)')
|
|
169
214
|
next.members.push({
|
|
170
215
|
pub: m.pub,
|
|
171
216
|
encPub: m.encPub || null,
|
|
172
217
|
label: String(m.label || '').slice(0, 60),
|
|
173
|
-
|
|
218
|
+
cn,
|
|
219
|
+
// Un servicio (con CN) solo puede tener `secrets`; un dispositivo, las otras tres.
|
|
220
|
+
caps: cleanCaps(m.caps).filter((c) => (cn ? SERVICE_CAPS : DEVICE_CAPS).includes(c)),
|
|
174
221
|
addedAt: now,
|
|
175
222
|
cert: m.cert || null,
|
|
176
223
|
// Puente con la identidad que este miembro traía de antes (ver makeContinuity).
|
|
@@ -181,7 +228,9 @@ export async function applyChanges (acta, changes, { by, now = Date.now() } = {}
|
|
|
181
228
|
case 'caps': {
|
|
182
229
|
const m = find(ch.pub)
|
|
183
230
|
if (!m) throw new Error('caps: ese miembro no está en el acta')
|
|
184
|
-
|
|
231
|
+
// No se puede ascender un servicio a dispositivo cambiándole las capacidades: para
|
|
232
|
+
// eso hay que sacarlo y volver a admitirlo, que es un gesto visible.
|
|
233
|
+
m.caps = cleanCaps(ch.caps).filter((c) => (m.cn ? SERVICE_CAPS : DEVICE_CAPS).includes(c))
|
|
185
234
|
break
|
|
186
235
|
}
|
|
187
236
|
case 'remove': {
|
|
@@ -284,11 +333,98 @@ export function effectiveCaps (acta, pub, extraRenounces = []) {
|
|
|
284
333
|
return m.caps.filter((c) => !quitadas.has(c))
|
|
285
334
|
}
|
|
286
335
|
|
|
336
|
+
/**
|
|
337
|
+
* ¿Puede este miembro leer el cajón de secretos `ns`? Solo si su CN es exactamente ése.
|
|
338
|
+
* Es la frontera que pediste: la llave del proxy no ve nada más que lo del proxy.
|
|
339
|
+
*/
|
|
340
|
+
export function memberCanReadSecrets (acta, pub, ns) {
|
|
341
|
+
const m = (acta?.members || []).find((x) => x.pub === pub)
|
|
342
|
+
if (!m || !m.cn) return false
|
|
343
|
+
return m.cn === ns && effectiveCaps(acta, pub).includes('secrets')
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Los scopes de cert que le corresponden a un miembro según el acta. */
|
|
347
|
+
export function memberScopes (acta, pub, extraRenounces = []) {
|
|
348
|
+
const m = (acta?.members || []).find((x) => x.pub === pub)
|
|
349
|
+
if (!m) return []
|
|
350
|
+
return effectiveCaps(acta, pub, extraRenounces).map((c) => capScope(c, m.cn)).filter(Boolean)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** ¿Es un servicio (tiene CN) o un dispositivo del usuario? */
|
|
354
|
+
export const isService = (acta, pub) => !!(acta?.members || []).find((x) => x.pub === pub)?.cn
|
|
355
|
+
|
|
287
356
|
/** ¿Puede este miembro hacer `cap` según el acta? (con el cert se cruza aparte: cert ∩ acta). */
|
|
288
357
|
export function memberCan (acta, pub, cap, extraRenounces = []) {
|
|
289
358
|
return effectiveCaps(acta, pub, extraRenounces).includes(cap)
|
|
290
359
|
}
|
|
291
360
|
|
|
361
|
+
// ----- TARJETA DE PERFIL: lo mínimo que se comparte con otra persona -----
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Para escribirle a alguien cifrado hay que conocer las llaves de CIFRADO de todos sus
|
|
365
|
+
* dispositivos — si no, el mensaje solo lo abre el aparato desde el que hablaste. Pero
|
|
366
|
+
* pasarle el ACTA a un contacto le contaría de más: cuántos dispositivos tienes, cómo se
|
|
367
|
+
* llaman y qué puede cada uno. Nada de eso es asunto suyo.
|
|
368
|
+
*
|
|
369
|
+
* Así que se comparte una TARJETA: el mínimo imprescindible —el perfil, su versión y las
|
|
370
|
+
* llaves— firmada por el master, y por lo tanto verificable sin tener el acta. Sin
|
|
371
|
+
* etiquetas, sin permisos, sin certificados.
|
|
372
|
+
*
|
|
373
|
+
* La firma el MASTER al sellar, y viaja con el acta, para que cualquier miembro pueda
|
|
374
|
+
* entregarla aunque no sea él quien manda.
|
|
375
|
+
*/
|
|
376
|
+
export function cardBody (acta) {
|
|
377
|
+
return {
|
|
378
|
+
v: 1,
|
|
379
|
+
profileId: acta.profileId,
|
|
380
|
+
seq: acta.seq,
|
|
381
|
+
sealedBy: acta.sealedBy,
|
|
382
|
+
// Solo las llaves. Los servicios (con CN) NO van: no son la persona, y no tienen por
|
|
383
|
+
// qué recibir los mensajes de nadie.
|
|
384
|
+
keys: acta.members
|
|
385
|
+
.filter((m) => !m.cn && m.encPub)
|
|
386
|
+
.map((m) => ({ pub: m.pub, encPub: m.encPub }))
|
|
387
|
+
.sort((a, b) => (a.pub < b.pub ? -1 : 1)),
|
|
388
|
+
iat: acta.updatedAt
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Firma la tarjeta de este acta (lo hace el master al sellar). */
|
|
393
|
+
export async function makeProfileCard ({ acta, privateKey, privateJwk }) {
|
|
394
|
+
const body = cardBody(acta)
|
|
395
|
+
const { signature } = await signWithDevice({ privateKey, privateJwk, publickey: acta.sealedBy, data: body })
|
|
396
|
+
return { ...body, sig: signature }
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** Verifica que la tarjeta la firmó quien dice (`sealedBy`). Que ÉSE sea el master de esa
|
|
400
|
+
* persona se decide al adoptarla, comparándola con la que ya tenías (`canAdoptCard`). */
|
|
401
|
+
export async function verifyProfileCard (card) {
|
|
402
|
+
if (!card || card.v !== 1 || !isPub(card.profileId) || !isPub(card.sealedBy)) return false
|
|
403
|
+
if (!Number.isInteger(card.seq) || !Array.isArray(card.keys) || typeof card.sig !== 'string') return false
|
|
404
|
+
const { sig, ...body } = card
|
|
405
|
+
return verifyDeviceSig({ publickey: card.sealedBy, data: body, signature: sig })
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* ¿Me quedo con esta tarjeta en vez de la que tenía de esa persona?
|
|
410
|
+
* · la primera vez, sí (confías en el contacto que agregaste: es el mismo criterio
|
|
411
|
+
* que ya usas al añadirlo);
|
|
412
|
+
* · después, solo si el `seq` es mayor o igual Y la firmó el mismo master que la
|
|
413
|
+
* anterior — así nadie te cuela dispositivos ajenos en el perfil de tu contacto,
|
|
414
|
+
* y nunca se retrocede a una versión vieja.
|
|
415
|
+
* Si el master cambió legítimamente (traspaso), la tarjeta nueva la firma el entrante y
|
|
416
|
+
* hay que re-confirmarla: se devuelve `master-cambiado` para que la app lo diga en vez de
|
|
417
|
+
* aceptarlo en silencio.
|
|
418
|
+
*/
|
|
419
|
+
export async function canAdoptCard ({ card, current }) {
|
|
420
|
+
if (!(await verifyProfileCard(card))) return { adopt: false, reason: 'firma-invalida' }
|
|
421
|
+
if (current && current.profileId !== card.profileId) return { adopt: false, reason: 'otro-perfil' }
|
|
422
|
+
if (!current) return { adopt: true, reason: 'primera-vez' }
|
|
423
|
+
if (card.seq < current.seq) return { adopt: false, reason: 'seq-menor' }
|
|
424
|
+
if (card.sealedBy !== current.sealedBy) return { adopt: false, reason: 'master-cambiado' }
|
|
425
|
+
return { adopt: true, reason: card.seq > current.seq ? 'seq-mayor' : 'igual' }
|
|
426
|
+
}
|
|
427
|
+
|
|
292
428
|
// ----- continuidad: unir una identidad que ya existía -----
|
|
293
429
|
|
|
294
430
|
/**
|
|
@@ -362,5 +498,6 @@ export default {
|
|
|
362
498
|
ACTA_V, CAPS, CAP_SCOPE, genesisActa, actaBody, actaHash, memberId, checkShape, isHandover,
|
|
363
499
|
sealActa, verifyActa, applyChanges, makeRenounce, verifyRenounce,
|
|
364
500
|
makeContinuity, verifyContinuity,
|
|
365
|
-
|
|
501
|
+
cardBody, makeProfileCard, verifyProfileCard, canAdoptCard,
|
|
502
|
+
effectiveCaps, memberCan, memberCanReadSecrets, memberScopes, isService, capScope, isValidCn, canAdopt
|
|
366
503
|
}
|
package/vault/core.js
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
import { signDelegationWith, MAX_DELEGATION_MS, DEFAULT_DELEGATION_MS } from './capabilities.js'
|
|
22
22
|
import * as Acta from './acta.js'
|
|
23
23
|
import * as Content from './content.js'
|
|
24
|
+
import { pubkeyId as pubkeyIdOf } from './capabilities.js'
|
|
24
25
|
import { enrollDevice as remoteEnroll, requestSign as remoteSign, requestStore as remoteStore, requestDevices as remoteDevices, requestRenew as remoteRenew } from './remote.js'
|
|
25
26
|
|
|
26
27
|
export const KEY_STORAGE = 'dotrino.identity.keypair'
|
|
@@ -319,6 +320,9 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
319
320
|
if (e && /\brevoked\b/.test(e.message || '')) emitVault({ phase: 'rejected', reason: e.message })
|
|
320
321
|
throw e
|
|
321
322
|
}
|
|
323
|
+
/** Id estable y corto de una llave de cifrado: con esto se indexan las envolturas. */
|
|
324
|
+
const encKeyId = async (encPub) => (await pubkeyIdOf(encPub)).slice(0, 16)
|
|
325
|
+
|
|
322
326
|
const loadVaultCert = () => { try { return JSON.parse(kv.getItem(VAULT_CERT_STORAGE) || 'null') } catch (_) { return null } }
|
|
323
327
|
const loadVaultDevice = () => {
|
|
324
328
|
try {
|
|
@@ -1071,6 +1075,8 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1071
1075
|
pub: m.pub,
|
|
1072
1076
|
id: await Acta.memberId(m.pub),
|
|
1073
1077
|
label: m.label || '',
|
|
1078
|
+
cn: m.cn || null,
|
|
1079
|
+
isService: !!m.cn,
|
|
1074
1080
|
caps: Acta.effectiveCaps(acta, m.pub, pend),
|
|
1075
1081
|
addedAt: m.addedAt || null,
|
|
1076
1082
|
isMe: m.pub === publickeyJwkStr,
|
|
@@ -1099,8 +1105,11 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1099
1105
|
* Admite un miembro (solo el master). El cert lo emite quien llama, antes o después.
|
|
1100
1106
|
* `continuity`: si esa identidad ya existía por su cuenta, su puente firmado (F3).
|
|
1101
1107
|
*/
|
|
1102
|
-
async admitMember ({ pub, encPub = null, label = '',
|
|
1103
|
-
|
|
1108
|
+
async admitMember ({ pub, encPub = null, label = '', cn = null, caps = null, cert = null, continuity = null } = {}) {
|
|
1109
|
+
// `cn`: si viene, este miembro es un SERVICIO y su único permiso es abrir el cajón de
|
|
1110
|
+
// secretos de ESE nombre — no ve nada más del usuario. Sin `cn` es un dispositivo.
|
|
1111
|
+
const finales = caps || (cn ? ['secrets'] : ['store', 'read'])
|
|
1112
|
+
const acta = await sealChanges([{ op: 'admit', member: { pub, encPub, label, cn, caps: finales, cert, continuity } }])
|
|
1104
1113
|
// Que entre al perfil incluye poder LEER lo que ya hay: se le envuelve la clave
|
|
1105
1114
|
// vigente (no hace falta rotar; rotar es para cuando alguien SALE).
|
|
1106
1115
|
let wrapped = false
|
|
@@ -1203,6 +1212,28 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1203
1212
|
return { chain: all.sort((a, b) => a.seq - b.seq), window: ACTA_WINDOW }
|
|
1204
1213
|
},
|
|
1205
1214
|
|
|
1215
|
+
/**
|
|
1216
|
+
* MI tarjeta de perfil: lo mínimo que se le pasa a un contacto para que pueda cifrarme a
|
|
1217
|
+
* todos mis dispositivos (perfil, versión y llaves de cifrado). Sin etiquetas, sin
|
|
1218
|
+
* permisos, sin certificados: lo demás no es asunto de nadie.
|
|
1219
|
+
*/
|
|
1220
|
+
async profileCard () { return loadActa()?.card || null },
|
|
1221
|
+
|
|
1222
|
+
/**
|
|
1223
|
+
* Guarda la tarjeta de OTRA persona en su ficha de contacto. La primera vez se acepta
|
|
1224
|
+
* (es el mismo criterio con el que agregaste el contacto); después solo si no retrocede
|
|
1225
|
+
* y la firmó el mismo master. Si el master cambió, se avisa en vez de aceptarlo callando.
|
|
1226
|
+
*/
|
|
1227
|
+
async adoptPeerCard ({ card } = {}) {
|
|
1228
|
+
if (!card?.profileId) throw new Error('tarjeta inválida')
|
|
1229
|
+
const peers = loadPeers()
|
|
1230
|
+
const prev = peers[card.profileId]?.card || null
|
|
1231
|
+
const r = await Acta.canAdoptCard({ card, current: prev })
|
|
1232
|
+
if (!r.adopt) return { adopted: false, reason: r.reason, devices: (prev?.keys || []).length }
|
|
1233
|
+
upsertPeer(card.profileId, { card, profileId: card.profileId })
|
|
1234
|
+
return { adopted: true, reason: r.reason, devices: card.keys.length }
|
|
1235
|
+
},
|
|
1236
|
+
|
|
1206
1237
|
/** Adopta un acta que llega de otro miembro (gana el seq mayor; a igual seq, el traspaso). */
|
|
1207
1238
|
async adoptActa ({ acta } = {}) { return adoptActa(acta) },
|
|
1208
1239
|
|
|
@@ -1356,6 +1387,15 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1356
1387
|
|
|
1357
1388
|
async getEncryptionPubkey () { return encPublickeyJwkStr },
|
|
1358
1389
|
|
|
1390
|
+
/**
|
|
1391
|
+
* Cifra para uno o varios destinatarios. Dos cosas que cambian respecto de antes:
|
|
1392
|
+
*
|
|
1393
|
+
* 1. **El sobre va atado a la LLAVE, no a la conexión.** Cada envoltura se indexa por un
|
|
1394
|
+
* id derivado de la llave de cifrado del destinatario, en vez del token del proxy: así
|
|
1395
|
+
* puede abrirla también un dispositivo que no estaba conectado cuando se envió.
|
|
1396
|
+
* 2. **Se cifra a TODOS los dispositivos de esa persona**, si conocemos su tarjeta de
|
|
1397
|
+
* perfil (§tarjeta). Le escribes a la persona, no al aparato desde el que te habló.
|
|
1398
|
+
*/
|
|
1359
1399
|
async encrypt ({ recipients, plaintext }) {
|
|
1360
1400
|
if (!Array.isArray(recipients) || recipients.length === 0) throw new Error('recipients required')
|
|
1361
1401
|
if (typeof plaintext !== 'string') throw new Error('plaintext required')
|
|
@@ -1363,26 +1403,40 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1363
1403
|
const kRaw = await crypto.subtle.exportKey('raw', k)
|
|
1364
1404
|
const iv = crypto.getRandomValues(new Uint8Array(12))
|
|
1365
1405
|
const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, k, new TextEncoder().encode(plaintext))
|
|
1366
|
-
|
|
1406
|
+
|
|
1407
|
+
// Expandir cada destinatario a TODOS los dispositivos de su perfil que conozcamos.
|
|
1408
|
+
const encPubs = new Set()
|
|
1409
|
+
const peers = loadPeers()
|
|
1367
1410
|
for (const r of recipients) {
|
|
1368
|
-
if (!r
|
|
1411
|
+
if (!r) continue
|
|
1412
|
+
if (r.encryptionPubkey) encPubs.add(r.encryptionPubkey)
|
|
1413
|
+
const card = (r.publickey && peers[r.publickey]?.card) || null
|
|
1414
|
+
for (const kk of (card?.keys || [])) if (kk.encPub) encPubs.add(kk.encPub)
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
const wrap = {}
|
|
1418
|
+
for (const encPub of encPubs) {
|
|
1369
1419
|
try {
|
|
1370
|
-
const peerPub = await importPeerEncPubkey(
|
|
1420
|
+
const peerPub = await importPeerEncPubkey(encPub)
|
|
1371
1421
|
const sharedKey = await deriveSharedAesKey(encKeypair.privateKey, peerPub)
|
|
1372
1422
|
const wrapIv = crypto.getRandomValues(new Uint8Array(12))
|
|
1373
1423
|
const wrappedCt = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: wrapIv }, sharedKey, kRaw)
|
|
1374
|
-
wrap[
|
|
1424
|
+
wrap[await encKeyId(encPub)] = { iv: bufToBase64(wrapIv), ct: bufToBase64(new Uint8Array(wrappedCt)) }
|
|
1375
1425
|
} catch (e) { /* destinatario omitido */ }
|
|
1376
1426
|
}
|
|
1377
|
-
return { v:
|
|
1427
|
+
return { v: 2, iv: bufToBase64(iv), ct: bufToBase64(new Uint8Array(ct)), wrap }
|
|
1378
1428
|
},
|
|
1379
1429
|
|
|
1430
|
+
/**
|
|
1431
|
+
* Descifra. Busca MI envoltura por el id de mi llave de cifrado (v2); `myToken` ya no
|
|
1432
|
+
* hace falta y se acepta solo por compatibilidad de llamada.
|
|
1433
|
+
*/
|
|
1380
1434
|
async decrypt ({ senderEncryptionPubkey, myToken, envelope }) {
|
|
1381
1435
|
if (!senderEncryptionPubkey) throw new Error('senderEncryptionPubkey required')
|
|
1382
|
-
if (!
|
|
1383
|
-
|
|
1384
|
-
const myEntry = envelope.wrap && envelope.wrap[myToken]
|
|
1385
|
-
if (!myEntry) throw new Error('
|
|
1436
|
+
if (!envelope || (envelope.v !== 1 && envelope.v !== 2)) throw new Error('Unsupported envelope')
|
|
1437
|
+
const myId = await encKeyId(encPublickeyJwkStr)
|
|
1438
|
+
const myEntry = envelope.wrap && (envelope.wrap[myId] || (myToken ? envelope.wrap[myToken] : null))
|
|
1439
|
+
if (!myEntry) throw new Error('este dispositivo no está entre los destinatarios del mensaje')
|
|
1386
1440
|
const senderPub = await importPeerEncPubkey(senderEncryptionPubkey)
|
|
1387
1441
|
const sharedKey = await deriveSharedAesKey(encKeypair.privateKey, senderPub)
|
|
1388
1442
|
const kRaw = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: base64ToBuf(myEntry.iv) }, sharedKey, base64ToBuf(myEntry.ct))
|
|
@@ -50,6 +50,19 @@ const SCOPE_TO_CAP = { 'vault:sign': 'sign', 'vault:store': 'store', 'vault:read
|
|
|
50
50
|
export const scopeToCaps = (scope) =>
|
|
51
51
|
(Array.isArray(scope) ? scope : [scope]).map((s) => SCOPE_TO_CAP[s]).filter(Boolean)
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* El CN de un servicio sale de su scope: `vault:secrets:proxy` ⇒ CN `proxy`. Con CN, el
|
|
55
|
+
* miembro entra al acta como SERVICIO —solo abre su propio cajón— en vez de como un
|
|
56
|
+
* dispositivo del usuario. Es la frontera, y vive en el acta para que se pueda comprobar.
|
|
57
|
+
*/
|
|
58
|
+
export function scopeToCn (scope) {
|
|
59
|
+
for (const s of (Array.isArray(scope) ? scope : [scope])) {
|
|
60
|
+
const m = /^vault:secrets:([a-z0-9-]{1,32})$/.exec(String(s || ''))
|
|
61
|
+
if (m) return m[1]
|
|
62
|
+
}
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
|
|
53
66
|
/** Token aleatorio de 128 bits en hex. */
|
|
54
67
|
export function randToken () {
|
|
55
68
|
const b = crypto.getRandomValues(new Uint8Array(16))
|
|
@@ -215,8 +228,9 @@ export function createEnrollDesk ({
|
|
|
215
228
|
let acta = null
|
|
216
229
|
try {
|
|
217
230
|
if (typeof identity.admitMember === 'function') {
|
|
218
|
-
const
|
|
219
|
-
|
|
231
|
+
const cn = scopeToCn(pend.scope)
|
|
232
|
+
const caps = cn ? ['secrets'] : scopeToCaps(pend.scope)
|
|
233
|
+
if (caps.length) await identity.admitMember({ pub: pend.dpub, encPub: pend.encPub || null, label: pend.label || '', cn, caps, cert, continuity: pend.continuity || null })
|
|
220
234
|
}
|
|
221
235
|
acta = (await identity.profileActa?.())?.acta || null
|
|
222
236
|
} catch (e) { log('[vault] no se pudo admitir en el acta:', e.message) }
|