@dotrino/identity 0.30.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 +78 -3
- package/vault/core.js +58 -9
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
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
-
|
|
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
|
|
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(
|
|
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[
|
|
1424
|
+
wrap[await encKeyId(encPub)] = { iv: bufToBase64(wrapIv), ct: bufToBase64(new Uint8Array(wrappedCt)) }
|
|
1380
1425
|
} catch (e) { /* destinatario omitido */ }
|
|
1381
1426
|
}
|
|
1382
|
-
return { v:
|
|
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 (!
|
|
1388
|
-
|
|
1389
|
-
const myEntry = envelope.wrap && envelope.wrap[myToken]
|
|
1390
|
-
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')
|
|
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))
|