@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/LICENSE +21 -0
- package/README.md +166 -0
- package/package.json +47 -0
- package/src/index.d.ts +187 -0
- package/src/index.js +367 -0
- package/src/node.js +179 -0
- package/vault/CNAME +1 -0
- package/vault/capabilities.js +143 -0
- package/vault/core.js +641 -0
- package/vault/index.html +24 -0
- package/vault/peerStore.js +147 -0
- package/vault/sync.js +473 -0
- package/vault/vault.js +67 -0
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
|
+
}
|
package/vault/index.html
ADDED
|
@@ -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>
|