@dotrino/identity 0.114.1 → 0.116.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.
Files changed (81) hide show
  1. package/package.json +7 -1
  2. package/src/index.js +9 -1
  3. package/src/node.js +4 -180
  4. package/vault/acta.js +5 -1
  5. package/vault/core.js +95 -15
  6. package/vault/facade.js +200 -0
  7. package/vault/index.html +10 -0
  8. package/vault/machine.js +95 -0
  9. package/vault/remote.js +54 -9
  10. package/vault/vault.js +29 -58
  11. package/vault/vendor/compat/VERSION.txt +3 -0
  12. package/vault/vendor/compat/advisory.js +141 -0
  13. package/vault/vendor/compat/index.js +188 -0
  14. package/vault/vendor/compat/ranges.js +93 -0
  15. package/vault/vendor/opaque/VERSION.txt +1 -1
  16. package/vault/vendor/passmanager/VERSION.txt +3 -0
  17. package/vault/vendor/passmanager/crypto.js +167 -0
  18. package/vault/vendor/passmanager/fields.js +183 -0
  19. package/vault/vendor/passmanager/generate.js +74 -0
  20. package/vault/vendor/passmanager/import.js +156 -0
  21. package/vault/vendor/passmanager/index.js +27 -0
  22. package/vault/vendor/passmanager/match.js +137 -0
  23. package/vault/vendor/passmanager/model.js +332 -0
  24. package/vault/vendor/passmanager/pubkey.js +33 -0
  25. package/vault/vendor/passmanager/sealed/convert.js +72 -0
  26. package/vault/vendor/passmanager/sealed/device.js +211 -0
  27. package/vault/vendor/passmanager/sealed/entry.js +209 -0
  28. package/vault/vendor/passmanager/sealed/index.js +20 -0
  29. package/vault/vendor/passmanager/sealed/keys.js +120 -0
  30. package/vault/vendor/passmanager/sealed/recovery.js +136 -0
  31. package/vault/vendor/passmanager/sealed/store.js +508 -0
  32. package/vault/vendor/passmanager/session-cache.js +60 -0
  33. package/vault/vendor/passmanager/totp.js +111 -0
  34. package/vault/vendor/passmanager/transport/protocol.js +83 -0
  35. package/vault/vendor/passmanager/transport/proxy.js +112 -0
  36. package/vault/vendor/passmanager/transport/sealed.js +74 -0
  37. package/vault/vendor/passmanager/vault/approval.js +63 -0
  38. package/vault/vendor/passmanager/vault/errors.js +34 -0
  39. package/vault/vendor/passmanager/vault/guard.js +114 -0
  40. package/vault/vendor/passmanager/vault/interface.js +34 -0
  41. package/vault/vendor/passmanager/vault/local.js +213 -0
  42. package/vault/vendor/passmanager/vault/remote.js +85 -0
  43. package/vault/vendor/passmanager/vault/responder.js +192 -0
  44. package/vault/vendor/passmanager/vault/sealed-local.js +199 -0
  45. package/vault/vendor/passmanager/vault/sealed-responder.js +159 -0
  46. package/vault/vendor/passmanager/vault/sealed.js +362 -0
  47. package/vault/vendor/passmanager/webauthn.js +221 -0
  48. package/vault/vendor/proxy-client/VERSION.txt +1 -1
  49. package/vault/vendor/proxy-client/client.js +36 -0
  50. package/vault/vendor/roadmap/VERSION.txt +3 -0
  51. package/vault/vendor/roadmap/index.js +108 -0
  52. package/vault/vendor/roadmap/manifests.js +797 -0
  53. package/vault/vendor/store/VERSION.txt +3 -0
  54. package/vault/vendor/store/core.js +309 -0
  55. package/vault/vendor/vault/VERSION.txt +4 -1
  56. package/vault/vendor/vault/admin.js +437 -0
  57. package/vault/vendor/vault/browser.js +167 -0
  58. package/vault/vendor/vault/core/approvals.js +204 -0
  59. package/vault/vendor/vault/core/blocked.js +68 -0
  60. package/vault/vendor/vault/core/browserDisk.js +109 -0
  61. package/vault/vendor/vault/core/commKey.js +68 -0
  62. package/vault/vendor/vault/core/disk.js +65 -0
  63. package/vault/vendor/vault/core/libVersion.js +8 -0
  64. package/vault/vendor/vault/core/machines.js +66 -0
  65. package/vault/vendor/vault/core/notices.js +71 -0
  66. package/vault/vendor/vault/core/notifiable.js +73 -0
  67. package/vault/vendor/vault/core/passwords.js +125 -0
  68. package/vault/vendor/vault/core/sealKey.js +77 -0
  69. package/vault/vendor/vault/core/sealer.js +172 -0
  70. package/vault/vendor/vault/core/sealers.js +159 -0
  71. package/vault/vendor/vault/core/secretsStore.js +1326 -0
  72. package/vault/vendor/vault/core/sha256.js +69 -0
  73. package/vault/vendor/vault/core/store.js +68 -0
  74. package/vault/vendor/vault/core/subacta.js +101 -0
  75. package/vault/vendor/vault/core/threadStore.js +273 -0
  76. package/vault/vendor/vault/core/transport.js +270 -0
  77. package/vault/vendor/vault/core/vault.js +4414 -0
  78. package/vault/vendor/vault/enroll.js +8 -4
  79. package/vault/vendor/vault/procContext.js +97 -0
  80. package/vault/vendor/vault/revocation.js +67 -0
  81. package/vault/vendor/vault/sealed.js +84 -0
