feat(ssh): prompt to reset a changed host fingerprint
This commit is contained in:
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();
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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: "请输入服务器要求的验证信息。",
|
||||
|
||||
@@ -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: "請輸入伺服器要求的驗證資訊。",
|
||||
|
||||
Reference in new issue
Block a user