mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
337 lines
13 KiB
TypeScript
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'
|
|
}
|