@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.
- package/lib/src/core/backup.js +244 -0
- package/lib/src/core/libVersion.js +1 -1
- package/lib/src/core/passwords.js +88 -12
- package/lib/src/core/secretsStore.js +61 -2
- package/lib/src/core/vault.js +169 -1
- package/package.json +2 -2
- package/src/ctl.js +232 -0
- package/src/daemon.js +35 -2
|
@@ -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
|
+
}
|
|
@@ -52,18 +52,7 @@ export function createPasswordDesk (opts = {}) {
|
|
|
52
52
|
log = () => {},
|
|
53
53
|
} = opts
|
|
54
54
|
|
|
55
|
-
const sealed =
|
|
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
|
-
|
|
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 >
|
|
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)
|
package/lib/src/core/vault.js
CHANGED
|
@@ -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:
|
|
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.
|
|
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.
|
|
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 (_) {}
|