Module: ConcernsOnRails::Support::Encryptor

Defined in:
lib/concerns_on_rails/support/encryptor.rb

Overview

Pure, stateless AES-256-GCM codec shared by Models::Encryptable. Like the other Support modules (Masker, Money) it holds no state — key material is always passed in — and uses only stdlib OpenSSL, matching the dependency -free crypto already in Controllers::WebhookVerifiable.

On-disk envelope (Base64 via pack("m0"), the gem's convention — avoids the base64 gem, no longer default on Ruby 3.4):

ver(1)=0x01 | alg(1)=0x01 | key_id(1) | iv(12) | auth_tag(16) | ciphertext

The 3-byte header is fed to GCM as additional authenticated data (AAD), so the version/algorithm/key-id cannot be altered without failing the auth tag. alg 0x11 (deterministic) and a non-zero key_id (rotation) are reserved for later features — the format tolerates them without a break.

Constant Summary collapse

VERSION_BYTE =
0x01
ALG_GCM =
0x01
HEADER_FORMAT =
"C3".freeze
HEADER_LEN =
3
IV_LEN =

96-bit IV: GCM's recommended size

12
TAG_LEN =

128-bit auth tag

16
KEY_BYTES =

AES-256

32
CIPHER =
"aes-256-gcm".freeze
MIN_ENVELOPE_BYTES =
HEADER_LEN + IV_LEN + TAG_LEN

Class Method Summary collapse

Class Method Details

.decrypt(envelope, key:, salt: ConcernsOnRails::Encryption::DEFAULT_KDF_SALT) ⇒ Object

Decrypt a Base64 envelope back to the plaintext String. nil -> nil. A wrong key, tampered ciphertext, or malformed envelope raises Encryption::DecryptionError (never a raw OpenSSL error).



52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
# File 'lib/concerns_on_rails/support/encryptor.rb', line 52

def decrypt(envelope, key:, salt: ConcernsOnRails::Encryption::DEFAULT_KDF_SALT)
  return nil if envelope.nil?

  # "m0" is strict Base64 and raises ArgumentError on non-Base64 input.
  raw =
    begin
      envelope.to_s.unpack1("m0").to_s
    rescue ArgumentError
      raise ConcernsOnRails::Encryption::DecryptionError, "malformed encryption envelope"
    end
  raise ConcernsOnRails::Encryption::DecryptionError, "malformed encryption envelope" if raw.bytesize < MIN_ENVELOPE_BYTES

  header = raw.byteslice(0, HEADER_LEN)
  iv = raw.byteslice(HEADER_LEN, IV_LEN)
  tag = raw.byteslice(HEADER_LEN + IV_LEN, TAG_LEN)
  ciphertext = raw.byteslice((HEADER_LEN + IV_LEN + TAG_LEN)..) || ""

  derived = normalize_key(key, salt: salt)
  cipher = OpenSSL::Cipher.new(CIPHER)
  cipher.decrypt
  cipher.key = derived
  cipher.iv = iv
  cipher.auth_tag = tag
  cipher.auth_data = header
  cipher.update(ciphertext) + cipher.final
rescue OpenSSL::Cipher::CipherError
  raise ConcernsOnRails::Encryption::DecryptionError,
        "could not decrypt value (wrong key or tampered ciphertext)"
end

.encrypt(plaintext, key:, key_id: 0, salt: ConcernsOnRails::Encryption::DEFAULT_KDF_SALT) ⇒ Object

Encrypt a String, returning a Base64 envelope. nil passes through as nil (a blank column stays blank / NULL-able), never an encrypted empty value.



35
36
37
38
39
40
41
42
43
44
45
46
47
# File 'lib/concerns_on_rails/support/encryptor.rb', line 35

def encrypt(plaintext, key:, key_id: 0, salt: ConcernsOnRails::Encryption::DEFAULT_KDF_SALT)
  return nil if plaintext.nil?

  derived = normalize_key(key, salt: salt)
  cipher = OpenSSL::Cipher.new(CIPHER)
  cipher.encrypt
  cipher.key = derived
  iv = cipher.random_iv
  header = [VERSION_BYTE, ALG_GCM, key_id & 0xFF].pack(HEADER_FORMAT)
  cipher.auth_data = header
  ciphertext = cipher.update(plaintext.to_s) + cipher.final
  [header + iv + cipher.auth_tag + ciphertext].pack("m0")
end

.normalize_key(value, salt: ConcernsOnRails::Encryption::DEFAULT_KDF_SALT) ⇒ Object

Coerce key material to a 32-byte AES key: raw 32-byte binary as-is, a 64-hex string decoded, otherwise a passphrase stretched with PBKDF2. The salt + iteration count are part of the derived key's identity.



85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
# File 'lib/concerns_on_rails/support/encryptor.rb', line 85

def normalize_key(value, salt: ConcernsOnRails::Encryption::DEFAULT_KDF_SALT)
  value = value.call if value.respond_to?(:call)
  material = value.to_s
  raise ConcernsOnRails::Encryption::MissingKeyError, "no encryption key configured" if material.empty?

  return material.b if material.bytesize == KEY_BYTES && material.encoding == Encoding::BINARY
  return [material].pack("H*") if material.match?(/\A\h{64}\z/)

  OpenSSL::KDF.pbkdf2_hmac(
    material,
    salt: salt.to_s,
    iterations: ConcernsOnRails::Encryption::KDF_ITERATIONS,
    length: KEY_BYTES,
    hash: "SHA256"
  )
end