@dotrino/identity 0.118.0 → 0.120.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.
@@ -28,6 +28,7 @@
28
28
  */
29
29
  import { canonicalStringify } from './canonical.js'
30
30
  import { verifyData, samePubkey } from './signature.js'
31
+ import { errorWithCode } from './errors.js'
31
32
 
32
33
  export const ENCPUB_V = 1
33
34
 
@@ -54,17 +55,6 @@ export function isEncPub (v) {
54
55
  }
55
56
  }
56
57
 
57
- /**
58
- * @param {string} mensaje
59
- * @param {string} code
60
- * @returns {Error & { code: string }}
61
- */
62
- function errorCon (mensaje, code) {
63
- const e = /** @type {Error & { code: string }} */ (new Error(mensaje))
64
- e.code = code
65
- return e
66
- }
67
-
68
58
  /**
69
59
  * Firma el anuncio con lo que firma la identidad (`identity.signData`, o la llave local
70
60
  * del cliente). No genera llaves ni cifra nada: solo dice de quién es la que ya tienes.
@@ -73,15 +63,15 @@ function errorCon (mensaje, code) {
73
63
  * @returns {Promise<{data:any, signature:string}>}
74
64
  */
75
65
  export async function buildEncPubStatement ({ publickey, encPub, sign } = /** @type {any} */ ({})) {
76
- if (typeof publickey !== 'string' || !publickey) throw errorCon('buildEncPubStatement: missing publickey', 'encpub-shape')
77
- if (!isEncPub(encPub)) throw errorCon('buildEncPubStatement: encPub is not a P-256 public JWK', 'encpub-shape')
78
- if (typeof sign !== 'function') throw errorCon('buildEncPubStatement: missing sign(data)', 'encpub-shape')
66
+ if (typeof publickey !== 'string' || !publickey) throw errorWithCode('buildEncPubStatement: missing publickey', 'encpub-shape')
67
+ if (!isEncPub(encPub)) throw errorWithCode('buildEncPubStatement: encPub is not a P-256 public JWK', 'encpub-shape')
68
+ if (typeof sign !== 'function') throw errorWithCode('buildEncPubStatement: missing sign(data)', 'encpub-shape')
79
69
  const data = encPubBody({ publickey, encPub })
80
70
  const firmado = await sign(data)
81
71
  const signature = typeof firmado === 'string' ? firmado : firmado?.signature
82
72
  // «No pude firmar» no es «se cayó la red», y aquí es donde se separan: quien llama
83
73
  // decide una cosa u otra según el `code`, nunca según la frase.
84
- if (typeof signature !== 'string') throw errorCon('buildEncPubStatement: sign() returned no signature', 'no-signature')
74
+ if (typeof signature !== 'string') throw errorWithCode('buildEncPubStatement: sign() returned no signature', 'no-signature')
85
75
  return { data, signature }
86
76
  }
87
77
 
