@dotrino/identity 0.78.0 → 0.80.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE CHANGED
File without changes
package/README.md CHANGED
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/identity",
3
- "version": "0.78.0",
3
+ "version": "0.80.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.d.ts CHANGED
File without changes
package/src/index.js CHANGED
@@ -571,6 +571,14 @@ export class Identity {
571
571
  async getMe () { return this._call('getMe') }
572
572
  /** Subconjunto PÚBLICO de tu perfil (solo lo visible) — para compartir/publicar. */
573
573
  async publicMe () { return this._call('publicMe') }
574
+ /**
575
+ * CÓMO FUE EL ÚLTIMO EMPUJÓN del perfil a la bóveda: `{ ok, at, error }`.
576
+ *
577
+ * El núcleo lo lleva desde la fase 3 y aquí no estaba, así que una app no podía
578
+ * preguntarlo — que es justo para lo que se guardó: poder decir «esto no se guardó» en
579
+ * vez de enseñar tan tranquila un perfil que solo vive en este aparato.
580
+ */
581
+ async profilePushState () { return this._call('profilePushState') }
574
582
 
575
583
  /** Pubkey ECDH (JWK string) propio para encripción. */
576
584
  async getEncryptionPubkey () {
package/src/node.js CHANGED
@@ -269,6 +269,8 @@ export class Identity {
269
269
  async updateMe (patch) { return this._h('updateMe', { patch }) }
270
270
  getMe () { return this._h('getMe') }
271
271
  publicMe () { return this._h('publicMe') }
272
+ /** Cómo fue el último empujón del perfil a la bóveda: `{ ok, at, error }`. */
273
+ profilePushState () { return this._h('profilePushState') }
272
274
  getEncryptionPubkey () { return this._h('getEncryptionPubkey') }
273
275
  encrypt (recipients, plaintext) { return this._h('encrypt', { recipients, plaintext }) }
274
276
  decrypt (senderEncryptionPubkey, myToken, envelope) {
package/vault/CNAME CHANGED
File without changes
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/avatar.js CHANGED
File without changes
File without changes
package/vault/content.js CHANGED
File without changes
package/vault/core.js CHANGED
@@ -124,6 +124,30 @@ const STD_FIELD_CAPS = [
124
124
  // (los demás campos estándar se comparten salvo que su flag sea false).
125
125
  const STD_FIELDS_SENSITIVE = new Set(['telefono', 'direccion'])
126
126
 
127
+ /**
128
+ * QUÉ CLASE ES CADA DATO DEL PERFIL: `public` o `private`
129
+ * (`dotrino-vault/docs/datos-del-perfil.md` §2).
130
+ *
131
+ * La regla no es nueva —es la que ya decidía `publicMe()`: lo que marcaste visible es lo
132
+ * que ve quien pregunta desde fuera—. Lo que cambia es que ahora decide **cómo se guarda**:
133
+ * en claro o en sobre. Por eso vive aquí, exportada y a nivel de módulo, en vez de escondida
134
+ * dentro de una función: es política y hay que poder mirarla y probarla.
135
+ *
136
+ * Sensibles (teléfono, dirección) OCULTOS salvo que su marca diga que sí, explícitamente.
137
+ * El resto se comparte salvo que digas que no.
138
+ */
139
+ export function profileFieldClasses (m = {}) {
140
+ const out = {}
141
+ const clase = (esPublico) => (esPublico ? 'public' : 'private')
142
+ if (m.nickname) out.nickname = clase(true)
143
+ if (m.avatar) out.avatar = clase(m.avatarVisible !== false)
144
+ for (const [k] of STD_FIELD_CAPS) {
145
+ if (!m[k]) continue
146
+ out[k] = clase(STD_FIELDS_SENSITIVE.has(k) ? (m[k + 'Visible'] === true) : (m[k + 'Visible'] !== false))
147
+ }
148
+ return out
149
+ }
150
+
127
151
  // Sanea un patch de perfil (avatar/links/fields/nickname + campos estándar). Cada link/field
128
152
  // lleva `visible` (oculto = no se comparte). Caps de tamaño para no inflar el `me`. Los ids los pone la UI.
129
153
  function sanitizeProfilePatch (patch = {}) {
@@ -1099,15 +1123,118 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1099
1123
  // store). Al editar aquí se EMPUJA; al arrancar se JALA y gana el más nuevo
1100
1124
  // (updatedAt). Las llaves (publickey/encryptionPubkey) son POR dispositivo y
1101
1125
  // nunca se sincronizan. Todo best-effort: sin vault encendido no molesta.
1126
+ /**
1127
+ * QUÉ ES PÚBLICO Y QUÉ ES PRIVADO, en un solo sitio.
1128
+ *
1129
+ * Es la misma regla que ya decidía `publicMe()`: lo que marcaste visible es lo que ve
1130
+ * quien pregunta desde fuera. Lo que cambia es que ahora esa marca decide **cómo se
1131
+ * guarda** —en claro o en sobre— y no solo qué se enseña (`docs/datos-del-perfil.md` §2).
1132
+ *
1133
+ * @returns {Array<{key:string, value:string, cls:'public'|'private'}>}
1134
+ */
1135
+ function profileFields (m) {
1136
+ const out = []
1137
+ const add = (key, value, esPublico) => {
1138
+ if (typeof value !== 'string' || !value) return
1139
+ out.push({ key, value, cls: esPublico ? 'public' : 'private' })
1140
+ }
1141
+ // La clase la decide `profileFieldClasses`, que está exportada y probada: tener la
1142
+ // regla en dos sitios es como acaban divergiendo lo que se enseña y lo que se guarda.
1143
+ const clases = profileFieldClasses(m)
1144
+ for (const [k, cls] of Object.entries(clases)) add(k, m[k], cls === 'public')
1145
+ // Enlaces y campos libres viajan como UN dato cada lista: son arrays y partirlos por
1146
+ // elemento haría que reordenarlos pareciera media docena de cambios.
1147
+ const visibles = (arr) => (arr || []).filter((x) => x.visible !== false).map(({ visible, ...r }) => r)
1148
+ const ocultos = (arr) => (arr || []).filter((x) => x.visible === false)
1149
+ if (Array.isArray(m.links)) {
1150
+ const v = visibles(m.links); if (v.length) add('links', JSON.stringify(v), true)
1151
+ const o = ocultos(m.links); if (o.length) add('links_private', JSON.stringify(o), false)
1152
+ }
1153
+ if (Array.isArray(m.fields)) {
1154
+ const v = visibles(m.fields); if (v.length) add('fields', JSON.stringify(v), true)
1155
+ const o = ocultos(m.fields); if (o.length) add('fields_private', JSON.stringify(o), false)
1156
+ }
1157
+ return out
1158
+ }
1159
+
1160
+ /**
1161
+ * EMPUJAR EL PERFIL, DATO A DATO Y EN SOBRES (`docs/datos-del-perfil.md`).
1162
+ *
1163
+ * Antes se mandaba el `me` entero por `profileSet`, y eso exigía la bóveda ABIERTA —era
1164
+ * ella quien decidía guardarlo, porque lo veía en claro—. Ahora cada dato viaja como un
1165
+ * sobre que la bóveda no puede leer ni fabricar, así que aceptarlo no es decisión suya y
1166
+ * el candado deja de estorbar. Los públicos van en claro porque no hay a quién sellarlos.
1167
+ *
1168
+ * Solo se manda LO QUE CAMBIÓ: cada escritura estrena generación, y reescribir un dato
1169
+ * que no cambió llenaría el llavero y el histórico de ruido.
1170
+ */
1171
+ async function pushProfileFields (v, device) {
1172
+ const campos = profileFields(me || {})
1173
+ const pendientes = campos.filter((c) => lastPushedFields[c.key] !== c.cls + '\u0000' + c.value)
1174
+ if (!pendientes.length) return
1175
+
1176
+ const privados = pendientes.filter((c) => c.cls === 'private')
1177
+ let destinatarios = null
1178
+ if (privados.length) {
1179
+ destinatarios = await remoteStore({
1180
+ master: v.master, proxy: v.proxy, device, cert: v.cert,
1181
+ method: 'profileRecipients', args: {}, onRevoked: wipeVaultLink
1182
+ })
1183
+ if (!destinatarios?.recoveryPub) {
1184
+ throw new Error('the vault did not say who to seal the profile for')
1185
+ }
1186
+ }
1187
+
1188
+ for (const c of pendientes) {
1189
+ const args = { key: c.key, cls: c.cls }
1190
+ if (c.cls === 'public') {
1191
+ args.value = c.value
1192
+ } else {
1193
+ // Envolver solo necesita PÚBLICAS: por eso esto se puede hacer aquí, en el
1194
+ // navegador, sin que ninguna privada ande suelta.
1195
+ const cek = await Content.makeContentKey()
1196
+ const e = await Content.encryptWithCek({ cek, gen: 0, plaintext: c.value })
1197
+ const wraps = { '#recovery': await Content.wrapForMember({ cek, memberEncPub: destinatarios.recoveryPub }) }
1198
+ for (const m of destinatarios.members || []) {
1199
+ if (m.encPub) wraps[m.pub] = await Content.wrapForMember({ cek, memberEncPub: m.encPub })
1200
+ }
1201
+ args.sobre = { e, wraps }
1202
+ }
1203
+ await remoteStore({ master: v.master, proxy: v.proxy, device, cert: v.cert, method: 'profilePut', args, onRevoked: wipeVaultLink })
1204
+ lastPushedFields[c.key] = c.cls + '\u0000' + c.value
1205
+ }
1206
+ }
1207
+
1208
+ /** Lo último que se consiguió empujar de cada dato, para no reescribir lo que no cambió. */
1209
+ const lastPushedFields = Object.create(null)
1210
+
1102
1211
  let profilePushTimer = null
1212
+ /**
1213
+ * CÓMO FUE EL ÚLTIMO EMPUJÓN. Se guarda para que la UI pueda decir «esto no se guardó»
1214
+ * en vez de enseñar un perfil que solo existe en este aparato. Sin esto, el usuario ve
1215
+ * su cambio en pantalla y cree que está hecho.
1216
+ */
1217
+ // `ok: null` = TODAVÍA NO SE HA EMPUJADO NADA. Nacía en `true`, así que «nunca se
1218
+ // intentó» y «salió bien» se veían igual — un valor por defecto que dice que sí, en la
1219
+ // función que existe justamente para no tragarse el fallo. Quien pregunte tiene que
1220
+ // poder distinguir las tres cosas, y por eso son tres valores y no dos.
1221
+ let lastProfilePush = { ok: null, at: 0, error: null }
1103
1222
  function pushProfileToVault () {
1104
1223
  const v = loadVaultCert(); const device = loadVaultDevice()
1105
1224
  if (!v?.cert || !device) return
1106
1225
  clearTimeout(profilePushTimer)
1107
1226
  profilePushTimer = setTimeout(() => {
1108
- const { publickey, encryptionPubkey, ...content } = me || {}
1109
- remoteStore({ master: v.master, proxy: v.proxy, device, cert: v.cert, method: 'profileSet', args: { me: content }, onRevoked: wipeVaultLink })
1110
- .catch(() => {}) // el vault puede estar apagado; se reintenta en la próxima edición
1227
+ pushProfileFields(v, device)
1228
+ .then(() => { lastProfilePush = { ok: true, at: Date.now(), error: null } })
1229
+ .catch((e) => {
1230
+ // EL FALLO SE VE. Aquí había un `.catch(() => {})` con un comentario que decía
1231
+ // «se reintenta en la próxima edición», y era falso de dos maneras: la próxima
1232
+ // edición se encontraba la bóveda cerrada otra vez, y mientras tanto el aparato
1233
+ // se quedaba con datos que nadie más tenía. Es exactamente lo que produjo el
1234
+ // «edito y no funciona, y cada dispositivo ve algo distinto».
1235
+ lastProfilePush = { ok: false, at: Date.now(), error: e?.message || String(e) }
1236
+ try { console.warn('[identity] the profile change did NOT reach the vault:', lastProfilePush.error) } catch (_) {}
1237
+ })
1111
1238
  }, 800) // debounce: ediciones seguidas = un solo push
1112
1239
  }
1113
1240
  /**
@@ -1152,6 +1279,41 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1152
1279
  * quedaba enseñando un perfil del que ya no era, para siempre, sin que nadie pulsara
1153
1280
  * nada porque no había nada que pulsar.
1154
1281
  */
1282
+ /**
1283
+ * COMPONE EL `me` CON LO QUE SE PUDO ABRIR. Devuelve si cambió algo, para no avisar de
1284
+ * una sincronización que no movió nada.
1285
+ *
1286
+ * `links`/`fields` viajan como UN dato cada lista (y su gemelo privado), así que aquí se
1287
+ * vuelven a juntar con su marca de visibilidad — que es de dónde salió la separación.
1288
+ */
1289
+ function applyPulledProfile (content) {
1290
+ const antes = JSON.stringify(me || {})
1291
+ const next = { ...(me || {}) }
1292
+ const lista = (json, visible) => {
1293
+ try { return (JSON.parse(json) || []).map((x) => ({ ...x, visible })) } catch (_) { return [] }
1294
+ }
1295
+ for (const [k, v] of Object.entries(content)) {
1296
+ if (k === 'links' || k === 'fields' || k === 'links_private' || k === 'fields_private') continue
1297
+ next[k] = v
1298
+ }
1299
+ if (content.links != null || content.links_private != null) {
1300
+ next.links = [...lista(content.links, true), ...lista(content.links_private, false)]
1301
+ }
1302
+ if (content.fields != null || content.fields_private != null) {
1303
+ next.fields = [...lista(content.fields, true), ...lista(content.fields_private, false)]
1304
+ }
1305
+ next.publickey = publickeyJwkStr
1306
+ next.encryptionPubkey = encPublickeyJwkStr
1307
+ if (JSON.stringify(next) === antes) return false
1308
+ me = next
1309
+ saveMe(me)
1310
+ if (typeof next.nickname === 'string') {
1311
+ const list = loadProfiles(); const e = list.find((p) => p.id === currentPid)
1312
+ if (e && e.name !== next.nickname) { e.name = next.nickname; saveProfiles(list) }
1313
+ }
1314
+ return true
1315
+ }
1316
+
1155
1317
  async function pullProfileFromVault () {
1156
1318
  try {
1157
1319
  const v = loadVaultCert(); const device = loadVaultDevice()
@@ -1161,23 +1323,31 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1161
1323
  // lo habían echado. La bóveda contesta «vencido» y no pasa nada; y si además ya no
1162
1324
  // está en el acta, contesta con el aviso firmado y aquí se le borra la cuenta.
1163
1325
  if (!v?.cert || !device) return
1164
- const res = await remoteStore({ master: v.master, proxy: v.proxy, device, cert: v.cert, method: 'profileGet', args: {}, onRevoked: wipeVaultLink })
1165
- const remoteMe = res?.me
1166
- if (!remoteMe) {
1167
- // el vault aún no tiene perfil: sembrar con el local (si tiene contenido)
1326
+ // EL PAQUETE LO ARMA ESTE APARATO (dueño, 2026-09-03). La bóveda entrega los sobres
1327
+ // que tiene; aquí se abren los que nos tocan y se compone el perfil.
1328
+ const b = await remoteStore({ master: v.master, proxy: v.proxy, device, cert: v.cert, method: 'profileBundle', args: {}, onRevoked: wipeVaultLink })
1329
+ const entries = b?.entries || {}
1330
+ if (!Object.keys(entries).length) {
1331
+ // La bóveda aún no tiene perfil: sembrar con el local (si tiene contenido).
1168
1332
  if (me?.nickname || me?.avatar) pushProfileToVault()
1169
1333
  return
1170
1334
  }
1171
- if ((remoteMe.updatedAt || 0) > (me?.updatedAt || 0)) {
1172
- const { publickey, encryptionPubkey, ...content } = remoteMe
1173
- me = { ...(me || {}), ...content, publickey: publickeyJwkStr, encryptionPubkey: encPublickeyJwkStr }
1174
- saveMe(me)
1175
- if (typeof content.nickname === 'string') {
1176
- const list = loadProfiles(); const e = list.find((p) => p.id === currentPid)
1177
- if (e && e.name !== content.nickname) { e.name = content.nickname; saveProfiles(list) }
1335
+ const keyring = (b.wraps || []).map((w) => ({ gen: w.gen, wraps: { [publickeyJwkStr]: w.wrap } }))
1336
+ const content = {}
1337
+ for (const [key, e] of Object.entries(entries)) {
1338
+ try {
1339
+ if (e.cls === 'public') { content[key] = e.pubv; continue }
1340
+ content[key] = await Content.decryptWithKeyring({
1341
+ envelope: e.e, keyring, myPub: publickeyJwkStr, myEncPrivateKey: encKeypair.privateKey
1342
+ })
1343
+ } catch (_) {
1344
+ // SIN ENVOLTURA NO SE INVENTA NADA. Un aparato que entró después de escribirse un
1345
+ // dato no tiene su llave hasta que el dueño abra la bóveda; dejarlo fuera es
1346
+ // correcto, y poner un valor por defecto sería fabricar un perfil falso.
1178
1347
  }
1179
- emitVault({ phase: 'profile-sync', updatedAt: remoteMe.updatedAt })
1180
1348
  }
1349
+ if (!applyPulledProfile(content)) return
1350
+ emitVault({ phase: 'profile-sync' })
1181
1351
  } catch (_) { /* vault apagado: el perfil local sigue mandando */ }
1182
1352
  }
1183
1353
 
@@ -2275,6 +2445,13 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
2275
2445
  return { me: applyMeUpdate(patch || {}) }
2276
2446
  },
2277
2447
  async getMe () { return me },
2448
+ /**
2449
+ * CÓMO FUE EL ÚLTIMO EMPUJÓN del perfil a la bóveda. Existe para que la interfaz pueda
2450
+ * decir «esto no se guardó» en vez de enseñar tan tranquila un perfil que solo vive en
2451
+ * este aparato — que es lo que pasaba cuando el fallo se tragaba.
2452
+ * `{ ok, at, error }`.
2453
+ */
2454
+ async profilePushState () { return lastProfilePush },
2278
2455
  // Subconjunto PÚBLICO del perfil (solo lo marcado visible) — para compartir/publicar.
2279
2456
  async publicMe () {
2280
2457
  const m = me || {}
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>
package/vault/keyid.js CHANGED
File without changes
File without changes
package/vault/remote.js CHANGED
File without changes
package/vault/sync.js CHANGED
File without changes
package/vault/vault.js CHANGED
File without changes
@@ -1 +1,4 @@
1
- 0.11.0
1
+ Copia vendorizada de @dotrino/proxy-client@0.17.0 (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.
File without changes
@@ -1,5 +1,6 @@
1
1
  import { buildSignedChannel, getPublicKeyJwk, signData } from './signature.js'
2
- import { WebRTCManager, RTC_TAG, DEFAULT_ICE_SERVERS } from './webrtc.js'
2
+ import { seal, open, isSealed } from './sealing.js'
3
+ import { WebRTCManager, RTC_TAG, DEFAULT_ICE_SERVERS, loadNodePeerConnection, resolvePeerConnection } from './webrtc.js'
3
4
 
4
5
  /**
5
6
  * Error con un `code` estable.
@@ -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,16 +95,40 @@ 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),
71
- emit: (event, ...args) => this._emit(event, ...args),
98
+ deliverMessage: (from, parsed, meta) => this._deliver(from, parsed, meta),
99
+ emit: (event, ...args) => {
100
+ // UN CANAL QUE SE CAE SE VUELVE A INTENTAR. Sin esto, el «un intento y no más» de
101
+ // `_upgradeDirect` sería para siempre: una desconexión momentánea condenaría a ese
102
+ // destinatario a ir por el proxio el resto de la vida del proceso.
103
+ if (event === 'webrtc_close') this._rtcTried?.delete(args[0])
104
+ this._emit(event, ...args)
105
+ },
72
106
  config: this.iceServers ? { iceServers: this.iceServers } : null
73
107
  }) : null
108
+ // QUIÉN PUEDE HACERTE NEGOCIAR UN CANAL DIRECTO. Sin política, cualquiera que sepa
109
+ // alcanzarte por el proxio — y negociar arranca DTLS/ICE/SCTP, o sea código que parsea
110
+ // red no confiable. En un navegador se vive con ello; en un proceso que guarda llaves,
111
+ // no. Quien monta el cliente lo acota (`acceptDirectFrom`).
112
+ if (this._rtc && typeof options.acceptDirectFrom === 'function') {
113
+ this._rtc.acceptFrom = options.acceptDirectFrom
114
+ }
74
115
  }
75
116
 
76
117
  // ---------- public API ----------
77
118
 
78
119
  get isConnected () { return this._connected }
79
120
 
121
+ /**
122
+ * WEBRTC EN NODE, si hay con qué. Se busca UNA vez al conectar y no en medio de una
123
+ * negociación: importar un paquete es asíncrono y hacerlo tarde metería una espera justo
124
+ * donde no puede haberla. Si no hay implementación, no pasa nada — se sigue por el
125
+ * proxio, que es el escalón que siempre funciona.
126
+ */
127
+ async _prepareWebRTC () {
128
+ if (!this._rtc || resolvePeerConnection()) return
129
+ await loadNodePeerConnection()
130
+ }
131
+
80
132
  connect () {
81
133
  return new Promise((resolve, reject) => {
82
134
  if (this._connected) return resolve(this.token)
@@ -127,6 +179,19 @@ export class WebSocketProxyClient {
127
179
  if (typeof options.autoReconnect === 'boolean') this.autoReconnect = options.autoReconnect
128
180
  if (typeof options.maxReconnectAttempts === 'number') this.maxReconnectAttempts = options.maxReconnectAttempts
129
181
  if (typeof options.reconnectDelay === 'number') this.reconnectDelay = options.reconnectDelay
182
+
183
+ // La configuración de sellado TAMBIÉN se aplica aquí. Casi todas las apps piden el
184
+ // cliente con `getWebSocketProxyClient()`, que es un singleton: si el primero en
185
+ // pedirlo no puso `requireSealed`, el que sí lo pide después se quedaba sin él y
186
+ // sin enterarse — la garantía perdida en silencio, que es la peor forma de
187
+ // perderla.
188
+ if (options.sealing) this.sealing = options.sealing
189
+ if (options.myEncPrivateKey) this.myEncPrivateKey = options.myEncPrivateKey
190
+
191
+ // Se puede ENCENDER, no apagar. Bajar la exigencia en caliente dejaría que
192
+ // cualquier otro módulo de la app la desactivara sin querer, y no hay ningún
193
+ // motivo legítimo para hacerlo a mitad de una sesión.
194
+ if (options.requireSealed === true) this.requireSealed = true
130
195
  }
131
196
 
132
197
  on (event, handler) {
@@ -157,6 +222,41 @@ export class WebSocketProxyClient {
157
222
  }
158
223
  if (proxyTokens.length) {
159
224
  this._sendRaw({ to: proxyTokens, message: messageStr })
225
+ // Y SE INTENTA IR DIRECTO PARA LA PRÓXIMA, sin esperar a nadie.
226
+ this._upgradeDirect(proxyTokens)
227
+ }
228
+ }
229
+
230
+ /**
231
+ * SIEMPRE EL CAMINO MÁS DIRECTO (dueño, 2026-09-03), pero sin pagar por ello.
232
+ *
233
+ * Hablarle a alguien por primera vez sale por el proxio, que es lo que hay AHORA. En
234
+ * paralelo se abre un canal directo, y desde el segundo mensaje `trySend` ya lo prefiere
235
+ * solo — eso no hay que cambiarlo, ya estaba.
236
+ *
237
+ * **No se espera a la negociación.** Bloquear el primer mensaje hasta tener canal directo
238
+ * haría más lento justo el arranque, que es lo que se quiere arreglar: se manda por donde
239
+ * se pueda y la conexión mejora por debajo.
240
+ *
241
+ * Un intento por destinatario y no más: si no salió, casi siempre es que no se puede
242
+ * (dos NAT que no se dejan, un navegador sin permisos) y reintentar en cada mensaje sería
243
+ * quemar CPU y señalización para nada. Si el canal se cae, `webrtc_close` lo desapunta y
244
+ * el siguiente mensaje vuelve a intentarlo.
245
+ */
246
+ _upgradeDirect (tokens) {
247
+ if (!this._rtc) return
248
+ this._rtcTried = this._rtcTried || new Set()
249
+ for (const t of tokens) {
250
+ if (this._rtcTried.has(t) || this._rtc.isOpen(t)) continue
251
+ this._rtcTried.add(t)
252
+ // La implementación se busca AQUÍ, justo antes de negociar, y no al conectar: esto ya
253
+ // corre desatendido, así que la espera no se la come nadie. Lanzarlo desde `connect`
254
+ // metía un microtask de más y descuadraba los tiempos de otras cosas — lo cazaron
255
+ // las pruebas del protocolo, que fallaban solo al correr todas juntas.
256
+ Promise.resolve()
257
+ .then(() => this._prepareWebRTC())
258
+ .then(() => this._rtc.connect(t))
259
+ .catch(() => {}) // no poder ir directo no es un fallo: es el caso normal en internet
160
260
  }
161
261
  }
162
262
 
@@ -265,7 +365,67 @@ export class WebSocketProxyClient {
265
365
  * incorrecto (reinicia negociaciones imposibles y muestra movimientos fuera de
266
366
  * contexto). NO lo uses para mensajes de chat, que sí quieren esperar.
267
367
  */
368
+ /**
369
+ * Seal a payload towards a peer's encryption key and send it. This is what an app
370
+ * should use for anything that is not meant for the proxy's eyes.
371
+ */
372
+ async sendSealed (toPubkeys, payload, { peerEncPub, ...opts } = {}) {
373
+ if (this.sealing) {
374
+ this._sendByPubkeyRaw(toPubkeys, await this.sealing.seal(payload, peerEncPub), opts)
375
+ return
376
+ }
377
+ if (!peerEncPub) throw Object.assign(new Error('sendSealed: missing peerEncPub'), { code: 'unsealed' })
378
+ this._sendByPubkeyRaw(toPubkeys, await seal(payload, peerEncPub), opts)
379
+ }
380
+
381
+ _isSealed (msg) {
382
+ return this.sealing ? this.sealing.isSealed(msg) : isSealed(msg)
383
+ }
384
+
268
385
  sendByPubkey (toPubkeys, payload, opts = {}) {
386
+ if (this.requireSealed && !this._isSealed(payload)) {
387
+ throw Object.assign(
388
+ new Error('requireSealed: refusing to send a directed message in the clear — use sendSealed()'),
389
+ { code: 'unsealed' })
390
+ }
391
+ this._sendByPubkeyRaw(toPubkeys, payload, opts)
392
+ }
393
+
394
+ /**
395
+ * Hands a message to the app, opening it first when it is sealed.
396
+ *
397
+ * With `requireSealed`, anything that arrives in the clear is DROPPED and reported
398
+ * as `{ type: 'unsealed' }`. Sealing on the way out is not enough on its own: if the
399
+ * receiving end still accepts plaintext, sending it that way bypasses the sealing
400
+ * entirely — and a peer that never read anything could still push a forged payload
401
+ * into the app.
402
+ */
403
+ async _deliver (from, payload, meta) {
404
+ if (this._isSealed(payload)) {
405
+ if (!this.sealing && !this.myEncPrivateKey) {
406
+ this._emit('error', { type: 'unsealed', reason: 'no_encryption_key', from })
407
+ return
408
+ }
409
+ try {
410
+ const opened = this.sealing
411
+ ? await this.sealing.open(payload, meta)
412
+ : await open(payload, this.myEncPrivateKey)
413
+ this._emit('message', from, opened, { ...meta, sealed: true })
414
+ } catch (e) {
415
+ // Sealed to somebody else, or tampered with. Staying quiet is the point.
416
+ this._emit('error', { type: 'undecipherable', from, error: e })
417
+ }
418
+ return
419
+ }
420
+
421
+ if (this.requireSealed) {
422
+ this._emit('error', { type: 'unsealed', reason: 'plaintext_rejected', from })
423
+ return
424
+ }
425
+ this._emit('message', from, payload, { ...meta, sealed: false })
426
+ }
427
+
428
+ _sendByPubkeyRaw (toPubkeys, payload, opts = {}) {
269
429
  const list = Array.isArray(toPubkeys) ? toPubkeys : [toPubkeys]
270
430
  const msg = {
271
431
  to_publickey: list,
@@ -314,14 +474,31 @@ export class WebSocketProxyClient {
314
474
  * (típicamente por el identity vault). Devuelve la respuesta del proxy con
315
475
  * `queued_delivered` (mensajes offline despachados al instante).
316
476
  */
317
- identify ({ data, signature, cert, acta }) {
477
+ /**
478
+ * @param {(data:any)=>Promise<any>} [opts.sign] Con qué firmar. Solo se usa para encender
479
+ * TURN, y por eso es opcional: quien no lo pase se queda como estaba.
480
+ *
481
+ * TURN NO ES UN CANAL APARTE: es lo que WebRTC usa cuando no consigue ir directo. Pero
482
+ * un canal por TURN sigue siendo mejor que el proxio, y no por velocidad — **el relevo
483
+ * no puede leer lo que reenvía** (va cifrado extremo a extremo) y el proxio sí. Por eso
484
+ * el orden es: aquí mismo, directo, por TURN, y el proxio el último (dueño, 2026-09-03).
485
+ *
486
+ * Se enciende SOLO y por detrás: encenderlo pide credenciales al proxio, y hacer
487
+ * esperar a `identify` por eso retrasaría todo lo que viene después para ganar algo que
488
+ * solo hace falta cuando se negocie el primer canal.
489
+ */
490
+ identify ({ data, signature, cert, acta, sign }) {
318
491
  if (!data || !signature) throw new Error('identify requires {data, signature}')
319
492
  const msg = { type: 'identify', data, signature }
320
493
  if (cert) msg.cert = cert // "una identidad": el proxy bindea este token también bajo tu maestra M
321
494
  // Acta de perfil: el proxy la verifica (va firmada) y bindea también el `profileId`, así
322
495
  // escribirle a la PERSONA llega a cualquiera de sus dispositivos. Ver acta-de-perfil.md.
323
496
  if (acta) msg.acta = acta
324
- return this._request(msg, 'identified')
497
+ const done = this._request(msg, 'identified')
498
+ if (this._rtc && typeof sign === 'function' && data.publickey) {
499
+ done.then(() => this.enableTurn({ publicKey: data.publickey, sign })).catch(() => {})
500
+ }
501
+ return done
325
502
  }
326
503
 
327
504
  /**
@@ -713,7 +890,7 @@ export class WebSocketProxyClient {
713
890
  this._rtc.handleIncoming(from, parsed)
714
891
  break
715
892
  }
716
- this._emit('message', from, parsed ?? message, {
893
+ this._deliver(from, parsed ?? message, {
717
894
  raw: message, timestamp, via: 'proxy',
718
895
  fromPubkey: from_publickey || null,
719
896
  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 }
@@ -20,6 +20,60 @@ export const DEFAULT_ICE_SERVERS = [
20
20
 
21
21
  const RTC_TAG = '__cc_rtc__'
22
22
 
23
+ /**
24
+ * DE DÓNDE SALE `RTCPeerConnection`, y por qué esto existe.
25
+ *
26
+ * En un navegador es nativo. En Node no hay ninguno, y por eso el ecosistema tenía WebRTC
27
+ * apagado a mano en todas partes: dos máquinas Node se hablaban por el proxio aunque
28
+ * estuvieran en la misma red, que es lo contrario de la regla («siempre el camino más
29
+ * directo», CLAUDE.md).
30
+ *
31
+ * Se resuelve en este orden:
32
+ *
33
+ * 1. el del entorno (navegador, o un Node que algún día lo traiga);
34
+ * 2. el que le inyecten (`setPeerConnection`), para no atarse a un paquete concreto;
35
+ * 3. **`@dotrino/webrtc`**, si está instalado — y si no, **`werift`**, del que aquél es
36
+ * una poda. Los dos son WebRTC en JavaScript puro, sin binario nativo, y eso no es una
37
+ * preferencia estética: la bóveda se distribuye como un ejecutable único (SEA) y un
38
+ * `.node` no entra ahí. Se cargan PEREZOSO y ninguno es dependencia de este paquete:
39
+ * quien lo quiera en Node lo instala.
40
+ *
41
+ * La poda va primero porque es la mitad de paquetes y sin la pila de audio y vídeo,
42
+ * que un canal de datos no toca. Se sigue aceptando el de arriba para no obligar a
43
+ * nadie a cambiar, y porque son el mismo código.
44
+ *
45
+ * Si no hay ninguno, WebRTC queda apagado y se sigue por el proxio. Eso no es un fallo:
46
+ * es el escalón 4, que siempre funciona.
47
+ */
48
+ let _PC = null
49
+ let _PCBuscado = false
50
+
51
+ export function setPeerConnection (impl) { _PC = impl; _PCBuscado = true }
52
+
53
+ export function resolvePeerConnection () {
54
+ if (_PCBuscado) return _PC
55
+ _PCBuscado = true
56
+ if (typeof globalThis.RTCPeerConnection === 'function') { _PC = globalThis.RTCPeerConnection; return _PC }
57
+ return _PC
58
+ }
59
+
60
+ /**
61
+ * Busca una implementación para Node. Es ASÍNCRONO —importar un paquete lo es— así que se
62
+ * llama una vez al arrancar, no en medio de una negociación.
63
+ */
64
+ export async function loadNodePeerConnection () {
65
+ if (_PCBuscado && _PC) return _PC
66
+ if (typeof globalThis.RTCPeerConnection === 'function') { _PC = globalThis.RTCPeerConnection; _PCBuscado = true; return _PC }
67
+ for (const nombre of ['@dotrino/webrtc', 'werift']) {
68
+ try {
69
+ const w = await import(nombre)
70
+ if (typeof w?.RTCPeerConnection === 'function') { _PC = w.RTCPeerConnection; _PCBuscado = true; return _PC }
71
+ } catch (_) { /* no está: se prueba el siguiente, y si no, el proxio (escalón 4) */ }
72
+ }
73
+ _PCBuscado = true
74
+ return _PC
75
+ }
76
+
23
77
  export class WebRTCManager {
24
78
  /**
25
79
  * @param {object} opts
@@ -31,6 +85,7 @@ export class WebRTCManager {
31
85
  * @param {{iceServers?: any[]}} [opts.config]
32
86
  */
33
87
  constructor (opts) {
88
+ this.acceptFrom = null // ver `handleIncoming`: lo pone quien monta el cliente
34
89
  this.getSelfToken = opts.getSelfToken
35
90
  this.signalSend = opts.signalSend
36
91
  this.deliverMessage = opts.deliverMessage
@@ -43,8 +98,19 @@ export class WebRTCManager {
43
98
  * True if this is a control envelope and was consumed.
44
99
  * Otherwise the caller should keep delivering it normally.
45
100
  */
101
+ /**
102
+ * QUIÉN PUEDE HACERTE NEGOCIAR. Antes: cualquiera que supiera alcanzarte por el proxio.
103
+ *
104
+ * Aceptar una señal arranca DTLS, ICE y SCTP — código que parsea red no confiable— así
105
+ * que quien decide si eso corre no puede ser el que llama a la puerta. En un navegador
106
+ * eso ya era así y se vivía con ello; en la bóveda es el proceso que tiene la maestra.
107
+ *
108
+ * `acceptFrom` lo decide quien monta el cliente: la bóveda solo acepta a MIEMBROS DE SU
109
+ * ACTA. Sin política se mantiene lo de antes, para no romper a quien ya dependía de ello.
110
+ */
46
111
  handleIncoming (from, parsed) {
47
112
  if (!parsed || typeof parsed !== 'object' || parsed.t !== RTC_TAG) return false
113
+ if (this.acceptFrom && !this.acceptFrom(from)) return false
48
114
  const peer = this._ensurePeer(from)
49
115
  this._handleSignal(peer, parsed).catch((e) => {
50
116
  this.emit('error', { type: 'webrtc_signal', error: e, peer: from })
@@ -135,7 +201,9 @@ export class WebRTCManager {
135
201
  }
136
202
 
137
203
  _createPC (peer) {
138
- const pc = new RTCPeerConnection({ iceServers: this.iceServers })
204
+ const PC = resolvePeerConnection()
205
+ if (!PC) throw new Error('no WebRTC here: install `werift` for Node, or run this in a browser')
206
+ const pc = new PC({ iceServers: this.iceServers })
139
207
  peer.pc = pc
140
208
  peer.polite = this._isPolite(peer.remote)
141
209
 
@@ -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.60.3 (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,18 @@ 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 = {
56
+ 'vault:sign': 'sign',
57
+ 'vault:store': 'store',
58
+ 'vault:read': 'read',
59
+ 'vault:admin': 'admin',
60
+ 'vault:passwords': 'passwords',
61
+ // `replica` SÍ se empareja, al revés que `sealer` y `admin`. La razón es la misma que
62
+ // hace estrecho al permiso: un replicador reparte sobres que no puede abrir y no cambia
63
+ // nada. Y se despliega sin teclado —un contenedor, una máquina ajena—, que es justo
64
+ // donde obligar a un segundo paso a mano es el paso que nadie da.
65
+ 'vault:replica': 'replica'
66
+ }
56
67
  export const scopeToCaps = (scope) =>
57
68
  (Array.isArray(scope) ? scope : [scope]).map((s) => SCOPE_TO_CAP[s]).filter(Boolean)
58
69
 
@@ -277,7 +288,12 @@ export function createEnrollDesk ({
277
288
  pend.continuity = (okC && d.continuity.member === d.dpub) ? d.continuity : null
278
289
  }
279
290
  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)
291
+ // EL NOMBRE QUE PUSISTE AQUÍ MANDA. El aparato manda el suyo al enrolarse, y hasta
292
+ // ahora pisaba siempre al de la bóveda — como el aparato usa por defecto el apodo del
293
+ // PERFIL, acababas con varios dispositivos llamados igual que tú y sin forma de saber
294
+ // cuál era cuál. Si en `pair` le diste un nombre, ese es el nombre; el del aparato
295
+ // sigue valiendo como propuesta cuando no dijiste nada.
296
+ if (d.label && !pend.label) pend.label = String(d.label).slice(0, 60)
281
297
  // Camino A: de qué cuenta estamos hablando. Se guarda para poder comprobar, cuando
282
298
  // llegue el acta sellada, que es la que este dispositivo dijo que iba a entregar.
283
299
  if (intent === 'adopt' && typeof d.profileId === 'string') pend.profileId = d.profileId
@@ -359,7 +375,7 @@ export function createEnrollDesk ({
359
375
  pend.state = 'DONE'
360
376
  pending.delete(pend.token)
361
377
  fire(onPendingChange)
362
- log('[vault] device approved: %s', pend.deviceId)
378
+ log(`[vault] device approved: ${pend.deviceId}`)
363
379
  return { ok: true, deviceId: pend.deviceId, cert }
364
380
  }
365
381
 
@@ -383,9 +399,12 @@ export function createEnrollDesk ({
383
399
  const pend = [...pending.values()].find((x) => x.state === 'AWAITING_ACTA' && (x.from === from || x.dpub))
384
400
  if (!pend) return reply(from, { type: MSG_ERROR, error: 'no adoption awaiting a record' })
385
401
  if (!record || typeof record !== 'object') return reply(from, { type: MSG_ERROR, error: 'record missing or unreadable' })
386
- if (record.sealer !== iss) {
402
+ // Ya no hay campo `sealer`: se le pregunta al PERMISO. Es la misma comprobación —«¿me
403
+ // nombra a mí la que me mandan?»— dicha en el idioma nuevo, y con varios selladores la
404
+ // respuesta puede ser que sí para más de uno, que es lo correcto.
405
+ if (!canSeal(record, iss)) {
387
406
  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' })
407
+ return reply(from, { type: MSG_ERROR, error: 'that record does not let this vault seal it' })
389
408
  }
390
409
  if (record.sealedBy !== pend.dpub) {
391
410
  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
  }
@@ -70,7 +84,10 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
70
84
  const client = injectedClient || await (async () => {
71
85
  const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
72
86
  const c = new WebSocketProxyClient({
73
- url: proxy, enableWebRTC: false, autoReconnect: true,
87
+ // WEBRTC SOLO DONDE EXISTE (ver `lib/src/service.js`): en Node no hay
88
+ // `RTCPeerConnection` y encenderlo reventaría al negociar; en un navegador es nativo y es
89
+ // el camino directo que hay que preferir. Se mira, en vez de apagarlo para siempre.
90
+ url: proxy, enableWebRTC: typeof globalThis.RTCPeerConnection === 'function', autoReconnect: true,
74
91
  maxReconnectAttempts: 100000, reconnectDelay: 4000
75
92
  })
76
93
  await c.connect()
@@ -137,13 +154,20 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
137
154
  if (typeof d.ts !== 'number' || Math.abs(Date.now() - d.ts) > FRESH_WINDOW_MS) {
138
155
  return send(from, { type: MSG.ERROR, error: 'stale request: ts outside the ±5 min window (possible replay, or the device clock is off)' })
139
156
  }
140
- const chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, trustedIssuer: iss, revoked: await revocationSet() })
157
+ const chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, ...(await contextoActa()), revoked: await revocationSet() })
141
158
  if (!chk.ok) return send(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason })
142
159
  // Reusar el label del cert original (si sigue registrado en delegations).
143
160
  const { issued } = await identity.listDelegations()
144
161
  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 })
162
+ // EL SCOPE SALE DEL ACTA, no del papel viejo: el papel dice a qué se comprometió esta
163
+ // bóveda al conectar el aparato; el acta, lo que puede HOY.
164
+ const acta = (await identity.profileActa?.().catch(() => null))?.acta || null
165
+ if (!acta) return send(from, { type: MSG.ERROR, error: 'unauthorized: this vault has no record to decide with' })
166
+ const scope = memberScopes(acta, p.cert.sub)
167
+ if (!scope.length) return send(from, { type: MSG.ERROR, error: 'unauthorized: the record no longer lists this device' })
168
+ const { cert } = await identity.signDelegation(p.cert.sub, scope, { label: prev?.label || '' })
169
+ // El acta viaja con el papel: sin ella quien lo recibe no puede comprobar quién lo firmó.
170
+ send(from, { type: MSG.RENEWED, cert, acta })
147
171
  }
148
172
 
149
173
  // Consulta de revocaciones (igual que `vault.devices` del daemon): responde la lista
@@ -153,12 +177,12 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
153
177
  const d = p?.data
154
178
  if (!d || !p.signature || !p.cert) return send(from, { type: MSG.ERROR, error: 'invalid request' })
155
179
  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 })
180
+ const chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, ...(await contextoActa()) })
157
181
  if (!chk.ok) return send(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason })
158
182
  const { issued, revoked, revokedCerts } = await identity.listDelegations()
159
183
  const devices = await Promise.all((issued || []).map(async (x) => ({
160
184
  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
185
+ label: x.label || '', scope: x.scope, seq: x.seq, nonce: x.nonce
162
186
  })))
163
187
  send(from, { type: MSG.DEVICES_RESULT, devices, revoked: (revoked || []).map((r) => r.nonce || r) })
164
188
  // ¿el que consulta es una máquina revocada que reapareció? → re-emite el REVOKED firmado.
@@ -188,12 +212,23 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
188
212
  const chk = await verifyChain({
189
213
  data: d, signature: p.signature, cert: p.cert,
190
214
  ...(expectedScope ? { expectedScope } : {}),
191
- trustedIssuer: iss, revoked: await revocationSet(),
215
+ ...(await contextoActa()), revoked: await revocationSet(),
192
216
  })
193
217
  if (!chk.ok) {
194
218
  send(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason })
195
219
  return null
196
220
  }
221
+ // EL ACTA MANDA, EL PAPEL SOLO ACOMPAÑA. Aquí y no en cada mostrador: el certificado
222
+ // dice a qué se comprometió esta bóveda al conectar el aparato, y el acta lo que puede
223
+ // HOY. Quitarle un permiso sella el acta pero no le retira el papel, que vive hasta 30
224
+ // días, así que sin esto seguiría entrando hasta que lo renovara.
225
+ if (expectedScope) {
226
+ const record = (await identity.profileActa?.().catch(() => null))?.acta || null
227
+ if (record && !memberCanScope(record, chk.device, expectedScope)) {
228
+ send(from, { type: MSG.ERROR, error: 'unauthorized: acta — this member no longer has that permission' })
229
+ return null
230
+ }
231
+ }
197
232
  return chk
198
233
  }
199
234
 
@@ -302,13 +337,14 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
302
337
  */
303
338
  async function listMachines () {
304
339
  const { issued } = await identity.listDelegations()
305
- const now = Date.now()
306
340
  const bySub = new Map()
307
341
  for (const x of (issued || [])) {
308
- if (!x.sub || x.revokedAt || (x.exp && x.exp <= now)) continue // revocada = fuera de la lista
342
+ if (!x.sub || x.revokedAt) continue // revocada = fuera de la lista
309
343
  if (!Array.isArray(x.scope) || !x.scope.includes(SIGN_SCOPE)) continue
310
344
  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)
345
+ // Se queda el papel del acta MÁS NUEVA de esa llave: ya no hay «el que vence más
346
+ // tarde», porque ninguno vence.
347
+ if (!bySub.has(x.sub) || (x.seq || 0) > (bySub.get(x.sub).seq || 0)) bySub.set(x.sub, x)
312
348
  }
313
349
  return Promise.all([...bySub.values()].map(async (x) => ({ ...x, deviceId: await deviceIdOf(x.sub) })))
314
350
  }
@@ -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
  /**