Files
orca/src/main/ssh/ssh-known-hosts.ts
T

337 lines
13 KiB
TypeScript

/**
* 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')
}
/**
* Whether the blob is exactly consumed by its own SSH-wire structure.
*
* A public key blob is a run of length-prefixed fields — name, then the algorithm's parameters — and
* nothing else. Checking only the algorithm header leaves trailing bytes undetected, and ssh parses
* the whole structure: verified live against OpenSSH 10.2p1, a valid ed25519 key with four extra
* base64 characters appended is still valid base64 and still reports `ssh-ed25519`, but ssh reports
* "No ED25519 host key is known" and drops the line, where we decoded 54 bytes instead of 51 and
* raised a CHANGED alarm from an entry the user's own ssh ignores.
*
* Deliberately algorithm-agnostic: the field walk is the same for every key type, so a type we do
* not model is checked as well as one we do.
*/
function isWellFormedHostKeyBlob(key: Buffer): boolean {
let offset = 0
while (offset < key.length) {
if (offset + 4 > key.length) {
return false
}
const fieldLength = key.readUInt32BE(offset)
offset += 4
if (fieldLength > key.length - offset) {
return false
}
offset += fieldLength
}
return offset === key.length
}
/** `SHA256:...` exactly as `ssh-keygen -lf` prints it, base64 with padding stripped. */
export function formatHostKeyFingerprint(sha256Base64: string): string {
return `SHA256:${sha256Base64.replace(/=+$/, '')}`
}
/**
* The ONE way this file turns a base64 field into bytes. Every base64 field on a known_hosts line —
* key blob, hash salt, host hash — must come through here, so a field added later cannot quietly
* skip the rule.
*
* Why re-encode and compare: Buffer.from never throws on bad base64, it silently SKIPS invalid
* characters, so `<valid>!!!` and a field with `@@` spliced into it both decode to the same correct
* bytes. ssh rejects those lines outright ("parse error in hostkeys file", "salt decode error"), so
* accepting them grants trust from a line the user's own ssh ignores; `<valid>AAAA` is worse still,
* decoding to different bytes that still parse, which reads as a CHANGED key.
*
* The comparison is EXACT, padding included, which is what OpenSSH's b64_pton does: it rejects a
* missing `=`, a stray one, the base64url alphabet, and a final character whose leftover bits are
* non-zero. Verified live against OpenSSH 10.2p1 — each of those mutations on a real `ssh-keygen -H`
* salt makes ssh refuse to find the host at all.
*/
function decodeCanonicalBase64(raw: string): Buffer | undefined {
const decoded = Buffer.from(raw, 'base64')
// Empty would re-encode to '' and pass the comparison; `|1||hash` must not survive as an entry.
if (decoded.length === 0) {
return undefined
}
return decoded.toString('base64') === raw ? 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 = decodeCanonicalBase64(parts[2] ?? '')
const hash = decodeCanonicalBase64(parts[3] ?? '')
// Length is orthogonal to canonicality, so both checks are needed: ssh requires BOTH fields to be
// exactly one SHA1 digest — extract_salt rejects anything else with "expected salt len 20, got N".
// Accepting a shorter salt would let us match a line ssh treats as a parse error, so the entry
// would be invisible to the user's own ssh but trusted by us.
if (!salt || !hash || salt.length !== SHA1_DIGEST_BYTES || 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 = decodeCanonicalBase64(keyBase64)
if (!key || readHostKeyType(key) !== keyType || !isWellFormedHostKeyBlob(key)) {
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.
*
* `HostKeyAlias` suppresses the port ENTIRELY: ssh looks the alias up bare and never brackets it.
* Verified against OpenSSH 10.2p1 on port 2225 with HostKeyAlias=myalias — an entry keyed `myalias`
* authenticates, and one keyed `[myalias]:2225` gives "No ED25519 host key is known for myalias".
* Bracketing an alias is not merely a stale-entry false alarm: because the first pass now decides
* as soon as it finds any entry, a leftover `[alias]:port` line would BLOCK the bare lookup that
* ssh actually performs, turning a working bastion into a hard failure.
*/
export function hostCandidatePasses(
host: string,
port: number,
isHostKeyAlias = false
): string[][] {
const lower = host.toLowerCase()
return port === 22 || isHostKeyAlias ? [[lower]] : [[`[${lower}]:${port}`], [lower]]
}
export type KnownHostsQuery = {
host: string
port: number
keyType: string
key: Buffer
/** True when `host` came from `HostKeyAlias`, which ssh looks up without the port. */
isHostKeyAlias?: boolean
}
/**
* 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, query.isHostKeyAlias)
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'
}
}
}
// A CA line is a MARKER, so it never satisfies a plain host key and never stops the fallback pass.
// Tracked across passes because it describes the host either way.
let sawCertAuthority = false
for (let passIndex = 0; passIndex < passes.length; passIndex += 1) {
const candidates = passes[passIndex]!
// Per pass, never carried forward. These describe what THIS candidate form knows, and the
// fallback form's knowledge is not admissible as evidence of a change — see below.
let sawSameTypeForHost = false
let sawPlainEntryForHost = false
for (const entry of entries) {
if (entry.marker === 'revoked' || !matchesHost(entry, candidates)) {
continue
}
if (entry.marker === 'cert-authority') {
sawCertAuthority = true
continue
}
// Byte equality implies the types agree: the blob carries its own algorithm name, and parsing
// already rejected any line whose declared type disagreed with it.
if (entry.key.equals(query.key)) {
return 'match'
}
sawPlainEntryForHost = true
sawSameTypeForHost ||= entry.keyType === query.keyType
}
// The first pass decides if it knows this host AT ALL. OpenSSH runs the bare-host fallback only
// when the port-qualified lookup matched no plain entry of ANY type; when one was there, its
// verdict is final. Verified live against OpenSSH 10.2p1 on 127.0.0.1:2223 — an off-port RSA
// entry plus a bare, correct ed25519 line makes ssh print IDENTIFICATION HAS CHANGED with no
// "checking without port identifier", where continuing to the fallback returns `match` and
// ACCEPTS A CHANGED KEY.
if (passIndex === 0 && sawPlainEntryForHost) {
// Both refuse. The distinction only picks the message: a changed key of the type we hold, or
// a type we have never seen for a host we do know. ssh calls both HOST_CHANGED.
return sawSameTypeForHost ? 'mismatch' : 'unknown-type-known-host'
}
}
// Nothing after the first pass may report a change. On the fallback pass OpenSSH downgrades any
// non-match to "not known" — including an entry of another type, which it treats as plain first
// contact. Verified live: a bare ssh-rsa entry, dialed on 2223 against an ed25519-only server,
// makes ssh add the host and connect. Reporting unknown-type-known-host there refuses a host ssh
// accepts, which is why these flags are scoped to their pass.
return sawCertAuthority ? 'ca-only' : 'unknown'
}