/**
* 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])
}