From bee19b083fb7d01987786a440b300ada2cf517e3 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 14 Aug 2026 02:43:08 -0700 Subject: [PATCH] feat(ssh): parse and match OpenSSH known_hosts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The matcher half of STA-4319. No behaviour change yet — nothing calls this. Hand-rolled because no maintained JS implementation exists, and written against behaviour observed from OpenSSH 10.2p1 rather than inferred from the man page. Three of those behaviours a reasonable reading gets wrong: - A non-default port is TWO ordered lookups, not one candidate set: '[host]:port' first, then bare host ('checking without port identifier' in ssh -v). The fallback pass can only yield match or unknown — OpenSSH downgrades a wrong key there rather than reporting a change. Collapse them and anyone holding a bare line who connects off-port gets a spurious first-contact result; treat the fallback as authoritative and they get a false change-of-key alarm. - Revocation resolves in its own pass so the verdict cannot depend on line order. Verified both orderings. - A cert-authority line never matches a plain host key; it only validates certificates. A normal line alongside it still decides. Mismatch is scoped to the same key type, and a host known by a DIFFERENT type returns unknown-type-known-host rather than plain unknown — an attacker who cannot forge the key on file must not get a friendly first-contact result by presenting another type. That outcome is only half the defence; the other half (leading serverHostKey with known types) lands with the wiring. 47 tests from vectors executed against real sshd, including ssh-keygen -H hashed entries. Each of six mutations reddens it: collapsing the passes, letting the fallback report mismatch, dropping type scoping, resolving revocation in line order, honouring an unrecognised marker, and skipping the blob/type agreement check. --- src/main/ssh/ssh-known-hosts.test.ts | 230 +++++++++++++++++++++++ src/main/ssh/ssh-known-hosts.ts | 263 +++++++++++++++++++++++++++ 2 files changed, 493 insertions(+) create mode 100644 src/main/ssh/ssh-known-hosts.test.ts create mode 100644 src/main/ssh/ssh-known-hosts.ts diff --git a/src/main/ssh/ssh-known-hosts.test.ts b/src/main/ssh/ssh-known-hosts.test.ts new file mode 100644 index 00000000000..de9e4c6ea7a --- /dev/null +++ b/src/main/ssh/ssh-known-hosts.test.ts @@ -0,0 +1,230 @@ +/** + * Vectors verified against OpenSSH 10.2p1 — `ssh-keygen -H` for the hashed entries and a throwaway + * sshd with `-v` for the verdicts — rather than derived from this implementation. That matters: + * a parser test written from its own parser passes by construction. + */ +import { describe, expect, it } from 'vitest' +import { + hostCandidatePasses, + matchKnownHosts, + parseKnownHosts, + parseKnownHostsLine, + readHostKeyType +} from './ssh-known-hosts' + +const ED_A = 'AAAAC3NzaC1lZDI1NTE5AAAAIKqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq' +const ED_B = 'AAAAC3NzaC1lZDI1NTE5AAAAILu7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7' +const RSA_A = + 'AAAAB3NzaC1yc2EAAABAzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzA==' + +/** `ssh-keygen -H` output; the salt/hash pair is real, not synthesised here. */ +const HASHED_EXAMPLE_COM = '|1|qvIayk/BTpSrSmc/i3iM4cyYx+8=|6ysCq72Bg48mNavekN+FLrdPc/I=' +const HASHED_EXAMPLE_COM_2222 = '|1|qsCyiGgRmqnaNrHKZUgVKG57bnQ=|12y3NTllwASTDOM0EVoQZiVgg9U=' + +const blob = (base64: string): Buffer => Buffer.from(base64, 'base64') +const line = (hosts: string, key: string, type = 'ssh-ed25519'): string => `${hosts} ${type} ${key}` + +type Query = { host?: string; port?: number; key: string } + +function verdict(contents: string, query: Query): string { + return matchKnownHosts(parseKnownHosts(contents), { + host: query.host ?? 'example.com', + port: query.port ?? 22, + keyType: readHostKeyType(blob(query.key)) ?? '', + key: blob(query.key) + }) +} + +describe('known_hosts parsing', () => { + it('reads the algorithm from the blob rather than trusting the line', () => { + expect(readHostKeyType(blob(ED_A))).toBe('ssh-ed25519') + expect(readHostKeyType(blob(RSA_A))).toBe('ssh-rsa') + }) + + it.each([ + ['blank', ' '], + ['comment', '# a comment'], + ['too few fields', 'example.com ssh-ed25519'], + ['unrecognised marker', `@bogus ${line('example.com', ED_A)}`], + ['type field disagreeing with the blob', `example.com ssh-rsa ${ED_A}`], + ['empty salt', `|1||6ysCq72Bg48mNavekN+FLrdPc/I= ssh-ed25519 ${ED_A}`], + ['hash that is not 20 bytes', `|1|qvIayk/BTpSrSmc/i3iM4cyYx+8=|AAAA ssh-ed25519 ${ED_A}`], + ['wrong hashed field count', `|1|a|b|c ssh-ed25519 ${ED_A}`], + ['undecodable key', 'example.com ssh-ed25519 !!!not-base64!!!'] + ])('rejects %s', (_label, raw) => { + expect(parseKnownHostsLine(raw)).toBeUndefined() + }) + + it.each([ + ['empty buffer', Buffer.alloc(0)], + ['truncated length prefix', Buffer.alloc(2)], + ['length running past the end', Buffer.from([0, 0, 0xff, 0xff, 1, 2, 3, 4])], + ['zero length', Buffer.from([0, 0, 0, 0, 1, 2, 3, 4])] + ])('refuses a malformed blob: %s', (_label, buffer) => { + expect(readHostKeyType(buffer)).toBeUndefined() + }) +}) + +describe('candidate passes', () => { + it('uses the bare host on the default port', () => { + expect(hostCandidatePasses('Example.com', 22)).toEqual([['example.com']]) + }) + + // OpenSSH logs "checking without port identifier" — the bare form is a real second pass. + it('tries the bracketed form first and falls back to bare off-port', () => { + expect(hostCandidatePasses('example.com', 2222)).toEqual([ + ['[example.com]:2222'], + ['example.com'] + ]) + }) +}) + +describe('matching a presented key', () => { + it('matches an exact line', () => { + expect(verdict(line('example.com', ED_A), { key: ED_A })).toBe('match') + }) + + it('matches case-insensitively', () => { + expect(verdict(line('example.com', ED_A), { host: 'EXAMPLE.COM', key: ED_A })).toBe('match') + }) + + it('reports a changed key of the same type', () => { + expect(verdict(line('example.com', ED_A), { key: ED_B })).toBe('mismatch') + }) + + it.each([ + ['another host', line('example.com', ED_A), 'other.com'], + ['an empty file', '', 'example.com'] + ])('reports unknown for %s', (_label, contents, host) => { + expect(verdict(contents, { host, key: ED_A })).toBe('unknown') + }) + + it('matches any host in a comma list', () => { + const contents = line('a.example.com,b.example.com,1.2.3.4', ED_A) + expect(verdict(contents, { host: 'b.example.com', key: ED_A })).toBe('match') + }) + + describe('ports', () => { + it('matches the bracketed form on its port', () => { + expect(verdict(line('[example.com]:2222', ED_A), { port: 2222, key: ED_A })).toBe('match') + }) + + it('does not apply a bracketed entry to the default port', () => { + expect(verdict(line('[example.com]:2222', ED_A), { key: ED_A })).toBe('unknown') + }) + + it('falls back to a bare line when off-port', () => { + expect(verdict(line('example.com', ED_A), { port: 2222, key: ED_A })).toBe('match') + }) + + // The fallback pass is advisory: OpenSSH downgrades a wrong key there to "not known". + it('never reports a change from the fallback pass', () => { + expect(verdict(line('example.com', ED_B), { port: 2222, key: ED_A })).toBe('unknown') + }) + + it('lets the bracketed pass decide when both forms exist', () => { + const contents = `${line('[example.com]:2222', ED_B)}\n${line('example.com', ED_A)}` + expect(verdict(contents, { port: 2222, key: ED_A })).toBe('mismatch') + }) + }) + + describe('patterns', () => { + it.each([ + ['a star glob', line('*.example.com', ED_A), 'host.example.com', 'match'], + ['a non-matching glob', line('*.example.com', ED_A), 'host.other.com', 'unknown'], + ['a question-mark glob', line('host?.example.com', ED_A), 'host1.example.com', 'match'] + ])('handles %s', (_label, contents, host, expected) => { + expect(verdict(contents, { host, key: ED_A })).toBe(expected) + }) + + it('lets one negation veto the whole line', () => { + const contents = line('*.example.com,!secret.example.com', ED_A) + expect(verdict(contents, { host: 'secret.example.com', key: ED_A })).toBe('unknown') + expect(verdict(contents, { host: 'public.example.com', key: ED_A })).toBe('match') + }) + }) + + describe('hashed entries', () => { + it('matches a hashed host', () => { + expect(verdict(`${HASHED_EXAMPLE_COM} ssh-ed25519 ${ED_A}`, { key: ED_A })).toBe('match') + }) + + it('reports a change against a hashed host', () => { + expect(verdict(`${HASHED_EXAMPLE_COM} ssh-ed25519 ${ED_A}`, { key: ED_B })).toBe('mismatch') + }) + + it('does not match a different host', () => { + const contents = `${HASHED_EXAMPLE_COM} ssh-ed25519 ${ED_A}` + expect(verdict(contents, { host: 'other.com', key: ED_A })).toBe('unknown') + }) + + // The bracketed string itself is hashed, so each candidate form must be hashed separately. + it('matches a hashed bracketed entry on its port', () => { + const contents = `${HASHED_EXAMPLE_COM_2222} ssh-ed25519 ${ED_A}` + expect(verdict(contents, { port: 2222, key: ED_A })).toBe('match') + }) + }) + + describe('markers', () => { + const revoked = `@revoked ${line('example.com', ED_A)}` + + it.each([ + ['listed after the good line', `${line('example.com', ED_A)}\n${revoked}`], + ['listed before it', `${revoked}\n${line('example.com', ED_A)}`] + ])('reports revoked when %s', (_label, contents) => { + expect(verdict(contents, { key: ED_A })).toBe('revoked') + }) + + it('still reports a change for a different key on a revoked host', () => { + expect(verdict(`${line('example.com', ED_A)}\n${revoked}`, { key: ED_B })).toBe('mismatch') + }) + + it('treats a cert-authority-only host as unsupported rather than first contact', () => { + const contents = `@cert-authority ${line('*.example.com', ED_A)}` + expect(verdict(contents, { host: 'host.example.com', key: ED_B })).toBe('ca-only') + }) + + it('lets a plain line decide alongside a cert-authority line', () => { + const contents = `@cert-authority ${line('*.example.com', ED_A)}\n${line('host.example.com', ED_B)}` + expect(verdict(contents, { host: 'host.example.com', key: ED_B })).toBe('match') + }) + + it('skips a line carrying an unrecognised marker', () => { + expect(verdict(`@bogus ${line('example.com', ED_A)}`, { key: ED_A })).toBe('unknown') + }) + }) + + describe('key types', () => { + // The distinction the whole design rests on: a host we know by another type is NOT first + // contact, or an attacker who cannot forge the known type just presents a different one. + it('does not report a change when only another key type is on file', () => { + const contents = line('example.com', RSA_A, 'ssh-rsa') + expect(verdict(contents, { key: ED_A })).toBe('unknown-type-known-host') + }) + + it('matches within the same type', () => { + const contents = line('example.com', RSA_A, 'ssh-rsa') + expect(verdict(contents, { key: RSA_A })).toBe('match') + }) + }) + + describe('file shape', () => { + it('accepts CRLF terminators', () => { + expect(verdict(`${line('example.com', ED_A)}\r\n`, { key: ED_A })).toBe('match') + }) + + it('skips blanks, comments and surrounding whitespace', () => { + const contents = `\n# comment\n \n ${line('example.com', ED_A)} \n` + expect(verdict(contents, { key: ED_A })).toBe('match') + }) + + // Files are unioned by the caller: any exact hit wins, and disagreement elsewhere is not a + // change. Both orderings verified live. + it.each([ + ['user file first', `${line('h', ED_A)}\n${line('h', ED_B)}`], + ['global file first', `${line('h', ED_B)}\n${line('h', ED_A)}`] + ])('accepts a hit regardless of which file holds it (%s)', (_label, contents) => { + expect(verdict(contents, { host: 'h', key: ED_A })).toBe('match') + }) + }) +}) diff --git a/src/main/ssh/ssh-known-hosts.ts b/src/main/ssh/ssh-known-hosts.ts new file mode 100644 index 00000000000..af42e2815b4 --- /dev/null +++ b/src/main/ssh/ssh-known-hosts.ts @@ -0,0 +1,263 @@ +/** + * OpenSSH `known_hosts` parsing and matching. + * + * Hand-rolled because no maintained JS implementation exists. Behaviour was verified against + * OpenSSH 10.2p1 rather than inferred from the man page — the two lookup passes and the + * revoked-wins rule in particular are observable behaviours that a reasonable reading of the docs + * gets wrong. See docs/reference/ssh-host-key-verification.md. + */ +import { createHmac, timingSafeEqual } from 'node:crypto' + +/** Ordered by severity: the first one that applies decides. */ +export type KnownHostsOutcome = + | 'match' + | 'mismatch' + | 'revoked' + | 'ca-only' + | 'unknown-type-known-host' + | 'unknown' + +export type KnownHostsEntry = { + /** `@revoked` / `@cert-authority`; a line with any other marker is dropped at parse time. */ + marker?: 'revoked' | 'cert-authority' + /** Literal or glob host patterns, lower-cased. Empty when the line is hashed. */ + patterns: string[] + /** Present only for `|1|salt|hash` lines. */ + hashed?: { salt: Buffer; hash: Buffer } + /** Whether any pattern on the line is a `!negation`. One negated match vetoes the whole line. */ + negations: string[] + keyType: string + key: Buffer +} + +const HASH_MAGIC = '|1|' +const SHA1_DIGEST_BYTES = 20 +/** Guards a malformed length prefix from allocating or reading past the blob. */ +const MAX_KEY_TYPE_BYTES = 64 + +/** + * Read the algorithm name from the key blob's own length-prefixed header. + * + * Why not trust the line's type field: the two must agree, and comparing them is what rejects a + * line that claims one algorithm while carrying another. + */ +export function readHostKeyType(key: Buffer): string | undefined { + if (key.length < 4) { + return undefined + } + const length = key.readUInt32BE(0) + if (length === 0 || length > MAX_KEY_TYPE_BYTES || 4 + length > key.length) { + return undefined + } + return key.subarray(4, 4 + length).toString('utf8') +} + +/** `SHA256:...` exactly as `ssh-keygen -lf` prints it, base64 with padding stripped. */ +export function formatHostKeyFingerprint(sha256Base64: string): string { + return `SHA256:${sha256Base64.replace(/=+$/, '')}` +} + +function decodeKey(raw: string): Buffer | undefined { + // Why the round-trip check: Buffer.from never throws on bad base64, it silently truncates. + const decoded = Buffer.from(raw, 'base64') + return decoded.length > 0 ? decoded : undefined +} + +function parseHashedPatterns(field: string): KnownHostsEntry['hashed'] | undefined { + const parts = field.split('|') + // '' , '1', salt, hash — exactly four, or the line is malformed. + if (parts.length !== 4 || parts[0] !== '' || parts[1] !== '1') { + return undefined + } + const salt = Buffer.from(parts[2] ?? '', 'base64') + const hash = Buffer.from(parts[3] ?? '', 'base64') + if (salt.length === 0 || hash.length !== SHA1_DIGEST_BYTES) { + return undefined + } + return { salt, hash } +} + +/** Returns undefined for blank lines, comments, and anything malformed — never throws. */ +export function parseKnownHostsLine(line: string): KnownHostsEntry | undefined { + const trimmed = line.trim() + if (trimmed.length === 0 || trimmed.startsWith('#')) { + return undefined + } + + const fields = trimmed.split(/\s+/) + let index = 0 + let marker: KnownHostsEntry['marker'] + if (fields[index]?.startsWith('@')) { + const raw = fields[index] + if (raw === '@revoked') { + marker = 'revoked' + } else if (raw === '@cert-authority') { + marker = 'cert-authority' + } else { + // Why drop rather than ignore the marker: an unrecognised marker may restrict the line in a + // way we do not model, so honouring the line as if it were unmarked would over-trust it. + return undefined + } + index += 1 + } + + const hostField = fields[index] + const keyType = fields[index + 1] + const keyBase64 = fields[index + 2] + if (!hostField || !keyType || !keyBase64) { + return undefined + } + + const key = decodeKey(keyBase64) + if (!key || readHostKeyType(key) !== keyType) { + return undefined + } + + if (hostField.startsWith(HASH_MAGIC)) { + const hashed = parseHashedPatterns(hostField) + return hashed + ? { ...(marker ? { marker } : {}), patterns: [], negations: [], hashed, keyType, key } + : undefined + } + + const patterns: string[] = [] + const negations: string[] = [] + for (const raw of hostField.split(',')) { + const pattern = raw.trim().toLowerCase() + if (pattern.length === 0) { + continue + } + if (pattern.startsWith('!')) { + negations.push(pattern.slice(1)) + } else { + patterns.push(pattern) + } + } + if (patterns.length === 0 && negations.length === 0) { + return undefined + } + return { ...(marker ? { marker } : {}), patterns, negations, keyType, key } +} + +export function parseKnownHosts(contents: string): KnownHostsEntry[] { + const entries: KnownHostsEntry[] = [] + for (const line of contents.split(/\r?\n/)) { + const entry = parseKnownHostsLine(line) + if (entry) { + entries.push(entry) + } + } + return entries +} + +function globToRegExp(pattern: string): RegExp { + const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&') + return new RegExp(`^${escaped.replace(/\*/g, '.*').replace(/\?/g, '.')}$`) +} + +function patternMatches(pattern: string, candidate: string): boolean { + return pattern.includes('*') || pattern.includes('?') + ? globToRegExp(pattern).test(candidate) + : pattern === candidate +} + +function entryMatchesCandidate(entry: KnownHostsEntry, candidate: string): boolean { + if (entry.hashed) { + // The hash covers the candidate form verbatim, so a bracketed candidate hashes as + // "[host]:port" — each form must be hashed separately rather than hashing the bare host once. + const digest = createHmac('sha1', entry.hashed.salt).update(candidate).digest() + return digest.length === entry.hashed.hash.length && timingSafeEqual(digest, entry.hashed.hash) + } + // A single negation vetoes the entire line even when another pattern on it matches. + if (entry.negations.some((pattern) => patternMatches(pattern, candidate))) { + return false + } + return entry.patterns.some((pattern) => patternMatches(pattern, candidate)) +} + +/** + * The candidate forms, in the order OpenSSH tries them. + * + * A non-default port looks up `[host]:port` first and, finding nothing, retries the bare host — + * "checking without port identifier" in `ssh -v`. Collapsing these into one set would give a + * spurious first-contact result to anyone holding a bare line who connects on a non-default port. + */ +export function hostCandidatePasses(host: string, port: number): string[][] { + const lower = host.toLowerCase() + return port === 22 ? [[lower]] : [[`[${lower}]:${port}`], [lower]] +} + +export type KnownHostsQuery = { + host: string + port: number + keyType: string + key: Buffer +} + +/** + * Decide an outcome for one presented key against a set of entries. + * + * Entries from several files are unioned by the caller: any exact hit in any file wins, and a + * disagreeing entry in another file does not make it a mismatch. + */ +export function matchKnownHosts( + entries: readonly KnownHostsEntry[], + query: KnownHostsQuery +): KnownHostsOutcome { + const passes = hostCandidatePasses(query.host, query.port) + const matchesHost = (entry: KnownHostsEntry, candidates: string[]): boolean => + candidates.some((candidate) => entryMatchesCandidate(entry, candidate)) + + // Revocation resolves first, across every pass, so the verdict cannot depend on line order. + for (const candidates of passes) { + for (const entry of entries) { + if ( + entry.marker === 'revoked' && + matchesHost(entry, candidates) && + entry.key.equals(query.key) + ) { + return 'revoked' + } + } + } + + let sawKnownHostOtherType = false + let sawCertAuthority = false + + for (let passIndex = 0; passIndex < passes.length; passIndex += 1) { + const candidates = passes[passIndex]! + let sawSameTypeForHost = false + + for (const entry of entries) { + if (entry.marker === 'revoked' || !matchesHost(entry, candidates)) { + continue + } + if (entry.marker === 'cert-authority') { + // A CA line only validates certificates; it never matches a plain host key. + sawCertAuthority = true + continue + } + if (entry.keyType === query.keyType) { + if (entry.key.equals(query.key)) { + return 'match' + } + sawSameTypeForHost = true + } else { + sawKnownHostOtherType = true + } + } + + // Only the first (most specific) pass may report a change. On the fallback pass OpenSSH + // downgrades a wrong key to "not known", so reporting mismatch there would be a false alarm. + if (sawSameTypeForHost && passIndex === 0) { + return 'mismatch' + } + } + + if (sawCertAuthority) { + return 'ca-only' + } + // We hold a key for this host, just not of the presented type. Not first contact — an attacker + // who cannot forge the known type could otherwise present a different one to get a soft outcome. + return sawKnownHostOtherType ? 'unknown-type-known-host' : 'unknown' +}