feat(ssh): parse and match OpenSSH known_hosts

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.
This commit is contained in:
Neil
2026-08-14 02:43:08 -07:00
parent 8be1696629
commit bee19b083f
2 changed files with 493 additions and 0 deletions
+230
View File
@@ -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')
})
})
})
+263
View File
@@ -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'
}