diff --git a/README.md b/README.md index c0145c3..a5d86f9 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ A seed is used to generate the secret, it's recommended you don't use the same s ## ⚠️ 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. ## 💻 CLI usage @@ -93,3 +94,10 @@ const decrypted = await agent.decrypt( ) console.log('Decrypted data:', decrypted.toString('utf8')) ``` + +## Local test + +```bash +ssh-agent -D +SSH_AUTH_SOCK= ssh-add id_ecdsa id_ed25519 id_rsa +``` diff --git a/src/lib/ssh_agent_client.ts b/src/lib/ssh_agent_client.ts index 14b8593..030a23d 100644 --- a/src/lib/ssh_agent_client.ts +++ b/src/lib/ssh_agent_client.ts @@ -15,6 +15,14 @@ const Protocol = { type Protocol = (typeof Protocol)[keyof typeof Protocol] +export const RsaSignatureFlag = { + DEFAULT: 0, + SSH_AGENT_RSA_SHA2_256: 2, + SSH_AGENT_RSA_SHA2_512: 4, +} as const + +type RsaSignatureFlag = (typeof RsaSignatureFlag)[keyof typeof RsaSignatureFlag] + export interface SSHKey { /** E.g. "ssh-rsa" */ type: string @@ -40,6 +48,8 @@ export interface SSHAgentClientOptions { sockFile?: string cipherAlgo?: string digestAlgo?: string + /** RSA signature method flag for signing request when using RSA keys */ + rsaSignatureFlag?: RsaSignatureFlag } /** Read a length-prefixed string (uint32 BE length + bytes) from a buffer. */ @@ -71,6 +81,7 @@ export class SSHAgentClient { private readonly sockFile: string private readonly cipherAlgo: string private readonly digestAlgo: string + private readonly rsaSignatureFlag: RsaSignatureFlag /** * @param options - Optional configuration. @@ -84,6 +95,8 @@ export class SSHAgentClient { this.cipherAlgo = options.cipherAlgo ?? 'aes-256-cbc' this.digestAlgo = options.digestAlgo ?? 'sha256' + this.rsaSignatureFlag = options.rsaSignatureFlag ?? RsaSignatureFlag.SSH_AGENT_RSA_SHA2_512 + const sockFile = options.sockFile ?? process.env.SSH_AUTH_SOCK if (!sockFile || !existsSync(sockFile)) { throw new Error(`Socket ${sockFile ?? '?'} not found`) @@ -161,7 +174,7 @@ export class SSHAgentClient { let offset = writeHeader(req, Protocol.SSH2_AGENTC_SIGN_REQUEST) offset = writeString(req, key.raw, offset) offset = writeString(req, data, offset) - req.writeUInt32BE(0, offset) // Flags = 0 + req.writeUInt32BE(this.rsaSignatureFlag, offset) return req } diff --git a/test/ssh_agent_cli.spec.ts b/test/ssh_agent_cli.spec.ts index 3aa053b..a4b0024 100644 --- a/test/ssh_agent_cli.spec.ts +++ b/test/ssh_agent_cli.spec.ts @@ -42,9 +42,9 @@ describe('ssh-crypt cli tests', () => { }) it('should decrypt', () => { const data = - 'ecfd6bb57f4891ba7226886e90d2eb848022a495b15ffd91ffe760bca5605f9062c305ee14226d9daf7faa58460c8f50' + '5f1979820d75926171e7028d5938f64ba5872683334f21b14947df1a4cce1f9ff1bb7c9c91e28e49aa8807b02c18c48c' const output = execSync( - 'npm exec -- tsx src/cli.ts -k key_rsa -s not_a_secret --decryptEncoding hex decrypt', + 'npm exec -- tsx src/cli.ts -k key_ed25519 -s not_a_secret --decryptEncoding hex decrypt', { encoding: 'utf8', input: data, diff --git a/test/ssh_agent_client.spec.ts b/test/ssh_agent_client.spec.ts index d1f1ac2..7b93549 100644 --- a/test/ssh_agent_client.spec.ts +++ b/test/ssh_agent_client.spec.ts @@ -1,7 +1,7 @@ import * as chai from 'chai' import { describe, it } from 'mocha' +import { RsaSignatureFlag, SSHAgentClient } from '../src/lib/ssh_agent_client.ts' import chaiAsPromised from 'chai-as-promised' -import { SSHAgentClient } from '../src/lib/ssh_agent_client.ts' chai.use(chaiAsPromised) @@ -65,8 +65,8 @@ describe('SSHAgentClient tests', () => { chai.assert.strictEqual(identity.type, 'ssh-rsa') chai.assert.strictEqual(identity.comment, 'key_rsa') }) - it('should sign', async () => { - const agent = new SSHAgentClient() + it('should sign with key_rsa and default ssh-rsa signature', async () => { + const agent = new SSHAgentClient({ rsaSignatureFlag: RsaSignatureFlag.DEFAULT }) const identity = await agent.getIdentity('key_rsa') if (!identity) { throw new Error() @@ -78,6 +78,55 @@ describe('SSHAgentClient tests', () => { '2Ox3c3wtOkmZwINZ0nSi31NP2ysWnERdaLWMcY/CONPcLK3DPBfEolG307yMdmYAsJY5VW/sgw0ye58zOu5daos2xtWxYTHUvY7peDMcJWgQJ5YFBQWGY6ku++tqR31FSlLl9KJMUnXGdE88T1RFbaWOsg8U37IsMd5juxCeakgmcvo4PXOcWlRKBnQKRaxoOl+lQxcBNb3GM2T/kqnKkae5NebaUMOI+v7U95tzNPq0xLrZ0805rNETEcLdMszi6XS8Gbh4iDZNZGV7sA5hh9rB/avhKQHYplJ9YzyLfLEX3S8bZf41xIynt62PXeEUuM5UcYRj1lUC6quGC59Z4P2pBujjvJqj9gmKjwVcwnVo28J9OEugmRnO2QmamR0LIJIhIJmdmRRFqD86vkfyYBz733KRkvA80gXvbvligTI5LZwpUoTX7YWGkpz8fCCrMJ4WRyu9nerp9EGk4afhlAb92wMSqQV3QmXI/uRjHkbs+8rfeZS6i77Xgj4AYkHp', ) }) + it('should sign with key_rsa and SHA2-256 signature', async () => { + const agent = new SSHAgentClient({ rsaSignatureFlag: RsaSignatureFlag.SSH_AGENT_RSA_SHA2_256 }) + const identity = await agent.getIdentity('key_rsa') + if (!identity) { + throw new Error() + } + const signature = await agent.sign(identity, Buffer.from('hello', 'utf8')) + chai.assert.strictEqual(signature.type, 'rsa-sha2-256') + chai.assert.strictEqual( + signature.signature, + 'InYgWb2UN3RCyQwKMWNSjljkzmMQ+6D+gwol2PfSrYaXejyKIEFH2ZwzueT0bTD8CEi0l51BZ6Lx5Pdi8JLTgdk3fneJQPayBkzq+QUINNeK8qLICYol36T2Huy2oS1TrqVvlhRZQSvJB8En6jRbsgo9qEoK4GtitiDYqNIdsG3mIEpmP8M+gA/iis6PHAxlT2cHPF8gQu43KXorZ1txvkOnahJ0LAfjB5axz+NvUkEgSbXbK3l4REFm3+TNXq/El9yqnC+5NW09v+m0dctW/YBY0eGpYZoO8pl/oXJ/47M8SOmDZjrmmAwG2zPzTq6Pctv3glVROY8sndcy32VpJs1DEEbk3MGgj2R2HkvCX6PPuhPDpMdcakpfSbOFvRDuYityeijJLA5d6v7EaWBTeH4nX10Dq4O+5CCsdb1dOhzS5gay4aedy2TwU8zpUF0GqzJwxLq7w26nokZLOoLmNFbkGsRXQqVfTPUN5aNNZqMNIdeXcjmvAb7fmcz6Gxdv', + ) + }) + it('should sign with key_rsa and SHA2-512 signature', async () => { + const agent = new SSHAgentClient({ rsaSignatureFlag: RsaSignatureFlag.SSH_AGENT_RSA_SHA2_512 }) + const identity = await agent.getIdentity('key_rsa') + if (!identity) { + throw new Error() + } + const signature = await agent.sign(identity, Buffer.from('hello', 'utf8')) + chai.assert.strictEqual(signature.type, 'rsa-sha2-512') + chai.assert.strictEqual( + signature.signature, + 'PEOu4DK+GXsdgd84PEEAFzQgsSz6kgCGiVgf4d464mBGyXhebFR5HBZ4RPCKDIPAu6zFt7DgAYBDmBio0LdwqgAs561ytLO+pQ1UCS1nmzE8f9n8220vGp18PSIXzDPAwlbAk9tPv940kFWbQOr1GwxmyERWC0XOdMLvueeCx5alThYWOAKbjHLhMSAry9E0I02g44UkoFV0VAgrSff03t9Y31c+n5ogpa2bii02IFg3khycrzaYv+3B+aU9kew7MhFH9awkJLFbFuQbLtOiINwhkZRnTAMbrqZPqeYpKrhHr+D6gjNhYXqNhAfZKBJFUurVAkccmkFWttmAZJwCpoiDD+yrOTYj7s5iQq5M0YlrIv3N+RP7MyN9GhvWwlD/Ti5Mnc7EckAIBIrFAja8hdJqmNbeVKD2o5tqJopvz7vvGpOiUHAZyL9Gbd74W3yjX971rZKSHsKtC7ngi3GqUK64QJ/sLjutQXnsxCWvEPaltnsT8dlyjdi2iizP1Prn', + ) + }) + it('should sign with ed25519', async () => { + const agent = new SSHAgentClient() + const identity = await agent.getIdentity('key_ed25519') + if (!identity) { + throw new Error() + } + const signature = await agent.sign(identity, Buffer.from('hello', 'utf8')) + chai.assert.strictEqual(signature.type, 'ssh-ed25519') + chai.assert.strictEqual( + signature.signature, + 'Uolma4H0fzvBSu1G/6pUYZthmFH/NjXRjP4Zx80SloXMIlTFsF/++HqOi4ooEhLoTh/ZlhAlyEONjVqqcAnWAQ==', + ) + }) + it('can sign with ecdsa', async () => { + const agent = new SSHAgentClient() + const identity = await agent.getIdentity('key_ecdsa') + if (!identity) { + throw new Error() + } + const signature = await agent.sign(identity, Buffer.from('hello', 'utf8')) + chai.assert.strictEqual(signature.type, 'ecdsa-sha2-nistp256') + // Can't assert signature value as it is not deterministic + }) it('should throw if wrong secret', async () => { const agent = new SSHAgentClient() const identity = await agent.getIdentity('key_rsa') @@ -153,13 +202,13 @@ describe('SSHAgentClient cipher combination tests', () => { const decrypted = await agent.decrypt( identity, SEED, - 'ecfd6bb57f4891ba7226886e90d2eb848022a495b15ffd91ffe760bca5605f9062c305ee14226d9daf7faa58460c8f50', + '5af153e6fbf83b40cf98ed8bf5710321aa3234b2121cc3d8c47ef6854007f35ece319b056ddba7791b1db776b26a7ea7', ) chai.assert.strictEqual(decrypted.toString('utf8'), DECODED_STRING) }) it('should encrypt and decrypt with aes128/shake128', async () => { const agent = new SSHAgentClient({ cipherAlgo: 'aes-128-cbc', digestAlgo: 'shake128' }) - const identity = await agent.getIdentity('key_rsa') + const identity = await agent.getIdentity('key_ed25519') if (!identity) { throw new Error() } @@ -168,13 +217,13 @@ describe('SSHAgentClient cipher combination tests', () => { const decrypted = await agent.decrypt( identity, SEED, - '9126f351eb84b1d9316b2808c69a09d379fa9f20d4f4a8d4e30135dbba262cbbfc3ad3774fcade60e6d1ae7c75af0a9c', + '9a9bdd5451fd1ed8220f4ce23a17aa0981d69521cadd73ff219fd85aa01bc8a3a44cbe95502854a5b37296e93a91db91', ) chai.assert.strictEqual(decrypted.toString('utf8'), DECODED_STRING) }) - it('should encrypt with non matching aes192/sha512', async () => { + it('should encrypt and decrypt with non matching aes192/sha512', async () => { const agent = new SSHAgentClient({ cipherAlgo: 'aes-192-cbc', digestAlgo: 'sha512' }) - const identity = await agent.getIdentity('key_rsa') + const identity = await agent.getIdentity('key_ed25519') if (!identity) { throw new Error() } @@ -183,7 +232,7 @@ describe('SSHAgentClient cipher combination tests', () => { const decrypted = await agent.decrypt( identity, SEED, - '280cd2993bb8f9d7ec14ef0c1d94d4ac8e128a39f3f9cba9f5730a7d99674057766e099c17a6786a4a6b33670d1b45b7', + '1e69398e2ad6ab5e1f748d538fafe6e54e9824b0646af6666437c556398a7495b48f76db5df52d52f9adde0a232465ab', ) chai.assert.strictEqual(decrypted.toString('utf8'), DECODED_STRING) }) @@ -192,7 +241,7 @@ describe('SSHAgentClient cipher combination tests', () => { describe('SSHAgentClient encodings tests', () => { it('should encrypt to base64', async () => { const agent = new SSHAgentClient() - const identity = await agent.getIdentity('key_rsa') + const identity = await agent.getIdentity('key_ed25519') if (!identity) { throw new Error() } @@ -201,14 +250,14 @@ describe('SSHAgentClient encodings tests', () => { }) it('should decrypt from base64', async () => { const agent = new SSHAgentClient() - const identity = await agent.getIdentity('key_rsa') + const identity = await agent.getIdentity('key_ed25519') if (!identity) { throw new Error() } const decrypted = await agent.decrypt( identity, SEED, - '8epe+B3bWcSGTPpyW2MRqHeAKjTj2NVnR4q1YNVB5LfXw9JE02wsb3RqZBTFbMXc', + 'QMf1r1/ZONTJImZyY1qO+ibDTqLCZNwD2dMs/tpfVpfLKovb7flyiU1Au/01xffv', 'base64', ) chai.assert.strictEqual(decrypted.toString('utf8'), DECODED_STRING) diff --git a/test/ssh_agent_stream.spec.ts b/test/ssh_agent_stream.spec.ts index 1493f60..aa4bce6 100644 --- a/test/ssh_agent_stream.spec.ts +++ b/test/ssh_agent_stream.spec.ts @@ -14,7 +14,7 @@ const SEED = 'not_a_secret' describe('SSHAgentClient streams tests', () => { it('should encrypt', async () => { const agent = new SSHAgentClient() - const identity = await agent.getIdentity('key_rsa') + const identity = await agent.getIdentity('key_ed25519') if (!identity) { throw new Error() } @@ -25,13 +25,13 @@ describe('SSHAgentClient streams tests', () => { }) it('should decrypt', async () => { const agent = new SSHAgentClient() - const identity = await agent.getIdentity('key_rsa') + const identity = await agent.getIdentity('key_ed25519') if (!identity) { throw new Error() } const transform = await agent.getDecryptTransform(identity, SEED, 'hex') const stream = Readable.from( - 'ecfd6bb57f4891ba7226886e90d2eb848022a495b15ffd91ffe760bca5605f9062c305ee14226d9daf7faa58460c8f50', + '2e306f1403b72eb17fe7187545dbd863be234ad1128ac3f34d60d102ced7bc43a7c4506d341463cff257b4d007b39143', ) const decrypted = await text(stream.pipe(transform)) chai.assert.strictEqual(decrypted, DECODED_STRING) @@ -41,7 +41,7 @@ describe('SSHAgentClient streams tests', () => { describe('SSHAgentClient streams encodings tests', () => { it('should encrypt to base64', async () => { const agent = new SSHAgentClient() - const identity = await agent.getIdentity('key_rsa') + const identity = await agent.getIdentity('key_ed25519') if (!identity) { throw new Error() } @@ -52,12 +52,12 @@ describe('SSHAgentClient streams encodings tests', () => { }) it('should decrypt from base64', async () => { const agent = new SSHAgentClient() - const identity = await agent.getIdentity('key_rsa') + const identity = await agent.getIdentity('key_ed25519') if (!identity) { throw new Error() } const transform = await agent.getDecryptTransform(identity, SEED, 'base64') - const stream = Readable.from('8epe+B3bWcSGTPpyW2MRqHeAKjTj2NVnR4q1YNVB5LfXw9JE02wsb3RqZBTFbMXc') + const stream = Readable.from('5e0UbJX4+Rad+byLPnRNho3Qvjbeeqcwmg7yrXinHrTZ0788uuyvTl9jjbcpErF6') const decrypted = await text(stream.pipe(transform)) chai.assert.strictEqual(decrypted, DECODED_STRING) })