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:
parent
5971cbd33e
commit
4723716e08
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue