208 lines
5.0 KiB
Go
208 lines
5.0 KiB
Go
package lib
|
|
|
|
import (
|
|
"bytes"
|
|
"compress/flate"
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/fips140"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"time"
|
|
)
|
|
|
|
var masterKey []byte
|
|
|
|
// CryptoInit loads the master key from file
|
|
func CryptoInit(keyPath string) error {
|
|
var err error
|
|
masterKey, err = os.ReadFile(keyPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(masterKey) != 32 {
|
|
return fmt.Errorf("master key must be 32 bytes, got %d", len(masterKey))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CryptoFIPSEnabled returns true if FIPS 140-3 mode is active
|
|
func CryptoFIPSEnabled() bool {
|
|
return fips140.Enabled()
|
|
}
|
|
|
|
// CryptoStatus returns a string describing crypto status
|
|
func CryptoStatus() string {
|
|
if fips140.Enabled() {
|
|
return "FIPS 140-3 ENABLED"
|
|
}
|
|
return "FIPS 140-3 DISABLED (set GODEBUG=fips140=on)"
|
|
}
|
|
|
|
// deriveNonce derives a deterministic nonce from data using AES
|
|
func deriveNonce(data []byte, nonceSize int) []byte {
|
|
block, _ := aes.NewCipher(masterKey)
|
|
nonce := make([]byte, nonceSize)
|
|
for i := 0; i < len(data); i += 16 {
|
|
chunk := make([]byte, 16)
|
|
end := i + 16
|
|
if end > len(data) {
|
|
end = len(data)
|
|
}
|
|
copy(chunk, data[i:end])
|
|
encrypted := make([]byte, 16)
|
|
block.Encrypt(encrypted, chunk)
|
|
for j := 0; j < nonceSize && j < 16; j++ {
|
|
nonce[j] ^= encrypted[j]
|
|
}
|
|
}
|
|
return nonce
|
|
}
|
|
|
|
// CryptoEncryptBytes encrypts binary data with AES-GCM (deterministic nonce)
|
|
func CryptoEncryptBytes(plaintext []byte) []byte {
|
|
if len(plaintext) == 0 {
|
|
return nil
|
|
}
|
|
block, _ := aes.NewCipher(masterKey)
|
|
gcm, _ := cipher.NewGCM(block)
|
|
nonce := deriveNonce(plaintext, gcm.NonceSize())
|
|
ciphertext := gcm.Seal(nil, nonce, plaintext, nil)
|
|
return append(nonce, ciphertext...)
|
|
}
|
|
|
|
// CryptoDecryptBytes decrypts binary data with AES-GCM
|
|
func CryptoDecryptBytes(ciphertext []byte) ([]byte, error) {
|
|
if len(ciphertext) == 0 {
|
|
return nil, nil
|
|
}
|
|
block, err := aes.NewCipher(masterKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
gcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(ciphertext) < gcm.NonceSize() {
|
|
return nil, fmt.Errorf("ciphertext too short")
|
|
}
|
|
nonce := ciphertext[:gcm.NonceSize()]
|
|
return gcm.Open(nil, nonce, ciphertext[gcm.NonceSize():], nil)
|
|
}
|
|
|
|
// CryptoEncrypt encrypts a string, returns base64
|
|
func CryptoEncrypt(plaintext string) string {
|
|
if plaintext == "" {
|
|
return ""
|
|
}
|
|
encrypted := CryptoEncryptBytes([]byte(plaintext))
|
|
return base64.StdEncoding.EncodeToString(encrypted)
|
|
}
|
|
|
|
// CryptoDecrypt decrypts a base64 string
|
|
func CryptoDecrypt(ciphertext string) string {
|
|
if ciphertext == "" {
|
|
return ""
|
|
}
|
|
data, err := base64.StdEncoding.DecodeString(ciphertext)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
result, err := CryptoDecryptBytes(data)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return string(result)
|
|
}
|
|
|
|
// cryptoEncryptSIV - internal, used by DB wrappers
|
|
func cryptoEncryptSIV(plaintext string) string {
|
|
return CryptoEncrypt(plaintext)
|
|
}
|
|
|
|
// cryptoDecryptSIV - internal, used by DB wrappers
|
|
func cryptoDecryptSIV(ciphertext string) string {
|
|
return CryptoDecrypt(ciphertext)
|
|
}
|
|
|
|
// Pack compresses and encrypts data. Used for all persistent storage (DB fields, files).
|
|
// compress (deflate) → encrypt (AES-256-GCM, deterministic nonce)
|
|
// Empty input returns nil. Output is always deterministic for the same input.
|
|
func Pack(data []byte) []byte {
|
|
if len(data) == 0 {
|
|
return nil
|
|
}
|
|
var buf bytes.Buffer
|
|
w, _ := flate.NewWriter(&buf, flate.DefaultCompression)
|
|
w.Write(data)
|
|
w.Close()
|
|
return CryptoEncryptBytes(buf.Bytes())
|
|
}
|
|
|
|
// Unpack decrypts and decompresses data. Inverse of Pack.
|
|
// decrypt (AES-256-GCM) → decompress (inflate)
|
|
// Empty or nil input returns nil. Invalid data returns nil.
|
|
func Unpack(data []byte) []byte {
|
|
if len(data) == 0 {
|
|
return nil
|
|
}
|
|
decrypted, err := CryptoDecryptBytes(data)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
r := flate.NewReader(bytes.NewReader(decrypted))
|
|
result, err := io.ReadAll(r)
|
|
r.Close()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return result
|
|
}
|
|
|
|
// Token holds the authenticated dossier and expiration
|
|
type Token struct {
|
|
DossierID string `json:"d"`
|
|
Exp int64 `json:"exp"`
|
|
}
|
|
|
|
// TokenCreate creates an encrypted token for a dossier
|
|
// duration is how long until expiration (e.g., 4*time.Hour)
|
|
func TokenCreate(dossierID string, duration time.Duration) string {
|
|
t := Token{
|
|
DossierID: dossierID,
|
|
Exp: time.Now().Unix() + int64(duration.Seconds()),
|
|
}
|
|
data, _ := json.Marshal(t)
|
|
encrypted := CryptoEncryptBytes(data)
|
|
return base64.URLEncoding.EncodeToString(encrypted)
|
|
}
|
|
|
|
// TokenParse decrypts and validates a token
|
|
// Returns the token if valid, or error if expired/invalid
|
|
func TokenParse(tokenStr string) (*Token, error) {
|
|
data, err := base64.URLEncoding.DecodeString(tokenStr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid token encoding")
|
|
}
|
|
|
|
decrypted, err := CryptoDecryptBytes(data)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid token")
|
|
}
|
|
|
|
var t Token
|
|
if err := json.Unmarshal(decrypted, &t); err != nil {
|
|
return nil, fmt.Errorf("invalid token format")
|
|
}
|
|
|
|
if time.Now().Unix() > t.Exp {
|
|
return nil, fmt.Errorf("token expired")
|
|
}
|
|
|
|
return &t, nil
|
|
}
|