From 4723716e086861d081fe47ebd1446df9ff566c6b Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Wed, 29 Apr 2026 00:43:12 -0400 Subject: [PATCH] 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 --- .../spec/unit/crypto/aead_property_spec.cr | 24 +++++ .../spec/unit/crypto/aead_spec.cr | 50 +++++++++ .../spec/unit/crypto/envelope_spec.cr | 55 ++++++++++ .../spec/unit/crypto/kek_spec.cr | 35 ++++++ .../spec/unit/crypto/random_spec.cr | 46 ++++++++ .../src/cre/crypto/aead.cr | 100 ++++++++++++++++++ .../src/cre/crypto/envelope.cr | 72 +++++++++++++ .../src/cre/crypto/kek.cr | 33 ++++++ .../src/cre/crypto/random.cr | 25 +++++ 9 files changed, 440 insertions(+) create mode 100644 PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/crypto/aead_property_spec.cr create mode 100644 PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/crypto/aead_spec.cr create mode 100644 PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/crypto/envelope_spec.cr create mode 100644 PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/crypto/kek_spec.cr create mode 100644 PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/crypto/random_spec.cr create mode 100644 PROJECTS/intermediate/credential-rotation-enforcer/src/cre/crypto/aead.cr create mode 100644 PROJECTS/intermediate/credential-rotation-enforcer/src/cre/crypto/envelope.cr create mode 100644 PROJECTS/intermediate/credential-rotation-enforcer/src/cre/crypto/kek.cr create mode 100644 PROJECTS/intermediate/credential-rotation-enforcer/src/cre/crypto/random.cr diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/crypto/aead_property_spec.cr b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/crypto/aead_property_spec.cr new file mode 100644 index 00000000..9cf1a285 --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/crypto/aead_property_spec.cr @@ -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 diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/crypto/aead_spec.cr b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/crypto/aead_spec.cr new file mode 100644 index 00000000..d3e9942c --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/crypto/aead_spec.cr @@ -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 diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/crypto/envelope_spec.cr b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/crypto/envelope_spec.cr new file mode 100644 index 00000000..9c96569c --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/crypto/envelope_spec.cr @@ -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 diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/crypto/kek_spec.cr b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/crypto/kek_spec.cr new file mode 100644 index 00000000..d189af3c --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/crypto/kek_spec.cr @@ -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 diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/crypto/random_spec.cr b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/crypto/random_spec.cr new file mode 100644 index 00000000..3a4c1391 --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/crypto/random_spec.cr @@ -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 diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/crypto/aead.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/crypto/aead.cr new file mode 100644 index 00000000..797b1383 --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/crypto/aead.cr @@ -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 diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/crypto/envelope.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/crypto/envelope.cr new file mode 100644 index 00000000..7e9b911a --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/crypto/envelope.cr @@ -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 diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/crypto/kek.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/crypto/kek.cr new file mode 100644 index 00000000..54bb6f80 --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/crypto/kek.cr @@ -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 diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/crypto/random.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/crypto/random.cr new file mode 100644 index 00000000..c7b0f3d8 --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/crypto/random.cr @@ -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