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