@dotrino/vaultd 0.160.3 → 0.162.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 +1 -1
- package/src/ctl.js +220 -0
- package/src/daemon.js +35 -2
- package/src/tui/app.js +300 -5
- package/src/tui/i18n.js +109 -2
- package/src/vaultControl.js +48 -0
|
@@ -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.162.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": {
|