package lib import ( "crypto/aes" "crypto/cipher" "crypto/hmac" "crypto/rand" "crypto/sha256" "errors" "io" "github.com/klauspost/compress/zstd" "golang.org/x/crypto/hkdf" ) var ( ErrDecryptionFailed = errors.New("decryption failed") ErrInvalidCiphertext = errors.New("invalid ciphertext") ) // DeriveProjectKey derives a per-project AES-256 key from the master key using HKDF-SHA256. func DeriveProjectKey(masterKey []byte, projectID string) ([]byte, error) { info := []byte("dealspace-project-" + projectID) reader := hkdf.New(sha256.New, masterKey, nil, info) key := make([]byte, 32) // AES-256 if _, err := io.ReadFull(reader, key); err != nil { return nil, err } return key, nil } // DeriveHMACKey derives a separate HMAC key for blind indexes. func DeriveHMACKey(masterKey []byte, projectID string) ([]byte, error) { info := []byte("dealspace-hmac-" + projectID) reader := hkdf.New(sha256.New, masterKey, nil, info) key := make([]byte, 32) if _, err := io.ReadFull(reader, key); err != nil { return nil, err } return key, nil } // BlindIndex computes an HMAC-SHA256 blind index for searchable encrypted fields. // Returns truncated hash for storage efficiency while maintaining collision resistance. func BlindIndex(hmacKey []byte, plaintext string) []byte { h := hmac.New(sha256.New, hmacKey) h.Write([]byte(plaintext)) return h.Sum(nil) // full 32 bytes } // Pack compresses with zstd then encrypts with AES-256-GCM (random nonce). func Pack(key []byte, plaintext string) ([]byte, error) { compressed, err := zstdCompress([]byte(plaintext)) if err != nil { return nil, err } block, err := aes.NewCipher(key) if err != nil { return nil, err } gcm, err := cipher.NewGCM(block) if err != nil { return nil, err } nonce := make([]byte, gcm.NonceSize()) if _, err := io.ReadFull(rand.Reader, nonce); err != nil { return nil, err } return gcm.Seal(nonce, nonce, compressed, nil), nil } // Unpack decrypts AES-256-GCM then decompresses zstd. func Unpack(key []byte, ciphertext []byte) (string, error) { if len(ciphertext) == 0 { return "", nil } block, err := aes.NewCipher(key) if err != nil { return "", err } gcm, err := cipher.NewGCM(block) if err != nil { return "", err } nonceSize := gcm.NonceSize() if len(ciphertext) < nonceSize { return "", ErrInvalidCiphertext } nonce, ct := ciphertext[:nonceSize], ciphertext[nonceSize:] compressed, err := gcm.Open(nil, nonce, ct, nil) if err != nil { return "", ErrDecryptionFailed } decompressed, err := zstdDecompress(compressed) if err != nil { return "", err } return string(decompressed), nil } // ObjectEncrypt encrypts file data with AES-256-GCM for object store. func ObjectEncrypt(key []byte, data []byte) ([]byte, error) { block, err := aes.NewCipher(key) if err != nil { return nil, err } gcm, err := cipher.NewGCM(block) if err != nil { return nil, err } nonce := make([]byte, gcm.NonceSize()) if _, err := io.ReadFull(rand.Reader, nonce); err != nil { return nil, err } return gcm.Seal(nonce, nonce, data, nil), nil } // ObjectDecrypt decrypts file data from the object store. func ObjectDecrypt(key []byte, ciphertext []byte) ([]byte, error) { if len(ciphertext) == 0 { return nil, ErrInvalidCiphertext } block, err := aes.NewCipher(key) if err != nil { return nil, err } gcm, err := cipher.NewGCM(block) if err != nil { return nil, err } nonceSize := gcm.NonceSize() if len(ciphertext) < nonceSize { return nil, ErrInvalidCiphertext } nonce, ct := ciphertext[:nonceSize], ciphertext[nonceSize:] return gcm.Open(nil, nonce, ct, nil) } // ContentHash computes SHA-256 of data for content-addressable storage. func ContentHash(data []byte) []byte { h := sha256.Sum256(data) return h[:] } // zstd encoder/decoder (reusable, goroutine-safe) var ( zstdEncoder, _ = zstd.NewWriter(nil, zstd.WithEncoderLevel(zstd.SpeedDefault)) zstdDecoder, _ = zstd.NewReader(nil) ) func zstdCompress(data []byte) ([]byte, error) { return zstdEncoder.EncodeAll(data, nil), nil } func zstdDecompress(data []byte) ([]byte, error) { return zstdDecoder.DecodeAll(data, nil) }