193 lines
4.7 KiB
Go
193 lines
4.7 KiB
Go
package lib
|
|
|
|
import (
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/fips140"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"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)
|
|
}
|
|
|
|
// NewID generates a random 16-character hex ID from UUID + hash
|
|
func NewID() string {
|
|
// Generate UUID v4 (crypto random)
|
|
uuid := make([]byte, 16)
|
|
if _, err := rand.Read(uuid); err != nil {
|
|
panic(err)
|
|
}
|
|
// Set version (4) and variant bits per RFC 4122
|
|
uuid[6] = (uuid[6] & 0x0f) | 0x40
|
|
uuid[8] = (uuid[8] & 0x3f) | 0x80
|
|
|
|
// Hash the UUID with SHA-256
|
|
hash := sha256.Sum256(uuid)
|
|
|
|
// Take first 8 bytes and return as hex (16 chars)
|
|
return fmt.Sprintf("%016x",
|
|
uint64(hash[0])<<56 | uint64(hash[1])<<48 | uint64(hash[2])<<40 | uint64(hash[3])<<32 |
|
|
uint64(hash[4])<<24 | uint64(hash[5])<<16 | uint64(hash[6])<<8 | uint64(hash[7]))
|
|
}
|
|
|
|
// 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
|
|
}
|