@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/identity",
3
- "version": "0.29.0",
3
+ "version": "0.31.0",
4
4
  "description": "Identidad y rating de usuarios compartidos entre apps de Dotrino (vault iframe + postMessage)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/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
- /** Lista CERRADA de capacidades. Sellar y admitir no están: eso es ser el master. */
36
- export const CAPS = Object.freeze(['sign', 'store', 'read'])
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
- /** Cada capacidad es uno de los scopes que ya existen en los certs (sin scopes nuevos). */
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
- /** El acta SIN la firma: es lo que se sella y sobre lo que se calcula el hash. */
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: [...CAPS], addedAt: now, cert: null }],
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
- return { ...acta, sig: signature }
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
- caps: cleanCaps(m.caps),
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
- m.caps = cleanCaps(ch.caps)
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
- effectiveCaps, memberCan, canAdopt
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 = '', caps = ['store', 'read'], cert = null, continuity = null } = {}) {
1103
- const acta = await sealChanges([{ op: 'admit', member: { pub, encPub, label, caps, cert, continuity } }])
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
- const wrap = {}
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 || !r.token || !r.encryptionPubkey) continue
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(r.encryptionPubkey)
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[r.token] = { iv: bufToBase64(wrapIv), ct: bufToBase64(new Uint8Array(wrappedCt)) }
1424
+ wrap[await encKeyId(encPub)] = { iv: bufToBase64(wrapIv), ct: bufToBase64(new Uint8Array(wrappedCt)) }
1375
1425
  } catch (e) { /* destinatario omitido */ }
1376
1426
  }
1377
- return { v: 1, iv: bufToBase64(iv), ct: bufToBase64(new Uint8Array(ct)), wrap }
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 (!myToken) throw new Error('myToken required')
1383
- if (!envelope || envelope.v !== 1) throw new Error('Unsupported envelope')
1384
- const myEntry = envelope.wrap && envelope.wrap[myToken]
1385
- if (!myEntry) throw new Error('No wrap entry for this recipient')
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 caps = scopeToCaps(pend.scope)
219
- if (caps.length) await identity.admitMember({ pub: pend.dpub, encPub: pend.encPub || null, label: pend.label || '', caps, cert, continuity: pend.continuity || null })
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) }