@dotrino/vaultd 0.129.1 → 0.130.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.129.1",
3
+ "version": "0.130.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": {
package/src/ctl.js CHANGED
@@ -1715,9 +1715,10 @@ async function cmdLogins (rest) {
1715
1715
  /**
1716
1716
  * `dotrino-vault update` — TRAER LA VERSIÓN NUEVA, cuando lo escribe una persona.
1717
1717
  *
1718
- * La mitad automática es MIRAR, y la hace el daemon una vez al día (`src/update.js`
1719
- * explica por qué la otra mitad no se automatiza: sería un canal para meter código en la
1720
- * máquina que guarda la maestra sin que nadie diga que sí).
1718
+ * Con la INSTALACIÓN DE USUARIO (`~/.local/share/dotrino/bin`) el daemon ya se actualiza
1719
+ * solo: pide permiso al teléfono si hay aprobadores y, si no los hay, no hace falta
1720
+ * (dueño, 2026-09-24). Esto es lo mismo a mano, sin esperar al aviso del día. Con el
1721
+ * `.deb` del sistema el binario es de root y aquí solo se verifica y se dice el `sudo`.
1721
1722
  *
1722
1723
  * Lo que se baja SE VERIFICA contra la atestación de sigstore antes de tocar el disco, y
1723
1724
  * si no se puede verificar NO SE INSTALA. Que la URL sea la correcta no prueba nada.
@@ -1726,7 +1727,7 @@ async function cmdLogins (rest) {
1726
1727
  * va para que systemd lo levante con el nuevo (`src/selfupdate.js`).
1727
1728
  */
1728
1729
  async function cmdUpdate (args = []) {
1729
- const { latestRelease, assetFor, download, verifyArtifact, isNewer } = await import('./update.js')
1730
+ const { latestRelease, assetFor, download, verifyArtifact, isNewer, isUserInstall, installUserRelease } = await import('./update.js')
1730
1731
  const soloMirar = args.includes('--check')
1731
1732
 
1732
1733
  process.stdout.write('Mirando qué hay publicado… ')
@@ -1745,6 +1746,21 @@ async function cmdUpdate (args = []) {
1745
1746
  console.log('%sHay %s y esta es la %s.%s', B, r.version, VERSION, Z)
1746
1747
  if (soloMirar) { console.log('Tráela con: dotrino-vault update'); return }
1747
1748
 
1749
+ // Instalación de usuario: se verifica y se instala aquí mismo, sin sudo.
1750
+ if (isUserInstall()) {
1751
+ process.stdout.write('Bajando y comprobando la firma… ')
1752
+ const res = await installUserRelease(r)
1753
+ if (!res.ok) {
1754
+ console.log('')
1755
+ console.error('%sNO se instala nada.%s %s', R, Z, res.reason)
1756
+ if (res.file) console.error('El archivo quedó en %s, sin tocar nada.', res.file)
1757
+ process.exit(1)
1758
+ }
1759
+ console.log('firmada por el release de %s ✓', 'imdotrino/dotrino-vault')
1760
+ console.log('Instalada la %s. El servicio se reinicia solo; compruébalo con: dotrino-vault status', res.version)
1761
+ return
1762
+ }
1763
+
1748
1764
  const pick = assetFor(r.assets)
1749
1765
  if (!pick.ok) { console.error('%s', pick.reason); process.exit(1) }
1750
1766
 
package/src/daemon.js CHANGED
@@ -23,7 +23,7 @@ import { dataDir } from './paths.js'
23
23
  import { ipcRead, ipcWrite, migrateIpcDir } from './ipc.js'
24
24
  import { parseInvite } from '../lib/src/invite.js'
25
25
  import { watchBinary } from './selfupdate.js'
26
- import { latestRelease, isNewer, CHECK_EVERY_MS } from './update.js'
26
+ import { latestRelease, isNewer, CHECK_EVERY_MS, isUserInstall, installUserRelease } from './update.js'
27
27
  import { VERSION } from './version.js'
28
28
  import crypto from 'node:crypto'
29
29
 
@@ -1025,11 +1025,37 @@ export async function runDaemon () {
1025
1025
  */
1026
1026
  let quienAprueba = []
1027
1027
  try { quienAprueba = (await mgr.current()?.approvers?.()) || [] } catch (_) {}
1028
- const comoLoDice = quienAprueba.length
1029
- ? `waiting for one of ${quienAprueba.length} approver(s) to say yes`
1030
- : 'no approver in the record, so this vault may update itself'
1031
- console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · ${comoLoDice} · dotrino-vault update`)
1028
+
1029
+ // INSTALADA COMO PAQUETE DEL SISTEMA (.deb en /usr/bin): el binario es de root y este
1030
+ // proceso no puede tocarlo. Se dice, con las dos salidas, y no se intenta nada.
1031
+ if (!isUserInstall()) {
1032
+ console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · installed as a system package, so it cannot update itself: \`dotrino-vault update\` (needs sudo), or move to a user install with the tarball's install.sh`)
1033
+ return
1034
+ }
1035
+ if (actualizando) return
1036
+ actualizando = true
1037
+ try {
1038
+ // PIDE PERMISO SOLO SI HAY A QUIÉN PEDÍRSELO (dueño, 2026-09-20); sin aprobadores
1039
+ // no se necesita aprobación (dueño, 2026-09-24).
1040
+ if (quienAprueba.length) {
1041
+ console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · asking one of ${quienAprueba.length} approver(s)`)
1042
+ const si = await mgr.current().askUpdateApproval({ version: r.version })
1043
+ if (!si) { console.log(`[vault] update to ${r.version}: not approved (denied or expired) · it will ask again`); return }
1044
+ } else {
1045
+ console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · no approver in the record: updating on its own`)
1046
+ }
1047
+ const res = await installUserRelease(r, { log: (m) => console.log(m) })
1048
+ if (!res.ok) {
1049
+ // Lo que NO cuadra no se instala, y se dice por qué: el archivo queda a mano.
1050
+ console.log(`[vault] update to ${r.version} NOT installed: ${res.reason}${res.file ? ` (file left at ${res.file})` : ''}`)
1051
+ return
1052
+ }
1053
+ console.log(`[vault] update: ${r.version} installed · the service restarts on its own`)
1054
+ } catch (e) {
1055
+ console.log(`[vault] update to ${r.version} failed: ${e.message}`)
1056
+ } finally { actualizando = false }
1032
1057
  }
1058
+ let actualizando = false
1033
1059
  mirarRelease()
1034
1060
  const relojRelease = setInterval(mirarRelease, CHECK_EVERY_MS)
1035
1061
  relojRelease.unref?.()
package/src/update.js CHANGED
@@ -10,8 +10,11 @@
10
10
  * mismo son dos sitios donde arreglar el mismo fallo, y el segundo siempre se olvida.
11
11
  */
12
12
  import fs from 'node:fs'
13
+ import os from 'node:os'
14
+ import path from 'node:path'
15
+ import { execFileSync } from 'node:child_process'
13
16
  import { latestVersion, isNewer, CHECK_EVERY_MS } from '@dotrino/update'
14
- import { pickAsset, download, verifyArtifact as verify } from '@dotrino/update/fetch'
17
+ import { pickAsset, download, verifyArtifact as verify, fetchVerified } from '@dotrino/update/fetch'
15
18
  import { VERSION } from './version.js'
16
19
 
17
20
  export const REPO = 'imdotrino/dotrino-vault'
@@ -37,3 +40,56 @@ export function assetFor (assets, { platform = process.platform, arch = process.
37
40
 
38
41
  /** Verifica contra la atestación del release del vault. Sin `gh`, no se instala. */
39
42
  export const verifyArtifact = (file, opts = {}) => verify(file, { repo: REPO, ...opts })
43
+
44
+
45
+ /**
46
+ * INSTALACIÓN DE USUARIO: los binarios viven en `~/.local/share/dotrino/bin`, que es del
47
+ * usuario, y la bóveda se actualiza sin `sudo` (dueño, 2026-09-24).
48
+ *
49
+ * Es esa carpeta y no `~/.local/bin` por una razón concreta: la unidad de systemd lleva
50
+ * `ProtectHome=read-only` y solo deja escribir en `~/.local/share/dotrino`. Desde ahí el
51
+ * daemon puede reemplazar su propio binario; en `~/.local/bin` no podría.
52
+ *
53
+ * El precio, dicho: un proceso de ese mismo usuario también podría reemplazarlo, cosa que
54
+ * con el `.deb` en `/usr/bin` (de root) no. En el modelo de la bóveda la frontera ya es el
55
+ * usuario (memoria `dotrino-aprobacion-comando`), y el binario nuevo solo entra verificado.
56
+ */
57
+ export function userBinDir (home = os.homedir()) {
58
+ return path.join(home, '.local', 'share', 'dotrino', 'bin')
59
+ }
60
+
61
+ /** ¿Corre este proceso desde la instalación de usuario? Solo entonces puede actualizarse solo. */
62
+ export function isUserInstall (execPath = process.execPath, home = os.homedir()) {
63
+ return path.dirname(execPath) === userBinDir(home)
64
+ }
65
+
66
+ /**
67
+ * Baja el tarball del release, lo VERIFICA contra su atestación y cambia los dos binarios
68
+ * de forma atómica (se escribe al lado y se renombra encima: nunca queda uno a medias).
69
+ * `selfupdate.js` ve el binario nuevo y el servicio se reinicia solo.
70
+ *
71
+ * Si la verificación falla no se toca nada, y se devuelve por qué.
72
+ *
73
+ * @returns {Promise<{ok:true, version:string} | {ok:false, code?:string, reason:string, file?:string}>}
74
+ */
75
+ export async function installUserRelease (release, { binDir = userBinDir(), log = () => {}, fetchVerifiedImpl = fetchVerified } = {}) {
76
+ const pick = pickAsset(release.assets, [{ kind: 'tar', re: /-linux-x64\.tar\.gz$/ }])
77
+ if (!pick.ok) return pick
78
+ const got = await fetchVerifiedImpl(pick.asset, { repo: REPO, product: 'dotrino-vault', version: VERSION })
79
+ if (!got.ok) return got
80
+ log(`[vault] update: ${pick.asset.name} verified against its attestation`)
81
+ const work = path.dirname(got.file)
82
+ execFileSync('tar', ['xzf', got.file, '-C', work])
83
+ const src = fs.readdirSync(work).map((d) => path.join(work, d)).find((d) => fs.existsSync(path.join(d, 'dotrino-vaultd')))
84
+ if (!src) return { ok: false, code: 'BAD_TARBALL', reason: 'the tarball has no dotrino-vaultd inside' }
85
+ fs.mkdirSync(binDir, { recursive: true })
86
+ // El CLI primero y el daemon al final: cambiar el daemon es lo que dispara el reinicio.
87
+ for (const name of ['dotrino-vault', 'dotrino-vaultd']) {
88
+ const tmp = path.join(binDir, `.${name}.new`)
89
+ fs.copyFileSync(path.join(src, name), tmp)
90
+ fs.chmodSync(tmp, 0o755)
91
+ fs.renameSync(tmp, path.join(binDir, name))
92
+ }
93
+ try { fs.rmSync(work, { recursive: true, force: true }) } catch (_) {}
94
+ return { ok: true, version: release.version }
95
+ }
package/src/vault.js CHANGED
@@ -37,7 +37,7 @@ import { loadManifest, brokenOf } from '@dotrino/roadmap'
37
37
  // espera reventaba con un ReferenceError y la aprobación del mostrador de contraseñas no
38
38
  // llegaba a existir. Solo se veía por ese camino —el único que lo usa—, y no había prueba
39
39
  // que lo recorriera hasta que la hubo (dotrino-test, smoke:demonio, 2026-08-30).
40
- import { createApprovals, createGrants, lastApproverGone, PENDING_TTL_MS, GRANT_TTL_MS } from './approvals.js'
40
+ import { createApprovals, createGrants, lastApproverGone, PENDING_TTL_MS, GRANT_TTL_MS, UPDATE_TTL_MS } from './approvals.js'
41
41
  import { readProcContext, sanitizeContext, commandFingerprint, commandLine } from '../lib/src/proc.js'
42
42
  import { makeContentKey, encryptWithCek, wrapForMember } from '@dotrino/identity/content'
43
43
  import { makeSealer } from './sealer.js'
@@ -167,11 +167,11 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
167
167
  * variables (la escritura se aplica al aprobar). Si nadie contesta, vence solo con un no:
168
168
  * nada se queda colgado para siempre.
169
169
  */
170
- async function awaitApproval ({ device, ns, kind, what, op = null, ctx = null, onAnswer }) {
170
+ async function awaitApproval ({ device, ns, kind, what, op = null, ctx = null, onAnswer, ttlMs = PENDING_TTL_MS }) {
171
171
  const deviceId = await deviceIdOf(device).catch(() => null)
172
172
  const record = await refreshActa()
173
173
  const label = (record?.members || []).find((m) => m.pub === device)?.label || ''
174
- const pend = approvals.request({ ns, device, deviceId, label, ek: '', ctx, kind })
174
+ const pend = approvals.request({ ns, device, deviceId, label, ek: '', ctx, kind, ttlMs })
175
175
  audit(`${what}.pending`, { device: deviceId, ns, id: pend.id, op })
176
176
  log(`[vault] ${what}: ${deviceId || '????-????'} is waiting for approval (${pend.id})`)
177
177
  waiters.set(pend.id, onAnswer)
@@ -180,7 +180,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
180
180
  audit(`${what}.expired`, { device: deviceId, ns, id: pend.id })
181
181
  onAnswer(false)
182
182
  }
183
- }, PENDING_TTL_MS)
183
+ }, ttlMs)
184
184
  t.unref?.()
