summaryrefslogtreecommitdiffstats
path: root/internal/cryptoutil/cryptoutil.go
diff options
context:
space:
mode:
authorRose Hogenson <rosehogenson@posteo.net>2025-09-25 21:53:40 -0700
committerRose Hogenson <rosehogenson@posteo.net>2025-09-25 21:53:40 -0700
commit240e6e995745185c34249f1939d4c0d469a6746e (patch)
treeb739a4ab16f3b6ab9981c6c433dbe92ff6bdeddc /internal/cryptoutil/cryptoutil.go
parent251ea4f8fcfd0960f6a627501eb556e109a3e2b6 (diff)
downloadroseh.moe-240e6e995745185c34249f1939d4c0d469a6746e.tar.zst
Move complex crypto logic into a helper package
Diffstat (limited to 'internal/cryptoutil/cryptoutil.go')
-rw-r--r--internal/cryptoutil/cryptoutil.go167
1 files changed, 167 insertions, 0 deletions
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
+}