cdf21682d6
AES-256-GCM encryption for credential storage, YAML seed config parser with validation, and transactional import into SQLite. Credentials (registry tokens, NPM password) encrypted before storage.
97 lines
2.5 KiB
Go
97 lines
2.5 KiB
Go
package crypto
|
|
|
|
import (
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
)
|
|
|
|
// ErrNoKey is returned when ENCRYPTION_KEY is not set.
|
|
var ErrNoKey = errors.New("ENCRYPTION_KEY environment variable is not set")
|
|
|
|
// DeriveKey computes a 32-byte AES-256 key from the given passphrase using SHA-256.
|
|
// This is acceptable when ENCRYPTION_KEY is a high-entropy random string (e.g., 32+ hex chars).
|
|
// For human-chosen passphrases, consider Argon2id or PBKDF2 with a salt instead.
|
|
func DeriveKey(passphrase string) [32]byte {
|
|
return sha256.Sum256([]byte(passphrase))
|
|
}
|
|
|
|
// KeyFromEnv reads ENCRYPTION_KEY from the environment and derives a 32-byte key.
|
|
func KeyFromEnv() ([32]byte, error) {
|
|
raw := os.Getenv("ENCRYPTION_KEY")
|
|
if raw == "" {
|
|
return [32]byte{}, ErrNoKey
|
|
}
|
|
return DeriveKey(raw), nil
|
|
}
|
|
|
|
// Encrypt encrypts plaintext using AES-256-GCM with a random nonce.
|
|
// The returned ciphertext is hex-encoded: nonce || ciphertext+tag.
|
|
func Encrypt(key [32]byte, plaintext string) (string, error) {
|
|
block, err := aes.NewCipher(key[:])
|
|
if err != nil {
|
|
return "", fmt.Errorf("create cipher: %w", err)
|
|
}
|
|
|
|
gcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return "", fmt.Errorf("create gcm: %w", err)
|
|
}
|
|
|
|
nonce := make([]byte, gcm.NonceSize())
|
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
|
return "", fmt.Errorf("generate nonce: %w", err)
|
|
}
|
|
|
|
sealed := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
|
return hex.EncodeToString(sealed), nil
|
|
}
|
|
|
|
// Decrypt decrypts a hex-encoded ciphertext produced by Encrypt.
|
|
func Decrypt(key [32]byte, ciphertextHex string) (string, error) {
|
|
data, err := hex.DecodeString(ciphertextHex)
|
|
if err != nil {
|
|
return "", fmt.Errorf("decode hex: %w", err)
|
|
}
|
|
|
|
block, err := aes.NewCipher(key[:])
|
|
if err != nil {
|
|
return "", fmt.Errorf("create cipher: %w", err)
|
|
}
|
|
|
|
gcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return "", fmt.Errorf("create gcm: %w", err)
|
|
}
|
|
|
|
nonceSize := gcm.NonceSize()
|
|
if len(data) < nonceSize {
|
|
return "", errors.New("ciphertext too short")
|
|
}
|
|
|
|
nonce := data[:nonceSize]
|
|
ciphertext := data[nonceSize:]
|
|
|
|
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("decrypt: %w", err)
|
|
}
|
|
|
|
return string(plaintext), nil
|
|
}
|
|
|
|
// EncryptIfNotEmpty encrypts the value only if it is non-empty.
|
|
// Returns empty string for empty input.
|
|
func EncryptIfNotEmpty(key [32]byte, value string) (string, error) {
|
|
if value == "" {
|
|
return "", nil
|
|
}
|
|
return Encrypt(key, value)
|
|
}
|