@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 +3 -6
- package/src/index.js +3 -1
- package/src/node.js +3 -1
- package/vault/acta.js +36 -0
- package/vault/capabilities.js +39 -0
- package/vault/content.js +14 -1
- package/vault/core.js +20 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/identity",
|
|
3
|
-
"version": "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')
|
package/vault/capabilities.js
CHANGED
|
@@ -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).
|