diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/audit/audit_log_spec.cr b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/audit/audit_log_spec.cr new file mode 100644 index 00000000..690c08e9 --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/audit/audit_log_spec.cr @@ -0,0 +1,62 @@ +# =================== +# ©AngelaMos | 2026 +# audit_log_spec.cr +# =================== + +require "../../spec_helper" +require "../../../src/cre/audit/audit_log" +require "../../../src/cre/persistence/sqlite/sqlite_persistence" + +describe CRE::Audit::AuditLog do + it "appends and reads back entries with valid hash chain" do + persist = CRE::Persistence::Sqlite::SqlitePersistence.new(":memory:") + persist.migrate! + log = CRE::Audit::AuditLog.new(persist, Bytes.new(32, 0_u8), 1, 1024) + + log.append("rotation.completed", "system", nil, {"x" => "y"}) + log.append("policy.violation", "system", nil, {"a" => "b"}) + + persist.audit.latest_seq.should eq 2_i64 + log.verify_chain.should be_true + ensure + persist.try(&.close) + end + + it "verify_chain detects when a row is tampered in DB directly" do + persist = CRE::Persistence::Sqlite::SqlitePersistence.new(":memory:") + persist.migrate! + log = CRE::Audit::AuditLog.new(persist, Bytes.new(32, 0_u8), 1, 1024) + + log.append("a", "s", nil, {"k" => "v"}) + log.append("b", "s", nil, {"k" => "v2"}) + + persist.db.exec("UPDATE audit_events SET payload = ? WHERE seq = 2", %({"event_type":"b","actor":"s","target_id":null,"payload":{"k":"BAD"}})) + + log.verify_chain.should be_false + ensure + persist.try(&.close) + end + + it "verify_chain returns true on empty log" do + persist = CRE::Persistence::Sqlite::SqlitePersistence.new(":memory:") + persist.migrate! + log = CRE::Audit::AuditLog.new(persist, Bytes.new(32, 0_u8), 1, 1024) + log.verify_chain.should be_true + ensure + persist.try(&.close) + end + + it "ratchets version after configured threshold" do + persist = CRE::Persistence::Sqlite::SqlitePersistence.new(":memory:") + persist.migrate! + log = CRE::Audit::AuditLog.new(persist, Bytes.new(32, 0_u8), 1, ratchet_every: 2) + + log.append("a", "s", nil, {"i" => "1"}) + log.append("a", "s", nil, {"i" => "2"}) + log.ratchet_version.should eq 1 + log.append("a", "s", nil, {"i" => "3"}) + log.ratchet_version.should eq 2 + ensure + persist.try(&.close) + end +end diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/audit/batch_sealer_spec.cr b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/audit/batch_sealer_spec.cr new file mode 100644 index 00000000..d1f1e2e0 --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/audit/batch_sealer_spec.cr @@ -0,0 +1,73 @@ +# =================== +# ©AngelaMos | 2026 +# batch_sealer_spec.cr +# =================== + +require "../../spec_helper" +require "../../../src/cre/audit/audit_log" +require "../../../src/cre/audit/batch_sealer" +require "../../../src/cre/audit/signing" +require "../../../src/cre/persistence/sqlite/sqlite_persistence" + +describe CRE::Audit::BatchSealer do + it "seals pending audit events into a signed Merkle batch" do + persist = CRE::Persistence::Sqlite::SqlitePersistence.new(":memory:") + persist.migrate! + + log = CRE::Audit::AuditLog.new(persist, Bytes.new(32, 0_u8), 1, 1024) + log.append("a", "s", nil, {"i" => "1"}) + log.append("b", "s", nil, {"i" => "2"}) + log.append("c", "s", nil, {"i" => "3"}) + + kp = CRE::Audit::Signing::Ed25519Keypair.generate(version: 1) + signer = CRE::Audit::Signing::Ed25519Signer.from_keypair(kp) + sealer = CRE::Audit::BatchSealer.new(persist, signer) + + batch = sealer.seal_pending.not_nil! + batch.start_seq.should eq 1_i64 + batch.end_seq.should eq 3_i64 + batch.signing_key_version.should eq 1 + batch.merkle_root.size.should eq 32 + batch.signature.size.should eq 64 + + verifier = CRE::Audit::Signing::Ed25519Verifier.new(kp.public_key) + msg = CRE::Audit::BatchSealer.pack_message(batch.start_seq, batch.end_seq, batch.merkle_root) + verifier.verify(msg, batch.signature).should be_true + + persist.audit.last_sealed_seq.should eq 3_i64 + ensure + persist.try(&.close) + end + + it "returns nil when nothing pending" do + persist = CRE::Persistence::Sqlite::SqlitePersistence.new(":memory:") + persist.migrate! + + kp = CRE::Audit::Signing::Ed25519Keypair.generate + signer = CRE::Audit::Signing::Ed25519Signer.from_keypair(kp) + sealer = CRE::Audit::BatchSealer.new(persist, signer) + sealer.seal_pending.should be_nil + ensure + persist.try(&.close) + end + + it "subsequent seal covers only new events" do + persist = CRE::Persistence::Sqlite::SqlitePersistence.new(":memory:") + persist.migrate! + log = CRE::Audit::AuditLog.new(persist, Bytes.new(32, 0_u8), 1, 1024) + kp = CRE::Audit::Signing::Ed25519Keypair.generate + signer = CRE::Audit::Signing::Ed25519Signer.from_keypair(kp) + sealer = CRE::Audit::BatchSealer.new(persist, signer) + + log.append("a", "s", nil, {"i" => "1"}) + log.append("b", "s", nil, {"i" => "2"}) + sealer.seal_pending.not_nil!.end_seq.should eq 2_i64 + + log.append("c", "s", nil, {"i" => "3"}) + second = sealer.seal_pending.not_nil! + second.start_seq.should eq 3_i64 + second.end_seq.should eq 3_i64 + ensure + persist.try(&.close) + end +end diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/audit/hash_chain_spec.cr b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/audit/hash_chain_spec.cr new file mode 100644 index 00000000..278a087f --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/audit/hash_chain_spec.cr @@ -0,0 +1,63 @@ +# =================== +# ©AngelaMos | 2026 +# hash_chain_spec.cr +# =================== + +require "../../spec_helper" +require "../../../src/cre/audit/hash_chain" + +describe CRE::Audit::HashChain do + it "first hash is 32 zero bytes" do + g = CRE::Audit::HashChain.genesis + g.size.should eq 32 + g.all? { |b| b == 0_u8 }.should be_true + end + + it "next_hash is deterministic" do + prev = CRE::Audit::HashChain.genesis + payload = "hello".to_slice + a = CRE::Audit::HashChain.next_hash(prev, payload) + b = CRE::Audit::HashChain.next_hash(prev, payload) + a.should eq b + a.size.should eq 32 + end + + it "different prev produces different hash" do + payload = "x".to_slice + a = CRE::Audit::HashChain.next_hash(Bytes.new(32, 0_u8), payload) + b = CRE::Audit::HashChain.next_hash(Bytes.new(32, 1_u8), payload) + a.should_not eq b + end + + it "different payload produces different hash" do + prev = CRE::Audit::HashChain.genesis + a = CRE::Audit::HashChain.next_hash(prev, "a".to_slice) + b = CRE::Audit::HashChain.next_hash(prev, "b".to_slice) + a.should_not eq b + end + + it "verify_chain detects tampering" do + pairs = [] of {Bytes, Bytes} + h = CRE::Audit::HashChain.genesis + payloads = ["a", "b", "c", "d"].map(&.to_slice) + payloads.each do |p| + next_h = CRE::Audit::HashChain.next_hash(h, p) + pairs << {h, next_h} + h = next_h + end + + CRE::Audit::HashChain.verify(pairs, payloads).should be_true + + tampered = payloads.dup + tampered[2] = "BAD".to_slice + CRE::Audit::HashChain.verify(pairs, tampered).should be_false + end + + it "verify_chain returns true for empty input" do + CRE::Audit::HashChain.verify([] of {Bytes, Bytes}, [] of Bytes).should be_true + end + + it "verify_chain returns false on mismatched array sizes" do + CRE::Audit::HashChain.verify([] of {Bytes, Bytes}, ["x".to_slice]).should be_false + end +end diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/audit/hmac_ratchet_spec.cr b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/audit/hmac_ratchet_spec.cr new file mode 100644 index 00000000..8cfd0195 --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/audit/hmac_ratchet_spec.cr @@ -0,0 +1,43 @@ +# =================== +# ©AngelaMos | 2026 +# hmac_ratchet_spec.cr +# =================== + +require "../../spec_helper" +require "../../../src/cre/audit/hmac_ratchet" + +describe CRE::Audit::HmacRatchet do + it "produces 32-byte HMAC" do + r = CRE::Audit::HmacRatchet.new(Bytes.new(32, 0_u8), version: 1, ratchet_every: 1024) + h = r.sign("payload".to_slice) + h.size.should eq 32 + r.version.should eq 1 + end + + it "rotates after N entries" do + r = CRE::Audit::HmacRatchet.new(Bytes.new(32, 0_u8), version: 1, ratchet_every: 3) + 3.times { r.sign("x".to_slice) } + r.version.should eq 1 # not yet rotated + r.sign("x".to_slice) + r.version.should eq 2 # rotated on the 4th call + end + + it "different keys produce different HMACs" do + a = CRE::Audit::HmacRatchet.new(Bytes.new(32, 0_u8), 1, 1024).sign("x".to_slice) + b = CRE::Audit::HmacRatchet.new(Bytes.new(32, 1_u8), 1, 1024).sign("x".to_slice) + a.should_not eq b + end + + it "rejects keys of incorrect size" do + expect_raises(ArgumentError) do + CRE::Audit::HmacRatchet.new(Bytes.new(16), 1, 1024) + end + end + + it "verify (static) round-trips" do + key = Bytes.new(32, 0xff_u8) + payload = "p".to_slice + h = OpenSSL::HMAC.digest(:sha256, key, payload) + CRE::Audit::HmacRatchet.verify(payload, h, key).should be_true + end +end diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/audit/merkle_spec.cr b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/audit/merkle_spec.cr new file mode 100644 index 00000000..4cd7dcce --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/audit/merkle_spec.cr @@ -0,0 +1,38 @@ +# =================== +# ©AngelaMos | 2026 +# merkle_spec.cr +# =================== + +require "../../spec_helper" +require "../../../src/cre/audit/merkle" + +describe CRE::Audit::Merkle do + it "single leaf root equals the leaf" do + leaf = "x".to_slice + CRE::Audit::Merkle.root([leaf]).should eq leaf + end + + it "balanced tree root is deterministic" do + leaves = ["a", "b", "c", "d"].map(&.to_slice) + r1 = CRE::Audit::Merkle.root(leaves) + r2 = CRE::Audit::Merkle.root(leaves) + r1.should eq r2 + r1.size.should eq 32 + end + + it "different leaves produce different roots" do + a = CRE::Audit::Merkle.root(["a", "b"].map(&.to_slice)) + b = CRE::Audit::Merkle.root(["a", "c"].map(&.to_slice)) + a.should_not eq b + end + + it "odd leaf count is supported (last is promoted)" do + leaves = ["a", "b", "c"].map(&.to_slice) + r = CRE::Audit::Merkle.root(leaves) + r.size.should eq 32 + end + + it "raises on empty input" do + expect_raises(ArgumentError) { CRE::Audit::Merkle.root([] of Bytes) } + end +end diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/audit/signing_spec.cr b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/audit/signing_spec.cr new file mode 100644 index 00000000..e3292c9d --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/audit/signing_spec.cr @@ -0,0 +1,55 @@ +# =================== +# ©AngelaMos | 2026 +# signing_spec.cr +# =================== + +require "../../spec_helper" +require "../../../src/cre/audit/signing" + +describe CRE::Audit::Signing do + it "generates a keypair" do + kp = CRE::Audit::Signing::Ed25519Keypair.generate(version: 1) + kp.private_key.size.should eq 32 + kp.public_key.size.should eq 32 + kp.version.should eq 1 + end + + it "signs and verifies a message" do + kp = CRE::Audit::Signing::Ed25519Keypair.generate + signer = CRE::Audit::Signing::Ed25519Signer.from_keypair(kp) + verifier = CRE::Audit::Signing::Ed25519Verifier.new(kp.public_key) + + msg = "audit batch root".to_slice + sig = signer.sign(msg) + sig.size.should eq 64 + verifier.verify(msg, sig).should be_true + end + + it "rejects tampered message" do + kp = CRE::Audit::Signing::Ed25519Keypair.generate + signer = CRE::Audit::Signing::Ed25519Signer.from_keypair(kp) + verifier = CRE::Audit::Signing::Ed25519Verifier.new(kp.public_key) + + msg = "original".to_slice + sig = signer.sign(msg) + verifier.verify("tampered".to_slice, sig).should be_false + end + + it "rejects tampered signature" do + kp = CRE::Audit::Signing::Ed25519Keypair.generate + signer = CRE::Audit::Signing::Ed25519Signer.from_keypair(kp) + verifier = CRE::Audit::Signing::Ed25519Verifier.new(kp.public_key) + + msg = "x".to_slice + sig = signer.sign(msg) + sig[0] ^= 0x01_u8 + verifier.verify(msg, sig).should be_false + end + + it "two keypairs produce different keys" do + a = CRE::Audit::Signing::Ed25519Keypair.generate + b = CRE::Audit::Signing::Ed25519Keypair.generate + a.private_key.should_not eq b.private_key + a.public_key.should_not eq b.public_key + end +end diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/audit/audit_log.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/audit/audit_log.cr new file mode 100644 index 00000000..bbca72b3 --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/audit/audit_log.cr @@ -0,0 +1,71 @@ +# =================== +# ©AngelaMos | 2026 +# audit_log.cr +# =================== + +require "json" +require "uuid" +require "./hash_chain" +require "./hmac_ratchet" +require "../persistence/persistence" +require "../persistence/repos" + +module CRE::Audit + class AuditLog + @ratchet : HmacRatchet + @mutex : Mutex + + def initialize(@persistence : Persistence::Persistence, initial_hmac_key : Bytes, @hmac_version : Int32, @ratchet_every : Int32) + @ratchet = HmacRatchet.new(initial_hmac_key, @hmac_version, @ratchet_every) + @mutex = Mutex.new + end + + def append(event_type : String, actor : String, target_id : UUID?, payload : Hash) : Persistence::AuditEntry + @mutex.synchronize do + prev = @persistence.audit.latest_hash + canonical = canonical_json(event_type, actor, target_id, payload) + content_hash = HashChain.next_hash(prev, canonical.to_slice) + hmac = @ratchet.sign(content_hash) + + entry = Persistence::AuditEntry.new( + seq: 0_i64, + event_id: UUID.random, + occurred_at: Time.utc, + event_type: event_type, + actor: actor, + target_id: target_id, + payload: canonical, + prev_hash: prev, + content_hash: content_hash, + hmac: hmac, + hmac_key_version: @ratchet.version, + ) + @persistence.audit.append(entry) + entry + end + end + + def verify_chain : Bool + latest = @persistence.audit.latest_seq + return true if latest == 0 + entries = @persistence.audit.range(1_i64, latest) + return false if entries.size != latest + pairs = entries.map { |e| {e.prev_hash, e.content_hash} } + payloads = entries.map(&.payload).map(&.to_slice) + HashChain.verify(pairs, payloads) + end + + def ratchet_version : Int32 + @ratchet.version + end + + private def canonical_json(event_type, actor, target_id, payload) : String + { + event_type: event_type, + actor: actor, + target_id: target_id.try(&.to_s), + payload: payload, + }.to_json + end + end +end diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/audit/batch_sealer.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/audit/batch_sealer.cr new file mode 100644 index 00000000..58f1ebac --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/audit/batch_sealer.cr @@ -0,0 +1,61 @@ +# =================== +# ©AngelaMos | 2026 +# batch_sealer.cr +# =================== + +require "uuid" +require "./merkle" +require "./signing" +require "../persistence/persistence" +require "../persistence/repos" + +module CRE::Audit + class BatchSealer + def initialize( + @persistence : Persistence::Persistence, + @signer : Signing::Ed25519Signer, + ) + end + + def seal_pending : Persistence::AuditBatch? + latest = @persistence.audit.latest_seq + last_end = @persistence.audit.last_sealed_seq + return nil if latest <= last_end + + start_seq = last_end + 1 + end_seq = latest + entries = @persistence.audit.range(start_seq, end_seq) + return nil if entries.empty? + + leaves = entries.map(&.content_hash) + root = Merkle.root(leaves) + + msg = pack_message(start_seq, end_seq, root) + sig = @signer.sign(msg) + + batch = Persistence::AuditBatch.new( + id: UUID.random, + start_seq: start_seq, + end_seq: end_seq, + merkle_root: root, + signature: sig, + signing_key_version: @signer.version, + sealed_at: Time.utc, + ) + @persistence.audit.insert_batch(batch) + batch + end + + def self.pack_message(start_seq : Int64, end_seq : Int64, root : Bytes) : Bytes + io = IO::Memory.new + io.write_bytes(start_seq, IO::ByteFormat::BigEndian) + io.write_bytes(end_seq, IO::ByteFormat::BigEndian) + io.write(root) + io.to_slice + end + + private def pack_message(start_seq : Int64, end_seq : Int64, root : Bytes) : Bytes + BatchSealer.pack_message(start_seq, end_seq, root) + end + end +end diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/audit/hash_chain.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/audit/hash_chain.cr new file mode 100644 index 00000000..3d8f1948 --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/audit/hash_chain.cr @@ -0,0 +1,34 @@ +# =================== +# ©AngelaMos | 2026 +# hash_chain.cr +# =================== + +require "openssl/digest" +require "../crypto/random" + +module CRE::Audit + module HashChain + GENESIS_SIZE = 32 + + def self.genesis : Bytes + Bytes.new(GENESIS_SIZE, 0_u8) + end + + def self.next_hash(prev_hash : Bytes, payload : Bytes) : Bytes + d = OpenSSL::Digest.new("SHA256") + d.update(prev_hash) + d.update(payload) + d.final + end + + def self.verify(pairs : Array({Bytes, Bytes}), payloads : Array(Bytes)) : Bool + return false unless pairs.size == payloads.size + pairs.each_with_index do |entry, i| + prev, current = entry + expected = next_hash(prev, payloads[i]) + return false unless CRE::Crypto::Random.constant_time_equal?(expected, current) + end + true + end + end +end diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/audit/hmac_ratchet.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/audit/hmac_ratchet.cr new file mode 100644 index 00000000..2dd5d0ce --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/audit/hmac_ratchet.cr @@ -0,0 +1,52 @@ +# =================== +# ©AngelaMos | 2026 +# hmac_ratchet.cr +# =================== + +require "openssl/hmac" +require "openssl/digest" + +module CRE::Audit + class HmacRatchet + getter version : Int32 + + @key : Bytes + @counter : Int32 + + def initialize(initial_key : Bytes, @version : Int32, @ratchet_every : Int32) + raise ArgumentError.new("key must be 32 bytes") unless initial_key.size == 32 + @key = initial_key.dup + @counter = 0 + end + + def sign(payload : Bytes) : Bytes + maybe_rotate + h = OpenSSL::HMAC.digest(:sha256, @key, payload) + @counter += 1 + h + end + + def self.verify(payload : Bytes, expected : Bytes, key : Bytes) : Bool + h = OpenSSL::HMAC.digest(:sha256, key, payload) + CRE::Crypto::Random.constant_time_equal?(h, expected) + end + + def current_key : Bytes + @key.dup + end + + private def maybe_rotate : Nil + return unless @counter >= @ratchet_every + + d = OpenSSL::Digest.new("SHA256") + d.update(@key) + d.update("ratchet-v#{@version + 1}".to_slice) + new_key = d.final + + @key.size.times { |i| @key[i] = 0_u8 } + @key = new_key + @version += 1 + @counter = 0 + end + end +end diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/audit/merkle.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/audit/merkle.cr new file mode 100644 index 00000000..5b177b7d --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/audit/merkle.cr @@ -0,0 +1,36 @@ +# =================== +# ©AngelaMos | 2026 +# merkle.cr +# =================== + +require "openssl/digest" + +module CRE::Audit + module Merkle + def self.root(leaves : Array(Bytes)) : Bytes + raise ArgumentError.new("empty merkle tree") if leaves.empty? + level = leaves.dup + while level.size > 1 + next_level = [] of Bytes + i = 0 + while i < level.size + if i + 1 < level.size + next_level << combine(level[i], level[i + 1]) + else + next_level << level[i] + end + i += 2 + end + level = next_level + end + level[0] + end + + private def self.combine(a : Bytes, b : Bytes) : Bytes + d = OpenSSL::Digest.new("SHA256") + d.update(a) + d.update(b) + d.final + end + end +end diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/audit/signing.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/audit/signing.cr new file mode 100644 index 00000000..780111eb --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/audit/signing.cr @@ -0,0 +1,116 @@ +# =================== +# ©AngelaMos | 2026 +# signing.cr +# =================== + +require "openssl" +require "openssl/lib_crypto" + +lib LibCrypto + type EVP_PKEY = Void* + + fun evp_pkey_new_raw_private_key_cre = EVP_PKEY_new_raw_private_key(type : LibC::Int, e : Void*, key : UInt8*, keylen : LibC::SizeT) : EVP_PKEY + fun evp_pkey_new_raw_public_key_cre = EVP_PKEY_new_raw_public_key(type : LibC::Int, e : Void*, key : UInt8*, keylen : LibC::SizeT) : EVP_PKEY + fun evp_pkey_get_raw_public_key_cre = EVP_PKEY_get_raw_public_key(pkey : EVP_PKEY, pub : UInt8*, len : LibC::SizeT*) : LibC::Int + fun evp_pkey_free_cre = EVP_PKEY_free(pkey : EVP_PKEY) : Void + fun evp_digestsigninit_cre = EVP_DigestSignInit(ctx : EVP_MD_CTX, pctx : Void*, type : EVP_MD, e : Void*, pkey : EVP_PKEY) : LibC::Int + fun evp_digestsign_cre = EVP_DigestSign(ctx : EVP_MD_CTX, sigret : UInt8*, siglen : LibC::SizeT*, tbs : UInt8*, tbslen : LibC::SizeT) : LibC::Int + fun evp_digestverifyinit_cre = EVP_DigestVerifyInit(ctx : EVP_MD_CTX, pctx : Void*, type : EVP_MD, e : Void*, pkey : EVP_PKEY) : LibC::Int + fun evp_digestverify_cre = EVP_DigestVerify(ctx : EVP_MD_CTX, sig : UInt8*, siglen : LibC::SizeT, tbs : UInt8*, tbslen : LibC::SizeT) : LibC::Int +end + +module CRE::Audit::Signing + NID_ED25519 = 1087 + ED25519_KEY_SIZE = 32 + ED25519_SIG_SIZE = 64 + ED25519_PUBKEY_SIZE = 32 + + class Error < OpenSSL::Error; end + + class Ed25519Keypair + getter version : Int32 + getter private_key : Bytes + getter public_key : Bytes + + def initialize(@private_key : Bytes, @public_key : Bytes, @version : Int32) + raise ArgumentError.new("private key must be 32 bytes") unless @private_key.size == ED25519_KEY_SIZE + raise ArgumentError.new("public key must be 32 bytes") unless @public_key.size == ED25519_PUBKEY_SIZE + end + + def self.generate(version : Int32 = 1) : Ed25519Keypair + private_key = ::Random::Secure.random_bytes(ED25519_KEY_SIZE) + pkey = LibCrypto.evp_pkey_new_raw_private_key_cre(NID_ED25519, Pointer(Void).null, private_key.to_unsafe, ED25519_KEY_SIZE.to_u64) + raise Error.new("EVP_PKEY_new_raw_private_key failed") if pkey.null? + begin + pubkey_buf = Bytes.new(ED25519_PUBKEY_SIZE) + len = ED25519_PUBKEY_SIZE.to_u64 + rc = LibCrypto.evp_pkey_get_raw_public_key_cre(pkey, pubkey_buf.to_unsafe, pointerof(len)) + raise Error.new("EVP_PKEY_get_raw_public_key failed (rc=#{rc})") unless rc == 1 + Ed25519Keypair.new(private_key, pubkey_buf, version) + ensure + LibCrypto.evp_pkey_free_cre(pkey) + end + end + end + + class Ed25519Signer + getter version : Int32 + + def initialize(@private_key : Bytes, @version : Int32) + raise ArgumentError.new("private key must be 32 bytes") unless @private_key.size == ED25519_KEY_SIZE + end + + def self.from_keypair(kp : Ed25519Keypair) : Ed25519Signer + Ed25519Signer.new(kp.private_key, kp.version) + end + + def sign(message : Bytes) : Bytes + pkey = LibCrypto.evp_pkey_new_raw_private_key_cre(NID_ED25519, Pointer(Void).null, @private_key.to_unsafe, ED25519_KEY_SIZE.to_u64) + raise Error.new("EVP_PKEY_new_raw_private_key failed") if pkey.null? + ctx = LibCrypto.evp_md_ctx_new + raise Error.new("EVP_MD_CTX_new failed") if ctx.null? + begin + rc = LibCrypto.evp_digestsigninit_cre(ctx, Pointer(Void).null, Pointer(Void).null.as(LibCrypto::EVP_MD), Pointer(Void).null, pkey) + raise Error.new("EVP_DigestSignInit failed (rc=#{rc})") unless rc == 1 + + siglen = ED25519_SIG_SIZE.to_u64 + sig = Bytes.new(ED25519_SIG_SIZE) + rc = LibCrypto.evp_digestsign_cre(ctx, sig.to_unsafe, pointerof(siglen), message.to_unsafe, message.size.to_u64) + raise Error.new("EVP_DigestSign failed (rc=#{rc})") unless rc == 1 + + sig + ensure + LibCrypto.evp_md_ctx_free(ctx) + LibCrypto.evp_pkey_free_cre(pkey) + end + end + end + + class Ed25519Verifier + def initialize(@public_key : Bytes) + raise ArgumentError.new("public key must be 32 bytes") unless @public_key.size == ED25519_PUBKEY_SIZE + end + + def verify(message : Bytes, signature : Bytes) : Bool + return false unless signature.size == ED25519_SIG_SIZE + + pkey = LibCrypto.evp_pkey_new_raw_public_key_cre(NID_ED25519, Pointer(Void).null, @public_key.to_unsafe, ED25519_PUBKEY_SIZE.to_u64) + return false if pkey.null? + ctx = LibCrypto.evp_md_ctx_new + if ctx.null? + LibCrypto.evp_pkey_free_cre(pkey) + return false + end + begin + rc = LibCrypto.evp_digestverifyinit_cre(ctx, Pointer(Void).null, Pointer(Void).null.as(LibCrypto::EVP_MD), Pointer(Void).null, pkey) + return false unless rc == 1 + + rc = LibCrypto.evp_digestverify_cre(ctx, signature.to_unsafe, signature.size.to_u64, message.to_unsafe, message.size.to_u64) + rc == 1 + ensure + LibCrypto.evp_md_ctx_free(ctx) + LibCrypto.evp_pkey_free_cre(pkey) + end + end + end +end