@@ -0,0 +1,200 @@
1
+ /**
2
+ * LA IDENTIDAD COMO OBJETO, sobre un core ya creado (`createIdentityCore`).
3
+ *
4
+ * Es la misma API que usa el binario (`@dotrino/identity/node`, que la extiende creando el
5
+ * core sobre archivos) y la que necesita la bóveda del navegador: allí el core ya lo creó el
6
+ * iframe o la extensión, y la bóveda lo envuelve con esto. Una sola envoltura, para que la
7
+ * bóveda llame a lo mismo en las tres versiones.
8
+ */
9
+ export class IdentityFacade {
10
+ /** @param {object} [core] el resultado de `createIdentityCore` */
11
+ constructor (core = null) {
12
+ this._core = core
13
+ this._listeners = new Map()
14
+ if (core) this._wire()
15
+ }
16
+
17
+ /** Engancha los eventos del core a `on()`. Lo llama quien pone el core. */
18
+ _wire () {
19
+ this._core.onSyncStatus?.((payload) => this._emit('sync', payload))
20
+ this._core.onVaultEvent?.((payload) => this._emit('vault', payload))
21
+ }
22
+
23
+ destroy () { this._core = null }
24
+
25
+ get me () { return this._core?.me || null }
26
+
27
+ // ----- candado de la maestra -----
28
+ /** `true` si la maestra está sellada: esta identidad NO puede firmar nada. */
29
+ get masterLocked () { return !!this._core?.masterLocked }
30
+ /** Sella la maestra que ya existe (se llama al abrir el perfil, con la llave en la mano). */
31
+ sealMasterKey () { return this._core?.sealMasterKey?.() }
32
+ /** Vuelve a cargar el par tras abrir el candado, sin reabrir la identidad. */
33
+ reloadMasterKey () { return this._core?.reloadMasterKey?.() }
34
+ /** Ver `core.js`: al abrir, los aparatos muertos salen del acta en una sola. */
35
+ pruneExpiredDevices (now) { return this._core?.pruneExpiredDevices?.(now) }
36
+
37
+ _h (method, params = {}) {
38
+ if (!this._core) throw new Error('Identity not ready — call ready()/connect() first')
39
+ return this._core.handlers[method](params)
40
+ }
41
+
42
+ // ----- API pública (espeja src/index.js) -----
43
+
44
+ makeChallenge () { return this._h('makeChallenge') }
45
+ signChallenge (nonce) { return this._h('signChallenge', { nonce }) }
46
+ verifyResponse ({ nonce, publickey, signature, encryptionPubkey }) {
47
+ return this._h('verifyResponse', { nonce, publickey, signature, encryptionPubkey })
48
+ }
49
+ getPeer (publickey) { return this._h('getPeer', { publickey }) }
50
+ setNickname (publickey, nickname) { return this._h('setNickname', { publickey, nickname }) }
51
+ setBlocked (publickey, blocked) { return this._h('setBlocked', { publickey, blocked }) }
52
+ setRating (publickey, rating, notes) { return this._h('setRating', { publickey, rating, notes }) }
53
+ listPeers () { return this._h('listPeers') }
54
+ forgetPeer (publickey) { return this._h('forgetPeer', { publickey }) }
55
+ addContact ({ publickey, nickname, encryptionPubkey, lastToken, notes } = {}) {
56
+ return this._h('addContact', { publickey, nickname, encryptionPubkey, lastToken, notes })
57
+ }
58
+ updateContact (publickey, patch) { return this._h('updateContact', { publickey, patch }) }
59
+ removeContact (publickey) { return this._h('removeContact', { publickey }) }
60
+ listContacts () { return this._h('listContacts') }
61
+ signData (data) { return this._h('signData', { data }) }
62
+ /**
63
+ * Prueba firmada con destinatario y vigencia (ver `vault/assertion.js`). Un servicio en
64
+ * Node la pide igual que una app: `{ audience, nonce, scopes?, ttlMs? }`.
65
+ */
66
+ async requestAssertion ({ audience, nonce, scopes, ttlMs } = {}) {
67
+ const { assertion } = await this._h('requestAssertion', { audience, nonce, scopes, ttlMs })
68
+ return assertion
69
+ }
70
+ // Delegación de capacidad (sub-clave de dispositivo con scope/exp/revocación)
71
+ signDelegation (sub, scope, opts = {}) { return this._h('signDelegation', { sub, scope, ...opts }) }
72
+ revokeDelegation (nonce) { return this._h('revokeDelegation', { nonce }) }
73
+ /** Quitar el DISPOSITIVO: retira todos sus certificados vigentes, no solo uno. */
74
+ revokeDevice (sub) { return this._h('revokeDevice', { sub }) }
75
+ listDelegations () { return this._h('listDelegations') }
76
+ // Acta de perfil (qué llaves son del perfil y qué puede cada una; ver acta-de-perfil.md)
77
+ profileActa () { return this._h('profileActa') }
78
+ /** La cadena de selladores, para mandarla con una firma (ver `vault/acta.js`). */
79
+ sealerChain () { return this._h('sealerChain') }
80
+ profileMembers () { return this._h('profileMembers') }
81
+ /**
82
+ * Quien SELLA SOBRES (la bóveda) registra aquí cómo estrenar su llave de sellado: se
83
+ * llama al sellar cada acta, porque esa llave rota con el acta (§8.9 de
84
+ * `dotrino-vault/docs/secretos-sellados.md`). Va por aquí y no por el iframe porque
85
+ * este puente es en-proceso: al navegador no se le puede pasar una función, y tampoco
86
+ * la necesita — no sella secretos de nadie.
87
+ */
88
+ /** Abre un sobre sellado A ESTE aparato (envoltura + sobre). Ver `core.js`. */
89
+ openSealedValue (wrap, envelope) { return this._h('openSealedValue', { wrap, envelope }) }
90
+ /** Re-envuelve para otro miembro una llave que este aparato ya puede abrir. */
91
+ rewrapFor (wrap, encPub) { return this._h('rewrapFor', { wrap, encPub }) }
92
+ setSealKeyProvider (provider) { return this._h('setSealKeyProvider', { provider }) }
93
+ /** Estrena la llave de sellado en un acta nueva, sin tocar a ningún miembro. */
94
+ rotateSealKey () { return this._h('rotateSealKey') }
95
+ /** Con qué llave se comprueban los sobres que se firmaron con el acta `seq`. */
96
+ sealKeyAt (seq) { return this._h('sealKeyAt', { seq }) }
97
+ myMembership () { return this._h('myMembership') }
98
+ /**
99
+ * La CADENA de actas desde `sinceSeq` (para que un dispositivo que estuvo apagado
100
+ * compruebe el encadenamiento en vez de tragarse un salto a ciegas).
101
+ *
102
+ * Vivía en el núcleo pero NO estaba aquí, así que `identity.actaHistory` era
103
+ * `undefined` en el daemon: al pedir la lista de dispositivos con un `sinceSeq`
104
+ * —que es lo que manda SIEMPRE un navegador ya emparejado— reventaba con
105
+ * «is not a function», la bóveda respondía error, y el aparato NUNCA volvía a
106
+ * sincronizar su acta. Se quedaba con la copia del día que emparejó.
107
+ */
108
+ actaHistory (opts) { return this._h('actaHistory', opts || {}) }
109
+ isMaster () { return this._h('isMaster') }
110
+ admitMember (member) { return this._h('admitMember', member) }
111
+ /** Registra la llave de cifrado de un miembro ya admitido (evita re-enrolarlo). */
112
+ setMemberEncPub (args) { return this._h('setMemberEncPub', args) }
113
+ setCaps (pub, caps) { return this._h('setCaps', { pub, caps }) }
114
+ setCapsMany (changes) { return this._h('setCapsMany', { changes }) }
115
+ setLabel (pub, label) { return this._h('setLabel', { pub, label }) }
116
+ removeMember (pub) { return this._h('removeMember', { pub }) }
117
+ handoverMaster (to, member = null) { return this._h('handoverMaster', { to, member }) }
118
+ renounceCaps (caps) { return this._h('renounceCaps', { caps }) }
119
+ absorbRenounce (record) { return this._h('absorbRenounce', { record }) }
120
+ adoptActa (acta) { return this._h('adoptActa', { acta }) }
121
+ /** Une ESTE perfil a la cuenta de otro (solo si nació para eso: `createProfile(n, { forVault: true })`). */
122
+ joinProfile (acta) { return this._h('joinProfile', { acta }) }
123
+ /** Marca este perfil como nacido para ADOPTAR la cuenta de otro (camino A: lo usa la bóveda). */
124
+ prepareForAdoption () { return this._h('prepareForAdoption') }
125
+ /** MI tarjeta de perfil: lo mínimo que un contacto necesita para cifrarme a todos mis
126
+ * dispositivos (perfil, versión y llaves). Sin etiquetas ni permisos. */
127
+ profileCard () { return this._h('profileCard') }
128
+ /** Guarda la tarjeta de otra persona en su ficha de contacto (verificándola). */
129
+ adoptPeerCard (card) { return this._h('adoptPeerCard', { card }) }
130
+ /** La clave de contenido del perfil, abierta con la llave de cifrado de este dispositivo. */
131
+ contentKey () { return this._h('contentKey') }
132
+ /** Cifra con la clave de contenido del perfil (la privada de cifrado no sale del vault). */
133
+ sealContent (plaintext) { return this._h('sealContent', { plaintext }) }
134
+ /** Abre un sobre de contenido con el llavero del perfil. */
135
+ openContent (envelope) { return this._h('openContent', { envelope }) }
136
+ /** Rota la clave de contenido (corta el acceso al contenido FUTURO de quien ya no está). */
137
+ rotateContentKey () { return this._h('rotateContentKey') }
138
+ // Emparejar ESTE dispositivo con el vault del usuario (Fase 1)
139
+ enrollDevice (qr, { label = '', join = 'current' } = {}) { return this._h('vaultPair', { qr, label, join }) }
140
+ vaultStatus () { return this._h('vaultStatus') }
141
+ unpairDevice () { return this._h('vaultUnpair') }
142
+ vaultSign (payload) { return this._h('vaultSign', { payload }) }
143
+ vaultStore (method, args) { return this._h('vaultStore', { method, args }) }
144
+ listVaultDevices () { return this._h('listVaultDevices') }
145
+ getVaultCert () { return this._h('getVaultCert') }
146
+ vaultApprovals (op, args) { return this._h('vaultApprovals', { op, ...(args || {}) }) }
147
+ /** Los pedidos de TODAS las cuentas que aprueban, sin cambiar la activa. */
148
+ vaultApprovalsAll (args) { return this._h('vaultApprovalsAll', args || {}) }
149
+ onVault (handler) { return this.on('vault', handler) }
150
+ // Multi-perfil por dispositivo (crear/cambiar reinicializa con el nuevo perfil activo).
151
+ listProfiles () { return this._h('listProfiles') }
152
+ currentProfile () { return this._h('currentProfile') }
153
+ createProfile (name, { forVault = false } = {}) { return this._h('createProfile', { name, forVault }) }
154
+ switchProfile (id) { return this._h('switchProfile', { id }) }
155
+ renameProfile (id, name) { return this._h('renameProfile', { id, name }) }
156
+ deleteProfile (id) { return this._h('deleteProfile', { id }) }
157
+ mergeEndorsements (subject, endorsements, askerPubkey) {
158
+ return this._h('mergeEndorsements', { subject, endorsements, askerPubkey })
159
+ }
160
+ getRatingsForSubject (subject) { return this._h('getRatingsForSubject', { subject }) }
161
+ recordQuery (askerPubkey, subject) { return this._h('recordQuery', { askerPubkey, subject }) }
162
+ async setMyNickname (nickname) {
163
+ const result = await this._h('setMyNickname', { nickname })
164
+ return result
165
+ }
166
+ async updateMe (patch) { return this._h('updateMe', { patch }) }
167
+ getMe () { return this._h('getMe') }
168
+ publicMe () { return this._h('publicMe') }
169
+ /** Cómo fue el último empujón del perfil a la bóveda: `{ ok, at, error }`. */
170
+ profilePushState () { return this._h('profilePushState') }
171
+ getEncryptionPubkey () { return this._h('getEncryptionPubkey') }
172
+ encrypt (recipients, plaintext) { return this._h('encrypt', { recipients, plaintext }) }
173
+ decrypt (senderEncryptionPubkey, myToken, envelope) {
174
+ return this._h('decrypt', { senderEncryptionPubkey, myToken, envelope })
175
+ }
176
+ exportIdentity () { return this._h('exportIdentity') }
177
+ async importIdentity (blob) { return this._h('importIdentity', blob || {}) }
178
+
179
+ // Sync (Google Drive) no disponible headless — los handlers responden acorde.
180
+ syncConnect (clientId) { return this._h('syncConnect', { clientId }) }
181
+ syncDisconnect () { return this._h('syncDisconnect') }
182
+ syncUnlock (passphrase) { return this._h('syncUnlock', { passphrase }) }
183
+ syncLock () { return this._h('syncLock') }
184
+ syncStatus () { return this._h('syncStatus') }
185
+ syncNow () { return this._h('syncNow') }
186
+
187
+ onSync (handler) { return this.on('sync', handler) }
188
+ on (event, handler) {
189
+ if (!this._listeners.has(event)) this._listeners.set(event, new Set())
190
+ this._listeners.get(event).add(handler)
191
+ return () => this._listeners.get(event)?.delete(handler)
192
+ }
193
+ _emit (event, payload) {
194
+ const set = this._listeners.get(event)
195
+ if (!set) return
196
+ for (const h of set) { try { h(payload) } catch (e) { console.error(e) } }
197
+ }
198
+ }
199
+
200
+ export default IdentityFacade
package/vault/index.html CHANGED
@@ -155,7 +155,17 @@
155
155
  <script type="importmap">
