feat: 实现 SSH 键盘交互认证,增加相关逻辑和测试用例
This commit is contained in:
1 parent
c040c1b9fd
commit
b0104591ac
7 files changed
+325
-37
No files matched your search
@@ -7,4 +7,3 @@ coverage/
|
||||
.env*
|
||||
!.env.example
|
||||
.workbuddy/
|
||||
tests/
|
||||
+20
-4
@@ -110,6 +110,8 @@ const LANGUAGE_STORAGE_KEY = 'workers-webssh.language';
|
||||
const MAX_KEY_BYTES = 65_536;
|
||||
const MAX_LEGACY_PASSWORD_BASE64_LENGTH = 16_384;
|
||||
const PING_INTERVAL_MS = 25_000;
|
||||
const CLIENT_CLOSE_SESSION_ERROR = 4000;
|
||||
const CLIENT_CLOSE_PROTOCOL_ERROR = 4002;
|
||||
|
||||
function loadLanguage(): Language {
|
||||
try {
|
||||
@@ -1249,7 +1251,7 @@ function handleServerMessage(message: ServerMessage): void {
|
||||
|| (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');
|
||||
socket?.close(CLIENT_CLOSE_PROTOCOL_ERROR, 'Invalid host key message');
|
||||
return;
|
||||
}
|
||||
ui.metricHostKey.textContent = keyType.replace('ssh-', '').replace('ecdsa-sha2-', '');
|
||||
@@ -1257,7 +1259,7 @@ function handleServerMessage(message: ServerMessage): void {
|
||||
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');
|
||||
socket?.close(CLIENT_CLOSE_PROTOCOL_ERROR, 'Invalid trusted host key message');
|
||||
return;
|
||||
}
|
||||
if (trust === 'matched') {
|
||||
@@ -1309,10 +1311,24 @@ function handleServerMessage(message: ServerMessage): void {
|
||||
async function handleSocketData(data: string | ArrayBuffer | Blob, activeSocket: WebSocket, generation: number): Promise<void> {
|
||||
if (socket !== activeSocket || generation !== connectGeneration) return;
|
||||
if (typeof data === 'string') {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
handleServerMessage(JSON.parse(data) as ServerMessage);
|
||||
parsed = JSON.parse(data);
|
||||
} catch {
|
||||
event(bilingual('已忽略无效的控制帧。', 'Ignored an invalid control frame.'), 'protocol', true);
|
||||
return;
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
event(bilingual('已忽略无效的控制帧。', 'Ignored an invalid control frame.'), 'protocol', true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
handleServerMessage(parsed as ServerMessage);
|
||||
} catch {
|
||||
event(bilingual('处理服务器控制消息时发生错误。', 'Failed to process the server control message.'), 'protocol', true);
|
||||
if (activeSocket.readyState < WebSocket.CLOSING) {
|
||||
activeSocket.close(CLIENT_CLOSE_PROTOCOL_ERROR, 'Control message handling failed');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1341,7 +1357,7 @@ function failActiveConnection(activeSocket: WebSocket | null, closeReason: strin
|
||||
currentSessionSubtitle = displayReason;
|
||||
ui.sessionSubtitle.textContent = localize(displayReason);
|
||||
setState('error');
|
||||
if (activeSocket && activeSocket.readyState < WebSocket.CLOSING) activeSocket.close(1011, closeReason);
|
||||
if (activeSocket && activeSocket.readyState < WebSocket.CLOSING) activeSocket.close(CLIENT_CLOSE_SESSION_ERROR, closeReason);
|
||||
}
|
||||
|
||||
async function issueTicket(signal: AbortSignal): Promise<{ ticket: string; sessionId: string }> {
|
||||
|
||||
+22
-22
@@ -87,6 +87,8 @@ export class SSHSession {
|
||||
private serverSigAlgs: string[] = [];
|
||||
private authRequestSent = false;
|
||||
private passwordAuthMethod: 'password' | 'keyboard-interactive' = 'password';
|
||||
private keyboardInteractiveRounds = 0;
|
||||
private keyboardInteractivePasswordSent = false;
|
||||
private pendingChannelRequest: 'pty' | 'shell' | null = null;
|
||||
private ignoreNextKexPacket = false;
|
||||
private inputQueue: Uint8Array[] = [];
|
||||
@@ -490,8 +492,26 @@ export class SSHSession {
|
||||
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);
|
||||
this.keyboardInteractiveRounds++;
|
||||
if (this.keyboardInteractiveRounds > 16) throw new Error('Too many SSH keyboard-interactive challenge rounds');
|
||||
const challenge = SSHAuth.parseKeyboardInteractiveChallenge(payload);
|
||||
let responses: string[];
|
||||
if (challenge.prompts.length === 0) {
|
||||
responses = [];
|
||||
} else {
|
||||
if (this.keyboardInteractivePasswordSent || this.config.password === undefined) {
|
||||
throw new Error('SSH keyboard-interactive requested credentials more than once');
|
||||
}
|
||||
responses = SSHAuth.passwordResponsesForChallenge(challenge, this.config.password, {
|
||||
username: this.config.username,
|
||||
host: this.config.host,
|
||||
});
|
||||
}
|
||||
const response = SSHAuth.buildKeyboardInteractiveResponse(responses);
|
||||
if (responses.length > 0) {
|
||||
this.keyboardInteractivePasswordSent = true;
|
||||
this.config.password = undefined;
|
||||
}
|
||||
await this.sendEncrypted(response);
|
||||
return;
|
||||
}
|
||||
@@ -784,26 +804,6 @@ export class SSHSession {
|
||||
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 {
|
||||
if (!negotiated) return false;
|
||||
if (negotiated === keyType) return true;
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
const SSH_MSG_USERAUTH_REQUEST = 50;
|
||||
const SSH_MSG_USERAUTH_INFO_RESPONSE = 61;
|
||||
const MAX_KEYBOARD_INTERACTIVE_PROMPTS = 16;
|
||||
|
||||
export interface KeyboardInteractivePrompt {
|
||||
text: string;
|
||||
echo: boolean;
|
||||
}
|
||||
|
||||
export interface KeyboardInteractiveChallenge {
|
||||
name: string;
|
||||
instruction: string;
|
||||
language: string;
|
||||
prompts: KeyboardInteractivePrompt[];
|
||||
}
|
||||
|
||||
export interface PasswordPromptIdentity {
|
||||
username: string;
|
||||
host: string;
|
||||
}
|
||||
|
||||
function concat(...arrays: Uint8Array[]): Uint8Array {
|
||||
const result = new Uint8Array(arrays.reduce((total, value) => total + value.length, 0));
|
||||
@@ -18,6 +36,25 @@ function encodeString(value: string): Uint8Array {
|
||||
return concat(length, data);
|
||||
}
|
||||
|
||||
function readUint32(data: Uint8Array, offset: number): number {
|
||||
if (offset + 4 > data.length) throw new Error('Malformed SSH keyboard-interactive challenge');
|
||||
return new DataView(data.buffer, data.byteOffset, data.byteLength).getUint32(offset, false);
|
||||
}
|
||||
|
||||
function readString(data: Uint8Array, offset: number): { value: string; next: number } {
|
||||
const length = readUint32(data, offset);
|
||||
const start = offset + 4;
|
||||
if (length > data.length - start) throw new Error('Malformed SSH keyboard-interactive challenge');
|
||||
try {
|
||||
return {
|
||||
value: new TextDecoder('utf-8', { fatal: true }).decode(data.subarray(start, start + length)),
|
||||
next: start + length,
|
||||
};
|
||||
} catch {
|
||||
throw new Error('Malformed SSH keyboard-interactive text');
|
||||
}
|
||||
}
|
||||
|
||||
export function buildPasswordAuthRequest(username: string, password: string): Uint8Array {
|
||||
return concat(
|
||||
new Uint8Array([SSH_MSG_USERAUTH_REQUEST]),
|
||||
@@ -40,15 +77,63 @@ export function buildKeyboardInteractiveAuthRequest(username: string): Uint8Arra
|
||||
);
|
||||
}
|
||||
|
||||
export function buildKeyboardInteractiveResponse(password: string, promptCount: number): Uint8Array {
|
||||
if (promptCount !== 1) throw new Error('SSH keyboard-interactive authentication requires exactly one prompt');
|
||||
export function parseKeyboardInteractiveChallenge(payload: Uint8Array): KeyboardInteractiveChallenge {
|
||||
if (payload[0] !== 60) throw new Error('Malformed SSH keyboard-interactive message type');
|
||||
let offset = 1;
|
||||
const name = readString(payload, offset); offset = name.next;
|
||||
const instruction = readString(payload, offset); offset = instruction.next;
|
||||
const language = readString(payload, offset); offset = language.next;
|
||||
const promptCount = readUint32(payload, offset); offset += 4;
|
||||
if (promptCount > MAX_KEYBOARD_INTERACTIVE_PROMPTS) {
|
||||
throw new Error('SSH keyboard-interactive challenge has too many prompts');
|
||||
}
|
||||
const prompts: KeyboardInteractivePrompt[] = [];
|
||||
for (let index = 0; index < promptCount; index++) {
|
||||
const prompt = readString(payload, offset); offset = prompt.next;
|
||||
if (prompt.value.length === 0 || offset >= payload.length || payload[offset] > 1) {
|
||||
throw new Error('Malformed SSH keyboard-interactive prompt');
|
||||
}
|
||||
prompts.push({ text: prompt.value, echo: payload[offset] === 1 });
|
||||
offset++;
|
||||
}
|
||||
if (offset !== payload.length) throw new Error('Malformed SSH keyboard-interactive trailing data');
|
||||
return { name: name.value, instruction: instruction.value, language: language.value, prompts };
|
||||
}
|
||||
|
||||
export function passwordResponsesForChallenge(
|
||||
challenge: KeyboardInteractiveChallenge,
|
||||
password: string,
|
||||
identity: PasswordPromptIdentity,
|
||||
): string[] {
|
||||
if (challenge.prompts.length === 0) return [];
|
||||
if (challenge.prompts.length === 1 && isSupportedPasswordPrompt(challenge, identity)) {
|
||||
return [password];
|
||||
}
|
||||
throw new Error(`SSH keyboard-interactive challenge is not a supported password prompt (${challenge.prompts.length} prompts)`);
|
||||
}
|
||||
|
||||
export function buildKeyboardInteractiveResponse(responses: string[]): Uint8Array {
|
||||
const count = new Uint8Array(4);
|
||||
new DataView(count.buffer).setUint32(0, responses.length, false);
|
||||
return concat(
|
||||
new Uint8Array([SSH_MSG_USERAUTH_INFO_RESPONSE]),
|
||||
new Uint8Array([0, 0, 0, 1]),
|
||||
encodeString(password),
|
||||
count,
|
||||
...responses.map(encodeString),
|
||||
);
|
||||
}
|
||||
|
||||
export function isSupportedPasswordPrompt(prompt: string, echo: boolean): boolean {
|
||||
return !echo && /(?:password|passphrase|密码|口令)/i.test(prompt);
|
||||
export function isSupportedPasswordPrompt(
|
||||
challenge: KeyboardInteractiveChallenge,
|
||||
identity: PasswordPromptIdentity,
|
||||
): boolean {
|
||||
if (challenge.prompts.length !== 1 || challenge.prompts[0].echo) return false;
|
||||
// Automatic credential reuse is deliberately limited to an unambiguous,
|
||||
// context-free password request for this exact SSH target.
|
||||
if (challenge.name.trim() || challenge.instruction.trim()) return false;
|
||||
const prompt = challenge.prompts[0].text;
|
||||
if (/[\u0000-\u001f\u007f-\u009f]/u.test(prompt)) return false;
|
||||
const normalized = prompt.trim();
|
||||
if (/^(?:password|密码|口令)\s*[::]$/iu.test(normalized)) return true;
|
||||
const match = /^password for (.+)@(.+):$/iu.exec(normalized);
|
||||
return match !== null && match[1] === identity.username && match[2].toLowerCase() === identity.host.toLowerCase();
|
||||
}
|
||||
+20
-4
@@ -5,6 +5,10 @@ import {
|
||||
buildKeyboardInteractiveResponse,
|
||||
buildPasswordAuthRequest,
|
||||
isSupportedPasswordPrompt,
|
||||
parseKeyboardInteractiveChallenge,
|
||||
passwordResponsesForChallenge,
|
||||
type KeyboardInteractiveChallenge,
|
||||
type PasswordPromptIdentity,
|
||||
} from './auth-password';
|
||||
|
||||
// SSH key type constants
|
||||
@@ -40,12 +44,24 @@ export class SSHAuth {
|
||||
return buildKeyboardInteractiveAuthRequest(username);
|
||||
}
|
||||
|
||||
static buildKeyboardInteractiveResponse(password: string, promptCount: number): Uint8Array {
|
||||
return buildKeyboardInteractiveResponse(password, promptCount);
|
||||
static parseKeyboardInteractiveChallenge(payload: Uint8Array): KeyboardInteractiveChallenge {
|
||||
return parseKeyboardInteractiveChallenge(payload);
|
||||
}
|
||||
|
||||
static isSupportedPasswordPrompt(prompt: string, echo: boolean): boolean {
|
||||
return isSupportedPasswordPrompt(prompt, echo);
|
||||
static passwordResponsesForChallenge(
|
||||
challenge: KeyboardInteractiveChallenge,
|
||||
password: string,
|
||||
identity: PasswordPromptIdentity,
|
||||
): string[] {
|
||||
return passwordResponsesForChallenge(challenge, password, identity);
|
||||
}
|
||||
|
||||
static buildKeyboardInteractiveResponse(responses: string[]): Uint8Array {
|
||||
return buildKeyboardInteractiveResponse(responses);
|
||||
}
|
||||
|
||||
static isSupportedPasswordPrompt(challenge: KeyboardInteractiveChallenge, identity: PasswordPromptIdentity): boolean {
|
||||
return isSupportedPasswordPrompt(challenge, identity);
|
||||
}
|
||||
|
||||
/** Build a signed public-key authentication request (RFC 4252 section 7). */
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
buildKeyboardInteractiveAuthRequest,
|
||||
buildKeyboardInteractiveResponse,
|
||||
isSupportedPasswordPrompt,
|
||||
parseKeyboardInteractiveChallenge,
|
||||
passwordResponsesForChallenge,
|
||||
} from '../src/ssh/auth-password.ts';
|
||||
|
||||
const IDENTITY = { username: 'alice', host: 'example.com' };
|
||||
|
||||
interface ChallengeFields {
|
||||
name?: string;
|
||||
instruction?: string;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
function encodeString(value: string): Uint8Array {
|
||||
const bytes = new TextEncoder().encode(value);
|
||||
const result = new Uint8Array(4 + bytes.length);
|
||||
new DataView(result.buffer).setUint32(0, bytes.length, false);
|
||||
result.set(bytes, 4);
|
||||
return result;
|
||||
}
|
||||
|
||||
function challenge(prompts: Array<{ text: string; echo: boolean }>, fields: ChallengeFields = {}): Uint8Array {
|
||||
const count = new Uint8Array(4);
|
||||
new DataView(count.buffer).setUint32(0, prompts.length, false);
|
||||
const parts = [
|
||||
new Uint8Array([60]),
|
||||
encodeString(fields.name ?? ''),
|
||||
encodeString(fields.instruction ?? ''),
|
||||
encodeString(fields.language ?? ''),
|
||||
count,
|
||||
...prompts.flatMap((prompt) => [encodeString(prompt.text), new Uint8Array([prompt.echo ? 1 : 0])]),
|
||||
];
|
||||
const result = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
|
||||
let offset = 0;
|
||||
for (const part of parts) { result.set(part, offset); offset += part.length; }
|
||||
return result;
|
||||
}
|
||||
|
||||
function readString(data: Uint8Array, offset: number): { value: string; next: number } {
|
||||
const length = readUint32(data, offset);
|
||||
const start = offset + 4;
|
||||
return { value: new TextDecoder().decode(data.subarray(start, start + length)), next: start + length };
|
||||
}
|
||||
|
||||
function readUint32(data: Uint8Array, offset: number): number {
|
||||
return new DataView(data.buffer, data.byteOffset, data.byteLength).getUint32(offset, false);
|
||||
}
|
||||
|
||||
test('builds an RFC 4256 keyboard-interactive request without the password', () => {
|
||||
const request = buildKeyboardInteractiveAuthRequest('coss');
|
||||
assert.equal(request[0], 50);
|
||||
let field = readString(request, 1);
|
||||
assert.equal(field.value, 'coss');
|
||||
field = readString(request, field.next);
|
||||
assert.equal(field.value, 'ssh-connection');
|
||||
field = readString(request, field.next);
|
||||
assert.equal(field.value, 'keyboard-interactive');
|
||||
field = readString(request, field.next);
|
||||
assert.equal(field.value, '');
|
||||
field = readString(request, field.next);
|
||||
assert.equal(field.value, '');
|
||||
assert.equal(field.next, request.length);
|
||||
assert.equal(new TextDecoder().decode(request).includes('secret'), false);
|
||||
});
|
||||
|
||||
test('answers a password prompt', () => {
|
||||
const parsed = parseKeyboardInteractiveChallenge(challenge([{
|
||||
text: 'Password for alice@example.com:',
|
||||
echo: false,
|
||||
}]));
|
||||
const response = buildKeyboardInteractiveResponse(passwordResponsesForChallenge(parsed, 'test-secret', IDENTITY));
|
||||
assert.equal(response[0], 61);
|
||||
assert.equal(readUint32(response, 1), 1);
|
||||
const answer = readString(response, 5);
|
||||
assert.equal(answer.value, 'test-secret');
|
||||
assert.equal(answer.next, response.length);
|
||||
});
|
||||
|
||||
test('acknowledges zero-prompt keyboard-interactive notices', () => {
|
||||
const parsed = parseKeyboardInteractiveChallenge(challenge([], { instruction: 'Authentication successful' }));
|
||||
assert.deepEqual(parsed.prompts, []);
|
||||
const response = buildKeyboardInteractiveResponse(passwordResponsesForChallenge(parsed, 'test-secret', IDENTITY));
|
||||
assert.equal(response[0], 61);
|
||||
assert.equal(readUint32(response, 1), 0);
|
||||
assert.equal(response.length, 5);
|
||||
});
|
||||
|
||||
test('rejects multiple or non-password prompts', () => {
|
||||
const multiple = parseKeyboardInteractiveChallenge(challenge([
|
||||
{ text: 'Password:', echo: false },
|
||||
{ text: 'Verification code:', echo: false },
|
||||
]));
|
||||
assert.throws(() => passwordResponsesForChallenge(multiple, 'test-secret', IDENTITY), /2 prompts/);
|
||||
const otp = parseKeyboardInteractiveChallenge(challenge([{ text: 'Verification code:', echo: false }]));
|
||||
assert.throws(() => passwordResponsesForChallenge(otp, 'test-secret', IDENTITY), /not a supported password prompt/);
|
||||
});
|
||||
|
||||
test('only auto-answers hidden password prompts', () => {
|
||||
const supported = (text: string, echo = false, fields: ChallengeFields = {}) => isSupportedPasswordPrompt(
|
||||
parseKeyboardInteractiveChallenge(challenge([{ text, echo }], fields)),
|
||||
IDENTITY,
|
||||
);
|
||||
assert.equal(supported('Password:'), true);
|
||||
assert.equal(supported('密码:'), true);
|
||||
assert.equal(supported('Password for alice@example.com:'), true);
|
||||
assert.equal(supported('password for alice@EXAMPLE.COM:'), true);
|
||||
assert.equal(supported('Password for mallory@example.com:'), false);
|
||||
assert.equal(supported('Password:', true), false);
|
||||
assert.equal(supported('Verification code:'), false);
|
||||
assert.equal(supported('Do not enter your password; enter OTP:'), false);
|
||||
assert.equal(supported('Enter new password:'), false);
|
||||
assert.equal(supported('Password + verification code:'), false);
|
||||
assert.equal(supported('Passphrase for key:'), false);
|
||||
assert.equal(supported('Password:', false, { instruction: 'Enter your one-time code' }), false);
|
||||
assert.equal(supported('Password:', false, { name: 'Second factor' }), false);
|
||||
});
|
||||
|
||||
test('strictly validates keyboard-interactive challenge framing', () => {
|
||||
assert.throws(() => parseKeyboardInteractiveChallenge(challenge([{ text: '', echo: false }])), /Malformed/);
|
||||
|
||||
const badBoolean = challenge([{ text: 'Password:', echo: false }]);
|
||||
badBoolean[badBoolean.length - 1] = 2;
|
||||
assert.throws(() => parseKeyboardInteractiveChallenge(badBoolean), /Malformed/);
|
||||
|
||||
const invalidUtf8 = challenge([], { name: 'x' });
|
||||
invalidUtf8[5] = 0xff;
|
||||
assert.throws(() => parseKeyboardInteractiveChallenge(invalidUtf8), /text/);
|
||||
|
||||
const valid = challenge([{ text: 'Password:', echo: false }]);
|
||||
const trailing = new Uint8Array(valid.length + 1);
|
||||
trailing.set(valid);
|
||||
assert.throws(() => parseKeyboardInteractiveChallenge(trailing), /trailing data/);
|
||||
assert.throws(() => parseKeyboardInteractiveChallenge(valid.subarray(0, valid.length - 1)), /Malformed/);
|
||||
|
||||
const tooMany = Array.from({ length: 17 }, () => ({ text: 'Password:', echo: false }));
|
||||
assert.throws(() => parseKeyboardInteractiveChallenge(challenge(tooMany)), /too many prompts/);
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
classifyHostKey as classifyBrowserHostKey,
|
||||
SSH_FINGERPRINT_RE,
|
||||
} from '../frontend/src/host-key.ts';
|
||||
import { classifyHostKey as classifyWorkerHostKey } from '../src/ssh/host-key.ts';
|
||||
|
||||
const OLD = 'SHA256:McWyGS5cvgt9EvFLQxxLOTPDqAY1MmKx3iAhgXUpEiY';
|
||||
const CURRENT = 'SHA256:xs49vJHNJGmereADhVfIZMt1xdeY04wYkj5FpDpLR9c';
|
||||
|
||||
test('validates OpenSSH SHA-256 fingerprints', () => {
|
||||
assert.equal(SSH_FINGERPRINT_RE.test(OLD), true);
|
||||
assert.equal(SSH_FINGERPRINT_RE.test(CURRENT), true);
|
||||
assert.equal(SSH_FINGERPRINT_RE.test(`${CURRENT}=`), false);
|
||||
assert.equal(SSH_FINGERPRINT_RE.test('SHA256:not-a-fingerprint'), false);
|
||||
});
|
||||
|
||||
test('browser prompts only for first-seen or changed host keys', () => {
|
||||
assert.equal(classifyBrowserHostKey('', CURRENT), 'first-seen');
|
||||
assert.equal(classifyBrowserHostKey(CURRENT, CURRENT), 'matched');
|
||||
assert.equal(classifyBrowserHostKey(OLD, CURRENT), 'changed');
|
||||
});
|
||||
|
||||
test('worker never treats a changed host key as already trusted', () => {
|
||||
assert.equal(classifyWorkerHostKey(undefined, CURRENT), 'first-seen');
|
||||
assert.equal(classifyWorkerHostKey(CURRENT, CURRENT), 'trusted');
|
||||
assert.equal(classifyWorkerHostKey(OLD, CURRENT), 'changed');
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user