banger/internal/guest/known_hosts.go
Thales Maciel ae14b9499d
ssh: trust-on-first-use host key pinning everywhere
Guest host-key verification was off in all three SSH paths:

  * Go SSH (internal/guest/ssh.go) used ssh.InsecureIgnoreHostKey
  * `banger vm ssh` passed StrictHostKeyChecking=no
    + UserKnownHostsFile=/dev/null
  * `~/.ssh/config` Host *.vm shipped the same posture into the
    user's global config

Now each path verifies against a banger-owned known_hosts file at
`~/.local/state/banger/ssh/known_hosts` with TOFU semantics:

  * First dial to a VM pins the key.
  * Subsequent dials require an exact match. A mismatch fails with
    an explicit "possible MITM" error.
  * `vm delete` removes the entries so a future VM reusing the IP
    or name re-pins cleanly.
  * The user's `~/.ssh/known_hosts` is untouched.

Changes:

  internal/guest/known_hosts.go (new) — OpenSSH-compatible parser,
    TOFUHostKeyCallback, RemoveKnownHosts. Process-wide mutex
    around the file.
  internal/guest/ssh.go — Dial and WaitForSSH grew a knownHostsPath
    parameter threaded through the callback. Empty path keeps the
    insecure callback (tests + throwaway tools only; documented).
  internal/daemon/{guest_sessions,session_attach,session_lifecycle,
    session_stream}.go — call sites pass d.layout.KnownHostsPath.
  internal/daemon/ssh_client_config.go — the ~/.ssh/config Host *.vm
    block now points at banger's known_hosts and uses
    StrictHostKeyChecking=accept-new. Missing path → fail closed.
  internal/daemon/vm_lifecycle.go — deleteVMLocked drops known_hosts
    entries for the VM's IP and DNS name via removeVMKnownHosts.
  internal/cli/banger.go — sshCommandArgs swaps StrictHostKeyChecking
    no + /dev/null for banger's file + accept-new. Path resolution
    failure falls through to StrictHostKeyChecking=yes.
  internal/paths/paths.go — Layout gains SSHDir + KnownHostsPath;
    Ensure creates SSHDir at 0700.

Tests (internal/guest/known_hosts_test.go): pin on first use, accept
matching key on second dial, reject mismatch, empty path skips
checking, RemoveKnownHosts drops the entry, re-pin works after
remove. Existing daemon + cli tests updated to assert the new
posture and regression-guard against the old flags.

Live verified: vm run writes the pin to banger's known_hosts at 0600
inside a 0700 dir; banger vm ssh + ssh root@<vm>.vm both succeed
using the pin; vm delete clears it.
2026-04-19 16:46:03 -03:00

256 lines
6.2 KiB
Go

