@dotrino/identity 0.14.0 → 0.15.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.14.0",
3
+ "version": "0.15.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
@@ -259,6 +259,23 @@ export class Identity {
259
259
  return this.on('vault', handler)
260
260
  }
261
261
 
262
+ // ----- multi-perfil por dispositivo -----
263
+ // Podés tener varios perfiles (identidades) en el mismo navegador, cada uno conectado o no
264
+ // a su propio vault. Crear/cambiar setea el perfil activo; la app RECARGA la página y toma
265
+ // el nuevo (no reactivo: las apps abiertas conservan el perfil con el que cargaron).
266
+ /** Lista de perfiles: [{ id, name, pubkey, current }]. */
267
+ async listProfiles () { return this._call('listProfiles') }
268
+ /** El perfil activo: { id, name, pubkey }. */
269
+ async currentProfile () { return this._call('currentProfile') }
270
+ /** Crea un perfil nuevo (identidad fresca) y lo deja activo. La app debe recargar. */
271
+ async createProfile (name) { return this._call('createProfile', { name }) }
272
+ /** Cambia el perfil activo. La app debe recargar la página. */
273
+ async switchProfile (id) { return this._call('switchProfile', { id }) }
274
+ /** Renombra un perfil (o el activo si no se pasa id). */
275
+ async renameProfile (id, name) { return this._call('renameProfile', { id, name }) }
276
+ /** Borra un perfil y sus datos (no el único). */
277
+ async deleteProfile (id) { return this._call('deleteProfile', { id }) }
278
+
262
279
  /**
263
280
  * Merge endorsements (signed ratings from third parties) about a subject
264
281
  * into the local peer book. Returns { merged, total }.
@@ -426,4 +443,4 @@ export class Identity {
426
443
 
427
444
  // Helpers de capacidad SIN clave maestra (lado dispositivo + verificación), reutilizables
428
445
  // por apps/bridges sin cargar el iframe del vault.
429
- export { makeDeviceKey, signWithDevice, verifyDelegation, verifyChain, pubkeyId, deriveSAS, verifyDeviceSig, makePairingCode, commitCode, MAX_DELEGATION_MS, DEFAULT_DELEGATION_MS } from '../vault/capabilities.js'
446
+ export { makeDeviceKey, 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
@@ -41,14 +41,19 @@ function fileKv (filePath) {
41
41
  function filePeers (filePath) {
42
42
  let peers = {}
43
43
  let markDirty = null
44
+ let pid = null // multi-perfil: el peer book se namespacea por perfil
45
+ const fileFor = () => pid ? path.join(path.dirname(filePath), `peers.${pid}.json`) : filePath
44
46
  const flush = () => {
45
- fs.mkdirSync(path.dirname(filePath), { recursive: true })
46
- fs.writeFileSync(filePath, JSON.stringify(peers))
47
+ const f = fileFor()
48
+ fs.mkdirSync(path.dirname(f), { recursive: true })
49
+ fs.writeFileSync(f, JSON.stringify(peers))
47
50
  }
48
51
  return {
52
+ setProfile (p) { pid = p || null },
49
53
  async initPeerStorage () {
54
+ const f = fileFor()
50
55
  try {
51
- if (fs.existsSync(filePath)) peers = JSON.parse(fs.readFileSync(filePath, 'utf8')) || {}
56
+ peers = (fs.existsSync(f)) ? (JSON.parse(fs.readFileSync(f, 'utf8')) || {}) : {}
52
57
  } catch (_) { peers = {} }
53
58
  return peers
54
59
  },
@@ -144,6 +149,13 @@ export class Identity {
144
149
  listVaultDevices () { return this._h('listVaultDevices') }
145
150
  getVaultCert () { return this._h('getVaultCert') }
146
151
  onVault (handler) { return this.on('vault', handler) }
152
+ // Multi-perfil por dispositivo (crear/cambiar reinicializa con el nuevo perfil activo).
153
+ listProfiles () { return this._h('listProfiles') }
154
+ currentProfile () { return this._h('currentProfile') }
155
+ createProfile (name) { return this._h('createProfile', { name }) }
156
+ switchProfile (id) { return this._h('switchProfile', { id }) }
157
+ renameProfile (id, name) { return this._h('renameProfile', { id, name }) }
158
+ deleteProfile (id) { return this._h('deleteProfile', { id }) }
147
159
  mergeEndorsements (subject, endorsements, askerPubkey) {
148
160
  return this._h('mergeEndorsements', { subject, endorsements, askerPubkey })
149
161
  }
@@ -186,4 +198,4 @@ export default Identity
186
198
 
187
199
  // Helpers de capacidad SIN clave maestra (lado dispositivo + verificación), para que
188
200
  // un bridge/bot Node pueda crear su clave, firmar acciones y verificar cadenas D←P.
189
- export { makeDeviceKey, signWithDevice, verifyDelegation, verifyChain, pubkeyId, deriveSAS, verifyDeviceSig, makePairingCode, commitCode, MAX_DELEGATION_MS, DEFAULT_DELEGATION_MS } from '../vault/capabilities.js'
201
+ export { makeDeviceKey, signWithDevice, verifyDelegation, verifyChain, pubkeyId, deriveSAS, verifyDeviceSig, makePairingCode, commitCode, avatarSvg, avatarDataUri, MAX_DELEGATION_MS, DEFAULT_DELEGATION_MS } from '../vault/capabilities.js'
@@ -99,6 +99,50 @@ export async function commitCode ({ code, dpub, sn }) {
99
99
  return [...new Uint8Array(h)].map((x) => x.toString(16).padStart(2, '0')).join('')
100
100
  }
101
101
 
102
+ /**
103
+ * Avatar generado (identicon de Dotrino): SVG DETERMINISTA a partir de una semilla
104
+ * (típicamente el pubkey del perfil) → cada perfil/identidad nace con imagen, sin que el
105
+ * usuario tenga que subir nada. Síncrono (hash FNV-1a + xorshift, sin necesidades de
106
+ * seguridad: es solo decorativo) → usable directo en plantillas. Rejilla 5×5 simétrica
107
+ * sobre una "moneda" redondeada, con color derivado del hash.
108
+ */
109
+ function _hashSeed (s) {
110
+ let h = 2166136261 >>> 0
111
+ for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619) }
112
+ const bytes = []
113
+ let x = (h ^ 0x9e3779b9) >>> 0
114
+ for (let i = 0; i < 16; i++) { x ^= x << 13; x ^= x >>> 17; x ^= x << 5; x >>>= 0; bytes.push(x & 0xff) }
115
+ return { h: h >>> 0, bytes }
116
+ }
117
+ export function avatarSvg (seed, { size = 80 } = {}) {
118
+ const { h, bytes } = _hashSeed(String(seed || 'dotrino'))
119
+ const hue = h % 360
120
+ const hue2 = (hue + 40) % 360
121
+ const fg = `hsl(${hue} 62% 46%)`
122
+ const bg1 = `hsl(${hue} 48% 95%)`
123
+ const bg2 = `hsl(${hue2} 48% 90%)`
124
+ const cells = 5
125
+ const unit = size / cells
126
+ let rects = ''
127
+ for (let col = 0; col < 3; col++) {
128
+ for (let row = 0; row < cells; row++) {
129
+ if (!(bytes[col * cells + row] & 1)) continue
130
+ for (const c of (col === 2 ? [2] : [col, cells - 1 - col])) {
131
+ rects += `<rect x="${(c * unit).toFixed(2)}" y="${(row * unit).toFixed(2)}" width="${unit.toFixed(2)}" height="${unit.toFixed(2)}"/>`
132
+ }
133
+ }
134
+ }
135
+ const id = 'g' + (h % 100000)
136
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}">` +
137
+ `<defs><linearGradient id="${id}" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="${bg1}"/><stop offset="1" stop-color="${bg2}"/></linearGradient></defs>` +
138
+ `<rect width="${size}" height="${size}" rx="${(size * 0.5).toFixed(2)}" fill="url(#${id})"/>` +
139
+ `<g fill="${fg}" transform="translate(${(size * 0.12).toFixed(2)} ${(size * 0.12).toFixed(2)}) scale(0.76)">${rects}</g></svg>`
140
+ }
141
+ /** El avatar como data-URI listo para `<img src>` o `background-image`. */
142
+ export function avatarDataUri (seed, opts) {
143
+ return 'data:image/svg+xml,' + encodeURIComponent(avatarSvg(seed, opts))
144
+ }
145
+
102
146
  /** Cuerpo canónico del certificado (lo que se firma): el cert SIN la firma. */
