Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ jobs:
with:
ssh-private-key: |
${{ secrets.RSA_KEY }}
${{ secrets.ECDSA_KEY }}
${{ secrets.ECDSA_256_KEY }}
${{ secrets.ECDSA_384_KEY }}
${{ secrets.ECDSA_521_KEY }}
${{ secrets.ED25519_KEY }}
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v6
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ mise.toml
/*.tgz
/.nyc_output
/coverage
/id_*
id_*
*.local.*
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,13 @@ A seed is used to generate the secret, it's recommended you don't use the same s
- 🔨 Node library included to decrypt secrets on-the-fly in your code
- 📦 Safe to store encrypted secrets in Git
- `node:stream` compatible
- `sign` message / `verify` signature
- 👥 Works with existing SSH agent workflows like [1Password](https://developer.1password.com/docs/ssh/agent/) or [Bitwarden](https://bitwarden.com/help/ssh-agent/)

## ⚠️ Limitations

- Can't use ECDSA keys, they always give different signatures
- [RFC8332](https://www.rfc-editor.org/info/rfc8332) compatible agent (e.g. OpenSSH 7.6+) mandatory to use SHA2-512 signature scheme. You can still use deprecated SHA1 signatures with `rsaSignatureFlag: 0` option in `SSHAgentClient` constructor.
- [RFC8332](https://www.rfc-editor.org/info/rfc8332) compatible agent (e.g. OpenSSH 7.6+) mandatory to use SHA2-512 signature scheme. You can still use deprecated SHA1 signatures with `rsaSignatureFlag:0` option in `SSHAgentClient` constructor.

## 💻 CLI usage

Expand Down
128 changes: 128 additions & 0 deletions src/lib/parse_utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import * as crypto from 'crypto'
import { type SSHKey, type SSHSignature } from './ssh_agent_client.ts'

/** Read a length-prefixed string (uint32 BE length + bytes) from a buffer. */
const readString = function readString(buffer: Buffer, offset: number): Buffer {
const len = buffer.readUInt32BE(offset)
return buffer.subarray(offset + 4, offset + 4 + len)
}

/** Write a length-prefixed string into `target` at `offset`, return next offset. */
const writeString = function writeString(target: Buffer, src: Buffer, offset: number): number {
target.writeUInt32BE(src.length, offset)
src.copy(target, offset + 4)
return offset + 4 + src.length
}

/**
* Write the 5-byte SSH agent frame header (4-byte length + 1-byte tag)
* into `request` and return the next write offset (5).
* The length field is the total buffer length minus the 4-byte length field itself.
*/
const writeHeader = function writeHeader(request: Buffer, tag: number): number {
request.writeUInt32BE(request.length - 4, 0)
request.writeUInt8(tag, 4)
return 5
}

const sshEcCurveParam = (curve: string): { crv: string; coordLen: number } => {
if (curve === 'nistp256') return { crv: 'P-256', coordLen: 32 }
if (curve === 'nistp384') return { crv: 'P-384', coordLen: 48 }
if (curve === 'nistp521') return { crv: 'P-521', coordLen: 66 }
throw new Error(`Unsupported EC curve: ${curve}`)
}

const ecdsaHashAlgo = (sigType: string): string => {
if (sigType === 'ecdsa-sha2-nistp256') return 'SHA256'
if (sigType === 'ecdsa-sha2-nistp384') return 'SHA384'
if (sigType === 'ecdsa-sha2-nistp521') return 'SHA512'
throw new Error(`Unsupported ECDSA signature type: ${sigType}`)
}

/** Convert an SSH public key blob to a Node.js `crypto.KeyObject`. */
const parseSSHPublicKey = (key: SSHKey): crypto.KeyObject => {
const blob = key.raw
const type = readString(blob, 0)
const keyType = type.toString('ascii')

if (keyType === 'ssh-rsa') {
const rsaOffset = 4 + type.length
const exponent = readString(blob, rsaOffset)
const modulus = readString(blob, rsaOffset + 4 + exponent.length)
return crypto.createPublicKey({
// eslint-disable-next-line id-length
key: { kty: 'RSA', n: modulus.toString('base64url'), e: exponent.toString('base64url') },
format: 'jwk',
})
}

if (keyType === 'ssh-ed25519') {
const pubKeyBytes = readString(blob, 4 + type.length)
// SPKI DER encoding for Ed25519 (OID 1.3.101.112)
const spkiPrefix = Buffer.from('302a300506032b6570032100', 'hex')
return crypto.createPublicKey({
key: Buffer.concat([spkiPrefix, pubKeyBytes]),
format: 'der',
type: 'spki',
})
}

if (keyType.startsWith('ecdsa-sha2-')) {
const ecOffset = 4 + type.length
const curveName = readString(blob, ecOffset)
const point = readString(blob, ecOffset + 4 + curveName.length)
const { crv, coordLen } = sshEcCurveParam(curveName.toString('ascii'))
// Uncompressed EC point: 0x04 || x || y
const pointX = point.subarray(1, 1 + coordLen)
const pointY = point.subarray(1 + coordLen)
return crypto.createPublicKey({
// eslint-disable-next-line id-length
key: { kty: 'EC', crv, x: pointX.toString('base64url'), y: pointY.toString('base64url') },
format: 'jwk',
})
}

throw new Error(`Unsupported key type: ${keyType}`)
}

