diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-22 09:41:45 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-22 09:41:45 +0200 |
| commit | 830b314656b0353e6a52652b650b686edfefa97a (patch) | |
| tree | 6eb0330566e9b785c697a3348d7e5e1f3824c140 /internal/crypto/crypto.go | |
| parent | f78a185364ee24bc8c46d4aa6cc96d705faab326 (diff) | |
Implement crypto package with AES-256-CBC and golden tests (task 354)
Add internal/crypto/crypto.go with byte-identical AES-256-CBC encryption
and decryption matching the Ruby OpenSSL reference in geheim.rb, including
key doubling (enforceKeyLength), IV derivation (buildIV), PKCS7 pad/unpad,
and NewCipher/Encrypt/Decrypt. Add internal/crypto/crypto_test.go with
table-driven golden tests (6 vectors from Ruby), roundtrip tests (8 cases),
enforceKeyLength/buildIV/PKCS7 unit tests, and error-path coverage (22 total).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/crypto/crypto.go')
| -rw-r--r-- | internal/crypto/crypto.go | 162 |
1 files changed, 161 insertions, 1 deletions
diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go index 0e985f4..83a4ae1 100644 --- a/internal/crypto/crypto.go +++ b/internal/crypto/crypto.go @@ -1,2 +1,162 @@ -// Package crypto provides encryption and decryption primitives for geheim. +// Package crypto provides AES-256-CBC encryption and decryption primitives +// for geheim. The implementation is byte-identical to Ruby's OpenSSL +// AES-256-CBC cipher so that files encrypted by the Ruby CLI can be +// decrypted by the Go implementation and vice-versa. package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "errors" + "fmt" + "os" +) + +const defaultBlockSize = 16 + +// Cipher holds the derived key and IV used for every encrypt/decrypt call. +// Both values are fixed for the lifetime of the struct; create a new Cipher +// if the key file or PIN changes. +type Cipher struct { + key []byte // exactly keyLength bytes (default 32 for AES-256) + iv []byte // exactly 16 bytes (AES block size) +} + +// NewCipher reads the raw key material from keyFile, pads/truncates it to +// keyLength bytes using the same doubling strategy as the Ruby reference +// implementation, and derives the 16-byte IV from pin and addToIV. +// +// keyLength is typically 32 (AES-256). pin and addToIV must be ASCII strings +// because the IV is constructed at the byte level (not the rune level). +func NewCipher(keyFile string, keyLength int, pin string, addToIV string) (*Cipher, error) { + raw, err := os.ReadFile(keyFile) + if err != nil { + return nil, fmt.Errorf("reading key file %q: %w", keyFile, err) + } + if len(raw) == 0 { + return nil, errors.New("key file is empty") + } + + return &Cipher{ + key: enforceKeyLength(raw, keyLength), + iv: buildIV(pin, addToIV), + }, nil +} + +// enforceKeyLength replicates the Ruby `enforce_key_length` method: +// +// new_key += key while new_key.size < force_size +// new_key[0, force_size] +// +// If key is already exactly size bytes it is returned unchanged (after a copy). +// If it is longer it is simply truncated. If it is shorter the key is +// concatenated with itself until it reaches at least size bytes, then +// truncated to exactly size bytes. +func enforceKeyLength(key []byte, size int) []byte { + newKey := make([]byte, len(key)) + copy(newKey, key) + + // Keep appending the original key until we have enough bytes. + for len(newKey) < size { + newKey = append(newKey, key...) + } + + return newKey[:size] +} + +// buildIV constructs the 16-byte initialization vector the same way the Ruby +// reference does: +// +// iv_str = pin * 2 + add_to_iv + pin * 2 +// iv = iv_str.byteslice(0, 16) +// +// The slice is performed on bytes, not runes, so ASCII PINs are required for +// correct cross-language compatibility. +func buildIV(pin, addToIV string) []byte { + ivStr := pin + pin + addToIV + pin + pin + return []byte(ivStr)[:16] +} + +// Encrypt encrypts plaintext using AES-256-CBC with PKCS7 padding and returns +// the raw binary ciphertext (no base64 encoding). PKCS7 always adds a full +// extra block when the plaintext length is already a multiple of 16. +func (c *Cipher) Encrypt(plaintext []byte) ([]byte, error) { + padded := pkcs7Pad(plaintext, defaultBlockSize) + + block, err := aes.NewCipher(c.key) + if err != nil { + return nil, fmt.Errorf("creating AES cipher: %w", err) + } + + ciphertext := make([]byte, len(padded)) + mode := cipher.NewCBCEncrypter(block, c.iv) + mode.CryptBlocks(ciphertext, padded) + + return ciphertext, nil +} + +// Decrypt decrypts AES-256-CBC ciphertext (raw binary, no base64) and strips +// PKCS7 padding, returning the original plaintext bytes. +func (c *Cipher) Decrypt(ciphertext []byte) ([]byte, error) { + if len(ciphertext)%defaultBlockSize != 0 { + return nil, fmt.Errorf("ciphertext length %d is not a multiple of block size %d", + len(ciphertext), defaultBlockSize) + } + if len(ciphertext) == 0 { + return nil, errors.New("ciphertext is empty") + } + + block, err := aes.NewCipher(c.key) + if err != nil { + return nil, fmt.Errorf("creating AES cipher: %w", err) + } + + // Decrypt in-place: CBC decrypter writes back into the same slice. + plaintext := make([]byte, len(ciphertext)) + mode := cipher.NewCBCDecrypter(block, c.iv) + mode.CryptBlocks(plaintext, ciphertext) + + return pkcs7Unpad(plaintext) +} + +// pkcs7Pad appends PKCS7 padding so that len(result) is a multiple of +// blockSize. A full extra block is added when the input is already aligned, +// matching OpenSSL's default behaviour. +func pkcs7Pad(data []byte, blockSize int) []byte { + // padding value is the number of bytes that need to be added; + // at minimum 1, at maximum blockSize (full block when already aligned). + padding := blockSize - (len(data) % blockSize) + padded := make([]byte, len(data)+padding) + copy(padded, data) + for i := len(data); i < len(padded); i++ { + padded[i] = byte(padding) + } + return padded +} + +// pkcs7Unpad validates and removes PKCS7 padding from decrypted data. +// It returns an error if the padding byte value is out of range (0 or >16) or +// if any of the trailing padding bytes do not equal the padding value. +func pkcs7Unpad(data []byte) ([]byte, error) { + if len(data) == 0 { + return nil, errors.New("pkcs7Unpad: data is empty") + } + + padLen := int(data[len(data)-1]) + if padLen < 1 || padLen > defaultBlockSize { + return nil, fmt.Errorf("pkcs7Unpad: invalid padding byte %d", padLen) + } + if padLen > len(data) { + return nil, fmt.Errorf("pkcs7Unpad: padding length %d exceeds data length %d", padLen, len(data)) + } + + // Validate that every padding byte equals padLen. + for i := len(data) - padLen; i < len(data); i++ { + if data[i] != byte(padLen) { + return nil, fmt.Errorf("pkcs7Unpad: invalid padding at byte %d: got %d, want %d", + i, data[i], padLen) + } + } + + return data[:len(data)-padLen], nil +} |