103
147
  export function delegationBody (cert) {
104
148
  return { v: cert.v, iss: cert.iss, sub: cert.sub, scope: cert.scope, iat: cert.iat, exp: cert.exp, nonce: cert.nonce }
package/vault/core.js CHANGED
@@ -29,6 +29,10 @@ export const DELEGATIONS_STORAGE = 'dotrino.identity.delegations' // caps emit
29
29
  export const REVOCATIONS_STORAGE = 'dotrino.identity.revocations' // nonces revocados
30
30
  export const VAULT_DEVICE_STORAGE = 'dotrino.identity.vault.device' // sub-clave D de ESTE dispositivo (custodia en el iframe)
31
31
  export const VAULT_CERT_STORAGE = 'dotrino.identity.vault.cert' // { cert, master, proxy, deviceId, pairedAt }
32
+ // Multi-perfil por dispositivo: lista de perfiles + el activo. Cada perfil tiene su propio
33
+ // namespace `dotrino.identity.p.<id>.<suffix>` para TODAS las claves de arriba (keypair, me, etc.).
34
+ export const PROFILES_STORAGE = 'dotrino.identity.profiles' // [{ id, name, pubkey }]
35
+ export const CURRENT_STORAGE = 'dotrino.identity.current' // id del perfil activo
32
36
 
33
37
  const NONCE_TTL_MS = 5 * 60 * 1000
34
38
 
@@ -104,11 +108,27 @@ async function verifyBytes (publicJwkStr, bytes, signatureBase64) {
104
108
  * @returns {Promise<{ handlers:Object, get me():Object, sync:Object|null,
105
109
  * onSyncStatus(fn):void }>}
106
110
  */
107
- export async function createIdentityCore ({ kv, peers, makeSync = null }) {
111
+ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null }) {
108
112
  const {
109
113
  initPeerStorage, loadPeers, savePeers, setPeersDirect, upsertPeer, onDirty
110
114
  } = peers
111
115
 
116
+ // ----- multi-perfil: kv SCOPEADO por el perfil activo -----
117
+ // Todas las claves `dotrino.identity.*` (keypair, me, nonces, delegations, vault.*) se
118
+ // namespacean transparentemente bajo `dotrino.identity.p.<currentPid>.*`. Las dos claves
119
+ // globales (lista de perfiles + activo) usan el kv crudo. Cambiar de perfil = setear el
120
+ // activo; la app recarga la página y re-inicializa con el nuevo (no reactivo, por diseño).
121
+ let currentPid = null
122
+ const _scoped = (k) => (!currentPid || k === PROFILES_STORAGE || k === CURRENT_STORAGE)
123
+ ? k : k.replace(/^dotrino\.identity\./, `dotrino.identity.p.${currentPid}.`)
124
+ const kv = {
125
+ getItem: (k) => rawKv.getItem(_scoped(k)),
126
+ setItem: (k, v) => rawKv.setItem(_scoped(k), v),
127
+ removeItem: (k) => rawKv.removeItem(_scoped(k))
128
+ }
129
+ const loadProfiles = () => { try { return JSON.parse(rawKv.getItem(PROFILES_STORAGE) || '[]') || [] } catch { return [] } }
130
+ const saveProfiles = (list) => rawKv.setItem(PROFILES_STORAGE, JSON.stringify(list))
131
+
112
132
  // ----- keypair loaders (kv-backed) -----
113
133
 
114
134
  async function loadOrCreateKeypair () {
@@ -511,6 +531,54 @@ export async function createIdentityCore ({ kv, peers, makeSync = null }) {
511
531
  }
512
532
  },
513
533
 
534
+ // ----- perfiles (multi-perfil por dispositivo) -----
535
+ // Cambiar/crear setea el perfil activo; la app RECARGA la página y re-inicializa con él
536
+ // (no reactivo, por diseño). Las apps abiertas conservan el perfil con el que cargaron.
537
+ async listProfiles () {
538
+ return loadProfiles().map((p) => ({ id: p.id, name: p.name || '', pubkey: p.pubkey || null, current: p.id === currentPid }))
539
+ },
540
+ async currentProfile () {
541
+ const e = loadProfiles().find((p) => p.id === currentPid) || {}
542
+ return { id: currentPid, name: e.name || me?.nickname || '', pubkey: publickeyJwkStr }
543
+ },
544
+ async createProfile ({ name } = {}) {
545
+ const pid = 'p' + crypto.randomUUID().slice(0, 8)
546
+ currentPid = pid
547
+ rawKv.setItem(CURRENT_STORAGE, pid)
548
+ await peers.setProfile?.(pid)
549
+ await initPeerStorage()
550
+ keypair = await loadOrCreateKeypair(); publickeyJwkStr = JSON.stringify(keypair.publicJwk)
551
+ encKeypair = await loadOrCreateEncKeypair(); encPublickeyJwkStr = JSON.stringify(encKeypair.publicJwk)
552
+ me = { publickey: publickeyJwkStr, encryptionPubkey: encPublickeyJwkStr, nickname: String(name || '').slice(0, 40) }
553
+ saveMe(me)
554
+ const list = loadProfiles(); list.push({ id: pid, name: me.nickname, pubkey: publickeyJwkStr }); saveProfiles(list)
555
+ return { id: pid, name: me.nickname, pubkey: publickeyJwkStr }
556
+ },
557
+ async switchProfile ({ id } = {}) {
558
+ if (!loadProfiles().find((p) => p.id === id)) throw new Error('perfil no existe')
559
+ rawKv.setItem(CURRENT_STORAGE, id) // la app recarga la página → re-init con el nuevo perfil
560
+ return { id }
561
+ },
562
+ async renameProfile ({ id, name } = {}) {
563
+ const list = loadProfiles(); const e = list.find((p) => p.id === (id || currentPid))
564
+ if (!e) throw new Error('perfil no existe')
565
+ e.name = String(name || '').slice(0, 40); saveProfiles(list)
566
+ if (e.id === currentPid) { me = { ...(me || {}), nickname: e.name }; saveMe(me) }
567
+ return { id: e.id, name: e.name }
568
+ },
569
+ async deleteProfile ({ id } = {}) {
570
+ let list = loadProfiles()
571
+ if (list.length <= 1) throw new Error('no se puede borrar el único perfil')
572
+ if (!list.find((p) => p.id === id)) throw new Error('perfil no existe')
573
+ list = list.filter((p) => p.id !== id); saveProfiles(list)
574
+ // Borrado directo del namespace del perfil (incluye su store del vault si lo tuviera).
575
+ for (const s of ['keypair', 'enc-keypair', 'me', 'nonces', 'delegations', 'revocations', 'vault.device', 'vault.cert']) {
576
+ rawKv.removeItem(`dotrino.identity.p.${id}.${s}`)
577
+ }
578
+ if (currentPid === id) { currentPid = list[0].id; rawKv.setItem(CURRENT_STORAGE, currentPid) }
579
+ return { ok: true, current: currentPid }
580
+ },
581
+
514
582
  // ----- emparejar ESTE dispositivo con el vault del usuario (Fase 1) -----
515
583
  // Genera D aquí dentro (su privada NUNCA sale de la identidad), hace el enroll
516
584
  // endurecido por el proxy y guarda el cert. NO cambia signData todavía (Fase 2).
@@ -671,6 +739,24 @@ export async function createIdentityCore ({ kv, peers, makeSync = null }) {
671
739
 
672
740
  // ----- bootstrap -----
673
741
 
742
+ // Perfil activo (multi-perfil por dispositivo). Sin migración (ecosistema nuevo, no importa
743
+ // perder data): si no hay perfiles, se crea uno fresco. A partir de acá `kv` está scopeado a
744
+ // `currentPid` y `peers` apunta al peer book de ese perfil.
745
+ {
746
+ let profiles = loadProfiles()
747
+ currentPid = rawKv.getItem(CURRENT_STORAGE)
748
+ if (!profiles.length) {
749
+ currentPid = 'p' + crypto.randomUUID().slice(0, 8)
750
+ profiles = [{ id: currentPid, name: '', pubkey: null }]
751
+ saveProfiles(profiles)
752
+ rawKv.setItem(CURRENT_STORAGE, currentPid)
753
+ } else if (!currentPid || !profiles.find((p) => p.id === currentPid)) {
754
+ currentPid = profiles[0].id
755
+ rawKv.setItem(CURRENT_STORAGE, currentPid)
756
+ }
757
+ }
758
+ await peers.setProfile?.(currentPid)
759
+
674
760
  keypair = await loadOrCreateKeypair()
675
761
  publickeyJwkStr = JSON.stringify(keypair.publicJwk)
676
762
  encKeypair = await loadOrCreateEncKeypair()
@@ -690,6 +776,14 @@ export async function createIdentityCore ({ kv, peers, makeSync = null }) {
690
776
  kv.setItem(ME_STORAGE, JSON.stringify(me))
691
777
  }
692
778
 
779
+ // Registrar el pubkey (y nombre) del perfil activo en su meta → para avatar/listado sin abrir cada perfil.
780
+ {
781
+ const list = loadProfiles(); const e = list.find((p) => p.id === currentPid)
782
+ if (e && (e.pubkey !== publickeyJwkStr || (!e.name && me?.nickname))) {
783
+ e.pubkey = publickeyJwkStr; if (!e.name && me?.nickname) e.name = me.nickname; saveProfiles(list)
784
+ }
785
+ }
786
+
693
787
  if (typeof makeSync === 'function') {
694
788
  sync = makeSync({
695
789
  fileName: 'dotrino-identity-backup.json',
@@ -31,10 +31,15 @@ let _fallback = false
31
31
  let _idb = null
32
32
  let _writeChain = Promise.resolve()
33
33
  let _markDirty = null
34
+ let _pid = null
34
35
 
35
36
  /** Registra el callback que marca el estado como "sucio" para el sync. */
36
37
  export function onDirty (fn) { _markDirty = fn }
37
38
 
39
+ /** Multi-perfil: namespacea el peer book por perfil. El core lo llama antes de initPeerStorage. */
40
+ export function setProfile (pid) { _pid = pid || null }
41
+ function peersKey () { return _pid ? `peers.${_pid}.v1` : IDB_PEERS_KEY }
42
+
38
43
  function openIdb () {
39
44
  return new Promise((resolve, reject) => {
40
45
  let req
@@ -74,43 +79,28 @@ export async function initPeerStorage () {
74
79
  catch (_) { /* best-effort */ }
75
80
  try {
76
81
  _idb = await openIdb()
77
- const stored = await idbGet(_idb, IDB_PEERS_KEY)
78
- const storedPeers = (stored && typeof stored === 'object') ? stored : {}
79
- const migratedFlag = await idbGet(_idb, IDB_MIGRATED_KEY)
80
- if (migratedFlag) {
81
- // Ya reconciliado: IndexedDB es la fuente de verdad (ignora el LS viejo).
82
- _peers = storedPeers
83
- } else {
84
- // Primera corrida (o reintento tras el bug que escribía {}): unimos el
85
- // peer book del localStorage viejo con lo que haya en IndexedDB —unión,
86
- // IndexedDB gana en conflictos— para recuperar contactos que un bug previo
87
- // pudo enmascarar. Nunca borra. Marcamos el flag para no rehacerlo (así no
88
- // se "resucitan" contactos que el usuario borre más adelante).
89
- const local = readLocalPeers()
90
- _peers = { ...local, ...storedPeers }
91
- const recovered = Object.keys(local).filter(k => !(k in storedPeers)).length
92
- await idbPut(_idb, IDB_PEERS_KEY, _peers)
93
- await idbPut(_idb, IDB_MIGRATED_KEY, true)
94
- if (recovered) console.log(`[cc-identity] ${recovered} peer(s) recuperados del localStorage viejo → IndexedDB`)
95
- }
82
+ const stored = await idbGet(_idb, peersKey()) // peer book DEL perfil activo (namespaceado)
83
+ _peers = (stored && typeof stored === 'object') ? stored : {}
96
84
  } catch (e) {
97
85
  console.warn('[cc-identity] IndexedDB no disponible, uso localStorage:', e?.message)
98
86
  _fallback = true
99
87
  _idb = null
100
- _peers = readLocalPeers()
88
+ try { const raw = localStorage.getItem(peersKey()); _peers = raw ? (JSON.parse(raw) || {}) : {} }
89
+ catch (_) { _peers = {} }
101
90
  }
102
91
  return _peers
103
92
  }
104
93
 
105
94
  function persistPeers () {
95
+ const key = peersKey()
106
96
  if (_fallback || !_idb) {
107
- try { localStorage.setItem(PEERS_STORAGE, JSON.stringify(_peers)) }
97
+ try { localStorage.setItem(key, JSON.stringify(_peers)) }
108
98
  catch (e) { console.warn('[cc-identity] persist (ls) falló:', e?.message) }
109
99
  return _writeChain
110
100
  }
111
101
  const snapshot = _peers
112
102
  _writeChain = _writeChain
113
- .then(() => idbPut(_idb, IDB_PEERS_KEY, snapshot))
103
+ .then(() => idbPut(_idb, key, snapshot))
114
104
  .catch(e => console.warn('[cc-identity] persist (idb) falló:', e?.message))
115
105
  return _writeChain
116
106
  }
@@ -143,5 +133,5 @@ export function upsertPeer (publickey, patch) {
143
133
  // Sólo para tests: resetea el estado del módulo (y cierra la conexión IDB).
144
134
  export function _resetForTest () {
145
135
  try { if (_idb && _idb.close) _idb.close() } catch (_) {}
146
- _peers = {}; _fallback = false; _idb = null; _writeChain = Promise.resolve(); _markDirty = null
136
+ _peers = {}; _fallback = false; _idb = null; _writeChain = Promise.resolve(); _markDirty = null; _pid = null
147
137
  }