package actuator import ( "bytes" "context" "fmt" "log/slog" "net" "sync" "time" "golang.org/x/crypto/ssh" ) var ( hostKeyMu sync.RWMutex hostKeyCache map[string]ssh.PublicKey hostKeyOnce sync.Once hostKeySrc HostKeySource ) // HostKeySource provides storage for SSH host public keys. type HostKeySource interface { GetHostKey(ctx context.Context, hostname string) (string, error) SetHostKey(ctx context.Context, hostname string, key string) error } // SetHostKeySource sets the host key source. Must be called before // any SSH connections. A nil source enables TOFU-only mode (keys // accepted in memory but not persisted). func SetHostKeySource(src HostKeySource) { hostKeyMu.Lock() defer hostKeyMu.Unlock() hostKeySrc = src } // HostKeyCallback returns an ssh.HostKeyCallback that verifies host keys. // Known keys are verified (MITM detection). Unknown keys are accepted // via TOFU and optionally persisted to the source. func HostKeyCallback() ssh.HostKeyCallback { return hostKeyVerify } func hostKeyVerify(hostname string, remote net.Addr, key ssh.PublicKey) error { hostKeyOnce.Do(func() { hostKeyCache = make(map[string]ssh.PublicKey) }) normalized := hostWithoutPort(hostname) hostKeyMu.RLock() known, exists := hostKeyCache[normalized] hostKeyMu.RUnlock() if exists { if bytes.Equal(key.Marshal(), known.Marshal()) { return nil } return fmt.Errorf("SSH HOST KEY CHANGED for %s (possible MITM)", normalized) } hostKeyMu.Lock() hostKeyCache[normalized] = key hostKeyMu.Unlock() slog.Info("ssh: accepting new host key (TOFU)", "host", normalized) if hostKeySrc != nil { go persistHostKey(normalized, key) } return nil } func persistHostKey(hostname string, key ssh.PublicKey) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() keyBase64 := key.Type() + " " + string(key.Marshal()) if err := hostKeySrc.SetHostKey(ctx, "ssh/host-keys/"+hostname, keyBase64); err != nil { slog.Warn("ssh: failed to persist host key", "host", hostname, "error", err) } } // LoadHostKeys pre-loads known host keys from the source into the // in-memory cache. Call at startup to avoid TOFU on first connection. // The source should return key lines in the format "key-type base64-data". func LoadHostKeys(ctx context.Context, hostnames []string, src HostKeySource) { if src == nil { return } SetHostKeySource(src) hostKeyMu.Lock() defer hostKeyMu.Unlock() if hostKeyCache == nil { hostKeyCache = make(map[string]ssh.PublicKey) } loaded := 0 for _, hostname := range hostnames { keyData, err := src.GetHostKey(ctx, "ssh/host-keys/"+hostname) if err != nil { slog.Debug("ssh: no stored key for host", "host", hostname, "error", err) continue } pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(keyData)) if err != nil { slog.Warn("ssh: invalid stored key for host", "host", hostname, "error", err) continue } hostKeyCache[hostname] = pubKey loaded++ } if loaded > 0 { slog.Info("ssh: loaded host keys from Infisical", "count", loaded) } } func hostWithoutPort(hostname string) string { for i := len(hostname) - 1; i >= 0; i-- { if hostname[i] == ':' { return hostname[:i] } } return hostname }