feat(security): add encryption health inventory and key recovery wizard
Backend:
- GET /api/security/encryption/health — inventory of all stored encrypted
secrets (LLM providers, DB connections, profile Git tokens) with
decrypt attempt and structured broken/healthy status
- GET /api/security/encryption/fingerprint — non-secret key fingerprint
- POST /api/security/encryption/recover — bulk replacement of
undecryptable secrets with partial_success semantics
Frontend:
- KeyRecoveryModel.svelte.ts — state machine (idle→scanning→
healthy/needs_recovery→editing→saving→complete/partial_success/error)
- KeyRecoveryWizard.svelte — tabbed dialog with LLM/DB/Git sections,
security guidance, re-encrypt command display, edit/save flow
- SystemSettings entry point — 'Check encrypted secrets' card with
fingerprint and broken count
- API methods: getEncryptionHealth, recoverEncryptedSecrets
- Types: EncryptionRecoveryTypes
- i18n: en/ru strings for recovery flow
Tests: 226 passed
This commit is contained in:
@@ -1081,6 +1081,16 @@ export const api = {
|
||||
// @RELATION DEPENDS_ON -> [requestApi]
|
||||
revokeApiKey: <T = unknown>(keyId: string) => requestApi<T>(`/admin/api-keys/${keyId}`, 'DELETE'),
|
||||
// #endregion revokeApiKey
|
||||
|
||||
// #region getEncryptionHealth [C:2] [TYPE Function] [SEMANTICS security,encryption,health]
|
||||
// @BRIEF Inventory all stored encrypted secrets and report broken ones.
|
||||
getEncryptionHealth: <T = unknown>() => fetchApi<T>('/security/encryption/health'),
|
||||
// #endregion getEncryptionHealth
|
||||
|
||||
// #region recoverEncryptedSecrets [C:2] [TYPE Function] [SEMANTICS security,encryption,recover]
|
||||
// @BRIEF Submit replacement secrets for items that could not be decrypted.
|
||||
recoverEncryptedSecrets: <T = unknown>(payload: unknown) => postApi<T>('/security/encryption/recover', payload),
|
||||
// #endregion recoverEncryptedSecrets
|
||||
};
|
||||
// #endregion ApiRegistry
|
||||
// #endregion ApiModule
|
||||
@@ -1130,3 +1140,5 @@ export const createConnection = api.createConnection;
|
||||
export const updateConnection = api.updateConnection;
|
||||
export const deleteConnection = api.deleteConnection;
|
||||
export const testConnection = api.testConnection;
|
||||
export const getEncryptionHealth = api.getEncryptionHealth;
|
||||
export const recoverEncryptedSecrets = api.recoverEncryptedSecrets;
|
||||
|
||||
273
frontend/src/lib/components/security/KeyRecoveryWizard.svelte
Normal file
273
frontend/src/lib/components/security/KeyRecoveryWizard.svelte
Normal file
@@ -0,0 +1,273 @@
|
||||
<!-- #region KeyRecoveryWizard [C:3] [TYPE Component] [SEMANTICS security,encryption,recovery,wizard] -->
|
||||
<!-- @BRIEF Key recovery wizard — guides admin through fixing encrypted secrets after ENCRYPTION_KEY change. -->
|
||||
<!-- @RELATION BINDS_TO -> [KeyRecoveryModel] -->
|
||||
<!-- @UX_STATE: idle -> hidden, model.state === 'idle' -> no render -->
|
||||
<!-- @UX_STATE: scanning -> spinner, key fingerprint, elapsed -->
|
||||
<!-- @UX_STATE: healthy -> green checkmark, "All credentials healthy" -->
|
||||
<!-- @UX_STATE: needs_recovery -> list by tab (LLM/DB/Git), action buttons -->
|
||||
<!-- @UX_STATE: editing -> input forms for each broken secret -->
|
||||
<!-- @UX_STATE: saving -> spinner, saving indicator -->
|
||||
<!-- @UX_STATE: partial_success -> updated/failed breakdown -->
|
||||
<!-- @UX_STATE: complete -> green checkmark, close -->
|
||||
<!-- @UX_STATE: error -> error banner, retry -->
|
||||
<!-- @UX_FEEDBACK green badge for saved items, red for failed -->
|
||||
<!-- @UX_RECOVERY Rescan button, Re-enter values -->
|
||||
<script lang="ts">
|
||||
import { KeyRecoveryModel } from "$lib/models/KeyRecoveryModel.svelte";
|
||||
import type { EncryptionHealthItem, RecoveryTab } from "../../../types/encryptionRecovery";
|
||||
|
||||
let {
|
||||
show = false,
|
||||
onclose = () => {},
|
||||
}: { show?: boolean; onclose?: () => void } = $props();
|
||||
|
||||
const model = new KeyRecoveryModel();
|
||||
|
||||
let selectedTab: RecoveryTab = $state("llm_provider");
|
||||
|
||||
$effect(() => {
|
||||
if (show && model.state === "idle") {
|
||||
model.scan();
|
||||
}
|
||||
});
|
||||
|
||||
function handleClose() {
|
||||
model.dismiss();
|
||||
onclose();
|
||||
}
|
||||
|
||||
function itemTypeLabel(type: string): string {
|
||||
switch (type) {
|
||||
case "llm_provider": return "LLM Provider";
|
||||
case "database_connection": return "DB Connection";
|
||||
case "profile_git_token": return "Git Token";
|
||||
default: return type;
|
||||
}
|
||||
}
|
||||
|
||||
function itemLocation(item: EncryptionHealthItem): string {
|
||||
switch (item.type) {
|
||||
case "llm_provider":
|
||||
return `${item.metadata?.provider_type ?? "?"} — ${item.metadata?.base_url ?? "?"}`;
|
||||
case "database_connection":
|
||||
return `${item.metadata?.host ?? "?"}/${item.metadata?.database ?? "?"} (${item.metadata?.username ?? "?"})`;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function itemIdType(item: EncryptionHealthItem): string {
|
||||
return `${item.type}#${item.id}`;
|
||||
}
|
||||
|
||||
function brokenByType(type: string): EncryptionHealthItem[] {
|
||||
return model.brokenItems.filter(i => i.type === type);
|
||||
}
|
||||
|
||||
const tabFilters: { key: RecoveryTab; label: string; brokenCount: number }[] = $derived([
|
||||
{ key: "llm_provider" as RecoveryTab, label: "LLM Providers", brokenCount: brokenByType("llm_provider").length },
|
||||
{ key: "database_connection" as RecoveryTab, label: "DB Connections", brokenCount: brokenByType("database_connection").length },
|
||||
{ key: "profile_git_token" as RecoveryTab, label: "Git Tokens", brokenCount: brokenByType("profile_git_token").length },
|
||||
]);
|
||||
|
||||
const brokenFiltered: EncryptionHealthItem[] = $derived(
|
||||
brokenByType(selectedTab)
|
||||
);
|
||||
|
||||
const recoverableTypes: Set<string> = new Set(["llm_provider", "database_connection"]);
|
||||
</script>
|
||||
|
||||
{#if show && model.state !== "idle"}
|
||||
<div
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div class="bg-surface-card rounded-lg shadow-xl w-full max-w-2xl max-h-[85vh] flex flex-col overflow-hidden">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<h2 class="text-lg font-semibold text-text">
|
||||
{model.state === "scanning"
|
||||
? "Scanning encrypted secrets..."
|
||||
: model.state === "healthy"
|
||||
? "Encrypted credentials are healthy"
|
||||
: model.state === "complete"
|
||||
? "Recovery complete"
|
||||
: "Encrypted credentials need re-entry"}
|
||||
</h2>
|
||||
<button
|
||||
class="text-text-muted hover:text-text text-xl leading-none"
|
||||
onclick={handleClose}
|
||||
aria-label="Close"
|
||||
>×</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
||||
|
||||
<!-- Scanning -->
|
||||
{#if model.state === "scanning"}
|
||||
<div class="flex items-center gap-3 py-8 justify-center">
|
||||
<span class="inline-block w-5 h-5 border-2 border-border-strong border-t-transparent rounded-full animate-spin"></span>
|
||||
<span class="text-text-muted">Checking all stored secrets...</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Error -->
|
||||
{#if model.state === "error"}
|
||||
<div class="bg-destructive-light border border-destructive-ring text-destructive px-4 py-3 rounded">
|
||||
{model.error || "Scan failed"}
|
||||
</div>
|
||||
<button
|
||||
class="rounded bg-primary px-4 py-2 text-sm text-white"
|
||||
onclick={() => model.scan()}
|
||||
>Retry scan</button>
|
||||
{/if}
|
||||
|
||||
<!-- Healthy -->
|
||||
{#if model.state === "healthy"}
|
||||
<div class="flex items-center gap-3 py-4">
|
||||
<span class="text-success text-2xl">✓</span>
|
||||
<div>
|
||||
<p class="text-text font-medium">All known encrypted secrets can be decrypted.</p>
|
||||
<p class="text-text-muted text-sm">
|
||||
Key fingerprint: <code class="font-mono">{model.health?.key_fingerprint ?? "?"}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Needs Recovery / Editing / Partial / Saving / Complete -->
|
||||
{#if ["needs_recovery", "editing", "partial_success", "saving", "complete"].includes(model.state)}
|
||||
<!-- Summary -->
|
||||
<div class="text-sm text-text-muted space-y-1">
|
||||
<p>Key fingerprint: <code class="font-mono text-xs">{model.health?.key_fingerprint ?? "?"}</code></p>
|
||||
<p>
|
||||
Changing <strong>AUTH_SECRET_KEY</strong> logs users out.
|
||||
Changing <strong>ENCRYPTION_KEY</strong> requires re-entering stored secrets or running re-encryption.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex gap-1 border-b border-border">
|
||||
{#each tabFilters as tab}
|
||||
<button
|
||||
class="px-3 py-2 text-sm font-medium border-b-2 transition-colors
|
||||
{selectedTab === tab.key ? 'border-primary text-primary' : 'border-transparent text-text-muted hover:text-text'}"
|
||||
onclick={() => { selectedTab = tab.key; }}
|
||||
>
|
||||
{tab.label}
|
||||
{#if tab.brokenCount > 0}
|
||||
<span class="ml-1 px-1.5 py-0.5 text-xs rounded-full bg-destructive-light text-destructive">{tab.brokenCount}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Item list per tab -->
|
||||
<div class="space-y-3">
|
||||
{#each brokenFiltered as item (itemIdType(item))}
|
||||
<div class="rounded-lg border border-border bg-surface-muted p-3" class:border-destructive-ring={model.failedIds.has(item.id)}>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<div>
|
||||
<span class="font-medium text-text">{item.label}</span>
|
||||
<span class="ml-2 text-xs text-text-muted">{itemTypeLabel(item.type)}</span>
|
||||
</div>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-destructive-light text-destructive">
|
||||
⚠ cannot decrypt
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-text-muted mb-2">{itemLocation(item)}</div>
|
||||
|
||||
{#if model.state === "editing" && recoverableTypes.has(item.type)}
|
||||
<div class="flex gap-2 mt-2">
|
||||
<input
|
||||
type="password"
|
||||
class="flex-1 rounded border border-border-strong px-2 py-1 text-sm"
|
||||
placeholder={item.requires[0] === "api_key" ? "Enter new API key..." : "Enter new password..."}
|
||||
value={model.secretValues[item.id] || ""}
|
||||
oninput={(e: Event) => {
|
||||
const t = e.target as HTMLInputElement;
|
||||
model.setSecretValue(item.id, t.value);
|
||||
}}
|
||||
/>
|
||||
{#if model.savingIds.has(item.id)}
|
||||
<span class="text-sm text-text-muted self-center">Saving...</span>
|
||||
{:else if model.savedIds.has(item.id)}
|
||||
<span class="text-sm text-success self-center">✓ Saved</span>
|
||||
{:else if model.failedIds.has(item.id)}
|
||||
<span class="text-sm text-destructive self-center">✗ Failed</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if item.type === "profile_git_token"}
|
||||
<p class="text-xs text-text-subtle mt-1">
|
||||
Git tokens are personal. Each user must open <strong>Profile → Git token</strong> and enter a new PAT.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if brokenFiltered.length === 0}
|
||||
<p class="text-text-muted text-sm">No broken items in this category.</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Recovery info panel -->
|
||||
{#if model.state === "needs_recovery"}
|
||||
<div class="rounded-lg bg-surface-muted border border-border p-3 text-sm">
|
||||
<p class="font-medium text-text mb-1">Recommended if old key is available:</p>
|
||||
<code class="block text-xs font-mono text-text-muted bg-surface-card p-2 rounded">
|
||||
OLD_ENCRYPTION_KEY=<old> NEW_ENCRYPTION_KEY=<new> \
|
||||
python -m src.scripts.reencrypt --dry-run
|
||||
</code>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if model.state === "partial_success"}
|
||||
<div class="rounded-lg bg-warning-light border border-warning-DEFAULT p-3 text-sm text-warning">
|
||||
Some secrets saved, some failed. Check failed items above and re-enter.
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-between px-6 py-3 border-t border-border bg-surface-muted">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="rounded px-3 py-1.5 text-sm border border-border hover:bg-surface-card transition"
|
||||
onclick={() => model.rescan()}
|
||||
disabled={model.state === "scanning" || model.state === "saving"}
|
||||
>↻ Rescan</button>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
{#if model.state === "needs_recovery"}
|
||||
<button
|
||||
class="rounded bg-primary px-4 py-1.5 text-sm text-white hover:bg-primary-hover transition"
|
||||
onclick={() => model.startEditing()}
|
||||
>Enter replacement values</button>
|
||||
{:else if model.state === "editing"}
|
||||
<button
|
||||
class="rounded px-3 py-1.5 text-sm border border-border hover:bg-surface-card transition"
|
||||
onclick={() => model.cancelEditing()}
|
||||
>Cancel</button>
|
||||
<button
|
||||
class="rounded bg-primary px-4 py-1.5 text-sm text-white hover:bg-primary-hover transition disabled:opacity-50"
|
||||
onclick={() => model.saveSecrets()}
|
||||
disabled={Object.values(model.secretValues).every(v => !v?.trim())}
|
||||
>Save entered secrets</button>
|
||||
{:else if model.state === "saving"}
|
||||
<button disabled class="rounded bg-primary px-4 py-1.5 text-sm text-white opacity-50">
|
||||
Saving...
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="rounded px-3 py-1.5 text-sm border border-border hover:bg-surface-card transition"
|
||||
onclick={handleClose}
|
||||
>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- #endregion KeyRecoveryWizard -->
|
||||
@@ -247,5 +247,33 @@
|
||||
"connections_test_success": "Connected — {latency}ms",
|
||||
"connections_test_error": "Connection test failed",
|
||||
"connections_load_failed": "Failed to load connections",
|
||||
"connections_dismiss": "Dismiss"
|
||||
"connections_dismiss": "Dismiss",
|
||||
|
||||
"encryption_recovery_title": "Encryption Key Recovery",
|
||||
"encryption_recovery_desc": "Check whether all stored encrypted credentials can be decrypted with the current ENCRYPTION_KEY. If credentials were encrypted with a different key, they must be re-entered or re-encrypted.",
|
||||
"encryption_recovery_scan": "Check encrypted secrets",
|
||||
"encryption_recovery_healthy": "All credentials healthy",
|
||||
"encryption_recovery_broken": "{count} saved credential(s) need attention",
|
||||
"encryption_recovery_scanning": "Checking all stored secrets...",
|
||||
"encryption_recovery_llm_tab": "LLM Providers",
|
||||
"encryption_recovery_db_tab": "DB Connections",
|
||||
"encryption_recovery_git_tab": "Git Tokens",
|
||||
"encryption_recovery_cannot_decrypt": "Cannot decrypt",
|
||||
"encryption_recovery_reenter_key": "Enter new API key...",
|
||||
"encryption_recovery_reenter_password": "Enter new password...",
|
||||
"encryption_recovery_save_secrets": "Save entered secrets",
|
||||
"encryption_recovery_cancel": "Cancel",
|
||||
"encryption_recovery_close": "Close",
|
||||
"encryption_recovery_rescan": "Rescan",
|
||||
"encryption_recovery_retry": "Retry scan",
|
||||
"encryption_recovery_complete": "Recovery complete",
|
||||
"encryption_recovery_key_fingerprint": "Key fingerprint",
|
||||
"encryption_recovery_auth_vs_encryption": "Changing AUTH_SECRET_KEY logs users out. Changing ENCRYPTION_KEY requires re-entering stored secrets or running re-encryption.",
|
||||
"encryption_recovery_recommended": "Recommended if old key is available:",
|
||||
"encryption_recovery_reencrypt_cmd": "OLD_ENCRYPTION_KEY=<old> NEW_ENCRYPTION_KEY=<new> \\\n python -m src.scripts.reencrypt --dry-run",
|
||||
"encryption_recovery_enter_values": "Enter replacement values",
|
||||
"encryption_recovery_git_token_hint": "Git tokens are personal. Each user must open Profile → Git token and enter a new PAT.",
|
||||
"encryption_recovery_partial_success": "Some secrets saved, some failed. Check failed items above and re-enter.",
|
||||
"encryption_recovery_saving": "Saving..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,5 +247,33 @@
|
||||
"connections_test_success": "Подключено — {latency}ms",
|
||||
"connections_test_error": "Не удалось проверить подключение",
|
||||
"connections_load_failed": "Не удалось загрузить подключения",
|
||||
"connections_dismiss": "Закрыть"
|
||||
"connections_dismiss": "Закрыть",
|
||||
|
||||
"encryption_recovery_title": "Восстановление ключа шифрования",
|
||||
"encryption_recovery_desc": "Проверить, расшифровываются ли все сохранённые секреты текущим ENCRYPTION_KEY. Если учётные данные были зашифрованы другим ключом, их нужно ввести заново или выполнить перешифрование.",
|
||||
"encryption_recovery_scan": "Проверить зашифрованные секреты",
|
||||
"encryption_recovery_healthy": "Все учётные данные в порядке",
|
||||
"encryption_recovery_broken": "{count} сохранённых секретов требуют внимания",
|
||||
"encryption_recovery_scanning": "Проверяю сохранённые секреты...",
|
||||
"encryption_recovery_llm_tab": "LLM провайдеры",
|
||||
"encryption_recovery_db_tab": "Подключения к БД",
|
||||
"encryption_recovery_git_tab": "Git токены",
|
||||
"encryption_recovery_cannot_decrypt": "Не расшифровывается",
|
||||
"encryption_recovery_reenter_key": "Введите новый API ключ...",
|
||||
"encryption_recovery_reenter_password": "Введите новый пароль...",
|
||||
"encryption_recovery_save_secrets": "Сохранить введённые секреты",
|
||||
"encryption_recovery_cancel": "Отмена",
|
||||
"encryption_recovery_close": "Закрыть",
|
||||
"encryption_recovery_rescan": "Обновить",
|
||||
"encryption_recovery_retry": "Повторить проверку",
|
||||
"encryption_recovery_complete": "Восстановление завершено",
|
||||
"encryption_recovery_key_fingerprint": "Отпечаток ключа",
|
||||
"encryption_recovery_auth_vs_encryption": "Смена AUTH_SECRET_KEY разлогинивает пользователей. Смена ENCRYPTION_KEY требует повторного ввода сохранённых секретов или перешифрования.",
|
||||
"encryption_recovery_recommended": "Рекомендуется, если доступен старый ключ:",
|
||||
"encryption_recovery_reencrypt_cmd": "OLD_ENCRYPTION_KEY=<старый> NEW_ENCRYPTION_KEY=<новый> \\\n python -m src.scripts.reencrypt --dry-run",
|
||||
"encryption_recovery_enter_values": "Ввести новые значения",
|
||||
"encryption_recovery_git_token_hint": "Git токены персональные. Каждый пользователь должен открыть Профиль → Git токен и ввести новый PAT.",
|
||||
"encryption_recovery_partial_success": "Часть секретов сохранена, часть не удалась. Проверьте ошибки выше и введите значения заново.",
|
||||
"encryption_recovery_saving": "Сохранение..."
|
||||
}
|
||||
}
|
||||
|
||||
159
frontend/src/lib/models/KeyRecoveryModel.svelte.ts
Normal file
159
frontend/src/lib/models/KeyRecoveryModel.svelte.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
// #region KeyRecoveryModel [C:3] [TYPE Model] [SEMANTICS security,encryption,recovery,model]
|
||||
// @BRIEF State model for key-change recovery wizard — inventory, editing, save.
|
||||
// @INVARIANT Editing changes are local until saved; cancel discards all edits.
|
||||
// @STATE idle — Not yet loaded.
|
||||
// @STATE scanning — Fetching health from backend.
|
||||
// @STATE healthy — All secrets decryptable.
|
||||
// @STATE needs_recovery — One or more broken secrets found.
|
||||
// @STATE editing — User entering replacement values.
|
||||
// @STATE saving — Replacement secrets being submitted.
|
||||
// @STATE partial_success — Some fixed, some failed.
|
||||
// @STATE complete — All fixed.
|
||||
// @STATE error — Scan or save failed.
|
||||
// @ACTION scan() — Fetch health from backend.
|
||||
// @ACTION startEditing(secretId) — Start editing a specific secret.
|
||||
// @ACTION setSecretValue(id, key, value) — Set a replacement value.
|
||||
// @ACTION saveSecrets() — Submit all entered replacements.
|
||||
// @ACTION cancelEditing() — Discard all edits and return to needs_recovery.
|
||||
// @ACTION dismiss() — Close wizard, return to idle.
|
||||
|
||||
import { getEncryptionHealth, recoverEncryptedSecrets } from "$lib/api";
|
||||
import type {
|
||||
EncryptionHealthResponse,
|
||||
EncryptionHealthItem,
|
||||
RecoveryResponse,
|
||||
RecoveryWizardState,
|
||||
RecoveryTab,
|
||||
SecretStatus,
|
||||
} from "../../types/encryptionRecovery";
|
||||
|
||||
interface SecretEntry {
|
||||
item: EncryptionHealthItem;
|
||||
editValue: string; // replacement value entered by user
|
||||
}
|
||||
|
||||
export class KeyRecoveryModel {
|
||||
// ── Atoms ──────────────────────────────────────────────────────
|
||||
state: RecoveryWizardState = $state("idle");
|
||||
health: EncryptionHealthResponse | null = $state(null);
|
||||
error: string | null = $state(null);
|
||||
activeTab: RecoveryTab = $state("llm_provider");
|
||||
|
||||
// Form values: Map<itemId, replacementString>
|
||||
secretValues: Record<string, string> = $state({});
|
||||
savingIds: Set<string> = $state(new Set());
|
||||
savedIds: Set<string> = $state(new Set());
|
||||
failedIds: Set<string> = $state(new Set());
|
||||
|
||||
// ── Derived ────────────────────────────────────────────────────
|
||||
brokenItems: EncryptionHealthItem[] = $derived(
|
||||
this.health?.items?.filter(i => i.status === "broken") ?? []
|
||||
);
|
||||
|
||||
brokenByType = $derived.by((type: string) =>
|
||||
this.brokenItems.filter(i => i.type === type)
|
||||
);
|
||||
|
||||
allHealthy: boolean = $derived(
|
||||
this.health?.status === "healthy"
|
||||
);
|
||||
|
||||
// Count of broken items that still need attention
|
||||
remainingBroken: number = $derived(
|
||||
this.brokenItems.length - this.savedIds.size
|
||||
);
|
||||
|
||||
// ── Actions ────────────────────────────────────────────────────
|
||||
async scan(): Promise<void> {
|
||||
this.state = "scanning";
|
||||
this.error = null;
|
||||
try {
|
||||
const data = await getEncryptionHealth<EncryptionHealthResponse>();
|
||||
this.health = data;
|
||||
this.secretValues = {};
|
||||
this.savingIds = new Set();
|
||||
this.savedIds = new Set();
|
||||
this.failedIds = new Set();
|
||||
this.state = data.status === "healthy" ? "healthy" : "needs_recovery";
|
||||
} catch (e: unknown) {
|
||||
this.error = e instanceof Error ? e.message : "Scan failed";
|
||||
this.state = "error";
|
||||
}
|
||||
}
|
||||
|
||||
startEditing(): void {
|
||||
this.state = "editing";
|
||||
}
|
||||
|
||||
setSecretValue(id: string, value: string): void {
|
||||
this.secretValues = { ...this.secretValues, [id]: value };
|
||||
}
|
||||
|
||||
cancelEditing(): void {
|
||||
if (this.health) {
|
||||
this.state = this.health.status === "healthy" ? "healthy" : "needs_recovery";
|
||||
} else {
|
||||
this.state = "idle";
|
||||
}
|
||||
}
|
||||
|
||||
async saveSecrets(): Promise<void> {
|
||||
this.state = "saving";
|
||||
this.error = null;
|
||||
const items = [];
|
||||
for (const item of this.brokenItems) {
|
||||
const val = this.secretValues[item.id];
|
||||
if (val && val.trim()) {
|
||||
const key = item.requires[0] || "value";
|
||||
items.push({ id: item.id, type: item.type, values: { [key]: val } });
|
||||
this.savingIds = new Set([...this.savingIds, item.id]);
|
||||
}
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
this.state = "needs_recovery";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await recoverEncryptedSecrets<RecoveryResponse>({ items });
|
||||
const newSaved = new Set(this.savedIds);
|
||||
const newFailed = new Set(this.failedIds);
|
||||
for (const r of resp.updated) {
|
||||
newSaved.add(r.id);
|
||||
this.savingIds.delete(r.id);
|
||||
}
|
||||
for (const r of resp.failed) {
|
||||
newFailed.add(r.id);
|
||||
this.savingIds.delete(r.id);
|
||||
}
|
||||
this.savedIds = newSaved;
|
||||
this.failedIds = newFailed;
|
||||
|
||||
if (resp.status === "complete") {
|
||||
this.state = "complete";
|
||||
await this.scan();
|
||||
} else if (resp.status === "partial_success") {
|
||||
this.state = "partial_success";
|
||||
await this.scan();
|
||||
} else {
|
||||
this.state = "error";
|
||||
this.error = "All recoveries failed";
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
this.error = e instanceof Error ? e.message : "Save failed";
|
||||
this.state = "error";
|
||||
}
|
||||
}
|
||||
|
||||
dismiss(): void {
|
||||
this.state = "idle";
|
||||
this.health = null;
|
||||
this.error = null;
|
||||
}
|
||||
|
||||
rescan(): void {
|
||||
this.scan();
|
||||
}
|
||||
}
|
||||
// #endregion KeyRecoveryModel
|
||||
@@ -6,10 +6,27 @@
|
||||
<script lang="ts">
|
||||
import { t, locale } from "$lib/i18n/index.svelte.js";
|
||||
import ApiKeysTab from "$lib/components/settings/ApiKeysTab.svelte";
|
||||
import KeyRecoveryWizard from "$lib/components/security/KeyRecoveryWizard.svelte";
|
||||
import { appTimezone } from "$lib/stores/timezone.svelte.js";
|
||||
import { getEncryptionHealth } from "$lib/api";
|
||||
import type { EncryptionHealthResponse } from "../../types/encryptionRecovery";
|
||||
|
||||
let { settings = $bindable(), onSave } = $props();
|
||||
|
||||
let showRecoveryWizard = $state(false);
|
||||
let encHealthSummary: { fingerprint: string; broken: number } | null = $state(null);
|
||||
|
||||
async function loadEncHealth() {
|
||||
try {
|
||||
const data = await getEncryptionHealth<EncryptionHealthResponse>();
|
||||
const broken = data.items.filter(i => i.status === "broken").length;
|
||||
encHealthSummary = { fingerprint: data.key_fingerprint, broken };
|
||||
} catch {
|
||||
encHealthSummary = null;
|
||||
}
|
||||
}
|
||||
$effect(() => { loadEncHealth(); });
|
||||
|
||||
async function handleSave() {
|
||||
// Sync app timezone to global store before persisting
|
||||
if (settings.app_timezone) {
|
||||
@@ -99,5 +116,39 @@
|
||||
<div class="border-t border-border pt-8">
|
||||
<ApiKeysTab />
|
||||
</div>
|
||||
|
||||
<!-- Encryption Key Recovery -->
|
||||
<div class="border-t border-border pt-8 mt-8">
|
||||
<h2 class="text-xl font-bold mb-4">Encryption Key Recovery</h2>
|
||||
<p class="text-text-muted mb-4">
|
||||
Check whether all stored encrypted credentials can be decrypted with the current ENCRYPTION_KEY.
|
||||
If credentials were encrypted with a different key, they must be re-entered or re-encrypted.
|
||||
</p>
|
||||
|
||||
<div class="bg-surface-muted rounded-lg border border-border p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-text">
|
||||
Key fingerprint: <code class="font-mono text-xs">{encHealthSummary?.fingerprint ?? "loading..."}</code>
|
||||
</div>
|
||||
<div class="text-sm mt-1">
|
||||
{#if encHealthSummary === null}
|
||||
<span class="text-text-muted">Checking...</span>
|
||||
{:else if encHealthSummary.broken === 0}
|
||||
<span class="text-success">✓ All credentials healthy</span>
|
||||
{:else}
|
||||
<span class="text-destructive">⚠ {encHealthSummary.broken} saved credential{encHealthSummary.broken !== 1 ? "s" : ""} need attention</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="rounded bg-primary px-4 py-2 text-sm text-white hover:bg-primary-hover transition"
|
||||
onclick={async () => { await loadEncHealth(); showRecoveryWizard = true; }}
|
||||
>Check encrypted secrets</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<KeyRecoveryWizard show={showRecoveryWizard} onclose={() => { showRecoveryWizard = false; }} />
|
||||
</div>
|
||||
<!-- #endregion SystemSettings -->
|
||||
|
||||
73
frontend/src/types/encryptionRecovery.ts
Normal file
73
frontend/src/types/encryptionRecovery.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
// #region EncryptionRecoveryTypes [C:2] [TYPE Module] [SEMANTICS types,encryption,recovery,health]
|
||||
// @BRIEF TypeScript DTOs for encryption health inventory and key-change recovery.
|
||||
// @RELATION DEPENDS_ON -> [EncryptionHealthRoutes:Backend]
|
||||
|
||||
export type EncryptedSecretType = "llm_provider" | "database_connection" | "profile_git_token";
|
||||
|
||||
export type SecretStatus = "healthy" | "broken" | "missing_key";
|
||||
|
||||
export interface EncryptionHealthItem {
|
||||
id: string;
|
||||
type: EncryptedSecretType;
|
||||
label: string;
|
||||
status: SecretStatus;
|
||||
reason: string | null;
|
||||
requires: string[];
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface EncryptionSummary {
|
||||
llm_providers_total: number;
|
||||
llm_providers_broken: number;
|
||||
connections_total: number;
|
||||
connections_broken: number;
|
||||
profile_tokens_broken: number;
|
||||
}
|
||||
|
||||
export interface EncryptionHealthResponse {
|
||||
status: "healthy" | "needs_recovery";
|
||||
key_fingerprint: string;
|
||||
summary: EncryptionSummary;
|
||||
items: EncryptionHealthItem[];
|
||||
}
|
||||
|
||||
export interface FingerprintResponse {
|
||||
fingerprint: string;
|
||||
}
|
||||
|
||||
export interface RecoveryItem {
|
||||
id: string;
|
||||
type: EncryptedSecretType;
|
||||
values: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface RecoveryPayload {
|
||||
items: RecoveryItem[];
|
||||
}
|
||||
|
||||
export interface RecoveryResultItem {
|
||||
id: string;
|
||||
type: EncryptedSecretType | string;
|
||||
status: "updated" | "failed" | "skipped";
|
||||
}
|
||||
|
||||
export interface RecoveryResponse {
|
||||
status: "complete" | "partial_success" | "failed";
|
||||
updated: RecoveryResultItem[];
|
||||
failed: RecoveryResultItem[];
|
||||
}
|
||||
|
||||
export type RecoveryWizardState =
|
||||
| "idle"
|
||||
| "scanning"
|
||||
| "healthy"
|
||||
| "needs_recovery"
|
||||
| "editing"
|
||||
| "saving"
|
||||
| "partial_success"
|
||||
| "complete"
|
||||
| "error";
|
||||
|
||||
export type RecoveryTab = "llm_provider" | "database_connection" | "profile_git_token";
|
||||
|
||||
// #endregion EncryptionRecoveryTypes
|
||||
Reference in New Issue
Block a user