Skip to content

Commit 414f39b

Browse files
feat: SSH Authentication Keys v2 (#113)
* feat: add SSH authentication keys v2 for Git operations Add comprehensive SSH key authentication support for Git operations including: - SSH host key verification with user approval flow - Encrypted SSH key storage with passphrase support - SSH connection testing endpoint - Real-time host key change warnings Backend changes: - New SSHHostKeyHandler for managing host key verification via IPC - Add passphrase handler for locked SSH keys - Add SSH key manager utilities (key writing, validation, cleanup) - Add crypto utilities for secure key/secret encryption - Add SSH validation utilities for key format checking - Add SSH routes for host key response handling - Enhance GitAuthService with SSH setup and environment methods - Update GitService to handle SSH authentication for push/fetch/pull - Update OpenCode server to support SSH keys with SSH config generation - Add database migration for trusted_ssh_hosts table - Add settings endpoint for SSH connection testing Frontend changes: - Add SSHHostKeyDialog for host key verification UI - Add PassphraseModal for SSH key passphrase input - Update GitCredentialDialog with SSH key type and testing - Update GitSettings to manage SSH keys - Add SSH API client and hooks (useSSH, usePassphraseHandler) Shared changes: - Add SSH schemas and types Test coverage: - Integration tests for SSH flow - IPC handler tests for host key and passphrase - SSH route tests - SSH utility validation tests - Updated existing tests for SSH-aware changes
1 parent 3d5ee46 commit 414f39b

54 files changed

Lines changed: 4051 additions & 214 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.env.test‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
NODE_ENV=test
2+
PORT=3001
3+
DATABASE_PATH=:memory:
4+
AUTH_SECRET=test-secret-for-encryption

‎backend/src/db/migrations.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,8 +163,8 @@ function migrateGitTokenToCredentials(db: Database): void {
163163
continue
164164
}
165165

166-
const { gitToken: _gitToken, ...rest } = parsed
167-
void _gitToken
166+
const { gitToken: _, ...rest } = parsed
167+
void _
168168
const migrated = {
169169
...rest,
170170
gitCredentials: [{

‎backend/src/db/schema.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,17 @@ export function initializeDatabase(dbPath: string = './data/opencode.db'): Datab
121121
122122
CREATE INDEX IF NOT EXISTS idx_passkey_userId ON "passkey"(userId);
123123
CREATE INDEX IF NOT EXISTS idx_passkey_credentialID ON "passkey"(credentialID);
124+
125+
CREATE TABLE IF NOT EXISTS trusted_ssh_hosts (
126+
id INTEGER PRIMARY KEY AUTOINCREMENT,
127+
host TEXT NOT NULL UNIQUE,
128+
key_type TEXT NOT NULL,
129+
public_key TEXT NOT NULL,
130+
created_at INTEGER NOT NULL,
131+
updated_at INTEGER NOT NULL
132+
);
133+
134+
CREATE INDEX IF NOT EXISTS idx_trusted_ssh_hosts_host ON trusted_ssh_hosts(host);
124135
`)
125136

126137
runMigrations(db)

‎backend/src/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { createProvidersRoutes } from './routes/providers'
1717
import { createOAuthRoutes } from './routes/oauth'
1818
import { createTitleRoutes } from './routes/title'
1919
import { createSSERoutes } from './routes/sse'
20+
import { createSSHRoutes } from './routes/ssh'
2021
import { createNotificationRoutes } from './routes/notifications'
2122
import { createAuthRoutes, createAuthInfoRoutes, syncAdminFromEnv } from './routes/auth'
2223
import { createAuth } from './auth'
@@ -241,6 +242,7 @@ protectedApi.route('/tts', createTTSRoutes(db))
241242
protectedApi.route('/stt', createSTTRoutes(db))
242243
protectedApi.route('/generate-title', createTitleRoutes())
243244
protectedApi.route('/sse', createSSERoutes())
245+
protectedApi.route('/ssh', createSSHRoutes(gitAuthService))
244246
protectedApi.route('/notifications', createNotificationRoutes(notificationService))
245247

246248
app.route('/api', protectedApi)

‎backend/src/ipc/askpassHandler.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,18 +111,19 @@ export class AskpassHandler implements IPCHandler {
111111

112112
const settingsService = new SettingsService(this.database)
113113
const settings = settingsService.getSettings('default')
114-
const gitCredentials: GitCredential[] = settings.preferences.gitCredentials || []
115-
logger.info(`Found ${gitCredentials.length} configured git credentials`)
114+
const allCredentials = (settings.preferences.gitCredentials || []) as GitCredential[]
115+
const gitCredentials = allCredentials.filter(cred => !cred.type || cred.type === 'pat')
116+
logger.info(`Found ${gitCredentials.length} configured PAT credentials (${allCredentials.length} total)`)
116117

117118
for (const cred of gitCredentials) {
118119
const normalizedCred = this.normalizeHostname(cred.host)
119120
logger.debug(`Comparing: request='${normalizedRequest}' vs stored='${normalizedCred}' (raw: ${cred.host})`)
120121

121122
if (normalizedCred === normalizedRequest) {
122-
logger.info(`Found matching credential '${cred.name}' for ${hostname}`)
123+
logger.info(`Found matching PAT credential '${cred.name}' for ${hostname}`)
123124
return {
124125
username: cred.username || this.getDefaultUsername(cred.host),
125-
password: cred.token,
126+
password: cred.token || '',
126127
}
127128
}
128129
}

‎backend/src/ipc/ipcServer.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ export class IPCServer {
3333
this.handlers.set(`/${name}`, handler)
3434
}
3535

36+
getHandler(name: string): IPCHandler | undefined {
37+
return this.handlers.get(`/${name}`)
38+
}
39+
3640
getEnv(): Record<string, string> {
3741
return { VSCODE_GIT_IPC_HANDLE: this.ipcHandlePath }
3842
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import type { IPCServer, IPCHandler } from './ipcServer'
2+
import { logger } from '../utils/logger'
3+
4+
interface PassphraseResponse {
5+
type: 'passphrase-response'
6+
passphrase: string
7+
}
8+
9+
export class PassphraseHandler implements IPCHandler {
10+
private resolveMap = new Map<string, { resolve: (passphrase: string) => void; reject: (error: Error) => void }>()
11+
12+
constructor(ipcServer: IPCServer | undefined) {
13+
if (ipcServer) {
14+
ipcServer.registerHandler('passphrase', this)
15+
logger.info('PassphraseHandler registered with IPC server')
16+
} else {
17+
logger.warn('PassphraseHandler: No IPC server provided, passphrase prompts will fail')
18+
}
19+
}
20+
21+
async handle(request: PassphraseResponse): Promise<string> {
22+
if (request.type === 'passphrase-response') {
23+
const pending = this.resolveMap.get('default')
24+
if (pending) {
25+
pending.resolve(request.passphrase)
26+
this.resolveMap.delete('default')
27+
}
28+
return 'ack'
29+
}
30+
return ''
31+
}
32+
33+
requestPassphrase(credentialName: string, host: string): Promise<string> {
34+
return new Promise((resolve, reject) => {
35+
const requestId = `default`
36+
37+
this.resolveMap.set(requestId, { resolve, reject })
38+
39+
logger.info(`Requesting passphrase for ${credentialName} (${host})`)
40+
41+
setTimeout(() => {
42+
if (this.resolveMap.has(requestId)) {
43+
this.resolveMap.delete(requestId)
44+
reject(new Error('Passphrase request timed out'))
45+
}
46+
}, 120000)
47+
})
48+
}
49+
50+
cleanup(): void {
51+
this.resolveMap.forEach(({ reject }) => reject(new Error('Passphrase handler closed')))
52+
this.resolveMap.clear()
53+
}
54+
}
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import * as path from 'path'
2+
import * as fs from 'fs/promises'
3+
import * as crypto from 'crypto'
4+
import type { IPCHandler } from './ipcServer'
5+
import type { Database } from 'bun:sqlite'
6+
import { logger } from '../utils/logger'
7+
import { getWorkspacePath } from '@opencode-manager/shared/config/env'
8+
import { broadcastSSHHostKeyRequest } from '../services/sse-aggregator'
9+
import { executeCommand } from '../utils/process'
10+
import { parseSSHHost, normalizeHostPort } from '../utils/ssh-key-manager'
11+
12+
interface SSHHostKeyRequest {
13+
id: string
14+
host: string
15+
ip: string
16+
keyType: string
17+
fingerprint: string
18+
timestamp: number
19+
isKeyChanged: boolean
20+
}
21+
22+
export class SSHHostKeyHandler implements IPCHandler {
23+
private pendingRequests = new Map<string, {
24+
request: SSHHostKeyRequest
25+
resolve: (value: boolean) => void
26+
timeout: ReturnType<typeof setTimeout>
27+
}>()
28+
private readonly timeoutMs: number
29+
private knownHostsPath: string
30+
private database: Database
31+
32+
constructor(database: Database, timeoutMs: number = 120_000) {
33+
this.database = database
34+
this.timeoutMs = timeoutMs
35+
const configDir = path.join(getWorkspacePath(), 'config')
36+
this.knownHostsPath = path.join(configDir, 'known_hosts')
37+
this.ensureKnownHostsFile()
38+
logger.info(`SSHHostKeyHandler initialized with timeout=${timeoutMs}ms, known_hosts=${this.knownHostsPath}`)
39+
}
40+
41+
private async ensureKnownHostsFile(): Promise<void> {
42+
try {
43+
const configDir = path.join(getWorkspacePath(), 'config')
44+
await fs.mkdir(configDir, { recursive: true })
45+
try {
46+
await fs.access(this.knownHostsPath)
47+
} catch {
48+
await fs.writeFile(this.knownHostsPath, '', { mode: 0o600 })
49+
logger.info(`Created known_hosts file at ${this.knownHostsPath}`)
50+
}
51+
} catch (error) {
52+
logger.error('Failed to ensure known_hosts file:', error)
53+
}
54+
}
55+
56+
async verifyHostKeyBeforeOperation(repoUrl: string): Promise<boolean> {
57+
const { host, port } = parseSSHHost(repoUrl)
58+
const hostPort = normalizeHostPort(host, port)
59+
60+
const trustedHost = this.getTrustedHost(hostPort)
61+
if (trustedHost) {
62+
logger.info(`Host ${hostPort} already trusted, skipping verification`)
63+
return true
64+
}
65+
66+
try {
67+
const publicKey = await this.fetchHostPublicKey(host, port)
68+
logger.info(`Fetched public key for ${hostPort}`)
69+
70+
const parts = publicKey.split(' ')
71+
const keyType = parts[1] || 'UNKNOWN'
72+
const requestId = crypto.randomBytes(16).toString('hex')
73+
const hostKeyRequest: SSHHostKeyRequest = {
74+
id: requestId,
75+
host: hostPort,
76+
ip: '',
77+
keyType,
78+
fingerprint: publicKey,
79+
timestamp: Date.now(),
80+
isKeyChanged: false
81+
}
82+
83+
logger.info(`Broadcasting SSH host key request: ${requestId} for host=${hostPort}`)
84+
broadcastSSHHostKeyRequest({ ...hostKeyRequest, requestId, action: 'verify' })
85+
86+
return new Promise<boolean>((resolve) => {
87+
const timeout = setTimeout(() => {
88+
logger.info(`SSH host key request timed out: ${requestId}, rejecting connection`)
89+
this.pendingRequests.delete(requestId)
90+
resolve(false)
91+
}, this.timeoutMs)
92+
93+
this.pendingRequests.set(requestId, { request: hostKeyRequest, resolve, timeout })
94+
})
95+
} catch (error) {
96+
logger.warn(`Failed to fetch host key for ${hostPort}, rejecting connection:`, (error as Error).message)
97+
return false
98+
}
99+
}
100+
101+
private async fetchHostPublicKey(host: string, port?: string): Promise<string> {
102+
const portArgs = port ? ['-p', port] : []
103+
const output = await executeCommand(['ssh-keyscan', '-t', 'ed25519,rsa,ecdsa', ...portArgs, host], { silent: true })
104+
105+
const bracketedHost = port && port !== '22' ? `[${host}]:${port}` : host
106+
const lines = output.trim().split('\n')
107+
for (const line of lines) {
108+
if (line.startsWith(host) || line.startsWith(bracketedHost)) {
109+
return line
110+
}
111+
}
112+
113+
throw new Error('No valid host keys found')
114+
}
115+
116+
async handle(request: unknown): Promise<unknown> {
117+
const response = request as { requestId: string; response: 'accept' | 'reject' }
118+
return await this.respond(response)
119+
}
120+
121+
async respond(response: { requestId: string; response: 'accept' | 'reject' }): Promise<{ success: boolean; error?: string }> {
122+
const pending = this.pendingRequests.get(response.requestId)
123+
if (!pending) {
124+
return { success: false, error: 'Request not found or expired' }
125+
}
126+
127+
clearTimeout(pending.timeout)
128+
this.pendingRequests.delete(response.requestId)
129+
130+
if (response.response === 'accept') {
131+
await this.addToKnownHosts(pending.request.host, pending.request.fingerprint)
132+
this.saveTrustedHost(pending.request.host, pending.request.fingerprint)
133+
logger.info(`Accepted SSH host key for ${pending.request.host}`)
134+
} else {
135+
logger.info(`Rejected SSH host key for ${pending.request.host}`)
136+
}
137+
138+
pending.resolve(response.response === 'accept')
139+
return { success: true }
140+
}
141+
142+
private async addToKnownHosts(host: string, publicKey: string): Promise<void> {
143+
try {
144+
await fs.appendFile(this.knownHostsPath, publicKey + '\n')
145+
logger.info(`Added host to known_hosts: ${host}`)
146+
} catch (error) {
147+
logger.error(`Failed to add host to known_hosts: ${error}`)
148+
}
149+
}
150+
151+
private async loadFromDatabaseToKnownHosts(): Promise<void> {
152+
try {
153+
const hosts = this.database.prepare('SELECT * FROM trusted_ssh_hosts').all() as Array<{
154+
id: number
155+
host: string
156+
key_type: string
157+
public_key: string
158+
created_at: number
159+
updated_at: number
160+
}>
161+
162+
const entries = hosts.map(h => h.public_key).join('\n')
163+
await fs.writeFile(this.knownHostsPath, entries + '\n', { mode: 0o600 })
164+
logger.info(`Loaded ${hosts.length} trusted hosts from database to known_hosts`)
165+
} catch (error) {
166+
logger.error('Failed to load trusted hosts from database:', error)
167+
}
168+
}
169+
170+
private getTrustedHost(host: string): { key_type: string; public_key: string } | null {
171+
try {
172+
const result = this.database.prepare('SELECT key_type, public_key FROM trusted_ssh_hosts WHERE host = ?').get(host) as {
173+
key_type: string
174+
public_key: string
175+
} | undefined
176+
return result || null
177+
} catch (error) {
178+
logger.error(`Failed to get trusted host ${host}:`, error)
179+
return null
180+
}
181+
}
182+
183+
private saveTrustedHost(host: string, publicKey: string): void {
184+
try {
185+
const parts = publicKey.split(' ')
186+
const keyType = parts[1] || 'UNKNOWN'
187+
const now = Date.now()
188+
const existing = this.getTrustedHost(host)
189+
if (existing) {
190+
this.database.prepare('UPDATE trusted_ssh_hosts SET key_type = ?, public_key = ?, updated_at = ? WHERE host = ?')
191+
.run(keyType, publicKey, now, host)
192+
logger.info(`Updated trusted host in database: ${host}`)
193+
} else {
194+
this.database.prepare('INSERT INTO trusted_ssh_hosts (host, key_type, public_key, created_at, updated_at) VALUES (?, ?, ?, ?, ?)')
195+
.run(host, keyType, publicKey, now, now)
196+
logger.info(`Saved new trusted host to database: ${host}`)
197+
}
198+
} catch (error) {
199+
logger.error(`Failed to save trusted host ${host}:`, error)
200+
}
201+
}
202+
203+
async initialize(): Promise<void> {
204+
await this.ensureKnownHostsFile()
205+
await this.loadFromDatabaseToKnownHosts()
206+
logger.info('SSHHostKeyHandler initialized with known_hosts from database')
207+
}
208+
209+
getKnownHostsPath(): string {
210+
return this.knownHostsPath
211+
}
212+
213+
getEnv(): Record<string, string> {
214+
return {
215+
KNOWN_HOSTS_PATH: this.knownHostsPath
216+
}
217+
}
218+
219+
getPendingCount(): number {
220+
return this.pendingRequests.size
221+
}
222+
}
223+
224+
export function createSSHHostKeyHandler(database: Database, timeoutMs?: number): SSHHostKeyHandler {
225+
return new SSHHostKeyHandler(database, timeoutMs)
226+
}

‎backend/src/routes/repo-git.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export function createRepoGitRoutes(database: Database, gitAuthService: GitAuthS
4343
const statuses = await Promise.all(
4444
repoIds.map(async (id) => {
4545
try {
46-
const status = await git.getStatus(id, database)
46+
const status = await git.getStatus(id, database)
4747
return [id, status]
4848
} catch (error: unknown) {
4949
logger.error(`Failed to get git status for repo ${id}:`, error)

0 commit comments

Comments
 (0)