@dotrino/vaultd 0.160.2 → 0.161.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.
@@ -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
+ }
@@ -5,4 +5,4 @@
5
5
  * si no coincide con la del paquete: una versión anunciada que no es la que corre es peor
6
6
  * que no anunciar ninguna.
7
7
  */
8
- export const LIB_VERSION = '0.88.1'
8
+ export const LIB_VERSION = '0.89.0'
@@ -52,18 +52,7 @@ export function createPasswordDesk (opts = {}) {
52
52
  log = () => {},
53
53
  } = opts
54
54
 
55
- const sealed = new SealedStore(store, {
56
- recipients: (kind) => members(kind),
57
- /**
58
- * ¿LA FIRMÓ QUIEN DICE, Y PUEDE ESCRIBIR? Es lo único que la bóveda puede comprobar
59
- * sobre una entrada cuyo contenido no ve. Las dos mitades importan: la firma ata el
60
- * sobre a una llave, y el acta dice si esa llave tiene algo que hacer aquí.
61
- */
62
- verifyAuthor: async ({ body, author }) => {
63
- if (!canWrite(author?.pub)) return false
64
- return verifyDeviceSig({ publickey: author.pub, data: body, signature: author.sig })
65
- },
66
- })
55
+ const sealed = openSealedPasswords({ store, members, canWrite })
67
56
 
68
57
  const responder = new SealedResponder({
69
58
  client,
@@ -103,6 +92,30 @@ export function createPasswordDesk (opts = {}) {
103
92
  }
104
93
  }
105
94
 
95
+ /**
96
+ * EL ALMACÉN SELLADO DE LAS CONTRASEÑAS, sin el mostrador. Lo usa el mostrador y lo usa el
97
+ * respaldo (`backup.js`): el respaldo tiene que poder leer y escribir aunque ningún aparato
98
+ * tenga todavía `passwords` y el mostrador no se haya levantado. Una sola pieza, para que
99
+ * la regla de quién puede escribir no se escriba dos veces.
100
+ */
101
+ /**
102
+ * @param {{ store?: any, members?: (kind: string) => any[], canWrite?: (pub: string) => boolean }} [o]
103
+ */
104
+ export function openSealedPasswords ({ store, members = () => [], canWrite = () => false } = {}) {
105
+ return new SealedStore(store, {
106
+ recipients: (kind) => members(kind),
107
+ /**
108
+ * ¿LA FIRMÓ QUIEN DICE, Y PUEDE ESCRIBIR? Es lo único que la bóveda puede comprobar
109
+ * sobre una entrada cuyo contenido no ve. Las dos mitades importan: la firma ata el
110
+ * sobre a una llave, y el acta dice si esa llave tiene algo que hacer aquí.
111
+ */
112
+ verifyAuthor: async ({ body, author }) => {
113
+ if (!canWrite(author?.pub)) return false
114
+ return verifyDeviceSig({ publickey: author.pub, data: body, signature: author.sig })
115
+ },
116
+ })
117
+ }
118
+
106
119
  /**
107
120
  * CONVERTIR LO QUE HAY (§2.7). Es un acto único y es **segundo trabajo de la maestra**: se
108
121
  * hace al ABRIR la bóveda, que es cuando la frase está a mano y hay con qué reescribirlo
@@ -122,3 +135,66 @@ export async function convertPasswords ({ store, sealed, cek, recipients, author
122
135
  log: (m) => log('[vault] ' + m)
123
136
  })
124
137
  }
138
+
139
+ /**
140
+ * LAS CONTRASEÑAS DEL RESPALDO, abiertas con la copia de recuperación (`backup.js`).
141
+ *
142
+ * Es lectura, así que pide lo mismo que ver una variable: `openRecovery(wrap)` → la CEK, que
143
+ * solo funciona con la bóveda abierta. Cada entrada sale entera, con la forma de siempre
144
+ * (`newEntry`), para poder volver a sellarla en OTRA bóveda con sus propias llaves.
145
+ *
146
+ * Lo que no abre la copia de recuperación no se calla: `unreadable` cuenta las entradas sin
147
+ * envoltura `#recovery` (guardadas antes de que existiera), y `withheld` los campos sueltos
148
+ * que tampoco la tienen. Un respaldo que se deja cosas tiene que decirlo.
149
+ */
150
+ export async function exportPasswordEntries ({ sealed, openRecovery }) {
151
+ const { RECOVERY, openSealedEntry } = await import('@dotrino/passmanager/sealed')
152
+ const { decryptWithCek } = await import('@dotrino/identity/content')
153
+ const stats = await sealed.stats()
154
+ if (!stats.sealed) return { entries: [], unreadable: stats.entries, withheld: 0 }
155
+ const openSealed = async ({ wrap, envelope }) => decryptWithCek({ cek: await openRecovery(wrap), envelope })
156
+ const views = await sealed.views({ pub: RECOVERY })
157
+ const entries = []
158
+ let withheld = 0
159
+ for (const v of views) {
160
+ const got = await sealed.get(v.id, { pub: RECOVERY })
161
+ const { withheld: lost = [], ...plain } = await openSealedEntry({ got, openSealed })
162
+ withheld += lost.length
163
+ entries.push(plain)
164
+ }
165
+ return { entries, unreadable: stats.entries - views.length, withheld }
166
+ }
167
+
168
+ /**
169
+ * VUELVE A SELLAR entradas de un respaldo en ESTA bóveda: a los aparatos de su acta y a su
170
+ * copia de recuperación, con la llave del perfil de aquí (la que hace las huellas de sitio).
171
+ *
172
+ * Si esta bóveda todavía no tiene llave de perfil (nunca guardó una contraseña), se estrena
173
+ * aquí: es lo mismo que haría al convertir, y sin ella no hay dónde guardar.
174
+ *
175
+ * `writes`: `[{ entry, replaces }]` de `writesOf`. `replaces` es el id de la entrada de aquí
176
+ * que se pisa; la entrada nueva toma ese id, así que el almacén la reemplaza en su sitio.
177
+ * `author` firma cada entrada (`{ publickey, sign }`), como cualquier aparato que escribe.
178
+ */
179
+ export async function importPasswordEntries ({ sealed, openRecovery, recipients, author, writes = [] }) {
180
+ if (!writes.length) return { written: 0 }
181
+ const { RECOVERY, profileKeys, buildSealedEntry, buildProfileKey } = await import('@dotrino/passmanager/sealed')
182
+ const { decryptWithCek } = await import('@dotrino/identity/content')
183
+ let base
184
+ if ((await sealed.stats()).sealed) {
185
+ const p = await sealed.profile({ pub: RECOVERY })
186
+ base = await decryptWithCek({ cek: await openRecovery(p.wrap), envelope: p.envelope })
187
+ } else {
188
+ const k = await buildProfileKey({ recipients })
189
+ await sealed.setProfile({ envelope: k.envelope, wraps: k.wraps })
190
+ base = k.base
191
+ }
192
+ const keys = await profileKeys(base)
193
+ let written = 0
194
+ for (const { entry, replaces } of writes) {
195
+ const built = await buildSealedEntry({ plain: { ...entry, id: replaces || entry.id }, keys, recipients, author })
196
+ await sealed.putSealed({ ...built, by: author.publickey })
197
+ written++
198
+ }
199
+ return { written }
200
+ }
@@ -96,6 +96,16 @@ export function assertProfileKey (key) {
96
96
  }
97
97
  }
98
98
 
99
+ /** Lo más largo que puede ser un dato del perfil (una foto pequeña cabe). */
100
+ const MAX_PROFILE_LEN = 200000
101
+
102
+ /** Valida un dato del perfil: su nombre (`assertProfileKey`) y su valor. */
103
+ export function assertProfileValue (key, value) {
104
+ assertProfileKey(key)
105
+ if (typeof value !== 'string' || !value) throw new Error('value must be a non-empty string')
106
+ if (value.length > MAX_PROFILE_LEN) throw new Error(`profile value too long (max ${MAX_PROFILE_LEN})`)
107
+ }
108
+
99
109
  /** Cuántas versiones anteriores se conservan, en total. Ver «EL HISTÓRICO» arriba. */
100
110
  const MAX_HISTORY = 500
101
111
 
@@ -555,6 +565,52 @@ export function openSecretsStore (disk, { sealer = null, recipients = null, sign
555
565
  return out
556
566
  },
557
567
 
568
+ /**
569
+ * UN CAJÓN ENTERO, ABIERTO. Es para el RESPALDO (`backup.js`), y por eso pide lo mismo
570
+ * que ver un valor: la copia de recuperación, o sea la bóveda abierta. Se abre UNA vez
571
+ * y se usa para todas las variables.
572
+ *
573
+ * Lo público del perfil (`cls: 'public'`) no tiene sobre: se guarda en claro y firmado,
574
+ * y sale tal cual. El histórico no sale: un respaldo es lo vigente.
575
+ *
576
+ * @returns {Promise<Object<string,{value:string, public:boolean}>|null>} `null` si el
577
+ * cajón no existe.
578
+ */
579
+ async exportDrawer (owner, adminKey = null) {
580
+ const [kind, k] = splitOwner(owner)
581
+ const bag = kind === 'ns' ? data.ns : data.dev
582
+ if (!bag[k]) return null
583
+ if (needsMigration()) throw new NeedsMigration()
584
+ const vars = varsOf(bag, k)
585
+ /** @type {Object<string,{value:string, public:boolean}>} */
586
+ const out = {}
587
+ let priv = null
588
+ for (const [key, e] of Object.entries(vars)) {
589
+ if (e?.cls === 'public' && typeof e.pubv === 'string') { out[key] = { value: e.pubv, public: true }; continue }
590
+ if (isLegacy()) { out[key] = { value: e.v, public: !!e.pub }; continue }
591
+ if (!priv) priv = await openRecovery(adminKey)
592
+ out[key] = { value: await sealer.openValue(await this._cekOf(bag, k, e.gen, priv), e.e), public: !!e.pub }
593
+ }
594
+ return out
595
+ },
596
+
597
+ /**
598
+ * ESCRIBE UN VALOR IMPORTADO en cualquier cajón, también en el del perfil.
599
+ *
600
+ * Es escribir y nada más (no pide la frase): lo de `@me` público va por
601
+ * `profilePutPublic`, como lo escribiría un aparato; lo demás, por el mismo camino que
602
+ * `set`. Lo que decide si se escribe —el conflicto con lo que ya había— lo decide
603
+ * `backup.js` antes de llegar aquí.
604
+ */
605
+ async importValue (owner, key, value, isPublic, { by = null } = {}) {
606
+ if (owner === PROFILE_OWNER && isPublic) return this.profilePutPublic(key, value, { by })
607
+ const [kind, k] = splitOwner(owner)
608
+ if (kind === 'ns' && owner !== PROFILE_OWNER) assertNs(k)
609
+ else if (kind === 'dev') assertPub(k)
610
+ else if (kind !== 'ns') throw new Error('invalid drawer: expected "ns:<name>" or "dev:<pubkey>"')
611
+ return this._put(kind === 'ns' ? data.ns : data.dev, k, owner, key, value, isPublic, by)
612
+ },
613
+
558
614
  // --- escritura: NO pide la frase (§8.1) --------------------------------------
559
615
  /**
560
616
  * Escribe en un cajón. `isPublic` es OPCIONAL a propósito: sin decir nada se
@@ -575,7 +631,10 @@ export function openSecretsStore (disk, { sealer = null, recipients = null, sign
575
631
  async _put (...a) { return enFila(() => this._putRaw(...a)) },
576
632
  /** @private El cuerpo, ya en fila (ver `enFila`). */
577
633
  async _putRaw (bag, k, owner, key, value, isPublic, by) {
578
- assertKeyValue(key, value)
634
+ // El cajón del perfil tiene su propio alfabeto (`nickname`, no `NICKNAME`). Por aquí
635
+ // solo llega con `importValue`: `set` ya rechaza `@me` como nombre de cajón.
636
+ if (owner === PROFILE_OWNER) assertProfileValue(key, value)
637
+ else assertKeyValue(key, value)
579
638
  const vars = ensureBag(bag, k)
580
639
  const before = vars[key]
581
640
  const pub = isPublic === undefined ? !!before?.pub : !!isPublic
@@ -949,7 +1008,7 @@ export function openSecretsStore (disk, { sealer = null, recipients = null, sign
949
1008
  if (isLegacy()) throw new Error('profile: this store is still v3 (run `migrate` first)')
950
1009
  if (needsMigration()) throw new NeedsMigration()
951
1010
  assertProfileKey(key)
952
- if (typeof value !== 'string' || value.length > 200000) {
1011
+ if (typeof value !== 'string' || value.length > MAX_PROFILE_LEN) {
953
1012
  throw new Error('a public profile field must be a string of at most 200 kB')
954
1013
  }
955
1014
  const vars = ensureBag(data.ns, PROFILE_NS)
@@ -40,6 +40,7 @@ import { createApprovals, createGrants, lastApproverGone, PENDING_TTL_MS, GRANT_
40
40
  import { sanitizeContext, commandFingerprint, commandLine } from '../procContext.js'
41
41
  import { makeContentKey, encryptWithCek, wrapForMember } from '@dotrino/identity/content'
42
42
  import { makeSealer } from './sealer.js'
43
+ import { BACKUP_VERSION, PASSWORDS_DRAWER, wants, assertSelected, planImport, resolvePlan, writesOf } from './backup.js'
43
44
  import { openSealKeys } from './sealKey.js'
44
45
  import { openCommKey, COMM_CN, COMM_CAPS } from './commKey.js'
45
46
  import { seal } from '../sealed.js'
@@ -317,6 +318,14 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
317
318
  * queda fuera y se ve en `incompleteMembers`.
318
319
  */
319
320
  const noEsLaBoveda = (pub) => pub !== master && pub !== commKey.pub()
321
+ /**
322
+ * ¿PUEDE ESCRIBIR UNA CONTRASEÑA? Un aparato del acta con `passwords`… y la MAESTRA de esta
323
+ * bóveda, que es quien reescribe al convertir (`sealPasswords`) y al importar un respaldo.
324
+ * Las dos cosas pasan con la bóveda abierta y son rehacer sobres, su segundo trabajo. Sin
325
+ * ella, las dos fallaban con `bad-author`: firmaban con la maestra y la regla solo miraba
326
+ * el acta. Escribir no le da a la bóveda ninguna envoltura: sigue sin poder leer nada.
327
+ */
328
+ const canWritePasswords = (pub) => !!pub && (pub === master || (!!actaCache && Acta.memberCan(actaCache, pub, 'passwords')))
320
329
  const passwordRecipients = (_kind = 'main') => (actaCache?.members || [])
321
330
  .filter((m) => m.encPub && noEsLaBoveda(m.pub) && Acta.memberCan(actaCache, m.pub, 'passwords'))
322
331
  .map((m) => ({ pub: m.pub, encPub: m.encPub }))
@@ -2330,7 +2339,7 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
2330
2339
  // aparato—. Ninguna de las dos abre nada por sí sola.
2331
2340
  members: (kind) => passwordRecipients(kind),
2332
2341
  recoveryPub: () => secrets.recoveryPub(),
2333
- canWrite: (pub) => !!actaCache && !!pub && Acta.memberCan(actaCache, pub, 'passwords'),
2342
+ canWrite: canWritePasswords,
2334
2343
  // UNA sola condición, y es del ACTA: el aparato tiene la capacidad `passwords`.
2335
2344
  // Quitársela —o quitar el aparato— le corta esto en la siguiente pasada, sin
2336
2345
  // que haya una segunda lista que acordarse de tocar.
@@ -3946,6 +3955,163 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
3946
3955
  return secrets.openBundle(ns, devicePub, adminKey)
3947
3956
  }
3948
3957
 
3958
+ // --- EL RESPALDO (`backup.js`, `docs/respaldo.md`) ------------------------------------
3959
+ //
3960
+ // Las dos puntas son de BÓVEDA ABIERTA (dueño, 2026-10-16): exportar lee todo, e importar
3961
+ // compara con lo que ya hay. Cerrada se para y se dice, sin intentar con la llave de la
3962
+ // máquina.
3963
+
3964
+ const needOpen = () => {
3965
+ if (isLocked()) throw Object.assign(new Error('the vault is locked: open it to back up or restore'), { code: 'VAULT_LOCKED' })
3966
+ }
3967
+ /** El almacén sellado de las contraseñas: el del mostrador si está, o uno igual. */
3968
+ const sealedPasswords = async () => {
3969
+ if (passwords?.sealed) return passwords.sealed
3970
+ const { openSealedPasswords } = await import('./passwords.js')
3971
+ return openSealedPasswords({ store: passwordsStore, members: (kind) => passwordRecipients(kind), canWrite: canWritePasswords })
3972
+ }
3973
+ const passwordsRecovery = (adminKey) => (wrap) => secrets.openRecoveryWrap(wrap, adminKey)
3974
+ /** Lo que hay en las contraseñas, abierto. `null` si no hay ni una. */
3975
+ async function passwordEntries (adminKey) {
3976
+ const { exportPasswordEntries } = await import('./passwords.js')
3977
+ return exportPasswordEntries({ sealed: await sealedPasswords(), openRecovery: passwordsRecovery(adminKey) })
3978
+ }
3979
+ /** Cómo se llama un cajón para quien lo lee: el del aparato, con su ID y su nombre. */
3980
+ const drawerLabel = (owner) => {
3981
+ if (!owner.startsWith('dev:')) return null
3982
+ const pub = owner.slice(4)
3983
+ const m = (actaCache?.members || []).find((x) => x.pub === pub)
3984
+ return { id: pubkeyId(pub), label: m?.label || m?.cn || null }
3985
+ }
3986
+
3987
+ /**
3988
+ * LOS CAJONES QUE HAY, con cuántos valores tiene cada uno. Es lo que se enseña antes de
3989
+ * elegir qué respaldar: nombres y cuentas, nunca valores.
3990
+ */
3991
+ async function backupDrawers () {
3992
+ const out = secrets.owners().map((owner) => ({
3993
+ owner,
3994
+ count: owner.startsWith('dev:')
3995
+ ? (secrets.listDevices()[owner.slice(4)] || []).length
3996
+ : (secrets.list()[owner.slice(3)] || []).length,
3997
+ ...(drawerLabel(owner) ? { device: drawerLabel(owner) } : {})
3998
+ }))
3999
+ const st = await (await sealedPasswords()).stats()
4000
+ if (st.entries) out.push({ owner: PASSWORDS_DRAWER, count: st.entries })
4001
+ return out
4002
+ }
4003
+
4004
+ /**
4005
+ * EXPORTA un respaldo: todo, o los cajones pedidos (`drawers`). Devuelve lo que se va a
4006
+ * cifrar (`payload`) y el resumen de lo que lleva — quien llama lo cifra con la contraseña
4007
+ * del respaldo (`sealBackup`), que no tiene por qué pasar por aquí.
4008
+ */
4009
+ async function exportBackup ({ drawers = null, adminKey = null } = {}) {
4010
+ needOpen()
4011
+ await refreshActa()
4012
+ const owners = secrets.owners()
4013
+ const pwd = (!drawers || wants(drawers, PASSWORDS_DRAWER)) ? await passwordEntries(adminKey) : null
4014
+ const available = [...owners, ...(pwd && (pwd.entries.length || pwd.unreadable) ? [PASSWORDS_DRAWER] : [])]
4015
+ assertSelected(drawers, available)
4016
+ /** @type {Array<{owner: string, label?: any, vars?: Object<string,{value:string, public:boolean}>, entries?: any[]}>} */
4017
+ const out = []
4018
+ for (const owner of owners) {
4019
+ if (!wants(drawers, owner)) continue
4020
+ const vars = await secrets.exportDrawer(owner, adminKey)
4021
+ if (!vars) continue
4022
+ const label = drawerLabel(owner)
4023
+ out.push({ owner, ...(label ? { label } : {}), vars })
4024
+ }
4025
+ if (pwd && wants(drawers, PASSWORDS_DRAWER) && pwd.entries.length) out.push({ owner: PASSWORDS_DRAWER, entries: pwd.entries })
4026
+ const payload = { v: BACKUP_VERSION, createdAt: Date.now(), account: fp || null, drawers: out }
4027
+ const summary = {
4028
+ drawers: out.map((d) => ({ owner: d.owner, count: d.entries ? d.entries.length : Object.keys(d.vars).length, ...(d.label ? { device: d.label } : {}) })),
4029
+ ...(pwd?.unreadable ? { passwordsUnreadable: pwd.unreadable } : {}),
4030
+ ...(pwd?.withheld ? { passwordFieldsWithheld: pwd.withheld } : {})
4031
+ }
4032
+ audit('backup.export', { drawers: out.length })
4033
+ return { payload, summary }
4034
+ }
4035
+
4036
+ /** Lo que ya hay en los cajones de destino del plan, abierto, para compararlo. */
4037
+ async function currentFor (payload, { selected, map, adminKey }) {
4038
+ const current = {}
4039
+ for (const d of payload.drawers) {
4040
+ if (d.owner === PASSWORDS_DRAWER || !wants(selected, d.owner)) continue
4041
+ const to = map[d.owner] || d.owner
4042
+ if (!(to in current)) current[to] = await secrets.exportDrawer(to, adminKey)
4043
+ }
4044
+ return current
4045
+ }
4046
+
4047
+ /**
4048
+ * EL PLAN de importar un respaldo ya abierto: qué es nuevo, qué es igual y qué choca. No
4049
+ * escribe nada. `map` mueve un cajón a otro nombre (o a otro aparato).
4050
+ */
4051
+ async function planBackupImport (payload, { drawers = null, map = {}, adminKey = null } = {}) {
4052
+ needOpen()
4053
+ await refreshActa()
4054
+ const current = await currentFor(payload, { selected: drawers, map, adminKey })
4055
+ const withPasswords = payload.drawers.some((d) => d.owner === PASSWORDS_DRAWER && wants(drawers, d.owner))
4056
+ const pwd = withPasswords ? await passwordEntries(adminKey) : null
4057
+ return planImport(payload, {
4058
+ selected: drawers,
4059
+ map,
4060
+ current,
4061
+ devices: (actaCache?.members || []).map((m) => m.pub),
4062
+ passwords: pwd ? { entries: pwd.entries } : null
4063
+ })
4064
+ }
4065
+
4066
+ /**
4067
+ * IMPORTA. Rehace el plan aquí mismo —no se fía de uno viejo: entre el plan y el sí, la
4068
+ * bóveda pudo cambiar— y si queda algún choque sin decidir no escribe nada
4069
+ * (`BACKUP_CONFLICTS`, con la lista).
4070
+ *
4071
+ * Todo lo de los cajones entra en UN guardado por cajón y con un solo aviso a sus
4072
+ * servicios, como una carga en grupo.
4073
+ */
4074
+ async function importBackup (payload, { drawers = null, map = {}, onConflict = null, decisions = {}, adminKey = null } = {}) {
4075
+ const plan = resolvePlan(await planBackupImport(payload, { drawers, map, adminKey }), { onConflict, decisions })
4076
+ const { vars, entries } = writesOf(plan, payload)
4077
+ const touched = new Set()
4078
+ await secrets.batch(async () => {
4079
+ for (const w of vars) {
4080
+ await secrets.importValue(w.to, w.key, w.value, w.public, { by: null })
4081
+ touched.add(w.to)
4082
+ }
4083
+ })
4084
+ let pwdWritten = 0
4085
+ if (entries.length) {
4086
+ const { importPasswordEntries } = await import('./passwords.js')
4087
+ const r = await importPasswordEntries({
4088
+ sealed: await sealedPasswords(),
4089
+ openRecovery: passwordsRecovery(adminKey),
4090
+ recipients: { recoveryPub: secrets.recoveryPub(), main: passwordRecipients('main'), passkeys: passwordRecipients('passkeys') },
4091
+ // LA MAESTRA FIRMA LO QUE ESCRIBE, igual que al convertir (`sealPasswords`): la bóveda
4092
+ // está abierta y esto es rehacer sobres. Lo que se le envuelve sigue siendo nada.
4093
+ author: { publickey: master, sign: (body) => identity.signData(body) },
4094
+ writes: entries
4095
+ })
4096
+ pwdWritten = r.written
4097
+ }
4098
+ // Como cualquier carga en grupo: saldar lo que se deba y un solo aviso por cajón.
4099
+ for (const owner of touched) {
4100
+ await settleDebts(owner)
4101
+ if (owner.startsWith('dev:')) scheduleDeviceNotice(owner.slice(4))
4102
+ else if (owner !== PROFILE_OWNER) scheduleNotice(owner.slice(3))
4103
+ }
4104
+ const result = {
4105
+ written: vars.length + pwdWritten,
4106
+ kept: plan.drawers.reduce((n, d) => n + d.items.filter((i) => i.resolution === 'keep').length, 0),
4107
+ same: plan.counts.same,
4108
+ skipped: plan.drawers.filter((d) => d.status !== 'ok').map((d) => ({ from: d.from, to: d.to, status: d.status })),
4109
+ keptPrivate: vars.filter((w) => w.keptPrivate).map((w) => ({ drawer: w.to, key: w.key }))
4110
+ }
4111
+ audit('backup.import', { written: result.written, kept: result.kept, drawers: touched.size + (pwdWritten ? 1 : 0) })
4112
+ return result
4113
+ }
4114
+
3949
4115
  /**
3950
4116
  * CAMBIA LOS PERMISOS DE UNO O VARIOS APARATOS EN UNA SOLA ACTA. `changes`: `[{ pub, caps }]`,
3951
4117
  * cada `caps` la lista COMPLETA de ese aparato.
@@ -4274,6 +4440,8 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
4274
4440
  // frontera de verdad (que el valor de una privada no salga ni dentro del sobre).
4275
4441
  vars: varsDesk,
4276
4442
  setSecret, deleteSecret, renameSecret, renameDeviceSecret, listSecrets, setSecretVisibility, openSecrets,
4443
+ // El respaldo: exportar, ver el plan e importar. Las tres con la bóveda abierta.
4444
+ backupDrawers, exportBackup, planBackupImport, importBackup,
4277
4445
  // Sella un `secrets.json` v3 entero. Es el punto de no retorno del despliegue y
4278
4446
  // por eso es una operación con nombre propio, no algo que ocurra de refilón al
4279
4447
  // desbloquear: deja `secrets.json.v3.bak` para poder volver.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.160.2",
3
+ "version": "0.161.0",
4
4
  "type": "module",
5
5
  "description": "Certificador personal de Dotrino: daemon headless que custodia la clave maestra y delega capacidades a tus dispositivos por el proxy. Tu CA propia.",
6
6
  "bin": {
@@ -24,7 +24,7 @@
24
24
  "@dotrino/compat": "0.4.0",
25
25
  "@dotrino/identity": "^0.118.0",
26
26
  "@dotrino/opaque": "0.1.0",
27
- "@dotrino/passmanager": "0.18.3",
27
+ "@dotrino/passmanager": "0.18.4",
28
28
  "@dotrino/proxy-client": "^0.36.1",
29
29
  "@dotrino/roadmap": "0.4.26",
30
30
  "@dotrino/store": "0.11.0",
package/src/ctl.js CHANGED
@@ -2305,6 +2305,226 @@ async function cmdTui () {
2305
2305
  await runTui()
2306
2306
  }
2307
2307
 
2308
+ // ---------------------------------------------------------------------------
2309
+ // EL RESPALDO (`docs/respaldo.md`): un archivo cifrado con una contraseña propia con los
2310
+ // valores de los cajones y las contraseñas. Las llaves no van: son de cada aparato.
2311
+ //
2312
+ // Las dos puntas son de bóveda ABIERTA. El archivo lo cierra el daemon, así que lo que
2313
+ // llega aquí ya va cifrado; y al importar, lo que se enseña son nombres y estados.
2314
+ // ---------------------------------------------------------------------------
2315
+
2316
+ const DEVICE_ID_RE = /^[0-9A-F]{4}-[0-9A-F]{4}$/i
2317
+
2318
+ /** Cómo se llama un cajón para quien lee. */
2319
+ function drawerName (owner, device) {
2320
+ if (owner === 'passwords') return 'contraseñas'
2321
+ if (owner === 'ns:@me') return '@me (tu perfil)'
2322
+ if (owner.startsWith('ns:')) return owner.slice(3)
2323
+ const d = device || {}
2324
+ return `aparato ${d.id || pubkeyId(owner.slice(4))}${d.label ? ' (' + d.label + ')' : ''}`
2325
+ }
2326
+
2327
+ /**
2328
+ * De lo que escribe quien usa el comando al nombre del cajón. `devices`: `[{id, owner}]`
2329
+ * para traducir un ID de aparato. Lo que no se reconoce se queda como está y lo rechaza
2330
+ * la bóveda con su nombre (`NO_SUCH_DRAWER`), en vez de adivinar.
2331
+ */
2332
+ function drawerArg (word, devices = []) {
2333
+ const w = String(word)
2334
+ if (/^(contrase(n|ñ)as|passwords)$/i.test(w)) return 'passwords'
2335
+ if (w === '@me' || w === 'perfil' || w === 'ns:@me') return 'ns:@me'
2336
+ if (w.startsWith('ns:') || w.startsWith('dev:')) return w
2337
+ if (DEVICE_ID_RE.test(w)) return devices.find((d) => d.id === w.toUpperCase())?.owner || `dev:?${w.toUpperCase()}`
2338
+ return 'ns:' + w
2339
+ }
2340
+
2341
+ /** Una orden de respaldo al daemon, y su respuesta (o su error, con código). */
2342
+ async function backupRequest (req, { timeoutMs = 120_000 } = {}) {
2343
+ const s = requireDaemon()
2344
+ const listFile = path.join(dataDir(), 'secrets-list.json')
2345
+ const id = crypto.randomBytes(8).toString('hex')
2346
+ try { fs.rmSync(listFile, { force: true }) } catch (_) {}
2347
+ writeReq('secret-request.json', { ...req, id })
2348
+ writeReq('dump-request.json', {})
2349
+ sendSignal(s.pid, 'SIGUSR2')
2350
+ for (let i = 0; i < timeoutMs / 100; i++) {
2351
+ await sleep(100)
2352
+ const d = ipcRead(listFile, null)
2353
+ if (!d?.at) continue
2354
+ try { fs.rmSync(listFile, { force: true }) } catch (_) {}
2355
+ assertOpen(d)
2356
+ if (d.backup?.id === id) return d.backup
2357
+ if (d.secretError?.id === id) throw Object.assign(new Error(d.secretError.error), d.secretError)
2358
+ }
2359
+ throw Object.assign(new Error('the daemon did not reply'), { code: 'NO_REPLY' })
2360
+ }
2361
+
2362
+ function backupFail (e) {
2363
+ const msg = {
2364
+ VAULT_LOCKED: 'La bóveda está cerrada. Ábrela con: dotrino-vault unlock',
2365
+ WRONG_PASSWORD: 'Esa contraseña no abre este respaldo.',
2366
+ NOT_A_BACKUP: 'Ese archivo no es un respaldo de la bóveda.',
2367
+ BACKUP_CORRUPT: 'El respaldo está dañado: no se puede leer.',
2368
+ WEAK_PASSWORD: 'La contraseña del respaldo tiene que tener al menos 12 caracteres.',
2369
+ NO_SUCH_DRAWER: `No existe ese cajón: ${(e.missing || []).join(', ')}. Míralos con: dotrino-vault backup list`,
2370
+ NO_REPLY: 'El daemon no respondió.'
2371
+ }[e.code]
2372
+ console.error(msg || `No se pudo: ${e.message}`)
2373
+ process.exit(1)
2374
+ }
2375
+
2376
+ async function askBackupPassword ({ twice }) {
2377
+ let pwd
2378
+ try {
2379
+ pwd = await askPassword('Contraseña del respaldo: ')
2380
+ if (twice) {
2381
+ if (pwd.length < 12) { console.error('Tiene que tener al menos 12 caracteres. Varias palabras al azar funcionan bien.'); process.exit(1) }
2382
+ const again = await askPassword('Repítela: ')
2383
+ if (again !== pwd) { console.error('No coinciden.'); process.exit(1) }
2384
+ }
2385
+ } catch (_) {
2386
+ console.error('Hace falta un terminal para escribir la contraseña del respaldo.')
2387
+ process.exit(1)
2388
+ }
2389
+ if (!pwd) { console.error('Cancelado.'); process.exit(1) }
2390
+ return pwd
2391
+ }
2392
+
2393
+ async function cmdBackup (rest) {
2394
+ const [sub, ...args] = rest
2395
+ const flag = (name) => { const i = args.indexOf(name); if (i < 0) return null; const v = args[i + 1]; args.splice(i, 2); return v }
2396
+ const has = (name) => { const i = args.indexOf(name); if (i < 0) return false; args.splice(i, 1); return true }
2397
+
2398
+ if (sub === 'list') {
2399
+ const r = await backupRequest({ op: 'backup-drawers' }).catch(backupFail)
2400
+ if (!r.drawers.length) { console.log('No hay nada que respaldar todavía.'); return }
2401
+ console.log('\n%sCajones%s\n', B, Z)
2402
+ for (const d of r.drawers) console.log(' %s %s', drawerName(d.owner, d.device).padEnd(36), `${d.count} ${d.owner === 'passwords' ? 'entrada(s)' : 'valor(es)'}`)
2403
+ console.log('\nTodo: dotrino-vault backup export')
2404
+ console.log('Solo algunos: dotrino-vault backup export proxy @me contrasenas <ID>\n')
2405
+ return
2406
+ }
2407
+
2408
+ if (sub === 'export') {
2409
+ let out = flag('-o') || flag('--out')
2410
+ let selected = null
2411
+ if (args.length) {
2412
+ const known = (await backupRequest({ op: 'backup-drawers' }).catch(backupFail)).drawers
2413
+ .filter((d) => d.device).map((d) => ({ id: d.device.id, owner: d.owner }))
2414
+ selected = args.map((a) => drawerArg(a, known))
2415
+ }
2416
+ if (!out) out = `dotrino-vault-backup-${new Date().toISOString().slice(0, 10)}.json`
2417
+ if (fs.existsSync(out)) { console.error('%s ya existe. Elige otro nombre con: -o <archivo>', out); process.exit(1) }
2418
+ const backupPassword = await askBackupPassword({ twice: true })
2419
+ const r = await backupRequest({ op: 'backup-export', drawers: selected, backupPassword }).catch(backupFail)
2420
+ fs.writeFileSync(out, JSON.stringify(r.file, null, 2) + '\n', { mode: 0o600 })
2421
+ console.log('\nRespaldo guardado en %s%s%s\n', B, out, Z)
2422
+ for (const d of r.summary.drawers) console.log(' %s %d', drawerName(d.owner, d.device).padEnd(36), d.count)
2423
+ if (r.summary.passwordsUnreadable) {
2424
+ console.log('\n%s¡OJO!%s %d contraseña(s) NO entran en el respaldo: se guardaron antes de que existiera', B, Z, r.summary.passwordsUnreadable)
2425
+ console.log('la copia de recuperación. Ábrelas una vez desde un aparato con «contraseñas» y repite el respaldo.')
2426
+ }
2427
+ if (r.summary.passwordFieldsWithheld) {
2428
+ console.log('\n%s¡OJO!%s %d campo(s) de contraseñas no se pudieron abrir y faltan en el respaldo.', B, Z, r.summary.passwordFieldsWithheld)
2429
+ }
2430
+ console.log('\nLa contraseña del respaldo no se puede recuperar. Sin ella el archivo no se abre.\n')
2431
+ return
2432
+ }
2433
+
2434
+ if (sub === 'import') {
2435
+ const onConflict = flag('--on-conflict')
2436
+ if (onConflict && onConflict !== 'keep' && onConflict !== 'replace') {
2437
+ console.error('--on-conflict es keep (conserva lo de aquí) o replace (gana el respaldo).'); process.exit(2)
2438
+ }
2439
+ const yes = has('--yes') || has('-y')
2440
+ const maps = []
2441
+ for (let a; (a = flag('--as'));) maps.push(a)
2442
+ const [file, ...wanted] = args
2443
+ if (!file) { console.error('Uso: dotrino-vault backup import <archivo> [cajón…] [--as <de>=<a>] [--on-conflict keep|replace]'); process.exit(2) }
2444
+ let sealed
2445
+ try { sealed = JSON.parse(fs.readFileSync(file, 'utf8')) } catch (e) { console.error('No se pudo leer %s: %s', file, e.message); process.exit(1) }
2446
+ const backupPassword = await askBackupPassword({ twice: false })
2447
+
2448
+ // Primero lo que trae el respaldo, para poder nombrar sus cajones (y sus aparatos).
2449
+ const all = (await backupRequest({ op: 'backup-plan', file: sealed, backupPassword }).catch(backupFail)).plan
2450
+ const fromDevices = all.drawers.filter((d) => d.label?.id).map((d) => ({ id: d.label.id, owner: d.from }))
2451
+ const drawers = wanted.length ? wanted.map((w) => drawerArg(w, fromDevices)) : null
2452
+ const map = {}
2453
+ for (const m of maps) {
2454
+ const [from, to] = m.split('=')
2455
+ if (!from || !to) { console.error('--as va así: --as proxy=proxy-nuevo (o --as <ID>=<ID> para un aparato)'); process.exit(2) }
2456
+ const target = DEVICE_ID_RE.test(to) ? `dev:${(await findMember(to)).pub}` : drawerArg(to)
2457
+ map[drawerArg(from, fromDevices)] = target
2458
+ }
2459
+
2460
+ const plan = (await backupRequest({ op: 'backup-plan', file: sealed, backupPassword, drawers, map }).catch(backupFail)).plan
2461
+ const label = { new: 'nuevo', same: 'igual', conflict: 'CHOCA' }
2462
+ console.log('')
2463
+ for (const d of plan.drawers) {
2464
+ const dest = d.from === d.to ? drawerName(d.to, d.label) : `${drawerName(d.from, d.label)} → ${drawerName(d.to)}`
2465
+ if (d.status === 'no-device') { console.log('%s%s%s no entra: ese aparato no está en esta cuenta (muévelo con --as <ID>=<ID>)', B, dest, Z); continue }
2466
+ console.log('%s%s%s', B, dest, Z)
2467
+ for (const it of d.items) console.log(' %s %s', (label[it.status] || it.status).padEnd(7), it.title ? `${it.title}${it.username ? ' · ' + it.username : ''}` : it.key)
2468
+ }
2469
+ const c = plan.counts
2470
+ console.log('\n%d nuevo(s) · %d igual(es) · %d choque(s)%s\n', c.new, c.same, c.conflict, c.skipped ? ` · ${c.skipped} sin destino` : '')
2471
+
2472
+ // LOS CHOQUES: o los decide la bandera, o se preguntan uno a uno. Sin terminal y sin
2473
+ // bandera se para: decidir por defecto sería pisar (o perder) algo sin que nadie lo vea.
2474
+ const decisions = {}
2475
+ if (c.conflict && !onConflict) {
2476
+ if (!process.stdin.isTTY) {
2477
+ console.error('Hay choques y no dijiste qué hacer. Repite con --on-conflict keep (conserva lo de aquí)')
2478
+ console.error('o --on-conflict replace (gana el respaldo). No se importó nada.')
2479
+ process.exit(1)
2480
+ }
2481
+ let forAll = null
2482
+ for (const d of plan.drawers) {
2483
+ for (const it of d.items) {
2484
+ if (it.status !== 'conflict') continue
2485
+ if (!forAll) {
2486
+ const name = it.title ? `${it.title}${it.username ? ' · ' + it.username : ''}` : it.key
2487
+ const a = (await askText(`${drawerName(d.to, d.label)} · ${name}: [c]onservar lo de aquí, [r]eemplazar, [C]/[R] todos los demás: `)).trim()
2488
+ if (a === 'C') forAll = 'keep'
2489
+ else if (a === 'R') forAll = 'replace'
2490
+ else if (a === 'c' || a === 'r') { decisions[`${d.to} ${it.key}`] = a === 'c' ? 'keep' : 'replace'; continue }
2491
+ else { console.error('Cancelado. No se importó nada.'); process.exit(1) }
2492
+ }
2493
+ decisions[`${d.to} ${it.key}`] = forAll
2494
+ }
2495
+ }
2496
+ }
2497
+ if (!c.new && !Object.values(decisions).includes('replace') && onConflict !== 'replace') { console.log('No hay nada que importar.'); return }
2498
+ if (!yes) {
2499
+ const a = (await askText('¿Importar? [s/N] ')).trim().toLowerCase()
2500
+ if (a !== 's' && a !== 'si' && a !== 'sí') { console.log('No se importó nada.'); return }
2501
+ }
2502
+ let r
2503
+ try {
2504
+ r = await backupRequest({ op: 'backup-import', file: sealed, backupPassword, drawers, map, onConflict, decisions })
2505
+ } catch (e) {
2506
+ if (e.code === 'BACKUP_CONFLICTS') {
2507
+ // La bóveda cambió entre el plan y el sí: hay un choque que no estaba.
2508
+ console.error('Apareció un choque nuevo mientras decidías (la bóveda cambió). No se importó nada; repite la orden.')
2509
+ process.exit(1)
2510
+ }
2511
+ backupFail(e)
2512
+ }
2513
+ console.log('\nImportado: %d escrito(s), %d conservado(s), %d ya igual(es).', r.result.written, r.result.kept, r.result.same)
2514
+ for (const k of r.result.keptPrivate) console.log(' %s · %s se queda privada: aquí ya era privada.', drawerName(k.drawer), k.key)
2515
+ console.log('')
2516
+ return
2517
+ }
2518
+
2519
+ console.error(`Uso:
2520
+ dotrino-vault backup list qué cajones hay
2521
+ dotrino-vault backup export [cajón…] [-o archivo] todo, o solo esos cajones
2522
+ dotrino-vault backup import <archivo> [cajón…] [--as <de>=<a>] [--on-conflict keep|replace] [--yes]
2523
+
2524
+ Un cajón es el nombre de un scope (proxy), @me (tu perfil), contrasenas, o el ID de un aparato.`)
2525
+ process.exit(2)
2526
+ }
2527
+
2308
2528
  function help () {
2309
2529
  console.log(`dotrino-vault — control del certificador personal
2310
2530
 
@@ -2369,6 +2589,17 @@ function help () {
2369
2589
  privada, y una privada NO se vuelve pública.
2370
2590
  secret visibility <ns> <CLAVE> private tapa una pública sin tocar el valor
2371
2591
  secret device visibility <ID> <CLAVE> private
2592
+ backup list qué cajones hay para respaldar (nombres y cuántos valores)
2593
+ backup export [cajón…] [-o archivo]
2594
+ un archivo cifrado con una contraseña propia, con los valores de
2595
+ TODOS los cajones o solo de los que digas (proxy, @me, contrasenas,
2596
+ el ID de un aparato). Las llaves no van: son de cada aparato.
2597
+ Bóveda abierta
2598
+ backup import <archivo> [cajón…] [--as <de>=<a>] [--on-conflict keep|replace] [--yes]
2599
+ lo trae a esta bóveda y lo vuelve a sellar con sus llaves. Primero
2600
+ enseña qué es nuevo, qué es igual y qué choca; un choque se decide
2601
+ (conservar o reemplazar) o no se importa nada. --as mueve un cajón a
2602
+ otro nombre o a otro aparato. Bóveda abierta
2372
2603
  join <invitación> [--name <n>] [--kms <config.json>]
2373
2604
  ESTA bóveda ENTRA en la cuenta de otra (el papel contrario a pair).
2374
2605
  Entra con su propia llave, así que después se le puede dar +sella
@@ -2575,6 +2806,7 @@ export async function runCtl (argv) {
2575
2806
  case 'logins': return cmdLogins(rest)
2576
2807
  case 'update': return cmdUpdate(rest)
2577
2808
  case 'secret': return cmdSecret(rest)
2809
+ case 'backup': return cmdBackup(rest)
2578
2810
  // `approval` se quitó el 2026-09-01 (ahora es el permiso `unattended`), pero la ruta se
2579
2811
  // quedó apuntando a una función que ya no existía: reventaba con un ReferenceError. Se
2580
2812
  // rechaza en voz alta, igual que `caps +approval`.
package/src/daemon.js CHANGED
@@ -31,6 +31,7 @@ import { readSettings } from './settings.js'
31
31
  import { approversOfAll, firstYes, readAsked, writeAsked, clearAsked, shouldAsk, resultOfNo, readRootTold, writeRootTold, shouldTellRoot } from './updateApproval.js'
32
32
  import { UPDATE_TTL_MS } from '../lib/src/core/approvals.js'
33
33
  import crypto from 'node:crypto'
34
+ import { sealBackup, openBackup } from '../lib/src/core/backup.js'
34
35
 
35
36
  const readJsonSafe = (f) => ipcRead(f, null)
36
37
  const rm = (f) => { try { fs.rmSync(f, { force: true }) } catch (_) {} }
@@ -366,6 +367,12 @@ export async function runDaemon () {
366
367
  let lastSecretValue = null
367
368
  /** Y las versiones anteriores que se acaban de pedir. Mismo camino, mismo volcado. */
368
369
  let lastSecretHistory = null
370
+ /**
371
+ * Lo que contestó la última orden de RESPALDO (`dotrino-vault backup`). Mismo camino que
372
+ * `lastSecretValue`. Lo que viaja nunca es un valor en claro: el respaldo sale YA CIFRADO
373
+ * con su contraseña, y el plan de importar lleva nombres y estados, no valores.
374
+ */
375
+ let lastBackup = null
369
376
  // ENTRAR CON USUARIO Y CONTRASEÑA, desde esta máquina (`dotrino-vault logins`).
370
377
  //
371
378
  // La respuesta NO se llama `logins.json`: ese nombre ya es el almacén del escritorio en
@@ -802,6 +809,26 @@ export async function runDaemon () {
802
809
  const value = await vault.revealSecret(sec.owner, sec.key, ak)
803
810
  lastSecretValue = { owner: sec.owner, key: sec.key, value }
804
811
  }
812
+ // EL RESPALDO. Bóveda abierta (lo comprueba la bóveda) y la contraseña del RESPALDO,
813
+ // que no es la del perfil: con ella se cierra el archivo aquí mismo, para que lo que
814
+ // salga de este proceso ya vaya cifrado.
815
+ else if (sec.op === 'backup-drawers') {
816
+ lastBackup = { op: sec.op, id: sec.id || null, drawers: await vault.backupDrawers() }
817
+ } else if (sec.op === 'backup-export') {
818
+ const { payload, summary } = await vault.exportBackup({ drawers: sec.drawers || null, adminKey: ak })
819
+ lastBackup = { op: sec.op, id: sec.id || null, file: await sealBackup(payload, sec.backupPassword), summary }
820
+ console.log('[vault] backup exported: %d drawer(s)', summary.drawers.length)
821
+ } else if (sec.op === 'backup-plan' || sec.op === 'backup-import') {
822
+ const payload = await openBackup(sec.file, sec.backupPassword)
823
+ const opts = { drawers: sec.drawers || null, map: sec.map || {}, adminKey: ak }
824
+ if (sec.op === 'backup-plan') {
825
+ lastBackup = { op: sec.op, id: sec.id || null, plan: await vault.planBackupImport(payload, opts) }
826
+ } else {
827
+ const result = await vault.importBackup(payload, { ...opts, onConflict: sec.onConflict || null, decisions: sec.decisions || {} })
828
+ lastBackup = { op: sec.op, id: sec.id || null, result }
829
+ console.log('[vault] backup imported: %d value(s) written, %d kept', result.written, result.kept)
830
+ }
831
+ }
805
832
  // Qué versiones anteriores hay (sin valores: son sobres).
806
833
  else if (sec.op === 'history') {
807
834
  lastSecretHistory = { owner: sec.owner || null, key: sec.key || null, items: vault.secretHistory(sec.owner || null, sec.key || null) }
@@ -818,7 +845,11 @@ export async function runDaemon () {
818
845
  } catch (e) {
819
846
  lastSecretError = {
820
847
  error: e.message,
821
- code: e.code || (/wrong password/i.test(e.message) ? 'WRONG_PASSWORD' : 'SECRET_FAILED')
848
+ code: e.code || (/wrong password/i.test(e.message) ? 'WRONG_PASSWORD' : 'SECRET_FAILED'),
849
+ // Los choques de una importación viajan con el error: son lo que hay que decidir.
850
+ ...(e.conflicts ? { conflicts: e.conflicts } : {}),
851
+ ...(e.missing ? { missing: e.missing } : {}),
852
+ ...(sec.op?.startsWith('backup-') ? { op: sec.op, id: sec.id || null } : {})
822
853
  }
823
854
  console.error('[vault] secret failed:', e.message)
824
855
  } finally { wipe(ak) }
@@ -928,11 +959,13 @@ export async function runDaemon () {
928
959
  incomplete: await t.vault.incompleteMembers(),
929
960
  ...(lastSecretError ? { secretError: lastSecretError } : {}),
930
961
  ...(lastSecretValue ? { revealed: lastSecretValue } : {}),
931
- ...(lastSecretHistory ? { history: lastSecretHistory } : {})
962
+ ...(lastSecretHistory ? { history: lastSecretHistory } : {}),
963
+ ...(lastBackup ? { backup: lastBackup } : {})
932
964
  })
933
965
  lastSecretError = null
934
966
  lastSecretValue = null
935
967
  lastSecretHistory = null
968
+ lastBackup = null
936
969
  ipcWrite(devFile, { v: 1, at: Date.now(), req: reqId, profile: t.id, ...(await t.vault.listDevices()) })
937
970
  // Acta del perfil: quién es del perfil y qué puede hacer cada uno (`members`/`caps`).
938
971
  try { ipcWrite(path.join(dir, 'acta.json'), { v: 1, at: Date.now(), req: reqId, profile: t.id, ...(await t.vault.profileMembers()), blocked: t.vault.listBlocked?.() || [] }) } catch (_) {}