orama/pkg/secrets/encrypt.go
anonpenguin23 fd87eec476 feat(security): add manifest signing, TLS TOFU, refresh token migration
- Invalidate plaintext refresh tokens (migration 019)
- Add `--sign` flag to `orama build` for rootwallet manifest signing
- Add `--ca-fingerprint` TOFU verification for production joins/invites
- Save cluster secrets from join (RQLite auth, Olric key, IPFS peers)
- Add RQLite auth config fields
2026-02-28 15:40:43 +02:00

99 lines
3.0 KiB
Go

// Package secrets provides application-level encryption for sensitive data stored in RQLite.
// Uses AES-256-GCM with HKDF key derivation from the cluster secret.
package secrets
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
"io"
"strings"
"golang.org/x/crypto/hkdf"
)
// Prefix for encrypted values to distinguish from plaintext during migration.
const encryptedPrefix = "enc:"
// DeriveKey derives a 32-byte AES-256 key from the cluster secret using HKDF-SHA256.
// The purpose string provides domain separation (e.g., "turn-encryption").
func DeriveKey(clusterSecret, purpose string) ([]byte, error) {
if clusterSecret == "" {
return nil, fmt.Errorf("cluster secret is empty")
}
reader := hkdf.New(sha256.New, []byte(clusterSecret), nil, []byte(purpose))
key := make([]byte, 32)
if _, err := io.ReadFull(reader, key); err != nil {
return nil, fmt.Errorf("HKDF key derivation failed: %w", err)
}
return key, nil
}
// Encrypt encrypts plaintext with AES-256-GCM using the given key.
// Returns a base64-encoded string prefixed with "enc:" for identification.
func Encrypt(plaintext string, key []byte) (string, error) {
block, err := aes.NewCipher(key)
if err != nil {
return "", fmt.Errorf("failed to create cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", fmt.Errorf("failed to create GCM: %w", err)
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", fmt.Errorf("failed to generate nonce: %w", err)
}
// nonce is prepended to ciphertext
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return encryptedPrefix + base64.StdEncoding.EncodeToString(ciphertext), nil
}
// Decrypt decrypts an "enc:"-prefixed ciphertext string with AES-256-GCM.
// If the input is not prefixed with "enc:", it is returned as-is (plaintext passthrough
// for backward compatibility during migration).
func Decrypt(ciphertext string, key []byte) (string, error) {
if !strings.HasPrefix(ciphertext, encryptedPrefix) {
return ciphertext, nil // plaintext passthrough
}
data, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(ciphertext, encryptedPrefix))
if err != nil {
return "", fmt.Errorf("failed to decode ciphertext: %w", err)
}
block, err := aes.NewCipher(key)
if err != nil {
return "", fmt.Errorf("failed to create cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", fmt.Errorf("failed to create GCM: %w", err)
}
nonceSize := gcm.NonceSize()
if len(data) < nonceSize {
return "", fmt.Errorf("ciphertext too short")
}
nonce, sealed := data[:nonceSize], data[nonceSize:]
plaintext, err := gcm.Open(nil, nonce, sealed, nil)
if err != nil {
return "", fmt.Errorf("decryption failed (wrong key or corrupted data): %w", err)
}
return string(plaintext), nil
}
// IsEncrypted returns true if the value has the "enc:" prefix.
func IsEncrypted(value string) bool {
return strings.HasPrefix(value, encryptedPrefix)
}