@dotrino/vault 0.32.0 → 0.33.1
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 +6 -5
- package/bin/dotrino-env.js +26 -19
- package/package.json +1 -1
- package/src/service.js +1 -49
- package/src/sshAgent.js +21 -27
- package/src/sshKeys.js +87 -45
package/README.md
CHANGED
|
@@ -280,9 +280,10 @@ cert de una máquina vigente: sin esto toda máquina enrolada caducaba a los 30
|
|
|
280
280
|
|
|
281
281
|
MIT · parte de [Dotrino](https://dotrino.com).
|
|
282
282
|
|
|
283
|
-
## Agente SSH
|
|
283
|
+
## Agente SSH con llaves en memoria (`dotrino-env ssh-agent`)
|
|
284
284
|
|
|
285
|
-
La llave SSH
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
`
|
|
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`).
|
package/bin/dotrino-env.js
CHANGED
|
@@ -39,8 +39,8 @@ function help () {
|
|
|
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
41
|
ssh-agent [--ns <ns>] [--socket <ruta>]
|
|
42
|
-
agente SSH
|
|
43
|
-
|
|
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.
|
|
44
44
|
ejecuta <cmd> con los secretos inyectados en su entorno
|
|
45
45
|
|
|
46
46
|
En tu código:
|
|
@@ -152,29 +152,36 @@ async function cmdRun () {
|
|
|
152
152
|
}
|
|
153
153
|
|
|
154
154
|
/**
|
|
155
|
-
* Agente SSH
|
|
156
|
-
*
|
|
157
|
-
*
|
|
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=…
|
|
158
162
|
*/
|
|
159
163
|
async function cmdSshAgent () {
|
|
160
164
|
const { startSshAgent, defaultSocketPath } = await import('../src/sshAgent.js')
|
|
161
|
-
const {
|
|
162
|
-
const ns = flag('ns') || undefined
|
|
165
|
+
const { loadPrivateKey, publicLine } = await import('../src/sshKeys.js')
|
|
163
166
|
const { serviceDir, resolveNs } = await import('../src/env.js')
|
|
164
|
-
const
|
|
167
|
+
const { waitForSecrets } = await import('../src/service.js')
|
|
168
|
+
const ns = resolveNs(flag('ns') || undefined)
|
|
169
|
+
const dir = flag('dir') || serviceDir(ns)
|
|
165
170
|
const socketPath = flag('socket') || defaultSocketPath(dir)
|
|
166
|
-
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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) }
|
|
174
180
|
}
|
|
175
|
-
|
|
176
|
-
const
|
|
177
|
-
|
|
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)
|
|
178
185
|
const stop = () => { agent.close(); process.exit(0) }
|
|
179
186
|
process.on('SIGINT', stop); process.on('SIGTERM', stop)
|
|
180
187
|
await new Promise(() => {})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vault",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.1",
|
|
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",
|
package/src/service.js
CHANGED
|
@@ -338,7 +338,7 @@ export async function enrollWithVault ({ qr, label = 'agent', expectedScope = nu
|
|
|
338
338
|
export async function enrollService ({ qr, ns, dir, label, onCode, onReplace, approveTimeoutMs = 180000 } = {}) {
|
|
339
339
|
if (!isValidSecretsNs(ns)) throw new Error('invalid ns (use [a-z0-9-]{1,32}, e.g. "proxy")')
|
|
340
340
|
if (!dir) throw new Error('dir required (where to persist the service identity)')
|
|
341
|
-
label = label ||
|
|
341
|
+
label = label || ns
|
|
342
342
|
|
|
343
343
|
// La identidad que va a quedar descartada. Se avisa antes de tocar nada: para
|
|
344
344
|
// el proxy, por ejemplo, esta llave es además su identidad de red, así que
|
|
@@ -491,54 +491,6 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
491
491
|
} finally { client.close() }
|
|
492
492
|
}
|
|
493
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
|
-
|
|
542
494
|
/**
|
|
543
495
|
* Abre un bundle sellado: saca la CEK de la envoltura dirigida a este aparato y
|
|
544
496
|
* descifra con ella las variables privadas. Las públicas vienen en claro.
|
package/src/sshAgent.js
CHANGED
|
@@ -1,21 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* AGENTE SSH
|
|
3
|
-
*
|
|
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.
|
|
4
7
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
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
|
|
14
11
|
*/
|
|
15
12
|
import fs from 'node:fs'
|
|
16
13
|
import net from 'node:net'
|
|
17
14
|
import path from 'node:path'
|
|
18
|
-
import { sshString, readStrings } from './sshKeys.js'
|
|
15
|
+
import { sshString, readStrings, signSsh } from './sshKeys.js'
|
|
19
16
|
|
|
20
17
|
const AGENT_FAILURE = 5
|
|
21
18
|
const AGENT_SUCCESS = 6
|
|
@@ -37,30 +34,27 @@ const frame = (type, payload = Buffer.alloc(0)) => {
|
|
|
37
34
|
}
|
|
38
35
|
|
|
39
36
|
/**
|
|
40
|
-
* @param {{ socketPath: string,
|
|
37
|
+
* @param {{ socketPath: string, keys: () => any[], log?: Function }} opts
|
|
38
|
+
* `keys()`: las llaves cargadas (`loadPrivateKey` de sshKeys.js), con su privada en memoria.
|
|
41
39
|
*/
|
|
42
|
-
export function startSshAgent ({ socketPath,
|
|
40
|
+
export function startSshAgent ({ socketPath, keys, log = () => {} }) {
|
|
43
41
|
fs.mkdirSync(path.dirname(socketPath), { recursive: true, mode: 0o700 })
|
|
44
42
|
try { fs.unlinkSync(socketPath) } catch (_) {}
|
|
45
43
|
|
|
46
44
|
async function handle (type, payload) {
|
|
47
|
-
|
|
48
|
-
const v = vault()
|
|
45
|
+
const list = keys() || []
|
|
49
46
|
if (type === REQUEST_IDENTITIES) {
|
|
50
|
-
const
|
|
51
|
-
const
|
|
52
|
-
const parts = keys.map((k) => Buffer.concat([sshString(Buffer.from(k.blob, 'base64')), sshString(Buffer.from(k.comment || ''))]))
|
|
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 || ''))]))
|
|
53
49
|
return frame(IDENTITIES_ANSWER, Buffer.concat([n, ...parts]))
|
|
54
50
|
}
|
|
55
51
|
if (type === SIGN_REQUEST) {
|
|
56
52
|
const [blob, data] = readStrings(payload, 2)
|
|
57
|
-
const
|
|
53
|
+
const flags = payload.length >= 4 ? payload.readUInt32BE(payload.length - 4) : 0
|
|
54
|
+
const key = list.find((k) => k.blob.equals(blob))
|
|
58
55
|
if (!key) return frame(AGENT_FAILURE)
|
|
59
|
-
try {
|
|
60
|
-
|
|
61
|
-
return frame(SIGN_RESPONSE, sshString(sig))
|
|
62
|
-
} catch (e) {
|
|
63
|
-
log('[vault] ssh-agent: not signed: ' + e.message)
|
|
56
|
+
try { return frame(SIGN_RESPONSE, sshString(signSsh(key, data, flags))) } catch (e) {
|
|
57
|
+
log('[ssh-agent] not signed: ' + e.message)
|
|
64
58
|
return frame(AGENT_FAILURE)
|
|
65
59
|
}
|
|
66
60
|
}
|
|
@@ -86,10 +80,10 @@ export function startSshAgent ({ socketPath, vault, log = () => {}, refresh = nu
|
|
|
86
80
|
})
|
|
87
81
|
sock.on('error', () => {})
|
|
88
82
|
})
|
|
89
|
-
server.on('error', (e) => log('[
|
|
83
|
+
server.on('error', (e) => log('[ssh-agent] ' + e.message))
|
|
90
84
|
server.listen(socketPath, () => {
|
|
91
85
|
try { fs.chmodSync(socketPath, 0o600) } catch (_) {}
|
|
92
|
-
log(`[
|
|
86
|
+
log(`[ssh-agent] listening at ${socketPath}`)
|
|
93
87
|
})
|
|
94
88
|
return {
|
|
95
89
|
socketPath,
|
package/src/sshKeys.js
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* LLAVES SSH
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* era exactamente lo que había que sacar de aquí.
|
|
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).
|
|
7
6
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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.
|
|
11
12
|
*/
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
export const ECDSA_P256 = 'ecdsa-sha2-nistp256'
|
|
13
|
+
import { createPrivateKey, createPublicKey, sign as nodeSign, createHash } from 'node:crypto'
|
|
15
14
|
|
|
16
15
|
const u32 = (n) => { const b = Buffer.alloc(4); b.writeUInt32BE(n >>> 0); return b }
|
|
17
16
|
export const sshString = (buf) => Buffer.concat([u32(buf.length), Buffer.from(buf)])
|
|
@@ -21,7 +20,6 @@ export function sshMpint (bytes) {
|
|
|
21
20
|
if (b[0] & 0x80) b = Buffer.concat([Buffer.from([0]), b])
|
|
22
21
|
return sshString(b)
|
|
23
22
|
}
|
|
24
|
-
/** Lee strings SSH encadenados: `[Buffer, …]`. */
|
|
25
23
|
export function readStrings (buf, max = 16) {
|
|
26
24
|
const out = []; let o = 0
|
|
27
25
|
while (o + 4 <= buf.length && out.length < max) {
|
|
@@ -31,46 +29,90 @@ export function readStrings (buf, max = 16) {
|
|
|
31
29
|
}
|
|
32
30
|
return out
|
|
33
31
|
}
|
|
32
|
+
export const fingerprint = (blob) => 'SHA256:' + createHash('sha256').update(Buffer.from(blob)).digest('base64').replace(/=+$/, '')
|
|
34
33
|
|
|
35
|
-
|
|
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
|
-
}
|
|
34
|
+
const OPENSSH_MAGIC = 'openssh-key-v1\0'
|
|
40
35
|
|
|
41
|
-
/**
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
|
|
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) }
|
|
54
60
|
}
|
|
55
61
|
|
|
56
|
-
/**
|
|
57
|
-
export function
|
|
58
|
-
|
|
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 || ''))
|
|
59
77
|
}
|
|
60
78
|
|
|
61
79
|
/**
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* blob inválido dejaría al `ssh` del usuario con un error opaco y a nosotros sin bitácora.
|
|
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 }`.
|
|
65
82
|
*/
|
|
66
|
-
export function
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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)
|
|
74
116
|
}
|
|
75
117
|
|
|
76
|
-
export default {
|
|
118
|
+
export default { loadPrivateKey, publicBlob, publicLine, signSsh, sshString, sshMpint, readStrings, fingerprint }
|