@dotrino/vault 0.31.0 → 0.33.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 CHANGED
@@ -279,3 +279,11 @@ cert de una máquina vigente: sin esto toda máquina enrolada caducaba a los 30
279
279
  | bitácora, cifrado en reposo, candado, multi-perfil | son del daemon; en el navegador dependen de `@dotrino/identity` |
280
280
 
281
281
  MIT · parte de [Dotrino](https://dotrino.com).
282
+
283
+ ## Agente SSH con llaves en memoria (`dotrino-env ssh-agent`)
284
+
285
+ La llave SSH es un secreto más del cajón (`SSH_KEY_*`, el archivo en base64). Al arrancar,
286
+ el agente pide el cajón a la bóveda (con la aprobación del teléfono si el aparato la pide),
287
+ carga las llaves en memoria y sirve el protocolo de `ssh-agent`; en el disco no queda nada.
288
+ `dotrino-env ssh-agent --ns ssh` imprime el `SSH_AUTH_SOCK`. Como librería:
289
+ `loadPrivateKey`, `publicLine`, `signSsh` (`src/sshKeys.js`) y `startSshAgent` (`src/sshAgent.js`).
@@ -38,6 +38,9 @@ function help () {
38
38
  status servicios enrolados en esta máquina
39
39
  check [--ns <ns>] pide los secretos al vault y lista sus NOMBRES (nunca los valores)
40
40
  run [--ns <ns>] -- <cmd> [args…]
41
+ ssh-agent [--ns <ns>] [--socket <ruta>]
42
+ agente SSH con las llaves (SSH_KEY_* del cajón) solo en memoria;
43
+ pide el cajón al arrancar. Pon SSH_AUTH_SOCK en la ruta que imprime.
41
44
  ejecuta <cmd> con los secretos inyectados en su entorno
42
45
 
43
46
  En tu código:
@@ -140,19 +143,57 @@ async function cmdRun () {
140
143
  const sep = argv.indexOf('--')
141
144
  const cmd = sep >= 0 ? argv.slice(sep + 1) : []
142
145
  if (!cmd.length) { console.error('uso: dotrino-env run [--ns <ns>] -- <cmd> [args…]'); process.exit(2) }
143
- const { injected, overridden } = await loadEnv({ ns: flag('ns') })
146
+ const onPending = () => console.error('[dotrino-env] waiting for approval on your phone…')
147
+ const { injected, overridden } = await loadEnv({ ns: flag('ns'), onPending })
144
148
  console.error('[dotrino-env] %d valor(es) en el entorno de %s%s', injected.length, cmd[0],
145
149
  overridden.length ? ` (pisados: ${overridden.join(', ')})` : '')
146
150
  const child = spawn(cmd[0], cmd.slice(1), { stdio: 'inherit', env: process.env })
147
151
  child.on('exit', (code, signal) => process.exit(signal ? 1 : (code ?? 0)))
148
152
  }
149
153
 
154
+ /**
155
+ * Agente SSH con las llaves EN MEMORIA. Al arrancar pide su cajón a la bóveda (si este
156
+ * aparato está marcado con `approval`, el teléfono tiene que decir que sí), carga las
157
+ * variables `SSH_KEY_*` (el archivo de la llave, en base64) y sirve el protocolo de
158
+ * `ssh-agent`. En el disco no queda nada; cerrar el agente es olvidar las llaves.
159
+ *
160
+ * dotrino-vault secret set ssh SSH_KEY_DOTRINO "$(base64 -w0 ~/.ssh/id_ed25519)"
161
+ * dotrino-env ssh-agent --ns ssh # imprime: export SSH_AUTH_SOCK=…
162
+ */
163
+ async function cmdSshAgent () {
164
+ const { startSshAgent, defaultSocketPath } = await import('../src/sshAgent.js')
165
+ const { loadPrivateKey, publicLine } = await import('../src/sshKeys.js')
166
+ const { serviceDir, resolveNs } = await import('../src/env.js')
167
+ const { waitForSecrets } = await import('../src/service.js')
168
+ const ns = resolveNs(flag('ns') || undefined)
169
+ const dir = flag('dir') || serviceDir(ns)
170
+ const socketPath = flag('socket') || defaultSocketPath(dir)
171
+ console.error('[dotrino-env] asking the vault for the keys of «%s»…', ns)
172
+ const secrets = await waitForSecrets({ dir, ns, onPending: () => console.error('[dotrino-env] waiting for approval on your phone…') })
173
+ const keys = []
174
+ for (const [name, value] of Object.entries(secrets)) {
175
+ if (!/^SSH_KEY_[A-Z0-9_]*$/.test(name)) continue
176
+ try {
177
+ const text = /^-----BEGIN/.test(value) ? value : Buffer.from(value, 'base64').toString('utf8')
178
+ keys.push(loadPrivateKey(text, name.toLowerCase().replace(/_/g, '-')))
179
+ } catch (e) { console.error('[dotrino-env] %s skipped: %s', name, e.message) }
180
+ }
181
+ if (!keys.length) { console.error('[dotrino-env] no SSH_KEY_* variables in «%s»: nothing to serve', ns); process.exit(1) }
182
+ for (const k of keys) console.error('[dotrino-env] key %s %s', k.id, k.comment)
183
+ const agent = startSshAgent({ socketPath, keys: () => keys, log: console.error })
184
+ console.log('export SSH_AUTH_SOCK=%s', agent.socketPath)
185
+ const stop = () => { agent.close(); process.exit(0) }
186
+ process.on('SIGINT', stop); process.on('SIGTERM', stop)
187
+ await new Promise(() => {})
188
+ }
189
+
150
190
  const run = async () => {
151
191
  switch (argv[0]) {
152
192
  case 'enroll': return cmdEnroll()
153
193
  case 'status': return cmdStatus()
154
194
  case 'check': return cmdCheck()
155
195
  case 'run': return cmdRun()
196
+ case 'ssh-agent': return cmdSshAgent()
156
197
  case undefined:
157
198
  case 'help':
158
199
  case '--help':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vault",
3
- "version": "0.31.0",
3
+ "version": "0.33.0",
4
4
  "description": "Usa ESTE dispositivo (navegador) como bóveda/CA del ecosistema Dotrino: atiende enrolamientos por el proxy y firma certificados de delegación a tus máquinas. Incluye el cliente de SERVICIO (Node): un proyecto se enrola una vez y jala sus credenciales del vault en vez del .env (`import '@dotrino/vault/config'`).",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -0,0 +1,94 @@
1
+ /**
2
+ * AGENTE SSH: un socket Unix con el protocolo de `ssh-agent` (draft-miller-ssh-agent)
3
+ * cuyas llaves viven SOLO EN MEMORIA — se las dio la bóveda al arrancar (`dotrino-env
4
+ * ssh-agent`, con la aprobación del teléfono si el aparato la pide). En el disco no hay
5
+ * nada. No acepta que le añadan llaves (`ssh-add` de un archivo se rechaza: la idea es
6
+ * justo que no haya archivos) ni hace de proxy de nada más.
7
+ *
8
+ * export SSH_AUTH_SOCK=… # lo imprime dotrino-env ssh-agent
9
+ * ssh-add -L # las llaves del cajón
10
+ * ssh mi-servidor # firma en local, con la llave en memoria
11
+ */
12
+ import fs from 'node:fs'
13
+ import net from 'node:net'
14
+ import path from 'node:path'
15
+ import { sshString, readStrings, signSsh } from './sshKeys.js'
16
+
17
+ const AGENT_FAILURE = 5
18
+ const AGENT_SUCCESS = 6
19
+ const REQUEST_IDENTITIES = 11
20
+ const IDENTITIES_ANSWER = 12
21
+ const SIGN_REQUEST = 13
22
+ const SIGN_RESPONSE = 14
23
+
24
+ /** Dónde va el socket: en el directorio de ejecución del usuario si lo hay (se limpia solo). */
25
+ export function defaultSocketPath (dir) {
26
+ const run = process.env.XDG_RUNTIME_DIR
27
+ return run ? path.join(run, 'dotrino-vault', 'ssh-agent.sock') : path.join(dir, 'ssh-agent.sock')
28
+ }
29
+
30
+ const frame = (type, payload = Buffer.alloc(0)) => {
31
+ const body = Buffer.concat([Buffer.from([type]), payload])
32
+ const len = Buffer.alloc(4); len.writeUInt32BE(body.length)
33
+ return Buffer.concat([len, body])
34
+ }
35
+
36
+ /**
37
+ * @param {{ socketPath: string, keys: () => any[], log?: Function }} opts
38
+ * `keys()`: las llaves cargadas (`loadPrivateKey` de sshKeys.js), con su privada en memoria.
39
+ */
40
+ export function startSshAgent ({ socketPath, keys, log = () => {} }) {
41
+ fs.mkdirSync(path.dirname(socketPath), { recursive: true, mode: 0o700 })
42
+ try { fs.unlinkSync(socketPath) } catch (_) {}
43
+
44
+ async function handle (type, payload) {
45
+ const list = keys() || []
46
+ if (type === REQUEST_IDENTITIES) {
47
+ const n = Buffer.alloc(4); n.writeUInt32BE(list.length)
48
+ const parts = list.map((k) => Buffer.concat([sshString(k.blob), sshString(Buffer.from(k.comment || ''))]))
49
+ return frame(IDENTITIES_ANSWER, Buffer.concat([n, ...parts]))
50
+ }
51
+ if (type === SIGN_REQUEST) {
52
+ const [blob, data] = readStrings(payload, 2)
53
+ const flags = payload.length >= 4 ? payload.readUInt32BE(payload.length - 4) : 0
54
+ const key = list.find((k) => k.blob.equals(blob))
55
+ if (!key) return frame(AGENT_FAILURE)
56
+ try { return frame(SIGN_RESPONSE, sshString(signSsh(key, data, flags))) } catch (e) {
57
+ log('[ssh-agent] not signed: ' + e.message)
58
+ return frame(AGENT_FAILURE)
59
+ }
60
+ }
61
+ // Añadir/quitar llaves, candado, extensiones: nada de eso vive aquí.
62
+ return frame(AGENT_FAILURE)
63
+ }
64
+
65
+ const server = net.createServer((sock) => {
66
+ let buf = Buffer.alloc(0)
67
+ let busy = Promise.resolve()
68
+ sock.on('data', (chunk) => {
69
+ buf = Buffer.concat([buf, chunk])
70
+ while (buf.length >= 4) {
71
+ const len = buf.readUInt32BE(0)
72
+ if (len < 1 || len > 256 * 1024) { sock.destroy(); return }
73
+ if (buf.length < 4 + len) break
74
+ const type = buf[4]; const payload = buf.subarray(5, 4 + len)
75
+ buf = buf.subarray(4 + len)
76
+ // En orden: el protocolo es petición-respuesta, y un cliente que encadena dos
77
+ // firmas espera las respuestas en el mismo orden.
78
+ busy = busy.then(() => handle(type, payload)).then((out) => { if (!sock.destroyed) sock.write(out) }).catch(() => { if (!sock.destroyed) sock.write(frame(AGENT_FAILURE)) })
79
+ }
80
+ })
81
+ sock.on('error', () => {})
82
+ })
83
+ server.on('error', (e) => log('[ssh-agent] ' + e.message))
84
+ server.listen(socketPath, () => {
85
+ try { fs.chmodSync(socketPath, 0o600) } catch (_) {}
86
+ log(`[ssh-agent] listening at ${socketPath}`)
87
+ })
88
+ return {
89
+ socketPath,
90
+ close () { try { server.close() } catch (_) {} try { fs.unlinkSync(socketPath) } catch (_) {} }
91
+ }
92
+ }
93
+
94
+ export default { startSshAgent, defaultSocketPath, AGENT_SUCCESS }
package/src/sshKeys.js ADDED
@@ -0,0 +1,118 @@
1
+ /**
2
+ * LLAVES SSH como secretos de la bóveda. La llave privada vive sellada en un cajón
3
+ * (variables `SSH_KEY_*`, valor = el archivo de la llave en base64) y solo existe en claro
4
+ * en la memoria del agente que la pidió (`dotrino-env ssh-agent`). Aquí: leerla, sacar su
5
+ * pública en el formato de `authorized_keys` y firmar como manda SSH (RFC 4253/8332/5656).
6
+ *
7
+ * · ed25519: formato OpenSSH («-----BEGIN OPENSSH PRIVATE KEY-----»), parseado a mano
8
+ * (Node no lo lee) — solo sin frase.
9
+ * · RSA / ECDSA P-256: PEM (PKCS#8 o tradicional), vía `node:crypto`.
10
+ *
11
+ * Puro: sin disco ni red.
12
+ */
13
+ import { createPrivateKey, createPublicKey, sign as nodeSign, createHash } from 'node:crypto'
14
+
15
+ const u32 = (n) => { const b = Buffer.alloc(4); b.writeUInt32BE(n >>> 0); return b }
16
+ export const sshString = (buf) => Buffer.concat([u32(buf.length), Buffer.from(buf)])
17
+ export function sshMpint (bytes) {
18
+ let b = Buffer.from(bytes)
19
+ while (b.length > 1 && b[0] === 0) b = b.subarray(1)
20
+ if (b[0] & 0x80) b = Buffer.concat([Buffer.from([0]), b])
21
+ return sshString(b)
22
+ }
23
+ export function readStrings (buf, max = 16) {
24
+ const out = []; let o = 0
25
+ while (o + 4 <= buf.length && out.length < max) {
26
+ const n = buf.readUInt32BE(o); o += 4
27
+ if (o + n > buf.length) throw new Error('ssh: truncated string')
28
+ out.push(buf.subarray(o, o + n)); o += n
29
+ }
30
+ return out
31
+ }
32
+ export const fingerprint = (blob) => 'SHA256:' + createHash('sha256').update(Buffer.from(blob)).digest('base64').replace(/=+$/, '')
33
+
34
+ const OPENSSH_MAGIC = 'openssh-key-v1\0'
35
+
36
+ /** Llave ed25519 en formato OpenSSH sin frase → { type, privateKey (KeyObject), blob }. */
37
+ function parseOpenSsh (text) {
38
+ const b64 = text.replace(/-----(BEGIN|END) OPENSSH PRIVATE KEY-----/g, '').replace(/\s+/g, '')
39
+ const buf = Buffer.from(b64, 'base64')
40
+ if (buf.subarray(0, OPENSSH_MAGIC.length).toString('latin1') !== OPENSSH_MAGIC) throw new Error('ssh: not an OpenSSH private key')
41
+ // string cipher · string kdf · string kdfoptions · uint32 nkeys · string pub · string priv
42
+ let body = buf.subarray(OPENSSH_MAGIC.length)
43
+ const [cipher, kdf, kdfopts] = readStrings(body, 3)
44
+ body = body.subarray(12 + cipher.length + kdf.length + kdfopts.length)
45
+ if (cipher.toString() !== 'none' || kdf.toString() !== 'none') throw new Error('ssh: passphrase-protected keys are not supported (store the key without one; the vault is the lock)')
46
+ const [pubBlob, priv] = readStrings(body.subarray(4), 2)
47
+ const [type] = readStrings(pubBlob, 1)
48
+ if (type.toString() !== 'ssh-ed25519') {
49
+ // Otros tipos en formato OpenSSH: conviértelos a PEM (`ssh-keygen -p -m PEM`).
50
+ throw new Error(`ssh: ${type.toString()} in OpenSSH format is not supported; convert it with ssh-keygen -p -m PEM`)
51
+ }
52
+ // priv: uint32 check ×2, string type, string pub(32), string priv(64 = seed‖pub), string comment
53
+ const [t2, , sk] = readStrings(priv.subarray(8), 3)
54
+ if (t2.toString() !== 'ssh-ed25519' || sk.length !== 64) throw new Error('ssh: malformed ed25519 key')
55
+ const seed = sk.subarray(0, 32)
56
+ // PKCS#8 de ed25519: prefijo fijo + semilla de 32 bytes.
57
+ const pkcs8 = Buffer.concat([Buffer.from('302e020100300506032b657004220420', 'hex'), seed])
58
+ const privateKey = createPrivateKey({ key: pkcs8, format: 'der', type: 'pkcs8' })
59
+ return { type: 'ssh-ed25519', privateKey, blob: Buffer.from(pubBlob) }
60
+ }
61
+
62
+ /** Blob público SSH de una KeyObject RSA / P-256 / ed25519. */
63
+ export function publicBlob (privateKey) {
64
+ const pub = createPublicKey(privateKey)
65
+ const jwk = pub.export({ format: 'jwk' })
66
+ if (jwk.kty === 'RSA') {
67
+ return Buffer.concat([sshString(Buffer.from('ssh-rsa')), sshMpint(Buffer.from(jwk.e, 'base64url')), sshMpint(Buffer.from(jwk.n, 'base64url'))])
68
+ }
69
+ if (jwk.kty === 'EC' && jwk.crv === 'P-256') {
70
+ const point = Buffer.concat([Buffer.from([4]), Buffer.from(jwk.x, 'base64url'), Buffer.from(jwk.y, 'base64url')])
71
+ return Buffer.concat([sshString(Buffer.from('ecdsa-sha2-nistp256')), sshString(Buffer.from('nistp256')), sshString(point)])
72
+ }
73
+ if (jwk.kty === 'OKP' && jwk.crv === 'Ed25519') {
74
+ return Buffer.concat([sshString(Buffer.from('ssh-ed25519')), sshString(Buffer.from(jwk.x, 'base64url'))])
75
+ }
76
+ throw new Error('ssh: unsupported key type ' + jwk.kty + '/' + (jwk.crv || ''))
77
+ }
78
+
79
+ /**
80
+ * Lee una llave privada (texto del archivo: OpenSSH ed25519 o PEM RSA/P-256) y devuelve
81
+ * lo que el agente necesita: `{ type, privateKey, blob, id, comment }`.
82
+ */
83
+ export function loadPrivateKey (text, comment = '') {
84
+ const t = String(text || '').trim()
85
+ let privateKey, blob
86
+ if (t.startsWith('-----BEGIN OPENSSH PRIVATE KEY-----')) ({ privateKey, blob } = parseOpenSsh(t))
87
+ else { privateKey = createPrivateKey(t); blob = publicBlob(privateKey) }
88
+ const [type] = readStrings(blob, 1)
89
+ return { type: type.toString(), privateKey, blob, id: fingerprint(blob), comment }
90
+ }
91
+
92
+ /** Línea `authorized_keys` de una llave cargada. */
93
+ export const publicLine = (k) => `${k.type} ${k.blob.toString('base64')}${k.comment ? ' ' + k.comment : ''}`
94
+
95
+ const SSH_AGENT_RSA_SHA2_256 = 2
96
+ const SSH_AGENT_RSA_SHA2_512 = 4
97
+
98
+ /** Firma `data` como manda SSH para ese tipo de llave; devuelve el blob de firma. */
99
+ export function signSsh (k, data, flags = 0) {
100
+ if (k.type === 'ssh-ed25519') {
101
+ return Buffer.concat([sshString(Buffer.from('ssh-ed25519')), sshString(nodeSign(null, Buffer.from(data), k.privateKey))])
102
+ }
103
+ if (k.type === 'ecdsa-sha2-nistp256') {
104
+ const raw = nodeSign('sha256', Buffer.from(data), { key: k.privateKey, dsaEncoding: 'ieee-p1363' })
105
+ const rs = Buffer.concat([sshMpint(raw.subarray(0, 32)), sshMpint(raw.subarray(32))])
106
+ return Buffer.concat([sshString(Buffer.from('ecdsa-sha2-nistp256')), sshString(rs)])
107
+ }
108
+ if (k.type === 'ssh-rsa') {
109
+ // Sin flags es el `ssh-rsa` (SHA-1) histórico, que los servidores modernos rechazan;
110
+ // OpenSSH pide rsa-sha2-256/512 con las banderas del agente (RFC 8332).
111
+ const algo = (flags & SSH_AGENT_RSA_SHA2_512) ? 'rsa-sha2-512' : (flags & SSH_AGENT_RSA_SHA2_256) ? 'rsa-sha2-256' : 'ssh-rsa'
112
+ const hash = algo === 'rsa-sha2-512' ? 'sha512' : algo === 'rsa-sha2-256' ? 'sha256' : 'sha1'
113
+ return Buffer.concat([sshString(Buffer.from(algo)), sshString(nodeSign(hash, Buffer.from(data), k.privateKey))])
114
+ }
115
+ throw new Error('ssh: cannot sign with ' + k.type)
116
+ }
117
+
118
+ export default { loadPrivateKey, publicBlob, publicLine, signSsh, sshString, sshMpint, readStrings, fingerprint }