@dotrino/vaultd 0.6.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/README.md +301 -0
- package/bin/dotrino-vault-tui.js +28 -0
- package/bin/dotrino-vault.js +23 -0
- package/bin/dotrino-vaultd.js +29 -0
- package/bin/sea-entry.js +29 -0
- package/lib/README.md +139 -0
- package/lib/src/config.js +26 -0
- package/lib/src/enroll.js +293 -0
- package/lib/src/env.js +95 -0
- package/lib/src/index.js +166 -0
- package/lib/src/protocol.js +53 -0
- package/lib/src/sealed.js +84 -0
- package/lib/src/service.js +258 -0
- package/package.json +41 -0
- package/src/atrest.js +0 -0
- package/src/client.js +149 -0
- package/src/ctl.js +597 -0
- package/src/daemon.js +217 -0
- package/src/manager.js +88 -0
- package/src/node-globals.js +37 -0
- package/src/paths.js +47 -0
- package/src/profiles.js +214 -0
- package/src/protocol.js +6 -0
- package/src/qr.js +61 -0
- package/src/secretsStore.js +61 -0
- package/src/store.js +64 -0
- package/src/threadStore.js +111 -0
- package/src/transport.js +64 -0
- package/src/tui/app.js +722 -0
- package/src/tui/term.js +278 -0
- package/src/vault.js +303 -0
- package/src/vaultControl.js +296 -0
- package/vendor/qrcode-generator.cjs +2297 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vaultControl.js — API programática de control del daemon del vault.
|
|
3
|
+
*
|
|
4
|
+
* Es la MISMA vía que usa la CLI (`ctl.js`): NO abre la identidad ni el proxy, no
|
|
5
|
+
* toca la maestra. Le da órdenes al daemon (único custodio) escribiendo peticiones
|
|
6
|
+
* en el dir de datos (0600) y disparando señales:
|
|
7
|
+
*
|
|
8
|
+
* SIGUSR1 → inicia un emparejamiento (vuelca pair.json)
|
|
9
|
+
* SIGUSR2 → consume approve/reject/revoke/secret/profile/dump-request y vuelca
|
|
10
|
+
* devices.json / secrets-list.json / profiles-list.json
|
|
11
|
+
*
|
|
12
|
+
* La usa la TUI (`src/tui/`). Se mantiene como capa fina y sin estado para que la
|
|
13
|
+
* TUI y la CLI no dupliquen el protocolo: si el daemon cambia el contrato de
|
|
14
|
+
* archivos/señales, se toca aquí y en `daemon.js`, en ningún otro lado.
|
|
15
|
+
*
|
|
16
|
+
* MULTI-PERFIL: cada función recibe opcionalmente `profile` (id o nombre). Sin él,
|
|
17
|
+
* el daemon apunta al perfil ACTIVO.
|
|
18
|
+
*/
|
|
19
|
+
import fs from 'node:fs'
|
|
20
|
+
import path from 'node:path'
|
|
21
|
+
import { pubkeyId } from '@dotrino/identity/capabilities'
|
|
22
|
+
import { dataDir, readJson } from './paths.js'
|
|
23
|
+
|
|
24
|
+
const dir = dataDir()
|
|
25
|
+
|
|
26
|
+
// Nombres de archivos del contrato con el daemon (ver daemon.js). Único lugar.
|
|
27
|
+
const F = {
|
|
28
|
+
state: 'state.json',
|
|
29
|
+
pair: 'pair.json',
|
|
30
|
+
pending: 'pending-enroll.json',
|
|
31
|
+
devices: 'devices.json',
|
|
32
|
+
profilesList: 'profiles-list.json',
|
|
33
|
+
secretsList: 'secrets-list.json',
|
|
34
|
+
// peticiones (las escribe el control; el daemon las consume y borra)
|
|
35
|
+
pairReq: 'pair-request.json',
|
|
36
|
+
approveReq: 'approve-request.json',
|
|
37
|
+
rejectReq: 'reject-request.json',
|
|
38
|
+
revokeReq: 'revoke-request.json',
|
|
39
|
+
secretReq: 'secret-request.json',
|
|
40
|
+
profileReq: 'profile-request.json',
|
|
41
|
+
dumpReq: 'dump-request.json'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const p = (name) => path.join(dir, name)
|
|
45
|
+
const read = (name, fb = null) => readJson(p(name), fb)
|
|
46
|
+
const rm = (name) => { try { fs.rmSync(p(name), { force: true }) } catch (_) {} }
|
|
47
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
48
|
+
|
|
49
|
+
export function vaultDir () { return dir }
|
|
50
|
+
|
|
51
|
+
export function readState () { return read(F.state, null) }
|
|
52
|
+
|
|
53
|
+
/** ¿El pid está vivo? (kill 0 no envía señal, solo comprueba permiso/existencia). */
|
|
54
|
+
export function pidAlive (pid) { try { return !!pid && (process.kill(pid, 0) || true) } catch (_) { return false } }
|
|
55
|
+
|
|
56
|
+
/** ¿Hay un daemon corriendo? (state.json presente + pid vivo). */
|
|
57
|
+
export function daemonAlive () { const s = readState(); return !!(s && pidAlive(s.pid)) }
|
|
58
|
+
|
|
59
|
+
/** deviceId legible (8 hex agrupados AB12-CD34) a partir del pubkey `sub`. */
|
|
60
|
+
export async function deviceIdOf (sub) {
|
|
61
|
+
if (!sub) return '????-????'
|
|
62
|
+
const id = (await pubkeyId(sub)).slice(0, 8).toUpperCase()
|
|
63
|
+
return id.slice(0, 4) + '-' + id.slice(4, 8)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
class DaemonDownError extends Error {
|
|
67
|
+
constructor () { super('el daemon del vault no está corriendo'); this.code = 'DAEMON_DOWN' }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Exige el daemon vivo ANTES de escribir cualquier petición. Es clave para las
|
|
72
|
+
* peticiones que llevan secretos (contraseña de perfil, valor de secreto): si el
|
|
73
|
+
* daemon está caído no habría quien las consuma ni borre, y quedarían en claro en
|
|
74
|
+
* disco. Mismo criterio que `requireDaemon()` de la CLI.
|
|
75
|
+
*/
|
|
76
|
+
function requireAlive () {
|
|
77
|
+
const s = readState()
|
|
78
|
+
if (!s || !pidAlive(s.pid)) throw new DaemonDownError()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function writeReq (name, obj, profile) {
|
|
82
|
+
const body = { ...obj, at: Date.now() }
|
|
83
|
+
if (profile) body.profile = profile
|
|
84
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
85
|
+
fs.writeFileSync(p(name), JSON.stringify(body), { mode: 0o600 })
|
|
86
|
+
// `mode` de writeFileSync solo aplica al CREAR: re-chmod por si el archivo ya
|
|
87
|
+
// existía con permisos más laxos (defensa en profundidad; el dir ya es 0700).
|
|
88
|
+
try { fs.chmodSync(p(name), 0o600) } catch (_) {}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function signal (sig) {
|
|
92
|
+
const s = readState()
|
|
93
|
+
if (!s || !pidAlive(s.pid)) throw new DaemonDownError()
|
|
94
|
+
process.kill(s.pid, sig)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Dispara la señal y, si falla (daemon murió entre el chequeo y ahora), BORRA las
|
|
99
|
+
* peticiones que quedaron escritas para no dejar secretos en disco.
|
|
100
|
+
*/
|
|
101
|
+
function signalOrCleanup (sig, reqFiles) {
|
|
102
|
+
try { signal(sig) } catch (e) { for (const f of reqFiles) rm(f); throw e }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Espera a que reaparezca un archivo de respuesta (con `.at`) tras borrarlo. */
|
|
106
|
+
async function waitFor (name, { tries = 60, interval = 100 } = {}) {
|
|
107
|
+
for (let i = 0; i < tries; i++) {
|
|
108
|
+
await sleep(interval)
|
|
109
|
+
const d = read(name, null)
|
|
110
|
+
if (d && d.at) return d
|
|
111
|
+
}
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Perfiles / bóvedas + candado
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Manda una orden de perfil y espera el volcado. `profile` (id/nombre) es tanto el
|
|
121
|
+
* OPERANDO (para use/rm/rename) como el DESTINO (para unlock/lock/password). La
|
|
122
|
+
* contraseña viaja en un archivo 0600 que el daemon borra al leerlo.
|
|
123
|
+
*/
|
|
124
|
+
async function profileOp (op, { profile, name, password } = {}) {
|
|
125
|
+
requireAlive() // nunca escribir la contraseña si no hay quien la consuma
|
|
126
|
+
rm(F.profilesList)
|
|
127
|
+
const extra = {}
|
|
128
|
+
if (name != null) extra.name = name
|
|
129
|
+
if (password != null) extra.password = password
|
|
130
|
+
writeReq(F.profileReq, { op, ...extra }, profile)
|
|
131
|
+
signalOrCleanup('SIGUSR2', [F.profileReq])
|
|
132
|
+
const d = await waitFor(F.profilesList)
|
|
133
|
+
if (!d) throw new Error('el daemon no respondió')
|
|
134
|
+
if (d.error) throw new Error(d.error)
|
|
135
|
+
return d // { profiles:[{id,name,protected,locked,current,fingerprint,iss,createdAt}], current, done? }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export const listProfiles = () => profileOp('list')
|
|
139
|
+
export const addProfile = (name) => profileOp('add', { name })
|
|
140
|
+
export const useProfile = (profile) => profileOp('use', { profile })
|
|
141
|
+
export const renameProfile = (profile, name) => profileOp('rename', { profile, name })
|
|
142
|
+
export const removeProfile = (profile) => profileOp('rm', { profile })
|
|
143
|
+
export const unlockProfile = (profile, password) => profileOp('unlock', { profile, password })
|
|
144
|
+
export const lockProfile = (profile) => profileOp('lock', { profile })
|
|
145
|
+
export const setProfilePassword = (profile, password) => profileOp('password-set', { profile, password })
|
|
146
|
+
export const removeProfilePassword = (profile) => profileOp('password-rm', { profile })
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// Volcado de dispositivos + secretos de un perfil
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Fuerza el volcado del daemon (devices.json + secrets-list.json + profiles-list.json)
|
|
154
|
+
* para `profile` (o el activo) y devuelve las tres cosas ya parseadas.
|
|
155
|
+
*/
|
|
156
|
+
export async function snapshot (profile) {
|
|
157
|
+
requireAlive()
|
|
158
|
+
rm(F.devices); rm(F.secretsList); rm(F.profilesList)
|
|
159
|
+
writeReq(F.dumpReq, {}, profile)
|
|
160
|
+
signalOrCleanup('SIGUSR2', [F.dumpReq])
|
|
161
|
+
const [devices, secrets, profiles] = await Promise.all([
|
|
162
|
+
waitFor(F.devices), waitFor(F.secretsList), waitFor(F.profilesList)
|
|
163
|
+
])
|
|
164
|
+
return { devices, secrets, profiles }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Dispositivos enrolados/revocados del perfil, con su deviceId ya calculado.
|
|
169
|
+
* `issued` viene de identity.listDelegations(); el deviceId se deriva del `sub`.
|
|
170
|
+
*/
|
|
171
|
+
export async function listDevices (profile) {
|
|
172
|
+
const { devices } = await snapshot(profile)
|
|
173
|
+
if (!devices) throw new Error('el daemon no respondió')
|
|
174
|
+
const issued = devices.issued || devices.active || devices.delegations || []
|
|
175
|
+
const withIds = await Promise.all(issued.map(async (d) => ({
|
|
176
|
+
...d, deviceId: d.sub ? await deviceIdOf(d.sub) : '????-????'
|
|
177
|
+
})))
|
|
178
|
+
return { issued: withIds, revoked: devices.revoked || [], profile: devices.profile || null }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Revoca un dispositivo por su `nonce` (le ordena autoborrarse) y revuelca. */
|
|
182
|
+
export async function revokeDevice (nonce, profile) {
|
|
183
|
+
requireAlive()
|
|
184
|
+
writeReq(F.revokeReq, { nonce }, profile)
|
|
185
|
+
signalOrCleanup('SIGUSR2', [F.revokeReq])
|
|
186
|
+
await sleep(300)
|
|
187
|
+
return listDevices(profile)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// Secretos: scopes (namespaces) y variables (claves)
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
/** Scopes→[claves] del perfil (NUNCA los valores; el daemon no los expone). */
|
|
195
|
+
export async function listSecrets (profile) {
|
|
196
|
+
const { secrets } = await snapshot(profile)
|
|
197
|
+
if (!secrets) throw new Error('el daemon no respondió')
|
|
198
|
+
return secrets.ns || {}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Guarda/actualiza una variable. ns: [a-z0-9-]{1,32}. clave: [A-Z0-9_]{1,64}. */
|
|
202
|
+
export async function setSecret (ns, key, value, profile) {
|
|
203
|
+
requireAlive() // el VALOR es secreto: no escribirlo si el daemon está caído
|
|
204
|
+
rm(F.secretsList)
|
|
205
|
+
writeReq(F.secretReq, { op: 'set', ns, key, value }, profile)
|
|
206
|
+
writeReq(F.dumpReq, {}, profile)
|
|
207
|
+
signalOrCleanup('SIGUSR2', [F.secretReq, F.dumpReq])
|
|
208
|
+
const d = await waitFor(F.secretsList)
|
|
209
|
+
if (!d) throw new Error('el daemon no respondió')
|
|
210
|
+
if (!(d.ns?.[ns] || []).includes(key)) throw new Error('el daemon no aplicó el cambio (revisa los logs del servicio)')
|
|
211
|
+
return d.ns
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Borra una variable. Si era la última del scope, el scope desaparece. */
|
|
215
|
+
export async function deleteSecret (ns, key, profile) {
|
|
216
|
+
requireAlive()
|
|
217
|
+
rm(F.secretsList)
|
|
218
|
+
writeReq(F.secretReq, { op: 'rm', ns, key }, profile)
|
|
219
|
+
writeReq(F.dumpReq, {}, profile)
|
|
220
|
+
signalOrCleanup('SIGUSR2', [F.secretReq, F.dumpReq])
|
|
221
|
+
const d = await waitFor(F.secretsList)
|
|
222
|
+
if (!d) throw new Error('el daemon no respondió')
|
|
223
|
+
if ((d.ns?.[ns] || []).includes(key)) throw new Error('el daemon no borró la variable (revisa los logs del servicio)')
|
|
224
|
+
return d.ns
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Borra un scope entero (todas sus variables, una por una). */
|
|
228
|
+
export async function deleteScope (ns, profile) {
|
|
229
|
+
const all = await listSecrets(profile)
|
|
230
|
+
const keys = all[ns] || []
|
|
231
|
+
let ns2 = all
|
|
232
|
+
for (const k of keys) ns2 = await deleteSecret(ns, k, profile)
|
|
233
|
+
return ns2
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
// Emparejamiento de dispositivos (pares)
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
const PROFILE_URL = 'https://vault.dotrino.com/dispositivos#vault='
|
|
241
|
+
|
|
242
|
+
/** Codifica el QR crudo como URL de la consola de vault.dotrino.com (base64url del payload). */
|
|
243
|
+
export function pairUrl (qr) {
|
|
244
|
+
const payload = JSON.stringify(qr)
|
|
245
|
+
const b64 = Buffer.from(payload, 'utf8').toString('base64')
|
|
246
|
+
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
247
|
+
return { url: PROFILE_URL + b64, payload }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Inicia un emparejamiento. `service` (opcional) enrola un SERVICIO con acceso
|
|
252
|
+
* SOLO a `vault:secrets:<service>` (no firma por ti ni lee tus datos).
|
|
253
|
+
* Devuelve { qr, expiresAt, url, payload }.
|
|
254
|
+
*/
|
|
255
|
+
export async function startPairing ({ profile, service } = {}) {
|
|
256
|
+
requireAlive()
|
|
257
|
+
rm(F.pair); rm(F.pending)
|
|
258
|
+
writeReq(F.pairReq, service ? { service } : {}, profile)
|
|
259
|
+
signalOrCleanup('SIGUSR1', [F.pairReq])
|
|
260
|
+
for (let i = 0; i < 50; i++) {
|
|
261
|
+
await sleep(100)
|
|
262
|
+
const pr = read(F.pair, null)
|
|
263
|
+
if (pr?.expiresAt > Date.now()) {
|
|
264
|
+
const { url, payload } = pairUrl(pr.qr)
|
|
265
|
+
return { qr: pr.qr, expiresAt: pr.expiresAt, url, payload }
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
throw new Error('el daemon no inició el emparejamiento')
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Dispositivo pendiente de aprobar (el que se conectó con el QR), o null. */
|
|
272
|
+
export function pendingEnroll () {
|
|
273
|
+
const pe = read(F.pending, null)
|
|
274
|
+
return pe?.deviceId ? pe : null
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Aprueba el dispositivo pendiente escribiendo el CÓDIGO que MUESTRA el dispositivo
|
|
279
|
+
* (el vault no lo conoce). Firma el cert y se lo manda. Devuelve la lista de
|
|
280
|
+
* dispositivos ya actualizada.
|
|
281
|
+
*/
|
|
282
|
+
export async function approvePending (code, profile) {
|
|
283
|
+
requireAlive()
|
|
284
|
+
writeReq(F.approveReq, { code: String(code) }, profile)
|
|
285
|
+
signalOrCleanup('SIGUSR2', [F.approveReq])
|
|
286
|
+
await sleep(400)
|
|
287
|
+
return listDevices(profile)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Rechaza el dispositivo pendiente. */
|
|
291
|
+
export async function rejectPending (deviceId, profile) {
|
|
292
|
+
requireAlive()
|
|
293
|
+
writeReq(F.rejectReq, { deviceId }, profile)
|
|
294
|
+
signalOrCleanup('SIGUSR2', [F.rejectReq])
|
|
295
|
+
await sleep(200)
|
|
296
|
+
}
|