@dotrino/identity 0.77.0 → 0.79.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/identity",
3
- "version": "0.77.0",
3
+ "version": "0.79.0",
4
4
  "description": "Identidad y rating de usuarios compartidos entre apps de Dotrino (vault iframe + postMessage)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -39,6 +39,7 @@
39
39
  "LICENSE"
40
40
  ],
41
41
  "scripts": {
42
+ "vendor": "node vendor.mjs",
42
43
  "test": "node --test \"test/*.test.js\" \"test/*.test.mjs\"",
43
44
  "type-check": "tsc --noEmit"
44
45
  },
package/src/index.js CHANGED
@@ -343,6 +343,15 @@ export class Identity {
343
343
  async openContent (envelope) { return this._call('openContent', { envelope }) }
344
344
  /** Rota la clave de contenido (corta el acceso al contenido FUTURO de quien ya no está). */
345
345
  async rotateContentKey () { return this._call('rotateContentKey') }
346
+ /**
347
+ * Abre un sobre de un CAJÓN DE SECRETOS con la envoltura dirigida a este aparato.
348
+ *
349
+ * Distinto de `openContent`, que usa el llavero del perfil: aquí la envoltura llega suelta
350
+ * y la privada de cifrado de este dispositivo —que nunca sale del iframe— la abre. Es lo
351
+ * que le permite a quien administra LEER una variable pública sin teclear ninguna
352
+ * contraseña, y solo las que la bóveda le haya envuelto.
353
+ */
354
+ async openSealedValue ({ wrap, envelope } = {}) { return this._call('openSealedValue', { wrap, envelope }) }
346
355
 
347
356
  // ----- Emparejar ESTE navegador/dispositivo con el vault del usuario (Fase 1) -----
348
357
 
package/vault/acta.js CHANGED
@@ -103,7 +103,7 @@ const conCampoSellador = (acta) => Number(acta?.v) < V_SIN_CAMPO_SELLADOR
103
103
  * el rol de master, que no se delega. Así un dispositivo con `admin` robado hace daño
104
104
  * acotado y **reversible** (se le revoca), en vez de poder dejarte fuera de tu cuenta.
105
105
  */
106
- export const CAPS = Object.freeze(['sign', 'store', 'read', 'secrets', 'admin', 'approve', 'passwords', 'sealer', 'unattended'])
106
+ export const CAPS = Object.freeze(['sign', 'store', 'read', 'secrets', 'admin', 'approve', 'passwords', 'sealer', 'unattended', 'replica'])
107
107
 
108
108
  /** Capacidades de un DISPOSITIVO (sin CN): acceso a todo lo del usuario. */
109
109
  /**
@@ -117,6 +117,15 @@ export const CAPS = Object.freeze(['sign', 'store', 'read', 'secrets', 'admin',
117
117
  * pedirle algo a la bóveda es exactamente lo que decide el acta — tener dos registros
118
118
  * de lo mismo obliga a acordarse de los dos al quitar un aparato.
119
119
  *
120
+ * `replica` es REPARTIR, NO DECIDIR. Un replicador no tiene maestra: guarda el acta y los
121
+ * sobres —que ya vienen sellados a su destinatario, así que tampoco puede abrirlos— y los
122
+ * entrega cuando la bóveda no está. Firma su respuesta con su propia llave de aparato, y
123
+ * es este permiso el que hace que un cliente la acepte como respondedor.
124
+ *
125
+ * Lo que NO le concede, y por eso es estrecho: no sella actas, no emite certificados, no
126
+ * abre nada. Un replicador comprometido cuesta disponibilidad, no confidencialidad.
127
+ * Diseño: `dotrino-vault/docs/replicas.md` §8.bis.
128
+ *
120
129
  * `unattended` es RECIBIR CLAVES PRIVADAS SIN QUE NADIE APRUEBE. Sin él, la bóveda no
121
130
  * entrega nada hasta que un aparato con `approve` lo firme — una vez por arranque del
122
131
  * servicio, no por petición.
@@ -131,7 +140,7 @@ export const CAPS = Object.freeze(['sign', 'store', 'read', 'secrets', 'admin',
131
140
  * de la cuenta, se ve en la pantalla de permisos como los demás, y se quita quitándolo —
132
141
  * sin acordarse de un segundo registro escondido.
133
142
  */
134
- export const DEVICE_CAPS = Object.freeze(['sign', 'store', 'read', 'admin', 'approve', 'passwords', 'sealer', 'unattended'])
143
+ export const DEVICE_CAPS = Object.freeze(['sign', 'store', 'read', 'admin', 'approve', 'passwords', 'sealer', 'unattended', 'replica'])
135
144
 
136
145
  /**
137
146
  * Lo que recibe un dispositivo recién emparejado. `admin` **no está**: no se
@@ -158,19 +167,16 @@ export const isValidCn = (cn) => typeof cn === 'string' && /^[a-z0-9-]{1,32}$/.t
158
167
  * scope de secretos «de todos».
159
168
  */
160
169
  export function capScope (cap, cn = null) {
161
- if (cap === 'sign') return 'vault:sign'
162
- if (cap === 'store') return 'vault:store'
163
- if (cap === 'read') return 'vault:read'
164
- if (cap === 'admin') return 'vault:admin'
165
- if (cap === 'approve') return 'vault:approve'
166
- if (cap === 'passwords') return 'vault:passwords'
167
- if (cap === 'sealer') return 'vault:sealer'
170
+ // SALE DE `CAP_SCOPE`, no de una cadena de `if` escrita a mano. Esto era lo segundo: la
171
+ // lista de arriba y esta se escribían por separado, así que un permiso nuevo entraba en
172
+ // una y no en la otra y se quedaba sin scope en silencio. Ya pasó con `sealer`,
173
+ // `unattended` y `secrets`; con `replica` se cortó aquí.
168
174
  if (cap === 'secrets') return isValidCn(cn) ? 'vault:secrets:' + cn : null
169
- return null
175
+ return CAP_SCOPE[cap] || null
170
176
  }
171
177
 
172
178
  /** Compat: el mapa directo, para las capacidades de dispositivo. */
173
- export const CAP_SCOPE = Object.freeze({ sign: 'vault:sign', store: 'vault:store', read: 'vault:read', admin: 'vault:admin', approve: 'vault:approve', passwords: 'vault:passwords', sealer: 'vault:sealer' })
179
+ export const CAP_SCOPE = Object.freeze({ sign: 'vault:sign', store: 'vault:store', read: 'vault:read', admin: 'vault:admin', approve: 'vault:approve', passwords: 'vault:passwords', sealer: 'vault:sealer', replica: 'vault:replica' })
174
180
 
175
181
  const enc = (s) => new TextEncoder().encode(s)
176
182
  const hex = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('')
package/vault/index.html CHANGED
@@ -26,7 +26,8 @@
26
26
  "@dotrino/proxy-client": "./vendor/proxy-client/index.js",
27
27
  "@dotrino/vault": "./vendor/vault/index.js",
28
28
  "@dotrino/identity/capabilities": "./capabilities.js",
29
- "@dotrino/identity/acta": "./acta.js"
29
+ "@dotrino/identity/acta": "./acta.js",
30
+ "@dotrino/identity/content": "./content.js"
30
31
  } }