@@ -98,21 +88,21 @@ export async function buildEncPubStatement ({ publickey, encPub, sign } = /** @t
98
88
  */
99
89
  export async function readEncPubStatement (statement, { publickey } = /** @type {any} */ ({})) {
100
90
  if (typeof publickey !== 'string' || !publickey) {
101
- throw errorCon('readEncPubStatement: missing the publickey to check against', 'encpub-shape')
91
+ throw errorWithCode('readEncPubStatement: missing the publickey to check against', 'encpub-shape')
102
92
  }
103
93
  const data = statement?.data
104
94
  const signature = statement?.signature
105
- if (!data || typeof signature !== 'string') throw errorCon('encpub statement: malformed', 'encpub-unverified')
106
- if (data.v !== ENCPUB_V || data.op !== 'encpub') throw errorCon('encpub statement: not an encpub announcement', 'encpub-unverified')
107
- if (data.aud !== ENCPUB_AUD) throw errorCon('encpub statement: wrong audience', 'encpub-unverified')
95
+ if (!data || typeof signature !== 'string') throw errorWithCode('encpub statement: malformed', 'encpub-unverified')
96
+ if (data.v !== ENCPUB_V || data.op !== 'encpub') throw errorWithCode('encpub statement: not an encpub announcement', 'encpub-unverified')
97
+ if (data.aud !== ENCPUB_AUD) throw errorWithCode('encpub statement: wrong audience', 'encpub-unverified')
108
98
  // `samePubkey` y no `===`: un JWK serializado no es canónico y la misma llave escrita
109
99
  // por dos piezas distintas da dos strings distintos.
110
- if (!samePubkey(data.publickey, publickey)) throw errorCon('encpub statement: announces another identity', 'encpub-unverified')
111
- if (!isEncPub(data.encpub)) throw errorCon('encpub statement: not a P-256 public JWK', 'encpub-unverified')
100
+ if (!samePubkey(data.publickey, publickey)) throw errorWithCode('encpub statement: announces another identity', 'encpub-unverified')
101
+ if (!isEncPub(data.encpub)) throw errorWithCode('encpub statement: not a P-256 public JWK', 'encpub-unverified')
112
102
  // Se verifica con la llave que ANUNCIA el sobre, que ya se comprobó que es la misma que
113
103
  // la pedida: así el texto firmado y la llave que lo comprueba salen del mismo sitio.
114
104
  if (!(await verifyData(data.publickey, data, signature))) {
115
- throw errorCon('encpub statement: bad signature — the key is not bound to that identity', 'encpub-unverified')
105
+ throw errorWithCode('encpub statement: bad signature — the key is not bound to that identity', 'encpub-unverified')
116
106
  }
117
107
  return data.encpub
118
108
  }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Error con un `code` estable.
3
+ *
4
+ * El contrato es el CODE, no la frase: comprobar por el texto cruza procesos y
5
+ * se rompe en silencio en cuanto alguien lo reescribe o lo traduce.
6
+ *
7
+ * @param {string} message
8
+ * @param {string} code
9
+ * @returns {Error & { code: string }}
10
+ */
11
+ export function errorWithCode (message, code) {
12
+ const e = /** @type {Error & { code: string }} */ (new Error(message))
13
+ e.code = code
14
+ return e
15
+ }
@@ -15,14 +15,9 @@
15
15
  * second, older copy of a pillar inside every consumer.
16
16
  */
17
17
 
18
- const ECDH = { name: 'ECDH', namedCurve: 'P-256' }
18
+ import { errorWithCode } from './errors.js'
19
19
 
20
- /** Un error con `code`: quien llama decide por el código, nunca por la frase. */
21
- function errorCon (mensaje, code) {
22
- const e = /** @type {Error & { code: string }} */ (new Error(mensaje))
23
- e.code = code
24
- return e
25
- }
20
+ const ECDH = { name: 'ECDH', namedCurve: 'P-256' }
26
21
 
27
22
  const VERSION = 1
28
23
 
@@ -137,7 +132,7 @@ export function identitySealing (identity, { app = 'dotrino' } = {}) {
137
132
 
138
133
  return {
139
134
  async seal (msg, peerEncPub, { publickey } = {}) {
140
- if (!peerEncPub) throw errorCon('no encryption key for the other side', 'unsealed')
135
+ if (!peerEncPub) throw errorWithCode('no encryption key for the other side', 'unsealed')
141
136
  // Destinatarios como OBJETOS, y CON SU `publickey`: `encrypt` expande cada uno a
142
137
  // todos los aparatos de esa persona por su tarjeta de perfil, y la tarjeta se busca
143
138
  // por la pubkey. Sin ella el sobre solo lo abría el aparato que anunció la llave, y
@@ -149,7 +144,7 @@ export function identitySealing (identity, { app = 'dotrino' } = {}) {
149
144
  // VACÍO: cifrado de verdad, y que no abre nadie. Eso no es un sobre, es un mensaje
150
145
  // perdido con cara de enviado.
151
146
  if (!sealed || !sealed.wrap || Object.keys(sealed.wrap).length === 0) {
152
- throw errorCon('identitySealing: the vault wrapped the message for nobody', 'unsealed')
147
+ throw errorWithCode('identitySealing: the vault wrapped the message for nobody', 'unsealed')
153
148
  }
154
149
  return { app, sealed, from: await myEncPub() }
155
150
  },
@@ -189,20 +184,20 @@ export function isBinSealed (env) {
189
184
  /** Una CEK nueva para un mensaje con bytes. */
190
185
  export async function makeMessageKey () {
191
186
  const { makeContentKey } = await crypto_()
192
- if (typeof makeContentKey !== 'function') throw errorCon('sealing bytes needs @dotrino/identity >= 0.117.0', 'no-bytes-sealing')
187
+ if (typeof makeContentKey !== 'function') throw errorWithCode('sealing bytes needs @dotrino/identity >= 0.117.0', 'no-bytes-sealing')
193
188
  return makeContentKey()
194
189
  }
195
190
 
196
191
  /** Cifra `bytes` con la CEK y devuelve los campos del sobre (`iv`, `ct`). */
197
192
  export async function encryptBytes (cek, bytes) {
198
193
  const { encryptBytesWithCek } = await crypto_()
199
- if (typeof encryptBytesWithCek !== 'function') throw errorCon('sealing bytes needs @dotrino/identity >= 0.117.0', 'no-bytes-sealing')
194
+ if (typeof encryptBytesWithCek !== 'function') throw errorWithCode('sealing bytes needs @dotrino/identity >= 0.117.0', 'no-bytes-sealing')
200
195
  return encryptBytesWithCek({ cek, bytes })
201
196
  }
202
197
 
203
198
  /** Descifra el `ct` de un sobre binario con su CEK ya abierta. */
204
199
  export async function decryptBytes (cek, env) {
205
200
  const { decryptBytesWithCek } = await crypto_()
206
- if (typeof decryptBytesWithCek !== 'function') throw errorCon('opening bytes needs @dotrino/identity >= 0.117.0', 'no-bytes-sealing')
201
+ if (typeof decryptBytesWithCek !== 'function') throw errorWithCode('opening bytes needs @dotrino/identity >= 0.117.0', 'no-bytes-sealing')
207
202
  return decryptBytesWithCek({ cek, iv: env.iv, ct: env.ct })
208
203
  }
@@ -1,3 +1,3 @@
1
- Copia vendorizada de @dotrino/roadmap@0.4.20 (dotrino-roadmap/src/{index,manifests}.js).
1
+ Copia vendorizada de @dotrino/roadmap@0.4.30 (dotrino-roadmap/src/{index,manifests}.js).
2
2
  NO se edita a mano: la escribe `node vendor.mjs` y la vigila test/vendor-up-to-date.test.mjs.
3
3
  El registro de versiones rotas que viaja con cada versión. Importa @dotrino/compat/ranges.
@@ -8,8 +8,8 @@
8
8
  export const MANIFESTS = Object.freeze({
9
9
  "dotrino": {
10
10
  "manifest": "dotrino",
11
- "seq": 29,
12
- "issued": "2026-10-15",
11
+ "seq": 30,
12
+ "issued": "2026-10-16",
13
13
  "note": "Registro comun de compatibilidad del ecosistema. Se edita a mano en este repo y se consulta con @dotrino/roadmap. Rangos: ver @dotrino/compat/ranges (x.y.z, >= > <= <, «a - b», «*», o una lista que es un O).",
14
14
  "products": {
15
15
  "compat": {
@@ -22,18 +22,20 @@ export const MANIFESTS = Object.freeze({
22
22
  "env": {
23
23
  "repo": "imdotrino/dotrino-vault",
24
24
  "npm": "@dotrino/env",
25
- "current": "0.85.0",
25
+ "current": "0.90.1",
26
26
  "protocol": 1,
27
27
  "requires": {
28
- "vault": ">=0.70.0"
28
+ "vault": ">=0.90.1"
29
29
  }
30
30
  },
31
31
  "identity": {
32
32
  "repo": "imdotrino/dotrino-identity",
33
33
  "npm": "@dotrino/identity",
34
- "current": "0.117.2",
34
+ "current": "0.119.0",
35
35
  "protocol": 1,
36
- "requires": {}
36
+ "requires": {
37
+ "proxy-client": ">=0.13.1"
38
+ }
37
39
  },
38
40
  "lobby": {
39
41
  "repo": "imdotrino/dotrino-lobby",
@@ -107,12 +109,12 @@ export const MANIFESTS = Object.freeze({
107
109
  },
108
110
  "proxy": {
109
111
  "repo": "imdotrino/dotrino-proxy",
110
- "current": "1.6.1",
112
+ "current": "1.6.4",
111
113
  "protocol": 2,
112
114
  "requires": {
113
- "vault": ">=0.86.0",
114
- "identity": ">=0.116.0",
115
- "proxy-client": ">=0.23.1",
115
+ "vault": ">=0.87.0",
116
+ "identity": ">=0.117.2",
117
+ "proxy-client": ">=0.36.1",
116
118
  "content": "0.5.3+"
117
119
  }
118
120
  },
@@ -169,7 +171,7 @@ export const MANIFESTS = Object.freeze({
169
171
  },
170
172
  "terminal-desktop": {
171
173
  "repo": "imdotrino/dotrino-terminal",
172
- "current": "0.2.74",
174
+ "current": "0.2.77",
173
175
  "protocol": 1,
174
176
  "requires": {
175
177
  "terminal-agent": ">=0.37.0"
@@ -179,42 +181,43 @@ export const MANIFESTS = Object.freeze({
179
181
  "repo": "imdotrino/dotrino-ia",
180
182
  "npm": "@dotrino/ia-agent",
181
183
  "deprecated": "2026-10-07: retired, replaced by terminal-agent (dotrino-terminal)",
182
- "current": "0.8.1",
184
+ "current": "0.9.0",
183
185
  "protocol": 1,
184
186
  "requires": {
185
- "remote-agent": ">=0.13.0",
186
- "identity": ">=0.92.0",
187
- "vault": ">=0.75.0"
187
+ "remote-agent": ">=0.20.1",
188
+ "identity": ">=0.117.2",
189
+ "vault": ">=0.87.0",
190
+ "proxy-client": ">=0.36.1"
188
191
  }
189
192
  },
190
193
  "content": {
191
194
  "repo": "imdotrino/dotrino-content",
192
195
  "npm": "@dotrino/content",
193
- "current": "0.5.3",
196
+ "current": "0.6.0",
194
197
  "protocol": 1,
195
198
  "requires": {
196
- "remote-agent": ">=0.13.0",
197
- "identity": ">=0.116.0",
198
- "vault": ">=0.86.0",
199
- "proxy-client": ">=0.23.1"
199
+ "remote-agent": ">=0.20.1",
200
+ "identity": ">=0.117.2",
201
+ "vault": ">=0.87.0",
202
+ "proxy-client": ">=0.36.1"
200
203
  }
201
204
  },
202
205
  "sealers": {
203
206
  "repo": "imdotrino/dotrino-sealers",
204
207
  "npm": "@dotrino/sealers",
205
- "current": "0.9.2",
208
+ "current": "0.10.0",
206
209
  "protocol": 1,
207
210
  "requires": {
208
- "remote-agent": ">=0.13.0",
209
- "identity": ">=0.116.0",
210
- "vault": ">=0.86.0",
211
- "proxy-client": ">=0.23.1"
211
+ "remote-agent": ">=0.20.1",
212
+ "identity": ">=0.117.2",
213
+ "vault": ">=0.87.0",
214
+ "proxy-client": ">=0.36.1"
212
215
  }
213
216
  },
214
217
  "roadmap": {
215
218
  "repo": "imdotrino/dotrino-roadmap",
216
219
  "npm": "@dotrino/roadmap",
217
- "current": "0.4.14",
220
+ "current": "0.4.30",
218
221
  "protocol": 1,
219
222
  "requires": {
220
223
  "compat": "0.4.0+"
@@ -250,32 +253,32 @@ export const MANIFESTS = Object.freeze({
250
253
  "vault": {
251
254
  "repo": "imdotrino/dotrino-vault",
252
255
  "npm": "@dotrino/vault",
253
- "current": "0.87.0",
256
+ "current": "0.90.1",
254
257
  "protocol": 1,
255
258
  "requires": {
256
259
  "vaultd": ">=0.105.0",
257
260
  "proxy-client": ">=0.36.1",
258
- "identity": ">=0.117.2",
261
+ "identity": ">=0.118.0",
259
262
  "opaque": ">=0.1.0",
260
263
  "compat": ">=0.4.0",
261
- "roadmap": ">=0.4.13",
262
- "passmanager": ">=0.17.0",
263
- "store": ">=0.11.0"
264
+ "roadmap": ">=0.4.26",
265
+ "passmanager": ">=0.18.4",
266
+ "store": ">=0.12.3"
264
267
  }
265
268
  },
266
269
  "vaultd": {
267
270
  "repo": "imdotrino/dotrino-vault",
268
271
  "npm": "@dotrino/vaultd",
269
- "current": "0.159.0",
272
+ "current": "0.162.1",
270
273
  "protocol": 1,
271
274
  "requires": {
272
- "identity": ">=0.117.2",
275
+ "identity": ">=0.118.0",
273
276
  "proxy-client": ">=0.36.1",
274
277
  "store": ">=0.11.0",
275
- "passmanager": ">=0.17.0",
278
+ "passmanager": ">=0.18.4",
276
279
  "compat": ">=0.4.0",
277
280
  "opaque": ">=0.1.0",
278
- "roadmap": ">=0.4.13"
281
+ "roadmap": ">=0.4.26"
279
282
  }
280
283
  },
281
284
  "verifier": {
@@ -290,7 +293,7 @@ export const MANIFESTS = Object.freeze({
290
293
  "passmanager": {
291
294
  "repo": "imdotrino/dotrino-passmanager",
292
295
  "npm": "@dotrino/passmanager",
293
- "current": "0.18.0",
296
+ "current": "0.18.4",
294
297
  "protocol": 1,
295
298
  "requires": {
296
299
  "identity": ">=0.117.2",
@@ -303,7 +306,7 @@ export const MANIFESTS = Object.freeze({
303
306
  "protocol": 1,
304
307
  "requires": {
305
308
  "proxy": "1.1.0+",
306
- "vaultd": ">=0.125.0"
309
+ "vaultd": ">=0.160.2"
307
310
  }
308
311
  },
309
312
  "passmanager-ios": {
@@ -317,18 +320,19 @@ export const MANIFESTS = Object.freeze({
317
320
  },
318
321
  "passmanager-extension": {
319
322
  "repo": "imdotrino/dotrino-passmanager",
320
- "current": "0.51.1",
323
+ "current": "0.54.0",
321
324
  "protocol": 1,
322
325
  "requires": {
323
- "identity": ">=0.117.2",
326
+ "identity": ">=0.118.0",
324
327
  "proxy-client": ">=0.36.1",
325
- "vault": ">=0.87.0",
326
- "proxy": "1.1.0+"
328
+ "vault": ">=0.90.0",
329
+ "proxy": "1.1.0+",
330
+ "vaultd": ">=0.160.2"
327
331
  }
328
332
  },
329
333
  "app": {
330
334
  "repo": "imdotrino/dotrino-app",
331
- "current": "0.4.21",
335
+ "current": "0.4.22",
332
336
  "protocol": 1,
333
337
  "requires": {
334
338
  "proxy": "1.4.7+",
@@ -338,13 +342,13 @@ export const MANIFESTS = Object.freeze({
338
342
  },
339
343
  "social-bot": {
340
344
  "repo": "imdotrino/dotrino-social-bot",
341
- "current": "0.2.1",
345
+ "current": "0.3.0",
342
346
  "protocol": 1,
343
347
  "requires": {
344
- "vault": ">=0.86.0",
345
- "identity": ">=0.116.0",
346
- "proxy-client": ">=0.23.1",
347
- "remote-agent": ">=0.13.0"
348
+ "vault": ">=0.87.0",
349
+ "identity": ">=0.117.2",
350
+ "proxy-client": ">=0.36.1",
351
+ "remote-agent": ">=0.20.1"
348
352
  }
349
353
  }
350
354
  },
@@ -832,6 +836,15 @@ export const MANIFESTS = Object.freeze({
832
836
  "versions": "0.36.0",
833
837
  "why": "por token frena los bytes por lo recordado de la identidad (que puede ser de antes de que el otro se actualizara), y no solo por lo que dijo esa conexión en su saludo",
834
838
  "fix": "@dotrino/proxy-client 0.36.1"
839
+ },
840
+ {
841
+ "product": "vaultd",
842
+ "versions": [
843
+ "0.160.0",
844
+ "0.160.1"
845
+ ],
846
+ "why": "la bóveda recordaba el sí de aprobación por aparato: después de aprobar un borrado, o de leer una contraseña, ese aparato podía borrar o pisar una passkey sin que se pidiera nada ni le llegara aviso a nadie",
847
+ "fix": "sube a @dotrino/vaultd 0.160.2"
835
848
  }
836
849
  ]
837
850
  }
@@ -1,3 +1,3 @@
1
- Copia vendorizada de @dotrino/store/core@0.12.3 (dotrino-store/store/{core}.js).
1
+ Copia vendorizada de @dotrino/store/core@0.12.4 (dotrino-store/store/{core}.js).
2
2
  NO se edita a mano: la escribe `node vendor.mjs` y la vigila test/vendor-up-to-date.test.mjs.
3
3
  Las reglas del almacén (lápidas, páginas): las mismas en el store y en la bóveda.
@@ -1,4 +1,4 @@
1
- Copia vendorizada de @dotrino/vault@0.88.0 (dotrino-vault/lib/src/{admin,b64,browser,core/approvals,core/blocked,core/browserDisk,core/commKey,core/disk,core/libVersion,core/machines,core/notices,core/notifiable,core/passwords,core/sealKey,core/sealer,core/sealers,core/secretsStore,core/sha256,core/store,core/subacta,core/threadStore,core/transport,core/vault,enroll,index,loginClient,passwordLogins,procContext,protocol,revocation,sealed}.js).
1
+ Copia vendorizada de @dotrino/vault@0.90.2 (dotrino-vault/lib/src/{admin,b64,browser,core/approvals,core/backup,core/blocked,core/browserDisk,core/commKey,core/disk,core/libVersion,core/machines,core/notices,core/notifiable,core/passwords,core/sealKey,core/sealer,core/sealers,core/secretsStore,core/sha256,core/store,core/subacta,core/threadStore,core/transport,core/vault,enroll,index,loginClient,passwordLogins,procContext,protocol,revocation,sealed}.js).
2
2
  NO se edita a mano: la escribe `node vendor.mjs` y la vigila test/vendor-up-to-date.test.mjs.
3
3
  browser.js es la bóveda de esta pestaña: el MISMO núcleo que el binario (core/), con
4
4
  el disco en IndexedDB. Importa @dotrino/{compat,roadmap,passmanager,store/core} y
@@ -0,0 +1,244 @@
1
+ /**
2
+ * EL RESPALDO DE LA BÓVEDA: un archivo cifrado con una contraseña, con lo que hay en los
3
+ * cajones y en la bóveda de contraseñas. Diseño: `docs/respaldo.md`.
4
+ *
5
+ * QUÉ LLEVA Y QUÉ NO (dueño, 2026-10-16: «como las llaves son de dispositivo, solo se
6
+ * respalda el contenido de sobres y cajones»):
7
+ *
8
+ * · SÍ: los VALORES de cada cajón (`ns:<scope>`, `dev:<aparato>`, el perfil `@me`) y las
9
+ * entradas de la bóveda de contraseñas, abiertos y vueltos a cerrar con la contraseña
10
+ * del respaldo.
11
+ * · NO: ninguna llave. Ni la maestra, ni la de comunicación, ni las de los aparatos, ni
12
+ * el acta. Una llave es de su aparato; quien restaura en otra bóveda la reparte con sus
13
+ * propias llaves, a los aparatos de SU acta.
14
+ * · NO: el histórico. Un respaldo es lo vigente.
15
+ *
16
+ * Por qué los valores en claro (dentro del archivo cifrado) y no los sobres tal cual: los
17
+ * sobres van envueltos a los aparatos de ESTA cuenta y a su copia de recuperación. En otra
18
+ * bóveda no los abre nadie, y mover un cajón de una bóveda a otra es la mitad de lo que esto
19
+ * tiene que hacer.
20
+ *
21
+ * LAS DOS PUNTAS EXIGEN LA BÓVEDA ABIERTA. Exportar es leer —lo que guarda la frase—, e
22
+ * importar compara con lo que ya hay, que también es leer.
23
+ *
24
+ * CONFLICTOS. Importar NUNCA pisa en silencio: primero se hace un PLAN (`planImport`) que
25
+ * dice, valor por valor, qué es nuevo, qué es igual y qué choca. Si algo choca y quien
26
+ * importa no dijo qué hacer, no se escribe NADA y se devuelve la lista (`BACKUP_CONFLICTS`).
27
+ * Las dos respuestas posibles son `keep` (se queda lo de la bóveda) y `replace` (gana el
28
+ * respaldo), para todo de una vez o para cada choque.
29
+ *
30
+ * El cifrado del archivo es el de `@dotrino/passmanager` (PBKDF2-SHA256 600 000 + AES-GCM):
31
+ * corre igual en el navegador y en Node, así que lo que exporta una versión de la bóveda lo
32
+ * abre cualquiera de las otras (regla de las tres versiones).
33
+ */
34
+ import { deriveKeyFromPassword, makeSalt, makeVerifier, checkVerifier, sealValue, openValue, toBase64, fromBase64 } from '@dotrino/passmanager'
35
+
36
+ export const BACKUP_FORMAT = 'dotrino-vault-backup'
37
+ export const BACKUP_VERSION = 1
38
+ /** El nombre del cajón de las contraseñas en un respaldo. No choca con un `ns:`/`dev:`. */
39
+ export const PASSWORDS_DRAWER = 'passwords'
40
+ /** Mínimo de la contraseña del respaldo: el archivo se ataca fuera de línea, sin freno. */
41
+ export const BACKUP_PWD_MIN = 12
42
+ const KDF_ITERATIONS = 600_000
43
+ const AAD = 'dotrino-vault-backup/v1'
44
+
45
+ const coded = (message, code, extra = {}) => Object.assign(new Error(message), { code, ...extra })
46
+
47
+ // --- el archivo ----------------------------------------------------------------
48
+
49
+ /**
50
+ * CIERRA un respaldo con su contraseña. Devuelve el objeto que se escribe al archivo.
51
+ * Lo único en claro es el formato y la fecha: ni los nombres de los cajones salen.
52
+ */
53
+ export async function sealBackup (payload, password) {
54
+ if (typeof password !== 'string' || password.length < BACKUP_PWD_MIN) {
55
+ throw coded(`the backup password must be at least ${BACKUP_PWD_MIN} characters`, 'WEAK_PASSWORD')
56
+ }
57
+ const salt = makeSalt()
58
+ const key = await deriveKeyFromPassword(password, salt, KDF_ITERATIONS)
59
+ return {
60
+ format: BACKUP_FORMAT,
61
+ v: BACKUP_VERSION,
62
+ createdAt: payload.createdAt,
63
+ kdf: { name: 'PBKDF2-SHA256', iterations: KDF_ITERATIONS, salt: toBase64(salt) },
64
+ verifier: await makeVerifier(key),
65
+ box: await sealValue(key, JSON.stringify(payload), AAD)
66
+ }
67
+ }
68
+
69
+ /**
70
+ * ABRE un respaldo. Distingue «no es un respaldo», «esa contraseña no es» y «está roto»,
71
+ * porque se arreglan de formas distintas.
72
+ */
73
+ export async function openBackup (file, password) {
74
+ if (!file || file.format !== BACKUP_FORMAT) throw coded('this is not a vault backup file', 'NOT_A_BACKUP')
75
+ if (file.v !== BACKUP_VERSION) throw coded(`unknown backup version v${file.v}: update the vault`, 'BACKUP_VERSION')
76
+ if (file.kdf?.name !== 'PBKDF2-SHA256' || !file.kdf.salt || !file.kdf.iterations) {
77
+ throw coded('the backup file has no usable key derivation', 'BACKUP_CORRUPT')
78
+ }
79
+ const key = await deriveKeyFromPassword(String(password || ''), fromBase64(file.kdf.salt), file.kdf.iterations)
80
+ if (!(await checkVerifier(key, file.verifier))) throw coded('that password does not open this backup', 'WRONG_PASSWORD')
81
+ let payload
82
+ try { payload = JSON.parse(await openValue(key, file.box, AAD)) } catch (_) {
83
+ throw coded('the backup opens but its content cannot be read', 'BACKUP_CORRUPT')
84
+ }
85
+ if (!Array.isArray(payload?.drawers)) throw coded('the backup has no drawers', 'BACKUP_CORRUPT')
86
+ return payload
87
+ }
88
+
89
+ // --- qué cajones -----------------------------------------------------------------
90
+
91
+ /**
92
+ * ¿Entra este cajón? `selected` es la lista que pidió quien exporta o importa (`null` =
93
+ * todos). Vale el dueño entero (`ns:proxy`, `dev:<pub>`, `passwords`) y `@me` como atajo.
94
+ */
95
+ export function wants (selected, owner) {
96
+ if (!selected || !selected.length) return true
97
+ return selected.some((s) => s === owner || (s === '@me' && owner === 'ns:@me'))
98
+ }
99
+
100
+ /** Valida que todo lo pedido exista: pedir un cajón que no está no es «nada que hacer». */
101
+ export function assertSelected (selected, available) {
102
+ if (!selected || !selected.length) return
103
+ const missing = selected.filter((s) => !available.some((o) => wants([s], o)))
104
+ if (missing.length) throw coded(`no such drawer: ${missing.join(', ')}`, 'NO_SUCH_DRAWER', { missing })
105
+ }
106
+
107
+ // --- importar: el plan -----------------------------------------------------------
108
+
109
+ /** Lo que identifica un valor dentro de un plan: `<cajón destino> <clave>`. */
110
+ export const itemRef = (owner, key) => `${owner} ${key}`
111
+
112
+ /**
113
+ * EL PLAN DE IMPORTACIÓN. Puro: no lee ni escribe nada, recibe lo que hay.
114
+ *
115
+ * @param {object} payload el respaldo ya abierto
116
+ * @param {object} o
117
+ * `selected` qué cajones del respaldo (`null` = todos)
118
+ * `map` `{ <cajón del respaldo>: <cajón destino> }` para moverlo de nombre o de
119
+ * aparato. Sin entrada, va al mismo nombre.
120
+ * `current` `{ <cajón destino>: { KEY: {value, public} } | null }` lo que ya hay
121
+ * `devices` pubs de los aparatos del acta de destino: un `dev:` solo entra si su
122
+ * aparato existe aquí
123
+ * `passwords` `{ entries: [...] }` lo que ya hay en la bóveda de contraseñas, abierto
124
+ */
125
+ export function planImport (payload, { selected = null, map = {}, current = {}, devices = [], passwords = null } = {}) {
126
+ const available = payload.drawers.map((d) => d.owner)
127
+ assertSelected(selected, available)
128
+ const withDevice = new Set(devices)
129
+ const plan = { drawers: [], counts: { new: 0, same: 0, conflict: 0, skipped: 0 } }
130
+ for (const d of payload.drawers) {
131
+ if (!wants(selected, d.owner)) continue
132
+ const to = map[d.owner] || d.owner
133
+ if (d.owner === PASSWORDS_DRAWER) {
134
+ plan.drawers.push(planPasswords(d, passwords, plan.counts))
135
+ continue
136
+ }
137
+ const out = { from: d.owner, to, label: d.label || null, status: 'ok', items: [] }
138
+ if (to.startsWith('dev:') && !withDevice.has(to.slice(4))) {
139
+ // EL APARATO NO ESTÁ EN ESTA CUENTA. No se guarda «por si acaso»: un cajón de aparato
140
+ // sin aparato no lo lee nadie, y reaparecería solo si un día entra esa llave. Se dice,
141
+ // y se puede mover a otro aparato con `map`.
142
+ out.status = 'no-device'
143
+ plan.counts.skipped += Object.keys(d.vars || {}).length
144
+ plan.drawers.push(out)
145
+ continue
146
+ }
147
+ const have = current[to] || {}
148
+ for (const [key, v] of Object.entries(d.vars || {})) {
149
+ const existing = have[key]
150
+ const status = !existing ? 'new' : (existing.value === v.value ? 'same' : 'conflict')
151
+ plan.counts[status]++
152
+ out.items.push({ key, status, public: !!v.public, ...(existing ? { currentPublic: !!existing.public } : {}) })
153
+ }
154
+ plan.drawers.push(out)
155
+ }
156
+ return plan
157
+ }
158
+
159
+ /**
160
+ * Lo que dice que una entrada de contraseñas es «la misma» que otra: el mismo id, o los
161
+ * mismos sitios y el mismo usuario (una credencial por sitio y usuario).
162
+ */
163
+ const sitesKey = (e) => [...(e.sites || [])].map((s) => String(s).toLowerCase()).sort().join(' ')
164
+ const whoKey = (e) => `${sitesKey(e)}\u0000${String(e.username || '').toLowerCase()}\u0000${e.sites?.length ? '' : String(e.title || '')}`
165
+ /** Lo que se compara para decir «igual»: el contenido, sin fechas. */
166
+ export const entryContent = (e) => JSON.stringify({
167
+ type: e.type || 'login', title: e.title || '', sites: [...(e.sites || [])].sort(),
168
+ name: e.name || '', username: e.username || '', secret: e.secret || '', totp: e.totp || '',
169
+ notes: e.notes || '', fields: e.fields || '[]', webauthn: e.webauthn || null
170
+ })
171
+
172
+ function planPasswords (d, passwords, counts) {
173
+ const out = { from: PASSWORDS_DRAWER, to: PASSWORDS_DRAWER, status: 'ok', items: [] }
174
+ if (!passwords) {
175
+ // Sin bóveda de contraseñas en el destino no hay con quién comparar ni dónde guardar:
176
+ // se dice. No se inventa una llave de perfil a medias.
177
+ out.status = 'no-passwords'
178
+ counts.skipped += (d.entries || []).length
179
+ return out
180
+ }
181
+ const byId = new Map(passwords.entries.map((e) => [e.id, e]))
182
+ const byWho = new Map(passwords.entries.map((e) => [whoKey(e), e]))
183
+ for (const e of d.entries || []) {
184
+ const existing = byId.get(e.id) || byWho.get(whoKey(e)) || null
185
+ const status = !existing ? 'new' : (entryContent(existing) === entryContent(e) ? 'same' : 'conflict')
186
+ counts[status]++
187
+ out.items.push({ key: e.id, title: e.title || (e.sites || [])[0] || '', username: e.username || '', status, ...(existing ? { currentId: existing.id } : {}) })
188
+ }
189
+ return out
190
+ }
191
+
192
+ /**
193
+ * LA DECISIÓN DE CADA CHOQUE. `onConflict` vale para todos; `decisions` (por `itemRef`) manda
194
+ * sobre él. Si queda alguno sin decidir, se para con la lista entera: importar a medias y
195
+ * descubrir el resto después es peor que no importar.
196
+ */
197
+ export function resolvePlan (plan, { onConflict = null, decisions = {} } = {}) {
198
+ if (onConflict && onConflict !== 'keep' && onConflict !== 'replace') {
199
+ throw coded(`unknown conflict policy "${onConflict}" (use keep or replace)`, 'BAD_POLICY')
200
+ }
201
+ const pending = []
202
+ for (const d of plan.drawers) {
203
+ for (const it of d.items) {
204
+ if (it.status !== 'conflict') continue
205
+ const r = decisions[itemRef(d.to, it.key)] || onConflict
206
+ if (r !== 'keep' && r !== 'replace') { pending.push({ drawer: d.to, key: it.key, ...(it.title ? { title: it.title } : {}) }); continue }
207
+ it.resolution = r
208
+ }
209
+ }
210
+ if (pending.length) {
211
+ throw coded(`${pending.length} conflict(s) without a decision: nothing was imported`, 'BACKUP_CONFLICTS', { conflicts: pending, plan })
212
+ }
213
+ return plan
214
+ }
215
+
216
+ /**
217
+ * LO QUE HAY QUE ESCRIBIR, ya resuelto: `[{ to, key, value, public }]` para los cajones y
218
+ * `[{ entry, replaces }]` para las contraseñas. Lo `same` y lo `keep` no se tocan.
219
+ *
220
+ * La VISIBILIDAD: si lo que había era privado, se queda privado aunque el respaldo diga
221
+ * público. Hacer público un secreto no puede colarse en una importación (es la misma regla
222
+ * de `PrivateStaysPrivate`), y se anota para decirlo.
223
+ */
224
+ export function writesOf (plan, payload) {
225
+ const vars = []
226
+ const entries = []
227
+ const byOwner = new Map(payload.drawers.map((d) => [d.owner, d]))
228
+ for (const d of plan.drawers) {
229
+ if (d.status !== 'ok') continue
230
+ const src = byOwner.get(d.from)
231
+ for (const it of d.items) {
232
+ if (it.status === 'same' || (it.status === 'conflict' && it.resolution !== 'replace')) continue
233
+ if (d.from === PASSWORDS_DRAWER) {
234
+ const entry = src.entries.find((e) => e.id === it.key)
235
+ entries.push({ entry, replaces: it.status === 'conflict' ? it.currentId : null })
236
+ continue
237
+ }
238
+ const v = src.vars[it.key]
239
+ const keptPrivate = it.currentPublic === false && !!v.public
240
+ vars.push({ to: d.to, key: it.key, value: v.value, public: keptPrivate ? false : !!v.public, ...(keptPrivate ? { keptPrivate: true } : {}) })
241
+ }
242
+ }
243
+ return { vars, entries }
244
+ }