@dotrino/identity 0.52.0 → 0.53.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.52.0",
3
+ "version": "0.53.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",
@@ -39,8 +39,7 @@
39
39
  "LICENSE"
40
40
  ],
41
41
  "scripts": {
42
- "test": "node --test test/*.test.js",
43
- "type-check": "tsc --noEmit"
42
+ "test": "node --test test/*.test.js"
44
43
  },
45
44
  "keywords": [
46
45
  "identity",
@@ -59,8 +58,6 @@
59
58
  "@dotrino/proxy-client": "0.10.0"
60
59
  },
61
60
  "devDependencies": {
62
- "fake-indexeddb": "^6.2.5",
63
- "typescript": "^5.7.3",
64
- "@types/node": "^22.0.0"
61
+ "fake-indexeddb": "^6.2.5"
65
62
  }
66
63
  }
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
@@ -82,6 +82,20 @@ export const CAP_SCOPE = Object.freeze({ sign: 'vault:sign', store: 'vault:store
82
82
  const enc = (s) => new TextEncoder().encode(s)
83
83
  const hex = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('')
84
84
  const isPub = (v) => typeof v === 'string' && v.length > 0
85
+
86
+ /**
87
+ * La llave de CIFRADO de un miembro (`encPub`): JWK público de una ECDH P-256.
88
+ * Se valida de verdad —no basta con «es un string»— porque es lo que decide si a
89
+ * ese aparato se le puede envolver un secreto: una `encPub` mal formada no falla al
90
+ * escribir el acta, falla mucho después al intentar sellarle algo.
91
+ */
92
+ const isEncPub = (v) => {
93
+ if (typeof v !== 'string' || !v) return false
94
+ try {
95
+ const j = JSON.parse(v)
96
+ return j?.kty === 'EC' && j?.crv === 'P-256' && typeof j?.x === 'string' && typeof j?.y === 'string'
97
+ } catch (_) { return false }
98
+ }
85
99
  const cleanCaps = (caps) => [...new Set((Array.isArray(caps) ? caps : []).filter((c) => CAPS.includes(c)))].sort()
86
100
 
87
101
  /**
@@ -151,6 +165,15 @@ export function checkShape (acta) {
151
165
  } else if (m.caps.includes('secrets')) {
152
166
  return 'secretos-sin-cn'
153
167
  }
168
+ // La llave de cifrado: por ahora se valida la FORMA y solo si viene. NO se exige
169
+ // todavía, aunque el destino sea exigírsela a quien recibe secretos.
170
+ //
171
+ // El motivo es que `checkShape` corre en CADA `verifyActa`, también sobre actas ya
172
+ // selladas: exigirla hoy invalidaría de golpe las actas en las que un servicio
173
+ // entró sin ella —que son todas las anteriores a esto— y el vault dejaría de
174
+ // arrancar en vez de avisar. Primero los servicios registran su llave (op
175
+ // `encpub`), y cuando no quede ninguno sin ella se aprieta aquí.
176
+ if (m.encPub != null && !isEncPub(m.encPub)) return 'encpub-invalido'
154
177
  }
155
178
  if (new Set(acta.members.map((m) => m.pub)).size !== acta.members.length) return 'miembro-duplicado'
156
179
  if (!acta.members.some((m) => m.pub === acta.sealer)) return 'sealer-no-es-miembro'
@@ -239,6 +262,19 @@ export async function applyChanges (acta, changes, { by, now = Date.now() } = {}
239
262
  })
240
263
  break
241
264
  }
265
+ // Registra (o reemplaza) la llave de CIFRADO de un miembro que YA está admitido.
266
+ //
267
+ // Existe para no tener que expulsar y volver a admitir a un servicio solo porque
268
+ // le falta la llave: re-enrolar le cambia la pubkey, y con ella pierde su cajón de
269
+ // variables —que va indexado por esa llave— y se queda sin configuración en
270
+ // silencio. Registrar la llave en el sitio no toca ni `pub`, ni `cn`, ni `caps`.
271
+ case 'encpub': {
272
+ const m = find(ch.pub)
273
+ if (!m) throw new Error('encpub: that member is not in the record')
274
+ if (!isEncPub(ch.encPub)) throw new Error('encpub: invalid encryption key (expected a P-256 public JWK)')
275
+ m.encPub = ch.encPub
276
+ break
277
+ }
242
278
  case 'caps': {
243
279
  const m = find(ch.pub)
244
280
  if (!m) throw new Error('caps: that member is not in the record')
@@ -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
@@ -1460,7 +1460,13 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1460
1460
  caps: Acta.effectiveCaps(acta, m.pub, pend),
1461
1461
  addedAt: m.addedAt || null,
1462
1462
  isMe: m.pub === publickeyJwkStr,
1463
- isMaster: m.pub === acta.sealer
1463
+ isMaster: m.pub === acta.sealer,
1464
+ // La llave de CIFRADO del miembro y, derivado, si se le puede envolver algo.
1465
+ // Sale en la proyección porque sin ella ninguna interfaz (CLI, TUI, consola)
1466
+ // puede decir «a este aparato no se le puede escribir», y ese silencio es
1467
+ // justo el que hace que un secreto no llegue y nadie sepa por qué.
1468
+ encPub: m.encPub || null,
1469
+ canSeal: !!m.encPub
1464
1470
  })))
1465
1471
  return { members, profileId: acta.profileId, seq: acta.seq, sealer: acta.sealer, updatedAt: acta.updatedAt }
1466
1472
  },
@@ -1481,6 +1487,19 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1481
1487
 
1482
1488
  async isMaster () { return amMaster() },
1483
1489
 
1490
+ /**
1491
+ * Registra la llave de CIFRADO de un miembro ya admitido (solo el master).
1492
+ *
1493
+ * Es la alternativa a expulsarlo y volver a admitirlo: un servicio re-enrolado
1494
+ * estrena pubkey, y su cajón de variables va indexado por la vieja, así que
1495
+ * arrancaría sin configuración y sin decirlo. Esto le pone la llave sin moverle
1496
+ * nada más.
1497
+ */
1498
+ async setMemberEncPub ({ pub, encPub } = {}) {
1499
+ const acta = await sealChanges([{ op: 'encpub', pub, encPub }])
1500
+ return { ok: true, seq: acta.seq }
1501
+ },
1502
+
1484
1503
  /**
1485
1504
  * Admite un miembro (solo el master). El cert lo emite quien llama, antes o después.
1486
1505
  * `continuity`: si esa identidad ya existía por su cuenta, su puente firmado (F3).