@dotrino/identity 0.37.1 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/identity",
3
- "version": "0.37.1",
3
+ "version": "0.38.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",
package/src/index.js CHANGED
@@ -371,6 +371,24 @@ export class Identity {
371
371
  return this._call('vaultStore', { method, args }, 20000)
372
372
  }
373
373
 
374
+ /**
375
+ * CONSOLA REMOTA: administra el perfil desde este dispositivo, contra la bóveda
376
+ * (`dotrino-vault/docs/consola-remota.md`). `op`: `pending` · `pair` · `approve` ·
377
+ * `reject` · `revoke` · `audit`.
378
+ *
379
+ * Requiere que el cert de este aparato lleve `vault:admin`, que **no se recibe al
380
+ * emparejar**: se concede a mano en la bóveda. Cambiar permisos y traspasar el mando
381
+ * NO se administran a distancia — siguen siendo del master, en su máquina.
382
+ */
383
+ async vaultAdmin (op, args) {
384
+ return this._call('vaultAdmin', { op, ...(args || {}) }, 20000)
385
+ }
386
+
387
+ /** ¿El cert de este dispositivo le permite administrar el perfil a distancia? */
388
+ async canAdminVault () {
389
+ return this._call('canAdminVault', {}, 20000)
390
+ }
391
+
374
392
  /** Lista (solo lectura) los dispositivos enrolados en tu vault: { devices, revoked }. */