31
32
  </script>
32
33
  <script type="module" src="./vault.js"></script>
@@ -1 +1,4 @@
1
- 0.11.0
1
+ Copia vendorizada de @dotrino/proxy-client@0.13.1 (dotrino-proxy-client/src/{index,client,signature,canonical,sealing,webrtc}.js).
2
+ NO se edita a mano: la escribe `node vendor.mjs` y la vigila test/vendor-up-to-date.test.mjs.
3
+ sealing.js resuelve @dotrino/identity/content de forma PEREZOSA (= ../../content.js
4
+ por el import map): solo se carga si de verdad se sella algo.
@@ -1,4 +1,5 @@
1
1
  import { buildSignedChannel, getPublicKeyJwk, signData } from './signature.js'
2
+ import { seal, open, isSealed } from './sealing.js'
2
3
  import { WebRTCManager, RTC_TAG, DEFAULT_ICE_SERVERS } from './webrtc.js'
3
4
 
4
5
  /**
@@ -43,6 +44,33 @@ export class WebSocketProxyClient {
43
44
  this.enableWebRTC = options.enableWebRTC !== false
44
45
  this.iceServers = options.iceServers || null
45
46
 
47
+ /**
48
+ * Refuse to send or accept directed messages in the clear.
49
+ *
50
+ * The proxy does not encrypt payloads, so anything sensitive sent with
51
+ * `sendByPubkey` is readable by whoever runs the proxy. With this on:
52
+ * · `sendSealed()` is the only way out — plain `sendByPubkey` throws
53
+ * · unsealed directed messages are dropped and reported as 'unsealed'
54
+ *
55
+ * Off by default so existing apps keep working; public channels are unaffected
56
+ * either way, since they are public by design.
57
+ */
58
+ this.requireSealed = options.requireSealed === true
59
+ this.myEncPrivateKey = options.myEncPrivateKey || null
60
+
61
+ /**
62
+ * Who does the sealing. Two worlds, and only one of them holds the key:
63
+ *
64
+ * · headless devices (a CLI, an agent) have their own encryption private key,
65
+ * so `myEncPrivateKey` is enough
66
+ * · browser apps do NOT: the private key lives in the vault, and they delegate
67
+ * with `identity.encrypt` / `identity.decrypt`
68
+ *
69
+ * Pass `sealing: { seal(msg, peerEncPub), open(envelope), isSealed(msg) }` to
70
+ * plug the second case in. Without it, the built-in sealing is used.
71
+ */
72
+ this.sealing = options.sealing || null
73
+
46
74
  // Heartbeat de aplicación: el WebSocket del browser NO expone ping/pong de
47
75
  // protocolo, así que mandamos `{type:'ping'}` y esperamos cualquier tráfico
48
76
  // de vuelta (el server responde `pong`). Si no hay respuesta en
