Skip to content

Commit 08c83e9

Browse files
committed
add verify SSH signature
1 parent 8df1f21 commit 08c83e9

File tree

8 files changed

+326
-35
lines changed

8 files changed

+326
-35
lines changed

.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.*

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,13 @@ A seed is used to generate the secret, it's recommended you don't use the same s
2626
- 🔨 Node library included to decrypt secrets on-the-fly in your code
2727
- 📦 Safe to store encrypted secrets in Git
2828
- `node:stream` compatible
29+
- `sign` message / `verify` signature
2930
- 👥 Works with existing SSH agent workflows like [1Password](https://developer.1password.com/docs/ssh/agent/) or [Bitwarden](https://bitwarden.com/help/ssh-agent/)
3031

3132
## ⚠️ Limitations
3233

3334
- Can't use ECDSA keys, they always give different signatures
34-
- [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.
35+
- [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.
3536

3637
## 💻 CLI usage
3738

src/lib/parse_utils.ts

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

src/lib/ssh_agent_client.ts

Lines changed: 18 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as crypto from 'node:crypto'
2+
import { parseSSHPublicKey, parseSSHSignature, readString, writeHeader, writeString } from './parse_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
@@ -193,6 +170,23 @@ export class SSHAgentClient {
193170
return this.request(buildRequest, parseResponse, Protocol.SSH2_AGENT_SIGN_RESPONSE)
194171
}
195172

173+
/**
174+
* Verify an SSH signature against a message and public key.
175+
*
176+
* No SSH agent communication is required — this is a local crypto operation.
177+
*
178+
* @param signature - The signature to verify (from {@link sign}).
179+
* @param key - The SSH public key (from {@link getIdentities} or {@link getIdentity}).
180+
* @param data - The original message that was signed.
181+
* @returns `true` if the signature is valid, `false` otherwise.
182+
* @throws {Error} if the key type or signature type is unsupported.
183+
*/
184+
static verify(signature: SSHSignature, key: SSHKey, data: Buffer): boolean {
185+
const publicKey = parseSSHPublicKey(key)
186+
const { algorithm, raw } = parseSSHSignature(signature)
187+
return crypto.verify(algorithm, data, publicKey, raw)
188+
}
189+
196190
/**
197191
* Encrypt data with given `SSHKey` and `seed` string, using SSH signature as the encryption key.
198192
*

test/ssh_agent_client.spec.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ chai.use(chaiAsPromised)
77

88
const DECODED_STRING = 'Lorem ipsum dolor'
99
const DECODED_STRING_BUFFER = Buffer.from(DECODED_STRING, 'utf8')
10+
const DATA = Buffer.from('hello', 'utf8')
1011
const SEED = 'not_a_secret'
1112

1213
describe('SSHAgentClient tests', () => {
@@ -31,7 +32,7 @@ describe('SSHAgentClient tests', () => {
3132
it('should find identites', async () => {
3233
const agent = new SSHAgentClient()
3334
const identities = await agent.getIdentities()
34-
chai.assert.strictEqual(identities.length, 3)
35+
chai.assert.strictEqual(identities.length, 5)
3536
const identity = identities.find(id => id.type === 'ssh-rsa')
3637
if (!identity) {
3738
throw new Error()
@@ -49,12 +50,12 @@ describe('SSHAgentClient tests', () => {
4950
})
5051
it('should find identity with selector in comment', async () => {
5152
const agent = new SSHAgentClient()
52-
const identity = await agent.getIdentity('key_ecdsa')
53+
const identity = await agent.getIdentity('key_ecdsa_256')
5354
if (!identity) {
5455
throw new Error()
5556
}
5657
chai.assert.strictEqual(identity.type, 'ecdsa-sha2-nistp256')
57-
chai.assert.strictEqual(identity.comment, 'key_ecdsa')
58+
chai.assert.strictEqual(identity.comment, 'key_ecdsa_256')
5859
})
5960
it('should find identity with selector in pubkey', async () => {
6061
const agent = new SSHAgentClient()
@@ -71,7 +72,7 @@ describe('SSHAgentClient tests', () => {
7172
if (!identity) {
7273
throw new Error()
7374
}
74-
const signature = await agent.sign(identity, Buffer.from('hello', 'utf8'))
75+
const signature = await agent.sign(identity, DATA)
7576
chai.assert.strictEqual(signature.type, 'ssh-rsa')
7677
chai.assert.strictEqual(
7778
signature.signature,
@@ -84,7 +85,7 @@ describe('SSHAgentClient tests', () => {
8485
if (!identity) {
8586
throw new Error()
8687
}
87-
const signature = await agent.sign(identity, Buffer.from('hello', 'utf8'))
88+
const signature = await agent.sign(identity, DATA)
8889
chai.assert.strictEqual(signature.type, 'rsa-sha2-256')
8990
chai.assert.strictEqual(
9091
signature.signature,

test/ssh_agent_mandatory_key_type.spec.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ describe('SSH key type tests', () => {
3030
})
3131
it("doesn't give the same signature twice with an ECDSA key", async () => {
3232
const agent = new SSHAgentClient()
33-
const identity = await agent.getIdentity('key_ecdsa')
33+
const identity = await agent.getIdentity('key_ecdsa_256')
3434
if (!identity) {
3535
throw new Error()
3636
}
@@ -41,7 +41,7 @@ describe('SSH key type tests', () => {
4141
})
4242
it('should throw if using ECDSA key for encrypting', async () => {
4343
const agent = new SSHAgentClient()
44-
const identity = await agent.getIdentity('key_ecdsa')
44+
const identity = await agent.getIdentity('key_ecdsa_256')
4545
if (!identity) {
4646
throw new Error()
4747
}
@@ -54,7 +54,7 @@ describe('SSH key type tests', () => {
5454
})
5555
it('should throw if using ECDSA key for decrypting', async () => {
5656
const agent = new SSHAgentClient()
57-
const identity = await agent.getIdentity('key_ecdsa')
57+
const identity = await agent.getIdentity('key_ecdsa_256')
5858
if (!identity) {
5959
throw new Error()
6060
}

0 commit comments

Comments
 (0)