mirror your GitHub repos to tangled.org automatically
1import { execFileSync } from 'node:child_process'
2import crypto from 'node:crypto'
3import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
4import os from 'node:os'
5import path from 'node:path'
6import { describe, expect, it } from 'vitest'
7import { generateKeypair, pkcs8ToOpenSshPrivate } from '../../server/utils/ssh-keypair'
8
9describe('ssh-keypair', () => {
10 it('produces an OpenSSH-formatted ed25519 public key', () => {
11 const { publicKeyOpenSsh } = generateKeypair('synchub.to/123')
12 expect(publicKeyOpenSsh).toMatch(/^ssh-ed25519 [A-Za-z0-9+/=]+ synchub\.to\/123$/)
13 })
14
15 it('produces a PKCS#8 PEM private key Node can load', () => {
16 const { privateKeyPem } = generateKeypair('test')
17 expect(privateKeyPem).toMatch(/^-----BEGIN PRIVATE KEY-----/)
18 // Round-trip: Node loads it back and reports the right type.
19 const key = crypto.createPrivateKey(privateKeyPem)
20 expect(key.asymmetricKeyType).toBe('ed25519')
21 })
22
23 it('public + private from the same call match each other (sign/verify)', () => {
24 const { publicKeyOpenSsh, privateKeyPem } = generateKeypair('test')
25
26 // Decode the OpenSSH public key back to raw bytes and reconstruct an SPKI key.
27 const b64 = publicKeyOpenSsh.split(' ')[1]!
28 const blob = Buffer.from(b64, 'base64')
29 // ssh-ed25519 framing: <4 bytes len><"ssh-ed25519"><4 bytes len><32 bytes raw key>
30 const algoLen = blob.readUInt32BE(0)
31 const keyLen = blob.readUInt32BE(4 + algoLen)
32 const rawPublic = blob.subarray(4 + algoLen + 4, 4 + algoLen + 4 + keyLen)
33 expect(rawPublic.length).toBe(32)
34
35 // Wrap raw key in the canonical 12-byte SPKI prefix for ed25519 (RFC 8410).
36 const spkiPrefix = Buffer.from('302a300506032b6570032100', 'hex')
37 const spki = Buffer.concat([spkiPrefix, rawPublic])
38 const publicKey = crypto.createPublicKey({ key: spki, format: 'der', type: 'spki' })
39 const privateKey = crypto.createPrivateKey(privateKeyPem)
40
41 const message = Buffer.from('test message')
42 const signature = crypto.sign(null, message, privateKey)
43 expect(crypto.verify(null, message, publicKey, signature)).toBe(true)
44 })
45
46 it('rejects keys with unexpected raw length', () => {
47 // Internal sanity \u2014 generateKeypair should never produce one, but the helper
48 // it relies on must error on wrong-sized input.
49 // (Tested indirectly via the keypair generator.)
50 const { publicKeyOpenSsh } = generateKeypair('comment with spaces ok')
51 expect(publicKeyOpenSsh).toContain('comment with spaces ok')
52 })
53
54 describe('pkcs8ToOpenSshPrivate', () => {
55 it('produces a PEM-wrapped OpenSSH private key block', () => {
56 const { privateKeyPem } = generateKeypair('test')
57 const openssh = pkcs8ToOpenSshPrivate(privateKeyPem, 'test-key')
58 expect(openssh).toMatch(/^-----BEGIN OPENSSH PRIVATE KEY-----\n/)
59 expect(openssh).toMatch(/-----END OPENSSH PRIVATE KEY-----\n$/)
60 // Lines between markers should be base64 and <=70 chars.
61 const innerLines = openssh.split('\n').slice(1, -2)
62 for (const line of innerLines) {
63 expect(line.length).toBeLessThanOrEqual(70)
64 expect(line).toMatch(/^[A-Za-z0-9+/=]+$/)
65 }
66 })
67
68 it('rejects non-ed25519 keys', () => {
69 const { privateKey } = crypto.generateKeyPairSync('rsa', {
70 modulusLength: 2048,
71 publicKeyEncoding: { type: 'spki', format: 'pem' },
72 privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
73 })
74 expect(() => pkcs8ToOpenSshPrivate(privateKey, 'x'))
75 .toThrow(/expected ed25519/)
76 })
77
78 it('is parseable by the real ssh-keygen, with derived public matching ours', () => {
79 const { publicKeyOpenSsh, privateKeyPem } = generateKeypair('synchub-test')
80 const openssh = pkcs8ToOpenSshPrivate(privateKeyPem, 'synchub-test')
81
82 const dir = mkdtempSync(path.join(os.tmpdir(), 'synchub-ssh-test-'))
83 try {
84 const keyPath = path.join(dir, 'id_ed25519')
85 writeFileSync(keyPath, openssh, { mode: 0o600 })
86 chmodSync(keyPath, 0o600)
87
88 const derived = execFileSync('ssh-keygen', ['-y', '-f', keyPath], { encoding: 'utf8' }).trim()
89 // ssh-keygen -y emits `ssh-ed25519 <base64>` (no comment). Compare
90 // ignoring the comment we put on `publicKeyOpenSsh`.
91 const ourBase64 = publicKeyOpenSsh.split(' ')[1]
92 const derivedBase64 = derived.split(' ')[1]
93 expect(derivedBase64).toBe(ourBase64)
94 }
95 finally {
96 rmSync(dir, { recursive: true, force: true })
97 }
98 })
99 })
100})