OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-identity / lib / auth / ssh-agent.js

159 lines · javascript ref:
/**
 * Minimal ssh-agent client (SSH_AGENT_PROTOCOL_V2, RFC draft-miller-ssh-agent).
 *
 * Used by CLI clients (e.g. pgh) to sign server challenges with the
 * user's SSH key without prompting for a passphrase. The private key
 * stays in the agent; we only ever receive signatures back.
 *
 * Protocol:
 *   Frame = <4-byte BE length> <1-byte message type> <payload>
 *
 *   SSH_AGENTC_REQUEST_IDENTITIES  = 11  → SSH_AGENT_IDENTITIES_ANSWER = 12
 *   SSH_AGENTC_SIGN_REQUEST         = 13  → SSH_AGENT_SIGN_RESPONSE   = 14
 *   SSH_AGENT_FAILURE               = 5
 *
 * Identities answer body: <uint32 count> [<string pubkey blob> <string comment>]*
 * Sign request body: <string pubkey blob> <string data> <uint32 flags>
 * Sign response body: <string signature blob>   (blob is itself SSH-wire-format:
 *                                                 <string sig-type> <string raw-sig>)
 */

import process from '#node:process'
import net from '#node:net'
import b4a from 'b4a'

const SSH_AGENTC_REQUEST_IDENTITIES = 11
const SSH_AGENT_IDENTITIES_ANSWER = 12
const SSH_AGENTC_SIGN_REQUEST = 13
const SSH_AGENT_SIGN_RESPONSE = 14
const SSH_AGENT_FAILURE = 5

/**
 * Check whether an ssh-agent is reachable on $SSH_AUTH_SOCK.
 */
export function isAgentAvailable () {
  return !!process.env.SSH_AUTH_SOCK
}

/**
 * List identities loaded into the agent.
 *
 * @param {string} [socketPath] - override $SSH_AUTH_SOCK
 * @returns {Promise<Array<{ publicKey: Buffer, comment: string }>>}
 */
export async function listIdentities (socketPath) {
  const path = socketPath || process.env.SSH_AUTH_SOCK
  if (!path) throw new Error('SSH_AUTH_SOCK is not set — no running ssh-agent')

  const req = b4a.from([SSH_AGENTC_REQUEST_IDENTITIES])
  const reply = await sendFrame(path, req)

  if (reply[0] === SSH_AGENT_FAILURE) throw new Error('ssh-agent returned FAILURE')
  if (reply[0] !== SSH_AGENT_IDENTITIES_ANSWER) throw new Error('ssh-agent: unexpected reply ' + reply[0])

  let off = 1
  const count = reply.readUInt32BE(off); off += 4
  const out = []
  for (let i = 0; i < count; i++) {
    const keyLen = reply.readUInt32BE(off); off += 4
    const publicKey = reply.subarray(off, off + keyLen); off += keyLen
    const commentLen = reply.readUInt32BE(off); off += 4
    const comment = reply.subarray(off, off + commentLen).toString('utf8'); off += commentLen
    out.push({ publicKey: b4a.from(publicKey), comment })
  }
  return out
}

/**
 * Sign `data` using a loaded key identified by its SSH-wire-format
 * public key blob.
 *
 * The returned signature is the agent's raw signature blob
 * (`<string sig-type> <string raw-sig>`) as delivered by the agent —
 * not parsed. Pass the full blob through to `SSHKeyParser.parseSignature()`
 * on the verify side.
 *
 * @param {Buffer} publicKey - SSH-wire-format key blob (NOT the base64 string)
 * @param {Buffer|string} data - message to sign
 * @param {Object} [opts]
 * @param {string} [opts.socketPath]
 * @param {number} [opts.flags=0] - 0=default, 2=SHA256, 4=SHA512 (RSA only)
 * @returns {Promise<Buffer>} signature blob
 */
export async function sign (publicKey, data, opts = {}) {
  const path = opts.socketPath || process.env.SSH_AUTH_SOCK
  if (!path) throw new Error('SSH_AUTH_SOCK is not set — no running ssh-agent')

  const msg = typeof data === 'string' ? b4a.from(data, 'utf8') : data
  const flags = opts.flags || 0

  // body = string(pubkey) + string(data) + uint32(flags)
  const body = b4a.concat([
    writeString(publicKey),
    writeString(msg),
    writeUint32(flags)
  ])
  const req = b4a.concat([b4a.from([SSH_AGENTC_SIGN_REQUEST]), body])

  const reply = await sendFrame(path, req)
  if (reply[0] === SSH_AGENT_FAILURE) throw new Error('ssh-agent: sign failed (key not in agent or refused)')
  if (reply[0] !== SSH_AGENT_SIGN_RESPONSE) throw new Error('ssh-agent: unexpected reply ' + reply[0])

  let off = 1
  const sigLen = reply.readUInt32BE(off); off += 4
  const sig = reply.subarray(off, off + sigLen)
  return b4a.from(sig)
}

/**
 * Send a single framed request to the agent and return the response
 * body (without the length prefix).
 *
 * ssh-agent keeps the Unix socket open after replying (expecting more
 * requests), so we can't wait for an 'end' event — we parse the length
 * prefix as soon as it arrives, read exactly 4+len bytes, then close
 * the socket ourselves.
 */
function sendFrame (socketPath, payload) {
  return new Promise((resolve, reject) => {
    const sock = net.createConnection(socketPath)
    let buf = b4a.alloc(0)
    let finished = false

    const done = (err, val) => {
      if (finished) return
      finished = true
      try { sock.destroy() } catch {}
      if (err) reject(err)
      else resolve(val)
    }

    sock.on('error', (err) => done(err))
    sock.on('connect', () => {
      const frame = b4a.concat([writeUint32(payload.length), payload])
      sock.write(frame)
    })
    sock.on('data', (chunk) => {
      buf = b4a.concat([buf, chunk])
      if (buf.length < 4) return
      const len = buf.readUInt32BE(0)
      if (buf.length >= 4 + len) {
        done(null, b4a.from(buf.subarray(4, 4 + len)))
      }
    })
    sock.on('close', () => {
      if (!finished) done(new Error('ssh-agent: socket closed before reply'))
    })
  })
}

function writeUint32 (n) {
  const b = b4a.alloc(4)
  b.writeUInt32BE(n, 0)
  return b
}

function writeString (buf) {
  return b4a.concat([writeUint32(buf.length), buf])
}