diff --git a/README.md b/README.md index 2a5cce3..77656a8 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ A web-based password manager with zero-knowledge architecture. All cryptography │ + Rate limiting │ │ + Git sync (go-git) │ └─────────────────────────────────────┘ - │ HTTPS + PAT + │ HTTPS (PAT) / SSH (key) ┌──────────────▼──────────────────────┐ │ Remote Git Repo (optional) │ │ └── *.gpg (encrypted blobs) │ @@ -125,6 +125,18 @@ cp .env.example .env docker compose up -d ``` +### Database Migrations & Upgrades + +Migrations are applied automatically on startup via embedded SQL files in `db/migrations/`. +They are numbered (`NNN-name.sql`) and tracked in a `migrations` table — each runs exactly once. + +**Forward upgrade ✅** — Fully supported. All migrations only add columns/tables, +never remove or rename. Upgrading from older versions (e.g. `v0.4.6`) to latest is safe. + +**Rollback ❌** — Not supported. Because sqlc generates `SELECT *` queries, the old +binary will fail to scan rows if new columns were added. To downgrade, restore both +the binary and the database from backup. + ### Security Hardening The container runs with: @@ -325,8 +337,10 @@ One-way overwrite sync with fresh clone/export: - **Push**: Local → Remote (export local DB, force-push to remote) - **No merge conflicts** — Last write wins - **Fresh operations** — Local git repo is temporary, cleaned before/after each operation -- **PGP-encrypted PAT** — Encrypted with user's PGP public key - **Per-user configuration** — Each user has their own repo URL +- **Two authentication methods**: + - **HTTPS** — PGP-encrypted Personal Access Token + - **SSH** — PGP-encrypted SSH private key with TOFU host key verification ### Git Repository Structure 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/package-lock.json b/frontend/package-lock.json index e47d98a..43c4776 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -55,12 +55,13 @@ "license": "ISC" }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -69,29 +70,31 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -108,13 +111,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -136,13 +140,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -152,36 +157,39 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -200,52 +208,57 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -304,31 +317,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -336,13 +351,14 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1793,10 +1809,11 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", - "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "version": "2.10.37", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz", + "integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==", "dev": true, + "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" }, @@ -1812,9 +1829,9 @@ "license": "ISC" }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "dev": true, "funding": [ { @@ -1830,12 +1847,13 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -1867,9 +1885,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001777", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz", - "integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==", + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", "dev": true, "funding": [ { @@ -1884,7 +1902,8 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ] + ], + "license": "CC-BY-4.0" }, "node_modules/chai": { "version": "6.2.2", @@ -2138,10 +2157,11 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.307", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz", - "integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==", - "dev": true + "version": "1.5.372", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.372.tgz", + "integrity": "sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA==", + "dev": true, + "license": "ISC" }, "node_modules/emoji-regex": { "version": "8.0.0", @@ -2264,6 +2284,7 @@ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -2347,17 +2368,17 @@ } }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -2487,9 +2508,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "dev": true, "license": "MIT", "dependencies": { @@ -2945,6 +2966,7 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dev": true, + "license": "ISC", "dependencies": { "yallist": "^3.0.2" } @@ -3037,10 +3059,14 @@ } }, "node_modules/node-releases": { - "version": "2.0.36", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", - "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", - "dev": true + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/nth-check": { "version": "2.1.1", @@ -3369,6 +3395,7 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, + "license": "ISC", "bin": { "semver": "bin/semver.js" } @@ -3652,6 +3679,7 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" @@ -3952,9 +3980,9 @@ } }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmmirror.com/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "dev": true, "license": "MIT", "engines": { @@ -3999,7 +4027,8 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/yargs": { "version": "15.4.1", diff --git a/frontend/playwright-e2e-test.sh b/frontend/playwright-e2e-test.sh index d5b0ba6..56c8ab5 100755 --- a/frontend/playwright-e2e-test.sh +++ b/frontend/playwright-e2e-test.sh @@ -93,9 +93,6 @@ cleanup() { # Remove any core dumps or temp files rm -f "$ROOT_DIR/core" 2>/dev/null || true - # Kill any leftover server on port 18080 - kill_port_18080 - log_info "Cleanup complete" # Preserve the original exit code @@ -106,10 +103,6 @@ cleanup() { trap cleanup EXIT INT TERM HUP # Kill any process listening on port 18080 (Playwright test port) -kill_port_18080() { - lsof -ti :18080 | xargs kill -9 2>/dev/null || true - sleep 1 -} cd "$ROOT_DIR" @@ -288,8 +281,6 @@ run_protected_mode() { log_info "=========================================" log_info "" - # Kill existing server to ensure fresh start with correct rate limits - kill_port_18080 # Set Protected Mode environment variables export REGISTRATION_ENABLED=true @@ -448,8 +439,6 @@ run_all_tests() { # Phase 2: All other tests EXCEPT registration in Protected mode (relaxed rate limits) if [ $total_exit -eq 0 ]; then - # Kill server to force restart with new env vars - kill_port_18080 export REGISTRATION_ENABLED=true export REGISTRATION_TOTP_SECRET="JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP" @@ -468,8 +457,6 @@ run_all_tests() { # Phase 3: Registration tests in Open mode if [ $total_exit -eq 0 ]; then - # Kill server to force restart with new env vars - kill_port_18080 export REGISTRATION_ENABLED=true export REGISTRATION_TOTP_SECRET="" @@ -485,8 +472,6 @@ run_all_tests() { # Phase 4: Registration tests in Protected mode if [ $total_exit -eq 0 ]; then - # Kill server to force restart with new env vars - kill_port_18080 export REGISTRATION_ENABLED=true export REGISTRATION_TOTP_SECRET="JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP" @@ -502,8 +487,6 @@ run_all_tests() { # Phase 5: Registration tests in Disabled mode if [ $total_exit -eq 0 ]; then - # Kill server to force restart with new env vars - kill_port_18080 export REGISTRATION_ENABLED=false export REGISTRATION_TOTP_SECRET="" diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 4571e72..26e158e 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -28,7 +28,7 @@ export default defineConfig({ command: 'go run ../cmd/srv', url: 'http://localhost:18080', timeout: 120 * 1000, - reuseExistingServer: true, + reuseExistingServer: false, env: { PORT: '18080', JWT_SECRET: process.env.JWT_SECRET || 'test-secret-key-32-bytes-long!!!', diff --git a/frontend/src/components/GitSync.tsx b/frontend/src/components/GitSync.tsx index ebf30b0..87aace3 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,32 @@ 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 handleUrlChange = (url: string) => { + setRepoUrl(url); + }; + const formatTime = (iso?: string) => { if (!iso) return 'Never'; const d = new Date(iso); @@ -68,16 +93,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 || 'https'); } - // 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 +120,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 +143,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 +198,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 = ''; + + 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 (branch always HEAD for auto-detect) - await session.api.configureGit(repoUrl, encryptedPat); + // 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 +249,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 +344,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 +351,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 +427,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 (