const encodeDerLength = (len: number): Buffer => {
if (len < 128) return Buffer.from([len])
if (len < 256) return Buffer.from([0x81, len])
return Buffer.from([0x82, Math.floor(len / 256), len % 256])
}

const encodeDerInt = (bytes: Buffer): Buffer => {
// Strip leading zeros, keeping at least one byte
let start = 0
while (start < bytes.length - 1 && bytes[start] === 0) start += 1
const trimmed = bytes.subarray(start)
// Prepend 0x00 if high bit is set to keep the DER INTEGER positive
const [firstByte] = trimmed
const content = firstByte >= 0x80 ? Buffer.concat([Buffer.from([0x00]), trimmed]) : trimmed
return Buffer.concat([Buffer.from([0x02]), encodeDerLength(content.length), content])
}

/** Map an SSH signature to the hash algorithm and signature bytes expected by `crypto.verify`. */
const parseSSHSignature = (signature: SSHSignature): { algorithm: string | null; raw: Buffer } => {
const { type, raw } = signature

if (type === 'rsa-sha2-256') return { algorithm: 'SHA256', raw }
if (type === 'rsa-sha2-512') return { algorithm: 'SHA512', raw }
if (type === 'ssh-rsa') return { algorithm: 'SHA1', raw }
if (type === 'ssh-ed25519') return { algorithm: null, raw }

if (type.startsWith('ecdsa-sha2-')) {
// SSH ECDSA signature: mpint(r) || mpint(s) → DER ASN.1 SEQUENCE { INTEGER r, INTEGER s }
const sigR = readString(raw, 0)
const sigS = readString(raw, 4 + sigR.length)
const rDer = encodeDerInt(sigR)
const sDer = encodeDerInt(sigS)
const seqContent = Buffer.concat([rDer, sDer])
const rawECDSA = Buffer.concat([Buffer.from([0x30]), encodeDerLength(seqContent.length), seqContent])
return { algorithm: ecdsaHashAlgo(type), raw: rawECDSA }
}

throw new Error(`Unsupported signature type: ${type}`)
}

export { readString, writeString, writeHeader, parseSSHPublicKey, parseSSHSignature }
42 changes: 18 additions & 24 deletions src/lib/ssh_agent_client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as crypto from 'node:crypto'
import { parseSSHPublicKey, parseSSHSignature, readString, writeHeader, writeString } from './parse_utils.ts'
import { createConnection } from 'node:net'
import { DecryptTransform } from './decrypt_transform.ts'
import { EncryptTransform } from './encrypt_transform.ts'
Expand Down Expand Up @@ -52,30 +53,6 @@ export interface SSHAgentClientOptions {
rsaSignatureFlag?: RsaSignatureFlag
}

/** Read a length-prefixed string (uint32 BE length + bytes) from a buffer. */
const readString = function readString(buffer: Buffer, offset: number): Buffer {
const len = buffer.readUInt32BE(offset)
return buffer.subarray(offset + 4, offset + 4 + len)
}

/** Write a length-prefixed string into `target` at `offset`, return next offset. */
const writeString = function writeString(target: Buffer, src: Buffer, offset: number): number {
target.writeUInt32BE(src.length, offset)
src.copy(target, offset + 4)
return offset + 4 + src.length
}

/**
* Write the 5-byte SSH agent frame header (4-byte length + 1-byte tag)
* into `request` and return the next write offset (5).
* The length field is the total buffer length minus the 4-byte length field itself.
*/
const writeHeader = function writeHeader(request: Buffer, tag: number): number {
request.writeUInt32BE(request.length - 4, 0)
request.writeUInt8(tag, 4)
return 5
}

