From 240e6e995745185c34249f1939d4c0d469a6746e Mon Sep 17 00:00:00 2001 From: Rose Hogenson Date: Thu, 25 Sep 2025 21:53:40 -0700 Subject: Move complex crypto logic into a helper package --- internal/cryptoutil/cryptoutil.go | 167 +++++++++++++++++++++++++++++++++ internal/cryptoutil/cryptoutil_test.go | 85 +++++++++++++++++ internal/pwhash/pwhash.go | 33 ------- 3 files changed, 252 insertions(+), 33 deletions(-) create mode 100644 internal/cryptoutil/cryptoutil.go create mode 100644 internal/cryptoutil/cryptoutil_test.go delete mode 100644 internal/pwhash/pwhash.go (limited to 'internal') diff --git a/internal/cryptoutil/cryptoutil.go b/internal/cryptoutil/cryptoutil.go new file mode 100644 index 0000000..c5d2a86 --- /dev/null +++ b/internal/cryptoutil/cryptoutil.go @@ -0,0 +1,167 @@ +// Package cryptoutil contains friendly wrappers around the algorithms from the +// standard library's crypto package. +package cryptoutil + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/hkdf" + "crypto/hmac" + "crypto/pbkdf2" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "errors" +) + +const ( + defaultIterations = 3749890 // from cmd/finditers + + saltSize = 2 * sha256.Size + hashSize = 32 + certSize = sha256.Size + aesKeySize = 32 + nonceSize = aes.BlockSize +) + +// A PasswordHash must be PasswordHashSize bytes. +const PasswordHashSize = saltSize + hashSize + +// A PasswordHash is derived from the user's password and can be passed to +// CheckPassword to verify if two passwords match. A PasswordHash must be +// PasswordHashSize bytes. +type PasswordHash []byte + +func (h PasswordHash) String() string { return hex.EncodeToString(h) } + +// A RawKey is generated from a password hash and can be used to derive further +// key material. +type RawKey struct { + key []byte +} + +// An HMACKey must be HMACKeySize bytes. +const HMACKeySize = sha256.Size + +// An HMAC key can be used to symmetrically sign and verify messages +// using HMAC-SHA256. An HMACKey must be HMACKeyLen bytes. +type HMACKey []byte + +// A SignedMessage is a message that has been cryptographically signed +// with HMAC-SHA256. +type SignedMessage []byte + +// An EncryptionKey must be EncryptionKeySize bytes. +const EncryptionKeySize = aesKeySize + HMACKeySize + +// An EncryptionKey is used for encrypting and decrypting data. An EncryptionKey +// must be EncryptionKeySize bytes. +type EncryptionKey []byte + +// An EncryptedMessage is encrypted with AES-256-CTR-HMAC-SHA256. +type EncryptedMessage []byte + +// HashIter runs pbkdf2-sha256 for iter iterations. Useful for benchmarking. +func HashIter(password string, salt []byte, iter int) ([]byte, error) { + return pbkdf2.Key(sha256.New, password, salt, iter, sha256.Size) +} + +func hashWithSalt(password string, salt []byte) (RawKey, []byte, error) { + hash, err := HashIter(password, salt[:sha256.Size], defaultIterations) + if err != nil { + return RawKey{}, nil, err + } + key, err := hkdf.Extract(sha256.New, hash, salt[sha256.Size:]) + if err != nil { + return RawKey{}, nil, err + } + pwHash, err := hkdf.Expand(sha256.New, key, "pwhash", hashSize) + if err != nil { + return RawKey{}, nil, err + } + return RawKey{key}, pwHash, nil +} + +// Hash hashes a user password using PBKDF2-SHA256. +func Hash(password string) (PasswordHash, error) { + salt := make([]byte, saltSize, saltSize+hashSize) + rand.Read(salt) + _, pwHash, err := hashWithSalt(password, salt) + if err != nil { + return nil, err + } + return append(salt, pwHash...), nil +} + +// CheckPassword verifies password against expectedHash and returns an +// EncryptionKey derived from the password if successful. +func CheckPassword(password string, expectedHash PasswordHash) (RawKey, error) { + salt, expectedHash := expectedHash[:saltSize], expectedHash[saltSize:] + key, pwHash, err := hashWithSalt(password, salt) + if err != nil { + return RawKey{}, err + } + if subtle.ConstantTimeCompare(pwHash, expectedHash) == 0 { + return RawKey{}, errors.New("incorrect password") + } + return key, nil +} + +// Sign generates an HMAC-SHA256 signature and appends it to msg. +func (k HMACKey) Sign(msg []byte) SignedMessage { + mac := hmac.New(sha256.New, k) + mac.Write(msg) + return mac.Sum(msg) +} + +// Verify checks whether the given message has a valid signature, and returns +// the raw message if it does. +func (k HMACKey) Verify(msg SignedMessage) ([]byte, bool) { + if len(msg) < certSize { + return nil, false + } + msg, sig := msg[:len(msg)-certSize], msg[len(msg)-certSize:] + mac := hmac.New(sha256.New, k) + mac.Write(msg) + if !hmac.Equal(sig, mac.Sum(nil)) { + return nil, false + } + return msg, true +} + +// EncryptionKey derives an EncryptionKey. +func (k RawKey) EncryptionKey() (EncryptionKey, error) { + return hkdf.Expand(sha256.New, k.key, "encrypt", EncryptionKeySize) +} + +// Encrypt encrypts a message with AES-256-CTR-HMAC-SHA256. +func (k EncryptionKey) Encrypt(msg []byte) (EncryptedMessage, error) { + aesKey, hmacKey := k[:aesKeySize], HMACKey(k[aesKeySize:]) + block, err := aes.NewCipher(aesKey) + if err != nil { + return nil, err + } + cipherText := make([]byte, len(msg)+nonceSize, len(msg)+nonceSize+certSize) + nonce := cipherText[len(msg) : len(msg)+nonceSize] + rand.Read(nonce) + cipher.NewCTR(block, nonce).XORKeyStream(cipherText, msg) + return EncryptedMessage(hmacKey.Sign(cipherText)), nil +} + +// Decrypt verifies and decrypts an encrypted message. It overwrites msg with +// the resulting plain text. +func (k EncryptionKey) Decrypt(msg EncryptedMessage) ([]byte, error) { + aesKey, hmacKey := k[:aesKeySize], HMACKey(k[aesKeySize:]) + msg, ok := hmacKey.Verify(SignedMessage(msg)) + if !ok { + return nil, errors.New("bad signature") + } + block, err := aes.NewCipher(aesKey) + if err != nil { + return nil, err + } + msg, nonce := msg[:len(msg)-nonceSize], msg[len(msg)-nonceSize:] + cipher.NewCTR(block, nonce).XORKeyStream(msg, msg) + return msg, nil +} diff --git a/internal/cryptoutil/cryptoutil_test.go b/internal/cryptoutil/cryptoutil_test.go new file mode 100644 index 0000000..6235878 --- /dev/null +++ b/internal/cryptoutil/cryptoutil_test.go @@ -0,0 +1,85 @@ +package cryptoutil + +import ( + "bytes" + "encoding/hex" + "testing" +) + +func mustHex(t *testing.T, s string) []byte { + t.Helper() + bytes, err := hex.DecodeString(s) + if err != nil { + t.Fatalf("Bad hex %q: %s", s, err) + } + return bytes +} + +func TestPassword(t *testing.T) { + const password = "eclair scroll gliding sled shining removed ascend android cheer confetti" + hash, err := Hash(password) + if err != nil { + t.Fatalf("Hash(%q) failed: %s", password, err) + } + _, err = CheckPassword(password, hash) + if err != nil { + t.Errorf("CheckPassword(%q, %q) failed: %s", password, hash, err) + } +} + +func TestSignature(t *testing.T) { + key := HMACKey(mustHex(t, "669e06ec457778b9a8133edb0a87ea82c6b141ffbbc63c038da96258175eb35c")) + msg := []byte("test message") + signedMsg := key.Sign(msg) + got, ok := key.Verify(signedMsg) + if !ok { + t.Fatalf("Verify(%x) rejected the message", signedMsg) + } + if !bytes.Equal(got, msg) { + t.Errorf("Verify(%x) = %x, want %x", signedMsg, got, msg) + } +} + +func TestEncrypt(t *testing.T) { + key := EncryptionKey(mustHex(t, "b6de26860e0a39aa134732e58055c06ba028675453f73a6912bc932e58d5743d0e423217f06487d3e96a59980301fcc97dfcc4c6b19765f2947de3c33ab7ef9e")) + msg := []byte("test message") + encryptedMsg, err := key.Encrypt(msg) + if err != nil { + t.Fatalf("Encrypt(%q) failed: %s", msg, err) + } + got, err := key.Decrypt(encryptedMsg) + if err != nil { + t.Fatalf("Decrypt(%x) failed: %s", encryptedMsg, err) + } + if !bytes.Equal(got, msg) { + t.Errorf("Decrypt(%x) = %x, want %x", encryptedMsg, got, msg) + } +} + +func TestDeriveKey(t *testing.T) { + const password = "automaker moisture botch tubular kelp rinse rule giggly donated stock" + hash, err := Hash(password) + if err != nil { + t.Fatalf("Hash(%q) failed: %s", password, err) + } + rawKey, err := CheckPassword(password, hash) + if err != nil { + t.Fatalf("CheckPassword(%q, %x) failed: %s", password, hash, err) + } + key, err := rawKey.EncryptionKey() + if err != nil { + t.Fatalf("EncryptionKey() failed: %s", err) + } + msg := []byte("test message") + encryptedMsg, err := key.Encrypt(msg) + if err != nil { + t.Fatalf("Encrypt(%q) failed: %s", msg, err) + } + got, err := key.Decrypt(encryptedMsg) + if err != nil { + t.Fatalf("Decrypt(%x) failed: %s", encryptedMsg, err) + } + if !bytes.Equal(got, msg) { + t.Errorf("Decrypt(%x) = %x, want %x", encryptedMsg, got, msg) + } +} diff --git a/internal/pwhash/pwhash.go b/internal/pwhash/pwhash.go deleted file mode 100644 index 9ef5d2a..0000000 --- a/internal/pwhash/pwhash.go +++ /dev/null @@ -1,33 +0,0 @@ -package pwhash - -import ( - "crypto/hkdf" - "crypto/pbkdf2" - "crypto/sha256" -) - -const SaltLen = 2 * sha256.Size - -const defaultIterations = 3749890 // from cmd/finditers - -func HashIter(password string, salt []byte, iter int) ([]byte, error) { - return pbkdf2.Key(sha256.New, password, salt, iter, sha256.Size) -} - -func Hash(password string, salt []byte) (rawKey, pwHash []byte, err error) { - const hashLen = 32 - - key, err := HashIter(password, salt[:sha256.Size], defaultIterations) - if err != nil { - return nil, nil, err - } - key, err = hkdf.Extract(sha256.New, key, salt[sha256.Size:]) - if err != nil { - return nil, nil, err - } - pwHash, err = hkdf.Expand(sha256.New, key, "pwhash", hashLen) - if err != nil { - return nil, nil, err - } - return key, pwHash, nil -} -- cgit v1.3.1