@dotrino/identity 0.32.0 → 0.34.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.32.0",
3
+ "version": "0.34.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",
@@ -27,6 +27,9 @@
27
27
  },
28
28
  "./content": {
29
29
  "import": "./vault/content.js"
30
+ },
31
+ "./keyid": {
32
+ "import": "./vault/keyid.js"
30
33
  }
31
34
  },
32
35
  "files": [
@@ -52,7 +55,7 @@
52
55
  "url": "git+https://github.com/imdotrino/dotrino-identity.git"
53
56
  },
54
57
  "dependencies": {
55
- "@dotrino/proxy-client": "0.6.3"
58
+ "@dotrino/proxy-client": "0.8.0"
56
59
  },
57
60
  "devDependencies": {
58
61
  "fake-indexeddb": "^6.2.5"
package/src/index.js CHANGED
@@ -299,6 +299,13 @@ 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
+ /**
303
+ * Une ESTE perfil a la cuenta de otro (la de una bóveda). No es adoptar una versión nueva
304
+ * de la tuya: esta llave pasa a ser de OTRA cuenta y deja de tener la suya, así que solo
305
+ * procede sobre un perfil creado con `{ forVault: true }`. Si no, devuelve
306
+ * `{ joined: false, reason: 'perfil-con-datos' }` y no escribe nada.
307
+ */
308
+ async joinProfile (acta) { return this._call('joinProfile', { acta }) }
302
309
  /** MI tarjeta de perfil: lo mínimo que un contacto necesita para cifrarme a todos mis
303
310
  * dispositivos (perfil, versión y llaves). Sin etiquetas ni permisos. */
304
311
  async profileCard () { return this._call('profileCard') }
@@ -321,10 +328,16 @@ export class Identity {
321
328
  * nunca sale), hace el emparejamiento endurecido por el proxy y guarda el cert.
322
329
  * Emite un evento 'vault' { phase:'challenge', deviceId, sas } para que muestres el
323
330
  * código a comparar; resuelve cuando el dueño aprueba en su PC (espera hasta 3 min).
331
+ *
332
+ * `join` dice **de qué cuenta se está hablando** (`vinculacion-de-cuentas.md` §3):
333
+ * · `'new'` → crea aquí una cuenta MÁS, con llave nueva, y es esa la que entra en la
334
+ * bóveda. La que estabas usando no se toca. **Es lo normal.**
335
+ * · `'current'` → sigue con la cuenta abierta; solo vale si nació para adoptar o si ya
336
+ * está emparejada con esa misma bóveda. Si no, falla antes de tocar la red.
324
337
  * @returns {Promise<{ ok:boolean, deviceId:string, master:string, exp:number, scope:string[] }>}
325
338
  */
326
- async enrollDevice (qr, { label = '' } = {}) {
327
- return this._call('vaultPair', { qr, label }, 200000)
339
+ async enrollDevice (qr, { label = '', join = 'current' } = {}) {
340
+ return this._call('vaultPair', { qr, label, join }, 200000)
328
341
  }
329
342
 
330
343
  /** Estado de emparejamiento: { paired, deviceId?, master?, scope?, exp?, pairedAt? }. */
@@ -410,8 +423,14 @@ export class Identity {
410
423
  async listProfiles () { return this._call('listProfiles') }
411
424
  /** El perfil activo: { id, name, pubkey }. */
412
425
  async currentProfile () { return this._call('currentProfile') }
413
- /** Crea un perfil nuevo (identidad fresca) y lo deja activo. La app debe recargar. */
414
- async createProfile (name) { return this._call('createProfile', { name }) }
426
+ /**
427
+ * Crea un perfil nuevo (identidad fresca) y lo deja activo. La app debe recargar.
428
+ *
429
+ * `forVault: true` lo marca como **nacido para adoptar** la cuenta de una bóveda (camino B
430
+ * de `vinculacion-de-cuentas.md`): es el único permiso que acepta `joinProfile`, y se
431
+ * consume al unirse. Sin esa marca, la cuenta es de este dispositivo y nadie se la lleva.
432
+ */
433
+ async createProfile (name, { forVault = false } = {}) { return this._call('createProfile', { name, forVault }) }
415
434
  /** Cambia el perfil activo. La app debe recargar la página. */
416
435
  async switchProfile (id) { return this._call('switchProfile', { id }) }
417
436
  /** Renombra un perfil (o el activo si no se pasa id). */
package/src/node.js CHANGED
@@ -173,6 +173,8 @@ 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
+ /** Une ESTE perfil a la cuenta de otro (solo si nació para eso: `createProfile(n, { forVault: true })`). */
177
+ joinProfile (acta) { return this._h('joinProfile', { acta }) }
176
178
  /** MI tarjeta de perfil: lo mínimo que un contacto necesita para cifrarme a todos mis
177
179
  * dispositivos (perfil, versión y llaves). Sin etiquetas ni permisos. */
178
180
  profileCard () { return this._h('profileCard') }
@@ -187,7 +189,7 @@ export class Identity {
187
189
  /** Rota la clave de contenido (corta el acceso al contenido FUTURO de quien ya no está). */
188
190
  rotateContentKey () { return this._h('rotateContentKey') }
189
191
  // Emparejar ESTE dispositivo con el vault del usuario (Fase 1)
190
- enrollDevice (qr, { label = '' } = {}) { return this._h('vaultPair', { qr, label }) }
192
+ enrollDevice (qr, { label = '', join = 'current' } = {}) { return this._h('vaultPair', { qr, label, join }) }
191
193
  vaultStatus () { return this._h('vaultStatus') }
192
194
  unpairDevice () { return this._h('vaultUnpair') }
193
195
  vaultSign (payload) { return this._h('vaultSign', { payload }) }
@@ -198,7 +200,7 @@ export class Identity {
198
200
  // Multi-perfil por dispositivo (crear/cambiar reinicializa con el nuevo perfil activo).
199
201
  listProfiles () { return this._h('listProfiles') }
200
202
  currentProfile () { return this._h('currentProfile') }
201
- createProfile (name) { return this._h('createProfile', { name }) }
203
+ createProfile (name, { forVault = false } = {}) { return this._h('createProfile', { name, forVault }) }
202
204
  switchProfile (id) { return this._h('switchProfile', { id }) }
203
205
  renameProfile (id, name) { return this._h('renameProfile', { id, name }) }
204
206
  deleteProfile (id) { return this._h('deleteProfile', { id }) }
@@ -21,6 +21,7 @@
21
21
  * servidor de geo sin cargar el iframe.
22
22
  */
23
23
  import { canonicalStringify, bufToBase64, base64ToBuf } from './core.js'
24
+ import { pubkeyId, keyLabel } from './keyid.js'
24
25
 
25
26
  const ECDSA = { name: 'ECDSA', namedCurve: 'P-256' }
26
27
  const SIGN = { name: 'ECDSA', hash: { name: 'SHA-256' } }
@@ -46,12 +47,9 @@ async function rawVerify (publicJwkStr, bytes, sigB64) {
46
47
  const publicOf = (privateJwk) => ({ kty: privateJwk.kty, crv: privateJwk.crv, x: privateJwk.x, y: privateJwk.y })
47
48
  const scopeAllows = (scope, expected) => Array.isArray(scope) ? scope.includes(expected) : scope === expected
48
49
 
49
- /** id corto y estable de un pubkey (sha-256 hex de los campos canónicos del JWK). */
50
- export async function pubkeyId (publicJwkStr) {
51
- const jwk = typeof publicJwkStr === 'string' ? JSON.parse(publicJwkStr) : publicJwkStr
52
- const h = await crypto.subtle.digest('SHA-256', enc(canonicalStringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y })))
53
- return [...new Uint8Array(h)].map(b => b.toString(16).padStart(2, '0')).join('')
54
- }
50
+ // El id de una llave y su huella legible viven en `./keyid.js`, que no tiene dependencias:
51
+ // así una interfaz puede mostrar `AB12-CD34` sin arrastrar todo esto. Una sola implementación.
52
+ export { pubkeyId, keyLabel }
55
53
 
56
54
  /**
57
55
  * Verifica que `signature` (base64) sobre `data` fue hecha por la privada de
package/vault/core.js CHANGED
@@ -178,6 +178,19 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
178
178
  const loadProfiles = () => { try { return JSON.parse(rawKv.getItem(PROFILES_STORAGE) || '[]') || [] } catch { return [] } }
179
179
  const saveProfiles = (list) => rawKv.setItem(PROFILES_STORAGE, JSON.stringify(list))
180
180
 
181
+ // ----- la MARCA de «este perfil nació para adoptar la cuenta de una bóveda» -----
182
+ // Unirse a otra cuenta borra la que este perfil tenía, así que no puede pasar por
183
+ // accidente ni deducirse de heurísticas tipo «parece vacío»: se pide a propósito con
184
+ // `createProfile({ forVault: true })` y esa marca es el único permiso que vale.
185
+ // Además identity NO PUEDE ver el contenido del store (vive en otro origen), así que
186
+ // «vacío» no es algo que pueda comprobar por su cuenta.
187
+ // Ver `dotrino-vault/docs/vinculacion-de-cuentas.md` §5.1.
188
+ const isPendingJoin = (pid = currentPid) => !!loadProfiles().find((p) => p.id === pid)?.pendingJoin
189
+ const clearPendingJoin = (pid = currentPid) => {
190
+ const list = loadProfiles(); const e = list.find((p) => p.id === pid)
191
+ if (e?.pendingJoin) { delete e.pendingJoin; saveProfiles(list) }
192
+ }
193
+
181
194
  // ----- keypair loaders -----
182
195
  // Con `keyStore` (IndexedDB del navegador): la privada vive como CryptoKey
183
196
  // NO EXTRACTABLE — puede FIRMAR/DERIVAR pero nadie (ni este código, ni un XSS)
@@ -440,13 +453,18 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
440
453
  }
441
454
 
442
455
  /**
443
- * UNIRSE a otro perfil (el de la bóveda a la que te acabas de conectar). No es adoptar
444
- * una versión nueva de TU acta: es cambiar de perfil, así que solo procede si aquí no
445
- * hay nada que perder es decir, si este dispositivo es el único miembro del suyo.
456
+ * UNIRSE a la cuenta de la bóveda con la que te acabas de emparejar (camino B de
457
+ * `dotrino-vault/docs/vinculacion-de-cuentas.md`). NO es adoptar una versión nueva de TU
458
+ * acta: es que ESTA llave pase a ser de OTRA cuenta, y por lo tanto **deja de tener la
459
+ * suya**.
460
+ *
461
+ * Por eso solo procede sobre un perfil que **nació para esto** (`createProfile({ forVault:
462
+ * true })`). Sin la marca no se une: se devuelve el conflicto para que la consola ofrezca
463
+ * crear una cuenta nueva, y **no se escribe nada**.
446
464
  *
447
- * Si ya tienes otros dispositivos, los dos lados tienen master y hay que ELEGIR cuál
448
- * manda (§2.4.3): eso es una decisión del dueño, no un efecto colateral de escanear un
449
- * código, así que se devuelve el conflicto para que lo resuelva la consola.
465
+ * Antes bastaba con ser el único miembro del acta propia, y entonces se sobrescribía el
466
+ * acta sin preguntar: una cuenta con su contenido pasaba a colgar de otra en silencio —
467
+ * justo la fusión de cuentas que el modelo prohíbe (§4). Eso se acabó.
450
468
  */
451
469
  async function joinProfile (candidate) {
452
470
  const v = await Acta.verifyActa({ acta: candidate })
@@ -455,10 +473,29 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
455
473
  return { joined: false, reason: 'no-soy-miembro' }
456
474
  }
457
475
  const current = loadActa()
458
- if (current && current.profileId !== candidate.profileId && current.members.length > 1) {
459
- return { joined: false, reason: 'ya-tienes-perfil-propio', members: current.members.length }
476
+
477
+ // Misma cuenta: no es unirse, es ponerse al día. Va por las reglas de adopción (§2.4.1).
478
+ if (current && current.profileId === candidate.profileId) {
479
+ const r = await adoptActa(candidate)
480
+ return { joined: r.adopted, reason: r.reason, profileId: candidate.profileId, seq: r.seq }
481
+ }
482
+
483
+ if (current && !isPendingJoin()) {
484
+ return {
485
+ joined: false,
486
+ reason: 'perfil-con-datos',
487
+ profileId: current.profileId,
488
+ seq: current.seq,
489
+ members: current.members.length
490
+ }
460
491
  }
492
+
461
493
  saveActa(candidate)
494
+ // El historial era de la cuenta anterior y no encadena con esta. La que se abandona es
495
+ // siempre una génesis recién nacida (lo garantiza la marca), así que no hay nada que
496
+ // retener; guardarla aquí solo mezclaría dos cuentas en la misma ventana (§1.3).
497
+ kv.setItem(ACTA_HISTORY_STORAGE, '[]')
498
+ clearPendingJoin()
462
499
  emitVault({ phase: 'acta', seq: candidate.seq, sealer: candidate.sealer, joined: true })
463
500
  return { joined: true, profileId: candidate.profileId, seq: candidate.seq }
464
501
  }
@@ -1005,14 +1042,36 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1005
1042
  // ----- perfiles (multi-perfil por dispositivo) -----
1006
1043
  // Cambiar/crear setea el perfil activo; la app RECARGA la página y re-inicializa con él
1007
1044
  // (no reactivo, por diseño). Las apps abiertas conservan el perfil con el que cargaron.
1045
+ /**
1046
+ * Los perfiles de este dispositivo, para el conmutador. Incluye el AVATAR de cada uno:
1047
+ * sin él, la lista caía siempre al identicon automático y tu foto no aparecía —aunque la
1048
+ * hubieras subido— porque el avatar vive en el `me` de cada perfil, no en el registro.
1049
+ */
1008
1050
  async listProfiles () {
1009
- return loadProfiles().map((p) => ({ id: p.id, name: p.name || '', pubkey: p.pubkey || null, current: p.id === currentPid }))
1051
+ return loadProfiles().map((p) => {
1052
+ let avatar = null
1053
+ try {
1054
+ const raw = p.id === currentPid ? JSON.stringify(me || null) : rawKv.getItem(`dotrino.identity.p.${p.id}.me`)
1055
+ const m = raw ? JSON.parse(raw) : null
1056
+ if (m && typeof m.avatar === 'string') avatar = m.avatar
1057
+ } catch (_) {}
1058
+ // `pendingJoin`: nació para adoptar la cuenta de una bóveda y todavía no se unió.
1059
+ // La consola lo usa para no ofrecerlo como una cuenta normal a medio hacer.
1060
+ return { id: p.id, name: p.name || '', pubkey: p.pubkey || null, avatar, current: p.id === currentPid, pendingJoin: !!p.pendingJoin }
1061
+ })
1010
1062
  },
1011
1063
  async currentProfile () {
1012
1064
  const e = loadProfiles().find((p) => p.id === currentPid) || {}
1013
1065
  return { id: currentPid, name: e.name || me?.nickname || '', pubkey: publickeyJwkStr }
1014
1066
  },
1015
- async createProfile ({ name } = {}) {
1067
+ /**
1068
+ * Crea una cuenta más en este dispositivo, con su propia llave.
1069
+ *
1070
+ * `forVault: true` la marca como **nacida para adoptar** la cuenta de una bóveda
1071
+ * (camino B): es el único permiso que acepta `joinProfile`, y se consume al unirse.
1072
+ * Sin la marca, la cuenta es de este dispositivo y nadie se la puede llevar.
1073
+ */
1074
+ async createProfile ({ name, forVault = false } = {}) {
1016
1075
  const pid = 'p' + crypto.randomUUID().slice(0, 8)
1017
1076
  currentPid = pid
1018
1077
  rawKv.setItem(CURRENT_STORAGE, pid)
@@ -1022,9 +1081,11 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1022
1081
  encKeypair = await loadOrCreateEncKeypair(); encPublickeyJwkStr = JSON.stringify(encKeypair.publicJwk)
1023
1082
  me = { publickey: publickeyJwkStr, encryptionPubkey: encPublickeyJwkStr, nickname: String(name || '').slice(0, 40) }
1024
1083
  saveMe(me)
1025
- const list = loadProfiles(); list.push({ id: pid, name: me.nickname, pubkey: publickeyJwkStr }); saveProfiles(list)
1084
+ const list = loadProfiles()
1085
+ list.push({ id: pid, name: me.nickname, pubkey: publickeyJwkStr, ...(forVault ? { pendingJoin: true } : {}) })
1086
+ saveProfiles(list)
1026
1087
  await ensureActa(me.nickname) // el perfil nuevo nace con su acta (él mismo es el master)
1027
- return { id: pid, name: me.nickname, pubkey: publickeyJwkStr }
1088
+ return { id: pid, name: me.nickname, pubkey: publickeyJwkStr, pendingJoin: !!forVault }
1028
1089
  },
1029
1090
  async switchProfile ({ id } = {}) {
1030
1091
  if (!loadProfiles().find((p) => p.id === id)) throw new Error('perfil no existe')
@@ -1246,7 +1307,27 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1246
1307
  // ----- emparejar ESTE dispositivo con el vault del usuario (Fase 1) -----
1247
1308
  // Genera D aquí dentro (su privada NUNCA sale de la identidad), hace el enroll
1248
1309
  // endurecido por el proxy y guarda el cert. NO cambia signData todavía (Fase 2).
1249
- async vaultPair ({ qr, label = '' }) {
1310
+ /**
1311
+ * Empareja este dispositivo con una bóveda.
1312
+ *
1313
+ * `join` dice **de qué cuenta estamos hablando** (V7: la intención es explícita, nunca
1314
+ * se adivina):
1315
+ * · `'new'` → camino B: crea aquí una cuenta más, con llave nueva, y ES ESA la que
1316
+ * entra al acta de la bóveda. La que estabas usando **no se toca**.
1317
+ * · `'current'` → sigue con la cuenta abierta. Solo vale si nació para adoptar
1318
+ * (`forVault`) o si ya está emparejada con ESA misma bóveda
1319
+ * (re-emparejar). En cualquier otro caso falla **antes de tocar la
1320
+ * red**, en vez de traerse un acta ajena y pisar la tuya.
1321
+ */
1322
+ async vaultPair ({ qr, label = '', join = 'current' }) {
1323
+ if (join === 'new') {
1324
+ await handlers.createProfile({ name: label || me?.nickname || '', forVault: true })
1325
+ } else {
1326
+ const yaConEsta = loadVaultCert()?.master === qr?.iss
1327
+ if (loadActa() && !isPendingJoin() && !yaConEsta) {
1328
+ throw new Error('este aparato ya está usando una cuenta: para usar también la de tu bóveda, crea una cuenta nueva aquí (la que tienes abierta no se toca)')
1329
+ }
1330
+ }
1250
1331
  // Usa la PROPIA llave de identidad de este navegador como dispositivo: el cert delega
1251
1332
  // TU identidad (P) desde la maestra M → una sola identidad (signData/identify/cert = P).
1252
1333
  // La privada es la CryptoKey del perfil (no extractable): se pasa como `privateKey`
@@ -1254,18 +1335,20 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1254
1335
  const device = { publickey: publickeyJwkStr, privateKey: keypair.privateKey }
1255
1336
  // Si esta identidad ya existía por su cuenta, se lleva un certificado de continuidad
1256
1337
  // firmado por ella misma: es el puente para que su reputación previa siga contando.
1338
+ // Solo si esta llave tenía vida propia. Una recién creada para adoptar (camino B) no
1339
+ // tiene pasado que salvar: mandarle un puente de continuidad sería puro ruido.
1257
1340
  const mio = loadActa()
1258
- const continuity = (mio && mio.members.length === 1)
1341
+ const continuity = (mio && mio.members.length === 1 && !isPendingJoin())
1259
1342
  ? await Acta.makeContinuity({ member: publickeyJwkStr, from: mio.profileId, privateKey: keypair.privateKey })
1260
1343
  : null
1261
1344
  const res = await remoteEnroll({ qr, device, continuity, encPub: encPublickeyJwkStr, label: label || me?.nickname || '', onChallenge: (c) => emitVault({ phase: 'challenge', deviceId: c.deviceId, code: c.code }) })
1262
1345
  kv.setItem(VAULT_DEVICE_STORAGE, JSON.stringify({ useIdentityKey: true, publickey: publickeyJwkStr }))
1263
1346
  kv.setItem(VAULT_CERT_STORAGE, JSON.stringify({ cert: res.cert, master: res.master, proxy: res.proxy, deviceId: res.deviceId, pairedAt: Date.now() }))
1264
- // Conectarse a una bóveda es ENTRAR A SU PERFIL: el acta viene con el cert.
1265
- const join = res.acta ? await joinProfile(res.acta) : { joined: false, reason: 'sin-acta' }
1266
- emitVault({ phase: 'paired', deviceId: res.deviceId, master: res.master, join })
1347
+ // Conectarse a una bóveda es ENTRAR A SU CUENTA: el acta viene con el cert.
1348
+ const unido = res.acta ? await joinProfile(res.acta) : { joined: false, reason: 'sin-acta' }
1349
+ emitVault({ phase: 'paired', deviceId: res.deviceId, master: res.master, join: unido })
1267
1350
  pullProfileFromVault() // adoptar el perfil que ya viva en el vault (si hay)
1268
- return { ok: true, deviceId: res.deviceId, master: res.master, exp: res.cert.exp, scope: res.cert.scope, join }
1351
+ return { ok: true, deviceId: res.deviceId, master: res.master, exp: res.cert.exp, scope: res.cert.scope, join: unido }
1269
1352
  },
1270
1353
 
1271
1354
  async vaultStatus () {
package/vault/keyid.js ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * keyid.js — el identificador LEGIBLE de una llave.
3
+ *
4
+ * Una pubkey es un JWK: `{"crv":"P-256","kty":"EC","x":"…","y":"…"}`. Eso no se le enseña a
5
+ * nadie — ni entero ni recortado, que es peor: parece un error. Para que una persona pueda
6
+ * reconocer y comparar una llave se usa su huella corta, `AB12-CD34`, que es la misma que
7
+ * ya se muestra al emparejar («aprueba el dispositivo AB12-CD34») y en el acta del perfil.
8
+ *
9
+ * Módulo aparte y sin dependencias a propósito: cualquier interfaz que necesite mostrar una
10
+ * llave puede importarlo sin arrastrar el resto de la identidad. `capabilities.js` reexporta
11
+ * `pubkeyId` desde aquí para que haya UNA sola implementación.
12
+ */
13
+
14
+ const enc = (s) => new TextEncoder().encode(s)
15
+
16
+ /** Serialización canónica de los campos que identifican la llave (mismo orden siempre). */
17
+ function canonicalJwk (jwk) {
18
+ return JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y })
19
+ .replace(/^{/, '{').replace(/}$/, '}')
20
+ }
21
+
22
+ /** id corto y estable de un pubkey (sha-256 hex de los campos canónicos del JWK). */
23
+ export async function pubkeyId (publicJwkStr) {
24
+ const jwk = typeof publicJwkStr === 'string' ? JSON.parse(publicJwkStr) : publicJwkStr
25
+ const h = await crypto.subtle.digest('SHA-256', enc(canonicalJwk(jwk)))
26
+ return [...new Uint8Array(h)].map((b) => b.toString(16).padStart(2, '0')).join('')
27
+ }
28
+
29
+ /**
30
+ * La huella que SÍ se le enseña a una persona: `AB12-CD34`. Corta, comparable de un vistazo
31
+ * y la misma en todo el ecosistema (emparejamiento, acta, lista de dispositivos).
32
+ */
33
+ export async function keyLabel (publicJwkStr) {
34
+ if (!publicJwkStr) return ''
35
+ try {
36
+ const id = (await pubkeyId(publicJwkStr)).slice(0, 8).toUpperCase()
37
+ return id.slice(0, 4) + '-' + id.slice(4, 8)
38
+ } catch (_) { return '' }
39
+ }
40
+
41
+ export default { pubkeyId, keyLabel }