feat(audit): hash chain + HMAC ratchet + Merkle batches + Ed25519 signing
Three layers of integrity: - Hash chain (SHA-256 chained) catches silent edits - HMAC ratchet rotates keys every N entries with HKDF-style derivation and zeroizes old key bytes from memory - Merkle batch sealing builds tree over content_hashes, signs root with Ed25519 (FFI to libcrypto EVP_PKEY_ED25519 directly since stdlib lacks the high-level OpenSSL::PKey wrapper) AuditLog.append is mutex-guarded for serial writes; verify_chain detects both single-row tampering and structural inconsistency. BatchSealer's pack_message format (BE start_seq || BE end_seq || merkle_root) is shared between signer and verifier so external auditors can validate offline.
This commit is contained in:
parent
4723716e08
commit
fe5e9cd07b
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in New Issue