feat: 实现 SSH 键盘交互认证,增加相关逻辑和测试用例
This commit is contained in:
1 parent
c040c1b9fd
commit
b0104591ac
7 files changed
+325
-37
No files matched your search
@@ -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