375
393
  async listVaultDevices () {
376
394
  return this._call('listVaultDevices', {}, 20000)
package/vault/acta.js CHANGED
@@ -35,13 +35,26 @@ export const ACTA_V = 1
35
35
  /**
36
36
  * Lista CERRADA de capacidades. Sellar y admitir no están: eso es ser el master.
37
37
  *
38
- * `secrets` es distinta de las otras tres: solo la pueden tener los miembros con **CN**
38
+ * `secrets` es distinta de las otras: solo la pueden tener los miembros con **CN**
39
39
  * (los servicios), y lo que abre es únicamente el cajón de SU nombre. Ver `cn` abajo.
40
+ *
41
+ * `admin` es la rendija que se le abre a la consola remota (`dotrino-vault/docs/
42
+ * consola-remota.md`): deja **admitir y expulsar** miembros a distancia, y nada más.
43
+ * NO deja cambiar permisos, traspasar el mando ni conceder `admin` — eso sigue siendo
44
+ * el rol de master, que no se delega. Así un dispositivo con `admin` robado hace daño
45
+ * acotado y **reversible** (se le revoca), en vez de poder dejarte fuera de tu cuenta.
40
46
  */
41
- export const CAPS = Object.freeze(['sign', 'store', 'read', 'secrets'])
47
+ export const CAPS = Object.freeze(['sign', 'store', 'read', 'secrets', 'admin'])
42
48
 
43
49
  /** Capacidades de un DISPOSITIVO (sin CN): acceso a todo lo del usuario. */
44
- export const DEVICE_CAPS = Object.freeze(['sign', 'store', 'read'])
50
+ export const DEVICE_CAPS = Object.freeze(['sign', 'store', 'read', 'admin'])
51
+
52
+ /**
53
+ * Lo que recibe un dispositivo recién emparejado. `admin` **no está**: no se
54
+ * empareja, se concede después y a mano (`dotrino-vault caps <ID> +admin`), para que
55
+ * ningún QR que circule pueda otorgar administración.
56
+ */
57
+ export const PAIRED_CAPS = Object.freeze(['sign', 'store', 'read'])
45
58
 
46
59
  /** Capacidades de un SERVICIO (con CN): solo su propio cajón de secretos. */
47
60
  export const SERVICE_CAPS = Object.freeze(['secrets'])
@@ -58,12 +71,13 @@ export function capScope (cap, cn = null) {
58
71
  if (cap === 'sign') return 'vault:sign'
59
72
  if (cap === 'store') return 'vault:store'
60
73
  if (cap === 'read') return 'vault:read'
74
+ if (cap === 'admin') return 'vault:admin'
61
75
  if (cap === 'secrets') return isValidCn(cn) ? 'vault:secrets:' + cn : null
62
76
  return null
63
77
  }
64
78
 
65
- /** Compat: el mapa directo, para las tres capacidades de dispositivo. */
66
- export const CAP_SCOPE = Object.freeze({ sign: 'vault:sign', store: 'vault:store', read: 'vault:read' })
79
+ /** Compat: el mapa directo, para las capacidades de dispositivo. */
80
+ export const CAP_SCOPE = Object.freeze({ sign: 'vault:sign', store: 'vault:store', read: 'vault:read', admin: 'vault:admin' })
67
81
 
68
82
  const enc = (s) => new TextEncoder().encode(s)
69
83
  const hex = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('')
@@ -99,7 +113,7 @@ export const isHandover = (acta) => !!acta && acta.sealer !== acta.sealedBy
99
113
  * estable para siempre y coincide con la identidad que el usuario ya tenía (cero migración).
100
114
  */
101
115
  export function genesisActa ({ pub, encPub = null, label = '', now = Date.now() }) {
102
- if (!isPub(pub)) throw new Error('genesisActa: falta la pubkey de la génesis')
116
+ if (!isPub(pub)) throw new Error('genesisActa: missing genesis pubkey')
103
117
  return {
104
118
  v: ACTA_V,
105
119
  profileId: pub,
@@ -107,7 +121,7 @@ export function genesisActa ({ pub, encPub = null, label = '', now = Date.now()
107
121
  sealedBy: pub,
108
122
  seq: 1,
109
123
  prev: null,
110
- members: [{ pub, encPub, label: String(label || '').slice(0, 60), cn: null, caps: [...DEVICE_CAPS], addedAt: now, cert: null }],
124
+ members: [{ pub, encPub, label: String(label || '').slice(0, 60), cn: null, caps: [...PAIRED_CAPS], addedAt: now, cert: null }],
111
125
  revoked: [],
112
126
  renounced: [],
113
127
  // Llavero del contenido: una entrada por generación, con la clave del perfil ENVUELTA
@@ -147,7 +161,7 @@ export function checkShape (acta) {
147
161
  /** Sella (firma) un acta. `privateKey` puede ser una CryptoKey no extractable. */
148
162
  export async function sealActa ({ acta, privateKey, privateJwk }) {
149
163
  const shape = checkShape(acta)
150
- if (shape) throw new Error('acta inválida: ' + shape)
164
+ if (shape) throw new Error('invalid record: ' + shape)
151
165
  const { signature } = await signWithDevice({ privateKey, privateJwk, publickey: acta.sealedBy, data: actaBody(acta) })
152
166
  const sealed = { ...acta, sig: signature }
153
167
  // La TARJETA se firma en el mismo gesto y viaja con el acta: así cualquier miembro puede
@@ -181,9 +195,9 @@ export async function verifyActa ({ acta, expectedProfileId } = {}) {
181
195
  */
182
196
  export async function applyChanges (acta, changes, { by, now = Date.now() } = {}) {
183
197
  const shape = checkShape(acta)
184
- if (shape) throw new Error('acta inválida: ' + shape)
185
- if (!by) throw new Error('applyChanges: falta `by` (quién sella)')
186
- if (by !== acta.sealer) throw new Error('solo el master puede cambiar el acta; este dispositivo no lo es')
198
+ if (shape) throw new Error('invalid record: ' + shape)
199
+ if (!by) throw new Error('applyChanges: missing `by` (who seals)')
200
+ if (by !== acta.sealer) throw new Error('only the master can change the record; this device is not the master')
187
201
 
188
202
  const list = Array.isArray(changes) ? changes : [changes]
189
203
  if (list.length === 0) throw new Error('applyChanges: no hay cambios')
@@ -208,9 +222,9 @@ export async function applyChanges (acta, changes, { by, now = Date.now() } = {}
208
222
  case 'admit': {
209
223
  const m = ch.member
210
224
  if (!isPub(m?.pub)) throw new Error('admit: falta la pubkey del miembro')
211
- if (find(m.pub)) throw new Error('admit: ese miembro ya está en el acta')
225
+ if (find(m.pub)) throw new Error('admit: that member is already in the record')
212
226
  const cn = m.cn != null ? String(m.cn) : null
213
- if (cn !== null && !isValidCn(cn)) throw new Error('admit: CN inválido (minúsculas, números y guiones)')
227
+ if (cn !== null && !isValidCn(cn)) throw new Error('admit: invalid CN (lowercase, digits and hyphens)')
214
228
  next.members.push({
215
229
  pub: m.pub,
216
230
  encPub: m.encPub || null,
@@ -227,7 +241,7 @@ export async function applyChanges (acta, changes, { by, now = Date.now() } = {}
227
241
  }
228
242
  case 'caps': {
229
243
  const m = find(ch.pub)
230
- if (!m) throw new Error('caps: ese miembro no está en el acta')
244
+ if (!m) throw new Error('caps: that member is not in the record')
231
245
  // No se puede ascender un servicio a dispositivo cambiándole las capacidades: para
232
246
  // eso hay que sacarlo y volver a admitirlo, que es un gesto visible.
233
247
  m.caps = cleanCaps(ch.caps).filter((c) => (m.cn ? SERVICE_CAPS : DEVICE_CAPS).includes(c))
@@ -235,8 +249,8 @@ export async function applyChanges (acta, changes, { by, now = Date.now() } = {}
235
249
  }
236
250
  case 'remove': {
237
251
  const i = next.members.findIndex((m) => m.pub === ch.pub)
238
- if (i < 0) throw new Error('remove: ese miembro no está en el acta')
239
- if (next.members[i].pub === next.sealer) throw new Error('remove: no puedes expulsar al master; primero traspasa el sellado')
252
+ if (i < 0) throw new Error('remove: that member is not in the record')
253
+ if (next.members[i].pub === next.sealer) throw new Error('remove: cannot remove the master; hand the sealing over first')
240
254
  const fuera = next.members.splice(i, 1)[0]
241
255
  // Sus envolturas se van con él: sin ellas no puede abrir ninguna generación. (El
242
256
  // acceso al contenido FUTURO se corta rotando, ver content.js; lo ya leído no vuelve.)
@@ -247,21 +261,21 @@ export async function applyChanges (acta, changes, { by, now = Date.now() } = {}
247
261
  break
248
262
  }
249
263
  case 'handover': {
250
- if (!find(ch.to)) throw new Error('handover: el nuevo master tiene que ser miembro (admítelo en el mismo cambio)')
264
+ if (!find(ch.to)) throw new Error('handover: the new master must be a member (admit them in the same change)')
251
265
  next.sealer = ch.to
252
266
  break
253
267
  }
254
268
  case 'keyring': {
255
269
  // Generación NUEVA de la clave de contenido (al rotar: expulsar a alguien).
256
270
  const g = ch.generation
257
- if (!g || !Number.isInteger(g.gen)) throw new Error('keyring: generación inválida')
271
+ if (!g || !Number.isInteger(g.gen)) throw new Error('keyring: invalid generation')
258
272
  next.keyring = [...next.keyring.filter((x) => x.gen !== g.gen), g].sort((a, b) => a.gen - b.gen)
259
273
  break
260
274
  }
261
275
  case 'wrap': {
262
276
  // Envolver la clave YA existente para un miembro nuevo (al admitir: no hace falta rotar).
263
277
  const g = next.keyring.find((x) => x.gen === ch.gen)
264
- if (!g) throw new Error('wrap: esa generación no está en el llavero')
278
+ if (!g) throw new Error('wrap: that generation is not in the keyring')
265
279
  g.wraps = { ...g.wraps, [ch.pub]: ch.wrap }
266
280
  break
267
281
  }
@@ -290,10 +304,10 @@ export async function applyChanges (acta, changes, { by, now = Date.now() } = {}
290
304
 
291
305
  // Reglas de cierre: sin firmante no se puede operar, y sin sellador no se puede cambiar.
292
306
  if (!next.members.some((m) => m.caps.includes('sign'))) {
293
- throw new Error('el cambio dejaría el perfil sin ningún miembro que pueda firmar')
307
+ throw new Error('the change would leave the profile with no member able to sign')
294
308
  }
295
309
  if (!next.members.some((m) => m.pub === next.sealer)) {
296
- throw new Error('el cambio dejaría el acta sin master')
310
+ throw new Error('the change would leave the record with no master')
297
311
  }
298
312
  return next
299
313
  }
package/vault/content.js CHANGED
@@ -70,7 +70,7 @@ export async function wrapForMember ({ cek, memberEncPub }) {
70
70
 
71
71
  /** Abre la envoltura con la llave de cifrado privada de ESTE miembro. */
72
72
  export async function openWrap ({ wrap, myEncPrivateKey }) {
73
- if (!wrap?.epk || !wrap?.iv || !wrap?.ct) throw new Error('envoltura inválida')
73
+ if (!wrap?.epk || !wrap?.iv || !wrap?.ct) throw new Error('invalid wrap')
74
74
  const key = await sharedKey(myEncPrivateKey, wrap.epk)
75
75
  const pt = await subtle.decrypt({ name: 'AES-GCM', iv: fromB64(wrap.iv) }, key, fromB64(wrap.ct))
76
76
  return new TextDecoder().decode(pt)
@@ -119,7 +119,7 @@ export async function encryptWithCek ({ cek, gen, plaintext }) {
119
119
  export async function decryptWithKeyring ({ envelope, keyring, myPub, myEncPrivateKey }) {
120
120
  const g = (keyring || []).find((x) => x.gen === envelope?.gen)
121
121
  const w = g?.wraps?.[myPub]
122
- if (!w) throw new Error('este dispositivo no tiene la llave de esa generación de contenido')
122
+ if (!w) throw new Error('this device does not hold the key for that content generation')
123
123
  const cek = await openWrap({ wrap: w, myEncPrivateKey })
124
124
  const k = await subtle.importKey('raw', fromB64(cek), { name: 'AES-GCM' }, false, ['decrypt'])
125
125
  const pt = await subtle.decrypt({ name: 'AES-GCM', iv: fromB64(envelope.iv) }, k, fromB64(envelope.ct))
package/vault/core.js CHANGED
@@ -22,7 +22,7 @@ import { signDelegationWith, MAX_DELEGATION_MS, DEFAULT_DELEGATION_MS } from './
22
22
  import * as Acta from './acta.js'
23
23
  import * as Content from './content.js'
24
24
  import { pubkeyId as pubkeyIdOf } from './capabilities.js'
25
- import { enrollDevice as remoteEnroll, requestSign as remoteSign, requestStore as remoteStore, requestDevices as remoteDevices, requestRenew as remoteRenew } from './remote.js'
25
+ import { enrollDevice as remoteEnroll, requestSign as remoteSign, requestStore as remoteStore, requestDevices as remoteDevices, requestRenew as remoteRenew, requestAdmin as remoteAdmin } from './remote.js'
26
26
 
27
27
  export const KEY_STORAGE = 'dotrino.identity.keypair'
28
28
  export const ENC_KEY_STORAGE = 'dotrino.identity.enc-keypair'
@@ -400,7 +400,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
400
400
  */
401
401
  async function sealChanges (changes) {
402
402
  const acta = loadActa()
403
- if (!acta) throw new Error('este perfil todavía no tiene acta')
403
+ if (!acta) throw new Error('this profile has no record yet')
404
404
  const next = await Acta.applyChanges(acta, changes, { by: publickeyJwkStr })
405
405
  const sealed = await seal(next)
406
406
  pushHistory(acta) // la que deja de ser vigente entra en la ventana de retención
@@ -815,7 +815,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
815
815
  const proof = await derivePwd(password, pwd.salt, pwd.iter)
816
816
  if (proof !== pwd.verifier) {
817
817
  kv.setItem('dotrino.identity.pwd.tries', JSON.stringify({ n: tries.n + 1, at: Date.now() }))
818
- throw new Error('contraseña incorrecta')
818
+ throw new Error('wrong password')
819
819
  }
820
820
  kv.removeItem('dotrino.identity.pwd.tries')
821
821
  try { sessionKv?.setItem(_scoped(PWD_SESSION), proof) } catch (_) {}
@@ -825,7 +825,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
825
825
  // Poner/cambiar contraseña (requiere estar desbloqueado; cambiar exige la actual vía unlock previo).
826
826
  async setProfilePassword ({ password }) {
827
827
  if (locked) throw new Error('perfil bloqueado')
828
- if (!password || String(password).length < 4) throw new Error('la contraseña debe tener al menos 4 caracteres')
828
+ if (!password || String(password).length < 4) throw new Error('password must be at least 4 characters')
829
829
  const salt = b64(crypto.getRandomValues(new Uint8Array(16)))
830
830
  const verifier = await derivePwd(password, salt, PWD_ITER)
831
831
  kv.setItem(PWD_STORAGE, JSON.stringify({ v: 1, salt, iter: PWD_ITER, verifier }))
@@ -1004,7 +1004,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1004
1004
 
1005
1005
  const v = loadVaultCert(); const device = loadVaultDevice()
1006
1006
  if (!v?.cert || !device) {
1007
- throw new Error('perfil-sin-firmante: este dispositivo ya no firma por ti y no está conectado a ninguna bóveda que pueda hacerlo')
1007
+ throw new Error('profile-without-signer: this device no longer signs for you and is not connected to any vault that can')
1008
1008
  }
1009
1009
  maybeRenewVaultCert()
1010
1010
  try { return await remoteSign({ master: v.master, proxy: v.proxy, device, cert: v.cert, payload: data, onRevoked: wipeVaultLink }) }
@@ -1109,7 +1109,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1109
1109
  },
1110
1110
  async deleteProfile ({ id } = {}) {
1111
1111
  let list = loadProfiles()
1112
- if (list.length <= 1) throw new Error('no se puede borrar el único perfil')
1112
+ if (list.length <= 1) throw new Error('cannot delete the only profile')
1113
1113
  if (!list.find((p) => p.id === id)) throw new Error('perfil no existe')
1114
1114
  list = list.filter((p) => p.id !== id); saveProfiles(list)
1115
1115
  // Borrado directo del namespace del perfil (incluye su store del vault si lo tuviera).
@@ -1220,7 +1220,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1220
1220
  */
1221
1221
  async renounceCaps ({ caps } = {}) {
1222
1222
  const acta = loadActa()
1223
- if (!acta) throw new Error('este perfil todavía no tiene acta')
1223
+ if (!acta) throw new Error('this profile has no record yet')
1224
1224
  const record = await Acta.makeRenounce({ member: publickeyJwkStr, caps, privateKey: keypair.privateKey })
1225
1225
  const pend = loadRenounces().filter((r) => r.member !== publickeyJwkStr)
1226
1226
  pend.push(record)
@@ -1233,7 +1233,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1233
1233
 
1234
1234
  /** Absorbe en el acta una renuncia ajena ya verificada (solo el master). */
1235
1235
  async absorbRenounce ({ record } = {}) {
1236
- if (!(await Acta.verifyRenounce(record))) throw new Error('renuncia inválida: la firma no es del propio miembro')
1236
+ if (!(await Acta.verifyRenounce(record))) throw new Error('invalid renounce: the signature is not the member own')
1237
1237
  const acta = await sealChanges([{ op: 'renounce', record }])
1238
1238
  return { ok: true, seq: acta.seq }
1239
1239
  },
@@ -1250,7 +1250,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1250
1250
  */
1251
1251
  async sealContent ({ plaintext } = {}) {
1252
1252
  const mine = await myCek()
1253
- if (!mine) throw new Error('este dispositivo todavía no tiene la clave de contenido del perfil')
1253
+ if (!mine) throw new Error('this device does not hold the profile content key yet')
1254
1254
  return Content.encryptWithCek({ cek: mine.cek, gen: mine.gen, plaintext: String(plaintext) })
1255
1255
  },
1256
1256
 
@@ -1294,7 +1294,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1294
1294
  * y la firmó el mismo master. Si el master cambió, se avisa en vez de aceptarlo callando.
1295
1295
  */
1296
1296
  async adoptPeerCard ({ card } = {}) {
1297
- if (!card?.profileId) throw new Error('tarjeta inválida')
1297
+ if (!card?.profileId) throw new Error('invalid card')
1298
1298
  const peers = loadPeers()
1299
1299
  const prev = peers[card.profileId]?.card || null
1300
1300
  const r = await Acta.canAdoptCard({ card, current: prev })
@@ -1339,7 +1339,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1339
1339
  } else {
1340
1340
  const yaConEsta = loadVaultCert()?.master === qr?.iss
1341
1341
  if (loadActa() && !isPendingJoin() && !yaConEsta) {
1342
- throw new Error('este aparato ya está usando una cuenta: para usar también la de tu bóveda, crea una cuenta nueva aquí (la que tienes abierta no se toca)')
1342
+ throw new Error('this device is already using an account: to also use your vault account, create a new account here (the open one is untouched)')
1343
1343
  }
1344
1344
  }
1345
1345
  // Usa la PROPIA llave de identidad de este navegador como dispositivo: el cert delega
@@ -1381,8 +1381,8 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1381
1381
  */
1382
1382
  async vaultAdopt ({ qr, label = '' } = {}) {
1383
1383
  const mio = loadActa()
1384
- if (!mio) throw new Error('este aparato todavía no tiene ninguna cuenta que entregar')
1385
- if (!amMaster()) throw new Error('no-eres-el-master: esta cuenta ya la manda otro dispositivo o bóveda; el traspaso se hace desde ahí')
1384
+ if (!mio) throw new Error('this device has no account to hand over yet')
1385
+ if (!amMaster()) throw new Error('not-the-master: another device or vault is in charge of this account; the handover is done from there')
1386
1386
 
1387
1387
  const device = { publickey: publickeyJwkStr, privateKey: keypair.privateKey }
1388
1388
  const res = await remoteEnroll({
@@ -1417,7 +1417,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1417
1417
  const r = await adoptActa(res.acta)
1418
1418
  const ok = r.adopted || r.reason === 'misma-acta'
1419
1419
  emitVault({ phase: 'adopted', master: res.master, seq: res.acta?.seq, ok })
1420
- if (!ok) throw new Error('la bóveda devolvió un acta que no encaja: ' + r.reason)
1420
+ if (!ok) throw new Error('the vault returned a record that does not fit: ' + r.reason)
1421
1421
  return { ok: true, adopted: true, profileId: mio.profileId, seq: r.seq ?? res.acta?.seq, master: res.master, deviceId: res.deviceId }
1422
1422
  },
1423
1423
 
@@ -1454,7 +1454,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1454
1454
  // así nada se rompe si no estás emparejado o si el vault está apagado.
1455
1455
  async vaultSign ({ payload }) {
1456
1456
  const v = loadVaultCert(); const device = loadVaultDevice()
1457
- if (!v?.cert || !device) throw new Error('este dispositivo no está emparejado con un vault')
1457
+ if (!v?.cert || !device) throw new Error('this device is not paired with a vault')
1458
1458
  maybeRenewVaultCert()
1459
1459
  try { return await remoteSign({ master: v.master, proxy: v.proxy, device, cert: v.cert, payload, onRevoked: wipeVaultLink }) }
1460
1460
  catch (e) { return handleVaultError(e) }
@@ -1470,7 +1470,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1470
1470
  */
1471
1471
  async vaultStore ({ method, args }) {
1472
1472
  const v = loadVaultCert(); const device = loadVaultDevice()
1473
- if (!v?.cert || !device) throw new Error('este dispositivo no está emparejado con un vault')
1473
+ if (!v?.cert || !device) throw new Error('this device is not paired with a vault')
1474
1474
  maybeRenewVaultCert()
1475
1475
  const mine = await myCek().catch(() => null)
1476
1476
  let payload = { method, args }
@@ -1489,10 +1489,38 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1489
1489
  } catch (e) { return handleVaultError(e) }
1490
1490
  },
1491
1491
 
1492
+ /**
1493
+ * CONSOLA REMOTA: administrar el perfil desde este dispositivo (ver
1494
+ * `dotrino-vault/docs/consola-remota.md`). Requiere que el cert de este aparato
1495
+ * lleve `vault:admin`, que **no se recibe al emparejar**: se concede a mano en la
1496
+ * bóveda (`dotrino-vault caps <ID> +administra`).
1497
+ *
1498
+ * `op`: `pending` · `pair` · `approve` · `reject` · `revoke` · `audit`. Cambiar
1499
+ * permisos y traspasar el mando NO están, y no es un olvido: eso sigue siendo del
1500
+ * master, en su máquina.
1501
+ */
1502
+ async vaultAdmin ({ op, ...rest } = {}) {
1503
+ const v = loadVaultCert(); const device = loadVaultDevice()
1504
+ if (!v?.cert || !device) throw new Error('this device is not paired with a vault')
1505
+ maybeRenewVaultCert()
1506
+ try { return await remoteAdmin({ master: v.master, proxy: v.proxy, device, cert: v.cert, op, ...rest, onRevoked: wipeVaultLink }) }
1507
+ catch (e) { return handleVaultError(e) }
1508
+ },
1509
+
1510
+ /**
1511
+ * ¿Puede ESTE dispositivo administrar el perfil a distancia? Sale del scope del
1512
+ * cert que le dio la bóveda, no de una preferencia: la interfaz pregunta para
1513
+ * saber qué pintar, pero quien decide es la bóveda al recibir la petición.
1514
+ */
1515
+ canAdminVault () {
1516
+ const v = loadVaultCert()
1517
+ return !!v?.cert && (v.cert.scope || []).includes('vault:admin')
1518
+ },
1519
+
1492
1520
  // Lista (solo lectura) de dispositivos enrolados en tu vault.
1493
1521
  async listVaultDevices () {
1494
1522
  const v = loadVaultCert(); const device = loadVaultDevice()
1495
- if (!v?.cert || !device) throw new Error('este dispositivo no está emparejado con un vault')
1523
+ if (!v?.cert || !device) throw new Error('this device is not paired with a vault')
1496
1524
  maybeRenewVaultCert()
1497
1525
  try {
1498
1526
  const res = await remoteDevices({ master: v.master, proxy: v.proxy, device, cert: v.cert, sinceSeq: loadActa()?.seq ?? 0, onRevoked: wipeVaultLink })
@@ -1603,7 +1631,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1603
1631
  if (!envelope || (envelope.v !== 1 && envelope.v !== 2)) throw new Error('Unsupported envelope')
1604
1632
  const myId = await encKeyId(encPublickeyJwkStr)
1605
1633
  const myEntry = envelope.wrap && (envelope.wrap[myId] || (myToken ? envelope.wrap[myToken] : null))
1606
- if (!myEntry) throw new Error('este dispositivo no está entre los destinatarios del mensaje')
1634
+ if (!myEntry) throw new Error('this device is not among the message recipients')
1607
1635
  const senderPub = await importPeerEncPubkey(senderEncryptionPubkey)
1608
1636
  const sharedKey = await deriveSharedAesKey(encKeypair.privateKey, senderPub)
1609
1637
  const kRaw = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: base64ToBuf(myEntry.iv) }, sharedKey, base64ToBuf(myEntry.ct))
@@ -1615,7 +1643,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1615
1643
  async exportIdentity () {
1616
1644
  const raw = kv.getItem(KEY_STORAGE)
1617
1645
  if (!raw) {
1618
- throw new Error('Este perfil guarda su llave de forma NO exportable (protección contra robo). ' +
1646
+ throw new Error('This profile stores its key as NON-exportable (theft protection). ' +
1619
1647
  'Para usar tu identidad en otro navegador, conecta ese navegador a tu bóveda (vault) desde profile.dotrino.com.')
1620
1648
  }
1621
1649
  const keys = JSON.parse(raw)
@@ -1758,7 +1786,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1758
1786
  const fn = handlers[name]
1759
1787
  handlers[name] = async (params) => {
1760
1788
  if (locked) refreshLockState() // otra pestaña pudo desbloquear… no: session es por pestaña; re-chequea por si se quitó el pwd
1761
- if (locked) throw new Error('perfil bloqueado: desbloquéalo con tu contraseña (unlockProfile)')
1789
+ if (locked) throw new Error('profile locked: unlock it with your password (unlockProfile)')
1762
1790
  return fn(params)
1763
1791
  }
1764
1792
  }
package/vault/index.html CHANGED
@@ -25,7 +25,8 @@
25
25
  { "imports": {
26
26
  "@dotrino/proxy-client": "./vendor/proxy-client/index.js",
27
27
  "@dotrino/vault": "./vendor/vault/index.js",
28
- "@dotrino/identity/capabilities": "./capabilities.js"
28
+ "@dotrino/identity/capabilities": "./capabilities.js",
29
+ "@dotrino/identity/acta": "./acta.js"
29
30
  } }
30
31
  </script>
31
32
  <script type="module" src="./vault.js"></script>
@@ -96,7 +96,7 @@ export async function initPeerStorage () {
96
96
  const stored = await idbGet(_idb, peersKey()) // peer book DEL perfil activo (namespaceado)
97
97
  _peers = (stored && typeof stored === 'object') ? stored : {}
98
98
  } catch (e) {
99
- console.warn('[cc-identity] IndexedDB no disponible, uso localStorage:', e?.message)
99
+ console.warn('[cc-identity] IndexedDB unavailable, falling back to localStorage:', e?.message)
100
100
  _fallback = true
101
101
  _idb = null
102
102
  try { const raw = localStorage.getItem(peersKey()); _peers = raw ? (JSON.parse(raw) || {}) : {} }
@@ -109,13 +109,13 @@ function persistPeers () {
109
109
  const key = peersKey()
110
110
  if (_fallback || !_idb) {
111
111
  try { localStorage.setItem(key, JSON.stringify(_peers)) }
112
- catch (e) { console.warn('[cc-identity] persist (ls) falló:', e?.message) }
112
+ catch (e) { console.warn('[cc-identity] persist (ls) failed:', e?.message) }
113
113
  return _writeChain
114
114
  }
115
115
  const snapshot = _peers
116
116
  _writeChain = _writeChain
117
117
  .then(() => idbPut(_idb, key, snapshot))
118
- .catch(e => console.warn('[cc-identity] persist (idb) falló:', e?.message))
118
+ .catch(e => console.warn('[cc-identity] persist (idb) failed:', e?.message))
119
119
  return _writeChain
120
120
  }
121
121
 
package/vault/remote.js CHANGED
@@ -28,6 +28,10 @@ const MSG = {
28
28
  ACTA_SEALED: 'vault.acta.sealed',
29
29
  ACTA_ADOPTED: 'vault.acta.adopted',
30
30
  REVOKED: 'vault.revoked',
31
+ // Consola remota: administrar el perfil desde un dispositivo (scope `vault:admin`).
32
+ ADMIN: 'vault.admin',
33
+ ADMIN_RESULT: 'vault.admin.result',
34
+ ADMIN_EVENT: 'vault.admin.event',
31
35
  ERROR: 'vault.error'
32
36
  }
33
37
  export { MSG as VAULT_MSG }
@@ -68,9 +72,9 @@ async function identifyAsDevice (client, device, { cert = null, acta = null } =
68
72
  */
69
73
  async function verificarHola (p, sn) {
70
74
  const b = p?.body
71
- if (!b?.iss || b.sn !== sn) throw new Error('la bóveda contestó a otro emparejamiento')
75
+ if (!b?.iss || b.sn !== sn) throw new Error('the vault answered a different pairing')
72
76
  if (!(await verifyDeviceSig({ publickey: b.iss, data: b, signature: p.signature }))) {
73
- throw new Error('la respuesta de la bóveda no está bien firmada')
77
+ throw new Error('the vault reply is not properly signed')
74
78
  }
75
79
  return b
76
80
  }
@@ -82,7 +86,7 @@ async function verificarHola (p, sn) {
82
86
  */
83
87
  async function canjearCita (client, code) {
84
88
  const r = await client.redeemPairingCode(code)
85
- if (!r?.ok || !r.instance) throw new Error(r?.error || 'ese código de emparejamiento ya no vale')
89
+ if (!r?.ok || !r.instance) throw new Error(r?.error || 'that pairing code is no longer valid')
86
90
  return r.instance
87
91
  }
88
92
 
@@ -94,7 +98,7 @@ async function askVault (client, qr) {
94
98
  if (p?.type === MSG.HELLO_OK) { fin(); verificarHola(p, qr.sn).then((b) => resolve({ iss: b.iss, proxy: b.proxy || qr.proxy, acct: b.acct || '', m: b.m || qr.m }), reject) }
95
99
  else if (p?.type === MSG.ERROR) { fin(); reject(new Error(p.error)) }
96
100
  })
97
- const t = setTimeout(() => { fin(); reject(new Error('la bóveda no contestó: ese código pudo caducar')) }, 15000)
101
+ const t = setTimeout(() => { fin(); reject(new Error('the vault did not answer: that code may have expired')) }, 15000)
98
102
  const fin = () => { off(); clearTimeout(t) }
99
103
  try { client.send(destino, { type: MSG.HELLO, sn: qr.sn }) } catch (e) { fin(); reject(e) }
100
104
  })
@@ -109,7 +113,7 @@ async function askVault (client, qr) {
109
113
  * @returns {Promise<{device, cert, master:string, proxy:string, deviceId:string}>}
110
114
  */
111
115
  export async function enrollDevice ({ qr, device, onChallenge, label = '', continuity = null, encPub = null, approveTimeoutMs = 180000, intent = 'join', profileId = null, onAdopt = null } = {}) {
112
- if (!qr?.sn || !(qr.iss || qr.conn)) throw new Error('qr inválido: falta la bóveda o el nonce')
116
+ if (!qr?.sn || !(qr.iss || qr.conn)) throw new Error('invalid qr: missing vault or nonce')
113
117
  const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
114
118
  const client = new WebSocketProxyClient({ url: qr.proxy || 'wss://proxy.dotrino.com', enableWebRTC: false, autoReconnect: false })
115
119
  await client.connect()
@@ -172,7 +176,7 @@ export async function enrollDevice ({ qr, device, onChallenge, label = '', conti
172
176
  else if (p.type === MSG.ACTA_ADOPTED && adoptar) { cleanup(); resolve(p) }
173
177
  else if (p.type === MSG.ERROR) { cleanup(); reject(new Error(p.error)) }
174
178
  })
175
- const t = setTimeout(() => { cleanup(); reject(new Error('timeout esperando la aprobación en el vault')) }, approveTimeoutMs)
179
+ const t = setTimeout(() => { cleanup(); reject(new Error('timeout waiting for approval at the vault')) }, approveTimeoutMs)
176
180
  const cleanup = () => { off(); clearTimeout(t) }
177
181
  })
178
182
  client.sendByPubkey(qr.iss, { type: MSG.ENROLL, data, signature })
@@ -181,14 +185,14 @@ export async function enrollDevice ({ qr, device, onChallenge, label = '', conti
181
185
  // Camino A: aquí no hay cert que validar — este aparato NO delega su identidad, sigue
182
186
  // siendo la cuenta. Lo que vuelve es el acta ya sellada por la bóveda.
183
187
  if (adoptar) {
184
- if (!res.acta) throw new Error('la bóveda no devolvió el acta adoptada')
185
- if (res.acta.sealer !== qr.iss) throw new Error('el acta la sella una bóveda distinta a la que viste')
188
+ if (!res.acta) throw new Error('the vault did not return the adopted record')
189
+ if (res.acta.sealer !== qr.iss) throw new Error('the record is sealed by a vault other than the one you saw')
186
190
  return { device: dev, cert: null, master: qr.iss, proxy: qr.proxy, deviceId, acta: res.acta, adopted: true }
187
191
  }
188
192
 
189
193
  // Validación estricta antes de guardar (cierra inyección de cert / sustitución de maestra).
190
194
  const v = await verifyDelegation({ cert: res.cert, expectedSub: dev.publickey })
191
- if (!v.ok) throw new Error('cert inválido: ' + v.reason)
195
+ if (!v.ok) throw new Error('invalid cert: ' + v.reason)
192
196
  if (res.cert.iss !== qr.iss) throw new Error('cert firmado por una maestra distinta a la que viste')
193
197
  if (res.cert.sub !== dev.publickey) throw new Error('cert emitido para otro dispositivo')
194
198
  return { device: dev, cert: res.cert, master: qr.iss, proxy: qr.proxy, deviceId, acta: res.acta || null }
@@ -237,7 +241,7 @@ async function vaultRpc ({ master, proxy, device, cert, acta = null, sendType, o
237
241
  if (p.type === okType) { cleanup(); resolve(p) }
238
242
  else if (p.type === 'vault.error') { cleanup(); reject(new Error(p.error)) }
239
243
  })
240
- const t = setTimeout(() => { cleanup(); reject(new Error('el vault no respondió (¿está encendido?)')) }, timeoutMs)
244
+ const t = setTimeout(() => { cleanup(); reject(new Error('the vault did not reply (is it running?)')) }, timeoutMs)
241
245
  const cleanup = () => { off(); clearTimeout(t) }
242
246
  })
243
247
  client.sendByPubkey(master, { type: sendType, data: signed, signature, cert })
@@ -267,6 +271,39 @@ export async function requestDevices ({ master, proxy, device, cert, sinceSeq, o
267
271
  */
268
272
  export async function requestRenew ({ master, proxy, device, cert, onRevoked } = {}) {
269
273
  const res = await vaultRpc({ master, proxy, device, cert, onRevoked, sendType: 'vault.renew', okType: 'vault.renewed', data: { op: 'renew' } })
270
- if (!res.cert || res.cert.sub !== device.publickey || res.cert.iss !== master) throw new Error('cert renovado inválido')
274
+ if (!res.cert || res.cert.sub !== device.publickey || res.cert.iss !== master) throw new Error('invalid renewed cert')
271
275
  return { cert: res.cert }
272
276
  }
277
+
278
+ /**
279
+ * CONSOLA REMOTA (`dotrino-vault/docs/consola-remota.md`): administrar el perfil desde
280
+ * este dispositivo, sin ir al PC. Requiere un cert con scope `vault:admin`, que **no se
281
+ * recibe al emparejar** — se concede a mano en la bóveda.
282
+ *
283
+ * `op`: `pending` · `pair` · `approve` · `reject` · `revoke` · `audit`. Lo que NO existe
284
+ * aquí es tan importante como lo que sí: cambiar permisos, traspasar el mando y los
285
+ * secretos de servicios no se administran a distancia.
286
+ *
287
+ * El `nonce` de un solo uso va en cada petición porque `approve` y `revoke` cambian
288
+ * estado, y para eso la ventana de frescura de ±5 min no alcanza.
289
+ */
290
+ export async function requestAdmin ({ master, proxy, device, cert, op, onRevoked, ...rest } = {}) {
291
+ const nonce = [...crypto.getRandomValues(new Uint8Array(16))]
292
+ .map((b) => b.toString(16).padStart(2, '0')).join('')
293
+ const res = await vaultRpc({
294
+ master, proxy, device, cert, onRevoked,
295
+ sendType: MSG.ADMIN, okType: MSG.ADMIN_RESULT,
296
+ data: { op, ...rest, nonce }
297
+ })
298
+ return res.result
299
+ }
300
+
301
+ /**
302
+ * ¿Es AUTÉNTICO este `vault.admin.event` (entró o salió alguien del perfil)? Solo si va
303
+ * firmado por la maestra PINEADA. Un aviso sin firma no se muestra: si no, cualquiera
304
+ * podría llenar de alarmas falsas los dispositivos del usuario.
305
+ */
306
+ export async function isAuthenticAdminEvent ({ body, signature, master }) {
307
+ if (!body || typeof body.ev !== 'string') return false
308
+ return verifyDeviceSig({ publickey: master, data: body, signature })
309
+ }
package/vault/vault.js CHANGED
@@ -119,7 +119,11 @@ import { pubkeyId } from './capabilities.js'
119
119
  listDelegations: () => handlers.listDelegations({}),
120
120
  revokeDelegation: (nonce) => handlers.revokeDelegation({ nonce }),
121
121
  admitMember: (m) => handlers.admitMember(m),
122
- profileActa: () => handlers.profileActa({})
122
+ profileActa: () => handlers.profileActa({}),
123
+ // Camino A (`mode: 'adopt'`): la bóveda se queda con la cuenta que trae el aparato,
124
+ // y para eso tiene que poder ENTRAR en su acta. Sin esto, adoptar fallaba con un
125
+ // «no es una función» en vez de con un error del protocolo.
126
+ joinProfile: (acta) => handlers.joinProfile({ acta })
123
127
  }
124
128
 
125
129
  async function startSelfDaemon () {
@@ -216,7 +220,7 @@ import { pubkeyId } from './capabilities.js'
216
220
  return { ok: true, enabled: !!enabled }
217
221
  },
218
222
  selfVaultPairing: async (opts) => {
219
- if (!daemon) throw new Error('esta pestaña no es la bóveda activa; ábrela como pestaña visible')
223
+ if (!daemon) throw new Error('this tab is not the active vault; open it as a visible tab')
220
224
  return daemon.startPairing(opts)
221
225
  },
222
226
  selfVaultPending: async () => (daemon ? daemon.listPending() : []),
@@ -226,11 +230,11 @@ import { pubkeyId } from './capabilities.js'
226
230
  return issued || []
227
231
  },
228
232
  selfVaultApprove: async ({ deviceId, code }) => {
229
- if (!daemon) throw new Error('esta pestaña no es la bóveda activa')
233
+ if (!daemon) throw new Error('this tab is not the active vault')
230
234
  return daemon.approve(deviceId, code)
231
235
  },
232
236
  selfVaultReject: async ({ deviceId }) => {
233
- if (!daemon) throw new Error('esta pestaña no es la bóveda activa')
237
+ if (!daemon) throw new Error('this tab is not the active vault')
234
238
  daemon.reject(deviceId)
235
239
  return { ok: true }
236
240
  },
@@ -1,6 +1,6 @@
1
- Copia vendorizada de @dotrino/vault@0.7.0 (lib/src/{index,enroll}.js, sin dependencias).
1
+ Copia vendorizada de @dotrino/vault@0.18.0 (lib/src/{index,enroll,protocol}.js, sin dependencias).
2
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 (relativo, se
4
- vendoriza tambien) y @dotrino/identity/capabilities (=../../capabilities.js) y
5
- @dotrino/proxy-client (=../proxy-client/), ambos via el import map de index.html.
6
- Re-vendorizar AMBOS archivos al subir @dotrino/vault.
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.
@@ -39,14 +39,20 @@ export const FRESH_WINDOW_MS = 5 * 60 * 1000
39
39
  /** Vida por defecto del cert de un dispositivo (tope duro de `MAX_DELEGATION_MS`). */
40
40
  export const DEVICE_TTL_MS = 30 * 24 * 60 * 60 * 1000
41
41
 
42
+ export const MSG_HELLO = 'vault.hello'
43
+ export const MSG_HELLO_OK = 'vault.hello.ok'
42
44
  export const MSG_ENROLL = 'vault.enroll'
43
45
  export const MSG_ENROLL_CHALLENGE = 'vault.enroll.challenge'
44
46
  export const MSG_ENROLLED = 'vault.enrolled'
47
+ // --- camino A: la cuenta del aparato pasa a vivir en la bóveda ---
48
+ export const MSG_ENROLL_ADOPT = 'vault.enroll.adopt'
49
+ export const MSG_ACTA_SEALED = 'vault.acta.sealed'
50
+ export const MSG_ACTA_ADOPTED = 'vault.acta.adopted'
45
51
  export const MSG_REVOKED = 'vault.revoked'
46
52
  export const MSG_ERROR = 'vault.error'
47
53
 
48
54
  /** Los scopes del cert se corresponden 1:1 con las capacidades del acta (§D7). */
49
- const SCOPE_TO_CAP = { 'vault:sign': 'sign', 'vault:store': 'store', 'vault:read': 'read' }
55
+ const SCOPE_TO_CAP = { 'vault:sign': 'sign', 'vault:store': 'store', 'vault:read': 'read', 'vault:admin': 'admin' }
50
56
  export const scopeToCaps = (scope) =>
51
57
  (Array.isArray(scope) ? scope : [scope]).map((s) => SCOPE_TO_CAP[s]).filter(Boolean)
52
58
 
@@ -63,12 +69,22 @@ export function scopeToCn (scope) {
63
69
  return null
64
70
  }
65
71
 
66
- /** Token aleatorio de 128 bits en hex. */
67
- export function randToken () {
68
- const b = crypto.getRandomValues(new Uint8Array(16))
72
+ /**
73
+ * Token aleatorio en hex (16 bytes = 128 bits por defecto).
74
+ *
75
+ * El emparejamiento pide 12 (96 bits): son de un solo uso, valen 5 minutos y hay
76
+ * UNA sesión viva a la vez, así que adivinarlo es 2^95 intentos contra una bóveda
77
+ * que además exige el código de 6 dígitos. A cambio, cada byte de menos son ~1,4
78
+ * caracteres menos en el QR — y el QR se mide en filas de terminal.
79
+ */
80
+ export function randToken (bytes = 16) {
81
+ const b = crypto.getRandomValues(new Uint8Array(bytes))
69
82
  return [...b].map((x) => x.toString(16).padStart(2, '0')).join('')
70
83
  }
71
84
 
85
+ /** Tamaño del token/nonce de una sesión de emparejamiento (ver `randToken`). */
86
+ const PAIR_TOKEN_BYTES = 12
87
+
72
88
  /** deviceId legible (p. ej. `C440-AC0E`) a partir de una pubkey JWK. */
73
89
  export async function deviceIdOf (pub) {
74
90
  const id = (await pubkeyId(pub)).slice(0, 8).toUpperCase()
@@ -94,27 +110,65 @@ export async function deviceIdOf (pub) {
94
110
  export function createEnrollDesk ({
95
111
  identity, iss, proxy, send, sendByPubkey,
96
112
  audit = () => {}, log = () => {},
97
- onChallenge = () => {}, onPendingChange = () => {},
98
- defaultScope = ['vault:sign'], defaultTtlMs = DEVICE_TTL_MS
113
+ onChallenge = () => {}, onPendingChange = () => {}, onAdopted = () => {},
114
+ defaultScope = ['vault:sign'], defaultTtlMs = DEVICE_TTL_MS,
115
+ // Camino A: lo que ESTA bóveda le manda al aparato para que la meta en su acta. `encPub`
116
+ // es su llave de CIFRADO — sin ella entra mandando pero sin poder leer el contenido.
117
+ encPub = null, vaultLabel = '',
118
+ // Token de CONEXIÓN de esta bóveda en el proxy (4 chars): su dirección. Es lo
119
+ // único que necesita el QR corto para que el aparato le hable punto a punto.
120
+ connToken = null
99
121
  } = {}) {
100
- if (!identity) throw new Error('createEnrollDesk: falta identity')
101
- if (!iss) throw new Error('createEnrollDesk: falta iss (pubkey de la maestra)')
122
+ if (!identity) throw new Error('createEnrollDesk: missing identity')
123
+ if (!iss) throw new Error('createEnrollDesk: missing iss (master pubkey)')
102
124
 
103
125
  // token -> { token, exp, scope, ttlMs, label, sn, state, dpub?, deviceId?, commit?, from? }
104
126
  // state: 'AWAITING_ENROLL' -> 'PENDING_CONFIRM'
105
127
  const pending = new Map()
106
128
 
107
129
  const fire = (fn, arg) => { try { fn(arg) } catch (_) {} }
108
- const reply = (to, obj) => { try { send(to, obj) } catch (e) { log('[vault] no se pudo responder:', e.message) } }
130
+ const reply = (to, obj) => { try { send(to, obj) } catch (e) { log('[vault] could not reply:', e.message) } }
109
131
  const isFresh = (d) => typeof d?.ts === 'number' && Math.abs(Date.now() - d.ts) <= FRESH_WINDOW_MS
110
132
 
111
- /** Inicia un emparejamiento: token + nonce de sesión. NO firma nada todavía. */
112
- function startPairing ({ scope = defaultScope, ttlMs = defaultTtlMs, label = '' } = {}) {
133
+ /**
134
+ * Inicia un emparejamiento: token + nonce de sesión. NO firma nada todavía.
135
+ *
136
+ * `mode` y `account` son LO QUE LA BÓVEDA DECLARA que va a pasar, y viajan en el QR
137
+ * para que el aparato pueda **decirlo antes de hacerlo** en vez de emparejar a
138
+ * ciegas (decisión V9 de `docs/vinculacion-de-cuentas.md`: pregunta el vault, el
139
+ * dispositivo muestra el proceso y sus consecuencias):
140
+ *
141
+ * · `mode: 'join'` → el dispositivo estrena una cuenta suya y entra a la de la
142
+ * bóveda. Es lo único que existe hoy.
143
+ * · `mode: 'adopt'` → la bóveda se quedaría con la cuenta que trae el aparato
144
+ * (camino A). Reservado: todavía no hay protocolo.
145
+ * · `account` → cómo se llama la cuenta de la bóveda, para nombrarla en el
146
+ * aviso. Es ORIENTATIVO (un nombre que puso su dueño); la
147
+ * identidad de verdad de la cuenta es `iss`.
148
+ */
149
+ async function startPairing ({ scope = defaultScope, ttlMs = defaultTtlMs, label = '', mode = 'join', account = '' } = {}) {
113
150
  pending.clear() // uno a la vez: una sesión nueva supersede a la anterior
114
- const token = randToken()
115
- const sn = randToken()
116
- pending.set(token, { token, exp: Date.now() + PAIRING_TTL_MS, scope, ttlMs, label, sn, state: 'AWAITING_ENROLL' })
117
- return { token, qr: { v: 2, iss, proxy, token, sn }, expiresInMs: PAIRING_TTL_MS }
151
+ const acct = String(account || '').slice(0, 40)
152
+ // INVITACIÓN CORTA: si sabemos cómo alcanzarnos, el QR lleva solo eso y el
153
+ // nonce de la sesión. La llave, el proxy y el nombre de la cuenta los pide el
154
+ // aparato por la red presentando el `sn`. El nonce hace de identificador de
155
+ // sesión: no hace falta un token de emparejamiento aparte.
156
+ //
157
+ // `conn` es una CITA del proxio (6 caracteres, un solo uso, caduca en
158
+ // minutos), no la dirección de la conexión: esa pasó a ser una instancia de
159
+ // 24 caracteres, que ni entra cómoda en un QR ni tiene por qué quedar impresa
160
+ // en algo que circula. Por eso se pide una nueva por emparejamiento, y por
161
+ // eso esto es asíncrono.
162
+ const conn = typeof connToken === 'function' ? await connToken() : connToken
163
+ if (conn) {
164
+ const sn = randToken(8)
165
+ pending.set(sn, { token: sn, exp: Date.now() + PAIRING_TTL_MS, scope, ttlMs, label, sn, mode, account: acct, state: 'AWAITING_ENROLL' })
166
+ return { token: sn, qr: { v: 2, conn, sn, m: mode, proxy }, expiresInMs: PAIRING_TTL_MS }
167
+ }
168
+ const token = randToken(PAIR_TOKEN_BYTES)
169
+ const sn = randToken(PAIR_TOKEN_BYTES)
170
+ pending.set(token, { token, exp: Date.now() + PAIRING_TTL_MS, scope, ttlMs, label, sn, mode, account: acct, state: 'AWAITING_ENROLL' })
171
+ return { token, qr: { v: 2, iss, proxy, token, sn, m: mode, ...(acct ? { acct } : {}) }, expiresInMs: PAIRING_TTL_MS }
118
172
  }
119
173
 
120
174
  function stopPairing (token) { pending.delete(token) }
@@ -132,6 +186,28 @@ export function createEnrollDesk ({
132
186
  return null
133
187
  }
134
188
 
189
+ /**
190
+ * «¿Quién eres?» — la respuesta al QR corto. Solo se contesta a quien presente el
191
+ * `sn` de una sesión VIVA: el token de conexión son 4 caracteres y se puede acertar
192
+ * a ciegas, el `sn` no. Fuera de un emparejamiento no hay ninguna sesión y por lo
193
+ * tanto no hay respuesta: la puerta solo está abierta mientras dura el `pair`.
194
+ */
195
+ async function handleHello (from, p) {
196
+ const pend = pending.get(String(p?.sn || ''))
197
+ if (!pend || Date.now() > pend.exp) {
198
+ audit('rejected', { what: 'hello', reason: 'sin-sesion' })
199
+ return reply(from, { type: MSG_ERROR, error: 'no pairing session open for that code' })
200
+ }
201
+ // La respuesta va FIRMADA por la maestra y el `sn` va dentro de lo firmado. Eso ata
202
+ // la respuesta a ESTA sesión: no se puede reutilizar la de otro emparejamiento ni la
203
+ // de otra bóveda. Lo que NO hace es demostrar que sea TU bóveda —cualquiera puede
204
+ // firmar con una llave suya—; eso solo lo demuestra el código de 6 dígitos.
205
+ const body = { op: 'hello', sn: pend.sn, iss, proxy, acct: pend.account || '', m: pend.mode || 'join', ts: Date.now() }
206
+ const { signature } = await identity.signData(body)
207
+ reply(from, { type: MSG_HELLO_OK, body, signature })
208
+ return { ok: true }
209
+ }
210
+
135
211
  /**
136
212
  * ENROLL: el dispositivo prueba posesión de `D` firmando el sobre y deja el
137
213
  * COMPROMISO de su código. Todavía NO se firma ningún cert.
@@ -139,31 +215,42 @@ export function createEnrollDesk ({
139
215
  async function handleEnroll (from, p) {
140
216
  const d = p?.data
141
217
  if (!d || typeof d.dpub !== 'string' || typeof p.signature !== 'string') {
142
- return reply(from, { type: MSG_ERROR, error: 'enroll inválido' })
218
+ return reply(from, { type: MSG_ERROR, error: 'invalid enroll' })
143
219
  }
144
220
  const pend = pending.get(d.token)
145
221
  if (!pend || pend.state === 'DONE' || Date.now() > pend.exp) {
146
- return reply(from, { type: MSG_ERROR, error: 'token de emparejamiento inválido o expirado' })
222
+ return reply(from, { type: MSG_ERROR, error: 'invalid or expired pairing token' })
223
+ }
224
+ if (d.sn !== pend.sn) return reply(from, { type: MSG_ERROR, error: 'invalid session' })
225
+ // V7 · la INTENCIÓN viaja firmada y tiene que coincidir con el modo con el que ESTA
226
+ // bóveda abrió el emparejamiento. Es lo que garantiza que lo que pasa es lo que el
227
+ // humano vio anunciado en las dos pantallas, y no algo que se decidió a mitad de camino.
228
+ const intent = d.intent || 'join'
229
+ if (intent !== 'join' && intent !== 'adopt') {
230
+ return reply(from, { type: MSG_ERROR, error: 'unknown intent: ' + intent })
231
+ }
232
+ if (intent !== (pend.mode || 'join')) {
233
+ audit('rejected', { what: 'enroll', reason: 'intent-mismatch' })
234
+ return reply(from, { type: MSG_ERROR, error: `este emparejamiento se abrió para «${pend.mode || 'join'}» y el dispositivo pidió «${intent}»` })
147
235
  }
148
- if (d.sn !== pend.sn) return reply(from, { type: MSG_ERROR, error: 'sesión inválida' })
149
236
  if (!isFresh(d)) {
150
237
  audit('rejected', { what: 'enroll', reason: 'stale' })
151
- return reply(from, { type: MSG_ERROR, error: 'petición vencida: ts fuera de la ventana ±5 min (posible replay, o el reloj del dispositivo está desfasado)' })
238
+ return reply(from, { type: MSG_ERROR, error: 'stale request: ts outside the ±5 min window (possible replay, or the device clock is off)' })
152
239
  }
153
240
  // PRUEBA DE POSESIÓN: la firma de `data` debe verificar contra `dpub`.
154
241
  if (!(await verifyDeviceSig({ publickey: d.dpub, data: d, signature: p.signature }))) {
155
242
  audit('rejected', { what: 'enroll', reason: 'bad-device-signature' })
156
- return reply(from, { type: MSG_ERROR, error: 'firma de dispositivo inválida' })
243
+ return reply(from, { type: MSG_ERROR, error: 'invalid device signature' })
157
244
  }
158
245
  // El COMPROMISO del código es obligatorio: sin él no se puede comprobar al aprobar
159
246
  // y volveríamos a emitir certs a ciegas. Un cliente viejo cae acá con un mensaje claro.
160
247
  if (typeof d.commit !== 'string' || !/^[0-9a-f]{64}$/.test(d.commit)) {
161
248
  audit('rejected', { what: 'enroll', reason: 'no-commit' })
162
- return reply(from, { type: MSG_ERROR, error: 'este dispositivo usa una versión antigua del emparejamiento (no envía el compromiso del código). Actualízalo y vuelve a intentarlo.' })
249
+ return reply(from, { type: MSG_ERROR, error: 'this device speaks an old pairing version (no code commitment). Update it and try again.' })
163
250
  }
164
251
  // Un solo dispositivo a la vez esperando su código (así aprobar no es ambiguo).
165
252
  if (pend.state === 'PENDING_CONFIRM' && pend.dpub && pend.dpub !== d.dpub) {
166
- return reply(from, { type: MSG_ERROR, error: 'ya hay un dispositivo usando este emparejamiento' })
253
+ return reply(from, { type: MSG_ERROR, error: 'another device is already using this pairing session' })
167
254
  }
168
255
 
169
256
  const deviceId = await deviceIdOf(d.dpub)
@@ -182,9 +269,12 @@ export function createEnrollDesk ({
182
269
  }
183
270
  pend.from = from // la bóveda NO conoce el código: lo aprende cuando lo tipeas
184
271
  if (d.label) pend.label = String(d.label).slice(0, 60)
272
+ // Camino A: de qué cuenta estamos hablando. Se guarda para poder comprobar, cuando
273
+ // llegue el acta sellada, que es la que este dispositivo dijo que iba a entregar.
274
+ if (intent === 'adopt' && typeof d.profileId === 'string') pend.profileId = d.profileId
185
275
 
186
276
  reply(from, { type: MSG_ENROLL_CHALLENGE, deviceId })
187
- fire(onChallenge, { deviceId, scope: pend.scope, label: pend.label || '' })
277
+ fire(onChallenge, { deviceId, scope: pend.scope, label: pend.label || '', mode: pend.mode || 'join' })
188
278
  fire(onPendingChange)
189
279
  return { deviceId }
190
280
  }
@@ -199,16 +289,16 @@ export function createEnrollDesk ({
199
289
  */
200
290
  async function approve (code, { deviceId } = {}) {
201
291
  code = String(code || '').trim()
202
- if (!code) throw new Error('falta el código (los dígitos que muestra el dispositivo)')
292
+ if (!code) throw new Error('missing code (the digits shown by the device)')
203
293
 
204
294
  let pend
205
295
  if (deviceId) {
206
296
  pend = findPending(deviceId)
207
- if (!pend) throw new Error('no hay ninguna máquina esperando aprobación con ese identificador')
297
+ if (!pend) throw new Error('no device awaiting approval with that id')
208
298
  } else {
209
299
  const waiting = [...pending.values()].filter((p) => p.state === 'PENDING_CONFIRM' && p.dpub)
210
- if (waiting.length === 0) throw new Error('no hay ningún dispositivo esperando aprobación')
211
- if (waiting.length > 1) throw new Error('hay más de un emparejamiento en curso; reinícialo con dotrino-vault pair')
300
+ if (waiting.length === 0) throw new Error('no device awaiting approval')
301
+ if (waiting.length > 1) throw new Error('more than one pairing in flight; restart it with dotrino-vault pair')
212
302
  pend = waiting[0]
213
303
  }
214
304
 
@@ -216,8 +306,22 @@ export function createEnrollDesk ({
216
306
  const expected = await commitCode({ code, dpub: pend.dpub, sn: pend.sn })
217
307
  if (expected !== pend.commit) {
218
308
  audit('rejected', { what: 'approve', device: pend.deviceId, reason: 'bad-code' })
219
- log('[vault] código incorrecto para %s: no se emitió ningún certificado', pend.deviceId)
220
- throw new Error('el código no coincide con el que muestra el dispositivo: no se emitió ningún certificado. Vuelve a mirarlo y prueba otra vez.')
309
+ log('[vault] wrong code for %s: no certificate was issued', pend.deviceId)
310
+ throw new Error('code does not match the one shown by the device: no certificate was issued. Check it and try again.')
311
+ }
312
+
313
+ // CAMINO A · aquí la bóveda no entrega un cert: entrega SU IDENTIDAD para que el
314
+ // aparato la meta en el acta de la cuenta que le está pasando. El código de vuelta es
315
+ // la misma defensa de siempre, en el otro sentido: el aparato solo hace caso a una
316
+ // bóveda que demuestre que un humano la aprobó.
317
+ if ((pend.mode || 'join') === 'adopt') {
318
+ audit('adopt-approve', { device: pend.deviceId, profile: pend.profileId || null })
319
+ pend.state = 'AWAITING_ACTA'
320
+ pend.approvedAt = Date.now()
321
+ reply(pend.from, { type: MSG_ENROLL_ADOPT, code, pub: iss, encPub: encPub || null, label: vaultLabel || '' })
322
+ log('[vault] adoption approved for %s: waiting for the sealed record', pend.deviceId)
323
+ fire(onPendingChange)
324
+ return { ok: true, deviceId: pend.deviceId, adopting: true }
221
325
  }
222
326
 
223
327
  const { cert } = await identity.signDelegation(pend.dpub, pend.scope, { ttlMs: pend.ttlMs, label: pend.label })
@@ -247,13 +351,67 @@ export function createEnrollDesk ({
247
351
  return { ok: true, deviceId: pend.deviceId, cert }
248
352
  }
249
353
 
354
+ /**
355
+ * CAMINO A · paso 6: llega el acta que el aparato acaba de sellar, con la bóveda dentro
356
+ * como miembro, la clave de contenido envuelta para ella y el mando ya traspasado.
357
+ *
358
+ * Lo que se comprueba antes de guardar nada (y por qué):
359
+ * · que el sellador sea ESTA bóveda — si no, no es un traspaso, es un acta ajena;
360
+ * · que la selle el aparato que estaba en este emparejamiento — cierra que un tercero
361
+ * que vea pasar el mensaje cuele la suya;
362
+ * · que sea la cuenta que ese aparato declaró al enrolarse (`profileId`) — cierra el
363
+ * cambiazo de cuenta entre el anuncio que leyó el humano y lo que llega después.
364
+ *
365
+ * Adoptar la cuenta de otro solo procede sobre un perfil que **nació para eso** (la marca
366
+ * de `prepareForAdoption`). Es la misma regla del navegador: sin la marca, adoptar sería
367
+ * pisar una cuenta con datos, y eso no puede pasar por accidente.
368
+ */
369
+ async function handleActaSealed (from, p) {
370
+ const acta = p?.acta
371
+ const pend = [...pending.values()].find((x) => x.state === 'AWAITING_ACTA' && (x.from === from || x.dpub))
372
+ if (!pend) return reply(from, { type: MSG_ERROR, error: 'no adoption awaiting a record' })
373
+ if (!acta || typeof acta !== 'object') return reply(from, { type: MSG_ERROR, error: 'record missing or unreadable' })
374
+ if (acta.sealer !== iss) {
375
+ audit('rejected', { what: 'adopt', reason: 'not-sealer' })
376
+ return reply(from, { type: MSG_ERROR, error: 'that record does not name this vault as the sealer' })
377
+ }
378
+ if (acta.sealedBy !== pend.dpub) {
379
+ audit('rejected', { what: 'adopt', reason: 'sealed-by-other' })
380
+ return reply(from, { type: MSG_ERROR, error: 'that record was not sealed by the device of this pairing' })
381
+ }
382
+ if (pend.profileId && acta.profileId !== pend.profileId) {
383
+ audit('rejected', { what: 'adopt', reason: 'other-profile' })
384
+ return reply(from, { type: MSG_ERROR, error: 'that record belongs to an account other than the one the device announced' })
385
+ }
386
+
387
+ try {
388
+ const r = await identity.joinProfile(acta)
389
+ if (!r?.joined) throw new Error(r?.reason || 'could not adopt')
390
+ audit('adopt', { device: pend.deviceId, profile: acta.profileId, seq: acta.seq })
391
+ // El acta que vuelve es la que la bóveda tiene guardada: el aparato la adopta y los
392
+ // dos quedan en la misma versión.
393
+ const mia = (await identity.profileActa?.())?.acta || acta
394
+ reply(pend.from, { type: MSG_ACTA_ADOPTED, code: p.code, acta: mia })
395
+ pend.state = 'DONE'
396
+ pending.delete(pend.token)
397
+ fire(onPendingChange)
398
+ fire(onAdopted, { deviceId: pend.deviceId, profileId: acta.profileId, seq: mia.seq })
399
+ log('[vault] cuenta adoptada del dispositivo %s (perfil %s)', pend.deviceId, acta.profileId?.slice(0, 12))
400
+ return { ok: true, adopted: true, profileId: acta.profileId, seq: mia.seq }
401
+ } catch (e) {
402
+ log('[vault] no se pudo adoptar la cuenta: %s', e.message)
403
+ reply(pend.from, { type: MSG_ERROR, error: 'the vault could not adopt the account: ' + e.message })
404
+ return { ok: false, error: e.message }
405
+ }
406
+ }
407
+
250
408
  /** Rechaza un enrolamiento pendiente. */
251
409
  function reject (deviceId) {
252
410
  const pend = deviceId
253
411
  ? findPending(deviceId)
254
412
  : [...pending.values()].find((p) => p.state === 'PENDING_CONFIRM')
255
413
  if (!pend) return { ok: false }
256
- reply(pend.from, { type: MSG_ERROR, error: 'emparejamiento rechazado' })
414
+ reply(pend.from, { type: MSG_ERROR, error: 'pairing rejected' })
257
415
  pending.delete(pend.token)
258
416
  audit('reject', { device: pend.deviceId })
259
417
  fire(onPendingChange)
@@ -270,7 +428,7 @@ export function createEnrollDesk ({
270
428
  const body = { op: 'revoke', sub: dpub, nonce, iat: Date.now(), exp: Date.now() + DEVICE_TTL_MS }
271
429
  const { signature } = await identity.signData(body)
272
430
  try { sendByPubkey(dpub, { type: MSG_REVOKED, body, signature }) }
273
- catch (e) { log('[vault] no se pudo emitir revoke:', e.message) }
431
+ catch (e) { log('[vault] could not emit revoke:', e.message) }
274
432
  }
275
433
 
276
434
  /** Revoca una delegación por `nonce` y avisa al dispositivo para que se autoborre. */
@@ -284,7 +442,7 @@ export function createEnrollDesk ({
284
442
  }
285
443
 
286
444
  return {
287
- startPairing, stopPairing, handleEnroll, approve, reject,
445
+ startPairing, stopPairing, handleEnroll, handleActaSealed, handleHello, approve, reject,
288
446
  listPending, findPending, emitRevoke, revoke,
289
447
  get pendingCount () { return pending.size }
290
448
  }
@@ -27,16 +27,13 @@
27
27
  */
28
28
  import { verifyChain } from '@dotrino/identity/capabilities'
29
29
  import { createEnrollDesk, deviceIdOf, DEVICE_TTL_MS, FRESH_WINDOW_MS } from './enroll.js'
30
+ // Las constantes del protocolo salen del MISMO módulo que usa el daemon: si la lista
31
+ // local se queda corta, el dispositivo deja de atender mensajes sin que nadie lo note.
32
+ import { MSG, SCOPE } from './protocol.js'
30
33
 
31
- const SIGN_SCOPE = 'vault:sign'
34
+ const SIGN_SCOPE = SCOPE.SIGN
32
35
  const SELFCERT_TTL_MS = 24 * 60 * 60 * 1000 // el self-cert P←P se regenera cada 24 h
33
-
34
- const MSG = {
35
- ENROLL: 'vault.enroll',
36
- DEVICES: 'vault.devices',
37
- DEVICES_RESULT: 'vault.devices.result',
38
- ERROR: 'vault.error'
39
- }
36
+ const RENEW_TTL_MS = DEVICE_TTL_MS // la renovación extiende la misma ventana (30 días)
40
37
 
41
38
  /** deviceId legible (p. ej. `C440-AC0E`) desde una pubkey JWK. */
42
39
  export { deviceIdOf }
@@ -49,10 +46,11 @@ export { deviceIdOf }
49
46
  * `me.publickey`, `signData`, `signDelegation`, `listDelegations`, `revokeDelegation`.
50
47
  * @param {object} [opts]
51
48
  * @param {string} [opts.proxyUrl='wss://proxy.dotrino.com']
52
- * @returns {Promise<object>} handle: { iss, proxy, client, startPairing, approve, reject,
53
- * listPending, listMachines, revoke, getSelfCert, onPendingChange, close }
49
+ * @returns {Promise<object>} handle: { iss, proxy, client, startPairing, stopPairing,
50
+ * approve, reject, listPending, listMachines, revoke, getSelfCert, onPendingChange,
51
+ * onAdopted, close }
54
52
  */
55
- export async function startDeviceVault (identity, { proxyUrl } = {}) {
53
+ export async function startDeviceVault (identity, { proxyUrl, client: injectedClient } = {}) {
56
54
  const iss = identity.me?.publickey
57
55
  if (!iss) throw new Error('sin identidad: crea/desbloquea tu identidad antes de usar el dispositivo como bóveda')
58
56
  const proxy = proxyUrl || 'wss://proxy.dotrino.com'
@@ -67,12 +65,17 @@ export async function startDeviceVault (identity, { proxyUrl } = {}) {
67
65
  return cert
68
66
  }
69
67
 
70
- const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
71
- const client = new WebSocketProxyClient({
72
- url: proxy, enableWebRTC: false, autoReconnect: true,
73
- maxReconnectAttempts: 100000, reconnectDelay: 4000
74
- })
75
- await client.connect()
68
+ // `client` inyectado: solo para las pruebas (transporte de mentira). En producción se
69
+ // levanta el del ecosistema — no hay otro transporte.
70
+ const client = injectedClient || await (async () => {
71
+ const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
72
+ const c = new WebSocketProxyClient({
73
+ url: proxy, enableWebRTC: false, autoReconnect: true,
74
+ maxReconnectAttempts: 100000, reconnectDelay: 4000
75
+ })
76
+ await c.connect()
77
+ return c
78
+ })()
76
79
 
77
80
  const selfCert = await getSelfCert()
78
81
  const identify = async () => {
@@ -87,6 +90,7 @@ export async function startDeviceVault (identity, { proxyUrl } = {}) {
87
90
  const send = (to, obj) => { try { client.send(to, obj) } catch (_) {} }
88
91
 
89
92
  let _onPendingChange = () => {}
93
+ let _onAdopted = () => {}
90
94
 
91
95
  // ENROLL / aprobación / revocación: núcleo COMPARTIDO con el daemon del PC y con la
92
96
  // copia vendorizada del iframe (`lib/src/enroll.js`). Un solo sitio donde vive el
@@ -99,9 +103,49 @@ export async function startDeviceVault (identity, { proxyUrl } = {}) {
99
103
  sendByPubkey: (pub, obj) => { try { client.sendByPubkey(pub, obj) } catch (_) {} },
100
104
  defaultScope: [SIGN_SCOPE],
101
105
  defaultTtlMs: DEVICE_TTL_MS,
106
+ // Camino A (la cuenta del aparato pasa a vivir aquí): sin la llave de cifrado, esta
107
+ // bóveda entraría mandando una cuenta cuyo contenido no puede abrir.
108
+ encPub: identity.me?.encryptionPubkey || null,
109
+ vaultLabel: 'bóveda',
110
+ // Cita del proxio para la invitación corta (QR). Si el proxio es viejo y no las
111
+ // conoce, el desk se cae solo a la invitación larga.
112
+ connToken: async () => {
113
+ try { return (await client.requestPairingCode())?.code || null }
114
+ catch (_) { return null }
115
+ },
116
+ onAdopted: (info) => { try { _onAdopted(info) } catch (_) {} },
102
117
  onPendingChange: () => _onPendingChange()
103
118
  })
104
119
 
120
+ /** Nonces revocados, para que un cert revocado no pase ningún `verifyChain`. */
121
+ async function revocationSet () {
122
+ const { revoked } = await identity.listDelegations()
123
+ return new Set((revoked || []).map((r) => r.nonce || r))
124
+ }
125
+
126
+ /**
127
+ * RENOVACIÓN automática (igual que `dotrino-vault#handleRenew`): un dispositivo con
128
+ * cert VIGENTE y no revocado pide uno fresco —misma sub-clave y scope— sin QR ni
129
+ * aprobación: sigue siendo el mismo dispositivo, solo extiende la ventana. Un cert
130
+ * vencido o revocado NO se renueva (ahí toca re-emparejar con aprobación).
131
+ *
132
+ * Sin esto, toda máquina enrolada contra un dispositivo-bóveda caduca a los 30 días.
133
+ */
134
+ async function handleRenew (from, p) {
135
+ const d = p?.data
136
+ if (!d || !p.signature || !p.cert) return send(from, { type: MSG.ERROR, error: 'petición inválida' })
137
+ if (typeof d.ts !== 'number' || Math.abs(Date.now() - d.ts) > FRESH_WINDOW_MS) {
138
+ return send(from, { type: MSG.ERROR, error: 'stale request: ts outside the ±5 min window (possible replay, or the device clock is off)' })
139
+ }
140
+ const chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, trustedIssuer: iss, revoked: await revocationSet() })
141
+ if (!chk.ok) return send(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason })
142
+ // Reusar el label del cert original (si sigue registrado en delegations).
143
+ const { issued } = await identity.listDelegations()
144
+ 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 })
147
+ }
148
+
105
149
  // Consulta de revocaciones (igual que `vault.devices` del daemon): responde la lista
106
150
  // de dispositivos enrolados + revocados para que el dispositivo refresque su set. Y si
107
151
  // QUIEN consulta es una máquina ya revocada (reapareció), le re-emite el REVOKED firmado.
@@ -124,7 +168,12 @@ export async function startDeviceVault (identity, { proxyUrl } = {}) {
124
168
 
125
169
  client.on('message', (_from, p) => {
126
170
  if (!p || typeof p !== 'object') return
127
- if (p.type === MSG.ENROLL) desk.handleEnroll(_from, p).catch(() => {})
171
+ // El QR corto no lleva la llave: el aparato la pide con un HELLO presentando el `sn`.
172
+ if (p.type === MSG.HELLO) Promise.resolve(desk.handleHello(_from, p)).catch(() => {})
173
+ else if (p.type === MSG.ENROLL) desk.handleEnroll(_from, p).catch(() => {})
174
+ // Camino A: el aparato devuelve su acta sellada admitiendo a esta bóveda.
175
+ else if (p.type === MSG.ACTA_SEALED) Promise.resolve(desk.handleActaSealed(_from, p)).catch(() => {})
176
+ else if (p.type === MSG.RENEW) handleRenew(_from, p).catch(() => {})
128
177
  else if (p.type === MSG.DEVICES) handleDevices(_from, p).catch(() => {})
129
178
  })
130
179
 
@@ -148,6 +197,7 @@ export async function startDeviceVault (identity, { proxyUrl } = {}) {
148
197
  return {
149
198
  iss, proxy, client,
150
199
  startPairing: desk.startPairing,
200
+ stopPairing: desk.stopPairing,
151
201
  // Aprueba TIPEANDO el código que muestra la máquina: el núcleo compartido recompone
152
202
  // el compromiso `SHA-256(code‖dpub‖sn)` y solo firma el cert si coincide.
153
203
  approve: (deviceId, code) => desk.approve(code, { deviceId }),
@@ -159,6 +209,8 @@ export async function startDeviceVault (identity, { proxyUrl } = {}) {
159
209
  revoke: (nonce) => desk.revoke(nonce),
160
210
  getSelfCert,
161
211
  onPendingChange (fn) { _onPendingChange = fn || (() => {}) },
212
+ /** Camino A: la cuenta del aparato quedó adoptada por esta bóveda. */
213
+ onAdopted (fn) { _onAdopted = fn || (() => {}) },
162
214
  close () { try { client.close() } catch (_) {} }
163
215
  }
164
216
  }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Protocolo de mensajes entre un dispositivo y el vault (viajan por el proxy,
3
+ * direccionados por pubkey con `sendByPubkey`). El cuerpo va JSON-serializado en
4
+ * el campo `message` del sobre del proxy; el cliente lo entrega ya parseado.
5
+ *
6
+ * Emparejamiento ENDURECIDO (ver dotrino-vault/docs/pairing-protocol.md):
7
+ * 1. dispositivo → vault ENROLL { data:{op,dpub,token,sn,label,ts}, signature }
8
+ * (la firma es del dispositivo con su llave D = PRUEBA DE POSESION; un token
9
+ * robado ya NO basta para enrolar).
10
+ * 2. vault → dispositivo ENROLL_CHALLENGE { deviceId, sas } (aun NO firma cert)
11
+ * 3. el dueño compara el SAS (pantalla del dispositivo ↔ del PC) y APRUEBA en el PC
12
+ * 4. vault → dispositivo ENROLLED { cert, iss, sas } (recien aqui firma el cert)
13
+ * 5. el dispositivo VALIDA la cadena: cert.iss === el iss que vio, cert.sub === D.
14
+ *
15
+ * Revocacion (robo): el vault envia REVOKED { body, signature } FIRMADO por la
16
+ * maestra → el dispositivo se autoborra SOLO si la firma valida contra la maestra
17
+ * pineada (cierra el wipe-DoS; un ERROR plano jamas borra).
18
+ */
19
+ export const MSG = Object.freeze({
20
+ // La invitación corta no lleva la llave: el aparato la pide presentando el `sn` de
21
+ // la sesión. Una pública es pública — esto no la esconde, solo evita abrirle la
22
+ // puerta a quien acertó el token de conexión a ciegas.
23
+ HELLO: 'vault.hello', // dispositivo → vault: { sn }
24
+ HELLO_OK: 'vault.hello.ok', // vault → dispositivo: { iss, acct }
25
+ ENROLL: 'vault.enroll', // dispositivo → vault: { data, signature }
26
+ ENROLL_CHALLENGE: 'vault.enroll.challenge', // vault → dispositivo: { deviceId, sas }
27
+ ENROLLED: 'vault.enrolled', // vault → dispositivo (tras aprobar): { cert, iss, sas }
28
+ // Camino A (la cuenta del aparato pasa a vivir en la bóveda): en vez de un cert, la
29
+ // bóveda manda QUIÉN es para que el aparato la admita, le envuelva la clave de
30
+ // contenido y le traspase el mando; el aparato devuelve el acta sellada y la bóveda
31
+ // responde con la definitiva. Ver docs/vinculacion-de-cuentas.md §2.
32
+ ENROLL_ADOPT: 'vault.enroll.adopt', // vault → dispositivo: { code, pub, encPub, label }
33
+ ACTA_SEALED: 'vault.acta.sealed', // dispositivo → vault: { acta, code }
34
+ ACTA_ADOPTED: 'vault.acta.adopted', // vault → dispositivo: { acta }
35
+ REVOKED: 'vault.revoked', // vault → dispositivo: { body:{op,sub,nonce,iat,exp}, signature }
36
+ SIGN: 'vault.sign', // dispositivo → vault: { data, signature, cert }
37
+ SIGNED: 'vault.signed', // vault → dispositivo: { signature, publickey, device }
38
+ GET: 'vault.get', // dispositivo → vault: { data, signature, cert }
39
+ DATA: 'vault.data', // vault → dispositivo: { id, node }
40
+ STORE: 'vault.store', // dispositivo → vault: { data:{method,args,publickey,ts}, signature, cert }
41
+ STORE_RESULT: 'vault.store.result', // vault → dispositivo: { method, result }
42
+ DEVICES: 'vault.devices', // dispositivo → vault: { data:{publickey,ts}, signature, cert }
43
+ DEVICES_RESULT: 'vault.devices.result', // vault → dispositivo: { devices, revoked }
44
+ RENEW: 'vault.renew', // dispositivo → vault: { data:{op,publickey,ts}, signature, cert }
45
+ RENEWED: 'vault.renewed', // vault → dispositivo: { cert } (cert fresco, misma sub-clave/scope)
46
+ SECRETS: 'vault.secrets', // servicio → vault: { data:{op,ns,ek,publickey,ts}, signature, cert }
47
+ SECRETS_RESULT: 'vault.secrets.result', // vault → servicio: { body:{op,ns,enc,ts}, signature } (enc SELLADO a ek; body firmado por la maestra)
48
+ // AVISO DE CAMBIO (no lleva valores): la bóveda dice «la configuración del ns
49
+ // cambió». El agente no la recarga en caliente — SALE limpio y su supervisor lo
50
+ // levanta. Dos razones, y la segunda es la de peso:
51
+ // · Lee todo fresco. Recargar en caliente exige que cada sitio que leyó una
52
+ // variable sepa releerla, y esa lista hay que mantenerla para siempre.
53
+ // · BORRA DE MEMORIA EL VALOR VIEJO. En JavaScript un secreto no se puede
54
+ // borrar: los strings son inmutables, no hay zeroize, y el valor queda en el
55
+ // heap hasta que al recolector le apetezca — más lo que capturó cada closure
56
+ // y cada caché derivada. Una llave se rota casi siempre PORQUE SE FILTRÓ, así
57
+ // que dejarla viva en el proceso anula la razón de rotarla. Un proceso nuevo
58
+ // empieza con el heap limpio.
59
+ // Va FIRMADO por la maestra y el agente lo verifica contra su `iss` pineada: un
60
+ // aviso de reinicio sin autenticar ES un ataque de denegación.
61
+ SECRETS_CHANGED: 'vault.secrets.changed', // vault → servicio: { body:{op,ns,ts}, signature }
62
+ // --- CONSOLA REMOTA (docs/consola-remota.md) — requiere cert `vault:admin` ---
63
+ // Un solo mensaje con `data.op`: pending · pair · approve · reject · revoke · audit.
64
+ // Admitir y expulsar, nada más: cambiar permisos, traspasar el mando y los secretos
65
+ // NO se exponen aquí, y no es un olvido — es el límite (§2 del diseño).
66
+ ADMIN: 'vault.admin', // admin → vault: { data:{op,…,ts,nonce}, signature, cert }
67
+ ADMIN_RESULT: 'vault.admin.result', // vault → admin: { op, result }
68
+ // Aviso a TODOS los miembros de que el perfil cambió (alguien entró o salió). Es la
69
+ // contrapartida de administrar a distancia: sin esto, un enrolamiento remoto sería
70
+ // invisible para el resto de tus dispositivos.
71
+ ADMIN_EVENT: 'vault.admin.event', // vault → todos: { body:{ev,deviceId,by,ts}, signature }
72
+ ERROR: 'vault.error' // vault → dispositivo: { error }
73
+ })
74
+
75
+ /** Capacidades que puede llevar un `cert` (scope). Mínimo por defecto. */
76
+ export const SCOPE = Object.freeze({
77
+ SIGN: 'vault:sign', // pedir a la maestra que firme datos (identidad)
78
+ READ: 'vault:read', // leer nodos del árbol de contenidos
79
+ STORE: 'vault:store', // leer/escribir el store de hilos + aperturas del usuario
80
+ // Consola remota (docs/consola-remota.md): admitir y expulsar miembros a distancia.
81
+ // NO incluye cambiar permisos, traspasar el mando ni conceder `admin`: eso es el rol
82
+ // de master y sigue siendo local. No se empareja — se concede desde el PC.
83
+ ADMIN: 'vault:admin'
84
+ })
85
+
86
+ /**
87
+ * Scope de SECRETOS por namespace de servicio: un cert con `vault:secrets:proxy`
88
+ * solo puede leer los secretos del ns `proxy` — un VPS comprometido no puede
89
+ * pedir los de otro servicio. ns válido: [a-z0-9-]{1,32}.
90
+ */
91
+ export const SECRETS_SCOPE_PREFIX = 'vault:secrets:'
92
+ export const secretsScope = (ns) => SECRETS_SCOPE_PREFIX + ns
93
+ export const isValidSecretsNs = (ns) => typeof ns === 'string' && /^[a-z0-9-]{1,32}$/.test(ns)