feat(crypto): AEAD envelope encryption with AES-256-GCM, KEK loader, secure random

- Random with constant-time-equal helper
- KEK loader from env (Tier 1) with InvalidKekError on bad/missing var
- AEAD via direct LibCrypto FFI bindings (Crystal 1.20 stdlib OpenSSL::Cipher
  doesn't expose GCM auth_data/auth_tag, so we extend LibCrypto with
  EVP_CIPHER_CTX_ctrl + EVP_aes_256_gcm and call them directly)
- Envelope: per-row DEK wrapped by KEK, AAD-bound, algorithm_id reserved
  for crypto agility (0x01 = AES-256-GCM today, 0x02/0x03 reserved)
- 17 unit specs including a 100-iteration random property test
- Tag-tampered ciphertext / wrong-AAD / wrong-key all raise Aead::Error
This commit is contained in:
CarterPerez-dev 2026-04-29 00:43:12 -04:00
parent 5971cbd33e
commit 4723716e08
9 changed files with 440 additions and 0 deletions

View File

@ -0,0 +1,24 @@
# ===================
# ©AngelaMos | 2026
# aead_property_spec.cr
# ===================
require "../../spec_helper"
require "../../../src/cre/crypto/aead"
require "../../../src/cre/crypto/random"
describe CRE::Crypto::Aead do
it "encrypt -> decrypt is identity for 100 random plaintexts of varying sizes" do
rng = ::Random.new(42)
100.times do
size = rng.rand(0..1024)
plaintext = Bytes.new(size) { rng.rand(0_u8..255_u8) }
key = CRE::Crypto::Random.bytes(32)
aad = CRE::Crypto::Random.bytes(rng.rand(0..64))
ct, nonce, tag = CRE::Crypto::Aead.encrypt(plaintext, key, aad)
decrypted = CRE::Crypto::Aead.decrypt(ct, key, aad, nonce, tag)
decrypted.should eq plaintext
end
end
end

View File

@ -0,0 +1,50 @@
# ===================
# ©AngelaMos | 2026
# aead_spec.cr
# ===================
require "../../spec_helper"
require "../../../src/cre/crypto/aead"
require "../../../src/cre/crypto/random"
describe CRE::Crypto::Aead do
it "round-trips AES-256-GCM with AAD" do
key = CRE::Crypto::Random.bytes(32)
plaintext = "secret value 123".to_slice
aad = "tenant=t1|cred=c1|ver=v1".to_slice
ct, nonce, tag = CRE::Crypto::Aead.encrypt(plaintext, key, aad)
decrypted = CRE::Crypto::Aead.decrypt(ct, key, aad, nonce, tag)
decrypted.should eq plaintext
end
it "fails to decrypt with wrong AAD" do
key = CRE::Crypto::Random.bytes(32)
plaintext = "secret".to_slice
aad = "right".to_slice
ct, nonce, tag = CRE::Crypto::Aead.encrypt(plaintext, key, aad)
expect_raises(CRE::Crypto::Aead::Error) do
CRE::Crypto::Aead.decrypt(ct, key, "wrong".to_slice, nonce, tag)
end
end
it "fails to decrypt with tampered ciphertext" do
key = CRE::Crypto::Random.bytes(32)
plaintext = "abc-def".to_slice
aad = "x".to_slice
ct, nonce, tag = CRE::Crypto::Aead.encrypt(plaintext, key, aad)
ct[0] ^= 0x01_u8
expect_raises(CRE::Crypto::Aead::Error) do
CRE::Crypto::Aead.decrypt(ct, key, aad, nonce, tag)
end
end
it "rejects keys of incorrect size" do
bad_key = Bytes.new(16)
expect_raises(ArgumentError) do
CRE::Crypto::Aead.encrypt("x".to_slice, bad_key, "a".to_slice)
end
end
end

View File

