feat: 增强 SSH 主机密钥验证,支持键盘交互认证并优化 UI 提示
This commit is contained in:
1 parent
663cf6abfb
commit
c040c1b9fd
12 files changed
+325
-51
No files matched your search
@@ -7,3 +7,4 @@ coverage/
|
||||
.env*
|
||||
!.env.example
|
||||
.workbuddy/
|
||||
tests/
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
- Cloudflare Workers 原生部署,使用 Durable Objects 隔离每个 SSH 会话。
|
||||
- 基于 xterm.js 的响应式终端,支持桌面端和移动端、自动缩放、全屏和会话日志。
|
||||
- 支持密码认证,以及 Ed25519、RSA、ECDSA 的未加密 OpenSSH 私钥认证。
|
||||
- 支持 SSH password 与单密码提示的 keyboard-interactive 认证,以及 Ed25519、RSA、ECDSA 的未加密 OpenSSH 私钥认证。
|
||||
- 首次连接时暂停认证并显示主机 SHA-256 指纹,确认后才会发送 SSH 凭据。
|
||||
- SSH 连接成功后自动写入浏览器本地"历史记录",密码使用 AES-256-GCM 加密,私钥不保存。
|
||||
- 支持 UTF-8、GB18030、Big5 显示编码、初始命令和分享链接(密码认证时链接携带 Base64 密码,粘贴即自动连接)。
|
||||
@@ -40,7 +40,7 @@ Cloudflare Worker
|
||||
| 类别 | 当前支持 |
|
||||
| --- | --- |
|
||||
| SSH 协议 | SSH 2.0 交互式 Shell、PTY、窗口尺寸同步、Keepalive |
|
||||
| 用户认证 | Password、OpenSSH Ed25519、RSA、ECDSA P-256/P-384/P-521 私钥 |
|
||||
| 用户认证 | Password、单密码提示 keyboard-interactive、OpenSSH Ed25519、RSA、ECDSA P-256/P-384/P-521 私钥 |
|
||||
| 密钥交换 | `curve25519-sha256`、`ecdh-sha2-nistp256` |
|
||||
| 主机密钥 | Ed25519、ECDSA P-256/P-384/P-521、RSA SHA-2 |
|
||||
| 加密算法 | AES-128/256-GCM、AES-128/192/256-CTR |
|
||||
|
||||
+7
-9
@@ -294,27 +294,25 @@
|
||||
|
||||
<dialog id="host-key-dialog" class="host-key-dialog" aria-labelledby="host-key-title">
|
||||
<form method="dialog">
|
||||
<span class="dialog-icon" aria-hidden="true" data-i18n-zh="密钥" data-i18n-en="KEY">密钥</span>
|
||||
<span id="host-key-icon" class="dialog-icon" aria-hidden="true">密钥</span>
|
||||
<div>
|
||||
<p class="eyebrow" data-i18n-zh="主机身份" data-i18n-en="HOST IDENTITY">主机身份</p>
|
||||
<h2 id="host-key-title" data-i18n-zh="信任此 SSH 主机?" data-i18n-en="Trust this SSH host?">信任此 SSH 主机?</h2>
|
||||
<p
|
||||
data-i18n-zh="服务器提供了尚未为此目标固定的密钥。继续前,请通过可信渠道核验。"
|
||||
data-i18n-en="The server presented a key that has not been pinned for this target. Verify it through a trusted channel before continuing."
|
||||
>服务器提供了尚未为此目标固定的密钥。继续前,请通过可信渠道核验。</p>
|
||||
<p id="host-key-eyebrow" class="eyebrow">主机身份</p>
|
||||
<h2 id="host-key-title">信任此 SSH 主机?</h2>
|
||||
<p id="host-key-description">服务器提供了尚未为此目标固定的密钥。继续前,请通过可信渠道核验。</p>
|
||||
</div>
|
||||
<dl>
|
||||
<div><dt data-i18n-zh="目标" data-i18n-en="Target">目标</dt><dd id="host-key-target">--</dd></div>
|
||||
<div><dt data-i18n-zh="密钥类型" data-i18n-en="Key type">密钥类型</dt><dd id="host-key-type">--</dd></div>
|
||||
<div id="host-key-expected-row" hidden><dt data-i18n-zh="之前的指纹" data-i18n-en="Previous fingerprint">之前的指纹</dt><dd id="host-key-expected-fingerprint">--</dd></div>
|
||||
<div><dt data-i18n-zh="SHA-256 指纹" data-i18n-en="SHA-256 fingerprint">SHA-256 指纹</dt><dd id="host-key-fingerprint">--</dd></div>
|
||||
</dl>
|
||||
<label class="trust-check">
|
||||
<input id="remember-host-key" type="checkbox" checked />
|
||||
<span data-i18n-zh="下次记住并验证此指纹" data-i18n-en="Remember and verify this fingerprint next time">下次记住并验证此指纹</span>
|
||||
<span id="remember-host-key-label">下次记住并验证此指纹</span>
|
||||
</label>
|
||||
<div class="dialog-actions">
|
||||
<button id="reject-host-key" class="secondary-button" value="reject" type="submit" data-i18n-zh="取消连接" data-i18n-en="Cancel">取消连接</button>
|
||||
<button id="accept-host-key" class="primary-button" value="accept" type="submit" data-i18n-zh="信任并继续" data-i18n-en="Trust & continue">信任并继续</button>
|
||||
<button id="accept-host-key" class="primary-button" value="accept" type="submit">信任并继续</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
export const SSH_FINGERPRINT_RE = /^SHA256:[A-Za-z0-9+/]{43}$/;
|
||||
|
||||
export type HostKeyTrust = 'first-seen' | 'changed';
|
||||
|
||||
export interface HostKeyPrompt {
|
||||
fingerprint: string;
|
||||
keyType: string;
|
||||
expectedFingerprint: string;
|
||||
trust: HostKeyTrust;
|
||||
}
|
||||
|
||||
export function classifyHostKey(
|
||||
expectedFingerprint: string,
|
||||
fingerprint: string,
|
||||
): 'matched' | HostKeyTrust {
|
||||
if (!expectedFingerprint) return 'first-seen';
|
||||
return expectedFingerprint === fingerprint ? 'matched' : 'changed';
|
||||
}
|
||||
|
||||
+118
-20
@@ -5,6 +5,7 @@ import { historyKey, historyLabel, normalizeHistory, upsertHistoryIfNewer, type
|
||||
import { getHistoryPasswordKey } from './history-key';
|
||||
import { decryptPasswordResult, encryptPassword, isEncryptedPassword } from './password-crypto';
|
||||
import { resolveConnectionControl, resolveConnectionPanel } from './ui-state';
|
||||
import { classifyHostKey, SSH_FINGERPRINT_RE, type HostKeyPrompt } from './host-key';
|
||||
import './style.css';
|
||||
|
||||
type AuthMethod = 'password' | 'publickey';
|
||||
@@ -62,6 +63,7 @@ interface ServerMessage {
|
||||
event?: string;
|
||||
message?: string;
|
||||
fingerprint?: string;
|
||||
expectedFingerprint?: string;
|
||||
keyType?: string;
|
||||
trusted?: boolean;
|
||||
latency?: number;
|
||||
@@ -287,10 +289,17 @@ const ui = {
|
||||
eventLog: element<HTMLElement>('event-log'),
|
||||
toastRegion: element<HTMLElement>('toast-region'),
|
||||
hostKeyDialog: element<HTMLDialogElement>('host-key-dialog'),
|
||||
hostKeyIcon: element<HTMLElement>('host-key-icon'),
|
||||
hostKeyEyebrow: element<HTMLElement>('host-key-eyebrow'),
|
||||
hostKeyTitle: element<HTMLElement>('host-key-title'),
|
||||
hostKeyDescription: element<HTMLElement>('host-key-description'),
|
||||
hostKeyTarget: element<HTMLElement>('host-key-target'),
|
||||
hostKeyType: element<HTMLElement>('host-key-type'),
|
||||
hostKeyExpectedRow: element<HTMLElement>('host-key-expected-row'),
|
||||
hostKeyExpectedFingerprint: element<HTMLElement>('host-key-expected-fingerprint'),
|
||||
hostKeyFingerprint: element<HTMLElement>('host-key-fingerprint'),
|
||||
rememberHostKey: element<HTMLInputElement>('remember-host-key'),
|
||||
rememberHostKeyLabel: element<HTMLElement>('remember-host-key-label'),
|
||||
rejectHostKey: element<HTMLButtonElement>('reject-host-key'),
|
||||
acceptHostKey: element<HTMLButtonElement>('accept-host-key'),
|
||||
};
|
||||
@@ -353,7 +362,7 @@ let sessionStartedAt = 0;
|
||||
let uptimeTimer: number | null = null;
|
||||
let pingTimer: number | null = null;
|
||||
let lastPingAt = 0;
|
||||
let pendingHostKey: { fingerprint: string; keyType: string } | null = null;
|
||||
let pendingHostKey: HostKeyPrompt | null = null;
|
||||
let currentTargetKey = '';
|
||||
let currentTargetLabel = '';
|
||||
let currentInitialCommand = '';
|
||||
@@ -364,6 +373,7 @@ let awaitingHostKeyDecision = false;
|
||||
let connectGeneration = 0;
|
||||
let authorizationAbort: AbortController | null = null;
|
||||
let currentExpectedFingerprint = '';
|
||||
let currentRememberedFingerprint = '';
|
||||
let currentSessionSubtitle: LocalizedMessage = { zh: '选择目标并连接', en: 'Choose a target and connect' };
|
||||
let currentEventMessage: LocalizedMessage = { zh: 'Worker 运行时待命', en: 'Worker runtime standing by' };
|
||||
let currentFormError: LocalizedMessage | null = null;
|
||||
@@ -442,7 +452,7 @@ function isStoredSavedProfile(value: unknown): value is StoredSavedProfile {
|
||||
typeof item.encoding === 'string' &&
|
||||
['utf-8', 'gb18030', 'big5'].includes(item.encoding) &&
|
||||
typeof item.fingerprint === 'string' &&
|
||||
(item.fingerprint === '' || /^SHA256:[A-Za-z0-9+/]{43}$/.test(item.fingerprint)) &&
|
||||
(item.fingerprint === '' || SSH_FINGERPRINT_RE.test(item.fingerprint)) &&
|
||||
typeof item.updatedAt === 'number' &&
|
||||
Number.isFinite(item.updatedAt) &&
|
||||
item.updatedAt >= 0 &&
|
||||
@@ -486,7 +496,7 @@ function isLegacySavedProfile(value: unknown): value is LegacySavedProfile {
|
||||
&& typeof item.initialCommand === 'string' && item.initialCommand.length <= 4096
|
||||
&& typeof item.termType === 'string' && /^[A-Za-z0-9._+-]{1,64}$/.test(item.termType)
|
||||
&& typeof item.encoding === 'string' && ['utf-8', 'gb18030', 'big5'].includes(item.encoding)
|
||||
&& typeof item.fingerprint === 'string' && (item.fingerprint === '' || /^SHA256:[A-Za-z0-9+/]{43}$/.test(item.fingerprint))
|
||||
&& typeof item.fingerprint === 'string' && (item.fingerprint === '' || SSH_FINGERPRINT_RE.test(item.fingerprint))
|
||||
&& typeof item.updatedAt === 'number' && Number.isFinite(item.updatedAt)
|
||||
&& item.updatedAt >= 0 && item.updatedAt <= 8_640_000_000_000_000;
|
||||
}
|
||||
@@ -617,7 +627,7 @@ function loadHostKeys(): Record<string, string> {
|
||||
const parsed: unknown = JSON.parse(localStorage.getItem(HOST_KEY_STORAGE_KEY) ?? '{}');
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
|
||||
return Object.fromEntries(
|
||||
Object.entries(parsed).filter(([key, value]) => key.length <= 512 && typeof value === 'string' && /^SHA256:[A-Za-z0-9+/]{43}$/.test(value)),
|
||||
Object.entries(parsed).filter(([key, value]) => key.length <= 512 && typeof value === 'string' && SSH_FINGERPRINT_RE.test(value)),
|
||||
);
|
||||
} catch {
|
||||
return {};
|
||||
@@ -668,9 +678,52 @@ async function persistHistoryMutation(mutation: HistoryMutation): Promise<Histor
|
||||
}
|
||||
}
|
||||
|
||||
function persistHostKeys(): void {
|
||||
try { localStorage.setItem(HOST_KEY_STORAGE_KEY, JSON.stringify(hostKeys)); }
|
||||
catch { toast(bilingual('此浏览器无法保存主机指纹。', 'Host fingerprints could not be saved in this browser.'), 'error'); }
|
||||
function persistHostKeys(): boolean {
|
||||
try {
|
||||
localStorage.setItem(HOST_KEY_STORAGE_KEY, JSON.stringify(hostKeys));
|
||||
return true;
|
||||
} catch {
|
||||
toast(bilingual('此浏览器无法保存主机指纹。', 'Host fingerprints could not be saved in this browser.'), 'error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function replaceRememberedHostKey(target: string, fingerprint: string): Promise<void> {
|
||||
const write = (): boolean => {
|
||||
const snapshot = readStoredArray(PROFILE_STORAGE_KEY);
|
||||
const stored = snapshot.values
|
||||
.map(sanitizeSavedProfile)
|
||||
.filter((profile): profile is SavedProfile => profile !== null);
|
||||
const base = snapshot.present ? normalizeHistory(stored) : profiles;
|
||||
const baseHostKeys = loadHostKeys();
|
||||
const updated = base.map((profile) => passwordContext(profile) === target
|
||||
? { ...profile, fingerprint }
|
||||
: profile);
|
||||
try {
|
||||
localStorage.setItem(PROFILE_STORAGE_KEY, JSON.stringify(updated));
|
||||
hostKeys = { ...baseHostKeys, [target]: fingerprint };
|
||||
if (!persistHostKeys()) throw new Error('Host key persistence failed');
|
||||
profiles = updated;
|
||||
return true;
|
||||
} catch {
|
||||
hostKeys = baseHostKeys;
|
||||
try { localStorage.setItem(PROFILE_STORAGE_KEY, JSON.stringify(base)); } catch { /* Best-effort rollback. */ }
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let persisted = false;
|
||||
try {
|
||||
persisted = 'locks' in navigator
|
||||
? await navigator.locks.request('workers-webssh.profiles.v2', write)
|
||||
: write();
|
||||
} catch {
|
||||
persisted = false;
|
||||
}
|
||||
if (!persisted) toast(bilingual(
|
||||
'当前连接已接受新指纹,但无法在此浏览器中记住。',
|
||||
'The new fingerprint was accepted for this connection but could not be remembered in this browser.',
|
||||
), 'error');
|
||||
renderProfiles();
|
||||
}
|
||||
|
||||
function authMethod(): AuthMethod {
|
||||
@@ -774,7 +827,7 @@ function validateProfileFields(): string | null {
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65_535) return bilingual('端口必须介于 1 和 65535 之间。', 'Port must be between 1 and 65535.');
|
||||
if (!username || username.length > 128 || /[\r\n\0]/.test(username)) return bilingual('请输入有效的 SSH 用户名。', 'Enter a valid SSH username.');
|
||||
const fingerprint = ui.fingerprint.value.trim();
|
||||
if (fingerprint && !/^SHA256:[A-Za-z0-9+/]{43}$/.test(fingerprint)) return bilingual('主机指纹必须使用 SHA256:base64 格式。', 'Host fingerprint must use the SHA256:base64 format.');
|
||||
if (fingerprint && !SSH_FINGERPRINT_RE.test(fingerprint)) return bilingual('主机指纹必须使用 SHA256:base64 格式。', 'Host fingerprint must use the SHA256:base64 format.');
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -875,7 +928,9 @@ async function saveConnectedProfile(): Promise<void> {
|
||||
}
|
||||
if (passwordContext(profile) !== operation.target || latestHistoryMutation.get(operation.target) !== mutation) return;
|
||||
const key = targetKey(profile.host, profile.port, profile.username);
|
||||
const rememberedFingerprint = profile.fingerprint || hostKeys[key] || '';
|
||||
const rememberedFingerprint = key === currentTargetKey && currentRememberedFingerprint
|
||||
? currentRememberedFingerprint
|
||||
: hostKeys[key] || profile.fingerprint || '';
|
||||
const saved: SavedProfile = {
|
||||
...profile,
|
||||
fingerprint: rememberedFingerprint,
|
||||
@@ -1127,15 +1182,46 @@ function sendHostKeyDecision(accept: boolean): void {
|
||||
fingerprint: hostKey.fingerprint,
|
||||
}));
|
||||
if (accept && ui.rememberHostKey.checked && currentTargetKey) {
|
||||
hostKeys[currentTargetKey] = hostKey.fingerprint;
|
||||
persistHostKeys();
|
||||
currentRememberedFingerprint = hostKey.fingerprint;
|
||||
void replaceRememberedHostKey(currentTargetKey, hostKey.fingerprint);
|
||||
if (targetKey() === currentTargetKey) ui.fingerprint.value = hostKey.fingerprint;
|
||||
}
|
||||
event(accept
|
||||
? bilingual('主机密钥已接受,可以继续认证。', 'Host key accepted; authentication may continue.')
|
||||
? hostKey.trust === 'changed'
|
||||
? bilingual('新的主机密钥已明确接受,可以继续认证。', 'The new host key was explicitly accepted; authentication may continue.')
|
||||
: bilingual('主机密钥已接受,可以继续认证。', 'Host key accepted; authentication may continue.')
|
||||
: bilingual('主机密钥已拒绝。', 'Host key rejected.'), 'host-key', !accept);
|
||||
}
|
||||
|
||||
function configureHostKeyDialog(hostKey: HostKeyPrompt): void {
|
||||
const changed = hostKey.trust === 'changed';
|
||||
ui.hostKeyDialog.classList.toggle('changed', changed);
|
||||
ui.hostKeyIcon.textContent = changed ? bilingual('警告', 'ALERT') : bilingual('密钥', 'KEY');
|
||||
ui.hostKeyEyebrow.textContent = changed
|
||||
? bilingual('主机身份已变化', 'HOST IDENTITY CHANGED')
|
||||
: bilingual('主机身份', 'HOST IDENTITY');
|
||||
ui.hostKeyTitle.textContent = changed
|
||||
? bilingual('SSH 主机密钥不匹配', 'SSH host key mismatch')
|
||||
: bilingual('信任此 SSH 主机?', 'Trust this SSH host?');
|
||||
ui.hostKeyDescription.textContent = changed
|
||||
? bilingual(
|
||||
'服务器密钥与之前保存的指纹不同。这可能是正常换钥,也可能是中间人攻击。仅在通过可信渠道确认新指纹后继续。',
|
||||
'The server key differs from the saved fingerprint. This can be a legitimate rotation or a man-in-the-middle attack. Continue only after verifying the new fingerprint through a trusted channel.',
|
||||
)
|
||||
: bilingual(
|
||||
'服务器提供了尚未为此目标固定的密钥。继续前,请通过可信渠道核验。',
|
||||
'The server presented a key that has not been pinned for this target. Verify it through a trusted channel before continuing.',
|
||||
);
|
||||
ui.hostKeyExpectedRow.hidden = !changed;
|
||||
ui.hostKeyExpectedFingerprint.textContent = changed ? hostKey.expectedFingerprint : '--';
|
||||
ui.rememberHostKeyLabel.textContent = changed
|
||||
? bilingual('替换并记住这个新指纹', 'Replace the saved fingerprint with this new one')
|
||||
: bilingual('下次记住并验证此指纹', 'Remember and verify this fingerprint next time');
|
||||
ui.acceptHostKey.textContent = changed
|
||||
? bilingual('我已核验,替换并继续', 'Verified: replace & continue')
|
||||
: bilingual('信任并继续', 'Trust & continue');
|
||||
}
|
||||
|
||||
function clearHostKeyPrompt(): void {
|
||||
awaitingHostKeyDecision = false;
|
||||
pendingHostKey = null;
|
||||
@@ -1156,30 +1242,38 @@ function handleServerMessage(message: ServerMessage): void {
|
||||
if (type === 'host_key') {
|
||||
const fingerprint = message.fingerprint ?? '';
|
||||
const keyType = message.keyType ?? '';
|
||||
if (!/^SHA256:[A-Za-z0-9+/]{43}$/.test(fingerprint) || !/^[A-Za-z0-9@._+-]{1,128}$/.test(keyType)) {
|
||||
const expected = message.expectedFingerprint ?? currentExpectedFingerprint;
|
||||
if (!SSH_FINGERPRINT_RE.test(fingerprint)
|
||||
|| !/^[A-Za-z0-9@._+-]{1,128}$/.test(keyType)
|
||||
|| typeof message.trusted !== 'boolean'
|
||||
|| (expected !== '' && !SSH_FINGERPRINT_RE.test(expected))
|
||||
|| (message.expectedFingerprint !== undefined && message.expectedFingerprint !== currentExpectedFingerprint)) {
|
||||
event(bilingual('收到无效的主机密钥消息。', 'Received an invalid host key message.'), 'protocol', true);
|
||||
socket?.close(1002, 'Invalid host key message');
|
||||
return;
|
||||
}
|
||||
pendingHostKey = { fingerprint, keyType };
|
||||
ui.metricHostKey.textContent = keyType.replace('ssh-', '').replace('ecdsa-sha2-', '');
|
||||
ui.metricHostKey.title = fingerprint;
|
||||
const expected = currentExpectedFingerprint;
|
||||
if (expected && expected !== fingerprint) {
|
||||
event(bilingual('主机密钥不匹配,连接已拒绝。', 'Host key mismatch; connection rejected.'), 'host-key', true);
|
||||
socket?.close(1008, 'Host key mismatch');
|
||||
const trust = classifyHostKey(expected, fingerprint);
|
||||
if (message.trusted !== (trust === 'matched')) {
|
||||
event(bilingual('收到不一致的主机密钥信任消息。', 'Received an inconsistent trusted host key message.'), 'protocol', true);
|
||||
socket?.close(1002, 'Invalid trusted host key message');
|
||||
return;
|
||||
}
|
||||
if (message.trusted || expected) {
|
||||
if (trust === 'matched') {
|
||||
event(bilingual('已固定的主机密钥匹配。', 'Pinned host key matched.'), 'host-key');
|
||||
return;
|
||||
}
|
||||
pendingHostKey = { fingerprint, keyType, expectedFingerprint: expected, trust };
|
||||
awaitingHostKeyDecision = true;
|
||||
configureHostKeyDialog(pendingHostKey);
|
||||
ui.hostKeyTarget.textContent = ui.sessionTitle.textContent ?? currentTargetKey;
|
||||
ui.hostKeyType.textContent = keyType;
|
||||
ui.hostKeyFingerprint.textContent = fingerprint;
|
||||
ui.rememberHostKey.checked = true;
|
||||
event(bilingual(`认证已暂停,请确认首次见到的主机密钥 ${fingerprint}`, `Authentication paused for first-seen host key ${fingerprint}`), 'host-key');
|
||||
event(trust === 'changed'
|
||||
? bilingual(`认证已暂停:主机密钥从 ${expected} 变为 ${fingerprint}`, `Authentication paused: host key changed from ${expected} to ${fingerprint}`)
|
||||
: bilingual(`认证已暂停,请确认首次见到的主机密钥 ${fingerprint}`, `Authentication paused for first-seen host key ${fingerprint}`), 'host-key', trust === 'changed');
|
||||
if (!ui.hostKeyDialog.open) {
|
||||
ui.hostKeyDialog.returnValue = '';
|
||||
ui.hostKeyDialog.showModal();
|
||||
@@ -1240,6 +1334,7 @@ function failActiveConnection(activeSocket: WebSocket | null, closeReason: strin
|
||||
authorizationAbort?.abort();
|
||||
authorizationAbort = null;
|
||||
currentExpectedFingerprint = '';
|
||||
currentRememberedFingerprint = '';
|
||||
stopTimers();
|
||||
clearHostKeyPrompt();
|
||||
invalidateHistoryPasswordLoad();
|
||||
@@ -1304,6 +1399,7 @@ async function connect(): Promise<void> {
|
||||
currentTargetLabel = targetLabel(host, port, username);
|
||||
const pinnedKey = ui.fingerprint.value.trim() || hostKeys[currentTargetKey] || '';
|
||||
currentExpectedFingerprint = pinnedKey;
|
||||
currentRememberedFingerprint = '';
|
||||
if (pinnedKey) ui.fingerprint.value = pinnedKey;
|
||||
currentInitialCommand = ui.initialCommand.value;
|
||||
initialCommandSent = false;
|
||||
@@ -1379,6 +1475,7 @@ async function connect(): Promise<void> {
|
||||
socket = null;
|
||||
pendingHistory = null;
|
||||
currentExpectedFingerprint = '';
|
||||
currentRememberedFingerprint = '';
|
||||
stopTimers();
|
||||
clearHostKeyPrompt();
|
||||
invalidateHistoryPasswordLoad();
|
||||
@@ -1425,6 +1522,7 @@ function disconnect(reason = bilingual('已由用户断开连接', 'Disconnected
|
||||
clearHostKeyPrompt();
|
||||
invalidateHistoryPasswordLoad();
|
||||
currentExpectedFingerprint = '';
|
||||
currentRememberedFingerprint = '';
|
||||
currentSessionSubtitle = messageTranslation(reason);
|
||||
ui.sessionSubtitle.textContent = reason;
|
||||
event(reason, 'disconnect');
|
||||
|
||||
@@ -1215,6 +1215,31 @@ button:disabled {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.host-key-dialog.changed {
|
||||
border-color: color-mix(in srgb, var(--danger) 48%, var(--border));
|
||||
}
|
||||
|
||||
.host-key-dialog.changed .dialog-icon {
|
||||
border-color: color-mix(in srgb, var(--danger) 52%, var(--border));
|
||||
background: var(--danger-dim);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.host-key-dialog.changed .eyebrow,
|
||||
.host-key-dialog.changed #host-key-title {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.host-key-dialog.changed #host-key-expected-fingerprint {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.host-key-dialog.changed .dialog-actions .primary-button {
|
||||
border-color: var(--danger);
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.host-key-dialog h2 {
|
||||
margin: 0 0 7px;
|
||||
font-size: 18px;
|
||||
|
||||
+2
-1
@@ -9,7 +9,8 @@
|
||||
"dev:web": "vite --config vite.config.ts",
|
||||
"build:web": "vite build --config vite.config.ts",
|
||||
"typecheck": "tsc -p tsconfig.worker.json --noEmit && tsc -p tsconfig.frontend.json --noEmit",
|
||||
"check": "npm run typecheck && wrangler deploy --dry-run --outdir .wrangler/build",
|
||||
"test": "node --test --experimental-strip-types tests/*.test.ts",
|
||||
"check": "npm run typecheck && npm run test && wrangler deploy --dry-run --outdir .wrangler/build",
|
||||
"deploy": "wrangler deploy"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
+68
-10
@@ -22,6 +22,7 @@ import {
|
||||
SSH_MSG_SERVICE_REQUEST,
|
||||
SSH_MSG_UNIMPLEMENTED,
|
||||
SSH_MSG_USERAUTH_FAILURE,
|
||||
SSH_MSG_USERAUTH_INFO_REQUEST,
|
||||
SSH_MSG_USERAUTH_SUCCESS,
|
||||
type SSHConnectionConfig,
|
||||
type SSHPacket,
|
||||
@@ -42,6 +43,7 @@ import { KeyDerivation } from '../ssh/keys';
|
||||
import { SSHAESCTRCipher, SSHAESGCMCipher, SSHHMAC } from '../ssh/crypto';
|
||||
import { SSHAuth } from '../ssh/auth';
|
||||
import { SSHChannel, type ChannelDataChunk } from '../ssh/channel';
|
||||
import { classifyHostKey } from '../ssh/host-key';
|
||||
import { encodeString, readUint32, toBufferSource } from '../ssh/utils';
|
||||
|
||||
type Cipher = SSHAESGCMCipher | SSHAESCTRCipher;
|
||||
@@ -84,6 +86,7 @@ export class SSHSession {
|
||||
private verifier: SSHHMAC | null = null;
|
||||
private serverSigAlgs: string[] = [];
|
||||
private authRequestSent = false;
|
||||
private passwordAuthMethod: 'password' | 'keyboard-interactive' = 'password';
|
||||
private pendingChannelRequest: 'pty' | 'shell' | null = null;
|
||||
private ignoreNextKexPacket = false;
|
||||
private inputQueue: Uint8Array[] = [];
|
||||
@@ -157,6 +160,8 @@ export class SSHSession {
|
||||
this.queuedBytes = 0;
|
||||
this.pendingHostConfirmation?.resolve(false);
|
||||
this.pendingHostConfirmation = null;
|
||||
this.config.password = undefined;
|
||||
this.config.privateKey = undefined;
|
||||
try { this.writer?.releaseLock(); } catch { /* already released */ }
|
||||
this.writer = null;
|
||||
try { this.socket.close(); } catch { /* already closed */ }
|
||||
@@ -351,11 +356,11 @@ export class SSHSession {
|
||||
const keyType = this.decoder.decode(hostKey.subarray(4, 4 + keyTypeLength));
|
||||
if (!this.isHostKeyAlgorithmCompatible(this.hostKeyAlgorithm, keyType)) throw new Error(`Server used ${keyType}, but negotiated ${this.hostKeyAlgorithm ?? 'no host key algorithm'}`);
|
||||
const fingerprint = `SHA256:${this.base64(new Uint8Array(await crypto.subtle.digest('SHA-256', toBufferSource(hostKey))))}`;
|
||||
if (this.config.expectedFingerprint && this.config.expectedFingerprint !== fingerprint) throw new Error(`Host key mismatch: expected ${this.config.expectedFingerprint}, received ${fingerprint}`);
|
||||
if (!await this.verifyHostSignature(hostKey, signature, hash)) throw new Error('SSH host key signature verification failed');
|
||||
if (this.config.expectedFingerprint) {
|
||||
const trust = classifyHostKey(this.config.expectedFingerprint, fingerprint);
|
||||
if (trust === 'trusted') {
|
||||
this.sendJson({ type: 'host_key', fingerprint, keyType, trusted: true });
|
||||
} else if (!await this.confirmHostKey(fingerprint, keyType)) {
|
||||
} else if (!await this.confirmHostKey(fingerprint, keyType, trust === 'changed' ? this.config.expectedFingerprint : undefined)) {
|
||||
throw new Error('Host key was not accepted');
|
||||
}
|
||||
this.status('host_key_verified', `Host key verified (${keyType})`);
|
||||
@@ -380,7 +385,7 @@ export class SSHSession {
|
||||
await this.enableOutboundEncryption();
|
||||
}
|
||||
|
||||
private async confirmHostKey(fingerprint: string, keyType: string): Promise<boolean> {
|
||||
private async confirmHostKey(fingerprint: string, keyType: string, expectedFingerprint?: string): Promise<boolean> {
|
||||
this.phase = 'host-confirm';
|
||||
this.status('host_key_confirmation', 'Confirm this host key before credentials are sent');
|
||||
return new Promise<boolean>((resolve) => {
|
||||
@@ -396,7 +401,7 @@ export class SSHSession {
|
||||
const timeout = setTimeout(() => finish(false), 30_000);
|
||||
this.pendingHostConfirmation = { fingerprint, resolve: finish };
|
||||
// Register the decision handler before notifying the browser.
|
||||
this.sendJson({ type: 'host_key', fingerprint, keyType, trusted: false });
|
||||
this.sendJson({ type: 'host_key', fingerprint, keyType, trusted: false, expectedFingerprint });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -477,14 +482,24 @@ export class SSHSession {
|
||||
? await SSHAuth.buildPublicKeyAuthRequest(this.config.username, this.config.privateKey!, this.sessionId!, this.serverSigAlgs)
|
||||
: SSHAuth.buildPasswordAuthRequest(this.config.username, this.config.password!);
|
||||
this.authRequestSent = true;
|
||||
this.config.password = undefined;
|
||||
this.config.privateKey = undefined;
|
||||
if (this.config.authMethod === 'publickey') this.config.privateKey = undefined;
|
||||
await this.sendEncrypted(request);
|
||||
return;
|
||||
}
|
||||
if (type === SSH_MSG_USERAUTH_INFO_REQUEST) {
|
||||
if (this.config.authMethod !== 'password' || this.passwordAuthMethod !== 'keyboard-interactive' || !this.authRequestSent) {
|
||||
throw new Error('Unexpected SSH keyboard-interactive challenge');
|
||||
}
|
||||
const promptCount = this.validateKeyboardInteractiveRequest(payload);
|
||||
const response = SSHAuth.buildKeyboardInteractiveResponse(this.config.password!, promptCount);
|
||||
await this.sendEncrypted(response);
|
||||
return;
|
||||
}
|
||||
if (type === SSH_MSG_USERAUTH_SUCCESS) {
|
||||
if (payload.length !== 1) throw new Error('Malformed SSH authentication success');
|
||||
if (!this.authRequestSent) throw new Error('SSH authentication completed before credentials were sent');
|
||||
this.config.password = undefined;
|
||||
this.config.privateKey = undefined;
|
||||
this.status('auth_success', 'SSH authentication succeeded; opening terminal');
|
||||
this.phase = 'pty';
|
||||
this.startKeepalive();
|
||||
@@ -493,7 +508,16 @@ export class SSHSession {
|
||||
}
|
||||
if (type === SSH_MSG_USERAUTH_FAILURE) {
|
||||
if (!this.authRequestSent) throw new Error('SSH authentication failed before credentials were sent');
|
||||
this.validateAuthFailure(payload);
|
||||
const methods = this.validateAuthFailure(payload);
|
||||
if (this.config.authMethod === 'password'
|
||||
&& this.passwordAuthMethod === 'password'
|
||||
&& this.config.password !== undefined
|
||||
&& methods.includes('keyboard-interactive')) {
|
||||
this.passwordAuthMethod = 'keyboard-interactive';
|
||||
await this.sendEncrypted(SSHAuth.buildKeyboardInteractiveAuthRequest(this.config.username));
|
||||
return;
|
||||
}
|
||||
this.config.password = undefined;
|
||||
throw new Error('SSH authentication failed');
|
||||
}
|
||||
}
|
||||
@@ -688,7 +712,11 @@ export class SSHSession {
|
||||
|
||||
private readString(bytes: Uint8Array, offset: number): { value: string; next: number } {
|
||||
const field = this.readBytes(bytes, offset);
|
||||
return { value: this.decoder.decode(field.value), next: field.next };
|
||||
try {
|
||||
return { value: new TextDecoder('utf-8', { fatal: true }).decode(field.value), next: field.next };
|
||||
} catch {
|
||||
throw new Error('Malformed SSH text field');
|
||||
}
|
||||
}
|
||||
|
||||
private sshEcdsaToRaw(signature: Uint8Array, coordinateBytes: number): Uint8Array {
|
||||
@@ -738,12 +766,42 @@ export class SSHSession {
|
||||
if (offset !== payload.length) throw new Error('Malformed SSH debug message');
|
||||
}
|
||||
|
||||
private validateAuthFailure(payload: Uint8Array): void {
|
||||
private validateAuthFailure(payload: Uint8Array): string[] {
|
||||
if (payload.length < 6) throw new Error('Malformed SSH authentication failure');
|
||||
const methodsLength = readUint32(payload, 1);
|
||||
if (methodsLength > payload.length - 6 || methodsLength + 6 !== payload.length) {
|
||||
throw new Error('Malformed SSH authentication failure');
|
||||
}
|
||||
const partialSuccess = payload[payload.length - 1];
|
||||
if (partialSuccess > 1) throw new Error('Malformed SSH authentication partial-success flag');
|
||||
if (partialSuccess === 1) throw new Error('Multi-factor SSH authentication is not supported');
|
||||
let methods: string;
|
||||
try { methods = new TextDecoder('utf-8', { fatal: true }).decode(payload.subarray(5, 5 + methodsLength)); }
|
||||
catch { throw new Error('Malformed SSH authentication methods'); }
|
||||
if (methods && methods.split(',').some((method) => method.length === 0 || !/^[A-Za-z0-9@._+-]+$/.test(method))) {
|
||||
throw new Error('Malformed SSH authentication methods');
|
||||
}
|
||||
return methods ? methods.split(',') : [];
|
||||
}
|
||||
|
||||
private validateKeyboardInteractiveRequest(payload: Uint8Array): number {
|
||||
let offset = 1;
|
||||
offset = this.readString(payload, offset).next; // name
|
||||
offset = this.readString(payload, offset).next; // instruction
|
||||
offset = this.readString(payload, offset).next; // language tag
|
||||
if (offset + 4 > payload.length) throw new Error('Malformed SSH keyboard-interactive challenge');
|
||||
const promptCount = readUint32(payload, offset);
|
||||
offset += 4;
|
||||
if (promptCount !== 1) throw new Error('SSH keyboard-interactive authentication requires exactly one prompt');
|
||||
const prompt = this.readString(payload, offset);
|
||||
offset = prompt.next;
|
||||
if (offset >= payload.length || payload[offset] > 1 || offset + 1 !== payload.length) {
|
||||
throw new Error('Malformed SSH keyboard-interactive prompt');
|
||||
}
|
||||
if (!SSHAuth.isSupportedPasswordPrompt(prompt.value, payload[offset] === 1)) {
|
||||
throw new Error('SSH keyboard-interactive prompt is not a supported password prompt');
|
||||
}
|
||||
return promptCount;
|
||||
}
|
||||
|
||||
private isHostKeyAlgorithmCompatible(negotiated: string | null, keyType: string): boolean {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
const SSH_MSG_USERAUTH_REQUEST = 50;
|
||||
const SSH_MSG_USERAUTH_INFO_RESPONSE = 61;
|
||||
|
||||
function concat(...arrays: Uint8Array[]): Uint8Array {
|
||||
const result = new Uint8Array(arrays.reduce((total, value) => total + value.length, 0));
|
||||
let offset = 0;
|
||||
for (const value of arrays) {
|
||||
result.set(value, offset);
|
||||
offset += value.length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function encodeString(value: string): Uint8Array {
|
||||
const data = new TextEncoder().encode(value);
|
||||
const length = new Uint8Array(4);
|
||||
new DataView(length.buffer).setUint32(0, data.length, false);
|
||||
return concat(length, data);
|
||||
}
|
||||
|
||||
export function buildPasswordAuthRequest(username: string, password: string): Uint8Array {
|
||||
return concat(
|
||||
new Uint8Array([SSH_MSG_USERAUTH_REQUEST]),
|
||||
encodeString(username),
|
||||
encodeString('ssh-connection'),
|
||||
encodeString('password'),
|
||||
new Uint8Array([0x00]),
|
||||
encodeString(password),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildKeyboardInteractiveAuthRequest(username: string): Uint8Array {
|
||||
return concat(
|
||||
new Uint8Array([SSH_MSG_USERAUTH_REQUEST]),
|
||||
encodeString(username),
|
||||
encodeString('ssh-connection'),
|
||||
encodeString('keyboard-interactive'),
|
||||
encodeString(''),
|
||||
encodeString(''),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildKeyboardInteractiveResponse(password: string, promptCount: number): Uint8Array {
|
||||
if (promptCount !== 1) throw new Error('SSH keyboard-interactive authentication requires exactly one prompt');
|
||||
return concat(
|
||||
new Uint8Array([SSH_MSG_USERAUTH_INFO_RESPONSE]),
|
||||
new Uint8Array([0, 0, 0, 1]),
|
||||
encodeString(password),
|
||||
);
|
||||
}
|
||||
|
||||
export function isSupportedPasswordPrompt(prompt: string, echo: boolean): boolean {
|
||||
return !echo && /(?:password|passphrase|密码|口令)/i.test(prompt);
|
||||
}
|
||||
+18
-9
@@ -1,5 +1,11 @@
|
||||
import { SSH_MSG_USERAUTH_REQUEST } from '../types';
|
||||
import { encodeString, concat, readUint32, toBufferSource } from './utils';
|
||||
import {
|
||||
buildKeyboardInteractiveAuthRequest,
|
||||
buildKeyboardInteractiveResponse,
|
||||
buildPasswordAuthRequest,
|
||||
isSupportedPasswordPrompt,
|
||||
} from './auth-password';
|
||||
|
||||
// SSH key type constants
|
||||
const SSH_ED25519 = 'ssh-ed25519';
|
||||
@@ -27,16 +33,19 @@ export class SSHAuth {
|
||||
username: string,
|
||||
password: string
|
||||
): Uint8Array {
|
||||
const parts: Uint8Array[] = [
|
||||
new Uint8Array([SSH_MSG_USERAUTH_REQUEST]),
|
||||
encodeString(username),
|
||||
encodeString('ssh-connection'),
|
||||
encodeString('password'),
|
||||
new Uint8Array([0x00]),
|
||||
encodeString(password),
|
||||
];
|
||||
return buildPasswordAuthRequest(username, password);
|
||||
}
|
||||
|
||||
return concat(...parts);
|
||||
static buildKeyboardInteractiveAuthRequest(username: string): Uint8Array {
|
||||
return buildKeyboardInteractiveAuthRequest(username);
|
||||
}
|
||||
|
||||
static buildKeyboardInteractiveResponse(password: string, promptCount: number): Uint8Array {
|
||||
return buildKeyboardInteractiveResponse(password, promptCount);
|
||||
}
|
||||
|
||||
static isSupportedPasswordPrompt(prompt: string, echo: boolean): boolean {
|
||||
return isSupportedPasswordPrompt(prompt, echo);
|
||||
}
|
||||
|
||||
/** Build a signed public-key authentication request (RFC 4252 section 7). */
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export type HostKeyTrust = 'trusted' | 'first-seen' | 'changed';
|
||||
|
||||
export function classifyHostKey(
|
||||
expectedFingerprint: string | undefined,
|
||||
fingerprint: string,
|
||||
): HostKeyTrust {
|
||||
if (!expectedFingerprint) return 'first-seen';
|
||||
return expectedFingerprint === fingerprint ? 'trusted' : 'changed';
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ export const SSH_MSG_KEX_ECDH_REPLY = 31;
|
||||
export const SSH_MSG_USERAUTH_REQUEST = 50;
|
||||
export const SSH_MSG_USERAUTH_FAILURE = 51;
|
||||
export const SSH_MSG_USERAUTH_SUCCESS = 52;
|
||||
export const SSH_MSG_USERAUTH_INFO_REQUEST = 60;
|
||||
export const SSH_MSG_GLOBAL_REQUEST = 80;
|
||||
export const SSH_MSG_REQUEST_SUCCESS = 81;
|
||||
export const SSH_MSG_REQUEST_FAILURE = 82;
|
||||
|
||||
Reference in new issue
Block a user