mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 16:03:47 +00:00
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:
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
Reference in New Issue
Block a user