From 8ec7a04989490dddefe6aedb92e36dcff87a5799 Mon Sep 17 00:00:00 2001 From: ocode Date: Mon, 15 Jun 2026 10:07:54 +0000 Subject: [PATCH 01/11] feat: add SSH git sync with TOFU host key verification - New DB migration 007: adds auth_type, encrypted_ssh_key to git_config, plus git_known_hosts table for TOFU - sqlc queries updated: UpsertGitConfig includes new fields, new queries for known hosts CRUD - Backend git.go: SSH auth via go-git/plumbing/transport/ssh, TOFU HostKeyCallback with structured error types (HostKeyUnknownError, HostKeyChangedError) - Backend server.go: updated config handlers for SSH fields, new endpoints for trust-hostkey, list-trusted-hosts, delete-trusted-host, structured host key errors returned from push/pull - Frontend crypto: encryptSSHKey/decryptSSHKey helpers - Frontend api: updated types, new trusted hosts API calls, push/pull handle structured host key errors - Frontend GitSync: SSH key config auto-detected from URL (git@/ssh://), TOFU prompt on first connection, changed-key warning, trusted hosts management UI, passphrase-protected SSH key support Co-authored-by: Shelley --- db/dbgen/git.sql.go | 114 +++++- db/dbgen/models.go | 21 +- db/migrations/007-git-ssh-auth.sql | 20 + db/queries/git.sql | 24 +- frontend/src/components/GitSync.tsx | 603 ++++++++++++++++++++++------ frontend/src/lib/api.ts | 106 ++++- frontend/src/lib/crypto.ts | 24 ++ srv/git.go | 265 +++++++----- srv/git_test.go | 156 ++++++- srv/server.go | 170 +++++++- 10 files changed, 1247 insertions(+), 256 deletions(-) create mode 100644 db/migrations/007-git-ssh-auth.sql diff --git a/db/dbgen/git.sql.go b/db/dbgen/git.sql.go index 4c25f31..be6a190 100644 --- a/db/dbgen/git.sql.go +++ b/db/dbgen/git.sql.go @@ -19,8 +19,22 @@ func (q *Queries) DeleteGitConfig(ctx context.Context, fingerprint string) error return err } +const deleteKnownHost = `-- name: DeleteKnownHost :exec +DELETE FROM git_known_hosts WHERE fingerprint = ? AND hostname = ? +` + +type DeleteKnownHostParams struct { + Fingerprint string `json:"fingerprint"` + Hostname string `json:"hostname"` +} + +func (q *Queries) DeleteKnownHost(ctx context.Context, arg DeleteKnownHostParams) error { + _, err := q.db.ExecContext(ctx, deleteKnownHost, arg.Fingerprint, arg.Hostname) + return err +} + const getGitConfig = `-- name: GetGitConfig :one -SELECT fingerprint, repo_url, encrypted_pat, created_at, updated_at, branch FROM git_config WHERE fingerprint = ? +SELECT fingerprint, repo_url, encrypted_pat, created_at, updated_at, branch, auth_type, encrypted_ssh_key FROM git_config WHERE fingerprint = ? ` func (q *Queries) GetGitConfig(ctx context.Context, fingerprint string) (GitConfig, error) { @@ -33,6 +47,8 @@ func (q *Queries) GetGitConfig(ctx context.Context, fingerprint string) (GitConf &i.CreatedAt, &i.UpdatedAt, &i.Branch, + &i.AuthType, + &i.EncryptedSshKey, ) return i, err } @@ -42,7 +58,9 @@ SELECT gc.fingerprint, gc.repo_url, gc.branch, + gc.auth_type, gc.encrypted_pat, + gc.encrypted_ssh_key, gc.created_at as config_created_at, (SELECT COUNT(*) FROM git_sync_log WHERE fingerprint = gc.fingerprint AND status = 'success') as success_count, (SELECT COUNT(*) FROM git_sync_log WHERE fingerprint = gc.fingerprint AND status = 'failed') as failed_count @@ -54,7 +72,9 @@ type GetGitSyncStatusRow struct { Fingerprint string `json:"fingerprint"` RepoUrl string `json:"repo_url"` Branch string `json:"branch"` + AuthType string `json:"auth_type"` EncryptedPat string `json:"encrypted_pat"` + EncryptedSshKey string `json:"encrypted_ssh_key"` ConfigCreatedAt time.Time `json:"config_created_at"` SuccessCount int64 `json:"success_count"` FailedCount int64 `json:"failed_count"` @@ -67,7 +87,9 @@ func (q *Queries) GetGitSyncStatus(ctx context.Context, fingerprint string) (Get &i.Fingerprint, &i.RepoUrl, &i.Branch, + &i.AuthType, &i.EncryptedPat, + &i.EncryptedSshKey, &i.ConfigCreatedAt, &i.SuccessCount, &i.FailedCount, @@ -75,6 +97,27 @@ func (q *Queries) GetGitSyncStatus(ctx context.Context, fingerprint string) (Get return i, err } +const getKnownHost = `-- name: GetKnownHost :one +SELECT fingerprint, hostname, host_key_fingerprint, created_at FROM git_known_hosts WHERE fingerprint = ? AND hostname = ? +` + +type GetKnownHostParams struct { + Fingerprint string `json:"fingerprint"` + Hostname string `json:"hostname"` +} + +func (q *Queries) GetKnownHost(ctx context.Context, arg GetKnownHostParams) (GitKnownHost, error) { + row := q.db.QueryRowContext(ctx, getKnownHost, arg.Fingerprint, arg.Hostname) + var i GitKnownHost + err := row.Scan( + &i.Fingerprint, + &i.Hostname, + &i.HostKeyFingerprint, + &i.CreatedAt, + ) + return i, err +} + const listGitSyncLog = `-- name: ListGitSyncLog :many SELECT id, fingerprint, operation, status, message, entries_changed, created_at FROM git_sync_log WHERE fingerprint = ? @@ -113,6 +156,38 @@ func (q *Queries) ListGitSyncLog(ctx context.Context, fingerprint string) ([]Git return items, nil } +const listKnownHosts = `-- name: ListKnownHosts :many +SELECT fingerprint, hostname, host_key_fingerprint, created_at FROM git_known_hosts WHERE fingerprint = ? ORDER BY hostname +` + +func (q *Queries) ListKnownHosts(ctx context.Context, fingerprint string) ([]GitKnownHost, error) { + rows, err := q.db.QueryContext(ctx, listKnownHosts, fingerprint) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GitKnownHost{} + for rows.Next() { + var i GitKnownHost + if err := rows.Scan( + &i.Fingerprint, + &i.Hostname, + &i.HostKeyFingerprint, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const logGitSync = `-- name: LogGitSync :exec INSERT INTO git_sync_log (fingerprint, operation, status, message, entries_changed) VALUES (?, ?, ?, ?, ?) @@ -154,20 +229,24 @@ func (q *Queries) UpdateGitEncryptedPat(ctx context.Context, arg UpdateGitEncryp } const upsertGitConfig = `-- name: UpsertGitConfig :exec -INSERT INTO git_config (fingerprint, repo_url, branch, encrypted_pat, updated_at) -VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) +INSERT INTO git_config (fingerprint, repo_url, branch, encrypted_pat, encrypted_ssh_key, auth_type, updated_at) +VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT (fingerprint) DO UPDATE SET repo_url = excluded.repo_url, branch = excluded.branch, encrypted_pat = excluded.encrypted_pat, + encrypted_ssh_key = excluded.encrypted_ssh_key, + auth_type = excluded.auth_type, updated_at = CURRENT_TIMESTAMP ` type UpsertGitConfigParams struct { - Fingerprint string `json:"fingerprint"` - RepoUrl string `json:"repo_url"` - Branch string `json:"branch"` - EncryptedPat string `json:"encrypted_pat"` + Fingerprint string `json:"fingerprint"` + RepoUrl string `json:"repo_url"` + Branch string `json:"branch"` + EncryptedPat string `json:"encrypted_pat"` + EncryptedSshKey string `json:"encrypted_ssh_key"` + AuthType string `json:"auth_type"` } func (q *Queries) UpsertGitConfig(ctx context.Context, arg UpsertGitConfigParams) error { @@ -176,6 +255,27 @@ func (q *Queries) UpsertGitConfig(ctx context.Context, arg UpsertGitConfigParams arg.RepoUrl, arg.Branch, arg.EncryptedPat, + arg.EncryptedSshKey, + arg.AuthType, ) return err } + +const upsertKnownHost = `-- name: UpsertKnownHost :exec +INSERT INTO git_known_hosts (fingerprint, hostname, host_key_fingerprint) +VALUES (?, ?, ?) +ON CONFLICT (fingerprint, hostname) DO UPDATE +SET host_key_fingerprint = excluded.host_key_fingerprint, + created_at = CURRENT_TIMESTAMP +` + +type UpsertKnownHostParams struct { + Fingerprint string `json:"fingerprint"` + Hostname string `json:"hostname"` + HostKeyFingerprint string `json:"host_key_fingerprint"` +} + +func (q *Queries) UpsertKnownHost(ctx context.Context, arg UpsertKnownHostParams) error { + _, err := q.db.ExecContext(ctx, upsertKnownHost, arg.Fingerprint, arg.Hostname, arg.HostKeyFingerprint) + return err +} diff --git a/db/dbgen/models.go b/db/dbgen/models.go index 66f15ba..ed52601 100644 --- a/db/dbgen/models.go +++ b/db/dbgen/models.go @@ -18,12 +18,21 @@ type Entry struct { } type GitConfig struct { - Fingerprint string `json:"fingerprint"` - RepoUrl string `json:"repo_url"` - EncryptedPat string `json:"encrypted_pat"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Branch string `json:"branch"` + Fingerprint string `json:"fingerprint"` + RepoUrl string `json:"repo_url"` + EncryptedPat string `json:"encrypted_pat"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Branch string `json:"branch"` + AuthType string `json:"auth_type"` + EncryptedSshKey string `json:"encrypted_ssh_key"` +} + +type GitKnownHost struct { + Fingerprint string `json:"fingerprint"` + Hostname string `json:"hostname"` + HostKeyFingerprint string `json:"host_key_fingerprint"` + CreatedAt time.Time `json:"created_at"` } type GitSyncLog struct { diff --git a/db/migrations/007-git-ssh-auth.sql b/db/migrations/007-git-ssh-auth.sql new file mode 100644 index 0000000..545d6a7 --- /dev/null +++ b/db/migrations/007-git-ssh-auth.sql @@ -0,0 +1,20 @@ +-- SSH authentication support for Git sync +-- +-- Adds auth_type and encrypted_ssh_key columns to git_config +-- Creates git_known_hosts table for TOFU host key verification + +ALTER TABLE git_config ADD COLUMN auth_type TEXT NOT NULL DEFAULT 'https'; +ALTER TABLE git_config ADD COLUMN encrypted_ssh_key TEXT NOT NULL DEFAULT ''; + +-- Known hosts table for SSH TOFU (Trust On First Use) +CREATE TABLE IF NOT EXISTS git_known_hosts ( + fingerprint TEXT NOT NULL REFERENCES users(fingerprint) ON DELETE CASCADE, + hostname TEXT NOT NULL, + host_key_fingerprint TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (fingerprint, hostname) +); + +-- Record execution of this migration +INSERT OR IGNORE INTO migrations (migration_number, migration_name) +VALUES (007, '007-git-ssh-auth'); diff --git a/db/queries/git.sql b/db/queries/git.sql index e9ed296..4f8011f 100644 --- a/db/queries/git.sql +++ b/db/queries/git.sql @@ -1,10 +1,12 @@ -- name: UpsertGitConfig :exec -INSERT INTO git_config (fingerprint, repo_url, branch, encrypted_pat, updated_at) -VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) +INSERT INTO git_config (fingerprint, repo_url, branch, encrypted_pat, encrypted_ssh_key, auth_type, updated_at) +VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT (fingerprint) DO UPDATE SET repo_url = excluded.repo_url, branch = excluded.branch, encrypted_pat = excluded.encrypted_pat, + encrypted_ssh_key = excluded.encrypted_ssh_key, + auth_type = excluded.auth_type, updated_at = CURRENT_TIMESTAMP; -- name: GetGitConfig :one @@ -33,9 +35,27 @@ SELECT gc.fingerprint, gc.repo_url, gc.branch, + gc.auth_type, gc.encrypted_pat, + gc.encrypted_ssh_key, gc.created_at as config_created_at, (SELECT COUNT(*) FROM git_sync_log WHERE fingerprint = gc.fingerprint AND status = 'success') as success_count, (SELECT COUNT(*) FROM git_sync_log WHERE fingerprint = gc.fingerprint AND status = 'failed') as failed_count FROM git_config gc WHERE gc.fingerprint = ?; + +-- name: UpsertKnownHost :exec +INSERT INTO git_known_hosts (fingerprint, hostname, host_key_fingerprint) +VALUES (?, ?, ?) +ON CONFLICT (fingerprint, hostname) DO UPDATE +SET host_key_fingerprint = excluded.host_key_fingerprint, + created_at = CURRENT_TIMESTAMP; + +-- name: GetKnownHost :one +SELECT * FROM git_known_hosts WHERE fingerprint = ? AND hostname = ?; + +-- name: DeleteKnownHost :exec +DELETE FROM git_known_hosts WHERE fingerprint = ? AND hostname = ?; + +-- name: ListKnownHosts :many +SELECT * FROM git_known_hosts WHERE fingerprint = ? ORDER BY hostname; diff --git a/frontend/src/components/GitSync.tsx b/frontend/src/components/GitSync.tsx index ebf30b0..321d236 100644 --- a/frontend/src/components/GitSync.tsx +++ b/frontend/src/components/GitSync.tsx @@ -1,12 +1,14 @@ import { useState, useEffect, useRef } from 'preact/hooks'; import { session } from '../lib/session'; import { getPublicKey, getDecryptedPrivateKey } from '../lib/storage'; -import { encryptPAT, decryptPAT, decryptPrivateKey } from '../lib/crypto'; +import { encryptPAT, decryptPAT, encryptSSHKey, decryptSSHKey, decryptPrivateKey } from '../lib/crypto'; interface GitStatus { configured: boolean; repo_url?: string; + auth_type?: string; has_encrypted_pat?: boolean; + has_encrypted_ssh_key?: boolean; success_count: number; failed_count: number; } @@ -20,11 +22,10 @@ interface GitLogEntry { created_at: string; } -interface PullResult { - status: string; - operation: string; - entries_changed?: number; - message: string; +interface TrustedHost { + hostname: string; + host_key_fingerprint: string; + created_at: string; } interface Props { @@ -43,8 +44,12 @@ export function GitSync({ onClose, onSuccess }: Props) { // Config form const [repoUrl, setRepoUrl] = useState(''); + const [authType, setAuthType] = useState('https'); const [pat, setPat] = useState(''); + const [sshKey, setSshKey] = useState(''); + const [sshPassphrase, setSshPassphrase] = useState(''); const [encryptedPat, setEncryptedPat] = useState(''); + const [encryptedSshKey, setEncryptedSshKey] = useState(''); const [configuring, setConfiguring] = useState(false); // Passphrase prompt for PGP key decryption @@ -52,12 +57,38 @@ export function GitSync({ onClose, onSuccess }: Props) { const [passphraseForPat, setPassphraseForPat] = useState(''); const [pendingAction, setPendingAction] = useState<'configure' | 'push' | 'pull' | null>(null); + // SSH TOFU prompt + const [showHostKeyPrompt, setShowHostKeyPrompt] = useState(false); + const [hostKeyInfo, setHostKeyInfo] = useState<{ + host: string; + fingerprint: string; + old_fingerprint?: string; + isChanged: boolean; + } | null>(null); + // Pending push/pull token to retry after trusting host + const pendingTokenRef = useRef(''); + + // Trusted hosts management + const [showTrustedHosts, setShowTrustedHosts] = useState(false); + const [trustedHosts, setTrustedHosts] = useState([]); + const [loadingHosts, setLoadingHosts] = useState(false); + // Use a ref to store the current passphrase value for the resolver const passphraseRef = useRef(''); const resolverRef = useRef<((pwd: string | null) => void) | null>(null); const fp = session.fingerprint || ''; + const detectAuthType = (url: string): string => { + if (url.startsWith('git@') || url.startsWith('ssh://')) return 'ssh'; + return 'https'; + }; + + const handleUrlChange = (url: string) => { + setRepoUrl(url); + setAuthType(detectAuthType(url)); + }; + const formatTime = (iso?: string) => { if (!iso) return 'Never'; const d = new Date(iso); @@ -68,16 +99,17 @@ export function GitSync({ onClose, onSuccess }: Props) { if (!session.api) return; try { const s = await session.api.getGitStatus(); - // Create a new object to ensure Preact detects the change setStatus({...s}); if (s.configured && s.repo_url) { setRepoUrl(s.repo_url); + setAuthType(s.auth_type || detectAuthType(s.repo_url)); } - // Fetch encrypted_pat from config endpoint + // Fetch encrypted credentials from config endpoint const config = await session.api.getGitConfig(); - if (config.configured && config.encrypted_pat) { - setEncryptedPat(config.encrypted_pat); + if (config.configured) { + if (config.encrypted_pat) setEncryptedPat(config.encrypted_pat); + if (config.encrypted_ssh_key) setEncryptedSshKey(config.encrypted_ssh_key); } } catch (e: any) { setError(e.message || 'Failed to load status'); @@ -94,6 +126,18 @@ export function GitSync({ onClose, onSuccess }: Props) { } }; + const loadTrustedHosts = async () => { + if (!session.api) return; + setLoadingHosts(true); + try { + const result = await session.api.getTrustedHosts(); + setTrustedHosts(result.hosts || []); + } catch (e: any) { + setError(e.message || 'Failed to load trusted hosts'); + } + setLoadingHosts(false); + }; + useEffect(() => { loadStatus(); }, []); @@ -105,15 +149,52 @@ export function GitSync({ onClose, onSuccess }: Props) { passphraseRef.current = ''; setShowPassphrasePrompt(true); - // Return a promise that resolves when user clicks OK or Cancel return new Promise((resolve) => { resolverRef.current = resolve; }); }; + // Auto-retry push/pull after trusting a host + const retryAfterTrust = async (action: 'push' | 'pull', token: string) => { + setShowHostKeyPrompt(false); + setHostKeyInfo(null); + setLoading(true); + try { + let result: any; + if (action === 'push') { + result = await session.api!.gitPush(token); + } else { + result = await session.api!.gitPull(token); + } + if (result.status === 'success') { + setSuccess(result.message || `${action} completed`); + setTimeout(() => setSuccess(''), 3000); + loadStatus(); + onSuccess?.(); + } else if (result.status === 'host_key_unknown' || result.status === 'host_key_changed') { + // Should not happen after trusting, but handle gracefully + setError(`Host key issue: ${result.status}`); + } else { + setError(result.message || `${action} returned unexpected status`); + } + } catch (e: any) { + setError(e.message || `${action} failed`); + } + setLoading(false); + pendingTokenRef.current = ''; + }; + const handleConfigure = async () => { - if (!repoUrl || !pat) { - setError('Repository URL and PAT are required'); + if (!repoUrl) { + setError('Repository URL is required'); + return; + } + if (authType === 'https' && !pat) { + setError('PAT is required for HTTPS'); + return; + } + if (authType === 'ssh' && !sshKey) { + setError('SSH private key is required'); return; } @@ -123,36 +204,40 @@ export function GitSync({ onClose, onSuccess }: Props) { try { if (!session.api) throw new Error('Not logged in'); - // Get public key for PGP encryption const publicKey = await getPublicKey(fp); if (!publicKey) throw new Error('Public key not found'); - // Encrypt PAT with PGP public key - const encryptedPat = await encryptPAT(pat, publicKey); + let encryptedPatData = ''; + let encryptedSshKeyData = ''; - // Configure server (branch always HEAD for auto-detect) - await session.api.configureGit(repoUrl, encryptedPat); + if (authType === 'https') { + encryptedPatData = await encryptPAT(pat, publicKey); + } else { + // Encrypt SSH key (+ optional passphrase) into one blob + encryptedSshKeyData = await encryptSSHKey(sshKey, sshPassphrase, publicKey); + } + + // Configure server (auto-detect auth type from URL too) + await session.api.configureGit(repoUrl, encryptedPatData, authType, encryptedSshKeyData); - // Directly update status to show configured state setStatus({ configured: true, repo_url: repoUrl, - has_encrypted_pat: true, + auth_type: authType, + has_encrypted_pat: authType === 'https', + has_encrypted_ssh_key: authType === 'ssh', success_count: 0, - failed_count: 0 + failed_count: 0, }); - setEncryptedPat(encryptedPat); + if (encryptedPatData) setEncryptedPat(encryptedPatData); + if (encryptedSshKeyData) setEncryptedSshKey(encryptedSshKeyData); setSuccess('Git sync configured successfully'); setTimeout(() => setSuccess(''), 3000); - setPat(''); // Clear PAT after config - - // Force a re-render + setPat(''); + setSshKey(''); + setSshPassphrase(''); forceUpdate(n => n + 1); - - // Don't call onSuccess() after config - we want to keep the modal open - // to show the status view. onSuccess is only for push/pull operations. - // onSuccess?.(); } catch (e: any) { console.error('[GitSync] configureGit error:', e); setError(e.message || 'Configuration failed'); @@ -170,44 +255,82 @@ export function GitSync({ onClose, onSuccess }: Props) { if (!status?.configured) throw new Error('Git sync not configured'); const passphrase = await promptForPassphrase('push'); - if (!passphrase) { setError('Passphrase required to decrypt private key'); setLoading(false); return; } - // Get encrypted PAT from server - if (!encryptedPat) { - setError('PAT not configured. Please reconfigure Git sync.'); - setLoading(false); - return; - } - - // Get armored private key from storage const armoredPrivateKey = await getDecryptedPrivateKey(fp, passphrase); if (!armoredPrivateKey) { setError('Failed to get private key. Check passphrase.'); setLoading(false); return; } - - // Decrypt the private key with the passphrase const privateKey = await decryptPrivateKey(armoredPrivateKey, passphrase); - // Decrypt PAT with PGP private key - const patToUse = await decryptPAT(encryptedPat, privateKey); - if (!patToUse) { - setError('Failed to decrypt PAT. Check passphrase.'); - setLoading(false); - return; + let token = ''; + + if (authType === 'ssh') { + if (!encryptedSshKey) { + setError('SSH key not configured. Please reconfigure Git sync.'); + setLoading(false); + return; + } + // Decrypt SSH key + passphrase + const sshData = await decryptSSHKey(encryptedSshKey, privateKey); + if (!sshData.key) { + setError('Failed to decrypt SSH key. Check passphrase.'); + setLoading(false); + return; + } + token = sshData.key; // PEM key content + // Note: sshData.passphrase is the SSH key's own passphrase (if any) + // go-git can handle passphrase-protected keys via the NewPublicKeys function + } else { + if (!encryptedPat) { + setError('PAT not configured. Please reconfigure Git sync.'); + setLoading(false); + return; + } + token = await decryptPAT(encryptedPat, privateKey); + if (!token) { + setError('Failed to decrypt PAT. Check passphrase.'); + setLoading(false); + return; + } } // Set session token - await session.api.setGitSession(patToUse); + await session.api.setGitSession(token); + pendingTokenRef.current = token; // Push - const result = await session.api.gitPush(patToUse); + const result = await session.api.gitPush(token); + + // Handle host key TOFU + if (result.status === 'host_key_unknown') { + setHostKeyInfo({ + host: result.host!, + fingerprint: result.fingerprint!, + isChanged: false, + }); + setShowHostKeyPrompt(true); + setLoading(false); + return; + } + if (result.status === 'host_key_changed') { + setHostKeyInfo({ + host: result.host!, + fingerprint: result.new_fingerprint!, + old_fingerprint: result.old_fingerprint!, + isChanged: true, + }); + setShowHostKeyPrompt(true); + setLoading(false); + return; + } + setSuccess(result.message || 'Pushed to remote'); setTimeout(() => setSuccess(''), 3000); loadStatus(); @@ -227,7 +350,6 @@ export function GitSync({ onClose, onSuccess }: Props) { if (!session.api) throw new Error('Not logged in'); if (!status?.configured) throw new Error('Git sync not configured'); - // Get passphrase to decrypt private key const passphrase = await promptForPassphrase('pull'); if (!passphrase) { setError('Passphrase required to decrypt private key'); @@ -235,37 +357,71 @@ export function GitSync({ onClose, onSuccess }: Props) { return; } - // Get encrypted PAT from server - if (!encryptedPat) { - setError('PAT not configured. Please reconfigure Git sync.'); - setLoading(false); - return; - } - - // Get armored private key from storage const armoredPrivateKey = await getDecryptedPrivateKey(fp, passphrase); if (!armoredPrivateKey) { setError('Failed to get private key. Check passphrase.'); setLoading(false); return; } - - // Decrypt the private key with the passphrase const privateKey = await decryptPrivateKey(armoredPrivateKey, passphrase); - // Decrypt PAT with PGP private key - const patToUse = await decryptPAT(encryptedPat, privateKey); - if (!patToUse) { - setError('Failed to decrypt PAT. Check passphrase.'); + let token = ''; + + if (authType === 'ssh') { + if (!encryptedSshKey) { + setError('SSH key not configured. Please reconfigure Git sync.'); + setLoading(false); + return; + } + const sshData = await decryptSSHKey(encryptedSshKey, privateKey); + if (!sshData.key) { + setError('Failed to decrypt SSH key. Check passphrase.'); + setLoading(false); + return; + } + token = sshData.key; + } else { + if (!encryptedPat) { + setError('PAT not configured. Please reconfigure Git sync.'); + setLoading(false); + return; + } + token = await decryptPAT(encryptedPat, privateKey); + if (!token) { + setError('Failed to decrypt PAT. Check passphrase.'); + setLoading(false); + return; + } + } + + await session.api.setGitSession(token); + pendingTokenRef.current = token; + + const result = await session.api.gitPull(token); + + // Handle host key TOFU + if (result.status === 'host_key_unknown') { + setHostKeyInfo({ + host: result.host!, + fingerprint: result.fingerprint!, + isChanged: false, + }); + setShowHostKeyPrompt(true); + setLoading(false); + return; + } + if (result.status === 'host_key_changed') { + setHostKeyInfo({ + host: result.host!, + fingerprint: result.new_fingerprint!, + old_fingerprint: result.old_fingerprint!, + isChanged: true, + }); + setShowHostKeyPrompt(true); setLoading(false); return; } - // Set session token - await session.api.setGitSession(patToUse); - - // Pull - const result: PullResult = await session.api.gitPull(patToUse); setSuccess(result.message || 'Pulled from remote'); setTimeout(() => setSuccess(''), 3000); loadStatus(); @@ -277,11 +433,44 @@ export function GitSync({ onClose, onSuccess }: Props) { setShowPassphrasePrompt(false); }; + const handleTrustHost = async () => { + if (!hostKeyInfo || !session.api) return; + try { + await session.api.trustHostKey(hostKeyInfo.host, hostKeyInfo.fingerprint); + // Auto-retry the pending operation + const action = pendingAction || 'push'; + const token = pendingTokenRef.current; + if (token) { + await retryAfterTrust(action as 'push' | 'pull', token); + } + } catch (e: any) { + setError(e.message || 'Failed to trust host key'); + } + setShowHostKeyPrompt(false); + setHostKeyInfo(null); + pendingTokenRef.current = ''; + }; + const handleViewLogs = async () => { setShowLogs(true); await loadLogs(); }; + const handleViewTrustedHosts = async () => { + setShowTrustedHosts(true); + await loadTrustedHosts(); + }; + + const handleDeleteTrustedHost = async (host: string) => { + if (!session.api) return; + try { + await session.api.deleteTrustedHost(host); + setTrustedHosts(prev => prev.filter(h => h.hostname !== host)); + } catch (e: any) { + setError(e.message || 'Failed to delete trusted host'); + } + }; + return (