@dotrino/vault 0.30.0 → 0.32.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 +7 -0
- package/bin/dotrino-env.js +35 -1
- package/package.json +2 -2
- package/src/enroll.js +4 -1
- package/src/env.js +2 -2
- package/src/protocol.js +2 -1
- package/src/service.js +70 -5
- package/src/sshAgent.js +100 -0
- package/src/sshKeys.js +76 -0
package/README.md
CHANGED
|
@@ -279,3 +279,10 @@ 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 delgado (`dotrino-env ssh-agent`)
|
|
284
|
+
|
|
285
|
+
La llave SSH vive en el teléfono del dueño (registrada en su bóveda). En esta máquina solo
|
|
286
|
+
corre un agente `ssh-agent` sin llaves: cada `SIGN_REQUEST` viaja a la bóveda como un
|
|
287
|
+
pedido que el teléfono aprueba firmando. `dotrino-env ssh-agent --ns <ns>` imprime el
|
|
288
|
+
`SSH_AUTH_SOCK`. Como librería: `listSshKeys(args)` y `requestSshSign(args, { keyId, data })`.
|
package/bin/dotrino-env.js
CHANGED
|
@@ -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 delgado: las llaves viven en el teléfono; cada firma es
|
|
43
|
+
un pedido a la bóveda. 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,50 @@ 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
|
|
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 DELGADO. Aquí no hay ninguna llave: el `ssh` del usuario pide firmar, esto
|
|
156
|
+
* convierte el reto en un pedido a la bóveda (la que enroló este servicio, esté donde
|
|
157
|
+
* esté) y el teléfono firma. Las públicas se cachean 30 s para no preguntar en cada `ssh`.
|
|
158
|
+
*/
|
|
159
|
+
async function cmdSshAgent () {
|
|
160
|
+
const { startSshAgent, defaultSocketPath } = await import('../src/sshAgent.js')
|
|
161
|
+
const { listSshKeys, requestSshSign } = await import('../src/service.js')
|
|
162
|
+
const ns = flag('ns') || undefined
|
|
163
|
+
const { serviceDir, resolveNs } = await import('../src/env.js')
|
|
164
|
+
const dir = flag('dir') || serviceDir(resolveNs(ns))
|
|
165
|
+
const socketPath = flag('socket') || defaultSocketPath(dir)
|
|
166
|
+
let cache = { at: 0, keys: [] }
|
|
167
|
+
const keys = async () => {
|
|
168
|
+
if (Date.now() - cache.at > 30 * 1000) { try { cache = { at: Date.now(), keys: await listSshKeys({ dir }) } } catch (e) { console.error('[dotrino-env] could not list keys: ' + e.message) } }
|
|
169
|
+
return cache.keys
|
|
170
|
+
}
|
|
171
|
+
const vault = {
|
|
172
|
+
sshKeys: () => cache.keys,
|
|
173
|
+
requestSshSign: ({ keyId, data }) => requestSshSign({ dir }, { keyId, data, onPending: () => console.error('[dotrino-env] ssh: waiting for approval on your phone…') })
|
|
174
|
+
}
|
|
175
|
+
await keys()
|
|
176
|
+
const agent = startSshAgent({ socketPath, vault: () => vault, log: console.error, refresh: keys })
|
|
177
|
+
console.error('export SSH_AUTH_SOCK=%s', agent.socketPath)
|
|
178
|
+
const stop = () => { agent.close(); process.exit(0) }
|
|
179
|
+
process.on('SIGINT', stop); process.on('SIGTERM', stop)
|
|
180
|
+
await new Promise(() => {})
|
|
181
|
+
}
|
|
182
|
+
|
|
150
183
|
const run = async () => {
|
|
151
184
|
switch (argv[0]) {
|
|
152
185
|
case 'enroll': return cmdEnroll()
|
|
153
186
|
case 'status': return cmdStatus()
|
|
154
187
|
case 'check': return cmdCheck()
|
|
155
188
|
case 'run': return cmdRun()
|
|
189
|
+
case 'ssh-agent': return cmdSshAgent()
|
|
156
190
|
case undefined:
|
|
157
191
|
case 'help':
|
|
158
192
|
case '--help':
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vault",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.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",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"pairing"
|
|
47
47
|
],
|
|
48
48
|
"peerDependencies": {
|
|
49
|
-
"@dotrino/identity": "
|
|
49
|
+
"@dotrino/identity": "^0.58.0",
|
|
50
50
|
"@dotrino/proxy-client": ">=0.9.0"
|
|
51
51
|
},
|
|
52
52
|
"license": "MIT",
|
package/src/enroll.js
CHANGED
|
@@ -341,8 +341,11 @@ export function createEnrollDesk ({
|
|
|
341
341
|
let record = null
|
|
342
342
|
try {
|
|
343
343
|
if (typeof identity.admitMember === 'function') {
|
|
344
|
+
// PERMISOS, no tipos (2026-08-22): las capacidades son las del scope ENTERO. Un
|
|
345
|
+
// cajón (`secrets:<ns>`) suma `secrets` y fija el CN; no borra lo demás — un bot
|
|
346
|
+
// con `sign,secrets:eco` firma como aparato del acta Y lee solo su cajón.
|
|
344
347
|
const cn = scopeToCn(pend.scope)
|
|
345
|
-
const caps = cn ? ['secrets'] :
|
|
348
|
+
const caps = [...new Set([...scopeToCaps(pend.scope), ...(cn ? ['secrets'] : [])])]
|
|
346
349
|
if (caps.length) await identity.admitMember({ pub: pend.dpub, encPub: pend.encPub || null, label: pend.label || '', cn, caps, cert, continuity: pend.continuity || null })
|
|
347
350
|
}
|
|
348
351
|
record = (await identity.profileActa?.())?.acta || null
|
package/src/env.js
CHANGED
|
@@ -98,12 +98,12 @@ let lastApplied = null
|
|
|
98
98
|
* `overridden` = las que YA tenían otro valor en el entorno y el vault pisó. Es
|
|
99
99
|
* el dato que delata un `.env` rancio, así que se reporta en vez de callarse.
|
|
100
100
|
*/
|
|
101
|
-
export async function loadEnv ({ ns, dir, override, wait = true, required = [], onRetry } = {}) {
|
|
101
|
+
export async function loadEnv ({ ns, dir, override, wait = true, required = [], onRetry, onPending } = {}) {
|
|
102
102
|
if (override === undefined) override = overrideByDefault()
|
|
103
103
|
ns = resolveNs(ns)
|
|
104
104
|
dir = dir || serviceDir(ns)
|
|
105
105
|
const load = wait ? waitForSecrets : fetchSecrets
|
|
106
|
-
const secrets = await load({ dir, ns, onRetry })
|
|
106
|
+
const secrets = await load({ dir, ns, onRetry, onPending })
|
|
107
107
|
|
|
108
108
|
const missing = required.filter((k) => !(k in secrets))
|
|
109
109
|
if (missing.length) {
|
package/src/protocol.js
CHANGED
|
@@ -97,7 +97,8 @@ export const SCOPE = Object.freeze({
|
|
|
97
97
|
// Consola remota (docs/consola-remota.md): admitir y expulsar miembros a distancia.
|
|
98
98
|
// NO incluye cambiar permisos, traspasar el mando ni conceder `admin`: eso es el rol
|
|
99
99
|
// de master y sigue siendo local. No se empareja — se concede desde el PC.
|
|
100
|
-
ADMIN: 'vault:admin'
|
|
100
|
+
ADMIN: 'vault:admin',
|
|
101
|
+
APPROVE: 'vault:approve' // aprobar pedidos de secretos (cajones con `approval`); se concede a mano, como admin
|
|
101
102
|
})
|
|
102
103
|
|
|
103
104
|
/**
|
package/src/service.js
CHANGED
|
@@ -161,6 +161,9 @@ async function identifyAsService (client, device) {
|
|
|
161
161
|
await client.identify({ data, signature })
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
/** Cuánto espera un agente a que alguien apruebe (el pedido vive 5 min en la bóveda). */
|
|
165
|
+
export const APPROVAL_TIMEOUT_MS = 5 * 60 * 1000 + 10 * 1000
|
|
166
|
+
|
|
164
167
|
function waitForMsg (client, predicate, timeoutMs = 30000) {
|
|
165
168
|
return new Promise((resolve, reject) => {
|
|
166
169
|
const off = client.on('message', (_from, payload) => {
|
|
@@ -407,7 +410,7 @@ export async function registerEncKey ({ dir, ns, proxyUrl, masterPubkey, device,
|
|
|
407
410
|
* Renueva el cert automáticamente si está por vencer (best-effort).
|
|
408
411
|
* @returns {Promise<Record<string,string>>} secretos KEY→valor
|
|
409
412
|
*/
|
|
410
|
-
export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, timeoutMs = 30000 } = {}) {
|
|
413
|
+
export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, timeoutMs = 30000, onPending, approvalTimeoutMs = APPROVAL_TIMEOUT_MS } = {}) {
|
|
411
414
|
let saved = null
|
|
412
415
|
if (dir) saved = readServiceIdentity(dir)
|
|
413
416
|
// Sin `dir`: la identidad viene entera por parámetros — es el caso de un agente
|
|
@@ -448,9 +451,22 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
448
451
|
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
|
|
449
452
|
const pending = waitForMsg(client, (p) => p.type === MSG.SECRETS_RESULT || p.type === MSG.ERROR, timeoutMs)
|
|
450
453
|
client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data, signature, cert })
|
|
451
|
-
|
|
454
|
+
let res = await pending
|
|
452
455
|
if (res.type === MSG.ERROR) throw new Error(res.error)
|
|
453
456
|
|
|
457
|
+
// CAJÓN CON APROBACIÓN: la bóveda contesta «pendiente» (firmado) y la respuesta de
|
|
458
|
+
// verdad llega cuando el aparato que aprueba firme — por esta misma conexión, que
|
|
459
|
+
// sigue identificada. Se espera lo que dura el pedido; denegado o vencido es error.
|
|
460
|
+
if (res.body?.op === 'secrets.pending' && res.body.ns === ns) {
|
|
461
|
+
const okPending = await verifyDeviceSig({ publickey: masterPubkey, data: res.body, signature: res.signature })
|
|
462
|
+
if (!okPending) throw new Error('invalid master signature on the pending reply')
|
|
463
|
+
try { onPending?.({ id: res.body.id, ns, exp: res.body.exp }) } catch (_) {}
|
|
464
|
+
const until = typeof res.body.exp === 'number' ? Math.max(5000, res.body.exp - Date.now() + 5000) : approvalTimeoutMs
|
|
465
|
+
res = await waitForMsg(client, (p) => (p.type === MSG.SECRETS_RESULT && p.body?.op === 'secrets.result') || p.type === MSG.ERROR, Math.min(until, approvalTimeoutMs))
|
|
466
|
+
.catch((e) => { throw new Error(/timeout/.test(e.message) ? 'approval: nobody approved the request in time' : e.message) })
|
|
467
|
+
if (res.type === MSG.ERROR) throw new Error(res.error)
|
|
468
|
+
}
|
|
469
|
+
|
|
454
470
|
// Autenticidad: el cuerpo viene firmado por la MAESTRA pineada.
|
|
455
471
|
const body = res.body
|
|
456
472
|
if (!body || body.op !== 'secrets.result' || body.ns !== ns) throw new Error('malformed secrets reply')
|
|
@@ -475,6 +491,54 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
475
491
|
} finally { client.close() }
|
|
476
492
|
}
|
|
477
493
|
|
|
494
|
+
/**
|
|
495
|
+
* AGENTE SSH DELGADO (`dotrino-env ssh-agent`): este proceso no custodia nada. Lista las
|
|
496
|
+
* llaves públicas que la bóveda tiene registradas y, por cada reto, le pide a la bóveda
|
|
497
|
+
* que lo convierta en un PEDIDO que el teléfono firma. Cualquier aparato con `vault:sign`
|
|
498
|
+
* puede pedir; quien decide es el teléfono.
|
|
499
|
+
*/
|
|
500
|
+
function serviceArgs ({ dir, ns, proxyUrl, masterPubkey, device, cert }) {
|
|
501
|
+
let saved = dir ? readServiceIdentity(dir) : null
|
|
502
|
+
if (!saved && device && cert) saved = { ns, iss: masterPubkey, proxy: proxyUrl, device, cert }
|
|
503
|
+
const out = { ns: ns || saved?.ns, proxyUrl: proxyUrl || saved?.proxy, masterPubkey: masterPubkey || saved?.iss, device: device || saved?.device, cert: cert || saved?.cert }
|
|
504
|
+
if (!out.proxyUrl || !out.masterPubkey || !out.device || !out.cert) throw new Error('service not enrolled: run enrollService() first (service-identity.json missing)')
|
|
505
|
+
return out
|
|
506
|
+
}
|
|
507
|
+
async function sshRpc (args, data, { timeoutMs = 30000, waitResult = null } = {}) {
|
|
508
|
+
const { proxyUrl, masterPubkey, device, cert } = serviceArgs(args)
|
|
509
|
+
const client = await freshClient(proxyUrl)
|
|
510
|
+
try {
|
|
511
|
+
await identifyAsService(client, device)
|
|
512
|
+
const signed = { ...data, publickey: device.publickey, ts: Date.now() }
|
|
513
|
+
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data: signed })
|
|
514
|
+
const pending = waitForMsg(client, (p) => p.type === MSG.SECRETS_RESULT || p.type === MSG.ERROR, timeoutMs)
|
|
515
|
+
client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data: signed, signature, cert })
|
|
516
|
+
let res = await pending
|
|
517
|
+
if (res.type === MSG.ERROR) throw new Error(res.error)
|
|
518
|
+
if (waitResult && res.body?.op === 'ssh.pending') {
|
|
519
|
+
try { waitResult.onPending?.({ id: res.body.id, exp: res.body.exp }) } catch (_) {}
|
|
520
|
+
const until = typeof res.body.exp === 'number' ? Math.max(5000, res.body.exp - Date.now() + 5000) : APPROVAL_TIMEOUT_MS
|
|
521
|
+
res = await waitForMsg(client, (p) => (p.type === MSG.SECRETS_RESULT && p.body?.op === 'ssh.sign.result') || p.type === MSG.ERROR, Math.min(until, APPROVAL_TIMEOUT_MS))
|
|
522
|
+
.catch((e) => { throw new Error(/timeout/.test(e.message) ? 'ssh: nobody approved the request in time' : e.message) })
|
|
523
|
+
if (res.type === MSG.ERROR) throw new Error(res.error)
|
|
524
|
+
}
|
|
525
|
+
const ok = await verifyDeviceSig({ publickey: masterPubkey, data: res.body, signature: res.signature })
|
|
526
|
+
if (!ok) throw new Error('invalid master signature on the reply')
|
|
527
|
+
return res.body
|
|
528
|
+
} finally { client.close() }
|
|
529
|
+
}
|
|
530
|
+
/** Las llaves SSH públicas registradas en la bóveda: `[{ id, blob, comment }]`. */
|
|
531
|
+
export async function listSshKeys (args = {}) {
|
|
532
|
+
const body = await sshRpc(args, { op: 'ssh.keys.public' })
|
|
533
|
+
return Array.isArray(body.items) ? body.items : []
|
|
534
|
+
}
|
|
535
|
+
/** Pide la firma SSH de `data` con la llave `keyId`; devuelve el blob de firma (Buffer). */
|
|
536
|
+
export async function requestSshSign (args = {}, { keyId, data, onPending } = {}) {
|
|
537
|
+
const body = await sshRpc(args, { op: 'ssh.sign', key: keyId, data: Buffer.from(data).toString('base64') }, { waitResult: { onPending } })
|
|
538
|
+
if (typeof body.sig !== 'string') throw new Error('ssh: malformed signature reply')
|
|
539
|
+
return Buffer.from(body.sig, 'base64')
|
|
540
|
+
}
|
|
541
|
+
|
|
478
542
|
/**
|
|
479
543
|
* Abre un bundle sellado: saca la CEK de la envoltura dirigida a este aparato y
|
|
480
544
|
* descifra con ella las variables privadas. Las públicas vienen en claro.
|
|
@@ -882,15 +946,16 @@ function fingerprintOf (secrets) {
|
|
|
882
946
|
* servicio no opera hasta que esto resuelva — esa es la regla.
|
|
883
947
|
* @returns {Promise<Record<string,string>>}
|
|
884
948
|
*/
|
|
885
|
-
export async function waitForSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, retryMs = 5000, maxRetryMs = 60000, onRetry } = {}) {
|
|
949
|
+
export async function waitForSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, retryMs = 5000, maxRetryMs = 60000, onRetry, onPending } = {}) {
|
|
886
950
|
let delay = retryMs
|
|
887
951
|
for (;;) {
|
|
888
952
|
try {
|
|
889
|
-
return await fetchSecrets({ dir, ns, proxyUrl, masterPubkey, device, cert, enc })
|
|
953
|
+
return await fetchSecrets({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, onPending })
|
|
890
954
|
} catch (e) {
|
|
891
955
|
// Lo NO transitorio no se arregla reintentando: falta de enrolamiento,
|
|
892
956
|
// cert revocado/vencido o scope equivocado exigen re-emparejar → se corta.
|
|
893
|
-
|
|
957
|
+
// Que te lo DENIEGUEN tampoco se reintenta: fue una decisión, no un tropiezo.
|
|
958
|
+
if (/not enrolled|invalid ns|unauthorized: (revoked|expired|scope|cn|untrusted-issuer|cert-device-mismatch|denied)/.test(e.message)) throw e
|
|
894
959
|
try { onRetry?.(e, delay) } catch (_) {}
|
|
895
960
|
await new Promise((r) => setTimeout(r, delay))
|
|
896
961
|
delay = Math.min(maxRetryMs, Math.round(delay * 1.6))
|
package/src/sshAgent.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AGENTE SSH del daemon: un socket Unix con el protocolo de `ssh-agent` (draft-miller-
|
|
3
|
+
* ssh-agent), para que el `ssh` del usuario firme con la llave que vive en su TELÉFONO.
|
|
4
|
+
*
|
|
5
|
+
* Lo que hace es poco a propósito: listar las llaves públicas registradas y, en cada
|
|
6
|
+
* `SIGN_REQUEST`, pedirle la firma al aparato que aprueba (`vault.requestSshSign`) y
|
|
7
|
+
* devolverla. No guarda llaves privadas, no acepta que le añadan (`ssh-add` de una llave
|
|
8
|
+
* del disco se rechaza: la idea es justo que no haya llaves en el disco) y no hace de
|
|
9
|
+
* proxy de nada más.
|
|
10
|
+
*
|
|
11
|
+
* export SSH_AUTH_SOCK=$XDG_RUNTIME_DIR/dotrino-vault/ssh-agent.sock
|
|
12
|
+
* ssh-add -L # las llaves del teléfono
|
|
13
|
+
* ssh mi-servidor # el teléfono pide tu «sí» y firma
|
|
14
|
+
*/
|
|
15
|
+
import fs from 'node:fs'
|
|
16
|
+
import net from 'node:net'
|
|
17
|
+
import path from 'node:path'
|
|
18
|
+
import { sshString, readStrings } from './sshKeys.js'
|
|
19
|
+
|
|
20
|
+
const AGENT_FAILURE = 5
|
|
21
|
+
const AGENT_SUCCESS = 6
|
|
22
|
+
const REQUEST_IDENTITIES = 11
|
|
23
|
+
const IDENTITIES_ANSWER = 12
|
|
24
|
+
const SIGN_REQUEST = 13
|
|
25
|
+
const SIGN_RESPONSE = 14
|
|
26
|
+
|
|
27
|
+
/** Dónde va el socket: en el directorio de ejecución del usuario si lo hay (se limpia solo). */
|
|
28
|
+
export function defaultSocketPath (dir) {
|
|
29
|
+
const run = process.env.XDG_RUNTIME_DIR
|
|
30
|
+
return run ? path.join(run, 'dotrino-vault', 'ssh-agent.sock') : path.join(dir, 'ssh-agent.sock')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const frame = (type, payload = Buffer.alloc(0)) => {
|
|
34
|
+
const body = Buffer.concat([Buffer.from([type]), payload])
|
|
35
|
+
const len = Buffer.alloc(4); len.writeUInt32BE(body.length)
|
|
36
|
+
return Buffer.concat([len, body])
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {{ socketPath: string, vault: () => { sshKeys: () => any[], requestSshSign: (a: { keyId: string, data: Buffer }) => Promise<Buffer> }, log?: Function }} opts
|
|
41
|
+
*/
|
|
42
|
+
export function startSshAgent ({ socketPath, vault, log = () => {}, refresh = null }) {
|
|
43
|
+
fs.mkdirSync(path.dirname(socketPath), { recursive: true, mode: 0o700 })
|
|
44
|
+
try { fs.unlinkSync(socketPath) } catch (_) {}
|
|
45
|
+
|
|
46
|
+
async function handle (type, payload) {
|
|
47
|
+
if (refresh) { try { await refresh() } catch (_) {} }
|
|
48
|
+
const v = vault()
|
|
49
|
+
if (type === REQUEST_IDENTITIES) {
|
|
50
|
+
const keys = v.sshKeys()
|
|
51
|
+
const n = Buffer.alloc(4); n.writeUInt32BE(keys.length)
|
|
52
|
+
const parts = keys.map((k) => Buffer.concat([sshString(Buffer.from(k.blob, 'base64')), sshString(Buffer.from(k.comment || ''))]))
|
|
53
|
+
return frame(IDENTITIES_ANSWER, Buffer.concat([n, ...parts]))
|
|
54
|
+
}
|
|
55
|
+
if (type === SIGN_REQUEST) {
|
|
56
|
+
const [blob, data] = readStrings(payload, 2)
|
|
57
|
+
const key = v.sshKeys().find((k) => k.blob === blob.toString('base64'))
|
|
58
|
+
if (!key) return frame(AGENT_FAILURE)
|
|
59
|
+
try {
|
|
60
|
+
const sig = await v.requestSshSign({ keyId: key.id, data })
|
|
61
|
+
return frame(SIGN_RESPONSE, sshString(sig))
|
|
62
|
+
} catch (e) {
|
|
63
|
+
log('[vault] ssh-agent: not signed: ' + e.message)
|
|
64
|
+
return frame(AGENT_FAILURE)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// Añadir/quitar llaves, candado, extensiones: nada de eso vive aquí.
|
|
68
|
+
return frame(AGENT_FAILURE)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const server = net.createServer((sock) => {
|
|
72
|
+
let buf = Buffer.alloc(0)
|
|
73
|
+
let busy = Promise.resolve()
|
|
74
|
+
sock.on('data', (chunk) => {
|
|
75
|
+
buf = Buffer.concat([buf, chunk])
|
|
76
|
+
while (buf.length >= 4) {
|
|
77
|
+
const len = buf.readUInt32BE(0)
|
|
78
|
+
if (len < 1 || len > 256 * 1024) { sock.destroy(); return }
|
|
79
|
+
if (buf.length < 4 + len) break
|
|
80
|
+
const type = buf[4]; const payload = buf.subarray(5, 4 + len)
|
|
81
|
+
buf = buf.subarray(4 + len)
|
|
82
|
+
// En orden: el protocolo es petición-respuesta, y un cliente que encadena dos
|
|
83
|
+
// firmas espera las respuestas en el mismo orden.
|
|
84
|
+
busy = busy.then(() => handle(type, payload)).then((out) => { if (!sock.destroyed) sock.write(out) }).catch(() => { if (!sock.destroyed) sock.write(frame(AGENT_FAILURE)) })
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
sock.on('error', () => {})
|
|
88
|
+
})
|
|
89
|
+
server.on('error', (e) => log('[vault] ssh-agent: ' + e.message))
|
|
90
|
+
server.listen(socketPath, () => {
|
|
91
|
+
try { fs.chmodSync(socketPath, 0o600) } catch (_) {}
|
|
92
|
+
log(`[vault] ssh-agent listening at ${socketPath} (export SSH_AUTH_SOCK=${socketPath})`)
|
|
93
|
+
})
|
|
94
|
+
return {
|
|
95
|
+
socketPath,
|
|
96
|
+
close () { try { server.close() } catch (_) {} try { fs.unlinkSync(socketPath) } catch (_) {} }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export default { startSshAgent, defaultSocketPath, AGENT_SUCCESS }
|
package/src/sshKeys.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLAVES SSH del teléfono — solo la parte PÚBLICA, que es lo único que vive en esta
|
|
3
|
+
* máquina. La privada nace y muere en el aparato que aprueba (WebCrypto no extraíble hoy;
|
|
4
|
+
* el llavero del sistema en la app nativa): firmar un reto SSH es un pedido de aprobación
|
|
5
|
+
* más, y la firma vuelve con el «sí». Así la llave SSH no está en el disco del PC, que
|
|
6
|
+
* era exactamente lo que había que sacar de aquí.
|
|
7
|
+
*
|
|
8
|
+
* Formatos (RFC 4253 / 5656): el blob de una llave `ecdsa-sha2-nistp256` es
|
|
9
|
+
* string(tipo) string("nistp256") string(0x04‖X‖Y); su firma es string(tipo) string(mpint r ‖ mpint s).
|
|
10
|
+
* Puro: sin disco ni red — quien guarda es `vault.js`.
|
|
11
|
+
*/
|
|
12
|
+
import { createPublicKey, createHash, verify as nodeVerify } from 'node:crypto'
|
|
13
|
+
|
|
14
|
+
export const ECDSA_P256 = 'ecdsa-sha2-nistp256'
|
|
15
|
+
|
|
16
|
+
const u32 = (n) => { const b = Buffer.alloc(4); b.writeUInt32BE(n >>> 0); return b }
|
|
17
|
+
export const sshString = (buf) => Buffer.concat([u32(buf.length), Buffer.from(buf)])
|
|
18
|
+
export function sshMpint (bytes) {
|
|
19
|
+
let b = Buffer.from(bytes)
|
|
20
|
+
while (b.length > 1 && b[0] === 0) b = b.subarray(1)
|
|
21
|
+
if (b[0] & 0x80) b = Buffer.concat([Buffer.from([0]), b])
|
|
22
|
+
return sshString(b)
|
|
23
|
+
}
|
|
24
|
+
/** Lee strings SSH encadenados: `[Buffer, …]`. */
|
|
25
|
+
export function readStrings (buf, max = 16) {
|
|
26
|
+
const out = []; let o = 0
|
|
27
|
+
while (o + 4 <= buf.length && out.length < max) {
|
|
28
|
+
const n = buf.readUInt32BE(o); o += 4
|
|
29
|
+
if (o + n > buf.length) throw new Error('ssh: truncated string')
|
|
30
|
+
out.push(buf.subarray(o, o + n)); o += n
|
|
31
|
+
}
|
|
32
|
+
return out
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Blob de llave pública a partir de las coordenadas (base64url JWK). */
|
|
36
|
+
export function p256Blob ({ x, y }) {
|
|
37
|
+
const point = Buffer.concat([Buffer.from([4]), Buffer.from(x, 'base64url'), Buffer.from(y, 'base64url')])
|
|
38
|
+
return Buffer.concat([sshString(Buffer.from(ECDSA_P256)), sshString(Buffer.from('nistp256')), sshString(point)])
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Acepta una línea `authorized_keys` (`ecdsa-sha2-nistp256 AAAA… comentario`) y devuelve
|
|
43
|
+
* lo que se guarda: tipo, blob, comentario y JWK pública. Solo P-256: es lo que firma
|
|
44
|
+
* WebCrypto y el llavero del teléfono; RSA no entra.
|
|
45
|
+
*/
|
|
46
|
+
export function parsePublicKey (line) {
|
|
47
|
+
const [type, b64, ...rest] = String(line || '').trim().split(/\s+/)
|
|
48
|
+
if (type !== ECDSA_P256 || !b64) throw new Error(`ssh: only ${ECDSA_P256} keys are accepted`)
|
|
49
|
+
const blob = Buffer.from(b64, 'base64')
|
|
50
|
+
const [t, curve, point] = readStrings(blob, 3)
|
|
51
|
+
if (t.toString() !== ECDSA_P256 || curve.toString() !== 'nistp256' || point.length !== 65 || point[0] !== 4) throw new Error('ssh: malformed key blob')
|
|
52
|
+
const jwk = { kty: 'EC', crv: 'P-256', x: point.subarray(1, 33).toString('base64url'), y: point.subarray(33).toString('base64url') }
|
|
53
|
+
return { type, blob: blob.toString('base64'), comment: rest.join(' '), jwk, id: fingerprint(blob) }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** `SHA256:…` como lo imprime `ssh-keygen -l`. */
|
|
57
|
+
export function fingerprint (blob) {
|
|
58
|
+
return 'SHA256:' + createHash('sha256').update(Buffer.from(blob)).digest('base64').replace(/=+$/, '')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Comprueba la firma cruda del teléfono (`r‖s`, 64 bytes, SHA-256 sobre `data`) y la
|
|
63
|
+
* convierte al blob de firma SSH. Una firma que no cuadra NO se convierte: devolver un
|
|
64
|
+
* blob inválido dejaría al `ssh` del usuario con un error opaco y a nosotros sin bitácora.
|
|
65
|
+
*/
|
|
66
|
+
export function sshSignature ({ jwk, data, rawSig }) {
|
|
67
|
+
const sig = Buffer.from(rawSig, 'base64')
|
|
68
|
+
if (sig.length !== 64) throw new Error('ssh: raw signature must be 64 bytes (r||s)')
|
|
69
|
+
const key = createPublicKey({ key: { kty: 'EC', crv: 'P-256', x: jwk.x, y: jwk.y }, format: 'jwk' })
|
|
70
|
+
const ok = nodeVerify('sha256', Buffer.from(data), { key, dsaEncoding: 'ieee-p1363' }, sig)
|
|
71
|
+
if (!ok) throw new Error('ssh: the signature does not verify against the registered key')
|
|
72
|
+
const rs = Buffer.concat([sshMpint(sig.subarray(0, 32)), sshMpint(sig.subarray(32))])
|
|
73
|
+
return Buffer.concat([sshString(Buffer.from(ECDSA_P256)), sshString(rs)])
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export default { ECDSA_P256, parsePublicKey, p256Blob, sshSignature, sshString, sshMpint, readStrings, fingerprint }
|