@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/src/index.js
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dotrino Identity client.
|
|
3
|
+
*
|
|
4
|
+
* Loads a hidden iframe pointing at the vault origin and exchanges
|
|
5
|
+
* postMessage requests. The vault holds the user's keypair and the
|
|
6
|
+
* peer ratings/nicknames in its own localStorage, so all apps that
|
|
7
|
+
* use this library share identity even across different origins.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const DEFAULT_VAULT_URL = 'https://id.dotrino.com/'
|
|
11
|
+
|
|
12
|
+
let singleton = null
|
|
13
|
+
|
|
14
|
+
export class Identity {
|
|
15
|
+
constructor (options = {}) {
|
|
16
|
+
this.vaultUrl = options.vaultUrl || DEFAULT_VAULT_URL
|
|
17
|
+
this.timeoutMs = options.timeoutMs ?? 5000
|
|
18
|
+
this._iframe = null
|
|
19
|
+
this._ready = null
|
|
20
|
+
this._readyResolve = null
|
|
21
|
+
this._nextId = 1
|
|
22
|
+
this._pending = new Map()
|
|
23
|
+
this._handler = null
|
|
24
|
+
this._me = null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
static async connect (options = {}) {
|
|
28
|
+
if (!singleton) singleton = new Identity(options)
|
|
29
|
+
// Esperar SIEMPRE a ready(): si otro caller creó el singleton pero su
|
|
30
|
+
// handshake con el vault aún no resolvió, devolver el singleton "pelado"
|
|
31
|
+
// dejaba `me` en null y las apps no encontraban el nickname (carrera).
|
|
32
|
+
// ready() es idempotente (devuelve la misma promesa), así que esto es
|
|
33
|
+
// seguro de llamar en cada connect().
|
|
34
|
+
await singleton.ready()
|
|
35
|
+
return singleton
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
static current () {
|
|
39
|
+
return singleton
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
ready () {
|
|
43
|
+
if (this._ready) return this._ready
|
|
44
|
+
|
|
45
|
+
this._ready = new Promise((resolve, reject) => {
|
|
46
|
+
this._readyResolve = resolve
|
|
47
|
+
|
|
48
|
+
const iframe = document.createElement('iframe')
|
|
49
|
+
iframe.src = this.vaultUrl
|
|
50
|
+
iframe.style.display = 'none'
|
|
51
|
+
iframe.setAttribute('aria-hidden', 'true')
|
|
52
|
+
iframe.setAttribute('title', 'Dotrino identity vault')
|
|
53
|
+
iframe.referrerPolicy = 'origin'
|
|
54
|
+
this._iframe = iframe
|
|
55
|
+
|
|
56
|
+
const timeout = setTimeout(() => {
|
|
57
|
+
reject(new Error(`Vault did not respond within ${this.timeoutMs}ms`))
|
|
58
|
+
}, this.timeoutMs)
|
|
59
|
+
|
|
60
|
+
this._handler = (event) => {
|
|
61
|
+
if (event.source !== iframe.contentWindow) return
|
|
62
|
+
const msg = event.data
|
|
63
|
+
if (!msg || msg._cci !== true) return
|
|
64
|
+
|
|
65
|
+
if (msg.type === 'ready') {
|
|
66
|
+
clearTimeout(timeout)
|
|
67
|
+
this._me = msg.me
|
|
68
|
+
this._readyResolve(this)
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (msg.type === 'response') {
|
|
73
|
+
const pending = this._pending.get(msg.id)
|
|
74
|
+
if (!pending) return
|
|
75
|
+
this._pending.delete(msg.id)
|
|
76
|
+
clearTimeout(pending.timer)
|
|
77
|
+
if (msg.error) pending.reject(new Error(msg.error))
|
|
78
|
+
else pending.resolve(msg.result)
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (msg.type === 'event') {
|
|
83
|
+
this._emit(msg.event, msg.payload)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
window.addEventListener('message', this._handler)
|
|
88
|
+
document.body.appendChild(iframe)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
return this._ready
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
destroy () {
|
|
95
|
+
if (this._handler) window.removeEventListener('message', this._handler)
|
|
96
|
+
if (this._iframe && this._iframe.parentNode) this._iframe.parentNode.removeChild(this._iframe)
|
|
97
|
+
this._iframe = null
|
|
98
|
+
this._handler = null
|
|
99
|
+
if (singleton === this) singleton = null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ----- public API -----
|
|
103
|
+
|
|
104
|
+
get me () { return this._me }
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Identify a peer by token: the peer must respond to our challenge by
|
|
108
|
+
* signing it with their private key. The vault holds and applies the rating.
|
|
109
|
+
*
|
|
110
|
+
* The host app is responsible for delivering the challenge to the peer
|
|
111
|
+
* and bringing back the signed response — see makeChallenge / verifyResponse.
|
|
112
|
+
*/
|
|
113
|
+
async makeChallenge () {
|
|
114
|
+
return this._call('makeChallenge')
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async signChallenge (nonce) {
|
|
118
|
+
return this._call('signChallenge', { nonce })
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async verifyResponse ({ nonce, publickey, signature }) {
|
|
122
|
+
return this._call('verifyResponse', { nonce, publickey, signature })
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async getPeer (publickey) {
|
|
126
|
+
return this._call('getPeer', { publickey })
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async setNickname (publickey, nickname) {
|
|
130
|
+
return this._call('setNickname', { publickey, nickname })
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async setRating (publickey, rating, notes) {
|
|
134
|
+
return this._call('setRating', { publickey, rating, notes })
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async listPeers () {
|
|
138
|
+
return this._call('listPeers')
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async forgetPeer (publickey) {
|
|
142
|
+
return this._call('forgetPeer', { publickey })
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Add (or refresh) a contact in the shared address book. Idempotent —
|
|
147
|
+
* existing peer records are upserted with the new metadata. Contacts are
|
|
148
|
+
* stored alongside the rating/endorsement record for the same pubkey, so
|
|
149
|
+
* any app in the ecosystem (chat, chess, messenger, …) sees the same list.
|
|
150
|
+
*/
|
|
151
|
+
async addContact ({ publickey, nickname, encryptionPubkey, lastToken, notes } = {}) {
|
|
152
|
+
return this._call('addContact', { publickey, nickname, encryptionPubkey, lastToken, notes })
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Patch contact metadata (nickname / lastToken / encryptionPubkey / contactNotes). */
|
|
156
|
+
async updateContact (publickey, patch) {
|
|
157
|
+
return this._call('updateContact', { publickey, patch })
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Remove the `isContact` flag while preserving rating/endorsement history. */
|
|
161
|
+
async removeContact (publickey) {
|
|
162
|
+
return this._call('removeContact', { publickey })
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** List peers flagged as contacts, sorted by lastSeen desc. */
|
|
166
|
+
async listContacts () {
|
|
167
|
+
return this._call('listContacts')
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Sign an arbitrary JSON-serializable payload with the vault's ECDSA key
|
|
172
|
+
* using canonical JSON encoding. Returns `{ signature, publickey }` —
|
|
173
|
+
* compatible with the proxy's `verifySignatureWithJWK` (used by
|
|
174
|
+
* `identify` to bind a stable pubkey to the proxy connection).
|
|
175
|
+
*/
|
|
176
|
+
async signData (data) {
|
|
177
|
+
return this._call('signData', { data })
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Firma un CERTIFICADO DE DELEGACIÓN: autoriza a una sub-clave de dispositivo
|
|
182
|
+
* `sub` (JWK string) a hacer `scope` en tu nombre, hasta `exp`, revocable por
|
|
183
|
+
* `nonce`. La clave maestra NUNCA sale del vault. `opts`: { ttlMs?, exp?, label?, nonce? }.
|
|
184
|
+
* @returns {Promise<{ cert: object }>}
|
|
185
|
+
*/
|
|
186
|
+
async signDelegation (sub, scope, opts = {}) {
|
|
187
|
+
return this._call('signDelegation', { sub, scope, ...opts })
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Revoca una delegación por su `nonce` (queda en la lista de revocación). */
|
|
191
|
+
async revokeDelegation (nonce) {
|
|
192
|
+
return this._call('revokeDelegation', { nonce })
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Lista las delegaciones emitidas + la lista de revocación (para el gestor de dispositivos). */
|
|
196
|
+
async listDelegations () {
|
|
197
|
+
return this._call('listDelegations')
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Merge endorsements (signed ratings from third parties) about a subject
|
|
202
|
+
* into the local peer book. Returns { merged, total }.
|
|
203
|
+
*/
|
|
204
|
+
async mergeEndorsements (subject, endorsements, askerPubkey) {
|
|
205
|
+
return this._call('mergeEndorsements', { subject, endorsements, askerPubkey })
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Return what this vault knows about a subject for the purpose of
|
|
210
|
+
* answering a RATING_QUERY: { mine: signedEnvelopeOrNull, endorsements: [] }.
|
|
211
|
+
*/
|
|
212
|
+
async getRatingsForSubject (subject) {
|
|
213
|
+
return this._call('getRatingsForSubject', { subject })
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Record that a peer asked us about a subject. Used for suspicion stats.
|
|
218
|
+
*/
|
|
219
|
+
async recordQuery (askerPubkey, subject) {
|
|
220
|
+
return this._call('recordQuery', { askerPubkey, subject })
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Update own nickname (broadcast to the vault, not to other apps automatically) */
|
|
224
|
+
async setMyNickname (nickname) {
|
|
225
|
+
const result = await this._call('setMyNickname', { nickname })
|
|
226
|
+
if (result?.me) this._me = result.me
|
|
227
|
+
return result
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Pubkey ECDH (JWK string) propio para encripción. */
|
|
231
|
+
async getEncryptionPubkey () {
|
|
232
|
+
return this._call('getEncryptionPubkey')
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Cifra `plaintext` para una lista de destinatarios usando ECDH+AES-GCM.
|
|
237
|
+
* @param {Array<{token:string, encryptionPubkey:string}>} recipients
|
|
238
|
+
* @param {string} plaintext
|
|
239
|
+
* @returns {Promise<Object>} Envelope { v, iv, ct, wrap }
|
|
240
|
+
*/
|
|
241
|
+
async encrypt (recipients, plaintext) {
|
|
242
|
+
return this._call('encrypt', { recipients, plaintext })
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Descifra un envelope dirigido a este vault.
|
|
247
|
+
* @param {string} senderEncryptionPubkey JWK string del emisor
|
|
248
|
+
* @param {string} myToken token efímero al que iba dirigido el wrap
|
|
249
|
+
* @param {Object} envelope
|
|
250
|
+
*/
|
|
251
|
+
async decrypt (senderEncryptionPubkey, myToken, envelope) {
|
|
252
|
+
return this._call('decrypt', { senderEncryptionPubkey, myToken, envelope })
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Export the full identity (private key + peer book) as a JSON-serializable object.
|
|
257
|
+
* The blob can be saved to a file by the host app and re-imported later.
|
|
258
|
+
* The private key is sensitive — handle accordingly.
|
|
259
|
+
*/
|
|
260
|
+
async exportIdentity () {
|
|
261
|
+
return this._call('exportIdentity')
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Import a previously exported identity blob, replacing the current one.
|
|
266
|
+
* Throws if the blob is malformed or keys are invalid.
|
|
267
|
+
*/
|
|
268
|
+
async importIdentity (blob) {
|
|
269
|
+
const result = await this._call('importIdentity', blob)
|
|
270
|
+
if (result?.me) this._me = result.me
|
|
271
|
+
return result
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ----- Auto-sync (Google Drive encrypted backup) -----
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Connect a Google account for encrypted backup to Drive's appDataFolder.
|
|
278
|
+
* Pops up a Google sign-in window. `clientId` is your Google OAuth Web client ID
|
|
279
|
+
* with Authorized JavaScript Origin = the vault origin (id.dotrino.com).
|
|
280
|
+
*/
|
|
281
|
+
async syncConnect (clientId) {
|
|
282
|
+
return this._call('syncConnect', { clientId })
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async syncDisconnect () {
|
|
286
|
+
return this._call('syncDisconnect')
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Unlock auto-sync by providing the passphrase used to encrypt the backup.
|
|
291
|
+
* Must be ≥ 8 chars. After unlock the sync engine pulls remote, merges,
|
|
292
|
+
* and pushes on every local change (debounced).
|
|
293
|
+
*/
|
|
294
|
+
async syncUnlock (passphrase) {
|
|
295
|
+
return this._call('syncUnlock', { passphrase })
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async syncLock () {
|
|
299
|
+
return this._call('syncLock')
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** { connected, unlocked, dirty, lastError } */
|
|
303
|
+
async syncStatus () {
|
|
304
|
+
return this._call('syncStatus')
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Force an immediate pull-then-push cycle. */
|
|
308
|
+
async syncNow () {
|
|
309
|
+
return this._call('syncNow')
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Subscribe to sync status events emitted by the vault. Handler receives
|
|
314
|
+
* `{ kind, status, error?, ts }` where status is one of
|
|
315
|
+
* 'connected' | 'disconnected' | 'unlocked' | 'locked' | 'syncing' |
|
|
316
|
+
* 'synced' | 'conflict' | 'offline' | 'error'.
|
|
317
|
+
*/
|
|
318
|
+
onSync (handler) {
|
|
319
|
+
return this.on('sync', handler)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
on (event, handler) {
|
|
323
|
+
if (!this._listeners) this._listeners = new Map()
|
|
324
|
+
if (!this._listeners.has(event)) this._listeners.set(event, new Set())
|
|
325
|
+
this._listeners.get(event).add(handler)
|
|
326
|
+
return () => this._listeners.get(event)?.delete(handler)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
_emit (event, payload) {
|
|
330
|
+
const set = this._listeners?.get(event)
|
|
331
|
+
if (!set) return
|
|
332
|
+
for (const h of set) {
|
|
333
|
+
try { h(payload) } catch (e) { console.error(e) }
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
_call (method, params = {}) {
|
|
338
|
+
return new Promise((resolve, reject) => {
|
|
339
|
+
if (!this._iframe?.contentWindow) {
|
|
340
|
+
return reject(new Error('Vault not ready'))
|
|
341
|
+
}
|
|
342
|
+
const id = `req_${this._nextId++}`
|
|
343
|
+
const timer = setTimeout(() => {
|
|
344
|
+
this._pending.delete(id)
|
|
345
|
+
reject(new Error(`Vault timeout for ${method}`))
|
|
346
|
+
}, this.timeoutMs)
|
|
347
|
+
this._pending.set(id, { resolve, reject, timer })
|
|
348
|
+
|
|
349
|
+
// Usamos targetOrigin='*' por compatibilidad: en algunos navegadores el
|
|
350
|
+
// origin que el browser asocia al postMessage SALIENTE no coincide con
|
|
351
|
+
// el de las respuestas (mismatch interno tras la navegación cross-origin
|
|
352
|
+
// del iframe), provocando rechazos espurios. El handler del lado padre
|
|
353
|
+
// sí filtra `event.source === iframe.contentWindow` y `_cci === true`,
|
|
354
|
+
// lo cual es la defensa real. El contenido de los mensajes salientes
|
|
355
|
+
// no contiene secretos (solo nombres de método y params); las claves
|
|
356
|
+
// privadas viven en el localStorage de la propia vault.
|
|
357
|
+
this._iframe.contentWindow.postMessage(
|
|
358
|
+
{ _cci: true, type: 'request', id, method, params },
|
|
359
|
+
'*'
|
|
360
|
+
)
|
|
361
|
+
})
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Helpers de capacidad SIN clave maestra (lado dispositivo + verificación), reutilizables
|
|
366
|
+
// por apps/bridges sin cargar el iframe del vault.
|
|
367
|
+
export { makeDeviceKey, signWithDevice, verifyDelegation, verifyChain, pubkeyId, MAX_DELEGATION_MS, DEFAULT_DELEGATION_MS } from '../vault/capabilities.js'
|
package/src/node.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dotrino Identity — adaptador headless para Node.js.
|
|
3
|
+
*
|
|
4
|
+
* Expone la MISMA API pública que el cliente de navegador (`./index.js`), pero
|
|
5
|
+
* sin iframe ni postMessage: llama directamente a los handlers de
|
|
6
|
+
* `../vault/core.js`. El keypair, `me`, los nonces y el peer book se persisten
|
|
7
|
+
* en archivos JSON dentro de un directorio por identidad, de modo que cada
|
|
8
|
+
* directorio es un "usuario" distinto y estable entre ejecuciones.
|
|
9
|
+
*
|
|
10
|
+
* La criptografía es byte-idéntica a la del vault del navegador (mismo core),
|
|
11
|
+
* así que un bot Node es plenamente interoperable con usuarios reales:
|
|
12
|
+
* firmas verificables por el proxy e `identify`, y cifrado E2E (ECDH+AES-GCM)
|
|
13
|
+
* descifrable por la app web y viceversa.
|
|
14
|
+
*
|
|
15
|
+
* Requiere Node ≥ 20 (crypto.subtle, btoa/atob, TextEncoder globales).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import fs from 'node:fs'
|
|
19
|
+
import path from 'node:path'
|
|
20
|
+
import os from 'node:os'
|
|
21
|
+
import { createIdentityCore } from '../vault/core.js'
|
|
22
|
+
|
|
23
|
+
/** kv síncrono respaldado por un archivo JSON (estilo localStorage). */
|
|
24
|
+
function fileKv (filePath) {
|
|
25
|
+
let data = {}
|
|
26
|
+
try {
|
|
27
|
+
if (fs.existsSync(filePath)) data = JSON.parse(fs.readFileSync(filePath, 'utf8')) || {}
|
|
28
|
+
} catch (_) { data = {} }
|
|
29
|
+
const flush = () => {
|
|
30
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
|
31
|
+
fs.writeFileSync(filePath, JSON.stringify(data))
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
getItem: (k) => (k in data ? data[k] : null),
|
|
35
|
+
setItem: (k, v) => { data[k] = String(v); flush() },
|
|
36
|
+
removeItem: (k) => { delete data[k]; flush() }
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Peer book respaldado por un archivo JSON (interfaz de vault/peerStore.js). */
|
|
41
|
+
function filePeers (filePath) {
|
|
42
|
+
let peers = {}
|
|
43
|
+
let markDirty = null
|
|
44
|
+
const flush = () => {
|
|
45
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
|
46
|
+
fs.writeFileSync(filePath, JSON.stringify(peers))
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
async initPeerStorage () {
|
|
50
|
+
try {
|
|
51
|
+
if (fs.existsSync(filePath)) peers = JSON.parse(fs.readFileSync(filePath, 'utf8')) || {}
|
|
52
|
+
} catch (_) { peers = {} }
|
|
53
|
+
return peers
|
|
54
|
+
},
|
|
55
|
+
loadPeers: () => peers,
|
|
56
|
+
savePeers: (next) => { peers = next; flush(); if (markDirty) markDirty() },
|
|
57
|
+
setPeersDirect: (next) => { peers = next; flush() },
|
|
58
|
+
upsertPeer: (publickey, patch) => {
|
|
59
|
+
const existing = peers[publickey] || { publickey, firstSeen: Date.now() }
|
|
60
|
+
peers[publickey] = { ...existing, ...patch, publickey, lastSeen: Date.now() }
|
|
61
|
+
flush()
|
|
62
|
+
if (markDirty) markDirty()
|
|
63
|
+
return peers[publickey]
|
|
64
|
+
},
|
|
65
|
+
onDirty: (fn) => { markDirty = fn }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const DEFAULT_DIR = path.join(os.homedir(), '.dotrino', 'identity')
|
|
70
|
+
|
|
71
|
+
export class Identity {
|
|
72
|
+
/**
|
|
73
|
+
* @param {Object} [options]
|
|
74
|
+
* @param {string} [options.dir] Directorio de persistencia de esta identidad.
|
|
75
|
+
* Cada directorio = un usuario distinto. Default: ~/.dotrino/identity
|
|
76
|
+
*/
|
|
77
|
+
constructor (options = {}) {
|
|
78
|
+
this._dir = options.dir || DEFAULT_DIR
|
|
79
|
+
this._core = null
|
|
80
|
+
this._listeners = new Map()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Crea/abre una identidad headless. A diferencia del navegador NO es singleton:
|
|
85
|
+
* cada `dir` distinto devuelve una identidad independiente (para correr muchos
|
|
86
|
+
* bots-usuario en el mismo proceso).
|
|
87
|
+
*/
|
|
88
|
+
static async connect (options = {}) {
|
|
89
|
+
const inst = new Identity(options)
|
|
90
|
+
await inst.ready()
|
|
91
|
+
return inst
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async ready () {
|
|
95
|
+
if (this._core) return this
|
|
96
|
+
this._core = await createIdentityCore({
|
|
97
|
+
kv: fileKv(path.join(this._dir, 'identity.json')),
|
|
98
|
+
peers: filePeers(path.join(this._dir, 'peers.json')),
|
|
99
|
+
makeSync: null
|
|
100
|
+
})
|
|
101
|
+
this._core.onSyncStatus((payload) => this._emit('sync', payload))
|
|
102
|
+
return this
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
destroy () { this._core = null }
|
|
106
|
+
|
|
107
|
+
get me () { return this._core?.me || null }
|
|
108
|
+
|
|
109
|
+
_h (method, params = {}) {
|
|
110
|
+
if (!this._core) throw new Error('Identity not ready — call ready()/connect() first')
|
|
111
|
+
return this._core.handlers[method](params)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ----- API pública (espeja src/index.js) -----
|
|
115
|
+
|
|
116
|
+
makeChallenge () { return this._h('makeChallenge') }
|
|
117
|
+
signChallenge (nonce) { return this._h('signChallenge', { nonce }) }
|
|
118
|
+
verifyResponse ({ nonce, publickey, signature, encryptionPubkey }) {
|
|
119
|
+
return this._h('verifyResponse', { nonce, publickey, signature, encryptionPubkey })
|
|
120
|
+
}
|
|
121
|
+
getPeer (publickey) { return this._h('getPeer', { publickey }) }
|
|
122
|
+
setNickname (publickey, nickname) { return this._h('setNickname', { publickey, nickname }) }
|
|
123
|
+
setRating (publickey, rating, notes) { return this._h('setRating', { publickey, rating, notes }) }
|
|
124
|
+
listPeers () { return this._h('listPeers') }
|
|
125
|
+
forgetPeer (publickey) { return this._h('forgetPeer', { publickey }) }
|
|
126
|
+
addContact ({ publickey, nickname, encryptionPubkey, lastToken, notes } = {}) {
|
|
127
|
+
return this._h('addContact', { publickey, nickname, encryptionPubkey, lastToken, notes })
|
|
128
|
+
}
|
|
129
|
+
updateContact (publickey, patch) { return this._h('updateContact', { publickey, patch }) }
|
|
130
|
+
removeContact (publickey) { return this._h('removeContact', { publickey }) }
|
|
131
|
+
listContacts () { return this._h('listContacts') }
|
|
132
|
+
signData (data) { return this._h('signData', { data }) }
|
|
133
|
+
// Delegación de capacidad (sub-clave de dispositivo con scope/exp/revocación)
|
|
134
|
+
signDelegation (sub, scope, opts = {}) { return this._h('signDelegation', { sub, scope, ...opts }) }
|
|
135
|
+
revokeDelegation (nonce) { return this._h('revokeDelegation', { nonce }) }
|
|
136
|
+
listDelegations () { return this._h('listDelegations') }
|
|
137
|
+
mergeEndorsements (subject, endorsements, askerPubkey) {
|
|
138
|
+
return this._h('mergeEndorsements', { subject, endorsements, askerPubkey })
|
|
139
|
+
}
|
|
140
|
+
getRatingsForSubject (subject) { return this._h('getRatingsForSubject', { subject }) }
|
|
141
|
+
recordQuery (askerPubkey, subject) { return this._h('recordQuery', { askerPubkey, subject }) }
|
|
142
|
+
async setMyNickname (nickname) {
|
|
143
|
+
const result = await this._h('setMyNickname', { nickname })
|
|
144
|
+
return result
|
|
145
|
+
}
|
|
146
|
+
getEncryptionPubkey () { return this._h('getEncryptionPubkey') }
|
|
147
|
+
encrypt (recipients, plaintext) { return this._h('encrypt', { recipients, plaintext }) }
|
|
148
|
+
decrypt (senderEncryptionPubkey, myToken, envelope) {
|
|
149
|
+
return this._h('decrypt', { senderEncryptionPubkey, myToken, envelope })
|
|
150
|
+
}
|
|
151
|
+
exportIdentity () { return this._h('exportIdentity') }
|
|
152
|
+
async importIdentity (blob) { return this._h('importIdentity', blob || {}) }
|
|
153
|
+
|
|
154
|
+
// Sync (Google Drive) no disponible headless — los handlers responden acorde.
|
|
155
|
+
syncConnect (clientId) { return this._h('syncConnect', { clientId }) }
|
|
156
|
+
syncDisconnect () { return this._h('syncDisconnect') }
|
|
157
|
+
syncUnlock (passphrase) { return this._h('syncUnlock', { passphrase }) }
|
|
158
|
+
syncLock () { return this._h('syncLock') }
|
|
159
|
+
syncStatus () { return this._h('syncStatus') }
|
|
160
|
+
syncNow () { return this._h('syncNow') }
|
|
161
|
+
|
|
162
|
+
onSync (handler) { return this.on('sync', handler) }
|
|
163
|
+
on (event, handler) {
|
|
164
|
+
if (!this._listeners.has(event)) this._listeners.set(event, new Set())
|
|
165
|
+
this._listeners.get(event).add(handler)
|
|
166
|
+
return () => this._listeners.get(event)?.delete(handler)
|
|
167
|
+
}
|
|
168
|
+
_emit (event, payload) {
|
|
169
|
+
const set = this._listeners.get(event)
|
|
170
|
+
if (!set) return
|
|
171
|
+
for (const h of set) { try { h(payload) } catch (e) { console.error(e) } }
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export default Identity
|
|
176
|
+
|
|
177
|
+
// Helpers de capacidad SIN clave maestra (lado dispositivo + verificación), para que
|
|
178
|
+
// un bridge/bot Node pueda crear su clave, firmar acciones y verificar cadenas D←P.
|
|
179
|
+
export { makeDeviceKey, signWithDevice, verifyDelegation, verifyChain, pubkeyId, MAX_DELEGATION_MS, DEFAULT_DELEGATION_MS } from '../vault/capabilities.js'
|
package/vault/CNAME
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
id.dotrino.com
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tokens de CAPACIDAD DELEGADOS, firmados por el vault.
|
|
3
|
+
*
|
|
4
|
+
* Problema: un dispositivo (p.ej. el bridge de OwnTracks, un bot, el launcher)
|
|
5
|
+
* necesita actuar EN NOMBRE de una identidad SIN tener su clave maestra. Si le
|
|
6
|
+
* diéramos la clave maestra, robar el dispositivo = robar la identidad.
|
|
7
|
+
*
|
|
8
|
+
* Solución (subkeys / capabilities, estilo certs SSH / OAuth device tokens):
|
|
9
|
+
* - El dispositivo genera SU PROPIA clave `D` (la maestra nunca la ve).
|
|
10
|
+
* - El vault firma un CERTIFICADO: «la clave D puede `scope` para la identidad P,
|
|
11
|
+
* hasta `exp`», con un `nonce` que es el mango de revocación.
|
|
12
|
+
* - El dispositivo firma cada acción con `D` y adjunta el cert. Cualquiera
|
|
13
|
+
* verifica la CADENA `D ← P` + scope + expiración + revocación, offline.
|
|
14
|
+
*
|
|
15
|
+
* Garantía: robar el dispositivo solo permite lo del `scope` (p.ej. publicar
|
|
16
|
+
* ubicación), hasta `exp`, y se puede revocar. La clave maestra queda intacta.
|
|
17
|
+
*
|
|
18
|
+
* Cripto IDÉNTICA al resto del ecosistema: ECDSA P-256 + SHA-256 sobre
|
|
19
|
+
* `canonicalStringify`, firma en base64 de los 64 bytes crudos (r||s). Módulo
|
|
20
|
+
* PURO (sin kv/iframe/localStorage) → reusable en el vault, en Node y en el
|
|
21
|
+
* servidor de geo sin cargar el iframe.
|
|
22
|
+
*/
|
|
23
|
+
import { canonicalStringify, bufToBase64, base64ToBuf } from './core.js'
|
|
24
|
+
|
|
25
|
+
const ECDSA = { name: 'ECDSA', namedCurve: 'P-256' }
|
|
26
|
+
const SIGN = { name: 'ECDSA', hash: { name: 'SHA-256' } }
|
|
27
|
+
|
|
28
|
+
/** Tope DURO de vida de una delegación (aunque pidan más). */
|
|
29
|
+
export const MAX_DELEGATION_MS = 30 * 24 * 60 * 60 * 1000 // 30 días
|
|
30
|
+
/** Vida por defecto si no se especifica ttl/exp. */
|
|
31
|
+
export const DEFAULT_DELEGATION_MS = 24 * 60 * 60 * 1000 // 24 h
|
|
32
|
+
|
|
33
|
+
const enc = (s) => new TextEncoder().encode(s)
|
|
34
|
+
|
|
35
|
+
async function rawSign (privateKey, bytes) {
|
|
36
|
+
return bufToBase64(await crypto.subtle.sign(SIGN, privateKey, bytes))
|
|
37
|
+
}
|
|
38
|
+
async function rawVerify (publicJwkStr, bytes, sigB64) {
|
|
39
|
+
let pub
|
|
40
|
+
try { pub = await crypto.subtle.importKey('jwk', JSON.parse(publicJwkStr), ECDSA, true, ['verify']) }
|
|
41
|
+
catch (_) { return false }
|
|
42
|
+
try { return await crypto.subtle.verify(SIGN, pub, base64ToBuf(sigB64), bytes) }
|
|
43
|
+
catch (_) { return false }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const publicOf = (privateJwk) => ({ kty: privateJwk.kty, crv: privateJwk.crv, x: privateJwk.x, y: privateJwk.y })
|
|
47
|
+
const scopeAllows = (scope, expected) => Array.isArray(scope) ? scope.includes(expected) : scope === expected
|
|
48
|
+
|
|
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
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Cuerpo canónico del certificado (lo que se firma): el cert SIN la firma. */
|
|
57
|
+
export function delegationBody (cert) {
|
|
58
|
+
return { v: cert.v, iss: cert.iss, sub: cert.sub, scope: cert.scope, iat: cert.iat, exp: cert.exp, nonce: cert.nonce }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Genera una sub-clave de DISPOSITIVO `D`. Corre EN el dispositivo / bridge; la
|
|
63
|
+
* clave maestra nunca ve la privada. Solo `publickey` (JWK string) sale del device.
|
|
64
|
+
*/
|
|
65
|
+
export async function makeDeviceKey ({ label = '' } = {}) {
|
|
66
|
+
const pair = await crypto.subtle.generateKey(ECDSA, true, ['sign', 'verify'])
|
|
67
|
+
const privateJwk = await crypto.subtle.exportKey('jwk', pair.privateKey)
|
|
68
|
+
const publicJwk = await crypto.subtle.exportKey('jwk', pair.publicKey)
|
|
69
|
+
const publickey = JSON.stringify(publicJwk)
|
|
70
|
+
return { publickey, privateJwk, publicJwk, label: String(label || ''), createdAt: Date.now(), deviceId: await pubkeyId(publickey) }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Firma un certificado de delegación con una `privateKey` (CryptoKey) cuyo pubkey
|
|
75
|
+
* es `iss`. Lo usa el handler del vault (con la clave maestra). Devuelve el cert
|
|
76
|
+
* completo `{ v, iss, sub, scope, iat, exp, nonce, sig }`.
|
|
77
|
+
*/
|
|
78
|
+
export async function signDelegationWith (privateKey, iss, { sub, scope, iat, exp, nonce }) {
|
|
79
|
+
const body = { v: 1, iss, sub, scope, iat, exp, nonce }
|
|
80
|
+
const sig = await rawSign(privateKey, enc(canonicalStringify(body)))
|
|
81
|
+
return { ...body, sig }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Firma datos con la clave de DISPOSITIVO (formato byte-idéntico a `signData` del
|
|
86
|
+
* vault → lo que el dispositivo/bridge usa para firmar cada pin/acción).
|
|
87
|
+
*/
|
|
88
|
+
export async function signWithDevice ({ privateJwk, data }) {
|
|
89
|
+
const priv = await crypto.subtle.importKey('jwk', privateJwk, ECDSA, true, ['sign'])
|
|
90
|
+
const signature = await rawSign(priv, enc(canonicalStringify(data)))
|
|
91
|
+
return { signature, publickey: JSON.stringify(publicOf(privateJwk)) }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Verifica un CERTIFICADO de delegación (offline; no requiere la clave maestra):
|
|
96
|
+
* 1) firma de la maestra (`iss`) sobre el cuerpo canónico,
|
|
97
|
+
* 2) ventana temporal `iat ≤ now ≤ exp`,
|
|
98
|
+
* 3) `scope` incluye `expectedScope` (si se pide),
|
|
99
|
+
* 4) `sub` === `expectedSub` (si se pide),
|
|
100
|
+
* 5) `nonce` no revocado (`revoked`: fn(nonce)→bool, Set o mapa).
|
|
101
|
+
* @returns {{ok:boolean, reason?:string, iss?, sub?, scope?, iat?, exp?, nonce?}}
|
|
102
|
+
*/
|
|
103
|
+
export async function verifyDelegation ({ cert, expectedScope, expectedSub, now = Date.now(), skewMs = 0, revoked } = {}) {
|
|
104
|
+
if (!cert || typeof cert !== 'object') return { ok: false, reason: 'no-cert' }
|
|
105
|
+
const { v, iss, sub, scope, iat, exp, nonce, sig } = cert
|
|
106
|
+
if (v !== 1 || typeof iss !== 'string' || typeof sub !== 'string' || typeof sig !== 'string') return { ok: false, reason: 'shape' }
|
|
107
|
+
if (typeof iat !== 'number' || typeof exp !== 'number' || (typeof scope !== 'string' && !Array.isArray(scope))) return { ok: false, reason: 'shape' }
|
|
108
|
+
if (!(await rawVerify(iss, enc(canonicalStringify(delegationBody(cert))), sig))) return { ok: false, reason: 'bad-signature' }
|
|
109
|
+
// `skewMs` tolera la diferencia de reloj entre el EMISOR (vault) y el VERIFICADOR
|
|
110
|
+
// (p.ej. el bridge de geo, otra máquina). Default 0 = estricto.
|
|
111
|
+
const sk = Math.max(0, skewMs)
|
|
112
|
+
if (now < iat - sk) return { ok: false, reason: 'not-yet-valid' }
|
|
113
|
+
if (now > exp + sk) return { ok: false, reason: 'expired' }
|
|
114
|
+
if (expectedScope != null && !scopeAllows(scope, expectedScope)) return { ok: false, reason: 'scope' }
|
|
115
|
+
if (expectedSub != null && sub !== expectedSub) return { ok: false, reason: 'sub' }
|
|
116
|
+
if (nonce && revoked) {
|
|
117
|
+
const isRev = typeof revoked === 'function' ? revoked(nonce)
|
|
118
|
+
: (revoked instanceof Set ? revoked.has(nonce) : !!revoked[nonce])
|
|
119
|
+
if (isRev) return { ok: false, reason: 'revoked' }
|
|
120
|
+
}
|
|
121
|
+
return { ok: true, iss, sub, scope, iat, exp, nonce }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Verificación de CADENA de una acción/pin delegado (lo único que llama el bridge):
|
|
126
|
+
* 1) el dispositivo `D` (= `data.publickey`) firmó `data`,
|
|
127
|
+
* 2) el cert delega a ESTE dispositivo (`cert.sub === data.publickey`),
|
|
128
|
+
* 3) el cert es válido (firma de `P`, scope, exp, revocación),
|
|
129
|
+
* 4) opcional: `cert.iss === trustedIssuer` (fija la identidad maestra esperada).
|
|
130
|
+
* @returns {{ok:boolean, reason?:string, issuer?:string, device?:string}}
|
|
131
|
+
*/
|
|
132
|
+
export async function verifyChain ({ data, signature, cert, expectedScope, expectedIssuer, trustedIssuer, now = Date.now(), skewMs = 0, revoked } = {}) {
|
|
133
|
+
if (!data || typeof data !== 'object' || typeof signature !== 'string') return { ok: false, reason: 'shape' }
|
|
134
|
+
const device = data.publickey
|
|
135
|
+
if (typeof device !== 'string') return { ok: false, reason: 'no-device-pubkey' }
|
|
136
|
+
if (!(await rawVerify(device, enc(canonicalStringify(data)), signature))) return { ok: false, reason: 'bad-action-signature' }
|
|
137
|
+
if (!cert || cert.sub !== device) return { ok: false, reason: 'cert-device-mismatch' }
|
|
138
|
+
const d = await verifyDelegation({ cert, expectedScope, now, skewMs, revoked })
|
|
139
|
+
if (!d.ok) return { ok: false, reason: d.reason }
|
|
140
|
+
const issuer = trustedIssuer != null ? trustedIssuer : expectedIssuer
|
|
141
|
+
if (issuer != null && cert.iss !== issuer) return { ok: false, reason: 'untrusted-issuer' }
|
|
142
|
+
return { ok: true, issuer: cert.iss, device }
|
|
143
|
+
}
|