185
185
  await notifyApprovers(pend, record)
186
186
  return pend
@@ -1621,7 +1621,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1621
1621
  const resolver = waiters.get(id)
1622
1622
  waiters.delete(id)
1623
1623
  const ok = op === 'approve'
1624
- const what = pend.kind === 'write' ? 'vars' : 'passwords'
1624
+ const what = pend.kind === 'write' ? 'vars' : pend.kind === 'update' ? 'update' : 'passwords'
1625
1625
  audit(ok ? `${what}.approved` : `${what}.denied`, { device: pend.deviceId, ns: pend.ns, id, by })
1626
1626
  log(`[vault] ${what}: request ${pend.id} of ${pend.deviceId} ${ok ? 'approved' : 'DENIED'} by ${by}`)
1627
1627
  // Lo aprobado SE HACE AQUÍ (una escritura se aplica ahora), y si falla quien aprobó
@@ -3655,6 +3655,21 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
3655
3655
  * aprobadores que se quedaría sin poder actualizarse nunca, esperando un sí que nadie
3656
3656
  * puede dar. Mismo sitio del que ya sale el «rang N approver(s)» de los secretos.
3657
3657
  */
3658
+ /**
3659
+ * PEDIR PERMISO PARA ACTUALIZARSE a `version` (la instala el daemon si se aprueba). Es
3660
+ * un pedido como los demás —sale en Pedidos del teléfono, `kind: 'update'`—, pero dura
3661
+ * un día (`UPDATE_TTL_MS`): no hay nadie esperando delante de una terminal.
3662
+ * @returns {Promise<boolean>} sí o no; vencido es no.
3663
+ */
3664
+ askUpdateApproval: async ({ version }) => {
3665
+ const yo = identity.me?.publickey || null
3666
+ return new Promise((resolve) => {
3667
+ awaitApproval({
3668
+ device: yo, ns: 'vault', kind: 'update', what: 'update',
3669
+ ctx: { version, from: VERSION }, ttlMs: UPDATE_TTL_MS, onAnswer: resolve
3670
+ }).catch((e) => { log(`[vault] update: could not ask for approval: ${e.message}`); resolve(false) })
3671
+ })
3672
+ },
3658
3673
  approvers: async () => {
3659
3674
  const record = await refreshActa()
3660
3675
  if (!record) return []