@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/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
+ }