Skip to content

Commit e6e57f4

Browse files
committed
add verify SSH signature
1 parent 553720f commit e6e57f4

7 files changed

Lines changed: 293 additions & 28 deletions

File tree

.github/workflows/main.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ jobs:
1919
with:
2020
ssh-private-key: |
2121
${{ secrets.RSA_KEY }}
22-
${{ secrets.ECDSA_KEY }}
22+
${{ secrets.ECDSA_256_KEY }}
23+
${{ secrets.ECDSA_384_KEY }}
24+
${{ secrets.ECDSA_521_KEY }}
2325
${{ secrets.ED25519_KEY }}
2426
- name: Use Node.js ${{ matrix.node-version }}
2527
uses: actions/setup-node@v6

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ mise.toml
66
/*.tgz
77
/.nyc_output
88
/coverage
9-
/id_*
9+
/id_*
10+
*.local.*

src/lib/ssh_agent_client.ts

Lines changed: 1 addition & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as crypto from 'node:crypto'
2+
import { readString, writeHeader, writeString } from './utils.ts'
23
import { createConnection } from 'node:net'
34
import { DecryptTransform } from './decrypt_transform.ts'
45
import { EncryptTransform } from './encrypt_transform.ts'
@@ -52,30 +53,6 @@ export interface SSHAgentClientOptions {
5253
rsaSignatureFlag?: RsaSignatureFlag
5354
}
5455

55-
/** Read a length-prefixed string (uint32 BE length + bytes) from a buffer. */
56-
const readString = function readString(buffer: Buffer, offset: number): Buffer {
57-
const len = buffer.readUInt32BE(offset)
58-
return buffer.subarray(offset + 4, offset + 4 + len)
59-
}
60-
61-
/** Write a length-prefixed string into `target` at `offset`, return next offset. */
62-
const writeString = function writeString(target: Buffer, src: Buffer, offset: number): number {
63-
target.writeUInt32BE(src.length, offset)
64-
src.copy(target, offset + 4)
65-
return offset + 4 + src.length
66-
}
67-
68-
/**
69-
* Write the 5-byte SSH agent frame header (4-byte length + 1-byte tag)
70-
* into `request` and return the next write offset (5).
71-
* The length field is the total buffer length minus the 4-byte length field itself.
72-
*/
73-
const writeHeader = function writeHeader(request: Buffer, tag: number): number {
74-
request.writeUInt32BE(request.length - 4, 0)
75-
request.writeUInt8(tag, 4)
76-
return 5
77-
}
78-
7956
export class SSHAgentClient {
8057
private readonly timeout: number
8158
private readonly sockFile: string

src/lib/utils.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/** Read a length-prefixed string (uint32 BE length + bytes) from a buffer. */
2+
const readString = function readString(buffer: Buffer, offset: number): Buffer {
3+
const len = buffer.readUInt32BE(offset)
4+
return buffer.subarray(offset + 4, offset + 4 + len)
5+
}
6+
7+
/** Write a length-prefixed string into `target` at `offset`, return next offset. */
8+
const writeString = function writeString(target: Buffer, src: Buffer, offset: number): number {
9+
target.writeUInt32BE(src.length, offset)
10+
src.copy(target, offset + 4)
11+
return offset + 4 + src.length
12+
}
13+
14+
/**
15+
* Write the 5-byte SSH agent frame header (4-byte length + 1-byte tag)
16+
* into `request` and return the next write offset (5).
17+
* The length field is the total buffer length minus the 4-byte length field itself.
18+
*/
19+
const writeHeader = function writeHeader(request: Buffer, tag: number): number {
20+
request.writeUInt32BE(request.length - 4, 0)
21+
request.writeUInt8(tag, 4)
22+
return 5
23+
}
24+
25+
export { readString, writeString, writeHeader }

src/lib/verify_signature.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/* eslint-disable id-length */
2+
3+
import * as crypto from 'crypto'
4+
import { type SSHKey, type SSHSignature } from './ssh_agent_client.ts'
5+
import { readString } from './utils.ts'
6+
7+
const sshEcCurveParam = (curve: string): { crv: string; coordLen: number } => {
8+
if (curve === 'nistp256') return { crv: 'P-256', coordLen: 32 }
9+
if (curve === 'nistp384') return { crv: 'P-384', coordLen: 48 }
10+
if (curve === 'nistp521') return { crv: 'P-521', coordLen: 66 }
11+
throw new Error(`Unsupported EC curve: ${curve}`)
12+
}
13+
14+
const ecdsaHashAlgo = (sigType: string): string => {
15+
if (sigType === 'ecdsa-sha2-nistp256') return 'SHA256'
16+
if (sigType === 'ecdsa-sha2-nistp384') return 'SHA384'
17+
if (sigType === 'ecdsa-sha2-nistp521') return 'SHA512'
18+
throw new Error(`Unsupported ECDSA signature type: ${sigType}`)
19+
}
20+
21+
/** Convert an SSH public key blob to a Node.js `crypto.KeyObject`. */
22+
const parseSSHPublicKey = (key: SSHKey): crypto.KeyObject => {
23+
const blob = key.raw
24+
const type = readString(blob, 0)
25+
const keyType = type.toString('ascii')
26+
27+
if (keyType === 'ssh-rsa') {
28+
const rsaOffset = 4 + type.length
29+
const exponent = readString(blob, rsaOffset)
30+
const modulus = readString(blob, rsaOffset + 4 + exponent.length)
31+
return crypto.createPublicKey({
32+
key: { kty: 'RSA', n: modulus.toString('base64url'), e: exponent.toString('base64url') },
33+
format: 'jwk',
34+
})
35+
}
36+
37+
if (keyType === 'ssh-ed25519') {
38+
const pubKeyBytes = readString(blob, 4 + type.length)
39+
// SPKI DER encoding for Ed25519 (OID 1.3.101.112)
40+
const spkiPrefix = Buffer.from('302a300506032b6570032100', 'hex')
41+
return crypto.createPublicKey({
42+
key: Buffer.concat([spkiPrefix, pubKeyBytes]),
43+
format: 'der',
44+
type: 'spki',
45+
})
46+
}
47+
48+
if (keyType.startsWith('ecdsa-sha2-')) {
49+
const ecOffset = 4 + type.length
50+
const curveName = readString(blob, ecOffset)
51+
const point = readString(blob, ecOffset + 4 + curveName.length)
52+
const { crv, coordLen } = sshEcCurveParam(curveName.toString('ascii'))
53+
// Uncompressed EC point: 0x04 || x || y
54+
const pointX = point.subarray(1, 1 + coordLen)
55+
const pointY = point.subarray(1 + coordLen)
56+
return crypto.createPublicKey({
57+
key: { kty: 'EC', crv, x: pointX.toString('base64url'), y: pointY.toString('base64url') },
58+
format: 'jwk',
59+
})
60+
}
61+
62+
throw new Error(`Unsupported key type: ${keyType}`)
63+
}
64+
65+
const encodeDerLength = (len: number): Buffer => {
66+
if (len < 128) return Buffer.from([len])
67+
if (len < 256) return Buffer.from([0x81, len])
68+
return Buffer.from([0x82, Math.floor(len / 256), len % 256])
69+
}
70+
71+
const encodeDerInt = (bytes: Buffer): Buffer => {
72+
// Strip leading zeros, keeping at least one byte
73+
let start = 0
74+
while (start < bytes.length - 1 && bytes[start] === 0) start += 1
75+
const trimmed = bytes.subarray(start)
76+
// Prepend 0x00 if high bit is set to keep the DER INTEGER positive
77+
const [firstByte] = trimmed
78+
const content = firstByte >= 0x80 ? Buffer.concat([Buffer.from([0x00]), trimmed]) : trimmed
79+
return Buffer.concat([Buffer.from([0x02]), encodeDerLength(content.length), content])
80+
}
81+
82+
/** Map an SSH signature to the hash algorithm and signature bytes expected by `crypto.verify`. */
83+
const parseSSHSignature = (sig: SSHSignature): { hashAlgo: string | null; sigBytes: Buffer } => {
84+
const { type, raw } = sig
85+
86+
if (type === 'rsa-sha2-256') return { hashAlgo: 'SHA256', sigBytes: raw }
87+
if (type === 'rsa-sha2-512') return { hashAlgo: 'SHA512', sigBytes: raw }
88+
if (type === 'ssh-rsa') return { hashAlgo: 'SHA1', sigBytes: raw }
89+
if (type === 'ssh-ed25519') return { hashAlgo: null, sigBytes: raw }
90+
91+
if (type.startsWith('ecdsa-sha2-')) {
92+
// SSH ECDSA signature: mpint(r) || mpint(s) → DER ASN.1 SEQUENCE { INTEGER r, INTEGER s }
93+
const sigR = readString(raw, 0)
94+
const sigS = readString(raw, 4 + sigR.length)
95+
const rDer = encodeDerInt(sigR)
96+
const sDer = encodeDerInt(sigS)
97+
const seqContent = Buffer.concat([rDer, sDer])
98+
const sigBytes = Buffer.concat([Buffer.from([0x30]), encodeDerLength(seqContent.length), seqContent])
99+
return { hashAlgo: ecdsaHashAlgo(type), sigBytes }
100+
}
101+
102+
throw new Error(`Unsupported signature type: ${type}`)
103+
}
104+
105+
/**
106+
* Verify an SSH signature against a message and public key.
107+
*
108+
* No SSH agent communication is required — this is a local crypto operation.
109+
*
110+
* @param signature - The signature to verify (from {@link sign}).
111+
* @param key - The SSH public key (from {@link getIdentities} or {@link getIdentity}).
112+
* @param data - The original message that was signed.
113+
* @returns `true` if the signature is valid, `false` otherwise.
114+
* @throws {Error} if the key type or signature type is unsupported.
115+
*/
116+
const verifySSHSignature = (signature: SSHSignature, key: SSHKey, data: Buffer): boolean => {
117+
const publicKey = parseSSHPublicKey(key)
118+
const { hashAlgo, sigBytes } = parseSSHSignature(signature)
119+
return crypto.verify(hashAlgo, data, publicKey, sigBytes)
120+
}
121+
122+
export { parseSSHPublicKey, parseSSHSignature, verifySSHSignature }

test/ssh_agent_client.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ describe('SSHAgentClient tests', () => {
3131
it('should find identites', async () => {
3232
const agent = new SSHAgentClient()
3333
const identities = await agent.getIdentities()
34-
chai.assert.strictEqual(identities.length, 3)
34+
chai.assert.strictEqual(identities.length, 5)
3535
const identity = identities.find(id => id.type === 'ssh-rsa')
3636
if (!identity) {
3737
throw new Error()
@@ -54,7 +54,7 @@ describe('SSHAgentClient tests', () => {
5454
throw new Error()
5555
}
5656
chai.assert.strictEqual(identity.type, 'ecdsa-sha2-nistp256')
57-
chai.assert.strictEqual(identity.comment, 'key_ecdsa')
57+
chai.assert.strictEqual(identity.comment, 'key_ecdsa_256')
5858
})
5959
it('should find identity with selector in pubkey', async () => {
6060
const agent = new SSHAgentClient()

test/ssh_agent_verify.spec.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import * as chai from 'chai'
2+
import { describe, it } from 'mocha'
3+
import {
4+
RsaSignatureFlag,
5+
SSHAgentClient,
6+
type SSHKey,
7+
type SSHSignature,
8+
} from '../src/lib/ssh_agent_client.ts'
9+
import { verifySSHSignature } from '../src/lib/verify_signature.ts'
10+
11+
const DATA = Buffer.from('hello', 'utf8')
12+
13+
describe('SSHAgentClient verify tests', () => {
14+
it('should verify RSA (ssh-rsa) signature', async () => {
15+
const agent = new SSHAgentClient({ rsaSignatureFlag: RsaSignatureFlag.DEFAULT })
16+
const identity = await agent.getIdentity('key_rsa')
17+
if (!identity) {
18+
throw new Error()
19+
}
20+
const signature = await agent.sign(identity, DATA)
21+
chai.assert.isTrue(verifySSHSignature(signature, identity, DATA))
22+
})
23+
24+
it('should verify RSA (rsa-sha2-256) signature', async () => {
25+
const agent = new SSHAgentClient({ rsaSignatureFlag: RsaSignatureFlag.SSH_AGENT_RSA_SHA2_256 })
26+
const identity = await agent.getIdentity('key_rsa')
27+
if (!identity) {
28+
throw new Error()
29+
}
30+
const signature = await agent.sign(identity, DATA)
31+
chai.assert.isTrue(verifySSHSignature(signature, identity, DATA))
32+
})
33+
34+
it('should verify RSA (rsa-sha2-512) signature', async () => {
35+
const agent = new SSHAgentClient()
36+
const identity = await agent.getIdentity('key_rsa')
37+
if (!identity) {
38+
throw new Error()
39+
}
40+
const signature = await agent.sign(identity, DATA)
41+
chai.assert.isTrue(verifySSHSignature(signature, identity, DATA))
42+
})
43+
44+
it('should verify Ed25519 signature', async () => {
45+
const agent = new SSHAgentClient()
46+
const identity = await agent.getIdentity('key_ed25519')
47+
if (!identity) {
48+
throw new Error()
49+
}
50+
const signature = await agent.sign(identity, DATA)
51+
chai.assert.isTrue(verifySSHSignature(signature, identity, DATA))
52+
})
53+
54+
it('should verify ECDSA 256 signature', async () => {
55+
const agent = new SSHAgentClient()
56+
const identity = await agent.getIdentity('key_ecdsa_256')
57+
if (!identity) {
58+
throw new Error()
59+
}
60+
const signature = await agent.sign(identity, DATA)
61+
chai.assert.isTrue(verifySSHSignature(signature, identity, DATA))
62+
})
63+
64+
it('should verify ECDSA 384 signature', async () => {
65+
const agent = new SSHAgentClient()
66+
const identity = await agent.getIdentity('key_ecdsa_384')
67+
if (!identity) {
68+
throw new Error()
69+
}
70+
const signature = await agent.sign(identity, DATA)
71+
chai.assert.isTrue(verifySSHSignature(signature, identity, DATA))
72+
})
73+
74+
it('should verify ECDSA 521 signature', async () => {
75+
const agent = new SSHAgentClient()
76+
const identity = await agent.getIdentity('key_ecdsa_521')
77+
if (!identity) {
78+
throw new Error()
79+
}
80+
const signature = await agent.sign(identity, DATA)
81+
chai.assert.isTrue(verifySSHSignature(signature, identity, DATA))
82+
})
83+
84+
it('should return false for wrong data', async () => {
85+
const agent = new SSHAgentClient()
86+
const identity = await agent.getIdentity('key_ed25519')
87+
if (!identity) {
88+
throw new Error()
89+
}
90+
const signature = await agent.sign(identity, DATA)
91+
chai.assert.isFalse(verifySSHSignature(signature, identity, Buffer.from('other', 'utf8')))
92+
})
93+
94+
it('should return false for corrupted RSA signature', async () => {
95+
const agent = new SSHAgentClient()
96+
const identity = await agent.getIdentity('key_rsa')
97+
if (!identity) {
98+
throw new Error()
99+
}
100+
const signature = await agent.sign(identity, DATA)
101+
const corruptedRaw = Buffer.alloc(signature.raw.length, 0)
102+
const corruptedSig: SSHSignature = {
103+
type: signature.type,
104+
raw: corruptedRaw,
105+
signature: corruptedRaw.toString('base64'),
106+
}
107+
chai.assert.isFalse(verifySSHSignature(corruptedSig, identity, DATA))
108+
})
109+
110+
it('should throw for unsupported key type', () => {
111+
const keyTypeName = 'unsupported-type'
112+
const keyTypeBytes = Buffer.from(keyTypeName, 'ascii')
113+
const keyTypeLenBuf = Buffer.alloc(4)
114+
keyTypeLenBuf.writeUInt32BE(keyTypeBytes.length, 0)
115+
const fakeKey: SSHKey = {
116+
type: keyTypeName,
117+
key: '',
118+
comment: '',
119+
raw: Buffer.concat([keyTypeLenBuf, keyTypeBytes]),
120+
}
121+
const fakeSig: SSHSignature = { type: 'ssh-rsa', signature: '', raw: Buffer.alloc(4) }
122+
chai
123+
.expect(() => verifySSHSignature(fakeSig, fakeKey, DATA))
124+
.to.throw('Unsupported key type: unsupported-type')
125+
})
126+
127+
it('should throw for unsupported signature type', async () => {
128+
const agent = new SSHAgentClient()
129+
const identity = await agent.getIdentity('key_rsa')
130+
if (!identity) {
131+
throw new Error()
132+
}
133+
const fakeSig: SSHSignature = { type: 'unsupported-sig', signature: '', raw: Buffer.alloc(4) }
134+
chai
135+
.expect(() => verifySSHSignature(fakeSig, identity, DATA))
136+
.to.throw('Unsupported signature type: unsupported-sig')
137+
})
138+
})

0 commit comments

Comments
 (0)