Handle unknown host keys #17

Closed
opened 2024-07-22 21:24:13 +01:00 by danny · 9 comments
danny commented 2024-07-22 21:24:13 +01:00 (Migrated from gitlab.keyop.co.uk)

When connecting to an unknown host, we currently error that the host key is unknown:

DEBUG  [2024-07-22T21:20:26+01:00] Opening ssh connection to 'dannys@jumpdc.cinesite.co.uk:22' 
FATAL  [2024-07-22T21:20:27+01:00] Failed to connect to jump host: sshtunnel.Dial('dannys', 'jumpdc.cinesite.co.uk:22') failed: ssh: handshake failed: knownhosts: key is unknown 
DEBUG  [2024-07-22T21:20:29+01:00] appConfig: {Verbose:false Debug:true Profiling:false JumpUser:dannys JumpHost:jumpdc.cinesite.co.uk Network:10.213.16.0/20 ForwardedPorts:[80 443 9100 9090] QueueNumber:307} 

This is reasonable behaviour, but it would be convenient to add unknown host keys automatically (although incorrect keys should still prevent connection).

When connecting to an unknown host, we currently error that the host key is unknown: ``` DEBUG [2024-07-22T21:20:26+01:00] Opening ssh connection to 'dannys@jumpdc.cinesite.co.uk:22' FATAL [2024-07-22T21:20:27+01:00] Failed to connect to jump host: sshtunnel.Dial('dannys', 'jumpdc.cinesite.co.uk:22') failed: ssh: handshake failed: knownhosts: key is unknown DEBUG [2024-07-22T21:20:29+01:00] appConfig: {Verbose:false Debug:true Profiling:false JumpUser:dannys JumpHost:jumpdc.cinesite.co.uk Network:10.213.16.0/20 ForwardedPorts:[80 443 9100 9090] QueueNumber:307} ``` This is reasonable behaviour, but it would be convenient to add unknown host keys automatically (although incorrect keys should still prevent connection).
danny commented 2024-07-22 21:24:13 +01:00 (Migrated from gitlab.keyop.co.uk)

assigned to @danny

assigned to @danny
danny commented 2026-07-04 14:47:49 +01:00 (Migrated from gitlab.keyop.co.uk)

mentioned in issue #3

mentioned in issue #3
danny commented 2026-07-04 15:04:43 +01:00 (Migrated from gitlab.keyop.co.uk)

To be clear, only keys which are unknown should be accepted and automatically added to the known_hosts configuration, and this behaviour should be a configurable option, on by default.

To be clear, only keys which are unknown should be accepted and automatically added to the `known_hosts` configuration, and this behaviour should be a configurable option, on by default.
danny commented 2026-07-05 19:13:13 +01:00 (Migrated from gitlab.keyop.co.uk)

Plan

golang.org/x/crypto/ssh/knownhosts (already used by sshtunnel.Dial) returns a
*knownhosts.KeyError when a host key doesn't check out, and that error type already
distinguishes the two cases this ticket needs treated differently:

  • KeyError.Want empty -> the host has no existing entry at all - genuinely unknown.
  • KeyError.Want non-empty -> the host has an entry, but with a different key - a
    mismatch, and exactly the signal of a possible MITM attack.

So the fix wraps the existing knownhosts.New(...) callback: on an empty-Want KeyError
(genuinely unknown host), auto-add the new key to known_hosts and accept the connection: on
anything else (a real mismatch, or any other failure) it's rejected exactly as today - never
silently overwritten.

  • New config option: ssh_strict_host_key_checking (Configuration.SSHStrictHostKeyChecking),
    a global setting matching ssh_key_path/known_hosts_path. Named after OpenSSH's own
    well-known StrictHostKeyChecking option for the same behavior. Defaults to false (i.e.
    auto-add is on by default, per your comment) - deliberately phrased as the "strict" opt-in
    rather than an "auto-add" opt-out, so Go's normal zero-value (false) already matches the
    wanted default without needing any special defaulting logic in config.LoadFromFile. Applies
    to both --config YAML and the legacy CLI-flags path uniformly (the latter never sets it
    either, so it inherits the same default).
  • sshtunnel.Dial: gains an autoAddUnknownHostKeys bool parameter (inverse of
    SSHStrictHostKeyChecking, wired up in internal/nfq_forwarder). When true, its
    HostKeyCallback wraps knownhosts.New's callback: on an empty-Want KeyError, it appends
    a new line to the known_hosts file (knownhosts.Line(...), same helper the file's own format
    is built from) and accepts the key; any other error (mismatch, revoked key, unreadable file,
    etc.) is returned unchanged - the connection is refused.
  • A log line is emitted whenever a new key is auto-added, so it's visible/auditable rather than
    silent.

Known limitation, out of scope for this MR: this only affects the "host key doesn't match an
existing known_hosts entry" case. If the known_hosts file itself doesn't exist yet at all,
knownhosts.New already fails before any of this runs (unchanged, pre-existing behavior) - the
file still needs to exist (even empty) for auto-add to kick in. Happy to also handle
create-if-missing in a follow-up if that'd be useful, but wanted to flag it rather than silently
expand scope.

Testing

Since this is pure file I/O + the knownhosts package's own logic (no live SSH handshake
needed), tests build real ssh.PublicKey values (ed25519) and a temp known_hosts file
directly:

  • a genuinely unknown host gets its key added and the connection accepted, and a second check
    against the now-updated file also passes (round-trip proof, not just "a line got written");
  • a host that's already known with a different key is still rejected, and the file is left
    unmodified (the critical security case - proves auto-add never overwrites a changed key);
  • a host that's already known with the matching key is accepted, unaffected by this change;
  • with ssh_strict_host_key_checking: true, an unknown host is still rejected exactly as
    today, and the file is left unmodified.

Work is happening on branch issue17_unknown_host_keys; an MR will follow referencing this
issue.

## Plan `golang.org/x/crypto/ssh/knownhosts` (already used by `sshtunnel.Dial`) returns a `*knownhosts.KeyError` when a host key doesn't check out, and that error type already distinguishes the two cases this ticket needs treated differently: - `KeyError.Want` **empty** -> the host has no existing entry at all - genuinely unknown. - `KeyError.Want` **non-empty** -> the host has an entry, but with a *different* key - a mismatch, and exactly the signal of a possible MITM attack. So the fix wraps the existing `knownhosts.New(...)` callback: on an empty-`Want` `KeyError` (genuinely unknown host), auto-add the new key to `known_hosts` and accept the connection: on anything else (a real mismatch, or any other failure) it's rejected exactly as today - never silently overwritten. - **New config option**: `ssh_strict_host_key_checking` (`Configuration.SSHStrictHostKeyChecking`), a global setting matching `ssh_key_path`/`known_hosts_path`. Named after OpenSSH's own well-known `StrictHostKeyChecking` option for the same behavior. **Defaults to `false`** (i.e. auto-add is on by default, per your comment) - deliberately phrased as the "strict" opt-*in* rather than an "auto-add" opt-out, so Go's normal zero-value (`false`) already matches the wanted default without needing any special defaulting logic in `config.LoadFromFile`. Applies to both `--config` YAML and the legacy CLI-flags path uniformly (the latter never sets it either, so it inherits the same default). - **`sshtunnel.Dial`**: gains an `autoAddUnknownHostKeys bool` parameter (inverse of `SSHStrictHostKeyChecking`, wired up in `internal/nfq_forwarder`). When true, its `HostKeyCallback` wraps `knownhosts.New`'s callback: on an empty-`Want` `KeyError`, it appends a new line to the known_hosts file (`knownhosts.Line(...)`, same helper the file's own format is built from) and accepts the key; any other error (mismatch, revoked key, unreadable file, etc.) is returned unchanged - the connection is refused. - A log line is emitted whenever a new key is auto-added, so it's visible/auditable rather than silent. **Known limitation, out of scope for this MR**: this only affects the "host key doesn't match an existing known_hosts entry" case. If the `known_hosts` **file itself** doesn't exist yet at all, `knownhosts.New` already fails before any of this runs (unchanged, pre-existing behavior) - the file still needs to exist (even empty) for auto-add to kick in. Happy to also handle create-if-missing in a follow-up if that'd be useful, but wanted to flag it rather than silently expand scope. ## Testing Since this is pure file I/O + the `knownhosts` package's own logic (no live SSH handshake needed), tests build real `ssh.PublicKey` values (ed25519) and a temp `known_hosts` file directly: - a genuinely unknown host gets its key added and the connection accepted, and a *second* check against the now-updated file also passes (round-trip proof, not just "a line got written"); - a host that's already known with a *different* key is still rejected, and the file is left unmodified (the critical security case - proves auto-add never overwrites a changed key); - a host that's already known with the *matching* key is accepted, unaffected by this change; - with `ssh_strict_host_key_checking: true`, an unknown host is still rejected exactly as today, and the file is left unmodified. Work is happening on branch `issue17_unknown_host_keys`; an MR will follow referencing this issue.
danny commented 2026-07-05 19:25:06 +01:00 (Migrated from gitlab.keyop.co.uk)