@ -0,0 +1,55 @@
# ===================
# ©AngelaMos | 2026
# envelope_spec.cr
# ===================
require "../../spec_helper"
require "../../../src/cre/crypto/envelope"
require "../../../src/cre/crypto/kek"
describe CRE::Crypto::Envelope do
it "encrypts and decrypts with envelope" do
ENV["KEK_TEST_HEX"] = "0" * 64
kek = CRE::Crypto::Kek::EnvKek.new("KEK_TEST_HEX", version: 1)
env = CRE::Crypto::Envelope.new(kek)
plaintext = "very secret".to_slice
aad = "ctx".to_slice
sealed = env.seal(plaintext, aad)
sealed.algorithm_id.should eq CRE::Crypto::ALGORITHM_AES_256_GCM
sealed.kek_version.should eq 1
opened = env.open(sealed, aad)
opened.should eq plaintext
ensure
ENV.delete("KEK_TEST_HEX")
end
it "fails to open with mismatched AAD" do
ENV["KEK_TEST_HEX2"] = "0" * 64
kek = CRE::Crypto::Kek::EnvKek.new("KEK_TEST_HEX2", version: 1)
env = CRE::Crypto::Envelope.new(kek)
sealed = env.seal("plaintext".to_slice, "good-aad".to_slice)
expect_raises(CRE::Crypto::Aead::Error) do
env.open(sealed, "bad-aad".to_slice)
end
ensure
ENV.delete("KEK_TEST_HEX2")
end
it "produces different ciphertexts for the same plaintext (random DEK + nonce)" do
ENV["KEK_TEST_HEX3"] = "0" * 64
kek = CRE::Crypto::Kek::EnvKek.new("KEK_TEST_HEX3", version: 1)
env = CRE::Crypto::Envelope.new(kek)
aad = "x".to_slice
a = env.seal("same".to_slice, aad)
b = env.seal("same".to_slice, aad)
a.ciphertext.should_not eq b.ciphertext
a.dek_wrapped.should_not eq b.dek_wrapped
ensure
ENV.delete("KEK_TEST_HEX3")
end
end

View File

@ -0,0 +1,35 @@
# ===================
# ©AngelaMos | 2026
# kek_spec.cr
# ===================
require "../../spec_helper"
require "../../../src/cre/crypto/kek"
describe CRE::Crypto::Kek do
it "loads a 32-byte KEK from env hex" do
hex = "0" * 64
ENV["TEST_KEK_HEX"] = hex
kek = CRE::Crypto::Kek::EnvKek.new("TEST_KEK_HEX", version: 1)
kek.bytes.size.should eq 32
kek.version.should eq 1
kek.source.should eq "env:TEST_KEK_HEX"
ensure
ENV.delete("TEST_KEK_HEX")
end
it "raises on wrong-length hex" do
ENV["BAD_KEK"] = "abcd"
expect_raises(CRE::Crypto::Kek::InvalidKekError) do
CRE::Crypto::Kek::EnvKek.new("BAD_KEK", version: 1)
end
ensure
ENV.delete("BAD_KEK")
end
it "raises on missing env var" do
expect_raises(CRE::Crypto::Kek::InvalidKekError) do
CRE::Crypto::Kek::EnvKek.new("DOES_NOT_EXIST_XYZ_KEK", version: 1)
end
end
end

View File

@ -0,0 +1,46 @@
# ===================
# ©AngelaMos | 2026
# random_spec.cr
# ===================
require "../../spec_helper"
require "../../../src/cre/crypto/random"
describe CRE::Crypto::Random do
it "generates 32 secure bytes" do
bytes = CRE::Crypto::Random.bytes(32)
bytes.size.should eq 32
end
it "two calls produce different bytes (overwhelmingly likely)" do
a = CRE::Crypto::Random.bytes(32)
b = CRE::Crypto::Random.bytes(32)
a.should_not eq b
end
it "hex returns 2n hex chars" do
h = CRE::Crypto::Random.hex(16)
h.size.should eq 32
h.each_char { |c| ("0123456789abcdef".includes?(c)).should be_true }
end
describe "constant_time_equal?" do
it "returns true for equal slices" do
a = Bytes[1, 2, 3, 4]
b = Bytes[1, 2, 3, 4]
CRE::Crypto::Random.constant_time_equal?(a, b).should be_true
end
it "returns false for different slices" do
a = Bytes[1, 2, 3, 4]
b = Bytes[1, 2, 3, 5]
CRE::Crypto::Random.constant_time_equal?(a, b).should be_false
end
it "returns false for different sizes" do
a = Bytes[1, 2, 3]
b = Bytes[1, 2, 3, 4]
CRE::Crypto::Random.constant_time_equal?(a, b).should be_false
end
end
end

View File

