@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/src/daemon.js ADDED
@@ -0,0 +1,217 @@
1
+ /**
2
+ * daemon.js — modo SERVICIO del vault. Arranca TODOS los perfiles del usuario
3
+ * (`manager.js`) y expone control LOCAL por archivos + señales (sin socket/puerto:
4
+ * nada escucha en red).
5
+ *
6
+ * state.json perfiles + fingerprint/iss de cada uno (lo lee el CLI/instalador)
7
+ * SIGUSR1 → pair.json inicia un emparejamiento y vuelca el QR
8
+ * pending-enroll.json cuando un dispositivo pide enrolarse: { deviceId } a
9
+ * aprobar (emparejamiento ENDURECIDO, ver docs/)
10
+ * SIGUSR2 → consume approve/reject/revoke/secret/profile-request y vuelca
11
+ * devices.json / secrets-list.json / profiles-list.json
12
+ *
13
+ * MULTI-PERFIL: cada petición de la CLI trae a qué perfil apunta (`profile`); si
14
+ * no lo trae, va al perfil activo. La maestra del perfil solo firma el cert de un
15
+ * dispositivo DESPUÉS de `dotrino-vault approve`.
16
+ */
17
+ import fs from 'node:fs'
18
+ import path from 'node:path'
19
+ import { startVaultManager } from './manager.js'
20
+ import { dataDir, writeJson, readJson } from './paths.js'
21
+
22
+ const readJsonSafe = (f) => readJson(f, null)
23
+ const rm = (f) => { try { fs.rmSync(f, { force: true }) } catch (_) {} }
24
+
25
+ export async function runDaemon () {
26
+ const dir = dataDir()
27
+ const proxyUrl = process.env.PROXY_URL || 'wss://proxy.dotrino.com'
28
+
29
+ const pendingEnrollFile = path.join(dir, 'pending-enroll.json')
30
+ // Cuando un dispositivo pide enrolarse, exponemos su deviceId (y a QUÉ perfil
31
+ // quiere entrar) para que el dueño lo compare con el del dispositivo y apruebe.
32
+ const onEnrollChallenge = ({ deviceId, scope, profile, profileName }) => {
33
+ writeJson(pendingEnrollFile, { v: 2, at: Date.now(), deviceId, scope, profile, profileName })
34
+ }
35
+
36
+ const mgr = await startVaultManager({ root: dir, proxyUrl, onEnrollChallenge })
37
+
38
+ // --- state.json ---
39
+ const stateFile = path.join(dir, 'state.json')
40
+ const daemonVersion = (typeof __VAULT_VERSION__ !== 'undefined') ? __VAULT_VERSION__ : 'dev'
41
+ // Los campos de la raíz (fingerprint/iss) son los del perfil ACTIVO: los leen el
42
+ // instalador y la web, que son anteriores al multi-perfil. La lista completa va
43
+ // en `profiles`.
44
+ const writeState = () => {
45
+ const cur = mgr.summary().find((p) => p.current) || {}
46
+ writeJson(stateFile, {
47
+ v: 2, version: daemonVersion, fingerprint: cur.fingerprint || null, iss: cur.iss || null,
48
+ proxy: proxyUrl, pid: process.pid, startedAt: new Date().toISOString(),
49
+ current: mgr.currentId(), profiles: mgr.summary()
50
+ })
51
+ }
52
+ writeState()
53
+ const profilesFile = path.join(dir, 'profiles-list.json')
54
+ const dumpProfiles = (extra = {}) => { writeState(); writeJson(profilesFile, { v: 1, at: Date.now(), current: mgr.currentId(), profiles: mgr.summary(), ...extra }) }
55
+
56
+ console.log(`dotrino-vault · datos en ${dir} · proxy ${proxyUrl}`)
57
+ for (const p of mgr.summary()) {
58
+ console.log(`perfil ${p.current ? '*' : ' '} ${p.name || '(sin nombre)'} · ${p.id} · ${p.fingerprint}${p.protected ? (p.locked ? ' · 🔒 bloqueado' : ' · 🔓 desbloqueado') : ''}`)
59
+ }
60
+
61
+ /** Perfil destino de una petición de la CLI (o el activo si no lo dice). */
62
+ const resolveTarget = (req) => {
63
+ try {
64
+ const id = req?.profile ? mgr.resolve(req.profile) : mgr.currentId()
65
+ return { id, vault: mgr.get(id) }
66
+ } catch (e) { console.error('[vault] perfil inválido en la petición:', e.message); return null }
67
+ }
68
+ const targetOf = (req) => resolveTarget(req)?.vault || null
69
+
70
+ // --- SIGUSR1: iniciar emparejamiento ---
71
+ const pairFile = path.join(dir, 'pair.json')
72
+ const pairReqFile = path.join(dir, 'pair-request.json')
73
+ process.on('SIGUSR1', () => {
74
+ try {
75
+ rm(pendingEnrollFile)
76
+ // Pairing manual por CLI = gesto explícito del dueño → cert de identidad completo.
77
+ // ttlMs: 30 días (MAX_DELEGATION_MS). Sin esto caía al default de 24 h, pensado
78
+ // para delegaciones efímeras: los dispositivos emparejados morían al día
79
+ // siguiente en silencio ("no autorizado" en todas las apps). La renovación
80
+ // automática la maneja `handleRenew`.
81
+ const DEVICE_TTL_MS = 30 * 24 * 60 * 60 * 1000
82
+ // `pair --service <ns>` (vía pair-request.json): cert SOLO con
83
+ // vault:secrets:<ns> — para enrolar un SERVICIO (proxy, geo…) que lee sus
84
+ // secretos, sin poder firmar como el usuario ni leer sus datos.
85
+ const pairReq = readJsonSafe(pairReqFile); rm(pairReqFile)
86
+ const vault = targetOf(pairReq)
87
+ if (!vault) return
88
+ const profileId = pairReq?.profile ? mgr.resolve(pairReq.profile) : mgr.currentId()
89
+ const isService = typeof pairReq?.service === 'string' && pairReq.service
90
+ const scope = isService ? ['vault:secrets:' + pairReq.service] : ['vault:sign', 'vault:read', 'vault:store']
91
+ const label = pairReq?.label || (isService ? 'servicio:' + pairReq.service : 'cli')
92
+ const { qr, expiresInMs } = vault.startPairing({ scope, label, ttlMs: DEVICE_TTL_MS })
93
+ writeJson(pairFile, { v: 2, qr, expiresAt: Date.now() + expiresInMs, profile: profileId })
94
+ // El token es un secreto efímero: no debe quedar en disco más allá de su
95
+ // vida. Se borra al VENCER (aquí) y al APROBARSE (abajo, consumido).
96
+ const tok = qr.token
97
+ setTimeout(() => {
98
+ const cur = readJsonSafe(pairFile)
99
+ if (cur?.qr?.token === tok) rm(pairFile)
100
+ }, expiresInMs + 1000).unref?.()
101
+ console.log('[vault] emparejamiento iniciado (válido %d min)', expiresInMs / 60000)
102
+ } catch (e) {
103
+ console.error('[vault] no se pudo iniciar emparejamiento:', e.message)
104
+ }
105
+ })
106
+
107
+ // --- SIGUSR2: approve / reject / revoke / secretos / perfiles + volcados ---
108
+ const devFile = path.join(dir, 'devices.json')
109
+ const approveReqFile = path.join(dir, 'approve-request.json')
110
+ const rejectReqFile = path.join(dir, 'reject-request.json')
111
+ const revokeReqFile = path.join(dir, 'revoke-request.json')
112
+ const secretReqFile = path.join(dir, 'secret-request.json')
113
+ const secretsListFile = path.join(dir, 'secrets-list.json')
114
+ const profileReqFile = path.join(dir, 'profile-request.json')
115
+ const dumpReqFile = path.join(dir, 'dump-request.json')
116
+
117
+ /**
118
+ * Órdenes de perfil (crear/renombrar/borrar/activar) y del candado
119
+ * (unlock/lock/password). La contraseña llega en un archivo 0600 dentro del dir
120
+ * 0700 del vault y se BORRA al consumirla — mismo camino que ya usan los
121
+ * secretos, y así nunca pasa por `ps` ni por el historial de la shell.
122
+ */
123
+ async function handleProfileRequest (req) {
124
+ const ref = () => mgr.resolve(req.profile || mgr.currentId())
125
+ switch (req.op) {
126
+ case 'list': return {} // el volcado de perfiles ya se hace abajo
127
+ case 'add': { const p = await mgr.add(req.name); return { done: `perfil creado: ${p.name || p.id}` } }
128
+ case 'rm': { const r = await mgr.remove(req.profile); return { done: `perfil borrado: ${r.name || r.id}` } }
129
+ case 'rename': { const p = mgr.profiles.rename(ref(), req.name); return { done: `perfil renombrado: ${p.name}` } }
130
+ case 'use': { const p = mgr.profiles.setCurrent(ref()); return { done: `perfil activo: ${p.name || p.id}` } }
131
+ case 'unlock': { await mgr.profiles.unlock(ref(), req.password); return { done: 'perfil desbloqueado' } }
132
+ case 'lock': { mgr.profiles.lock(ref()); return { done: 'perfil bloqueado' } }
133
+ case 'password-set': { await mgr.profiles.setPassword(ref(), req.password); return { done: 'contraseña guardada' } }
134
+ case 'password-rm': { mgr.profiles.removePassword(ref()); return { done: 'contraseña quitada' } }
135
+ default: throw new Error('operación de perfil desconocida: ' + req.op)
136
+ }
137
+ }
138
+
139
+ process.on('SIGUSR2', async () => {
140
+ try {
141
+ const appr = readJsonSafe(approveReqFile)
142
+ if (appr?.code) {
143
+ try {
144
+ const vault = targetOf(appr)
145
+ const r = await vault.approveDevice(appr.code); rm(pendingEnrollFile); rm(pairFile); console.log('[vault] aprobado %s', r.deviceId)
146
+ } catch (e) { console.error('[vault] aprobación falló:', e.message) }
147
+ rm(approveReqFile)
148
+ }
149
+ const rej = readJsonSafe(rejectReqFile)
150
+ if (rej?.deviceId) {
151
+ try { targetOf(rej)?.rejectDevice(rej.deviceId); rm(pendingEnrollFile) } catch (_) {}
152
+ rm(rejectReqFile)
153
+ }
154
+ // Cambio de permisos de un miembro del acta (`dotrino-vault caps`).
155
+ const capsReq = readJsonSafe(path.join(dir, 'caps-request.json'))
156
+ if (capsReq?.pub && Array.isArray(capsReq.caps)) {
157
+ rm(path.join(dir, 'caps-request.json'))
158
+ try {
159
+ await targetOf(capsReq)?.setCaps(capsReq.pub, capsReq.caps)
160
+ console.log('[vault] permisos actualizados: %s', capsReq.caps.join(', ') || '(ninguno)')
161
+ } catch (e) { console.error('[vault] no se pudieron cambiar los permisos:', e.message) }
162
+ }
163
+ const req = readJsonSafe(revokeReqFile)
164
+ if (req?.nonce) {
165
+ try { await targetOf(req)?.revokeDevice(req.nonce); console.log('[vault] revocado nonce=%s', req.nonce) }
166
+ catch (e) { console.error('[vault] revocación falló:', e.message) }
167
+ rm(revokeReqFile)
168
+ }
169
+ // Secretos de servicios: `secret set/rm` del CLI. El archivo con el valor
170
+ // vive un instante en el mismo dir 0700 del vault y se borra al consumir.
171
+ const sec = readJsonSafe(secretReqFile)
172
+ if (sec?.op) {
173
+ rm(secretReqFile)
174
+ try {
175
+ const vault = targetOf(sec)
176
+ if (sec.op === 'set') { vault.setSecret(sec.ns, sec.key, sec.value); console.log('[vault] secreto guardado: %s/%s', sec.ns, sec.key) }
177
+ else if (sec.op === 'rm') { vault.deleteSecret(sec.ns, sec.key); console.log('[vault] secreto borrado: %s/%s', sec.ns, sec.key) }
178
+ } catch (e) { console.error('[vault] secreto falló:', e.message) }
179
+ }
180
+ // Perfiles / candado.
181
+ const preq = readJsonSafe(profileReqFile)
182
+ if (preq?.op) {
183
+ rm(profileReqFile) // lleva la contraseña: fuera del disco cuanto antes
184
+ let extra = {}
185
+ try { extra = await handleProfileRequest(preq) }
186
+ catch (e) { extra = { error: e.message }; console.error('[vault] perfil: %s', e.message) }
187
+ dumpProfiles(extra)
188
+ } else {
189
+ dumpProfiles()
190
+ }
191
+ // Volcados que lee la CLI (`devices`, `secret list`). A QUÉ perfil miran lo
192
+ // dice dump-request.json; sin él, al activo.
193
+ const dumpReq = readJsonSafe(dumpReqFile); rm(dumpReqFile)
194
+ const t = resolveTarget(dumpReq || appr || rej || req || sec || {}) || { id: mgr.currentId(), vault: mgr.current() }
195
+ // Nombres de secretos, nunca valores.
196
+ writeJson(secretsListFile, { v: 1, at: Date.now(), profile: t.id, ns: t.vault.listSecrets() })
197
+ writeJson(devFile, { v: 1, at: Date.now(), profile: t.id, ...(await t.vault.listDevices()) })
198
+ // Acta del perfil: quién es del perfil y qué puede hacer cada uno (`members`/`caps`).
199
+ try { writeJson(path.join(dir, 'acta.json'), { v: 1, at: Date.now(), profile: t.id, ...(await t.vault.profileMembers()) }) } catch (_) {}
200
+ } catch (e) {
201
+ console.error('[vault] error en señal de control:', e.message)
202
+ }
203
+ })
204
+
205
+ // --- apagado limpio ---
206
+ const shutdown = (sig) => {
207
+ console.log(`\n[vault] ${sig} → deteniendo…`)
208
+ rm(pairFile); rm(pendingEnrollFile)
209
+ try { mgr.close() } catch (_) {}
210
+ process.exit(0)
211
+ }
212
+ process.on('SIGTERM', () => shutdown('SIGTERM'))
213
+ process.on('SIGINT', () => shutdown('SIGINT'))
214
+
215
+ console.log('[vault] servicio listo.')
216
+ return mgr
217
+ }
package/src/manager.js ADDED
@@ -0,0 +1,88 @@
1
+ /**
2
+ * manager.js — corre TODOS los perfiles del vault a la vez.
3
+ *
4
+ * Cada perfil es un `startVault` independiente: su propia maestra, su propio
5
+ * directorio y su propia conexión al proxy (identificada con SU pubkey). Los N
6
+ * conviven en el mismo daemon: los dispositivos se direccionan por pubkey, así
7
+ * que el perfil «activo» NO limita el servicio —es solo el destino por defecto de
8
+ * la CLI cuando no pasas `--profile`—. Un dispositivo enrolado en el perfil de
9
+ * trabajo sigue funcionando mientras usas el personal, sin cambiar nada.
10
+ */
11
+ import { startVault } from './vault.js'
12
+ import { openProfiles } from './profiles.js'
13
+ import { installNodeGlobals } from './node-globals.js'
14
+ import { dataDir, ensureDir } from './paths.js'
15
+
16
+ export async function startVaultManager ({ root = dataDir(), proxyUrl, log = console.log, onEnrollChallenge } = {}) {
17
+ ensureDir(root)
18
+ // El keypair de transporte del proxy-client es del PROCESO, no de la identidad:
19
+ // se instala apuntando a la RAÍZ (no al dir de un perfil) para que los perfiles
20
+ // no se peleen por el archivo ni se lo lleven al borrarse.
21
+ installNodeGlobals(root)
22
+
23
+ const profiles = openProfiles(root)
24
+ const migrated = profiles.migrate()
25
+ if (migrated?.migrated) log('[vault] identidad mono-perfil migrada al perfil %s', migrated.id)
26
+
27
+ const running = new Map() // id -> instancia de startVault
28
+
29
+ async function open (id) {
30
+ const p = profiles.get(id)
31
+ const tag = p?.name ? `${p.name}` : id
32
+ const v = await startVault({
33
+ dir: profiles.dirOf(id),
34
+ proxyUrl,
35
+ log: (...a) => log(`[${tag}]`, ...a),
36
+ isLocked: () => profiles.isLocked(id),
37
+ onEnrollChallenge: (info) => onEnrollChallenge?.({ ...info, profile: id, profileName: p?.name || '' })
38
+ })
39
+ running.set(id, v)
40
+ return v
41
+ }
42
+
43
+ for (const p of profiles.list()) {
44
+ try { await open(p.id) } catch (e) { log('[vault] no se pudo abrir el perfil %s: %s', p.id, e.message) }
45
+ }
46
+
47
+ const get = (id) => {
48
+ const v = running.get(id)
49
+ if (!v) throw new Error('el perfil no está abierto: ' + id)
50
+ return v
51
+ }
52
+
53
+ /** Resumen para state.json / `profile ls`: identidad + candado de cada perfil. */
54
+ const summary = () => profiles.list().map((p) => {
55
+ const v = running.get(p.id)
56
+ return { ...p, fingerprint: v?.fingerprint || null, iss: v?.master || null }
57
+ })
58
+
59
+ return {
60
+ profiles,
61
+ running,
62
+ get,
63
+ list: () => profiles.list(),
64
+ summary,
65
+ current: () => get(profiles.current()),
66
+ currentId: () => profiles.current(),
67
+ resolve: (ref) => profiles.resolve(ref),
68
+
69
+ async add (name) {
70
+ const p = profiles.add(name)
71
+ await open(p.id)
72
+ log('[vault] perfil creado: %s (%s)', p.name || '(sin nombre)', p.id)
73
+ return profiles.get(p.id)
74
+ },
75
+
76
+ /** Borra el perfil: cierra su conexión y elimina su maestra y sus datos. */
77
+ async remove (ref) {
78
+ const id = profiles.resolve(ref)
79
+ const res = profiles.remove(id) // valida: no es el único, no está bloqueado
80
+ try { running.get(id)?.close() } catch (_) {}
81
+ running.delete(id)
82
+ log('[vault] perfil borrado: %s (%s)', res.name || '(sin nombre)', id)
83
+ return res
84
+ },
85
+
86
+ close () { for (const v of running.values()) { try { v.close() } catch (_) {} } running.clear() }
87
+ }
88
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Instala los globals del navegador que los paquetes del ecosistema esperan
3
+ * cuando corren headless en Node: `WebSocket` (paquete `ws`) y un `localStorage`
4
+ * respaldado en archivo (lo usa `@dotrino/proxy-client` para su keypair de
5
+ * transporte estable). Mismo patrón que `dotrino-bots/src/core/node-globals.js`
6
+ * — NO se reimplementa nada, solo se inyecta el entorno.
7
+ */
8
+ import fs from 'node:fs'
9
+ import path from 'node:path'
10
+ import WebSocket from 'ws'
11
+
12
+ /** localStorage síncrono respaldado por un archivo JSON (solo lo que usan los paquetes). */
13
+ export function fileLocalStorage (filePath) {
14
+ let data = {}
15
+ try { if (fs.existsSync(filePath)) data = JSON.parse(fs.readFileSync(filePath, 'utf8')) || {} } catch (_) {}
16
+ const flush = () => {
17
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
18
+ fs.writeFileSync(filePath, JSON.stringify(data), { mode: 0o600 })
19
+ }
20
+ return {
21
+ getItem: (k) => (k in data ? data[k] : null),
22
+ setItem: (k, v) => { data[k] = String(v); flush() },
23
+ removeItem: (k) => { delete data[k]; flush() },
24
+ clear: () => { data = {}; flush() },
25
+ key: (i) => Object.keys(data)[i] ?? null,
26
+ get length () { return Object.keys(data).length }
27
+ }
28
+ }
29
+
30
+ let _installed = false
31
+ export function installNodeGlobals (dir) {
32
+ if (_installed) return
33
+ if (typeof globalThis.WebSocket === 'undefined') globalThis.WebSocket = WebSocket
34
+ // Node ≥22 expone un `localStorage` no funcional sin flag → forzamos el shim.
35
+ globalThis.localStorage = fileLocalStorage(path.join(dir, 'transport.json'))
36
+ _installed = true
37
+ }
package/src/paths.js ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Resolución del directorio de datos y escritura de archivos con permisos
3
+ * restrictivos (0600). En v1 la clave maestra va en claro dentro de este dir
4
+ * (la guarda `@dotrino/identity` en `identity.json`); 0600 evita que sea
5
+ * world-readable. v2 añadirá cifrado en reposo con contraseña maestra.
6
+ */
7
+ import os from 'node:os'
8
+ import path from 'node:path'
9
+ import fs from 'node:fs'
10
+
11
+ /**
12
+ * Dir de datos del vault. Override con DOTRINO_VAULT_DIR.
13
+ *
14
+ * En Linux/macOS: `~/.local/share/dotrino/vault`. DEBE coincidir exactamente con la unit
15
+ * systemd (`%h/.local/share/...` + ReadWritePaths) y con install.sh (DATA_DIR),
16
+ * porque el CLI de control corre en la shell del usuario SIN el `Environment=` del
17
+ * servicio: si difieren, `dotrino-vault status/pair/devices` no encuentra al daemon.
18
+ *
19
+ * En Windows: `%LOCALAPPDATA%\Dotrino\vault`, que es donde Windows espera los datos de
20
+ * una app. El daemon y el CLI lo resuelven igual, así que el contrato por archivos entre
21
+ * los dos (ver `vaultControl.js`) sigue funcionando sin tocar nada.
22
+ */
23
+ export function dataDir () {
24
+ if (process.env.DOTRINO_VAULT_DIR) return process.env.DOTRINO_VAULT_DIR
25
+ if (process.platform === 'win32') {
26
+ const base = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local')
27
+ return path.join(base, 'Dotrino', 'vault')
28
+ }
29
+ return path.join(os.homedir(), '.local', 'share', 'dotrino', 'vault')
30
+ }
31
+
32
+ export function ensureDir (dir) {
33
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
34
+ }
35
+
36
+ export function readJson (file, fallback) {
37
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')) } catch (_) { return fallback }
38
+ }
39
+
40
+ /** Escritura atómica (tmp + rename) con modo 0600. */
41
+ export function writeJson (file, obj) {
42
+ ensureDir(path.dirname(file))
43
+ const tmp = file + '.tmp'
44
+ fs.writeFileSync(tmp, JSON.stringify(obj, null, 2), { mode: 0o600 })
45
+ fs.renameSync(tmp, file)
46
+ try { fs.chmodSync(file, 0o600) } catch (_) {}
47
+ }
@@ -0,0 +1,214 @@
1
+ /**
2
+ * profiles.js — registro MULTI-PERFIL del vault.
3
+ *
4
+ * Un mismo PC puede custodiar varias identidades del usuario (personal, trabajo…).
5
+ * Cada perfil es una maestra distinta y vive en su PROPIO subdirectorio, así que
6
+ * todo lo que ya era «por dir» (identity.json, peers, vault.json, threads.json,
7
+ * secrets.json, activity.log) queda naturalmente aislado entre perfiles: un
8
+ * dispositivo enrolado en un perfil no ve ni firma nada del otro.
9
+ *
10
+ * <root>/profiles.json este registro: [{ id, name, createdAt, pwd? }] + activo
11
+ * <root>/transport.json keypair del proxy-client (a nivel PROCESO, no por perfil)
12
+ * <root>/p/<id>/… los datos de cada perfil (incluida su maestra)
13
+ *
14
+ * CONTRASEÑA (opcional, por perfil): es un VERIFICADOR PBKDF2 —mismo modelo que el
15
+ * candado del navegador (`@dotrino/identity` vault/core.js)— NO cifra nada en reposo.
16
+ * Y solo bloquea EDITAR el perfil: el daemon sigue firmando y sirviendo a los
17
+ * dispositivos ya enrolados aunque el perfil esté bloqueado, para que un reinicio
18
+ * del PC no deje las apps muertas hasta que alguien teclee la contraseña.
19
+ * Protege contra que otro que se siente en la máquina —o un dispositivo enrolado—
20
+ * te reescriba el perfil; NO contra quien pueda leer el disco (para eso hace falta
21
+ * cifrado en reposo, ver `paths.js`).
22
+ */
23
+ import fs from 'node:fs'
24
+ import path from 'node:path'
25
+ import { dataDir, ensureDir, readJson, writeJson } from './paths.js'
26
+
27
+ const REGISTRY = 'profiles.json'
28
+ const PWD_ITER = 300000 // mismo coste que el candado del navegador
29
+ const MAX_NAME = 40
30
+
31
+ /** Archivos de un perfil que en la versión mono-perfil vivían sueltos en la raíz. */
32
+ const LEGACY_FILES = ['identity.json', 'peers.json', 'vault.json', 'threads.json', 'secrets.json', 'activity.log']
33
+
34
+ const b64 = (buf) => Buffer.from(new Uint8Array(buf)).toString('base64')
35
+
36
+ /** PBKDF2-SHA256 → verificador base64 (byte-idéntico al del navegador). */
37
+ async function derivePwd (password, saltB64, iter) {
38
+ const salt = Buffer.from(saltB64, 'base64')
39
+ const km = await crypto.subtle.importKey('raw', new TextEncoder().encode(String(password)), 'PBKDF2', false, ['deriveBits'])
40
+ const bits = await crypto.subtle.deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt, iterations: iter }, km, 256)
41
+ return b64(bits)
42
+ }
43
+
44
+ const newId = () => 'p' + crypto.randomUUID().slice(0, 8)
45
+ const cleanName = (name) => String(name || '').slice(0, MAX_NAME)
46
+
47
+ export function openProfiles (root = dataDir()) {
48
+ const file = path.join(root, REGISTRY)
49
+ let data = readJson(file, null)
50
+ if (!data || !Array.isArray(data.profiles)) data = { v: 1, current: null, profiles: [] }
51
+ const save = () => writeJson(file, data)
52
+
53
+ // Perfiles DESBLOQUEADOS en esta ejecución del daemon (en memoria: un reinicio
54
+ // vuelve a bloquear, igual que cerrar la pestaña en el navegador).
55
+ const unlocked = new Set()
56
+
57
+ const find = (id) => data.profiles.find((p) => p.id === id) || null
58
+ const dirOf = (id) => path.join(root, 'p', id)
59
+
60
+ const entry = (p) => ({
61
+ id: p.id,
62
+ name: p.name || '',
63
+ createdAt: p.createdAt || null,
64
+ protected: !!p.pwd,
65
+ locked: !!p.pwd && !unlocked.has(p.id),
66
+ current: p.id === data.current
67
+ })
68
+
69
+ function assertExists (id) {
70
+ const p = find(id)
71
+ if (!p) throw new Error('el perfil no existe: ' + id)
72
+ return p
73
+ }
74
+
75
+ const api = {
76
+ get root () { return root },
77
+ dirOf,
78
+ list: () => data.profiles.map(entry),
79
+ get: (id) => { const p = find(id); return p ? entry(p) : null },
80
+ current: () => data.current,
81
+
82
+ /**
83
+ * Resuelve una referencia de la CLI: id exacto, o nombre (sin distinguir
84
+ * mayúsculas). Un nombre ambiguo es un error explícito, no una elección al azar.
85
+ */
86
+ resolve (ref) {
87
+ if (!ref) return data.current
88
+ if (find(ref)) return ref
89
+ const needle = String(ref).trim().toLowerCase()
90
+ const hits = data.profiles.filter((p) => (p.name || '').toLowerCase() === needle)
91
+ if (hits.length === 1) return hits[0].id
92
+ if (hits.length > 1) throw new Error(`hay ${hits.length} perfiles llamados "${ref}"; usa su id (dotrino-vault profile ls)`)
93
+ throw new Error('el perfil no existe: ' + ref)
94
+ },
95
+
96
+ /**
97
+ * Migración desde la versión mono-perfil: los datos que vivían sueltos en la
98
+ * raíz pasan a ser el primer perfil (mismo criterio que la migración del
99
+ * navegador, que adopta la identidad vieja como «Perfil 1»). `transport.json`
100
+ * se queda en la raíz: es del proceso, no de la identidad.
101
+ */
102
+ migrate () {
103
+ if (data.profiles.length) return null
104
+ const legacy = fs.existsSync(path.join(root, 'identity.json'))
105
+ const id = newId()
106
+ const dir = dirOf(id)
107
+ ensureDir(dir)
108
+ if (legacy) {
109
+ for (const f of LEGACY_FILES) {
110
+ const from = path.join(root, f)
111
+ if (fs.existsSync(from)) { try { fs.renameSync(from, path.join(dir, f)) } catch (_) {} }
112
+ }
113
+ // peers namespaceados por el multi-perfil interno de @dotrino/identity
114
+ for (const f of fs.readdirSync(root)) {
115
+ if (/^peers\..+\.json$/.test(f)) { try { fs.renameSync(path.join(root, f), path.join(dir, f)) } catch (_) {} }
116
+ }
117
+ }
118
+ // «Perfil 1» tanto al migrar como en una instalación nueva: es un nombre que
119
+ // el dueño puede cambiar, y evita que la CLI salude con «(sin nombre)».
120
+ data.profiles.push({ id, name: 'Perfil 1', createdAt: Date.now() })
121
+ data.current = id
122
+ save()
123
+ return { id, migrated: legacy }
124
+ },
125
+
126
+ add (name) {
127
+ const id = newId()
128
+ ensureDir(dirOf(id))
129
+ data.profiles.push({ id, name: cleanName(name), createdAt: Date.now() })
130
+ if (!data.current) data.current = id
131
+ save()
132
+ return entry(find(id))
133
+ },
134
+
135
+ rename (id, name) {
136
+ const p = assertExists(id)
137
+ api.assertUnlocked(id)
138
+ p.name = cleanName(name)
139
+ save()
140
+ return entry(p)
141
+ },
142
+
143
+ setCurrent (id) { assertExists(id); data.current = id; save(); return entry(find(id)) },
144
+
145
+ /** Borra el perfil y TODOS sus datos (incluida su maestra). Irreversible. */
146
+ remove (id) {
147
+ const p = assertExists(id)
148
+ if (data.profiles.length <= 1) throw new Error('no se puede borrar el único perfil')
149
+ api.assertUnlocked(id)
150
+ data.profiles = data.profiles.filter((x) => x.id !== id)
151
+ if (data.current === id) data.current = data.profiles[0].id
152
+ save()
153
+ unlocked.delete(id)
154
+ try { fs.rmSync(dirOf(id), { recursive: true, force: true }) } catch (_) {}
155
+ return { id, name: p.name || '' }
156
+ },
157
+
158
+ // ----- candado -----
159
+
160
+ isProtected: (id) => !!find(id)?.pwd,
161
+ isLocked: (id) => { const p = find(id); return !!p?.pwd && !unlocked.has(id) },
162
+ assertUnlocked (id) {
163
+ if (api.isLocked(id)) throw new Error('perfil bloqueado: desbloquéalo con tu contraseña (dotrino-vault unlock)')
164
+ },
165
+
166
+ async unlock (id, password) {
167
+ const p = assertExists(id)
168
+ if (!p.pwd) { unlocked.add(id); return { ok: true, locked: false } }
169
+ // Freno de fuerza bruta (una contraseña corta se adivina probando): tras 5
170
+ // fallos, espera exponencial (2^n s, tope 5 min) persistida en el registro.
171
+ const tries = p.tries || { n: 0, at: 0 }
172
+ const waitMs = tries.n >= 5 ? Math.min(2 ** (tries.n - 4) * 1000, 5 * 60 * 1000) : 0
173
+ const left = tries.at + waitMs - Date.now()
174
+ if (left > 0) throw new Error(`demasiados intentos: espera ${Math.ceil(left / 1000)} s`)
175
+ const proof = await derivePwd(password, p.pwd.salt, p.pwd.iter)
176
+ if (proof !== p.pwd.verifier) {
177
+ p.tries = { n: tries.n + 1, at: Date.now() }
178
+ save()
179
+ throw new Error('contraseña incorrecta')
180
+ }
181
+ delete p.tries
182
+ save()
183
+ unlocked.add(id)
184
+ return { ok: true, locked: false }
185
+ },
186
+
187
+ lock (id) { assertExists(id); unlocked.delete(id); return { ok: true, locked: api.isLocked(id) } },
188
+
189
+ /** Pone o cambia la contraseña. Cambiarla exige haber desbloqueado antes. */
190
+ async setPassword (id, password) {
191
+ const p = assertExists(id)
192
+ api.assertUnlocked(id)
193
+ if (!password || String(password).length < 4) throw new Error('la contraseña debe tener al menos 4 caracteres')
194
+ const salt = b64(crypto.getRandomValues(new Uint8Array(16)))
195
+ p.pwd = { v: 1, salt, iter: PWD_ITER, verifier: await derivePwd(password, salt, PWD_ITER) }
196
+ delete p.tries
197
+ save()
198
+ unlocked.add(id)
199
+ return entry(p)
200
+ },
201
+
202
+ removePassword (id) {
203
+ const p = assertExists(id)
204
+ api.assertUnlocked(id)
205
+ delete p.pwd
206
+ delete p.tries
207
+ save()
208
+ unlocked.add(id)
209
+ return entry(p)
210
+ }
211
+ }
212
+
213
+ return api
214
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Protocolo dispositivo↔vault. La fuente ÚNICA vive en la lib publicable
3
+ * (`@dotrino/vault`, lib/src/protocol.js) para que daemon, dispositivos y
4
+ * SERVICIOS usen exactamente las mismas constantes. Este archivo solo re-exporta.
5
+ */
6
+ export * from '../lib/src/protocol.js'
package/src/qr.js ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * qr.js — render de un QR en la terminal con bloques Unicode.
3
+ *
4
+ * Usa el encoder QR `qrcode-generator` (MIT, Kazuhiko Arase) VENDORIZADO en
5
+ * `vendor/qrcode-generator.cjs`: JS puro, sin red ni telemetría, y se empaqueta
6
+ * dentro del binario SEA (regla de privacidad del ecosistema: nada de JS de
7
+ * terceros cargado en runtime, todo va embebido). No reimplementamos el QR a mano.
8
+ *
9
+ * Render con medio bloque por carácter: un QR es "alto", así que dibujamos dos
10
+ * filas de módulos por línea de texto con ▀▄█/espacio.
11
+ */
12
+ // Import estático del encoder vendorizado (UMD/CJS). Así esbuild lo INLINE-a en
13
+ // el binario SEA y en dev funciona por el interop ESM↔CJS de Node.
14
+ import qrcodeMod from '../vendor/qrcode-generator.cjs'
15
+
16
+ const qrcode = typeof qrcodeMod === 'function' ? qrcodeMod : qrcodeMod.default
17
+
18
+ /**
19
+ * Devuelve una string con el QR listo para imprimir en la terminal, **negro sobre
20
+ * blanco** y con zona de silencio blanca, INDEPENDIENTE del tema del terminal.
21
+ *
22
+ * Por qué: los escáneres esperan módulos oscuros sobre fondo claro. Si se dibuja
23
+ * con los colores del terminal (claro sobre oscuro en un terminal oscuro), el QR
24
+ * queda invertido y sin margen blanco → no escanea. Aquí forzamos el color con
25
+ * ANSI: medio bloque superior `▀` con color de TEXTO = módulo de arriba y color de
26
+ * FONDO = módulo de abajo (oscuro=negro, claro=blanco). Compacto (2 filas de
27
+ * módulos por línea) y siempre con el contraste correcto.
28
+ *
29
+ * @param {string} text Contenido a codificar (modo byte / UTF-8).
30
+ * @param {number} [quiet=4] Zona de silencio en módulos (mín. recomendado: 4).
31
+ */
32
+ export function qrToString (text, quiet = 4) {
33
+ // typeNumber 0 = autoseleccionar la versión mínima; EC level 'L' (más capacidad).
34
+ const qr = qrcode(0, 'L')
35
+ qr.addData(text) // 'Byte' por defecto para datos no numéricos/alfanuméricos
36
+ qr.make()
37
+
38
+ const n = qr.getModuleCount()
39
+ const N = n + quiet * 2
40
+ // Fuera de la cuadrícula = zona de silencio = claro (blanco).
41
+ const dark = (r, c) => {
42
+ const rr = r - quiet; const cc = c - quiet
43
+ if (rr < 0 || cc < 0 || rr >= n || cc >= n) return false
44
+ return qr.isDark(rr, cc)
45
+ }
46
+
47
+ const FG = (d) => (d ? 30 : 37) // texto: negro / blanco
48
+ const BG = (d) => (d ? 40 : 47) // fondo: negro / blanco
49
+ const RESET = '\x1b[0m'
50
+
51
+ let out = ''
52
+ for (let r = 0; r < N; r += 2) {
53
+ for (let c = 0; c < N; c++) {
54
+ const top = dark(r, c)
55
+ const bot = r + 1 < N ? dark(r + 1, c) : false
56
+ out += `\x1b[${FG(top)};${BG(bot)}m▀`
57
+ }
58
+ out += RESET + '\n'
59
+ }
60
+ return out
61
+ }