initial
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
// Package crypto provides AES-256-GCM encryption, HKDF key derivation,
|
||||
// bcrypt helpers, and secure random utilities.
|
||||
// All encryption uses authenticated encryption — tampering is detected.
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"golang.org/x/crypto/hkdf"
|
||||
)
|
||||
|
||||
const (
|
||||
// BcryptCost is the minimum bcrypt work factor.
|
||||
BcryptCost = 12
|
||||
|
||||
// keyLen is AES-256 key length in bytes.
|
||||
keyLen = 32
|
||||
|
||||
// gcmNonceLen is the standard GCM nonce size.
|
||||
gcmNonceLen = 12
|
||||
)
|
||||
|
||||
// Crypto holds the master encryption key.
|
||||
// One instance per application, injected everywhere that needs encryption.
|
||||
type Crypto struct {
|
||||
masterKey [keyLen]byte
|
||||
}
|
||||
|
||||
// New creates a Crypto instance from a 32-byte master key.
|
||||
func New(masterKey []byte) (*Crypto, error) {
|
||||
if len(masterKey) != keyLen {
|
||||
return nil, fmt.Errorf("crypto: master key must be %d bytes, got %d", keyLen, len(masterKey))
|
||||
}
|
||||
c := &Crypto{}
|
||||
copy(c.masterKey[:], masterKey)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// DeriveKey returns a unique 32-byte AES-256 key for a given purpose + userID.
|
||||
// Uses HKDF-SHA256 so each (purpose, userID) pair gets a unique subkey,
|
||||
// and the master key is never used directly for encryption.
|
||||
func (c *Crypto) DeriveKey(purpose string, userID int64) ([keyLen]byte, error) {
|
||||
var key [keyLen]byte
|
||||
info := fmt.Sprintf("%s:user:%d", purpose, userID)
|
||||
r := hkdf.New(sha256.New, c.masterKey[:], nil, []byte(info))
|
||||
if _, err := io.ReadFull(r, key[:]); err != nil {
|
||||
return key, fmt.Errorf("hkdf derive: %w", err)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// DeriveKeyGlobal returns a 32-byte key derived from master key for global use
|
||||
// (e.g. encrypting DKIM private keys stored per-domain, not per-user).
|
||||
func (c *Crypto) DeriveKeyGlobal(purpose string) ([keyLen]byte, error) {
|
||||
var key [keyLen]byte
|
||||
r := hkdf.New(sha256.New, c.masterKey[:], nil, []byte("global:"+purpose))
|
||||
if _, err := io.ReadFull(r, key[:]); err != nil {
|
||||
return key, fmt.Errorf("hkdf derive global: %w", err)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// Encrypt encrypts plaintext with AES-256-GCM using the provided 32-byte key.
|
||||
// Returns nonce||ciphertext||tag (nonce prepended, all opaque bytes).
|
||||
// Returns an error if plaintext is nil (use []byte{} for empty).
|
||||
func Encrypt(key [keyLen]byte, plaintext []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key[:])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("aes new cipher: %w", err)
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("aes gcm: %w", err)
|
||||
}
|
||||
|
||||
nonce := make([]byte, gcmNonceLen)
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return nil, fmt.Errorf("rand nonce: %w", err)
|
||||
}
|
||||
|
||||
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
|
||||
return ciphertext, nil
|
||||
}
|
||||
|
||||
// Decrypt decrypts a nonce||ciphertext||tag blob produced by Encrypt.
|
||||
func Decrypt(key [keyLen]byte, ciphertext []byte) ([]byte, error) {
|
||||
if len(ciphertext) < gcmNonceLen {
|
||||
return nil, fmt.Errorf("ciphertext too short")
|
||||
}
|
||||
block, err := aes.NewCipher(key[:])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("aes new cipher: %w", err)
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("aes gcm: %w", err)
|
||||
}
|
||||
|
||||
nonce := ciphertext[:gcmNonceLen]
|
||||
data := ciphertext[gcmNonceLen:]
|
||||
plaintext, err := gcm.Open(nil, nonce, data, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decrypt: %w", err) // do not leak GCM error details
|
||||
}
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
// EncryptForUser derives a per-user key and encrypts.
|
||||
func (c *Crypto) EncryptForUser(userID int64, purpose string, plaintext []byte) ([]byte, error) {
|
||||
key, err := c.DeriveKey(purpose, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Encrypt(key, plaintext)
|
||||
}
|
||||
|
||||
// DecryptForUser derives a per-user key and decrypts.
|
||||
func (c *Crypto) DecryptForUser(userID int64, purpose string, ciphertext []byte) ([]byte, error) {
|
||||
key, err := c.DeriveKey(purpose, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Decrypt(key, ciphertext)
|
||||
}
|
||||
|
||||
// EncryptGlobal derives a global key for given purpose and encrypts.
|
||||
func (c *Crypto) EncryptGlobal(purpose string, plaintext []byte) ([]byte, error) {
|
||||
key, err := c.DeriveKeyGlobal(purpose)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Encrypt(key, plaintext)
|
||||
}
|
||||
|
||||
// DecryptGlobal derives a global key for given purpose and decrypts.
|
||||
func (c *Crypto) DecryptGlobal(purpose string, ciphertext []byte) ([]byte, error) {
|
||||
key, err := c.DeriveKeyGlobal(purpose)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Decrypt(key, ciphertext)
|
||||
}
|
||||
|
||||
// ---- Bcrypt ----
|
||||
|
||||
// HashPassword hashes a password with bcrypt at cost BcryptCost.
|
||||
func HashPassword(password string) (string, error) {
|
||||
if password == "" {
|
||||
return "", fmt.Errorf("password must not be empty")
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("bcrypt: %w", err)
|
||||
}
|
||||
return string(hash), nil
|
||||
}
|
||||
|
||||
// CheckPassword returns nil if password matches the stored bcrypt hash.
|
||||
// Uses constant-time comparison internally (bcrypt).
|
||||
func CheckPassword(hash, password string) error {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
}
|
||||
|
||||
// ---- Session tokens ----
|
||||
|
||||
// NewToken generates a cryptographically random 32-byte token and returns
|
||||
// (rawToken, sha256HexHash). Store the hash; send the raw token to the client.
|
||||
func NewToken() (raw string, hash string, err error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err = rand.Read(b); err != nil {
|
||||
return "", "", fmt.Errorf("rand token: %w", err)
|
||||
}
|
||||
raw = hex.EncodeToString(b)
|
||||
hash = HashToken(raw)
|
||||
return raw, hash, nil
|
||||
}
|
||||
|
||||
// HashToken returns the SHA-256 hex hash of a raw token string.
|
||||
func HashToken(raw string) string {
|
||||
h := sha256.Sum256([]byte(raw))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// SecureCompare returns true if a == b using constant-time comparison.
|
||||
// Use for any comparison where timing attacks are a concern.
|
||||
func SecureCompare(a, b string) bool {
|
||||
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
||||
}
|
||||
|
||||
// ---- Random helpers ----
|
||||
|
||||
// RandomHex returns n random bytes as a hex string (length 2n).
|
||||
func RandomHex(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("rand: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// RandomBytes returns n cryptographically random bytes.
|
||||
func RandomBytes(n int) ([]byte, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return nil, fmt.Errorf("rand: %w", err)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
Reference in New Issue
Block a user