@ -0,0 +1,100 @@
# ===================
# ©AngelaMos | 2026
# aead.cr
# ===================
require "openssl"
require "openssl/lib_crypto"
lib LibCrypto
fun evp_cipher_ctx_ctrl_cre = EVP_CIPHER_CTX_ctrl(ctx : EVP_CIPHER_CTX, type : LibC::Int, arg : LibC::Int, ptr : Void*) : LibC::Int
fun evp_aes_256_gcm_cre = EVP_aes_256_gcm : EVP_CIPHER
end
module CRE::Crypto
module Aead
NONCE_SIZE = 12
TAG_SIZE = 16
EVP_CTRL_GCM_SET_IVLEN = 0x9
EVP_CTRL_GCM_GET_TAG = 0x10
EVP_CTRL_GCM_SET_TAG = 0x11
class Error < OpenSSL::Error; end
def self.encrypt(plaintext : Bytes, key : Bytes, aad : Bytes) : {Bytes, Bytes, Bytes}
raise ArgumentError.new("key must be 32 bytes (AES-256)") unless key.size == 32
ctx = LibCrypto.evp_cipher_ctx_new
raise Error.new("EVP_CIPHER_CTX_new") if ctx.null?
begin
nonce = ::Random::Secure.random_bytes(NONCE_SIZE)
cipher = LibCrypto.evp_aes_256_gcm_cre
check(LibCrypto.evp_cipherinit_ex(ctx, cipher, Pointer(Void).null, Pointer(UInt8).null, Pointer(UInt8).null, 1), "EncryptInit (cipher)")
check(LibCrypto.evp_cipher_ctx_ctrl_cre(ctx, EVP_CTRL_GCM_SET_IVLEN, NONCE_SIZE, Pointer(Void).null), "set IV len")
check(LibCrypto.evp_cipherinit_ex(ctx, Pointer(Void).null.as(LibCrypto::EVP_CIPHER), Pointer(Void).null, key.to_unsafe, nonce.to_unsafe, 1), "EncryptInit (key/iv)")
unless aad.empty?
aad_outl = 0
check(LibCrypto.evp_cipherupdate(ctx, Pointer(UInt8).null, pointerof(aad_outl), aad.to_unsafe, aad.size), "EncryptUpdate (AAD)")
end
ct_buf = Bytes.new(plaintext.size + 16)
outl = 0
check(LibCrypto.evp_cipherupdate(ctx, ct_buf.to_unsafe, pointerof(outl), plaintext.to_unsafe, plaintext.size), "EncryptUpdate (data)")
ct_size = outl
final_outl = 0
check(LibCrypto.evp_cipherfinal_ex(ctx, ct_buf.to_unsafe + ct_size, pointerof(final_outl)), "EncryptFinal_ex")
ct_size += final_outl
tag = Bytes.new(TAG_SIZE)
check(LibCrypto.evp_cipher_ctx_ctrl_cre(ctx, EVP_CTRL_GCM_GET_TAG, TAG_SIZE, tag.to_unsafe.as(Void*)), "get tag")
{ct_buf[0, ct_size], nonce, tag}
ensure
LibCrypto.evp_cipher_ctx_free(ctx)
end
end
def self.decrypt(ciphertext : Bytes, key : Bytes, aad : Bytes, nonce : Bytes, tag : Bytes) : Bytes
raise ArgumentError.new("key must be 32 bytes") unless key.size == 32
ctx = LibCrypto.evp_cipher_ctx_new
raise Error.new("EVP_CIPHER_CTX_new") if ctx.null?
begin
cipher = LibCrypto.evp_aes_256_gcm_cre
check(LibCrypto.evp_cipherinit_ex(ctx, cipher, Pointer(Void).null, Pointer(UInt8).null, Pointer(UInt8).null, 0), "DecryptInit (cipher)")
check(LibCrypto.evp_cipher_ctx_ctrl_cre(ctx, EVP_CTRL_GCM_SET_IVLEN, NONCE_SIZE, Pointer(Void).null), "set IV len")
check(LibCrypto.evp_cipherinit_ex(ctx, Pointer(Void).null.as(LibCrypto::EVP_CIPHER), Pointer(Void).null, key.to_unsafe, nonce.to_unsafe, 0), "DecryptInit (key/iv)")
unless aad.empty?
aad_outl = 0
check(LibCrypto.evp_cipherupdate(ctx, Pointer(UInt8).null, pointerof(aad_outl), aad.to_unsafe, aad.size), "DecryptUpdate (AAD)")
end
pt_buf = Bytes.new(ciphertext.size + 16)
outl = 0
check(LibCrypto.evp_cipherupdate(ctx, pt_buf.to_unsafe, pointerof(outl), ciphertext.to_unsafe, ciphertext.size), "DecryptUpdate (data)")
pt_size = outl
check(LibCrypto.evp_cipher_ctx_ctrl_cre(ctx, EVP_CTRL_GCM_SET_TAG, TAG_SIZE, tag.to_unsafe.as(Void*)), "set tag")
final_outl = 0
result = LibCrypto.evp_cipherfinal_ex(ctx, pt_buf.to_unsafe + pt_size, pointerof(final_outl))
if result <= 0
raise Error.new("AEAD authentication failed (tag mismatch / tampered ciphertext)")
end
pt_size += final_outl
pt_buf[0, pt_size]
ensure
LibCrypto.evp_cipher_ctx_free(ctx)
end
end
private def self.check(rc : LibC::Int, what : String) : Nil
raise Error.new("#{what} failed (rc=#{rc})") unless rc == 1
end
end
end