export class SSHAgentClient {
private readonly timeout: number
private readonly sockFile: string
Expand Down Expand Up @@ -193,6 +170,23 @@ export class SSHAgentClient {
return this.request(buildRequest, parseResponse, Protocol.SSH2_AGENT_SIGN_RESPONSE)
}

/**
* Verify an SSH signature against a message and public key.
*
* No SSH agent communication is required — this is a local crypto operation.
*
* @param signature - The signature to verify (from {@link sign}).
* @param key - The SSH public key (from {@link getIdentities} or {@link getIdentity}).
* @param data - The original message that was signed.
* @returns `true` if the signature is valid, `false` otherwise.
* @throws {Error} if the key type or signature type is unsupported.
*/
static verify(signature: SSHSignature, key: SSHKey, data: Buffer): boolean {
const publicKey = parseSSHPublicKey(key)
const { algorithm, raw } = parseSSHSignature(signature)
return crypto.verify(algorithm, data, publicKey, raw)
}

/**
* Encrypt data with given `SSHKey` and `seed` string, using SSH signature as the encryption key.
*
Expand Down
11 changes: 6 additions & 5 deletions test/ssh_agent_client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ chai.use(chaiAsPromised)

const DECODED_STRING = 'Lorem ipsum dolor'
const DECODED_STRING_BUFFER = Buffer.from(DECODED_STRING, 'utf8')
const DATA = Buffer.from('hello', 'utf8')
const SEED = 'not_a_secret'

describe('SSHAgentClient tests', () => {
Expand All @@ -31,7 +32,7 @@ describe('SSHAgentClient tests', () => {
it('should find identites', async () => {
const agent = new SSHAgentClient()
const identities = await agent.getIdentities()
chai.assert.strictEqual(identities.length, 3)
chai.assert.strictEqual(identities.length, 5)
const identity = identities.find(id => id.type === 'ssh-rsa')
if (!identity) {
throw new Error()
Expand All @@ -49,12 +50,12 @@ describe('SSHAgentClient tests', () => {
})
it('should find identity with selector in comment', async () => {
const agent = new SSHAgentClient()
const identity = await agent.getIdentity('key_ecdsa')
const identity = await agent.getIdentity('key_ecdsa_256')
if (!identity) {
throw new Error()
}
chai.assert.strictEqual(identity.type, 'ecdsa-sha2-nistp256')
chai.assert.strictEqual(identity.comment, 'key_ecdsa')
chai.assert.strictEqual(identity.comment, 'key_ecdsa_256')
})
it('should find identity with selector in pubkey', async () => {
const agent = new SSHAgentClient()
Expand All @@ -71,7 +72,7 @@ describe('SSHAgentClient tests', () => {
if (!identity) {
throw new Error()
}
const signature = await agent.sign(identity, Buffer.from('hello', 'utf8'))
const signature = await agent.sign(identity, DATA)
chai.assert.strictEqual(signature.type, 'ssh-rsa')
chai.assert.strictEqual(
signature.signature,
Expand All @@ -84,7 +85,7 @@ describe('SSHAgentClient tests', () => {
if (!identity) {
throw new Error()
}
const signature = await agent.sign(identity, Buffer.from('hello', 'utf8'))
const signature = await agent.sign(identity, DATA)
chai.assert.strictEqual(signature.type, 'rsa-sha2-256')
chai.assert.strictEqual(
signature.signature,
Expand Down
6 changes: 3 additions & 3 deletions test/ssh_agent_mandatory_key_type.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ describe('SSH key type tests', () => {
})
it("doesn't give the same signature twice with an ECDSA key", async () => {
const agent = new SSHAgentClient()
const identity = await agent.getIdentity('key_ecdsa')
const identity = await agent.getIdentity('key_ecdsa_256')
if (!identity) {
throw new Error()
}
Expand All @@ -41,7 +41,7 @@ describe('SSH key type tests', () => {
})
it('should throw if using ECDSA key for encrypting', async () => {
const agent = new SSHAgentClient()
const identity = await agent.getIdentity('key_ecdsa')
const identity = await agent.getIdentity('key_ecdsa_256')
if (!identity) {
throw new Error()
}
Expand All @@ -54,7 +54,7 @@ describe('SSH key type tests', () => {
})
it('should throw if using ECDSA key for decrypting', async () => {
const agent = new SSHAgentClient()
const identity = await agent.getIdentity('key_ecdsa')
const identity = await agent.getIdentity('key_ecdsa_256')
if (!identity) {
throw new Error()
}
Expand Down
Loading
Loading