156
156
  { "imports": {
157
157
  "@dotrino/proxy-client": "./vendor/proxy-client/index.js",
158
+ "@dotrino/proxy-client/sealing": "./vendor/proxy-client/sealing.js",
158
159
  "@dotrino/vault": "./vendor/vault/index.js",
160
+ "@dotrino/vault/browser": "./vendor/vault/browser.js",
161
+ "@dotrino/compat": "./vendor/compat/index.js",
162
+ "@dotrino/compat/ranges": "./vendor/compat/ranges.js",
163
+ "@dotrino/roadmap": "./vendor/roadmap/index.js",
164
+ "@dotrino/passmanager": "./vendor/passmanager/index.js",
165
+ "@dotrino/passmanager/sealed": "./vendor/passmanager/sealed/index.js",
166
+ "@dotrino/store/core": "./vendor/store/core.js",
167
+ "@dotrino/identity/facade": "./facade.js",
168
+ "@dotrino/identity/machine": "./machine.js",
159
169
  "@dotrino/vault/password-logins": "./vendor/vault/passwordLogins.js",
160
170
  "@dotrino/vault/login-client": "./vendor/vault/loginClient.js",
161
171
  "@dotrino/opaque": "./vendor/opaque/src/index.js",
@@ -0,0 +1,95 @@
1
+ /**
2
+ * QUÉ MÁQUINA ES CADA APARATO: dónde corre, y nada más.
3
+ *
4
+ * En la lista de aparatos de la bóveda solo había un ID (`AB12-CD34`), una etiqueta que pone
5
+ * el propio aparato —muchas veces el apodo del usuario, igual en todos— y la fecha de alta.
6
+ * Con tres navegadores y dos agentes no había forma de saber cuál era cuál (dueño,
7
+ * 2026-10-15). Esto es lo que lo distingue a simple vista:
8
+ *
9
+ * · `kind` — qué es: `node` (un agente o un comando), `browser`, `extension`, `ios`, `android`.
10
+ * · `os` — el sistema donde corre (`Linux 7.0.0`, `Windows`, `macOS`, `Android 14`).
11
+ * · `name` — en Node, el hostname; en un navegador, el navegador (`Chrome`). Un navegador no
12
+ * sabe el nombre de la máquina, y lo que no se sabe no se inventa.
13
+ *
14
+ * Lo DICE el aparato y va dentro de lo que firma, así que nadie más puede escribirlo por él.
15
+ * Tampoco es una prueba de nada: un aparato puede llamarse como quiera. Sirve para reconocerlo,
16
+ * no para decidir — ninguna puerta mira esto.
17
+ *
18
+ * Una sola pieza para las dos puntas: lo limpia igual quien lo manda y quien lo guarda (el acta,
19
+ * la bóveda), para que un campo de más o una cadena de un megabyte no entren por ningún lado.
20
+ */
21
+
22
+ export const MACHINE_KINDS = ['node', 'browser', 'extension', 'ios', 'android']
23
+
24
+ const clip = (s, n) => (typeof s === 'string' ? s.replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, n) : '')
25
+
26
+ /**
27
+ * Lo deja en la forma de siempre, o `null` si no dice nada que valga. Nunca lanza: un aparato
28
+ * viejo o raro no puede tumbar un emparejamiento por cómo describe su máquina.
29
+ * @param {any} raw
30
+ * @returns {{ kind: string, os: string, name: string } | null}
31
+ */
32
+ export function cleanMachine (raw) {
33
+ if (!raw || typeof raw !== 'object') return null
34
+ const kind = MACHINE_KINDS.includes(raw.kind) ? raw.kind : ''
35
+ const os = clip(raw.os, 40)
36
+ const name = clip(raw.name, 64)
37
+ if (!kind && !os && !name) return null
38
+ return { kind, os, name }
39
+ }
40
+
41
+ /** Una línea para enseñarlo: `node · Linux 7.0.0 · mi-pc`. Vacía si no hay nada. */
42
+ export function machineLine (m) {
43
+ const c = cleanMachine(m)
44
+ return c ? [c.kind, c.os, c.name].filter(Boolean).join(' · ') : ''
45
+ }
46
+
47
+ /** ¿Dicen lo mismo? Para no reescribir un archivo cada vez que un aparato repite lo de siempre. */
48
+ export const sameMachine = (a, b) => machineLine(a) === machineLine(b)
49
+
50
+ /** @type {Array<[RegExp, (v: string) => string]>} */
51
+ const UA_OS = [
52
+ [/Android ([\d.]+)/, (v) => 'Android ' + v.split('.')[0]],
53
+ [/(?:iPhone|iPad|iPod).*? OS (\d+)/, (v) => 'iOS ' + v],
54
+ [/Windows NT/, () => 'Windows'],
55
+ [/CrOS/, () => 'ChromeOS'],
56
+ [/Mac OS X/, () => 'macOS'],
57
+ [/Linux/, () => 'Linux']
58
+ ]
59
+ /** @type {Array<[RegExp, string]>} */
60
+ const UA_BROWSER = [
61
+ [/Edg\//, 'Edge'], [/OPR\//, 'Opera'], [/Firefox\//, 'Firefox'], [/SamsungBrowser\//, 'Samsung Internet'],
62
+ [/Chrome\//, 'Chrome'], [/Safari\//, 'Safari']
63
+ ]
64
+
65
+ /**
66
+ * La máquina de ESTE navegador. `env` se inyecta en las pruebas; en la página es `globalThis`.
67
+ * Lo nativo se reconoce por el puente de `dotrino-native` (`DotrinoNative.platform()`), la
68
+ * extensión por tener `chrome.runtime.id`, y el resto es un navegador.
69
+ * @param {any} [env]
70
+ */
71
+ export function browserMachine (env = globalThis) {
72
+ /** @type {any} */
73
+ const nav = env?.navigator || {}
74
+ const ua = String(nav.userAgent || '')
75
+ // Node también tiene `navigator` (`Node.js/22`): no es un navegador, y su máquina la dice
76
+ // quien sabe mirarla (`selfMachine` de `@dotrino/vault`). Aquí no se inventa.
77
+ if (env?.process?.versions?.node || !ua || ua.startsWith('Node.js')) return null
78
+ let kind = 'browser'
79
+ try {
80
+ const p = env?.DotrinoNative?.platform?.()
81
+ if (p === 'ios' || p === 'android') kind = p
82
+ else if (env?.chrome?.runtime?.id) kind = 'extension'
83
+ } catch (_) { /* un puente roto no cambia qué navegador es: se queda en `browser` */ }
84
+ let os = ''
85
+ for (const [re, fmt] of UA_OS) { const m = ua.match(re); if (m) { os = fmt(m[1] || ''); break } }
86
+ // iPadOS se presenta como un Mac de escritorio: lo delata la pantalla táctil.
87
+ if (os === 'macOS' && nav.maxTouchPoints > 1) os = 'iPadOS'
88
+ let name = ''
89
+ if (kind === 'browser' || kind === 'extension') {
90
+ for (const [re, n] of UA_BROWSER) if (re.test(ua)) { name = n; break }
91
+ }
92
+ return cleanMachine({ kind, os, name })
93
+ }
94
+
95
+ export default { MACHINE_KINDS, cleanMachine, machineLine, sameMachine, browserMachine }
package/vault/remote.js CHANGED
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import { makeDeviceKey, signWithDevice, verifyDeviceSig, makePairingCode, commitCode, pubkeyId } from './capabilities.js'
17
17
  import { checkVaultReply } from './acta.js'
18
+ import { cleanMachine, browserMachine } from './machine.js'
18
19
 
19
20
  const MSG = {
20
21
  HELLO: 'vault.hello',
@@ -130,7 +131,7 @@ async function askVault (client, qr) {
130
131
  * @param {number} [opts.approveTimeoutMs] Espera de la aprobación humana (def 3 min).
131
132
  * @returns {Promise<{device, cert, master:string, proxy:string, deviceId:string, account:string}>}
132
133
  */
133
- export async function enrollDevice ({ qr, device, onChallenge, label = '', continuity = null, encPub = null, approveTimeoutMs = 180000, intent = 'join', profileId = null, onAdopt = null } = {}) {
134
+ export async function enrollDevice ({ qr, device, onChallenge, label = '', continuity = null, encPub = null, approveTimeoutMs = 180000, intent = 'join', profileId = null, onAdopt = null, machine = undefined } = {}) {
134
135
  if (!qr?.sn || !(qr.iss || qr.conn)) throw new Error('invalid qr: missing vault or nonce')
135
136
  const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
136
137
  const client = new WebSocketProxyClient({ url: qr.proxy || 'wss://proxy.dotrino.com', enableWebRTC: false, autoReconnect: false })
@@ -162,12 +163,16 @@ export async function enrollDevice ({ qr, device, onChallenge, label = '', conti
162
163
  // rechaza el que no coincida con el modo con el que ella abrió el emparejamiento. Así
163
164
  // ninguno de los dos puede hacer, a mitad de camino, algo distinto de lo que el humano
164
165
  // vio anunciado en las dos pantallas.
166
+ // DÓNDE CORRE (`machine.js`), para que la bóveda lo reconozca en su lista. Quien llama
167
+ // puede pasarlo (lo sabe mejor); si no, se mira el navegador. `null` es no decir nada.
168
+ const maquina = cleanMachine(machine === undefined ? browserMachine() : machine)
165
169
  const adopting = intent === 'adopt'
166
170
  if (adopting && typeof onAdopt !== 'function') throw new Error('enrollDevice(adopt): missing onAdopt')
167
171
  const data = {
168
172
  op: 'enroll', dpub: dev.publickey, token: qr.token || qr.sn, sn: qr.sn, commit, label, ts: Date.now(), intent,
169
173
  ...(adopting && profileId ? { profileId } : {}),
170
- ...(continuity ? { continuity } : {}), ...(encPub ? { encPub } : {})
174
+ ...(continuity ? { continuity } : {}), ...(encPub ? { encPub } : {}),
175
+ ...(maquina ? { machine: maquina } : {})
171
176
  }
172
177
  // `dev.sign`: la llave vive fuera (el chip del teléfono) y solo se le pide la firma.
173
178
  const { signature } = await signWithDevice({ privateJwk: dev.privateJwk, privateKey: dev.privateKey, publickey: dev.publickey, sign: dev.sign, data })
@@ -276,14 +281,56 @@ function vaultError (p) {
276
281
  return e
277
282
  }
278
283
 
279
- async function vaultRpc ({ master, proxy, device, cert, acta = null, sendType, okType, data, onRevoked, timeoutMs = 15000 }) {
280
- if (!master || !proxy || !(device?.privateJwk || device?.privateKey || typeof device?.sign === 'function') || !cert) throw new Error('missing pairing data')
284
+ /**
285
+ * LA BÓVEDA DE ESTE MISMO NAVEGADOR, sin dar la vuelta por el proxio.
286
+ *
287
+ * Cuando esta pestaña o esta extensión ES la bóveda (`@dotrino/vault`, `startBrowserVault`),
288
+ * lo que el aparato le pide a su bóveda —administrar, aprobar, firmar— va directo al núcleo
289
+ * que corre aquí. Es el mostrador local del binario en el navegador: el MISMO protocolo, la
290
+ * misma firma y el mismo certificado, comprobados igual. Alcanzar el atajo no autoriza nada.
291
+ *
292
+ * `open(onReply)` → `{ send(payload), close() }`. Lo registra quien levanta la bóveda.
293
+ */
294
+ const localVaults = new Map()
295
+ export function registerLocalVault (master, open) {
296
+ if (typeof master !== 'string' || typeof open !== 'function') throw new Error('registerLocalVault: master and open are required')
297
+ localVaults.set(master, open)
298
+ return () => { if (localVaults.get(master) === open) localVaults.delete(master) }
299
+ }
300
+
301
+ /** Un cliente con la forma del del proxio que habla con la bóveda local. */
302
+ function localClient (open) {
303
+ const handlers = new Set()
304
+ const chan = open((reply) => { for (const h of [...handlers]) { try { h('local', reply) } catch (_) {} } })
305
+ return {
306
+ connect: async () => {},
307
+ identifyAs: async () => {},
308
+ on (ev, fn) {
309
+ if (ev !== 'message') return () => {}
310
+ handlers.add(fn)
311
+ return () => handlers.delete(fn)
312
+ },
313
+ sendByPubkey (_to, payload) { chan.send(payload) },
314
+ close () { handlers.clear(); chan.close() }
315
+ }
316
+ }
317
+
318
+ /** El cliente para hablar con `master`: el local si esa bóveda corre aquí, y si no el del proxio. */
319
+ async function clientFor (proxy, master) {
320
+ const open = master ? localVaults.get(master) : null
321
+ if (open) return localClient(open)
281
322
  const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
282
323
  const client = new WebSocketProxyClient({ url: proxy, enableWebRTC: false, autoReconnect: false })
283
324
  await client.connect()
325
+ return client
326
+ }
327
+
328
+ async function vaultRpc ({ master, proxy, device, cert, acta = null, sendType, okType, data, onRevoked, timeoutMs = 15000 }) {
329
+ if (!master || !proxy || !(device?.privateJwk || device?.privateKey || typeof device?.sign === 'function') || !cert) throw new Error('missing pairing data')
330
+ const client = await clientFor(proxy, master)
284
331
  try {
285
332
  try { await identifyAsDevice(client, device, { cert, acta }) } catch (_) { /* sin identify seguimos: solo perdemos la cola */ }
286
- const signed = { ...data, publickey: device.publickey, ts: Date.now() }
333
+ const signed = { ...data, publickey: device.publickey, ts: Date.now(), ...(cleanMachine(device.machine) ? { machine: cleanMachine(device.machine) } : {}) }
287
334
  const { signature } = await signWithDevice({ privateJwk: device.privateJwk, privateKey: device.privateKey, publickey: device.publickey, sign: device.sign, data: signed })
288
335
  const pending = new Promise((resolve, reject) => {
289
336
  let graceTimer = null
@@ -331,14 +378,12 @@ async function vaultRpc ({ master, proxy, device, cert, acta = null, sendType, o
331
378
  */
332
379
  export async function checkMembership ({ master, proxy, device, onRevoked, timeoutMs = 12000 } = {}) {
333
380
  if (!master || !proxy || !(device?.privateJwk || device?.privateKey || typeof device?.sign === 'function')) throw new Error('missing device data')
334
- const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
335
- const client = new WebSocketProxyClient({ url: proxy, enableWebRTC: false, autoReconnect: false })
336
- await client.connect()
381
+ const client = await clientFor(proxy, master)
337
382
  try {
338
383
  // Identificarse hace además que el proxy entregue lo ENCOLADO (un aviso de cuando
339
384
  // estaba apagado, si todavía está dentro de las 24 h).
340
385
  try { await identifyAsDevice(client, device) } catch (_) {}
341
- const data = { op: 'check', publickey: device.publickey, ts: Date.now() }
386
+ const data = { op: 'check', publickey: device.publickey, ts: Date.now(), ...(cleanMachine(device.machine) ? { machine: cleanMachine(device.machine) } : {}) }
342
387
  const { signature } = await signWithDevice({ privateJwk: device.privateJwk, privateKey: device.privateKey, publickey: device.publickey, sign: device.sign, data })
343
388
  const res = await new Promise((resolve) => {
344
389
  let settled = false
package/vault/vault.js CHANGED
@@ -185,9 +185,9 @@ import { useBackend as usePeerBackend } from './peerStore.js'
185
185
  core.onSyncStatus((p) => broadcast('sync', p))
186
186
  core.onVaultEvent((p) => broadcast('vault', p))
187
187
 
188
- // ---- Modo SELF: este navegador actúa como bóveda (daemon device-vault) ----
189
- // startDeviceVault convierte la identidad P en CA: atiende enrolamientos y consultas
190
- // de revocación por el proxy. Solo UN iframe por origin es el daemon activo
188
+ // ---- Modo SELF: este navegador actúa como bóveda ----
189
+ // Corre la MISMA bóveda que el binario (`startBrowserVault`): emparejar, firmar, cajones,
190
+ // aprobaciones, consola remota, inicios de sesión. Solo UN iframe por origin es el activo
191
191
  // (navigator.locks): la pestaña VISIBLE sostiene el lock; al pasar a background lo
192
192
  // libera y otra pestaña visible lo toma. Así varias apps abiertas no compiten.
193
193
  const SELF_FLAG = 'dotrino.self-vault.enabled' // persistido en localStorage (kv)
@@ -208,79 +208,47 @@ import { useBackend as usePeerBackend } from './peerStore.js'
208
208
  return /^wss?:\/\//.test(p || '') ? p : null
209
209
  } catch (_) { return null }
210
210
  })()
211
- let daemon = null // handle de startDeviceVault cuando ESTE iframe es el activo
211
+ let daemon = null // la bóveda (`startBrowserVault`) cuando ESTE iframe es el activo
212
212
  let _lockResolver = null // resolver del callback del lock (libera al resolverlo)
213
213
 
214
- // Adaptador: startDeviceVault exige identity.{me.publickey, signData, signDelegation,
215
- // listDelegations, revokeDelegation}; el core los expone vía handlers + getter me.
216
- // `admitMember`/`profileActa` son opcionales para el mostrador de enrolamiento, pero sin
217
- // ellos aprobar emitiría el cert SIN meter al dispositivo en el acta: se pasan también.
218
- const selfIdentity = {
219
- get me () { return core.me },
220
- signData: (data) => handlers.signData({ data }),
221
- signDelegation: (sub, scope, opts) => handlers.signDelegation({ sub, scope, ...(opts || {}) }),
222
- listDelegations: () => handlers.listDelegations({}),
223
- revokeDelegation: (nonce) => handlers.revokeDelegation({ nonce }),
224
- revokeDevice: (sub) => handlers.revokeDevice({ sub }),
225
- admitMember: (m) => handlers.admitMember(m),
226
- profileActa: () => handlers.profileActa({}),
227
- // Camino A (`mode: 'adopt'`): la bóveda se queda con la cuenta que trae el aparato,
228
- // y para eso tiene que poder ENTRAR en su acta. Sin esto, adoptar fallaba con un
229
- // «no es una función» en vez de con un error del protocolo.
230
- joinProfile: (acta) => handlers.joinProfile({ acta })
231
- }
214
+ // La dirección `nombre@AB12-CD34-EF56` sale del acta de la cuenta.
215
+ const selfIdentity = { profileActa: () => handlers.profileActa({}) }
232
216
 
233
217
  /**
234
- * EL ESCRITORIO DE LOS INICIOS DE SESIÓN CON CONTRASEÑA, con el almacén de este navegador.
235
- *
236
- * La bóveda no decide dónde se guarda nada: en el navegador no hay archivos, así que el
237
- * estado lo pone quien la monta —esto—. Sin escritorio la pestaña contesta
238
- * `logins-unavailable`, que es distinto de «contraseña incorrecta» y por eso se ve.
239
- *
240
- * Cuelga del PERFIL ACTIVO: los inicios de sesión son de una cuenta, no de este
241
- * navegador, y dos cuentas en el mismo equipo no comparten usuarios.
242
- *
243
- * Se carga solo aquí, y no arriba, porque arrastra el OPAQUE en WASM (~270 KB): el
244
- * iframe lo cargan las ~30 apps del ecosistema y solo esta pestaña es bóveda.
245
- *
246
- * Lo que guarda aguanta lo mismo que el disco del daemon: con una copia de este
247
- * `localStorage` se pueden probar contraseñas sin límite contra el registro de OPAQUE.
248
- * Lo único que aguanta ahí es que la contraseña sea larga (`temporary-access.md` §4).
218
+ * LOS INICIOS DE SESIÓN QUE ESTA PESTAÑA GUARDABA ANTES de correr la bóveda entera. Ahora
219
+ * viven en el almacén de la bóveda (IndexedDB cifrado, uno por perfil); lo de antes se le
220
+ * pasa una vez y se borra de aquí. Si está y no parsea, REVIENTA a propósito: tomarlo por
221
+ * vacío perdería los inicios de sesión de la cuenta sin que nadie se enterara.
249
222
  */
250
- async function makeLoginDesk () {
251
- const { createLoginDesk } = await import('@dotrino/vault/password-logins')
223
+ async function legacyLogins () {
252
224
  const { id: pid } = await handlers.currentProfile()
253
- if (!pid) throw new Error('no profile: password logins belong to an account')
225
+ if (!pid) return null
254
226
  const key = `dotrino.identity.p.${pid}.self-vault.logins`
255
- return createLoginDesk({
256
- load: () => {
257
- const raw = kv.getItem(key)
258
- // Si está y no parsea, REVIENTA aquí a propósito: devolver `null` haría nacer el
259
- // escritorio vacío, y el primer guardado se llevaría por delante todos los inicios
260
- // de sesión de esta cuenta sin que nadie se enterara.
261
- return raw ? JSON.parse(raw) : null
262
- },
263
- save: (s) => kv.setItem(key, JSON.stringify(s))
264
- })
227
+ const raw = kv.getItem(key)
228
+ if (!raw) return null
229
+ return { state: JSON.parse(raw), drop: () => kv.removeItem(key) }
265
230
  }
266
231
 
267
232
  async function startSelfDaemon () {
268
233
  if (daemon) return
269
234
  try {
270
- // Import dinámico: aísla fallos del vendor del arranque del vault (cargado por
271
- // todas las apps). El import map de index.html resuelve @dotrino/vault.
272
- const { startDeviceVault } = await import('@dotrino/vault')
273
- daemon = await startDeviceVault(selfIdentity, {
235
+ // LA MISMA BÓVEDA QUE EL BINARIO (`@dotrino/vault/browser`): el núcleo entero, con el
236
+ // disco en IndexedDB. Import dinámico: el iframe lo cargan las ~30 apps y solo esta
237
+ // pestaña es bóveda; además aísla un fallo del vendor del arranque de la identidad.
238
+ const { startBrowserVault } = await import('@dotrino/vault/browser')
239
+ daemon = await startBrowserVault({
240
+ core,
274
241
  ...(selfProxyUrl ? { proxyUrl: selfProxyUrl } : {}),
275
- logins: await makeLoginDesk()
242
+ legacyLogins,
243
+ onPendingChange: () => broadcast('selfVault', { pending: daemon ? daemon.listPending() : [] }),
244
+ log: (...a) => console.log(...a)
276
245
  })
277
- daemon.onPendingChange(() => broadcast('selfVault', { pending: daemon.listPending() }))
278
246
  broadcast('selfVault', { running: true })
279
247
  } catch (e) { daemon = null; broadcast('selfVault', { error: e?.message || String(e) }) }
280
248
  }
281
249
  function stopSelfDaemon () {
282
250
  if (!daemon) return
283
- try { daemon.close() } catch {}
251
+ try { daemon.close()?.catch?.(() => {}) } catch {}
284
252
  daemon = null
285
253
  broadcast('selfVault', { running: false })
286
254
  }
@@ -340,6 +308,9 @@ import { useBackend as usePeerBackend } from './peerStore.js'
340
308
  // agentes siempre, sin depender de qué pestaña sostenga el lock del daemon.
341
309
  listVaultDevices: async () => {
342
310
  if (kv.getItem(SELF_FLAG) !== '1') return handlers.listVaultDevices({})
311
+ // Con la bóveda encendida en ESTA pestaña, la lista sale de ella (por el camino
312
+ // local): trae lo mismo que la del binario. Sin ella encendida, se lee lo guardado.
313
+ if (daemon) return handlers.listVaultDevices({})
343
314
  const { issued, revoked } = await handlers.listDelegations({})
344
315
  const now = Date.now()
345
316
  const bySub = new Map()
@@ -491,7 +462,7 @@ import { useBackend as usePeerBackend } from './peerStore.js'
491
462
  selfVaultLoginRemove: async ({ user } = {}) => {
492
463
  const d = pidaDaemon()
493
464
  const fila = d.listLogins().find((x) => x.user === user)
494
- const r = d.removeLogin({ user })
465
+ const r = await d.removeLogin({ user })
495
466
  if (r?.ok && fila?.pub) {
496
467
  try { await handlers.revokeDevice({ sub: fila.pub }) } catch (e) {
497
468
  throw Object.assign(new Error(`the login is gone but its key is still in the record: ${e.message}`), { code: 'revoke-failed' })
@@ -0,0 +1,3 @@
1
+ Copia vendorizada de @dotrino/compat@0.4.0 (dotrino-compat/src/{advisory,index,ranges}.js).
2
+ NO se edita a mano: la escribe `node vendor.mjs` y la vigila test/vendor-up-to-date.test.mjs.
3
+ Qué versión es cada pieza y si pueden hablar (CONVENCIONES §14).