View File

@ -0,0 +1,72 @@
# ===================
# ©AngelaMos | 2026
# envelope.cr
# ===================
require "./aead"
require "./kek"
require "./random"
module CRE::Crypto
ALGORITHM_AES_256_GCM = 1_i16
struct SealedSecret
getter ciphertext : Bytes
getter dek_wrapped : Bytes
getter kek_version : Int32
getter algorithm_id : Int16
def initialize(@ciphertext, @dek_wrapped, @kek_version, @algorithm_id)
end
end
class Envelope
def initialize(@kek : Kek::Kek)
end
def seal(plaintext : Bytes, aad : Bytes) : SealedSecret
dek = Random.bytes(32)
ct, nonce, tag = Aead.encrypt(plaintext, dek, aad)
packed_ct = pack(nonce, tag, ct)
wrap_aad = "kek-wrap|v#{@kek.version}".to_slice
wrapped_ct, wrap_nonce, wrap_tag = Aead.encrypt(dek, @kek.bytes, wrap_aad)
packed_wrap = pack(wrap_nonce, wrap_tag, wrapped_ct)
SealedSecret.new(
ciphertext: packed_ct,
dek_wrapped: packed_wrap,
kek_version: @kek.version,
algorithm_id: ALGORITHM_AES_256_GCM,
)
end
def open(sealed : SealedSecret, aad : Bytes) : Bytes
raise "unsupported algorithm_id #{sealed.algorithm_id}" unless sealed.algorithm_id == ALGORITHM_AES_256_GCM
raise "kek version mismatch (sealed=#{sealed.kek_version}, kek=#{@kek.version})" unless sealed.kek_version == @kek.version
wrap_nonce, wrap_tag, wrapped = unpack(sealed.dek_wrapped)
wrap_aad = "kek-wrap|v#{@kek.version}".to_slice
dek = Aead.decrypt(wrapped, @kek.bytes, wrap_aad, wrap_nonce, wrap_tag)
nonce, tag, ct = unpack(sealed.ciphertext)
Aead.decrypt(ct, dek, aad, nonce, tag)
end
private def pack(nonce : Bytes, tag : Bytes, body : Bytes) : Bytes
io = IO::Memory.new(nonce.size + tag.size + body.size)
io.write(nonce); io.write(tag); io.write(body)
io.to_slice
end
private def unpack(packed : Bytes) : {Bytes, Bytes, Bytes}
n = Aead::NONCE_SIZE
t = Aead::TAG_SIZE
raise "packed too small" if packed.size < n + t
nonce = packed[0, n]
tag = packed[n, t]
body = packed[n + t, packed.size - n - t]
{nonce, tag, body}
end
end
end

View File

@ -0,0 +1,33 @@
# ===================
# ©AngelaMos | 2026
# kek.cr
# ===================
module CRE::Crypto
module Kek
class InvalidKekError < Exception; end
abstract class Kek
abstract def bytes : Bytes
abstract def version : Int32
abstract def source : String
end
class EnvKek < Kek
getter bytes : Bytes
getter version : Int32
getter source : String
def initialize(env_var : String, @version : Int32)
@source = "env:#{env_var}"
hex = ENV[env_var]? || raise InvalidKekError.new("ENV[#{env_var}] not set")
raise InvalidKekError.new("expected 64-char hex (32 bytes), got #{hex.size}") unless hex.size == 64
@bytes = hex.hexbytes
end
protected def bytes_writer=(b : Bytes) : Nil
@bytes = b
end
end
end
end

View File

@ -0,0 +1,25 @@
# ===================
# ©AngelaMos | 2026
# random.cr
# ===================
require "random/secure"
module CRE::Crypto
module Random
def self.bytes(n : Int32) : Bytes
::Random::Secure.random_bytes(n)
end
def self.hex(n : Int32) : String
bytes(n).hexstring
end
def self.constant_time_equal?(a : Bytes, b : Bytes) : Bool
return false unless a.size == b.size
diff = 0_u8
a.size.times { |i| diff |= a[i] ^ b[i] }
diff == 0_u8
end
end
end