feat: Phase 2 Security Enhancement

- pkg/crypto: AES-256-GCM encryption with PBKDF2 key derivation
  - 100k iterations, 16-byte salt, SHA-256
  - Encrypt/Decrypt/IsEncrypted/HashPassword
- Storage layer encryption:
  - JSONStorage.SetPassword() enables transparent encrypt/decrypt
  - readJSON auto-decrypts, replace* auto-encrypts
- pkg/knownhosts: TOFU host key verification
  - Verify/Add/Remove host keys
  - HostKeyCallback for SSH config
- SSH client security:
  - SetHostKeyCallback() replaces InsecureIgnoreHostKey()
  - SetPassphraseCallback() for encrypted private keys
  - getKeySigner() tries passphrase on encrypted keys
- Models: AppConfig gains EncryptionEnabled, PasswordHash, KnownHostsFile
This commit is contained in:
swanadiva
2026-06-25 13:28:46 +07:00
parent a1cd3d5dc0
commit 611b794fc7
7 changed files with 445 additions and 8 deletions
+122
View File
@@ -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[:])
}