feat(ssh): prompt to reset a changed host fingerprint

This commit is contained in:
64673a authored and GitHub committed 2026-09-09 16:29:31 +08:00
1 parent fd44331876
commit c30fc33305
14 files changed
+418 -32

No files matched your search

@@ -2,6 +2,7 @@
import { ref, computed, onMounted, onBeforeUnmount } from "vue";
import { useI18n } from "vue-i18n";
import { invoke } from "@tauri-apps/api/core";
import { TriangleAlert } from "@lucide/vue";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { useToast } from "@/composables/useToast";
@@ -11,11 +12,12 @@ import { apiUrl } from "@/lib/common/webPath";
interface SshPromptRequest {
id: string;
kind: "HostKeyVerify" | "SecretInput" | "WorkerUploadConsent";
kind: "HostKeyVerify" | "HostKeyChanged" | "SecretInput" | "WorkerUploadConsent";
host: string;
port: number;
key_type?: string | null;
fingerprint?: string | null;
previous_fingerprint?: string | null;
prompt?: string | null;
echo?: boolean;
}
@@ -38,6 +40,19 @@ const queue = ref<SshPromptRequest[]>([]);
const current = computed<SshPromptRequest | null>(() => queue.value[0] ?? null);
const isSecretPrompt = computed(() => current.value?.kind === "SecretInput");
const isWorkerUploadPrompt = computed(() => current.value?.kind === "WorkerUploadConsent");
const isHostKeyChanged = computed(() => current.value?.kind === "HostKeyChanged");
const titleKey = computed(() => {
if (isSecretPrompt.value) return "connection.sshInteractiveTitle";
if (isWorkerUploadPrompt.value) return "connection.sshWorkerUploadConsentTitle";
if (isHostKeyChanged.value) return "connection.sshHostKeyChangedTitle";
return "connection.sshHostKeyVerifyTitle";
});
const messageKey = computed(() => {
if (isSecretPrompt.value) return "connection.sshInteractiveMessage";
if (isWorkerUploadPrompt.value) return "connection.sshWorkerUploadConsentMessage";
if (isHostKeyChanged.value) return "connection.sshHostKeyChangedMessage";
return "connection.sshHostKeyVerifyMessage";
});
const visible = ref(false);
const remember = ref(true);
@@ -224,10 +239,24 @@ function resetPromptState() {
secretCode.value = "";
}
function fingerprintBody(value: string | null | undefined): string {
return (value ?? "").replace(/^SHA256:/i, "");
}
function accept() {
void resolve("accept");
}
function acceptSessionOnly() {
remember.value = false;
void resolve("accept");
}
function acceptAndUpdate() {
remember.value = true;
void resolve("accept");
}
function reject() {
void resolve("reject");
}
@@ -250,16 +279,35 @@ function submitSecret() {
100). -->
<DialogContent class="flex max-h-[min(36rem,calc(var(--dbx-viewport-height)-2rem))] w-full max-w-[32rem] flex-col gap-4 overflow-hidden" overlay-class="z-[200]" portal-class="z-[200]" :show-close-button="false" @interact-outside.prevent @escape-key-down.prevent>
<DialogHeader class="shrink-0">
<DialogTitle>
{{ t(isSecretPrompt ? "connection.sshInteractiveTitle" : isWorkerUploadPrompt ? "connection.sshWorkerUploadConsentTitle" : "connection.sshHostKeyVerifyTitle") }}
</DialogTitle>
<DialogTitle>{{ t(titleKey) }}</DialogTitle>
<DialogDescription class="text-muted-foreground">
{{ t(isSecretPrompt ? "connection.sshInteractiveMessage" : isWorkerUploadPrompt ? "connection.sshWorkerUploadConsentMessage" : "connection.sshHostKeyVerifyMessage", { host: current?.host ?? "", port: current?.port ?? "" }) }}
{{ t(messageKey, { host: current?.host ?? "", port: current?.port ?? "" }) }}
</DialogDescription>
</DialogHeader>
<div v-if="current" class="min-h-0 flex-1 space-y-3 overflow-y-auto py-1">
<div v-if="current.kind === 'HostKeyVerify' || current.kind === 'WorkerUploadConsent'" class="space-y-3 rounded-md border border-border bg-muted/40 p-3 text-sm">
<div v-if="isHostKeyChanged" class="space-y-3">
<div class="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<TriangleAlert class="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
<div class="space-y-0.5">
<div class="font-medium">{{ t("connection.sshHostKeyChangedTitle") }}</div>
<div class="text-destructive/90">{{ t("connection.sshHostKeyChangedMessage", { host: current.host, port: current.port }) }}</div>
</div>
</div>
<div class="space-y-2 text-sm">
<div class="space-y-1">
<div class="text-muted-foreground">{{ t("connection.sshHostKeyChangedCurrent", { keyType: current.key_type || "—" }) }}</div>
<div class="break-all rounded-md border border-border bg-muted/40 px-3 py-2 font-mono text-xs font-medium leading-5">{{ fingerprintBody(current.fingerprint) || "—" }}</div>
</div>
<div class="space-y-1">
<div class="text-destructive">{{ t("connection.sshHostKeyChangedSaved") }}</div>
<div class="break-all rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 font-mono text-xs font-medium leading-5 text-destructive">{{ fingerprintBody(current.previous_fingerprint) || "—" }}</div>
</div>
<p class="text-xs text-muted-foreground">{{ t("connection.sshHostKeyChangedWarning") }}</p>
</div>
</div>
<div v-else-if="current.kind === 'HostKeyVerify' || current.kind === 'WorkerUploadConsent'" class="space-y-3 rounded-md border border-border bg-muted/40 p-3 text-sm">
<div class="space-y-1">
<div class="text-muted-foreground">{{ current.kind === "WorkerUploadConsent" ? t("connection.sshWorkerUploadConsentDigest") : t("connection.sshHostKeyVerifyKeyType") }}</div>
<div class="break-all font-mono text-xs font-medium leading-5">{{ current.kind === "WorkerUploadConsent" ? current.fingerprint || "—" : current.key_type || "—" }}</div>
@@ -292,9 +340,17 @@ function submitSecret() {
<DialogFooter class="shrink-0">
<Button variant="outline" :disabled="resolving" @click="reject">
{{ t(isSecretPrompt ? "connection.sshInteractiveCancel" : isWorkerUploadPrompt ? "connection.sshWorkerUploadConsentReject" : "connection.sshHostKeyVerifyReject") }}
{{ t(isSecretPrompt ? "connection.sshInteractiveCancel" : isWorkerUploadPrompt ? "connection.sshWorkerUploadConsentReject" : isHostKeyChanged ? "connection.sshHostKeyChangedClose" : "connection.sshHostKeyVerifyReject") }}
</Button>
<Button v-if="current?.kind !== 'SecretInput'" :disabled="resolving" @click="accept">
<template v-if="isHostKeyChanged">
<Button variant="outline" :disabled="resolving" @click="acceptSessionOnly">
{{ t("connection.sshHostKeyChangedContinue") }}
</Button>
<Button :disabled="resolving" @click="acceptAndUpdate">
{{ t("connection.sshHostKeyChangedUpdate") }}
</Button>
</template>
<Button v-else-if="current?.kind !== 'SecretInput'" :disabled="resolving" @click="accept">
{{ t(isWorkerUploadPrompt ? "connection.sshWorkerUploadConsentAccept" : "connection.sshHostKeyVerifyAccept") }}
</Button>
<Button v-else :disabled="resolving || !secretCode" @click="submitSecret">
@@ -168,6 +168,77 @@ describe("SshHostKeyPromptDialog web bridge", () => {
});
});
it("shows a changed host-key prompt and updates the saved fingerprint", async () => {
await mountDialog();
const eventSource = MockEventSource.instances[0];
eventSource?.emit({
type: "prompt",
request: {
id: "changed-1",
kind: "HostKeyChanged",
host: "192.168.1.111",
port: 22,
key_type: "ssh-ed25519",
fingerprint: "SHA256:new-fingerprint",
previous_fingerprint: "SHA256:old-fingerprint",
},
});
await nextTick();
expect(document.body.textContent).toContain("Host fingerprint has changed");
expect(document.body.textContent).toContain("new-fingerprint");
expect(document.body.textContent).toContain("old-fingerprint");
expect(document.body.textContent).toContain("Saved fingerprint");
const buttons = [...document.body.querySelectorAll("button")].map((button) => button.textContent?.trim());
expect(buttons).toContain("Close");
expect(buttons).toContain("Continue");
expect(buttons).toContain("Update and Continue");
const update = [...document.body.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.trim() === "Update and Continue");
update?.click();
await vi.waitFor(() => {
expect(resolveSshPromptMock).toHaveBeenCalledWith({
id: "changed-1",
action: "accept",
remember: true,
secret: undefined,
});
});
});
it("continues a changed host key for this session only", async () => {
await mountDialog();
MockEventSource.instances[0]?.emit({
type: "prompt",
request: {
id: "changed-2",
kind: "HostKeyChanged",
host: "board.example.test",
port: 22,
key_type: "ssh-ed25519",
fingerprint: "SHA256:new",
previous_fingerprint: "SHA256:old",
},
});
await nextTick();
const cont = [...document.body.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.trim() === "Continue");
cont?.click();
await vi.waitFor(() => {
expect(resolveSshPromptMock).toHaveBeenCalledWith({
id: "changed-2",
action: "accept",
remember: false,
secret: undefined,
});
});
});
it("clears prompts that are no longer pending after an SSE reconnect snapshot", async () => {
await mountDialog();
+8
View File
@@ -1102,6 +1102,14 @@ export default {
sshHostKeyVerifyRemember: "Trust this host (remember this key for future connections)",
sshHostKeyVerifyAccept: "Accept & Connect",
sshHostKeyVerifyReject: "Cancel",
sshHostKeyChangedTitle: "Host fingerprint has changed",
sshHostKeyChangedMessage: "The saved fingerprint for {host} does not match the current server.",
sshHostKeyChangedCurrent: "{keyType} fingerprint is SHA256:",
sshHostKeyChangedSaved: "Saved fingerprint",
sshHostKeyChangedWarning: "Continue only if you are sure this host has actually changed.",
sshHostKeyChangedClose: "Close",
sshHostKeyChangedContinue: "Continue",
sshHostKeyChangedUpdate: "Update and Continue",
sshInteractiveTitle: "SSH Verification Required",
sshInteractiveMessage: "The SSH server at {host}:{port} requires additional verification.",
sshInteractiveDefaultPrompt: "Enter the requested verification response.",
+8
View File
@@ -1029,6 +1029,14 @@ export default withEnglishFallback({
sshHostKeyVerifyRemember: "信任此主机(记住该密钥用于以后的连接)",
sshHostKeyVerifyAccept: "接受并连接",
sshHostKeyVerifyReject: "取消",
sshHostKeyChangedTitle: "主机指纹已变化",
sshHostKeyChangedMessage: "{host} 的已保存指纹与当前服务器不一致。",
sshHostKeyChangedCurrent: "{keyType} 指纹为 SHA256:",
sshHostKeyChangedSaved: "已保存的指纹",
sshHostKeyChangedWarning: "只有在你确认这台主机确实变更过时才继续。",
sshHostKeyChangedClose: "关闭",
sshHostKeyChangedContinue: "继续",
sshHostKeyChangedUpdate: "更新并继续",
sshInteractiveTitle: "需要 SSH 验证",
sshInteractiveMessage: "SSH 服务器 {host}:{port} 要求完成额外验证。",
sshInteractiveDefaultPrompt: "请输入服务器要求的验证信息。",
+8
View File
@@ -1085,6 +1085,14 @@ export default withEnglishFallback({
sshHostKeyVerifyRemember: "信任此主機(記住此金鑰用於之後的連線)",
sshHostKeyVerifyAccept: "接受並連線",
sshHostKeyVerifyReject: "取消",
sshHostKeyChangedTitle: "主機指紋已變化",
sshHostKeyChangedMessage: "{host} 的已儲存指紋與目前伺服器不一致。",
sshHostKeyChangedCurrent: "{keyType} 指紋為 SHA256:",
sshHostKeyChangedSaved: "已儲存的指紋",
sshHostKeyChangedWarning: "只有在你確認這台主機確實變更過時才繼續。",
sshHostKeyChangedClose: "關閉",
sshHostKeyChangedContinue: "繼續",
sshHostKeyChangedUpdate: "更新並繼續",
sshInteractiveTitle: "需要 SSH 驗證",
sshInteractiveMessage: "SSH 伺服器 {host}:{port} 要求完成額外驗證。",
sshInteractiveDefaultPrompt: "請輸入伺服器要求的驗證資訊。",