12 KiB
Implementation Walkthrough
This document points you at the most important code paths. Read it with tree src/ open in another window.
The Policy DSL (statically- and registration-time validated)
src/cre/policy/dsl.cr declares the DSL inside module CRE::Policy::Dsl. Consumers opt in explicitly:
require "cre/policy/dsl"
include CRE::Policy::Dsl
policy "production-aws-secrets" do
match { |c| c.kind.aws_secretsmgr? && c.tag(:env) == "prod" }
max_age 30.days
enforce :rotate_immediately
notify_via :telegram, :structured_log
end
with builder yield makes every Builder method callable receiver-less inside the block. Two flavors of typo-detection apply:
- Compile time —
enforce :rotate_immediatly(single Symbol arg →Actionenum) is rejected by Crystal's autocast.match { |c| c.kund }(typo on a Credential getter) breaks compilation pointing at the policy file. - Registration time —
notify_via :telegrm, :slakuses a splat-Symbol overload that runsChannel.parse?on each value and raisesBuilderError("unknown channel 'telegrm' in policy '<name>'")when the file is loaded. Splat autocast doesn't reach into Symbols, so this layer is the next-best thing.
Builder#build also raises BuilderError on missing required fields (matcher, max_age, enforce_action). Either way, a misformed policy never reaches a running daemon.
The Event Bus (fanout via Crystal channels)
src/cre/engine/event_bus.cr exposes subscribe(buffer:, overflow:) returning a Channel(Event). The run method spawns a single dispatcher fiber that reads from @inbox and forwards to each subscriber's channel.
dispatch uses Crystal's select for both overflow modes:
Drop—select … elsedrops the event when the buffer is full and logs a warn.Block—select … when timeout(@block_send_timeout)waits up to@block_send_timeout(default 5s) and only then drops, logging the stall. This isolates the bus from a stuck subscriber: head-of-line blocking is bounded.
The Rotator Plugin Registration
src/cre/rotators/rotator.cr declares an abstract base with a class-level REGISTRY = {} of Symbol => Rotator.class. The macro register_as populates this at compile time:
abstract class Rotator
REGISTRY = {} of Symbol => Rotator.class
macro register_as(kind)
::CRE::Rotators::Rotator::REGISTRY[{{ kind }}] = self
end
end
When a file like src/cre/rotators/aws_secrets.cr is required, the register_as :aws_secretsmgr line runs at compile time and the class shows up in Rotator::REGISTRY[:aws_secretsmgr]. No central list to maintain.
The 4-step Orchestrator
src/cre/engine/rotation_orchestrator.cr runs the contract:
generate -> rotator-specific (often produces the new value + cloud version_id)
apply -> rotator-specific (often no-op for cloud rotators where generate already exposed)
verify -> read back, byte-equal check
commit -> promote new -> AWSCURRENT, demote old -> AWSPREVIOUS
Each step publishes RotationStepStarted and either RotationStepCompleted or RotationStepFailed to the bus.
Failure handling has two regimes:
- apply or verify fails —
rotator.rollback_apply(c, new_secret)reverses the cloud-side mutation; rotation moves toFailed; bus emitsRotationStepFailed+RotationFailed. - commit fails — partial cross-call commit sequences (e.g., AWS
UpdateSecretVersionStage5xx half-way through) cannot be reliably reversed client-side. The rotation transitions toInconsistent(a terminal state distinct fromFailed), and the orchestrator emits a criticalAlertRaisedso operators know to intervene.
Success path is now the heavyweight one: when the four steps complete, the orchestrator
- seals
new_secret.ciphertextwith the optionalCrypto::Envelope(AES-256-GCM, AAD =cred=<id>|kind=<k>), - inserts a
credential_versionsrow with the wrapped DEK + KEK version, - updates the credential row to bump
last_rotated_at, setcurrent_version_idto the new version's id, and demote the old one toprevious_version_id.
That last step is what stops the policy evaluator from re-scheduling the same rotation on every tick — Policy#overdue? keys on c.rotation_anchor (which is last_rotated_at || created_at), not on updated_at.
The orchestrator never directly calls audit. Audit happens automatically because AuditSubscriber is on the bus listening for these exact event types — the orchestrator can't forget to log. And because the orchestrator's path is the only path that runs versions.insert + credentials.update, persistence-side state stays consistent with the audit-log narrative.
SigV4 Signer (the AWS-flavored work)
src/cre/aws/signer.cr implements RFC-style AWS SigV4:
canonical_request = method + canonical_uri + canonical_query +
canonical_headers + signed_headers + payload_hash
string_to_sign = "AWS4-HMAC-SHA256\n" + amz_date + "\n" +
credential_scope + "\n" + sha256(canonical_request)
signing_key = HMAC chain (kSecret -> kDate -> kRegion -> kService -> kSigning)
signature = HMAC(signing_key, string_to_sign)
The Authorization header is built from algorithm + Credential=... + SignedHeaders=... + Signature=.... Includes X-Amz-Security-Token when an STS session token is supplied.
Two test files cover the signer:
spec/unit/aws/signer_spec.cr— idempotence + Authorization-header regex shape.spec/unit/aws/signer_aws_vector_spec.cr— uses the AWS reference suite'sget-vanillainputs (access key, secret, region, service, fixed timestamp) and locks in a regression vector for the exact signature our signer produces. Because we always emitX-Amz-Content-SHA256(required by Secrets Manager and other JSON-protocol services), the signed-headers list ishost;x-amz-content-sha256;x-amz-date— slightly different from AWS's vanilla vector, so we lock in our own bytes rather than match theirs. Any future change to canonicalization, key derivation, or header ordering trips the test.
Audit Log Integrity (three-layer)
src/cre/audit/audit_log.cr writes Layers 1 + 2 on every append:
latest_hashfrom the DB (genesis = 32 zero bytes for an empty log)content_hash = HashChain.next_hash(prev_hash, canonical_payload)hmac = HmacRatchet#sign(content_hash); ratchet rolls every 1024 rows- All three columns plus
hmac_key_versionget persisted in oneINSERTper row
Verification is split into three independently-callable methods:
verify_hash_chain— walks every entry, recomputesSHA256(prev_hash || payload), compares againstcontent_hash.verify_hmac_ratchet(seed_key)— replays the ratchet from the seedCRE_HMAC_KEY_HEX, recomputes each row's HMAC againstcontent_hash, and checkshmac_key_versionmatches the ratchet's view of where rotation should be. Catches an attacker who fixed up the hash chain but doesn't have the seed.verify_batches(verifier)— for every row inaudit_batches, refetch the correspondingcontent_hashleaves, recompute the Merkle root, then verify the Ed25519 signature over(start_seq, end_seq, root).
src/cre/audit/batch_sealer.cr builds Layer 3 entries:
- Walk new audit_events since
last_sealed_seq - Build a Merkle tree (
Merkle.root) over each row'scontent_hash - Sign
(start_seq, end_seq, root)with Ed25519 viaSigning::Ed25519Signer - Insert into
audit_batches
src/cre/audit/batch_sealer_scheduler.cr is the fiber that actually drives the sealer in cre run / cre watch: it calls seal_pending once on start, again every CRE_SEAL_INTERVAL_SECONDS (default 300s), and once more on shutdown. Every successful seal publishes a typed AuditBatchSealed event, which the audit subscriber writes back into the audit log under audit.batch.sealed — closing the loop with the SOC 2 / PCI-DSS / ISO / HIPAA control mapping that already keys on that event type.
Crystal's stdlib OpenSSL doesn't expose Ed25519 high-level wrappers, so src/cre/audit/signing.cr reaches into LibCrypto via FFI: EVP_PKEY_new_raw_private_key, EVP_DigestSign, etc. Public-key verification is symmetrical: Ed25519Verifier#verify(message, signature).
AEAD Envelope Encryption
src/cre/crypto/aead.cr does AES-256-GCM via LibCrypto FFI (stdlib OpenSSL::Cipher doesn't expose auth_data= / auth_tag for GCM). The envelope (src/cre/crypto/envelope.cr) generates a 32-byte DEK per row, encrypts plaintext with AES-256-GCM(plaintext, DEK, AAD), then wraps the DEK with KEK using a separate AEAD (with its own AAD kek-wrap|v<version>). Both ciphertexts are nonce(12) || tag(16) || body.
Decrypting requires the KEK to unwrap the DEK, then the DEK + AAD to decrypt the payload. AAD mismatch fails tag verification at the inner layer; KEK version mismatch fails at unwrap.
TUI
src/cre/tui/state.cr holds a rolling view of active rotations + recent events. apply(ev) is the single entry point that mutates state; pure update logic, easy to test.
src/cre/tui/renderer.cr paints the four panels to any IO. ANSI escapes via src/cre/tui/ansi.cr (stdlib only). The renderer's pad helper accounts for ANSI escape widths so column alignment is correct under colors.
src/cre/tui/tui.cr ties it together: subscribes to the bus (Drop overflow), spawns a tick fiber + an event fiber, both calling maybe_render which throttles to refresh_interval.
Telegram Bot
src/cre/notifiers/telegram.cr is a thin HTTP::Client wrapper for the Telegram Bot API (no tourmaline dependency for the notification path). Errors get the bot token redacted before they hit logs — Telegram requires the token in the URL path, so the redaction is best-effort, but it stops the obvious leak.
src/cre/notifiers/telegram_bot.cr does long-poll getUpdates and dispatches commands. Auth is by chat-ID allowlist; viewer tier (/status, /queue, /history, /alerts) is read-only; operator tier adds /rotate. /rotate <id> publishes RotationScheduled to the bus, which the RotationWorker consumes (see src/cre/engine/rotation_worker.cr) — the worker resolves the credential, looks up the right Rotator from the env-driven dispatch table, checks rotations.in_flight to dedupe, and hands off to RotationOrchestrator.
Persistence Layer Shape
src/cre/persistence/repos.cr declares the abstract repos (CredentialsRepo, VersionsRepo, RotationsRepo, AuditRepo) and the record types (RotationRecord, AuditEntry, AuditBatch, plus the RotatorKind and RotationState enums). RotationState::Inconsistent is included in TERMINAL_STATES alongside Completed / Failed / Aborted.
Both adapters apply schema changes through Migrations::Step records keyed on a monotonic version. The schema_migrations table tracks which versions have run; new alterations land as new Step.new(N, ["ALTER TABLE ..."]) entries instead of editing the soup of IF NOT EXISTS statements.
audit_events is the most carefully guarded table in the schema:
- Postgres has the original
audit_events_no_updatetrigger (raisesaudit_events is append-onlyon UPDATE/DELETE/TRUNCATE). - SQLite gets parity via two
BEFORE UPDATE/BEFORE DELETEtriggers usingRAISE(ABORT, '...'). - The repo's
INSERTno longer usesOR IGNORE/ON CONFLICT … DO NOTHING, so a constraint failure raises into the application instead of silently dropping. - The audit subscriber's rescue path publishes a critical
AlertRaisedand (by default) panics the process viaCRE_AUDIT_FAILURE_MODE=panic.
Two independent layers; both must be subverted to forge history.