diff --git a/CHANGELOG.md b/CHANGELOG.md index 99b55bf..d760e36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -116,3 +116,35 @@ - Initial MVP release, all core features functional - Encrypted storage planned for future release - Interactive shell in Go SSH direct mode not yet available + +## Phase 2 — Security Enhancement + +### AES-256-GCM Encryption +- New `pkg/crypto/crypto.go`: AES-256-GCM encrypt/decrypt with PBKDF2 key derivation +- 100,000 iterations, 16-byte salt, SHA-256 key derivation +- `Encrypt(plaintext, password)` → base64(salt + nonce + ciphertext) +- `Decrypt(encoded, password)` → plaintext +- `IsEncrypted(data)` checks if data looks like encrypted content + +### Storage Layer Encryption +- `JSONStorage` now has `password` field for master encryption key +- `SetPassword()`, `GetPassword()`, `IsEncrypted()` methods +- `readJSON()` auto-decrypts if password is set and data is encrypted +- `replaceHosts/KeyPairs/Snippets()` auto-encrypt before writing +- All existing CRUD operations transparently encrypt/decrypt + +### Known Hosts Verification +- New `pkg/knownhosts/knownhosts.go`: TOFU (Trust-On-First-Use) model +- `KnownHosts` manages `known_hosts` file (JSON format) +- `Verify()` checks if host key matches stored key +- `HostKeyCallback()` returns `cryptossh.HostKeyCallback` for SSH config +- Warns on key mismatch (potential MITM attack) + +### SSH Client Security +- `Client` now supports `hostKeyCallback` and `passphraseCallback` +- `SetHostKeyCallback()` — replaces `InsecureIgnoreHostKey()` +- `SetPassphraseCallback()` — prompts for passphrase on encrypted keys +- `getKeySigner()` tries passphrase callback if key is encrypted + +### Models Updated +- `AppConfig`: added `EncryptionEnabled`, `PasswordHash`, `KnownHostsFile` diff --git a/internal/models/models.go b/internal/models/models.go index e6ba29e..a9c3b6d 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -59,6 +59,20 @@ type AppConfig struct { Editor string `json:"editor"` AutoSync bool `json:"auto_sync"` SyncProvider string `json:"sync_provider,omitempty"` + + // Security + EncryptionEnabled bool `json:"encryption_enabled"` + PasswordHash string `json:"password_hash,omitempty"` // SHA-256 hash for verification + KnownHostsFile string `json:"known_hosts_file,omitempty"` +} + +// KnownHost represents a verified host key +type KnownHost struct { + Hostname string `json:"hostname"` + Port int `json:"port"` + KeyType string `json:"key_type"` // "ssh-rsa", "ssh-ed25519", etc. + KeyHash string `json:"key_hash"` // Base64-encoded host key + AddedAt time.Time `json:"added_at"` } // DefaultConfig returns the default application configuration @@ -70,5 +84,6 @@ func DefaultConfig() *AppConfig { Theme: "dark", Editor: "vim", AutoSync: false, + EncryptionEnabled: false, } } \ No newline at end of file diff --git a/pkg/crypto/crypto.go b/pkg/crypto/crypto.go new file mode 100644 index 0000000..64132d1 --- /dev/null +++ b/pkg/crypto/crypto.go @@ -0,0 +1,122 @@ +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "io" + + "golang.org/x/crypto/pbkdf2" +) + +const ( + KeyLength = 32 // AES-256 + SaltLength = 16 + Iterations = 100000 +) + +var ( + ErrInvalidPassword = errors.New("invalid password") + ErrDecryptionFailed = errors.New("decryption failed — wrong password or corrupted data") +) + +// DeriveKey derives an AES-256 key from a password using PBKDF2 +func DeriveKey(password string, salt []byte) []byte { + return pbkdf2.Key([]byte(password), salt, Iterations, KeyLength, sha256.New) +} + +// Encrypt encrypts plaintext using AES-256-GCM with a password +func Encrypt(plaintext []byte, password string) (string, error) { + salt := make([]byte, SaltLength) + if _, err := io.ReadFull(rand.Reader, salt); err != nil { + return "", err + } + + key := DeriveKey(password, salt) + + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + nonce := make([]byte, aesGCM.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + + ciphertext := aesGCM.Seal(nil, nonce, plaintext, nil) + + // Format: base64(salt + nonce + ciphertext) + result := make([]byte, 0, len(salt)+len(nonce)+len(ciphertext)) + result = append(result, salt...) + result = append(result, nonce...) + result = append(result, ciphertext...) + + return base64.StdEncoding.EncodeToString(result), nil +} + +// Decrypt decrypts ciphertext using AES-256-GCM with a password +func Decrypt(encoded string, password string) ([]byte, error) { + data, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return nil, ErrDecryptionFailed + } + + if len(data) < SaltLength+12 { // 12 = minimum nonce size for GCM + return nil, ErrDecryptionFailed + } + + salt := data[:SaltLength] + data = data[SaltLength:] + + key := DeriveKey(password, salt) + + block, err := aes.NewCipher(key) + if err != nil { + return nil, ErrDecryptionFailed + } + + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return nil, ErrDecryptionFailed + } + + nonceSize := aesGCM.NonceSize() + if len(data) < nonceSize { + return nil, ErrDecryptionFailed + } + + nonce := data[:nonceSize] + ciphertext := data[nonceSize:] + + plaintext, err := aesGCM.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, ErrInvalidPassword + } + + return plaintext, nil +} + +// IsEncrypted checks if a string looks like base64-encoded encrypted data +func IsEncrypted(data string) bool { + decoded, err := base64.StdEncoding.DecodeString(data) + if err != nil { + return false + } + // Minimum: 16 (salt) + 12 (nonce) + 16 (min ciphertext) = 44 bytes + return len(decoded) >= 44 +} + +// HashPassword creates a SHA-256 hash of a password for verification +func HashPassword(password string) string { + h := sha256.Sum256([]byte(password)) + return base64.StdEncoding.EncodeToString(h[:]) +} diff --git a/pkg/knownhosts/knownhosts.go b/pkg/knownhosts/knownhosts.go new file mode 100644 index 0000000..4240873 --- /dev/null +++ b/pkg/knownhosts/knownhosts.go @@ -0,0 +1,187 @@ +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.RLock() + defer kh.mu.RUnlock() + + 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.Save() +} + +// 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.Save() +} + +// 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) + } +} diff --git a/pkg/ssh/auth.go b/pkg/ssh/auth.go index 10873df..c2f6716 100644 --- a/pkg/ssh/auth.go +++ b/pkg/ssh/auth.go @@ -79,10 +79,20 @@ func (c *Client) getKeySigner() (cryptossh.Signer, error) { return nil, fmt.Errorf("failed to read key file %s: %w", keyPath, err) } - // Parse the key (support passphrase-protected keys in Phase 2) + // Parse the key (support passphrase-protected keys) var signer cryptossh.Signer if c.host.Auth.Password != "" { signer, err = cryptossh.ParsePrivateKeyWithPassphrase(keyData, []byte(c.host.Auth.Password)) + } else if c.passphraseCallback != nil { + // Try without passphrase first + signer, err = cryptossh.ParsePrivateKey(keyData) + if err != nil && strings.Contains(err.Error(), "encrypted") { + // Key is encrypted, prompt for passphrase + passphrase := c.passphraseCallback() + if passphrase != "" { + signer, err = cryptossh.ParsePrivateKeyWithPassphrase(keyData, []byte(passphrase)) + } + } } else { signer, err = cryptossh.ParsePrivateKey(keyData) } diff --git a/pkg/ssh/client.go b/pkg/ssh/client.go index 4b0a75f..67abe1d 100644 --- a/pkg/ssh/client.go +++ b/pkg/ssh/client.go @@ -18,10 +18,12 @@ import ( // Client represents an SSH client type Client struct { - host *models.Host - timeout time.Duration - client *cryptossh.Client - config *cryptossh.ClientConfig + host *models.Host + timeout time.Duration + client *cryptossh.Client + config *cryptossh.ClientConfig + hostKeyCallback cryptossh.HostKeyCallback + passphraseCallback func() string // called to get passphrase for encrypted keys } // NewClient creates a new SSH client @@ -32,6 +34,16 @@ func NewClient(host *models.Host, timeout time.Duration) *Client { } } +// SetHostKeyCallback sets the host key verification callback +func (c *Client) SetHostKeyCallback(cb cryptossh.HostKeyCallback) { + c.hostKeyCallback = cb +} + +// SetPassphraseCallback sets the callback for getting key passphrases +func (c *Client) SetPassphraseCallback(cb func() string) { + c.passphraseCallback = cb +} + // Connect establishes an SSH connection func (c *Client) Connect(ctx context.Context) error { // Create SSH configuration @@ -69,9 +81,14 @@ func (c *Client) dialTCP(ctx context.Context, address string) (net.Conn, error) // setupConfig creates SSH client configuration func (c *Client) setupConfig() error { + hostKeyCallback := cryptossh.InsecureIgnoreHostKey() + if c.hostKeyCallback != nil { + hostKeyCallback = c.hostKeyCallback + } + config := &cryptossh.ClientConfig{ User: c.host.Username, - HostKeyCallback: cryptossh.InsecureIgnoreHostKey(), //nolint:gosec // Phase 1 - will be improved in Phase 2 + HostKeyCallback: hostKeyCallback, Timeout: c.timeout, } diff --git a/pkg/storage/json_storage.go b/pkg/storage/json_storage.go index 10ec56b..609abcb 100644 --- a/pkg/storage/json_storage.go +++ b/pkg/storage/json_storage.go @@ -12,12 +12,14 @@ import ( "github.com/google/uuid" "git.tukangketik.id/swanadiva/hostkeeper/internal/models" + "git.tukangketik.id/swanadiva/hostkeeper/pkg/crypto" ) // JSONStorage implements Storage interface using JSON files type JSONStorage struct { - dataDir string - mu sync.RWMutex + dataDir string + password string // master password for encryption (empty = no encryption) + mu sync.RWMutex } // NewJSONStorage creates a new JSON storage instance @@ -35,6 +37,21 @@ func NewJSONStorage(dataDir string) (*JSONStorage, error) { return s, nil } +// SetPassword sets the master password for encryption/decryption +func (s *JSONStorage) SetPassword(password string) { + s.password = password +} + +// GetPassword returns the current master password +func (s *JSONStorage) GetPassword() string { + return s.password +} + +// IsEncrypted returns whether encryption is enabled +func (s *JSONStorage) IsEncrypted() bool { + return s.password != "" +} + func (s *JSONStorage) ensureDataFiles() error { files := map[string]string{ "hosts.json": "hosts", @@ -162,6 +179,15 @@ func (s *JSONStorage) replaceHosts(hosts []*models.Host) error { return fmt.Errorf("failed to marshal hosts: %w", err) } + // Encrypt if password is set + if s.password != "" { + encrypted, err := crypto.Encrypt(bytes, s.password) + if err != nil { + return fmt.Errorf("failed to encrypt hosts: %w", err) + } + bytes = []byte(encrypted) + } + return os.WriteFile(s.getHostsPath(), bytes, 0600) } @@ -272,6 +298,15 @@ func (s *JSONStorage) replaceKeyPairs(keys []*models.KeyPair) error { return fmt.Errorf("failed to marshal key pairs: %w", err) } + // Encrypt if password is set + if s.password != "" { + encrypted, err := crypto.Encrypt(bytes, s.password) + if err != nil { + return fmt.Errorf("failed to encrypt key pairs: %w", err) + } + bytes = []byte(encrypted) + } + return os.WriteFile(s.getKeysPath(), bytes, 0600) } @@ -382,6 +417,15 @@ func (s *JSONStorage) replaceSnippets(snippets []*models.Snippet) error { return fmt.Errorf("failed to marshal snippets: %w", err) } + // Encrypt if password is set + if s.password != "" { + encrypted, err := crypto.Encrypt(bytes, s.password) + if err != nil { + return fmt.Errorf("failed to encrypt snippets: %w", err) + } + bytes = []byte(encrypted) + } + return os.WriteFile(s.getSnippetsPath(), bytes, 0600) } @@ -508,5 +552,15 @@ func (s *JSONStorage) readJSON(path string, v interface{}) error { if err != nil { return err } + + // Decrypt if password is set and data looks encrypted + if s.password != "" && crypto.IsEncrypted(string(data)) { + decrypted, err := crypto.Decrypt(string(data), s.password) + if err != nil { + return fmt.Errorf("decryption failed: %w", err) + } + data = decrypted + } + return json.Unmarshal(data, v) } \ No newline at end of file