@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 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 delgado (`dotrino-env ssh-agent`)
283
+ ## Agente SSH con llaves en memoria (`dotrino-env ssh-agent`)
284
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 })`.
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`).
@@ -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 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.
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 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`.
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 { listSshKeys, requestSshSign } = await import('../src/service.js')
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 dir = flag('dir') || serviceDir(resolveNs(ns))
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
- 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…') })
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
- await keys()
176
- const agent = startSshAgent({ socketPath, vault: () => vault, log: console.error, refresh: keys })
177
- console.error('export SSH_AUTH_SOCK=%s', agent.socketPath)
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.32.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 || 'service:' + ns
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 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.
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
- * 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
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, vault: () => { sshKeys: () => any[], requestSshSign: (a: { keyId: string, data: Buffer }) => Promise<Buffer> }, log?: Function }} opts
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, vault, log = () => {}, refresh = null }) {
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
- if (refresh) { try { await refresh() } catch (_) {} }
48
- const v = vault()
45
+ const list = keys() || []
49
46
  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 || ''))]))
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 key = v.sshKeys().find((k) => k.blob === blob.toString('base64'))
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
- 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)
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('[vault] ssh-agent: ' + e.message))
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(`[vault] ssh-agent listening at ${socketPath} (export SSH_AUTH_SOCK=${socketPath})`)
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 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í.
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
- * 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`.
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 { createPublicKey, createHash, verify as nodeVerify } from 'node:crypto'
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
- /** 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
- }
34
+ const OPENSSH_MAGIC = 'openssh-key-v1\0'
40
35
 
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) }
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
- /** `SHA256:…` como lo imprime `ssh-keygen -l`. */
57
- export function fingerprint (blob) {
58
- return 'SHA256:' + createHash('sha256').update(Buffer.from(blob)).digest('base64').replace(/=+$/, '')
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
- * 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.
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 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)])
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 { ECDSA_P256, parsePublicKey, p256Blob, sshSignature, sshString, sshMpint, readStrings, fingerprint }
118
+ export default { loadPrivateKey, publicBlob, publicLine, signSsh, sshString, sshMpint, readStrings, fingerprint }