@@ -67,7 +95,7 @@ export class WebSocketProxyClient {
67
95
  this._rtc = this.enableWebRTC ? new WebRTCManager({
68
96
  getSelfToken: () => this.token,
69
97
  signalSend: (to, payload) => this._proxySendOne(to, payload),
70
- deliverMessage: (from, parsed, meta) => this._emit('message', from, parsed, meta),
98
+ deliverMessage: (from, parsed, meta) => this._deliver(from, parsed, meta),
71
99
  emit: (event, ...args) => this._emit(event, ...args),
72
100
  config: this.iceServers ? { iceServers: this.iceServers } : null
73
101
  }) : null
@@ -127,6 +155,19 @@ export class WebSocketProxyClient {
127
155
  if (typeof options.autoReconnect === 'boolean') this.autoReconnect = options.autoReconnect
128
156
  if (typeof options.maxReconnectAttempts === 'number') this.maxReconnectAttempts = options.maxReconnectAttempts
129
157
  if (typeof options.reconnectDelay === 'number') this.reconnectDelay = options.reconnectDelay
158
+
159
+ // La configuración de sellado TAMBIÉN se aplica aquí. Casi todas las apps piden el
160
+ // cliente con `getWebSocketProxyClient()`, que es un singleton: si el primero en
161
+ // pedirlo no puso `requireSealed`, el que sí lo pide después se quedaba sin él y
162
+ // sin enterarse — la garantía perdida en silencio, que es la peor forma de
163
+ // perderla.
164
+ if (options.sealing) this.sealing = options.sealing
165
+ if (options.myEncPrivateKey) this.myEncPrivateKey = options.myEncPrivateKey
166
+
167
+ // Se puede ENCENDER, no apagar. Bajar la exigencia en caliente dejaría que
168
+ // cualquier otro módulo de la app la desactivara sin querer, y no hay ningún
169
+ // motivo legítimo para hacerlo a mitad de una sesión.
170
+ if (options.requireSealed === true) this.requireSealed = true
130
171
  }
131
172
 
132
173
  on (event, handler) {
@@ -265,7 +306,67 @@ export class WebSocketProxyClient {
265
306
  * incorrecto (reinicia negociaciones imposibles y muestra movimientos fuera de
266
307
  * contexto). NO lo uses para mensajes de chat, que sí quieren esperar.
267
308
  */
309
+ /**
310
+ * Seal a payload towards a peer's encryption key and send it. This is what an app
311
+ * should use for anything that is not meant for the proxy's eyes.
312
+ */
313
+ async sendSealed (toPubkeys, payload, { peerEncPub, ...opts } = {}) {
314
+ if (this.sealing) {
315
+ this._sendByPubkeyRaw(toPubkeys, await this.sealing.seal(payload, peerEncPub), opts)
316
+ return
317
+ }
318
+ if (!peerEncPub) throw Object.assign(new Error('sendSealed: missing peerEncPub'), { code: 'unsealed' })
319
+ this._sendByPubkeyRaw(toPubkeys, await seal(payload, peerEncPub), opts)
320
+ }
321
+
322
+ _isSealed (msg) {
323
+ return this.sealing ? this.sealing.isSealed(msg) : isSealed(msg)
324
+ }
325
+
268
326
  sendByPubkey (toPubkeys, payload, opts = {}) {
327
+ if (this.requireSealed && !this._isSealed(payload)) {
328
+ throw Object.assign(
329
+ new Error('requireSealed: refusing to send a directed message in the clear — use sendSealed()'),
330
+ { code: 'unsealed' })
331
+ }
332
+ this._sendByPubkeyRaw(toPubkeys, payload, opts)
333
+ }
334
+
335
+ /**
336
+ * Hands a message to the app, opening it first when it is sealed.
337
+ *
338
+ * With `requireSealed`, anything that arrives in the clear is DROPPED and reported
339
+ * as `{ type: 'unsealed' }`. Sealing on the way out is not enough on its own: if the
340
+ * receiving end still accepts plaintext, sending it that way bypasses the sealing
341
+ * entirely — and a peer that never read anything could still push a forged payload
342
+ * into the app.
343
+ */
344
+ async _deliver (from, payload, meta) {
345
+ if (this._isSealed(payload)) {
346
+ if (!this.sealing && !this.myEncPrivateKey) {
347
+ this._emit('error', { type: 'unsealed', reason: 'no_encryption_key', from })
348
+ return
349
+ }
350
+ try {
351
+ const opened = this.sealing
352
+ ? await this.sealing.open(payload, meta)
353
+ : await open(payload, this.myEncPrivateKey)
354
+ this._emit('message', from, opened, { ...meta, sealed: true })
355
+ } catch (e) {
356
+ // Sealed to somebody else, or tampered with. Staying quiet is the point.
357
+ this._emit('error', { type: 'undecipherable', from, error: e })
358
+ }
359
+ return
360
+ }
361
+
362
+ if (this.requireSealed) {
363
+ this._emit('error', { type: 'unsealed', reason: 'plaintext_rejected', from })
364
+ return
365
+ }
366
+ this._emit('message', from, payload, { ...meta, sealed: false })
367
+ }
368
+
369
+ _sendByPubkeyRaw (toPubkeys, payload, opts = {}) {
269
370
  const list = Array.isArray(toPubkeys) ? toPubkeys : [toPubkeys]
270
371
  const msg = {
271
372
  to_publickey: list,
@@ -713,7 +814,7 @@ export class WebSocketProxyClient {
713
814
  this._rtc.handleIncoming(from, parsed)
714
815
  break
715
816
  }
716
- this._emit('message', from, parsed ?? message, {
817
+ this._deliver(from, parsed ?? message, {
717
818
  raw: message, timestamp, via: 'proxy',
718
819
  fromPubkey: from_publickey || null,
719
820
  queued: !!queued,
@@ -1,6 +1,10 @@
1
1
  export { WebSocketProxyClient } from './client.js'
2
2
  export { canonicalStringify } from './canonical.js'
3
- export { getPublicKeyJwk, signData, buildSignedChannel } from './signature.js'
3
+ export { getPublicKeyJwk, signData, buildSignedChannel, setKeypairStore } from './signature.js'
4
+ export {
5
+ seal, open, isSealed, makeEncKeypair, importEncPrivate, exportEncPrivate,
6
+ setSealingPrimitives,
7
+ } from './sealing.js'
4
8
 
5
9
  import { WebSocketProxyClient } from './client.js'
6
10
 
@@ -0,0 +1,77 @@
1
+ /**
2
+ * End-to-end sealing for directed messages.
3
+ *
4
+ * The proxy routes by public key but does NOT encrypt the payload: `sendByPubkey`
5
+ * serializes it and sends it as-is. Anything sensitive that travels this way is
6
+ * readable by whoever runs the proxy — which is exactly what the ecosystem promises
7
+ * does not happen.
8
+ *
9
+ * This is NOT new cryptography. It is `wrapForMember`/`openWrap` from
10
+ * `@dotrino/identity/content`, the same primitives the vault uses for sealed secrets:
11
+ * ephemeral ECDH P-256 against the recipient's encryption public key, plus AES-GCM.
12
+ * Each message carries its own ephemeral key, so there is no shared state to keep.
13
+ *
14
+ * `@dotrino/identity` is a PEER dependency on purpose: bundling it here would ship a
15
+ * second, older copy of a pillar inside every consumer.
16
+ */
17
+
18
+ const ECDH = { name: 'ECDH', namedCurve: 'P-256' }
19
+ const VERSION = 1
20
+
21
+ let primitives = null
22
+
23
+ async function crypto_ () {
24
+ if (primitives) return primitives
25
+ try {
26
+ primitives = await import('@dotrino/identity/content')
27
+ } catch (e) {
28
+ throw new Error(
29
+ 'sealing requires @dotrino/identity (peer dependency) — install it, or pass ' +
30
+ 'your own primitives to setSealingPrimitives()')
31
+ }
32
+ return primitives
33
+ }
34
+
35
+ /** Inject the primitives instead of resolving `@dotrino/identity` (bundlers, tests). */
36
+ export function setSealingPrimitives (mod) {
37
+ primitives = mod
38
+ }
39
+
40
+ /** A durable encryption keypair for this device. Its public half goes in the pairing code. */
41
+ export async function makeEncKeypair () {
42
+ const pair = await globalThis.crypto.subtle.generateKey(ECDH, true, ['deriveBits'])
43
+ const pub = await globalThis.crypto.subtle.exportKey('jwk', pair.publicKey)
44
+ return {
45
+ privateKey: pair.privateKey,
46
+ publicKey: pair.publicKey,
47
+ encPub: JSON.stringify({ kty: pub.kty, crv: pub.crv, x: pub.x, y: pub.y }),
48
+ }
49
+ }
50
+
51
+ export async function importEncPrivate (jwk) {
52
+ return globalThis.crypto.subtle.importKey('jwk', jwk, ECDH, true, ['deriveBits'])
53
+ }
54
+
55
+ export async function exportEncPrivate (privateKey) {
56
+ return globalThis.crypto.subtle.exportKey('jwk', privateKey)
57
+ }
58
+
59
+ /** Seal a message towards a peer's encryption public key. */
60
+ export async function seal (message, peerEncPub) {
61
+ if (!peerEncPub) throw new Error('seal: missing peer encryption key')
62
+ const { wrapForMember } = await crypto_()
63
+ const sealed = await wrapForMember({ cek: JSON.stringify(message), memberEncPub: peerEncPub })
64
+ return { v: VERSION, sealed }
65
+ }
66
+
67
+ /** Open a message sealed to me. Throws if it is not mine or was tampered with. */
68
+ export async function open (envelope, myEncPrivateKey) {
69
+ if (!isSealed(envelope)) throw new Error('open: not a sealed envelope')
70
+ if (!myEncPrivateKey) throw new Error('open: missing my encryption key')
71
+ const { openWrap } = await crypto_()
72
+ return JSON.parse(await openWrap({ wrap: envelope.sealed, myEncPrivateKey }))
73
+ }
74
+
75
+ export function isSealed (msg) {
76
+ return !!msg && msg.v === VERSION && !!msg.sealed?.ct && !!msg.sealed?.epk
77
+ }
@@ -1,17 +1,141 @@
1
1
  /**
2
- * ECDSA P-256 keypair management using SubtleCrypto, persisted in localStorage as JWK.
2
+ * ECDSA P-256 keypair management using SubtleCrypto.
3
+ *
4
+ * Persisted in localStorage as JWK where it exists. Where it does NOT — a service
5
+ * worker, which is where a browser extension keeps its background logic — the pair
6
+ * used to be regenerated on every call and never stored, so the identity changed
7
+ * every time the worker went to sleep. Any peer that knows a device by its public
8
+ * key would see a stranger each time. IndexedDB is the fallback there: it is
9
+ * available in workers, and it can store the CryptoKey itself, so the private key
10
+ * stays non-extractable instead of being written out as a JWK.
11
+ *
3
12
  * Public key in JWK form is what the proxy expects in `channel.data.publickey`.
4
13
  */
5
14
  import { canonicalStringify } from './canonical.js'
6
15
 
7
16
  const STORAGE_KEY = 'dotrino.proxy-client.keypair'
17
+ const DB_NAME = 'dotrino.proxy-client'
18
+ const DB_STORE = 'keypair'
8
19
 
9
20
  let cachedKeypair = null
21
+ let injectedStore = null
22
+ let injectedExtractable = false
23
+
24
+ /**
25
+ * Override where the keypair is kept. Takes `{ get(), set(pair) }` handling
26
+ * `{ privateKey, publicKey, publicJwk }`. Rarely needed: the defaults already cover
27
+ * pages (localStorage) and workers (IndexedDB).
28
+ *
29
+ * `extractable` matters: a store that keeps CryptoKeys as-is (IndexedDB) does not
30
+ * need it and is safer without, but a store that serializes to disk or to text has
31
+ * to export the private key as a JWK, and that throws on a non-extractable key. Pass
32
+ * `{ extractable: true }` for those.
33
+ */
34
+ export function setKeypairStore (store, { extractable = false } = {}) {
35
+ injectedStore = store
36
+ injectedExtractable = !!extractable
37
+ cachedKeypair = null
38
+ }
39
+
40
+ function idb () {
41
+ return new Promise((resolve, reject) => {
42
+ const req = indexedDB.open(DB_NAME, 1)
43
+ req.onupgradeneeded = () => {
44
+ if (!req.result.objectStoreNames.contains(DB_STORE)) req.result.createObjectStore(DB_STORE)
45
+ }
46
+ req.onsuccess = () => resolve(req.result)
47
+ req.onerror = () => reject(req.error)
48
+ })
49
+ }
50
+
51
+ function idbRequest (db, mode, fn) {
52
+ return new Promise((resolve, reject) => {
53
+ const tx = db.transaction(DB_STORE, mode)
54
+ const req = fn(tx.objectStore(DB_STORE))
55
+ req.onsuccess = () => resolve(req.result)
56
+ req.onerror = () => reject(req.error)
57
+ })
58
+ }
59
+
60
+ const indexedDbStore = {
61
+ async get () {
62
+ const db = await idb()
63
+ try { return await idbRequest(db, 'readonly', s => s.get(STORAGE_KEY)) } finally { db.close() }
64
+ },
65
+ async set (pair) {
66
+ const db = await idb()
67
+ try { await idbRequest(db, 'readwrite', s => s.put(pair, STORAGE_KEY)) } finally { db.close() }
68
+ },
69
+ }
70
+
71
+ /**
72
+ * Is there a WORKING localStorage? Not "is it defined" — Node >= 22 exposes one that
73
+ * throws unless started with `--localstorage-file`, so checking for existence alone
74
+ * sends the keypair down a path that fails. Anything headless without a shim would
75
+ * crash instead of quietly falling back.
76
+ */
77
+ function localStorageWorks () {
78
+ try {
79
+ if (typeof localStorage === 'undefined') return false
80
+ const probe = STORAGE_KEY + '.probe'
81
+ localStorage.setItem(probe, '1')
82
+ localStorage.removeItem(probe)
83
+ return true
84
+ } catch (e) {
85
+ return false
86
+ }
87
+ }
88
+
89
+ function fallbackStore () {
90
+ if (injectedStore) return injectedStore
91
+ if (!localStorageWorks() && typeof indexedDB !== 'undefined') return indexedDbStore
92
+ return null
93
+ }
10
94
 
11
95
  async function loadOrCreate () {
12
96
  if (cachedKeypair) return cachedKeypair
13
97
 
14
- if (typeof localStorage !== 'undefined') {
98
+ const store = fallbackStore()
99
+ if (store) {
100
+ try {
101
+ const saved = await store.get()
102
+ if (saved?.privateKey && saved?.publicKey) {
103
+ cachedKeypair = {
104
+ privateKey: saved.privateKey,
105
+ publicKey: saved.publicKey,
106
+ publicJwk: saved.publicJwk || await crypto.subtle.exportKey('jwk', saved.publicKey),
107
+ }
108
+ return cachedKeypair
109
+ }
110
+ } catch (e) {
111
+ // unreadable entry, regenerate below
112
+ }
113
+
114
+ // Non-extractable by default: nothing here needs to export the private key, and
115
+ // a CryptoKey survives structured clone, so with IndexedDB it never has to leave
116
+ // as a JWK. A store that serializes has to opt in via `setKeypairStore`.
117
+ const extractable = store === indexedDbStore ? false : injectedExtractable
118
+ const pair = await crypto.subtle.generateKey(
119
+ { name: 'ECDSA', namedCurve: 'P-256' },
120
+ extractable, ['sign', 'verify']
121
+ )
122
+ const publicJwk = await crypto.subtle.exportKey('jwk', pair.publicKey)
123
+ const entry = { privateKey: pair.privateKey, publicKey: pair.publicKey, publicJwk }
124
+ try {
125
+ await store.set(entry)
126
+ } catch (e) {
127
+ // Loud on purpose. If this fails the identity is regenerated on every start,
128
+ // and every peer that knows this device by its public key stops recognising
129
+ // it — the exact failure this whole path exists to prevent. A silent catch
130
+ // here means finding out days later, from the other side.
131
+ console.error('[proxy-client] could not persist the keypair: %s', e?.message || e)
132
+ console.error('[proxy-client] identity will NOT survive a restart. If the store serializes, pass { extractable: true } to setKeypairStore.')
133
+ }
134
+ cachedKeypair = entry
135
+ return cachedKeypair
136
+ }
137
+
138
+ if (localStorageWorks()) {
15
139
  const raw = localStorage.getItem(STORAGE_KEY)
16
140
  if (raw) {
17
141
  try {
@@ -40,7 +164,7 @@ async function loadOrCreate () {
40
164
  )
41
165
  const privateJwk = await crypto.subtle.exportKey('jwk', pair.privateKey)
42
166
  const publicJwk = await crypto.subtle.exportKey('jwk', pair.publicKey)
43
- if (typeof localStorage !== 'undefined') {
167
+ if (localStorageWorks()) {
44
168
  localStorage.setItem(STORAGE_KEY, JSON.stringify({ privateJwk, publicJwk }))
45
169
  }
46
170
  cachedKeypair = { privateKey: pair.privateKey, publicKey: pair.publicKey, publicJwk }
@@ -1,6 +1,5 @@
1
- Copia vendorizada de @dotrino/vault@0.34.0 (lib/src/{index,enroll,protocol}.js, sin dependencias).
2
- El iframe de identity se sirve estatico (vanilla, sin build); asi startDeviceVault
3
- resuelve en el navegador sin bundler. index.js importa ./enroll.js y ./protocol.js
4
- (relativos, se vendorizan tambien) y @dotrino/identity/capabilities (=../../capabilities.js)
5
- y @dotrino/proxy-client (=../proxy-client/), ambos via el import map de index.html.
6
- Re-vendorizar LOS TRES archivos al subir @dotrino/vault.
1
+ Copia vendorizada de @dotrino/vault@0.53.0 (dotrino-vault/lib/src/{index,enroll,protocol}.js).
2
+ NO se edita a mano: la escribe `node vendor.mjs` y la vigila test/vendor-up-to-date.test.mjs.
3
+ index.js importa ./enroll.js y ./protocol.js (relativos, van en esta misma copia),
4
+ @dotrino/identity/{capabilities,acta} (= ../../{capabilities,acta}.js) y
5
+ @dotrino/proxy-client (= ../proxy-client/), todos por el import map de index.html.
@@ -30,7 +30,7 @@
30
30
  * `docs/pairing-protocol.md`).
31
31
  */
32
32
  import { verifyDeviceSig, pubkeyId, commitCode } from '@dotrino/identity/capabilities'
33
- import { verifyContinuity } from '@dotrino/identity/acta'
33
+ import { verifyContinuity, canSeal } from '@dotrino/identity/acta'
34
34
 
35
35
  /** Un token de emparejamiento vale 5 min. */
36
36
  export const PAIRING_TTL_MS = 5 * 60 * 1000
@@ -52,7 +52,7 @@ export const MSG_REVOKED = 'vault.revoked'
52
52
  export const MSG_ERROR = 'vault.error'
53
53
 
54
54
  /** Los scopes del cert se corresponden 1:1 con las capacidades del acta (§D7). */
55
- const SCOPE_TO_CAP = { 'vault:sign': 'sign', 'vault:store': 'store', 'vault:read': 'read', 'vault:admin': 'admin' }
55
+ const SCOPE_TO_CAP = { 'vault:sign': 'sign', 'vault:store': 'store', 'vault:read': 'read', 'vault:admin': 'admin', 'vault:passwords': 'passwords' }
56
56
  export const scopeToCaps = (scope) =>
57
57
  (Array.isArray(scope) ? scope : [scope]).map((s) => SCOPE_TO_CAP[s]).filter(Boolean)
58
58
 
@@ -277,7 +277,12 @@ export function createEnrollDesk ({
277
277
  pend.continuity = (okC && d.continuity.member === d.dpub) ? d.continuity : null
278
278
  }
279
279
  pend.from = from // la bóveda NO conoce el código: lo aprende cuando lo tipeas
280
- if (d.label) pend.label = String(d.label).slice(0, 60)
280
+ // EL NOMBRE QUE PUSISTE AQUÍ MANDA. El aparato manda el suyo al enrolarse, y hasta
281
+ // ahora pisaba siempre al de la bóveda — como el aparato usa por defecto el apodo del
282
+ // PERFIL, acababas con varios dispositivos llamados igual que tú y sin forma de saber
283
+ // cuál era cuál. Si en `pair` le diste un nombre, ese es el nombre; el del aparato
284
+ // sigue valiendo como propuesta cuando no dijiste nada.
285
+ if (d.label && !pend.label) pend.label = String(d.label).slice(0, 60)
281
286
  // Camino A: de qué cuenta estamos hablando. Se guarda para poder comprobar, cuando
282
287
  // llegue el acta sellada, que es la que este dispositivo dijo que iba a entregar.
283
288
  if (intent === 'adopt' && typeof d.profileId === 'string') pend.profileId = d.profileId
@@ -359,7 +364,7 @@ export function createEnrollDesk ({
359
364
  pend.state = 'DONE'
360
365
  pending.delete(pend.token)
361
366
  fire(onPendingChange)
362
- log('[vault] device approved: %s', pend.deviceId)
367
+ log(`[vault] device approved: ${pend.deviceId}`)
363
368
  return { ok: true, deviceId: pend.deviceId, cert }
364
369
  }
365
370
 
@@ -383,9 +388,12 @@ export function createEnrollDesk ({
383
388
  const pend = [...pending.values()].find((x) => x.state === 'AWAITING_ACTA' && (x.from === from || x.dpub))
384
389
  if (!pend) return reply(from, { type: MSG_ERROR, error: 'no adoption awaiting a record' })
385
390
  if (!record || typeof record !== 'object') return reply(from, { type: MSG_ERROR, error: 'record missing or unreadable' })
386
- if (record.sealer !== iss) {
391
+ // Ya no hay campo `sealer`: se le pregunta al PERMISO. Es la misma comprobación —«¿me
392
+ // nombra a mí la que me mandan?»— dicha en el idioma nuevo, y con varios selladores la
393
+ // respuesta puede ser que sí para más de uno, que es lo correcto.
394
+ if (!canSeal(record, iss)) {
387
395
  audit('rejected', { what: 'adopt', reason: 'not-sealer' })
388
- return reply(from, { type: MSG_ERROR, error: 'that record does not name this vault as the sealer' })
396
+ return reply(from, { type: MSG_ERROR, error: 'that record does not let this vault seal it' })
389
397
  }
390
398
  if (record.sealedBy !== pend.dpub) {
391
399
  audit('rejected', { what: 'adopt', reason: 'sealed-by-other' })
@@ -26,13 +26,13 @@
26
26
  * No reimplementa nada del ecosistema.
27
27
  */
28
28
  import { verifyChain, verifyDeviceSig } from '@dotrino/identity/capabilities'
29
+ import { memberCanScope, sealersOf, memberScopes } from '@dotrino/identity/acta'
29
30
  import { createEnrollDesk, deviceIdOf, DEVICE_TTL_MS, FRESH_WINDOW_MS } from './enroll.js'
30
31
  // Las constantes del protocolo salen del MISMO módulo que usa el daemon: si la lista
31
32
  // local se queda corta, el dispositivo deja de handle mensajes sin que nadie lo note.
32
33
  import { MSG, SCOPE } from './protocol.js'
33
34
 
34
35
  const SIGN_SCOPE = SCOPE.SIGN
35
- const SELFCERT_TTL_MS = 24 * 60 * 60 * 1000 // el self-cert P←P se regenera cada 24 h
36
36
  const RENEW_TTL_MS = DEVICE_TTL_MS // la renovación extiende la misma ventana (30 días)
37
37
 
38
38
  /** deviceId legible (p. ej. `C440-AC0E`) desde una pubkey JWK. */
@@ -57,10 +57,24 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
57
57
 
58
58
  // ----- self-cert P ← P (para que este dispositivo pueda además actuar de cliente
59
59
  // de sus propias máquinas: lo firma la propia P y verifyChain lo acepta) -----
60
+ /**
61
+ * Con qué se juzga un papel: el acta que tiene esta bóveda. Sustituye a `trustedIssuer`,
62
+ * que fijaba UNA llave y por eso los papeles de una segunda selladora no valían.
63
+ * Sin acta van nulos y `verifyDelegation` contesta `no-acta`: no hay con qué decidir, así
64
+ * que no se decide que sí.
65
+ */
66
+ async function contextoActa () {
67
+ const acta = (await identity.profileActa?.().catch(() => null))?.acta || null
68
+ if (!acta) return { actaSeq: null, sealers: null }
69
+ return { actaSeq: acta.seq, sealers: sealersOf(acta) }
70
+ }
71
+
60
72
  let _selfCert = null
61
73
  const getSelfCert = async () => {
62
- if (_selfCert && _selfCert.exp > Date.now() + 60_000) return _selfCert
63
- const { cert } = await identity.signDelegation(iss, SIGN_SCOPE, { ttlMs: SELFCERT_TTL_MS })
74
+ // Se rehace cuando el acta cambia, no cuando pasa el tiempo: el papel ya no caduca.
75
+ const { actaSeq } = await contextoActa()
76
+ if (_selfCert && _selfCert.seq === actaSeq) return _selfCert
77
+ const { cert } = await identity.signDelegation(iss, SIGN_SCOPE)
64
78
  _selfCert = cert
65
79
  return cert
66
80
  }
@@ -137,13 +151,20 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
137
151
  if (typeof d.ts !== 'number' || Math.abs(Date.now() - d.ts) > FRESH_WINDOW_MS) {
138
152
  return send(from, { type: MSG.ERROR, error: 'stale request: ts outside the ±5 min window (possible replay, or the device clock is off)' })
139
153
  }
140
- const chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, trustedIssuer: iss, revoked: await revocationSet() })
154
+ const chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, ...(await contextoActa()), revoked: await revocationSet() })
141
155
  if (!chk.ok) return send(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason })
142
156
  // Reusar el label del cert original (si sigue registrado en delegations).
143
157
  const { issued } = await identity.listDelegations()
144
158
  const prev = (issued || []).find((x) => x.nonce === p.cert.nonce)
145
- const { cert } = await identity.signDelegation(p.cert.sub, p.cert.scope, { ttlMs: RENEW_TTL_MS, label: prev?.label || '' })
146
- send(from, { type: MSG.RENEWED, cert })
159
+ // EL SCOPE SALE DEL ACTA, no del papel viejo: el papel dice a qué se comprometió esta
160
+ // bóveda al conectar el aparato; el acta, lo que puede HOY.
161
+ const acta = (await identity.profileActa?.().catch(() => null))?.acta || null
162
+ if (!acta) return send(from, { type: MSG.ERROR, error: 'unauthorized: this vault has no record to decide with' })
163
+ const scope = memberScopes(acta, p.cert.sub)
164
+ if (!scope.length) return send(from, { type: MSG.ERROR, error: 'unauthorized: the record no longer lists this device' })
165
+ const { cert } = await identity.signDelegation(p.cert.sub, scope, { label: prev?.label || '' })
166
+ // El acta viaja con el papel: sin ella quien lo recibe no puede comprobar quién lo firmó.
167
+ send(from, { type: MSG.RENEWED, cert, acta })
147
168
  }
148
169
 
149
170
  // Consulta de revocaciones (igual que `vault.devices` del daemon): responde la lista
@@ -153,12 +174,12 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
153
174
  const d = p?.data
154
175
  if (!d || !p.signature || !p.cert) return send(from, { type: MSG.ERROR, error: 'invalid request' })
155
176
  if (typeof d.ts !== 'number' || Math.abs(Date.now() - d.ts) > FRESH_WINDOW_MS) return
156
- const chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, trustedIssuer: iss })
177
+ const chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, ...(await contextoActa()) })
157
178
  if (!chk.ok) return send(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason })
158
179
  const { issued, revoked, revokedCerts } = await identity.listDelegations()
159
180
  const devices = await Promise.all((issued || []).map(async (x) => ({
160
181
  deviceId: x.sub ? await deviceIdOf(x.sub) : null, sub: x.sub || null,
161
- label: x.label || '', scope: x.scope, exp: x.exp, nonce: x.nonce
182
+ label: x.label || '', scope: x.scope, seq: x.seq, nonce: x.nonce
162
183
  })))
163
184
  send(from, { type: MSG.DEVICES_RESULT, devices, revoked: (revoked || []).map((r) => r.nonce || r) })
164
185
  // ¿el que consulta es una máquina revocada que reapareció? → re-emite el REVOKED firmado.
@@ -188,12 +209,23 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
188
209
  const chk = await verifyChain({
189
210
  data: d, signature: p.signature, cert: p.cert,
190
211
  ...(expectedScope ? { expectedScope } : {}),
191
- trustedIssuer: iss, revoked: await revocationSet(),
212
+ ...(await contextoActa()), revoked: await revocationSet(),
192
213
  })
193
214
  if (!chk.ok) {
194
215
  send(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason })
195
216
  return null
196
217
  }
218
+ // EL ACTA MANDA, EL PAPEL SOLO ACOMPAÑA. Aquí y no en cada mostrador: el certificado
219
+ // dice a qué se comprometió esta bóveda al conectar el aparato, y el acta lo que puede
220
+ // HOY. Quitarle un permiso sella el acta pero no le retira el papel, que vive hasta 30
221
+ // días, así que sin esto seguiría entrando hasta que lo renovara.
222
+ if (expectedScope) {
223
+ const record = (await identity.profileActa?.().catch(() => null))?.acta || null
224
+ if (record && !memberCanScope(record, chk.device, expectedScope)) {
225
+ send(from, { type: MSG.ERROR, error: 'unauthorized: acta — this member no longer has that permission' })
226
+ return null
227
+ }
228
+ }
197
229
  return chk
198
230
  }
199
231
 
@@ -302,13 +334,14 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
302
334
  */
303
335
  async function listMachines () {
304
336
  const { issued } = await identity.listDelegations()
305
- const now = Date.now()
306
337
  const bySub = new Map()
307
338
  for (const x of (issued || [])) {
308
- if (!x.sub || x.revokedAt || (x.exp && x.exp <= now)) continue // revocada = fuera de la lista
339
+ if (!x.sub || x.revokedAt) continue // revocada = fuera de la lista
309
340
  if (!Array.isArray(x.scope) || !x.scope.includes(SIGN_SCOPE)) continue
310
341
  if (!x.label || x.label === 'cli') continue
311
- if (!bySub.has(x.sub) || (x.exp || 0) > (bySub.get(x.sub).exp || 0)) bySub.set(x.sub, x)
342
+ // Se queda el papel del acta MÁS NUEVA de esa llave: ya no hay «el que vence más
343
+ // tarde», porque ninguno vence.
344
+ if (!bySub.has(x.sub) || (x.seq || 0) > (bySub.get(x.sub).seq || 0)) bySub.set(x.sub, x)
312
345
  }
313
346
  return Promise.all([...bySub.values()].map(async (x) => ({ ...x, deviceId: await deviceIdOf(x.sub) })))
314
347
  }
@@ -86,6 +86,10 @@ export const MSG = Object.freeze({
86
86
  // contrapartida de administrar a distancia: sin esto, un enrolamiento remoto sería
87
87
  // invisible para el resto de tus dispositivos.
88
88
  ADMIN_EVENT: 'vault.admin.event', // vault → todos: { body:{ev,deviceId,by,ts}, signature }
89
+ // RÉPLICAS: la principal empuja lo que hay que servir (el acta y los sobres, que van
90
+ // firmados de antes y no se pueden falsificar) y la réplica acusa hasta qué `seq` tiene.
91
+ REPLICA_PUSH: 'vault.replica.push', // master → réplica: { body:{seq,acta,secrets,ts}, signature }
92
+ REPLICA_ACK: 'vault.replica.ack', // réplica → master: { body:{seq,ts}, signature }
89
93
  ERROR: 'vault.error' // vault → dispositivo: { error }
90
94
  })
91
95
 
@@ -98,7 +102,15 @@ export const SCOPE = Object.freeze({
98
102
  // NO incluye cambiar permisos, traspasar el mando ni conceder `admin`: eso es el rol
99
103
  // de master y sigue siendo local. No se empareja — se concede desde el PC.
100
104
  ADMIN: 'vault:admin',
101
- APPROVE: 'vault:approve' // aprobar pedidos de secretos (cajones con `approval`); se concede a mano, como admin
105
+ APPROVE: 'vault:approve', // aprobar pedidos de secretos (cajones con `approval`); se concede a mano, como admin
106
+ // El gestor de contraseñas: pedir credenciales de la bóveda, de a una y por dominio.
107
+ // Nunca lista la bóveda entera. Este SÍ se empareja (`pair --scope contrasenas`): es
108
+ // lo primero que hace la extensión, y no tendría sentido obligar a un segundo paso.
109
+ PASSWORDS: 'vault:passwords',
110
+ // SELLAR EL ACTA: la OTRA bóveda de esta cuenta. Con esto puede admitir aparatos y
111
+ // cambiar permisos si la principal se pierde — que es todo el punto del multivault. Como
112
+ // `admin`, no se empareja: se concede a mano (`caps <ID> +sella`).
113
+ SEALER: 'vault:sealer'
102
114
  })
103
115
 
104
116
  /**