@dotrino/identity 0.10.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/vault/core.js ADDED
@@ -0,0 +1,641 @@
1
+ /**
2
+ * Núcleo runtime-agnóstico de la identidad Dotrino.
3
+ *
4
+ * Contiene TODA la criptografía y los handlers del vault, SIN depender de
5
+ * `localStorage`, `iframe`, `IndexedDB` ni `postMessage`. El almacenamiento
6
+ * (kv), el peer book (peers) y el sync se inyectan, de modo que el mismo código
7
+ * corre:
8
+ * - dentro del iframe del vault (`vault.js` → kv=localStorage, peers=IndexedDB,
9
+ * sync=Google Drive, transporte=postMessage), y
10
+ * - headless en Node (`src/node.js` → kv y peers respaldados en archivos,
11
+ * sync deshabilitado, llamadas directas a los handlers).
12
+ *
13
+ * Sólo usa globals presentes en navegadores modernos y Node ≥ 20:
14
+ * `crypto.subtle`, `crypto.randomUUID`, `crypto.getRandomValues`,
15
+ * `TextEncoder`/`TextDecoder`, `btoa`/`atob`.
16
+ *
17
+ * NO reimplementa el protocolo: es la única fuente de verdad de la cripto del
18
+ * vault, compartida por todos los runtimes.
19
+ */
20
+
21
+ import { signDelegationWith, MAX_DELEGATION_MS, DEFAULT_DELEGATION_MS } from './capabilities.js'
22
+
23
+ export const KEY_STORAGE = 'dotrino.identity.keypair'
24
+ export const ENC_KEY_STORAGE = 'dotrino.identity.enc-keypair'
25
+ export const ME_STORAGE = 'dotrino.identity.me'
26
+ export const NONCE_STORAGE = 'dotrino.identity.nonces' // replay window
27
+ export const DELEGATIONS_STORAGE = 'dotrino.identity.delegations' // caps emitidas
28
+ export const REVOCATIONS_STORAGE = 'dotrino.identity.revocations' // nonces revocados
29
+
30
+ const NONCE_TTL_MS = 5 * 60 * 1000
31
+
32
+ // ----- crypto helpers (puros) -----
33
+
34
+ export function canonicalStringify (v) {
35
+ if (v === null || typeof v !== 'object') return JSON.stringify(v)
36
+ if (Array.isArray(v)) return '[' + v.map(canonicalStringify).join(',') + ']'
37
+ const ks = Object.keys(v).sort()
38
+ return '{' + ks.map(k => JSON.stringify(k) + ':' + canonicalStringify(v[k])).join(',') + '}'
39
+ }
40
+
41
+ export function bufToBase64 (buf) {
42
+ const bytes = new Uint8Array(buf)
43
+ let s = ''
44
+ for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i])
45
+ return btoa(s)
46
+ }
47
+
48
+ export function base64ToBuf (b64) {
49
+ const s = atob(b64)
50
+ const bytes = new Uint8Array(s.length)
51
+ for (let i = 0; i < s.length; i++) bytes[i] = s.charCodeAt(i)
52
+ return bytes.buffer
53
+ }
54
+
55
+ async function importPeerEncPubkey (jwkStr) {
56
+ const jwk = typeof jwkStr === 'string' ? JSON.parse(jwkStr) : jwkStr
57
+ return crypto.subtle.importKey('jwk', jwk, { name: 'ECDH', namedCurve: 'P-256' }, true, [])
58
+ }
59
+
60
+ async function deriveSharedAesKey (myPriv, peerPub) {
61
+ return crypto.subtle.deriveKey(
62
+ { name: 'ECDH', public: peerPub },
63
+ myPriv,
64
+ { name: 'AES-GCM', length: 256 },
65
+ false,
66
+ ['encrypt', 'decrypt']
67
+ )
68
+ }
69
+
70
+ async function signBytes (privateKey, bytes) {
71
+ const sig = await crypto.subtle.sign({ name: 'ECDSA', hash: { name: 'SHA-256' } }, privateKey, bytes)
72
+ return bufToBase64(sig)
73
+ }
74
+
75
+ async function verifyBytes (publicJwkStr, bytes, signatureBase64) {
76
+ let publicKey
77
+ try {
78
+ const jwk = JSON.parse(publicJwkStr)
79
+ publicKey = await crypto.subtle.importKey('jwk', jwk, { name: 'ECDSA', namedCurve: 'P-256' }, true, ['verify'])
80
+ } catch (_) {
81
+ return false
82
+ }
83
+ return crypto.subtle.verify(
84
+ { name: 'ECDSA', hash: { name: 'SHA-256' } },
85
+ publicKey,
86
+ base64ToBuf(signatureBase64),
87
+ bytes
88
+ )
89
+ }
90
+
91
+ /**
92
+ * Crea el núcleo de identidad sobre los backends inyectados.
93
+ *
94
+ * @param {Object} deps
95
+ * @param {{getItem(k):string|null, setItem(k,v):void, removeItem(k):void}} deps.kv
96
+ * Almacén clave-valor síncrono estilo localStorage (keypairs, me, nonces).
97
+ * @param {Object} deps.peers Peer book con la interfaz de vault/peerStore.js:
98
+ * { initPeerStorage, loadPeers, savePeers, setPeersDirect, upsertPeer, onDirty }
99
+ * @param {Function|null} [deps.makeSync] Factory de sync (createSync). Si es null,
100
+ * los métodos sync* lanzan "sync not ready" (modo headless / sin Drive).
101
+ * @returns {Promise<{ handlers:Object, get me():Object, sync:Object|null,
102
+ * onSyncStatus(fn):void }>}
103
+ */
104
+ export async function createIdentityCore ({ kv, peers, makeSync = null }) {
105
+ const {
106
+ initPeerStorage, loadPeers, savePeers, setPeersDirect, upsertPeer, onDirty
107
+ } = peers
108
+
109
+ // ----- keypair loaders (kv-backed) -----
110
+
111
+ async function loadOrCreateKeypair () {
112
+ const raw = kv.getItem(KEY_STORAGE)
113
+ if (raw) {
114
+ try {
115
+ const { privateJwk, publicJwk } = JSON.parse(raw)
116
+ const privateKey = await crypto.subtle.importKey('jwk', privateJwk, { name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign'])
117
+ const publicKey = await crypto.subtle.importKey('jwk', publicJwk, { name: 'ECDSA', namedCurve: 'P-256' }, true, ['verify'])
118
+ return { privateKey, publicKey, publicJwk }
119
+ } catch (_) {}
120
+ }
121
+ const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'])
122
+ const privateJwk = await crypto.subtle.exportKey('jwk', pair.privateKey)
123
+ const publicJwk = await crypto.subtle.exportKey('jwk', pair.publicKey)
124
+ kv.setItem(KEY_STORAGE, JSON.stringify({ privateJwk, publicJwk }))
125
+ return { privateKey: pair.privateKey, publicKey: pair.publicKey, publicJwk }
126
+ }
127
+
128
+ async function loadOrCreateEncKeypair () {
129
+ const raw = kv.getItem(ENC_KEY_STORAGE)
130
+ if (raw) {
131
+ try {
132
+ const { privateJwk, publicJwk } = JSON.parse(raw)
133
+ const privateKey = await crypto.subtle.importKey('jwk', privateJwk, { name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits', 'deriveKey'])
134
+ const publicKey = await crypto.subtle.importKey('jwk', publicJwk, { name: 'ECDH', namedCurve: 'P-256' }, true, [])
135
+ return { privateKey, publicKey, publicJwk }
136
+ } catch (_) {}
137
+ }
138
+ const pair = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits', 'deriveKey'])
139
+ const privateJwk = await crypto.subtle.exportKey('jwk', pair.privateKey)
140
+ const publicJwk = await crypto.subtle.exportKey('jwk', pair.publicKey)
141
+ kv.setItem(ENC_KEY_STORAGE, JSON.stringify({ privateJwk, publicJwk }))
142
+ return { privateKey: pair.privateKey, publicKey: pair.publicKey, publicJwk }
143
+ }
144
+
145
+ // ----- nonce replay protection (kv-backed) -----
146
+
147
+ function loadNonces () {
148
+ try {
149
+ const raw = kv.getItem(NONCE_STORAGE)
150
+ if (!raw) return {}
151
+ const obj = JSON.parse(raw) || {}
152
+ const now = Date.now()
153
+ for (const k of Object.keys(obj)) if (now - obj[k] > NONCE_TTL_MS) delete obj[k]
154
+ return obj
155
+ } catch (_) {
156
+ return {}
157
+ }
158
+ }
159
+ function saveNonces (obj) { kv.setItem(NONCE_STORAGE, JSON.stringify(obj)) }
160
+ function rememberNonce (nonce) { const o = loadNonces(); o[nonce] = Date.now(); saveNonces(o) }
161
+ function isFreshNonce (nonce) { return Object.prototype.hasOwnProperty.call(loadNonces(), nonce) }
162
+
163
+ // ----- delegaciones de capacidad emitidas + revocaciones (kv-backed) -----
164
+
165
+ function loadJson (key) { try { return JSON.parse(kv.getItem(key) || '{}') || {} } catch (_) { return {} } }
166
+ const loadDelegations = () => loadJson(DELEGATIONS_STORAGE)
167
+ const saveDelegations = (o) => kv.setItem(DELEGATIONS_STORAGE, JSON.stringify(o))
168
+ const loadRevocations = () => loadJson(REVOCATIONS_STORAGE)
169
+ const saveRevocations = (o) => kv.setItem(REVOCATIONS_STORAGE, JSON.stringify(o))
170
+
171
+ // ----- me (kv-backed) -----
172
+
173
+ function loadMe () {
174
+ try { const raw = kv.getItem(ME_STORAGE); return raw ? JSON.parse(raw) : null }
175
+ catch (_) { return null }
176
+ }
177
+ function saveMe (next) {
178
+ kv.setItem(ME_STORAGE, JSON.stringify(next))
179
+ me = next
180
+ if (sync) sync.markDirty()
181
+ }
182
+
183
+ // ----- endorsement verify / merge (sync) -----
184
+
185
+ async function verifyEndorsement (env) {
186
+ if (!env || typeof env !== 'object') return false
187
+ const { subject, rating, notes, ratedBy, issuedAt, signature } = env
188
+ if (typeof ratedBy !== 'string' || typeof signature !== 'string') return false
189
+ const canonical = canonicalStringify({ subject, rating, notes: typeof notes === 'string' ? notes : '', ratedBy, issuedAt })
190
+ try { return await verifyBytes(ratedBy, new TextEncoder().encode(canonical), signature) }
191
+ catch { return false }
192
+ }
193
+
194
+ async function mergePeerMaps (localPeers, remotePeers) {
195
+ const out = { ...localPeers }
196
+ let changed = false
197
+ const allKeys = new Set([...Object.keys(localPeers || {}), ...Object.keys(remotePeers || {})])
198
+ for (const pk of allKeys) {
199
+ const a = localPeers[pk]
200
+ const b = remotePeers[pk]
201
+ if (a && !b) continue
202
+ if (!a && b) {
203
+ const adopted = { ...b }
204
+ if (Array.isArray(adopted.endorsements)) {
205
+ const verified = []
206
+ for (const e of adopted.endorsements) if (await verifyEndorsement(e)) verified.push(e)
207
+ adopted.endorsements = verified
208
+ }
209
+ out[pk] = adopted
210
+ changed = true
211
+ continue
212
+ }
213
+ const merged = { ...a }
214
+ const aSeen = a.lastSeen || 0
215
+ const bSeen = b.lastSeen || 0
216
+ const newer = bSeen > aSeen ? b : a
217
+ if (newer === b) {
218
+ if (b.nickname !== undefined) merged.nickname = b.nickname
219
+ if (b.notes !== undefined) merged.notes = b.notes
220
+ if (b.contactNotes !== undefined) merged.contactNotes = b.contactNotes
221
+ if (b.encryptionPubkey) merged.encryptionPubkey = b.encryptionPubkey
222
+ if (typeof b.rating === 'number') merged.rating = b.rating
223
+ }
224
+ merged.firstSeen = Math.min(a.firstSeen || aSeen || Date.now(), b.firstSeen || bSeen || Date.now())
225
+ merged.lastSeen = Math.max(aSeen, bSeen)
226
+ merged.isContact = !!(a.isContact || b.isContact)
227
+ const aMine = a.myRating
228
+ const bMine = b.myRating
229
+ if (bMine && (!aMine || (bMine.issuedAt || 0) > (aMine.issuedAt || 0))) {
230
+ if (await verifyEndorsement(bMine)) merged.myRating = bMine
231
+ }
232
+ const byRater = new Map()
233
+ for (const e of (a.endorsements || [])) if (e?.ratedBy) byRater.set(e.ratedBy, e)
234
+ for (const e of (b.endorsements || [])) {
235
+ if (!e?.ratedBy) continue
236
+ const prev = byRater.get(e.ratedBy)
237
+ if (prev && (prev.issuedAt || 0) >= (e.issuedAt || 0)) continue
238
+ if (await verifyEndorsement(e)) byRater.set(e.ratedBy, e)
239
+ }
240
+ merged.endorsements = Array.from(byRater.values())
241
+ .sort((x, y) => (y.issuedAt || 0) - (x.issuedAt || 0)).slice(0, 50)
242
+ if (a.queryStats || b.queryStats) {
243
+ merged.queryStats = {
244
+ queriesMade: Math.max(a.queryStats?.queriesMade || 0, b.queryStats?.queriesMade || 0),
245
+ queriesKnown: Math.max(a.queryStats?.queriesKnown || 0, b.queryStats?.queriesKnown || 0)
246
+ }
247
+ }
248
+ if (JSON.stringify(merged) !== JSON.stringify(a)) changed = true
249
+ out[pk] = merged
250
+ }
251
+ return { merged: out, changed }
252
+ }
253
+
254
+ async function exportLocalForSync () {
255
+ const raw = kv.getItem(KEY_STORAGE)
256
+ const keys = raw ? JSON.parse(raw) : null
257
+ const encRaw = kv.getItem(ENC_KEY_STORAGE)
258
+ const encKeys = encRaw ? JSON.parse(encRaw) : null
259
+ return {
260
+ privateJwk: keys?.privateJwk || null,
261
+ publicJwk: keys?.publicJwk || null,
262
+ encPrivateJwk: encKeys?.privateJwk || null,
263
+ encPublicJwk: encKeys?.publicJwk || null,
264
+ me: loadMe(),
265
+ peers: loadPeers()
266
+ }
267
+ }
268
+
269
+ async function applyMergedFromSync (merged) {
270
+ const localKeys = kv.getItem(KEY_STORAGE)
271
+ if (!localKeys && merged.privateJwk && merged.publicJwk) {
272
+ kv.setItem(KEY_STORAGE, JSON.stringify({ privateJwk: merged.privateJwk, publicJwk: merged.publicJwk }))
273
+ if (merged.encPrivateJwk && merged.encPublicJwk) {
274
+ kv.setItem(ENC_KEY_STORAGE, JSON.stringify({ privateJwk: merged.encPrivateJwk, publicJwk: merged.encPublicJwk }))
275
+ }
276
+ keypair = await loadOrCreateKeypair()
277
+ publickeyJwkStr = JSON.stringify(keypair.publicJwk)
278
+ encKeypair = await loadOrCreateEncKeypair()
279
+ encPublickeyJwkStr = JSON.stringify(encKeypair.publicJwk)
280
+ if (merged.me) kv.setItem(ME_STORAGE, JSON.stringify(merged.me))
281
+ } else if (localKeys && merged.publicJwk) {
282
+ const localPub = JSON.parse(localKeys).publicJwk
283
+ if (JSON.stringify(localPub) !== JSON.stringify(merged.publicJwk)) {
284
+ console.warn('[vault.sync] Remote keypair differs from local — keeping local keypair.')
285
+ }
286
+ }
287
+ if (merged.peers && typeof merged.peers === 'object') setPeersDirect(merged.peers)
288
+ }
289
+
290
+ async function mergeForSync (local, remote) {
291
+ if (!remote) return { merged: local, changed: false }
292
+ const { merged: mergedPeers, changed } = await mergePeerMaps(local.peers || {}, remote.peers || {})
293
+ return {
294
+ merged: {
295
+ privateJwk: local.privateJwk || remote.privateJwk,
296
+ publicJwk: local.publicJwk || remote.publicJwk,
297
+ encPrivateJwk: local.encPrivateJwk || remote.encPrivateJwk,
298
+ encPublicJwk: local.encPublicJwk || remote.encPublicJwk,
299
+ me: local.me || remote.me,
300
+ peers: mergedPeers
301
+ },
302
+ changed
303
+ }
304
+ }
305
+
306
+ // ----- runtime state -----
307
+
308
+ let keypair = null
309
+ let publickeyJwkStr = null
310
+ let encKeypair = null
311
+ let encPublickeyJwkStr = null
312
+ let sync = null
313
+ let me = null
314
+
315
+ // ----- handlers (idénticos a la versión iframe) -----
316
+
317
+ const handlers = {
318
+ async makeChallenge () {
319
+ const nonce = crypto.randomUUID()
320
+ rememberNonce(nonce)
321
+ return { nonce }
322
+ },
323
+
324
+ async signChallenge ({ nonce }) {
325
+ if (!nonce || typeof nonce !== 'string') throw new Error('nonce required')
326
+ const bytes = new TextEncoder().encode(nonce)
327
+ const signature = await signBytes(keypair.privateKey, bytes)
328
+ return { nonce, publickey: publickeyJwkStr, encryptionPubkey: encPublickeyJwkStr, signature }
329
+ },
330
+
331
+ async verifyResponse ({ nonce, publickey, signature, encryptionPubkey }) {
332
+ if (!nonce || !publickey || !signature) return { ok: false }
333
+ if (!isFreshNonce(nonce)) return { ok: false, reason: 'nonce expired or unknown' }
334
+ const bytes = new TextEncoder().encode(nonce)
335
+ const ok = await verifyBytes(publickey, bytes, signature)
336
+ if (!ok) return { ok: false }
337
+ const patch = {}
338
+ if (typeof encryptionPubkey === 'string' && encryptionPubkey) patch.encryptionPubkey = encryptionPubkey
339
+ const peer = upsertPeer(publickey, patch)
340
+ return { ok: true, publickey, encryptionPubkey: encryptionPubkey || null, peer }
341
+ },
342
+
343
+ async getPeer ({ publickey }) {
344
+ const p = loadPeers()
345
+ return p[publickey] || null
346
+ },
347
+
348
+ async setNickname ({ publickey, nickname }) {
349
+ return upsertPeer(publickey, { nickname: String(nickname || '').slice(0, 40) })
350
+ },
351
+
352
+ async setRating ({ publickey, rating, notes }) {
353
+ const r = Math.max(0, Math.min(5, Number(rating) || 0))
354
+ const safeNotes = typeof notes === 'string' ? notes.slice(0, 500) : ''
355
+ const issuedAt = Date.now()
356
+ const envelope = { subject: publickey, rating: r, notes: safeNotes, ratedBy: publickeyJwkStr, issuedAt }
357
+ const sigBytes = new TextEncoder().encode(canonicalStringify(envelope))
358
+ const signature = await signBytes(keypair.privateKey, sigBytes)
359
+ const myRating = { ...envelope, signature }
360
+ return upsertPeer(publickey, { myRating, rating: r, notes: safeNotes })
361
+ },
362
+
363
+ async mergeEndorsements ({ subject, endorsements, askerPubkey }) {
364
+ if (!subject || !Array.isArray(endorsements)) return { merged: 0, total: 0 }
365
+ const peersMap = loadPeers()
366
+ const existing = peersMap[subject] || { publickey: subject, firstSeen: Date.now() }
367
+ const current = Array.isArray(existing.endorsements) ? existing.endorsements : []
368
+ const byRater = new Map()
369
+ for (const e of current) if (e?.ratedBy) byRater.set(e.ratedBy, e)
370
+ let merged = 0
371
+ for (const env of endorsements) {
372
+ if (!env || typeof env !== 'object') continue
373
+ const { subject: s, rating, notes, ratedBy, issuedAt, signature } = env
374
+ if (s !== subject) continue
375
+ if (typeof ratedBy !== 'string' || !ratedBy) continue
376
+ if (ratedBy === publickeyJwkStr) continue
377
+ if (typeof signature !== 'string') continue
378
+ if (typeof rating !== 'number' || rating < 0 || rating > 5) continue
379
+ const prev = byRater.get(ratedBy)
380
+ if (prev && (prev.issuedAt || 0) >= (issuedAt || 0)) continue
381
+ const canonical = canonicalStringify({ subject: s, rating, notes: typeof notes === 'string' ? notes : '', ratedBy, issuedAt })
382
+ const ok = await verifyBytes(ratedBy, new TextEncoder().encode(canonical), signature)
383
+ if (!ok) continue
384
+ byRater.set(ratedBy, env)
385
+ merged++
386
+ }
387
+ const all = Array.from(byRater.values()).sort((a, b) => (b.issuedAt || 0) - (a.issuedAt || 0)).slice(0, 50)
388
+ peersMap[subject] = { ...existing, publickey: subject, endorsements: all, lastSeen: Date.now() }
389
+ if (typeof askerPubkey === 'string' && askerPubkey && askerPubkey !== publickeyJwkStr) {
390
+ const askerRecord = peersMap[askerPubkey] || { publickey: askerPubkey, firstSeen: Date.now() }
391
+ const stats = askerRecord.queryStats || { queriesMade: 0, queriesKnown: 0 }
392
+ stats.queriesMade = (stats.queriesMade || 0) + 1
393
+ const knewIt = !!(existing.myRating) || (Array.isArray(existing.endorsements) && existing.endorsements.length > 0)
394
+ if (knewIt) stats.queriesKnown = (stats.queriesKnown || 0) + 1
395
+ peersMap[askerPubkey] = { ...askerRecord, queryStats: stats, lastSeen: askerRecord.lastSeen || Date.now() }
396
+ }
397
+ savePeers(peersMap)
398
+ return { merged, total: all.length }
399
+ },
400
+
401
+ async getRatingsForSubject ({ subject }) {
402
+ const p = loadPeers()
403
+ const r = p[subject]
404
+ return { mine: r?.myRating || null, endorsements: Array.isArray(r?.endorsements) ? r.endorsements : [] }
405
+ },
406
+
407
+ async recordQuery ({ askerPubkey, subject }) {
408
+ if (!askerPubkey || askerPubkey === publickeyJwkStr) return null
409
+ const peersMap = loadPeers()
410
+ const askerRecord = peersMap[askerPubkey] || { publickey: askerPubkey, firstSeen: Date.now() }
411
+ const stats = askerRecord.queryStats || { queriesMade: 0, queriesKnown: 0 }
412
+ stats.queriesMade = (stats.queriesMade || 0) + 1
413
+ if (subject) {
414
+ const subjectRec = peersMap[subject]
415
+ const knewIt = !!(subjectRec?.myRating) || (Array.isArray(subjectRec?.endorsements) && subjectRec.endorsements.length > 0)
416
+ if (knewIt) stats.queriesKnown = (stats.queriesKnown || 0) + 1
417
+ }
418
+ peersMap[askerPubkey] = { ...askerRecord, queryStats: stats, lastSeen: askerRecord.lastSeen || Date.now() }
419
+ savePeers(peersMap)
420
+ return peersMap[askerPubkey]
421
+ },
422
+
423
+ async listPeers () {
424
+ return Object.values(loadPeers()).sort((a, b) => (b.lastSeen || 0) - (a.lastSeen || 0))
425
+ },
426
+
427
+ async forgetPeer ({ publickey }) {
428
+ const p = loadPeers()
429
+ delete p[publickey]
430
+ savePeers(p)
431
+ },
432
+
433
+ async addContact ({ publickey, nickname, encryptionPubkey, lastToken, notes }) {
434
+ if (!publickey) throw new Error('publickey required')
435
+ const patch = { isContact: true }
436
+ if (nickname != null) patch.nickname = String(nickname).slice(0, 40)
437
+ if (encryptionPubkey) patch.encryptionPubkey = encryptionPubkey
438
+ if (lastToken) patch.lastToken = lastToken
439
+ if (notes != null) patch.contactNotes = String(notes).slice(0, 300)
440
+ return upsertPeer(publickey, patch)
441
+ },
442
+
443
+ async updateContact ({ publickey, patch }) {
444
+ if (!publickey) throw new Error('publickey required')
445
+ if (!patch || typeof patch !== 'object') return null
446
+ const allowed = {}
447
+ for (const k of ['nickname', 'encryptionPubkey', 'lastToken', 'contactNotes']) if (k in patch) allowed[k] = patch[k]
448
+ return upsertPeer(publickey, allowed)
449
+ },
450
+
451
+ async removeContact ({ publickey }) {
452
+ const p = loadPeers()
453
+ const rec = p[publickey]
454
+ if (!rec) return null
455
+ delete rec.isContact
456
+ p[publickey] = rec
457
+ savePeers(p)
458
+ return rec
459
+ },
460
+
461
+ async signData ({ data }) {
462
+ if (data == null) throw new Error('data required')
463
+ const bytes = new TextEncoder().encode(canonicalStringify(data))
464
+ const signature = await signBytes(keypair.privateKey, bytes)
465
+ return { signature, publickey: publickeyJwkStr }
466
+ },
467
+
468
+ // ----- delegación de capacidad: la maestra firma un cert para una sub-clave -----
469
+ // de dispositivo `sub`, acotado por `scope` y `exp`, revocable por `nonce`.
470
+ // Es la ÚNICA forma en que la autoridad sale de la clave maestra, y va limitada.
471
+
472
+ async signDelegation ({ sub, scope, ttlMs, exp, nonce, label }) {
473
+ if (!sub || typeof sub !== 'string') throw new Error('sub (device pubkey) required')
474
+ if (!scope || (typeof scope !== 'string' && !Array.isArray(scope))) throw new Error('scope required')
475
+ const iat = Date.now()
476
+ const want = typeof exp === 'number' ? exp : iat + (Number(ttlMs) || DEFAULT_DELEGATION_MS)
477
+ const cappedExp = Math.min(want, iat + MAX_DELEGATION_MS) // tope duro de vida
478
+ // `iss` se FUERZA a la propia maestra: el usuario no puede emitir cert para otro emisor.
479
+ const cert = await signDelegationWith(keypair.privateKey, publickeyJwkStr, { sub, scope, iat, exp: cappedExp, nonce: nonce || crypto.randomUUID() })
480
+ const store = loadDelegations()
481
+ store[cert.nonce] = { nonce: cert.nonce, sub, scope, iat, exp: cappedExp, label: typeof label === 'string' ? label.slice(0, 60) : '' }
482
+ saveDelegations(store)
483
+ return { cert }
484
+ },
485
+
486
+ async revokeDelegation ({ nonce }) {
487
+ if (!nonce || typeof nonce !== 'string') throw new Error('nonce required')
488
+ const rev = loadRevocations()
489
+ rev[nonce] = Date.now()
490
+ saveRevocations(rev)
491
+ const store = loadDelegations()
492
+ if (store[nonce]) { store[nonce].revokedAt = rev[nonce]; saveDelegations(store) }
493
+ return { ok: true, revokedAt: rev[nonce] }
494
+ },
495
+
496
+ async listDelegations () {
497
+ const store = loadDelegations(); const rev = loadRevocations()
498
+ return {
499
+ issued: Object.values(store).sort((a, b) => (b.iat || 0) - (a.iat || 0)),
500
+ revoked: Object.keys(rev).map(nonce => ({ nonce, revokedAt: rev[nonce] }))
501
+ }
502
+ },
503
+
504
+ async listContacts () {
505
+ return Object.values(loadPeers()).filter(p => p && p.isContact).sort((a, b) => (b.lastSeen || 0) - (a.lastSeen || 0))
506
+ },
507
+
508
+ async setMyNickname ({ nickname }) {
509
+ const next = { publickey: publickeyJwkStr, encryptionPubkey: encPublickeyJwkStr, nickname: String(nickname || '').slice(0, 40) }
510
+ saveMe(next)
511
+ return { me: next }
512
+ },
513
+
514
+ async getEncryptionPubkey () { return encPublickeyJwkStr },
515
+
516
+ async encrypt ({ recipients, plaintext }) {
517
+ if (!Array.isArray(recipients) || recipients.length === 0) throw new Error('recipients required')
518
+ if (typeof plaintext !== 'string') throw new Error('plaintext required')
519
+ const k = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'])
520
+ const kRaw = await crypto.subtle.exportKey('raw', k)
521
+ const iv = crypto.getRandomValues(new Uint8Array(12))
522
+ const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, k, new TextEncoder().encode(plaintext))
523
+ const wrap = {}
524
+ for (const r of recipients) {
525
+ if (!r || !r.token || !r.encryptionPubkey) continue
526
+ try {
527
+ const peerPub = await importPeerEncPubkey(r.encryptionPubkey)
528
+ const sharedKey = await deriveSharedAesKey(encKeypair.privateKey, peerPub)
529
+ const wrapIv = crypto.getRandomValues(new Uint8Array(12))
530
+ const wrappedCt = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: wrapIv }, sharedKey, kRaw)
531
+ wrap[r.token] = { iv: bufToBase64(wrapIv), ct: bufToBase64(new Uint8Array(wrappedCt)) }
532
+ } catch (e) { /* destinatario omitido */ }
533
+ }
534
+ return { v: 1, iv: bufToBase64(iv), ct: bufToBase64(new Uint8Array(ct)), wrap }
535
+ },
536
+
537
+ async decrypt ({ senderEncryptionPubkey, myToken, envelope }) {
538
+ if (!senderEncryptionPubkey) throw new Error('senderEncryptionPubkey required')
539
+ if (!myToken) throw new Error('myToken required')
540
+ if (!envelope || envelope.v !== 1) throw new Error('Unsupported envelope')
541
+ const myEntry = envelope.wrap && envelope.wrap[myToken]
542
+ if (!myEntry) throw new Error('No wrap entry for this recipient')
543
+ const senderPub = await importPeerEncPubkey(senderEncryptionPubkey)
544
+ const sharedKey = await deriveSharedAesKey(encKeypair.privateKey, senderPub)
545
+ const kRaw = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: base64ToBuf(myEntry.iv) }, sharedKey, base64ToBuf(myEntry.ct))
546
+ const k = await crypto.subtle.importKey('raw', kRaw, { name: 'AES-GCM', length: 256 }, false, ['decrypt'])
547
+ const ptBytes = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: base64ToBuf(envelope.iv) }, k, base64ToBuf(envelope.ct))
548
+ return { plaintext: new TextDecoder().decode(ptBytes) }
549
+ },
550
+
551
+ async exportIdentity () {
552
+ const raw = kv.getItem(KEY_STORAGE)
553
+ if (!raw) throw new Error('No keypair to export')
554
+ const keys = JSON.parse(raw)
555
+ const encRaw = kv.getItem(ENC_KEY_STORAGE)
556
+ const encKeys = encRaw ? JSON.parse(encRaw) : null
557
+ return {
558
+ version: 2,
559
+ privateJwk: keys.privateJwk,
560
+ publicJwk: keys.publicJwk,
561
+ encPrivateJwk: encKeys?.privateJwk || null,
562
+ encPublicJwk: encKeys?.publicJwk || null,
563
+ me: loadMe(),
564
+ peers: loadPeers(),
565
+ exportedAt: new Date().toISOString()
566
+ }
567
+ },
568
+
569
+ async syncConnect ({ clientId }) { if (!sync) throw new Error('sync not ready'); return sync.connectGoogle(clientId) },
570
+ async syncDisconnect () { if (!sync) return; return sync.disconnectGoogle() },
571
+ async syncUnlock ({ passphrase }) { if (!sync) throw new Error('sync not ready'); return sync.unlock(passphrase) },
572
+ async syncLock () { if (!sync) return; return sync.lock() },
573
+ async syncStatus () { return sync ? sync.getStatus() : { connected: false, unlocked: false, dirty: false } },
574
+ async syncNow () { if (!sync) throw new Error('sync not ready'); await sync.pull(); await sync.push(); return sync.getStatus() },
575
+
576
+ async importIdentity ({ privateJwk, publicJwk, encPrivateJwk, encPublicJwk, me: meIn, peers: peersIn }) {
577
+ if (!privateJwk || !publicJwk) throw new Error('privateJwk and publicJwk required')
578
+ await crypto.subtle.importKey('jwk', privateJwk, { name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign'])
579
+ await crypto.subtle.importKey('jwk', publicJwk, { name: 'ECDSA', namedCurve: 'P-256' }, true, ['verify'])
580
+ kv.setItem(KEY_STORAGE, JSON.stringify({ privateJwk, publicJwk }))
581
+ if (encPrivateJwk && encPublicJwk) {
582
+ await crypto.subtle.importKey('jwk', encPrivateJwk, { name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits', 'deriveKey'])
583
+ await crypto.subtle.importKey('jwk', encPublicJwk, { name: 'ECDH', namedCurve: 'P-256' }, true, [])
584
+ kv.setItem(ENC_KEY_STORAGE, JSON.stringify({ privateJwk: encPrivateJwk, publicJwk: encPublicJwk }))
585
+ } else {
586
+ kv.removeItem(ENC_KEY_STORAGE)
587
+ }
588
+ if (peersIn && typeof peersIn === 'object' && Object.keys(peersIn).length) {
589
+ savePeers({ ...loadPeers(), ...peersIn })
590
+ }
591
+ keypair = await loadOrCreateKeypair()
592
+ publickeyJwkStr = JSON.stringify(keypair.publicJwk)
593
+ encKeypair = await loadOrCreateEncKeypair()
594
+ encPublickeyJwkStr = JSON.stringify(encKeypair.publicJwk)
595
+ const newMe = meIn && meIn.publickey === publickeyJwkStr
596
+ ? { ...meIn, encryptionPubkey: encPublickeyJwkStr }
597
+ : { publickey: publickeyJwkStr, encryptionPubkey: encPublickeyJwkStr, ...(meIn?.nickname ? { nickname: meIn.nickname } : {}) }
598
+ saveMe(newMe)
599
+ return { me: newMe }
600
+ }
601
+ }
602
+
603
+ // ----- bootstrap -----
604
+
605
+ keypair = await loadOrCreateKeypair()
606
+ publickeyJwkStr = JSON.stringify(keypair.publicJwk)
607
+ encKeypair = await loadOrCreateEncKeypair()
608
+ encPublickeyJwkStr = JSON.stringify(encKeypair.publicJwk)
609
+
610
+ await initPeerStorage()
611
+
612
+ const persistedMe = loadMe()
613
+ if (persistedMe && persistedMe.publickey === publickeyJwkStr) {
614
+ me = persistedMe
615
+ if (me.encryptionPubkey !== encPublickeyJwkStr) {
616
+ me = { ...me, encryptionPubkey: encPublickeyJwkStr }
617
+ kv.setItem(ME_STORAGE, JSON.stringify(me))
618
+ }
619
+ } else {
620
+ me = { publickey: publickeyJwkStr, encryptionPubkey: encPublickeyJwkStr }
621
+ kv.setItem(ME_STORAGE, JSON.stringify(me))
622
+ }
623
+
624
+ if (typeof makeSync === 'function') {
625
+ sync = makeSync({
626
+ fileName: 'dotrino-identity-backup.json',
627
+ kind: 'identity',
628
+ exportLocal: exportLocalForSync,
629
+ applyMerged: applyMergedFromSync,
630
+ mergeFn: mergeForSync
631
+ })
632
+ onDirty(() => { if (sync) sync.markDirty() })
633
+ }
634
+
635
+ return {
636
+ handlers,
637
+ get me () { return me },
638
+ sync,
639
+ onSyncStatus (fn) { if (sync) sync.onStatus(fn) }
640
+ }
641
+ }
@@ -0,0 +1,24 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>Dotrino Identity Vault</title>
6
+ <meta name="robots" content="noindex,nofollow">
7
+ <style>
8
+ body { font-family: system-ui, sans-serif; padding: 1rem; color: #444; background: #fafafa; }
9
+ code { background: #eee; padding: 2px 6px; border-radius: 3px; }
10
+ </style>
11
+ </head>
12
+ <body>
13
+ <h1>Dotrino — Identity Vault</h1>
14
+ <p>This page is the cross-app storage for Dotrino identities.
15
+ It is meant to be embedded as an invisible iframe by the apps.</p>
16
+ <p>Stored in <code>localStorage</code> on this origin:</p>
17
+ <ul>
18
+ <li>Your ECDSA P-256 keypair (private key never leaves this page).</li>
19
+ <li>Nicknames and ratings of peers you've met.</li>
20
+ </ul>
21
+ <p>Source: <a href="https://github.com/imdotrino/dotrino-identity" target="_blank" rel="noopener">github.com/imdotrino/dotrino-identity</a></p>
22
+ <script type="module" src="./vault.js"></script>
23
+ </body>
24
+ </html>