package guest
import (
"bufio"
"encoding/base64"
"errors"
"fmt"
"net"
"os"
"strings"
"sync"
"golang.org/x/crypto/ssh"
)
// TOFUHostKeyCallback returns a HostKeyCallback that implements
// trust-on-first-use against a banger-owned known_hosts file.
//
// Semantics:
// - If the file has an entry for `host:port` → require an exact
// key match; a mismatch returns an error (MITM protection).
// - If no entry exists → append one and accept.
//
// The file format is compatible with OpenSSH so shell SSH clients can
// use the same path via `UserKnownHostsFile`.
//
// Callers keep a process-wide mutex on the file so concurrent dials
// to different VMs don't interleave writes.
//
// An empty path disables host-key checking entirely — only for test
// harnesses and tools that dial ad-hoc infrastructure; production
// paths must supply a real file.
func TOFUHostKeyCallback(path string) ssh.HostKeyCallback {
if strings.TrimSpace(path) == "" {
return ssh.InsecureIgnoreHostKey()
}
return func(hostname string, remote net.Addr, key ssh.PublicKey) error {
host := hostLookupKey(hostname, remote)
knownHostsMu.Lock()
defer knownHostsMu.Unlock()
entries, err := loadKnownHosts(path)
if err != nil {
return fmt.Errorf("read known_hosts: %w", err)
}
stored, matched := entries.match(host, key.Type())
if matched {
if keysEqual(stored.key, key) {
return nil
}
return fmt.Errorf("banger: host key for %s does not match pinned entry — "+
"possible MITM. If the VM was legitimately rebuilt, remove the old "+
"entry from %s and retry.", host, path)
}
if err := appendKnownHost(path, host, key); err != nil {
return fmt.Errorf("pin host key for %s: %w", host, err)
}
return nil
}
}
// RemoveKnownHosts strips every entry matching any host in `hosts`
// from the known_hosts file. Called on VM delete so a future VM
// reusing the same IP or name never trips the TOFU mismatch branch.
// Missing file / missing hosts = no-op.
func RemoveKnownHosts(path string, hosts ...string) error {
if strings.TrimSpace(path) == "" || len(hosts) == 0 {
return nil
}
knownHostsMu.Lock()
defer knownHostsMu.Unlock()
entries, err := loadKnownHosts(path)
if err != nil {
return err
}
drop := make(map[string]struct{}, len(hosts))
for _, h := range hosts {
h = strings.TrimSpace(h)
if h == "" {
continue
}
drop[h] = struct{}{}
}
if len(drop) == 0 {
return nil
}
filtered := entries.filter(func(e knownHostEntry) bool {
for _, h := range e.hosts {
if _, skip := drop[h]; skip {
return false
}
}
return true
})
return filtered.write(path)
}
var knownHostsMu sync.Mutex
// knownHostEntry is one line in known_hosts: a set of host patterns
// (comma-separated in the file), a key type, and a key blob.
type knownHostEntry struct {
hosts []string
keyType string
key ssh.PublicKey
raw string
}
type knownHostList []knownHostEntry
func (l knownHostList) match(host, keyType string) (knownHostEntry, bool) {
for _, e := range l {
if e.keyType != keyType {
continue
}
for _, h := range e.hosts {
if h == host {
return e, true
}
}
}
return knownHostEntry{}, false
}
func (l knownHostList) filter(keep func(knownHostEntry) bool) knownHostList {
out := make(knownHostList, 0, len(l))
for _, e := range l {
if keep(e) {
out = append(out, e)
}
}
return out
}
func (l knownHostList) write(path string) error {
if len(l) == 0 {
// If everything got filtered, truncate the file rather than
// removing it — callers may want the file to keep existing
// (with 0600 perms) for later appends.
return os.WriteFile(path, nil, 0o600)
}
var buf strings.Builder
for _, e := range l {
buf.WriteString(e.raw)
if !strings.HasSuffix(e.raw, "\n") {
buf.WriteByte('\n')
}
}
return os.WriteFile(path, []byte(buf.String()), 0o600)
}
func loadKnownHosts(path string) (knownHostList, error) {
f, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
defer f.Close()
var out knownHostList
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
fields := strings.Fields(trimmed)
if len(fields) < 3 {
continue
}
keyBytes, err := base64.StdEncoding.DecodeString(fields[2])
if err != nil {
continue
}
key, err := ssh.ParsePublicKey(keyBytes)
if err != nil {
continue
}
out = append(out, knownHostEntry{
hosts: strings.Split(fields[0], ","),
keyType: fields[1],
key: key,
raw: line,
})
}
if err := scanner.Err(); err != nil {
return nil, err
}
return out, nil
}
func appendKnownHost(path, host string, key ssh.PublicKey) error {
line := fmt.Sprintf("%s %s %s\n",
host,
key.Type(),
base64.StdEncoding.EncodeToString(key.Marshal()),
)
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return err
}
defer f.Close()
_, err = f.WriteString(line)
return err
}
// hostLookupKey returns the canonical key under which we store host
// entries. For a TCP dial the SSH library hands us hostname of the
// form "host:port"; we normalise to "host" so pinning by IP also
// works for a hostname-based lookup that resolves to the same IP.
//
// If hostname contains a port, strip it. If it's empty, fall back to
// the remote address.
func hostLookupKey(hostname string, remote net.Addr) string {
if h, _, err := net.SplitHostPort(hostname); err == nil {
hostname = h
}
if strings.TrimSpace(hostname) != "" {
return hostname
}
if remote != nil {
if h, _, err := net.SplitHostPort(remote.String()); err == nil {
return h
}
return remote.String()
}
return ""
}
func keysEqual(a, b ssh.PublicKey) bool {
if a == nil || b == nil {
return a == nil && b == nil
}
ba := a.Marshal()
bb := b.Marshal()
if len(ba) != len(bb) {
return false
}
for i := range ba {
if ba[i] != bb[i] {
return false
}
}
return true
}
// errHostKeyMismatch sentinel is currently unused but reserved for
// callers that want to distinguish MITM from other failures.
var errHostKeyMismatch = errors.New("host key mismatch")
var _ = errHostKeyMismatch