mentioned in commit e7b8178413

mentioned in commit e7b8178413ec9f92b891af732c8e4193bf1bcb4a
danny commented 2026-07-05 19:25:29 +01:00 (Migrated from gitlab.keyop.co.uk)

mentioned in merge request !66

mentioned in merge request !66
danny commented 2026-07-05 19:40:03 +01:00 (Migrated from gitlab.keyop.co.uk)

Added a follow-up commit to MR !66: when auto-add is enabled (the default), known_hosts_path is now created automatically (empty) if it doesn't already exist, so a brand new setup that's never manually ssh'd anywhere works out of the box - no manual touch known_hosts needed first. With ssh_strict_host_key_checking: true, a missing file remains a hard error, matching that setting's explicit intent. The MR description and tests have been updated accordingly.

Added a follow-up commit to MR !66: when auto-add is enabled (the default), `known_hosts_path` is now created automatically (empty) if it doesn't already exist, so a brand new setup that's never manually `ssh`'d anywhere works out of the box - no manual `touch known_hosts` needed first. With `ssh_strict_host_key_checking: true`, a missing file remains a hard error, matching that setting's explicit intent. The MR description and tests have been updated accordingly.
danny commented 2026-07-05 21:28:09 +01:00 (Migrated from gitlab.keyop.co.uk)

mentioned in commit 79f9e6ba9e02eca23090f98a3aad381875fafbf1

mentioned in commit 79f9e6ba9e02eca23090f98a3aad381875fafbf1
danny (Migrated from gitlab.keyop.co.uk) closed this issue 2026-07-05 21:28:09 +01:00
danny commented 2026-07-05 21:29:40 +01:00 (Migrated from gitlab.keyop.co.uk)

Fixed via MR !66 and released in v1.0.5 (https://gitlab.keyop.co.uk/keyop/go/nfq_forwarder/-/releases/v1.0.5). Unknown jump host keys are now automatically accepted and recorded on first connection (Trust On First Use), on by default, controlled by ssh_strict_host_key_checking; a key that's already known but has changed is still always rejected. known_hosts_path is also now created automatically if missing. Closing.

Fixed via MR !66 and released in **v1.0.5** (https://gitlab.keyop.co.uk/keyop/go/nfq_forwarder/-/releases/v1.0.5). Unknown jump host keys are now automatically accepted and recorded on first connection (Trust On First Use), on by default, controlled by `ssh_strict_host_key_checking`; a key that's already known but has changed is still always rejected. `known_hosts_path` is also now created automatically if missing. Closing.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
keyop-go/nfq_forwarder#17
No description provided.