1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
|
/**
* Host-key trust decision — the `accept-new` (auto-trust-and-pin) analog.
*
* Per decisions #2/#3: on connect, the ssh2 `hostVerifier` checks whether the
* host's key is in `~/.ssh/known_hosts`. If present → verify match (reject on
* mismatch, surface "HOST KEY CHANGED" loudly). If absent (first connect) →
* accept + append the fingerprint to `known_hosts` (the pin). A future FE
* "approve host key" prompt (roadmap) would gate that first accept.
*
* The DECISION is PURE (input → output, no I/O, zero mocks): given the current
* known_hosts text + the host + its key fingerprint, decide accept/reject and
* (if pinning) produce the line to append. The I/O — reading/append-writing the
* real `~/.ssh/known_hosts` file — lives in the `SshConnectionPool` shell, which
* injects the text + applies the append. This keeps the policy unit-testable
* against fixture strings (plan §4.4).
*/
/** Outcome of a host-key check. The shell acts on `accept` + `append`. */
export interface HostKeyDecision {
/** Accept the connection (true) or reject it loudly (false). */
readonly accept: boolean;
/**
* When the host is unseen (first connect), the line to append to
* `known_hosts` to pin the key. `undefined` when the host is already known
* (no write needed) or when rejecting (do not pin a mismatched key).
*/
readonly append: string | undefined;
/** Human-readable reason for logging/the rejection error. */
readonly reason: string;
}
/**
* The host-key verification fingerprint. ssh2 hands the verifier the raw host
* key Buffer; the shell computes a fingerprint string (e.g. an OpenSSH-style
* `SHA256:...` hex) + the key type + the line OpenSSH would write (the `host`
* token + base64 key) so the decision is string-comparable against known_hosts.
*
* Carrying the exact line OpenSSH itself writes keeps `~/.ssh/known_hosts`
* interchangeable with the real ssh client (decision #2 — the file is the
* shared trust store).
*/
export interface HostKeyFingerprint {
/** The OpenSSH `known_hosts` line token, e.g. `[localhost]:2222` or `myhost`. */
readonly knownHostToken: string;
/** The base64-encoded public key (the 2nd field of a known_hosts line). */
readonly keyBase64: string;
/** Key type label, e.g. `ssh-ed25519` (the 1st field). */
readonly keyType: string;
}
/**
* Decide whether to accept a host key, given the current `known_hosts` text.
*
* The matching mirrors OpenSSH's `known_hosts` semantics at the granularity the
* MVP needs: a line whose FIRST field equals `fingerprint.knownHostToken` is a
* match for that host. (OpenSSH also supports comma-host + hash + wildcard
* tokens; the MVP pins one explicit token per host — the shell writes exactly
* the token ssh2 supplied — so a plain first-field compare is correct and
* sufficient. A full known_hosts parser is a roadmap item.)
*
* - **present + key matches** → accept, no append (already pinned).
* - **present + key differs** → REJECT ("HOST KEY CHANGED" — never silently
* connect; this is the MITM guard).
* - **absent (first connect)** → accept + append the pin line.
*
* Pure: `knownHostsText` + `fingerprint` → `HostKeyDecision`.
*/
export function decideHostKey(
knownHostsText: string,
fingerprint: HostKeyFingerprint,
): HostKeyDecision {
const { knownHostToken, keyBase64, keyType } = fingerprint;
const expectedLine = `${knownHostToken} ${keyType} ${keyBase64}`;
// A line matches THIS host when its first field is the knownHostToken.
const existing = findHostLine(knownHostsText, knownHostToken);
if (existing === undefined) {
// Absent → first connect → accept + pin (the accept-new analog).
return {
accept: true,
append: expectedLine,
reason: `first connect to "${knownHostToken}": pinning host key`,
};
}
// Present → compare the key material (fields 2+3). Ignore leading/trailing
// whitespace differences (OpenSSH tolerates these).
const normalizedExisting = normalizeLine(existing);
if (normalizedExisting === normalizeLine(expectedLine)) {
return { accept: true, append: undefined, reason: `host key for "${knownHostToken}" matches` };
}
// Present but DIFFERENT → reject loudly. Do NOT pin (the key changed →
// possible MITM; the user must clear the stale line manually).
return {
accept: false,
append: undefined,
reason:
`HOST KEY CHANGED for "${knownHostToken}" — refusing to connect ` +
`(remove the stale entry from ~/.ssh/known_hosts if this change is expected)`,
};
}
/** Find the first known_hosts line whose first field is `token`. Pure. */
function findHostLine(text: string, token: string): string | undefined {
for (const raw of text.split("\n")) {
const line = raw.trim();
if (line === "" || line.startsWith("#")) continue;
// First whitespace-delimited field is the host token (possibly comma-list).
const firstSpace = findFirstSpace(line);
const firstField = firstSpace === -1 ? line : line.slice(0, firstSpace);
// A token may be a comma-separated host list; accept if any element matches.
if (
firstField
.split(",")
.map((h) => h.trim())
.includes(token)
) {
return line;
}
}
return undefined;
}
/** Normalize a known_hosts line for key-material comparison (host-independent). */
function normalizeLine(line: string): string {
const parts = line.split(/\s+/).filter((p) => p.length > 0);
// Drop the first field (host token); compare key-type + base64 key.
return parts.slice(1).join(" ");
}
function findFirstSpace(line: string): number {
for (let i = 0; i < line.length; i++) {
const ch = line.charCodeAt(i);
if (ch === 32 || ch === 9) return i; // space or tab
}
return -1;
}
/**
* Read whether a host appears in `known_hosts` at all (for the read-only
* `Computer.knownHost` view surfaced by `GET /computers`). Pure. Uses the same
* first-field matching as `decideHostKey`.
*/
export function isKnownHost(knownHostsText: string, token: string): boolean {
return findHostLine(knownHostsText, token) !== undefined;
}
|