refactor: move V1 code into v1/ subdirectory
- git mv cmd/ internal/ pkg/ test/ go.mod go.sum Makefile build.sh docs/ v1/ - Create v1/README.md with V1 documentation - Update root README for V1 + V2 structure - V1 still builds (cd v1 && go build ./cmd/hostkeeper) and 105 tests pass - Root is now clean for V2 development
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
package knownhosts
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
cryptossh "golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// KnownHosts manages the known_hosts file
|
||||
type KnownHosts struct {
|
||||
path string
|
||||
hosts map[string]*HostKey // key = "hostname:port"
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// HostKey represents a stored host key
|
||||
type HostKey struct {
|
||||
Hostname string `json:"hostname"`
|
||||
Port int `json:"port"`
|
||||
KeyType string `json:"key_type"`
|
||||
KeyData string `json:"key_data"` // Base64-encoded raw key
|
||||
AddedAt time.Time `json:"added_at"`
|
||||
}
|
||||
|
||||
// New creates a new KnownHosts manager
|
||||
func New(dataDir string) (*KnownHosts, error) {
|
||||
path := filepath.Join(dataDir, "known_hosts")
|
||||
kh := &KnownHosts{
|
||||
path: path,
|
||||
hosts: make(map[string]*HostKey),
|
||||
}
|
||||
|
||||
if err := kh.load(); err != nil {
|
||||
// File doesn't exist yet, that's OK
|
||||
if !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return kh, nil
|
||||
}
|
||||
|
||||
// Load reads the known_hosts file
|
||||
func (kh *KnownHosts) load() error {
|
||||
kh.mu.Lock()
|
||||
defer kh.mu.Unlock()
|
||||
|
||||
data, err := os.ReadFile(kh.path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var hosts []*HostKey
|
||||
if err := json.Unmarshal(data, &hosts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, h := range hosts {
|
||||
key := fmt.Sprintf("%s:%d", h.Hostname, h.Port)
|
||||
kh.hosts[key] = h
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Save writes the known_hosts file
|
||||
func (kh *KnownHosts) Save() error {
|
||||
kh.mu.Lock()
|
||||
defer kh.mu.Unlock()
|
||||
|
||||
return kh.saveInternal()
|
||||
}
|
||||
|
||||
// saveInternal writes the known_hosts file without locking (caller must hold lock)
|
||||
func (kh *KnownHosts) saveInternal() error {
|
||||
var hosts []*HostKey
|
||||
for _, h := range kh.hosts {
|
||||
hosts = append(hosts, h)
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(hosts, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(kh.path, data, 0600)
|
||||
}
|
||||
|
||||
// Verify checks if a host key is known and matches
|
||||
func (kh *KnownHosts) Verify(hostname string, port int, remoteKey cryptossh.PublicKey) (bool, *HostKey) {
|
||||
kh.mu.RLock()
|
||||
defer kh.mu.RUnlock()
|
||||
|
||||
key := fmt.Sprintf("%s:%d", hostname, port)
|
||||
stored, ok := kh.hosts[key]
|
||||
if !ok {
|
||||
return false, nil // Unknown host
|
||||
}
|
||||
|
||||
// Compare key type and data
|
||||
remoteType := remoteKey.Type()
|
||||
remoteData := base64.StdEncoding.EncodeToString(remoteKey.Marshal())
|
||||
|
||||
if stored.KeyType != remoteType || stored.KeyData != remoteData {
|
||||
return false, stored // Key mismatch — potential MITM
|
||||
}
|
||||
|
||||
return true, stored // Key matches
|
||||
}
|
||||
|
||||
// Add stores a new host key
|
||||
func (kh *KnownHosts) Add(hostname string, port int, remoteKey cryptossh.PublicKey) error {
|
||||
kh.mu.Lock()
|
||||
defer kh.mu.Unlock()
|
||||
|
||||
key := fmt.Sprintf("%s:%d", hostname, port)
|
||||
kh.hosts[key] = &HostKey{
|
||||
Hostname: hostname,
|
||||
Port: port,
|
||||
KeyType: remoteKey.Type(),
|
||||
KeyData: base64.StdEncoding.EncodeToString(remoteKey.Marshal()),
|
||||
AddedAt: time.Now(),
|
||||
}
|
||||
|
||||
return kh.saveInternal()
|
||||
}
|
||||
|
||||
// Remove removes a host key
|
||||
func (kh *KnownHosts) Remove(hostname string, port int) error {
|
||||
kh.mu.Lock()
|
||||
defer kh.mu.Unlock()
|
||||
|
||||
key := fmt.Sprintf("%s:%d", hostname, port)
|
||||
delete(kh.hosts, key)
|
||||
|
||||
return kh.saveInternal()
|
||||
}
|
||||
|
||||
// Get returns the stored host key for a given host
|
||||
func (kh *KnownHosts) Get(hostname string, port int) *HostKey {
|
||||
kh.mu.RLock()
|
||||
defer kh.mu.RUnlock()
|
||||
|
||||
key := fmt.Sprintf("%s:%d", hostname, port)
|
||||
return kh.hosts[key]
|
||||
}
|
||||
|
||||
// HostKeyCallback returns a crypto/ssh HostKeyCallback for use in SSH config
|
||||
func (kh *KnownHosts) HostKeyCallback(autoAdd bool) cryptossh.HostKeyCallback {
|
||||
return func(hostname string, remote net.Addr, remoteKey cryptossh.PublicKey) error {
|
||||
// Extract port from address
|
||||
_, portStr, err := net.SplitHostPort(remote.String())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse address: %w", err)
|
||||
}
|
||||
|
||||
port := 22
|
||||
fmt.Sscanf(portStr, "%d", &port)
|
||||
|
||||
// Check if host is known
|
||||
matches, stored := kh.Verify(hostname, port, remoteKey)
|
||||
if matches {
|
||||
return nil // Key matches, connection OK
|
||||
}
|
||||
|
||||
if stored == nil {
|
||||
// Unknown host — auto-add if enabled
|
||||
if autoAdd {
|
||||
if err := kh.Add(hostname, port, remoteKey); err != nil {
|
||||
return fmt.Errorf("failed to add host key: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("host key not found for %s:%d — run 'hostkeeper trust %s' to add", hostname, port, hostname)
|
||||
}
|
||||
|
||||
// Key mismatch — potential MITM attack
|
||||
return fmt.Errorf("WARNING: host key mismatch for %s:%d!\n"+
|
||||
"Stored key type: %s\n"+
|
||||
"Remote key type: %s\n"+
|
||||
"This could indicate a MITM attack.\n"+
|
||||
"Run 'hostkeeper trust --remove %s' and try again.",
|
||||
hostname, port, stored.KeyType, remoteKey.Type(), hostname)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user