@dotrino/identity 0.30.0 → 0.32.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.30.0",
3
+ "version": "0.32.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). */
@@ -318,8 +323,8 @@ export class Identity {
318
323
  * código a comparar; resuelve cuando el dueño aprueba en su PC (espera hasta 3 min).
319
324
  * @returns {Promise<{ ok:boolean, deviceId:string, master:string, exp:number, scope:string[] }>}
320
325
  */
321
- async enrollDevice (qr) {
322
- return this._call('vaultPair', { qr }, 200000)
326
+ async enrollDevice (qr, { label = '' } = {}) {
327
+ return this._call('vaultPair', { qr, label }, 200000)
323
328
  }
324
329
 
325
330
  /** Estado de emparejamiento: { paired, deviceId?, master?, scope?, exp?, pairedAt? }. */
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). */
@@ -182,7 +187,7 @@ export class Identity {
182
187
  /** Rota la clave de contenido (corta el acceso al contenido FUTURO de quien ya no está). */
183
188
  rotateContentKey () { return this._h('rotateContentKey') }
184
189
  // Emparejar ESTE dispositivo con el vault del usuario (Fase 1)
185
- enrollDevice (qr) { return this._h('vaultPair', { qr }) }
190
+ enrollDevice (qr, { label = '' } = {}) { return this._h('vaultPair', { qr, label }) }
186
191
  vaultStatus () { return this._h('vaultStatus') }
187
192
  unpairDevice () { return this._h('vaultUnpair') }
188
193
  vaultSign (payload) { return this._h('vaultSign', { payload }) }
package/vault/acta.js CHANGED
@@ -70,9 +70,12 @@ const hex = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart
70
70
  const isPub = (v) => typeof v === 'string' && v.length > 0
71
71
  const cleanCaps = (caps) => [...new Set((Array.isArray(caps) ? caps : []).filter((c) => CAPS.includes(c)))].sort()
72
72
 
73
- /** 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
+ */
74
77
  export function actaBody (acta) {
75
- const { sig, ...body } = acta || {}
78
+ const { sig, card, ...body } = acta || {}
76
79
  return body
77
80
  }
78
81
 
@@ -146,7 +149,11 @@ export async function sealActa ({ acta, privateKey, privateJwk }) {
146
149
  const shape = checkShape(acta)
147
150
  if (shape) throw new Error('acta inválida: ' + shape)
148
151
  const { signature } = await signWithDevice({ privateKey, privateJwk, publickey: acta.sealedBy, data: actaBody(acta) })
149
- 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
150
157
  }
151
158
 
152
159
  /**
@@ -351,6 +358,73 @@ export function memberCan (acta, pub, cap, extraRenounces = []) {
351
358
  return effectiveCaps(acta, pub, extraRenounces).includes(cap)
352
359
  }
353
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
+
354
428
  // ----- continuidad: unir una identidad que ya existía -----
355
429
 
356
430
  /**
@@ -424,5 +498,6 @@ export default {
424
498
  ACTA_V, CAPS, CAP_SCOPE, genesisActa, actaBody, actaHash, memberId, checkShape, isHandover,
425
499
  sealActa, verifyActa, applyChanges, makeRenounce, verifyRenounce,
426
500
  makeContinuity, verifyContinuity,
501
+ cardBody, makeProfileCard, verifyProfileCard, canAdoptCard,
427
502
  effectiveCaps, memberCan, memberCanReadSecrets, memberScopes, isService, capScope, isValidCn, canAdopt
428
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 {
@@ -1208,6 +1212,28 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1208
1212
  return { chain: all.sort((a, b) => a.seq - b.seq), window: ACTA_WINDOW }
1209
1213
  },
1210
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
+
1211
1237
  /** Adopta un acta que llega de otro miembro (gana el seq mayor; a igual seq, el traspaso). */
1212
1238
  async adoptActa ({ acta } = {}) { return adoptActa(acta) },
1213
1239
 
@@ -1220,7 +1246,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1220
1246
  // ----- emparejar ESTE dispositivo con el vault del usuario (Fase 1) -----
1221
1247
  // Genera D aquí dentro (su privada NUNCA sale de la identidad), hace el enroll
1222
1248
  // endurecido por el proxy y guarda el cert. NO cambia signData todavía (Fase 2).
1223
- async vaultPair ({ qr }) {
1249
+ async vaultPair ({ qr, label = '' }) {
1224
1250
  // Usa la PROPIA llave de identidad de este navegador como dispositivo: el cert delega
1225
1251
  // TU identidad (P) desde la maestra M → una sola identidad (signData/identify/cert = P).
1226
1252
  // La privada es la CryptoKey del perfil (no extractable): se pasa como `privateKey`
@@ -1232,7 +1258,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1232
1258
  const continuity = (mio && mio.members.length === 1)
1233
1259
  ? await Acta.makeContinuity({ member: publickeyJwkStr, from: mio.profileId, privateKey: keypair.privateKey })
1234
1260
  : null
1235
- const res = await remoteEnroll({ qr, device, continuity, encPub: encPublickeyJwkStr, onChallenge: (c) => emitVault({ phase: 'challenge', deviceId: c.deviceId, code: c.code }) })
1261
+ const res = await remoteEnroll({ qr, device, continuity, encPub: encPublickeyJwkStr, label: label || me?.nickname || '', onChallenge: (c) => emitVault({ phase: 'challenge', deviceId: c.deviceId, code: c.code }) })
1236
1262
  kv.setItem(VAULT_DEVICE_STORAGE, JSON.stringify({ useIdentityKey: true, publickey: publickeyJwkStr }))
1237
1263
  kv.setItem(VAULT_CERT_STORAGE, JSON.stringify({ cert: res.cert, master: res.master, proxy: res.proxy, deviceId: res.deviceId, pairedAt: Date.now() }))
1238
1264
  // Conectarse a una bóveda es ENTRAR A SU PERFIL: el acta viene con el cert.
@@ -1361,6 +1387,15 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1361
1387
 
1362
1388
  async getEncryptionPubkey () { return encPublickeyJwkStr },
1363
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
+ */
1364
1399
  async encrypt ({ recipients, plaintext }) {
1365
1400
  if (!Array.isArray(recipients) || recipients.length === 0) throw new Error('recipients required')
1366
1401
  if (typeof plaintext !== 'string') throw new Error('plaintext required')
@@ -1368,26 +1403,40 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1368
1403
  const kRaw = await crypto.subtle.exportKey('raw', k)
1369
1404
  const iv = crypto.getRandomValues(new Uint8Array(12))
1370
1405
  const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, k, new TextEncoder().encode(plaintext))
1371
- 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()
1372
1410
  for (const r of recipients) {
1373
- 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) {
1374
1419
  try {
1375
- const peerPub = await importPeerEncPubkey(r.encryptionPubkey)
1420
+ const peerPub = await importPeerEncPubkey(encPub)
1376
1421
  const sharedKey = await deriveSharedAesKey(encKeypair.privateKey, peerPub)
1377
1422
  const wrapIv = crypto.getRandomValues(new Uint8Array(12))
1378
1423
  const wrappedCt = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: wrapIv }, sharedKey, kRaw)
1379
- wrap[r.token] = { iv: bufToBase64(wrapIv), ct: bufToBase64(new Uint8Array(wrappedCt)) }
1424
+ wrap[await encKeyId(encPub)] = { iv: bufToBase64(wrapIv), ct: bufToBase64(new Uint8Array(wrappedCt)) }
1380
1425
  } catch (e) { /* destinatario omitido */ }
1381
1426
  }
1382
- 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 }
1383
1428
  },
1384
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
+ */
1385
1434
  async decrypt ({ senderEncryptionPubkey, myToken, envelope }) {
1386
1435
  if (!senderEncryptionPubkey) throw new Error('senderEncryptionPubkey required')
1387
- if (!myToken) throw new Error('myToken required')
1388
- if (!envelope || envelope.v !== 1) throw new Error('Unsupported envelope')
1389
- const myEntry = envelope.wrap && envelope.wrap[myToken]
1390
- 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')
1391
1440
  const senderPub = await importPeerEncPubkey(senderEncryptionPubkey)
1392
1441
  const sharedKey = await deriveSharedAesKey(encKeypair.privateKey, senderPub)
1393
1442
  const kRaw = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: base64ToBuf(myEntry.iv) }, sharedKey, base64ToBuf(myEntry.ct))