summaryrefslogtreecommitdiff
path: root/internal/crypto/crypto.go
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-22 09:41:45 +0200
committerPaul Buetow <paul@buetow.org>2026-02-22 09:41:45 +0200
commit830b314656b0353e6a52652b650b686edfefa97a (patch)
tree6eb0330566e9b785c697a3348d7e5e1f3824c140 /internal/crypto/crypto.go
parentf78a185364ee24bc8c46d4aa6cc96d705faab326 (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.go162
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
+}