Skip to content

Security: verify server host keys against known_hosts (TOFU) - #42

Merged
frequency403 merged 4 commits into
developmentfrom
security/host-key-verification
Sep 28, 2026
Merged

frequency403 merged 4 commits into
developmentfrom
security/host-key-verification

Conversation

@frequency403

Copy link
Copy Markdown
Owner

Summary

ServerConnection created its SshClient without a HostKeyReceived handler, and SSH.NET then accepts every host key. That allowed a man-in-the-middle attack:

  • An attacker in the network path can impersonate the target server.
  • With PasswordConnectionCredentials, the attacker receives the password in plain text.
  • The attacker can manipulate the authorized_keys and known_hosts content that the app displays and writes back.

New

  • KnownHostKeyStore (Core) checks the presented key against ~/.ssh/known_hosts. It supports plain and hashed (|1|salt|hash) host names, [host]:port, comma-separated lists, wildcards and negations (via SshWildcardMatcher), and @revoked. @cert-authority entries are ignored. The comparison uses the key blob; a mismatch is only reported for the same key type, matching OpenSSH.

  • ServerConnection rejects every key the validator does not trust, on both the SSH and the SFTP channel. Without a validator, it rejects everything.

  • ServerConnectionService handles the three outcomes:

    Situation Behavior
    Unknown host Dialog showing host, key type and SHA256: fingerprint. If the user confirms, the key is appended to known_hosts and the connection is re-established.
    Mismatch or @revoked HostKeyVerificationException with an MITM warning, no dialog.
    User declines HostKeyVerificationException.
  • HostKeyTrustPrompt (UI) implements the dialog via IMessageBoxProvider, marshalled to the UI thread, with strings in EN and DE.

  • New known_hosts entries are written as plain host names (lower case, [host]:port for ports other than 22). A new file gets 0600.

Base is security/file-access-hardening (#40), because the validator is attached to the SFTP client introduced there as well.

Changes Made

  • Bug fix (non-breaking change)
  • New feature (non-breaking change)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update
  • Refactoring (no functional change)

Testing Performed

  • Tested on Windows (Version: _______)
  • Tested on macOS (Version: _______)
  • Tested on Linux (Ubuntu 24.04, .NET SDK 10.0.112, OpenSSH 9.6p1 server)
  • Added unit tests (if applicable)
  • Verified no regression in existing functionality

Test Evidence

  • New file KnownHostKeyStoreTests with 15 test cases. They cover plain, list, port, wildcard, hashed and negated entries, mismatch, a different key type, @revoked, @cert-authority, Add (including a missing trailing newline and 0600), and the fingerprint format. All 222 tests pass.
  • End-to-end run against a local sshd (port 2222, password auth) using the real ServerConnectionService:
    • Unknown host, declined: HostKeyVerificationException(Unknown).
    • Unknown host, accepted: connected, entry written to known_hosts.
    • Known host: connected without a prompt.
    • Manipulated key in known_hosts: HostKeyVerificationException(Mismatch), no prompt.
    • The same run also exercises the SFTP path from Security: harden local key file writes, use SFTP for remote file access #40. authorized_keys with the comments first"quoted"$(id) and it's'$HOME was read and written back byte-for-byte. When .ssh was missing, the directory was created with 0700 and the file with 0600.

Checklist

  • Code follows project style guidelines
  • Self-reviewed my code
  • Commented difficult areas
  • Updated documentation (if needed)
  • No new warnings/errors introduced
  • All builds pass locally (dotnet build, dotnet test)

Questions for Reviewers

  • ConnectToServerViewModel.TestConnectionAsync runs with a 5 s timeout. If the user needs longer than that in the TOFU dialog, the reconnect after confirmation fails with a cancellation. The key is already stored at that point, so the next attempt works. Should the dialog be excluded from the timeout?
  • known_hosts is always taken from ~/.ssh/known_hosts. UserKnownHostsFile or GlobalKnownHostsFile from ssh_config are not evaluated.

Notes for Reviewers:

  • Priority: high
  • Breaking change: no. Behavior change: the first connection to a host now asks for confirmation.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XSUJwZ17AWMdDvYXboJAbQ


Generated by Claude Code

Local:
- KeyFileWriterService: truncate on overwrite (FileMode.Create), CreateNew
  otherwise, FileShare.None, and enforce 0600 on overwritten files since
  UnixCreateMode only applies on creation.
- Create the backup directory (holds private key copies) and ~/.ssh with 0700.

Remote:
- Read/write authorized_keys and known_hosts via SFTP instead of
  shell command lines built from server-provided paths and file contents
  (broken quoting on Windows for entries containing '"', e.g. command="...").
- No more eager file creation on connect; missing files read as empty and
  are created with 0600 (dir 0700) on Unix hosts when written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XSUJwZ17AWMdDvYXboJAbQ
SshClient was created without a HostKeyReceived handler, so SSH.NET
accepted any host key. An attacker in the network path could impersonate
the server, capture the password of PasswordConnectionCredentials and
tamper with authorized_keys/known_hosts shown and written by the app.

- KnownHostKeyStore checks the presented key against ~/.ssh/known_hosts
  (plain and hashed |1| names, [host]:port, wildcards, negation,
  @Revoked; @cert-authority is ignored).
- ServerConnection rejects every key the validator does not trust, for
  both the SSH and the SFTP channel.
- ServerConnectionService: unknown host -> dialog with SHA256 fingerprint,
  on confirmation the key is appended to known_hosts and the connection
  is retried; mismatch or revoked -> HostKeyVerificationException.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XSUJwZ17AWMdDvYXboJAbQ
Base automatically changed from security/file-access-hardening to development September 28, 2026 12:51
@frequency403
frequency403 merged commit 9e268cc into development Sep 28, 2026
0 of 3 checks passed
@frequency403
frequency403 deleted the security/host-key-verification branch September 28, 2026 13:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants