feat: comprehensive audit-driven hardening pass

Closes 36 findings from the project audit. The headline win is correctness:
the daemon now actually does what the README claims, all three audit-log
integrity layers verify, and the rotation control loop terminates.

Critical fixes:
- Orchestrator bumps Credential.last_rotated_at on success and writes a
  sealed credential_versions row, so the policy evaluator stops re-firing
  the same rotation every tick
- Envelope encryption (AES-256-GCM, KEK-wrapped DEK, AAD-bound) is now
  wired into the rotation success path; the credential_versions table is
  no longer dead schema
- cre audit verify checks all three layers (hash chain + HMAC ratchet +
  Merkle batches) instead of only the chain
- BatchSealerScheduler runs every 300s and on shutdown so audit_batches
  actually fills with signed Merkle roots
- Compliance bundle exports the real audit_batches list and covers
  public_key.pem under the manifest's checksum
- cre run/watch hard-fail without CRE_HMAC_KEY_HEX + CRE_KEK_HEX rather
  than silently using zero defaults

Quality fixes:
- HTTP::Client wrapper with connect/read timeouts + bounded retry-with-
  jitter on 408/429/5xx; Telegram error logs redact the bot token
- EventBus dispatch isolates Block subscribers behind select+timeout so
  one stuck subscriber can't pin the bus
- RotationWorker checks rotations.in_flight before dispatching to dedupe
  duplicate schedules; PG enforces it at the DB via partial unique index
- Commit-step failures transition rotations to Inconsistent (a terminal
  state) and raise a critical alert instead of pretending success
- env_file rotator uses per-PID pending paths plus an exclusive flock on
  a sibling .lock file so two daemons can't race on the rename
- Versioned migration runner replaces the soup of CREATE IF NOT EXISTS;
  SQLite gains BEFORE UPDATE / BEFORE DELETE triggers on audit_events
- AuditSubscriber publishes a critical alert + panics by default when
  log writes fail, instead of silently dropping
- Engine.stop drains via an ack channel with a 2s deadline rather than
  the previous magic 50ms sleep
- Policy DSL moved into CRE::Policy::Dsl module (consumers `include` it);
  multiple matching policies raise PolicyConflictError
- Evaluator uses credentials.overdue() per-max_age group instead of
  loading every credential each tick
- AuditRepo gains each_in_range streaming and all_batches enumeration;
  bundle export streams instead of buffering
- New cre verify-bundle <zip> CLI re-runs every check the bundle README
  documents; new cre tui-demo for synthetic event preview

Tests: 179 unit (up from 159), all passing. New rotation_worker_spec,
new SigV4 regression vector, new orchestrator success-path coverage
(envelope write + last_rotated_at bump + Inconsistent state).

Docs: README + learn/00..04 aligned with the post-audit behavior;
removed /snooze (was a stub) and the AwsIamKey/Database CredentialKind
variants that had no rotators. Added required-env-var documentation.
This commit is contained in:
CarterPerez-dev 2026-04-29 03:35:10 -04:00
parent 99d89b3050
commit 91199476e1
55 changed files with 2216 additions and 521 deletions

View File

@ -29,7 +29,7 @@ README.md
- Three-layer tamper-evident audit log: SHA-256 hash chain + ratcheting HMAC-SHA256 + Ed25519-signed Merkle batches
- AEAD envelope encryption (AES-256-GCM, per-row DEKs wrapped by KEK, AAD-bound to credential identity, reserved `algorithm_id` byte for crypto agility)
- Hand-rolled live TUI (no external TUI framework — stdlib ANSI escapes only) with event-driven repaints coalesced to a tick interval
- Bidirectional Telegram bot — viewer tier (`/status`, `/queue`, `/history`) + operator tier (`/rotate`, `/snooze`)
- Bidirectional Telegram bot — viewer tier (`/status`, `/queue`, `/history`, `/alerts`) + operator tier (`/rotate`)
- Compliance evidence export bundle (signed ZIP with audit log, Merkle batches, control mapping for SOC 2 / PCI-DSS / ISO 27001 / HIPAA)
## Quick Start
@ -63,14 +63,25 @@ just demo-full-down # tear down the stack
### Daemon usage
`cre run` and `cre watch` require two 32-byte secrets — the seed key for the audit-log HMAC ratchet and the KEK that wraps per-row data keys. Generate fresh values once and store them somewhere durable (KMS, password manager, sealed env file):
```bash
cre run --db=sqlite:cre.db # headless daemon
cre watch --db=sqlite:cre.db # daemon + live TUI
cre check --db=sqlite:cre.db --output=json # one-shot CI gate
cre rotate <credential-id> # manual rotation
cre policy list # inspect compiled policies
cre audit verify # check hash chain integrity
cre export --framework=soc2 --out=evidence.zip # signed compliance bundle
export CRE_HMAC_KEY_HEX=$(openssl rand -hex 32)
export CRE_KEK_HEX=$(openssl rand -hex 32)
export CRE_SIGNING_KEY_HEX=$(openssl rand -hex 32) # optional: enables Merkle-batch sealing
```
Then:
```bash
cre run --db=sqlite:cre.db # headless daemon
cre watch --db=sqlite:cre.db # daemon + live TUI
cre check --db=sqlite:cre.db --output=json # one-shot CI gate (no key required)
cre rotate <credential-id> # manual rotation (uses same env-driven rotators as run)
cre policy list # inspect compiled policies
cre audit verify # hash chain + HMAC ratchet (+ Merkle if --public-key given)
cre export --framework=soc2 --out=evidence.zip # signed compliance bundle
cre verify-bundle evidence.zip # offline re-verify a bundle
```
`cre check` exits 1 when any credential violates its policy — drop into any CI pipeline.
@ -131,7 +142,7 @@ All long-lived components are fibers in one OS process. The bus is in-process (C
**Direct LibCrypto FFI** for AES-256-GCM AEAD (Crystal stdlib `OpenSSL::Cipher` lacks GCM auth_data/auth_tag) and Ed25519 signing (stdlib lacks high-level wrapper). Bindings live in `src/cre/crypto/aead.cr` and `src/cre/audit/signing.cr`.
**Testing:** stdlib `Spec` runner, 159+ unit tests + integration tests against real PostgreSQL via Docker.
**Testing:** stdlib `Spec` runner, 179+ unit tests + integration tests against real PostgreSQL via Docker.
## Configuration

View File

@ -11,7 +11,7 @@ A Crystal daemon that **tracks** credentials, **enforces** rotation policies as
| Concept | What you'll see in the code |
|---|---|
| Compile-time-checked policy DSL | `policies/*.cr` evaluated by the Crystal compiler; typo'd action symbols, missing fields, or bad credential property references all fail `crystal build` |
| Statically-validated policy DSL | `policies/*.cr` evaluated by the Crystal compiler; single-symbol enum args (`enforce :rotate_immediately`) and `match {}` block typos fail `crystal build`. Splat-symbol args (`notify_via :telegram, :slack`) and missing required fields raise `BuilderError` at policy registration time. Either way, a misformed policy never reaches a running daemon. |
| Bus + plugin architecture | Typed events fan out across Crystal channels; subscribers (audit, TUI, Telegram, log) react independently; rotators register at compile time via `register_as :kind` macro |
| 4-step rotation contract | `generate -> apply -> verify -> commit`, dual-version safe (AWSCURRENT / AWSPENDING analog), with rollback on failure between apply and commit |
| Tamper-evident audit log | SHA-256 hash chain + ratcheting HMAC-SHA256 + Ed25519-signed Merkle batches (3 independent layers of integrity) |
@ -58,21 +58,31 @@ Setup time: ~2 minutes (mostly image pulls).
### Tier 3 - Real Cloud
Copy `config/demo-full.cr.example` and edit env vars to point at your real AWS account / Vault server / GitHub. Run `cre run` headless or `cre watch` for the live TUI.
`cre run` and `cre watch` refuse to start without two 32-byte secrets:
```
export CRE_HMAC_KEY_HEX=$(openssl rand -hex 32) # audit log seed key
export CRE_KEK_HEX=$(openssl rand -hex 32) # envelope KEK
export CRE_SIGNING_KEY_HEX=$(openssl rand -hex 32) # optional: enables Merkle-batch sealing
```
Then copy `config/demo-full.cr.example`, set the AWS / Vault / GitHub env vars it documents, and run `cre run` headless or `cre watch` for the live TUI. Without `CRE_SIGNING_KEY_HEX`, the daemon still runs but skips Layer 3 of the audit log — `cre audit verify` will skip the Merkle layer too.
## Subcommand Cheat Sheet
| Command | Purpose |
|---|---|
| `cre run` | Headless daemon (production / systemd) |
| `cre watch` | Engine + live TUI in one process |
| `cre run` | Headless daemon (production / systemd) — requires `CRE_HMAC_KEY_HEX` + `CRE_KEK_HEX` |
| `cre watch` | Engine + live TUI in one process — same env requirements |
| `cre check` | One-shot policy eval, exit code by violations (CI-friendly) |
| `cre rotate <id>` | Manual rotation of a single credential |
| `cre rotate <id>` | Manual rotation of a single credential — uses the same env-driven rotators as `cre run` |
| `cre policy list` | List compiled-in policies |
| `cre policy show <name>` | Inspect one policy in detail |
| `cre export --framework=soc2` | Generate signed compliance evidence ZIP |
| `cre audit verify` | Verify hash chain + HMAC + Merkle batch signatures |
| `cre audit verify` | Hash chain + HMAC ratchet (Merkle layer adds when `--public-key=PATH` or `CRE_AUDIT_PUBLIC_KEY_HEX`) |
| `cre verify-bundle <zip>` | Offline re-verify of an evidence bundle (sha256 + manifest sig + chain + Merkle) |
| `cre demo` | Tier 1 zero-deps demo |
| `cre tui-demo` | 8-second TUI preview using synthetic events (no daemon, no DB) |
| `cre version` | Print version |
## Where to Read Next

View File

@ -80,12 +80,15 @@ Step 4 is the only **irreversible** step. By design. If step 4 fails partially (
Verification is exposed as a CLI command:
```
$ cre audit verify
✓ chain valid: 14,892 entries
✓ hmac ratchet: 14 generations traversed; all valid
✓ merkle batches: 24 sealed, all signatures verify against pubkey v1
$ cre audit verify --public-key=/etc/cre/audit_pubkey.hex
✓ hash chain: OK
✓ HMAC ratchet: OK
✓ Merkle batches: OK
✓ audit chain valid: 14892 entries
```
The HMAC ratchet replays from the seed key supplied via `CRE_HMAC_KEY_HEX`, so the verifier needs the *same* seed the writer used. Merkle batches are signed under `CRE_SIGNING_KEY_HEX` and verified with the matching public key — bundled in the `cre export` ZIP so an offline auditor can `cre verify-bundle` without DB access.
## Compliance Framework Coverage
The export bundle (`cre export --framework=soc2`) maps audit events to specific framework controls. Per `src/cre/compliance/control_mapping.cr`:

View File

@ -41,12 +41,15 @@ Fanout dispatch via Crystal channels. Each subscriber gets its own bounded chann
| Subscriber | Overflow | Reason |
|---|---|---|
| `AuditSubscriber` | `Block` | Never drop audit events; compliance requirement |
| `TuiSubscriber` | `Drop` | Stale UI is fine; can't block engine |
| `MetricsSubscriber` | `Drop` | Best-effort metrics |
| `TelegramSubscriber` | `Drop` (large buffer) | Network-flaky anyway |
| `RotationOrchestrator` | `Block` | Must process scheduled rotations |
| `Tui` | `Drop` | Stale UI is fine; can't block engine |
| `LogNotifier` | `Drop` | Best-effort structured logs |
| `TelegramSubscriber` | `Drop` (buffer 128) | Network-flaky anyway |
| `RotationWorker` | `Block` (buffer 32) | Must dispatch scheduled rotations |
The dispatcher is a single fiber reading from the inbox channel and writing to all subscriber channels in order. A slow subscriber configured `Block` causes the dispatcher to block on that subscriber's `send` - which is exactly what you want for audit (better to backpressure than to lose).
The dispatcher is a single fiber reading from the inbox channel and writing to subscriber channels. Both overflow modes use `select` so a stuck subscriber can't pin the bus indefinitely:
- `Drop`: non-blocking `select … else …` — full buffer logs a warn and drops the event.
- `Block`: `select … when timeout(@block_send_timeout) …` — if a subscriber's buffer stays full past the timeout (default 5s), the bus drops that one event, logs the stall, and moves on. Operators tune the timeout up for slow downstreams they trust (audit DB writes) and down for unreliable ones.
### Rotators (`src/cre/rotators/`)
@ -66,10 +69,10 @@ Rotators receive their cloud client through their constructor (DI). The CLI `run
### Persistence (`src/cre/persistence/`)
Two adapters behind one interface:
- `Sqlite::SqlitePersistence` - WAL mode, single connection (avoids `:memory:` per-connection split), application-level mutex for advisory lock simulation. Used for Tier 1 demo.
- `Postgres::PostgresPersistence` - JSONB tags, BIGSERIAL audit, append-only triggers refusing UPDATE/DELETE/TRUNCATE on `audit_events`, `pg_advisory_xact_lock` for cross-process row locking. Used for Tier 2/3.
- `Sqlite::SqlitePersistence` - single connection (`max_pool_size=1`) so SQLite's writer-serialization is safe; `synchronous=NORMAL`; `BEFORE UPDATE` and `BEFORE DELETE` triggers on `audit_events` raise `audit_events is append-only`. Application-level mutex for advisory-lock simulation (the abstraction is in-process only on SQLite). Used for Tier 1 demo.
- `Postgres::PostgresPersistence` - JSONB tags, `BIGSERIAL` audit seq, `audit_events_no_update` trigger refusing UPDATE/DELETE/TRUNCATE, `pg_advisory_xact_lock` for cross-process locking, and a partial unique index on `rotations(credential_id) WHERE state NOT IN ('completed','failed','aborted','inconsistent')` so two daemons can never insert overlapping in-flight rotations for the same credential. Used for Tier 2/3.
Same repo contracts (`CredentialsRepo`, `VersionsRepo`, `RotationsRepo`, `AuditRepo`) for both. The `Persistence` superclass exposes `transaction(&)` and `with_advisory_lock(key, &)` so the rest of the system is backend-agnostic.
Both backends share the same migration runner (`schema_migrations` table + version-tracked `Step` records), so adding a column is a one-line `Step.new(N, ["ALTER TABLE ..."])` instead of editing a soup of `IF NOT EXISTS`. The repo contracts (`CredentialsRepo`, `VersionsRepo`, `RotationsRepo`, `AuditRepo`) are identical between adapters; `Persistence` exposes `transaction(&)` and `with_advisory_lock(key, &)` so the rest of the system stays backend-agnostic.
### Crypto layers (`src/cre/crypto/`, `src/cre/audit/`)
@ -150,25 +153,26 @@ The PG triggers are not strictly necessary (the chain catches tampering anyway),
| Scope | Bound | Mechanism |
|---|---|---|
| Per-credential | 1 active rotation | PG advisory lock keyed on `credential_id` (or per-process Mutex on SQLite) |
| Per-rotator-kind | configurable | `Channel(Nil).new(capacity: N)` semaphore |
| Global | 20 (default) | Global rotation worker pool |
| Per-credential | 1 active rotation | `RotationWorker` checks `rotations.in_flight` before dispatching; PG also enforces a partial unique index so cross-process duplicates fail at the DB |
| Per-rotation lifecycle | 1 step at a time | Orchestrator runs `generate -> apply -> verify -> commit` sequentially; `rollback_apply` fires on apply/verify failure; commit failure marks the rotation `inconsistent` and raises a critical alert |
| Engine event bus | per-subscriber buffer + timeout | `EventBus#dispatch` uses `select` for both Block and Drop overflow (see Bus subscribers table above) |
Crystal fibers + bounded channels = clean rate limiting without threads or locks.
## Lifecycle (cre run)
```
1. Load config (env + flags)
2. Open persistence (PG or SQLite); migrate!
3. Initialize crypto (load KEK from env or KMS)
4. Load + validate compiled-in policies (REGISTRY)
1. Bootstrap: validate CRE_HMAC_KEY_HEX + CRE_KEK_HEX (hard-fail if missing)
2. Open persistence (PG or SQLite); migrate! (versioned Step list)
3. Build Envelope from KEK; build optional Ed25519Signer from CRE_SIGNING_KEY_HEX
4. Load compiled-in policies (REGISTRY) + register rotators from env vars
5. Start EventBus.run (dispatcher fiber)
6. Start subscribers: audit, log, telegram, metrics
7. Start Scheduler (publishes SchedulerTick on tick)
8. Start PolicyEvaluator (subscribes to ticks + credential events)
9. Optionally start TUI (cre watch)
10. Block on signal: SIGTERM/SIGINT triggers graceful drain
6. Start subscribers: AuditSubscriber, LogNotifier, RotationWorker, PolicyEvaluator
7. Start Scheduler (SchedulerTick every CRE_TICK_SECONDS)
8. Start BatchSealerScheduler if signer present (default every 5min)
9. Wire Telegram bot if TELEGRAM_TOKEN + chat IDs are set
10. Optionally start TUI + Snapshotter (cre watch)
11. Block on stop_signal channel; SIGINT triggers graceful drain
```
Graceful shutdown: `engine.stop` publishes `ShutdownRequested`, gives subscribers ~50ms to flush, then closes the bus inbox and joins each subscriber fiber.
Graceful shutdown: `engine.stop` publishes `ShutdownRequested` and waits up to 2s on `audit_subscriber.await_drain` — the audit subscriber sends on a private completion channel as soon as it processes that event, which proves it has handled everything queued before it. Then the bus closes its inbox and subscriber channels; each subscriber fiber exits on `Channel::ClosedError`.

View File

@ -7,27 +7,36 @@
This document points you at the most important code paths. Read it with `tree src/` open in another window.
## The Policy DSL (compile-time validation)
## The Policy DSL (statically- and registration-time validated)
`src/cre/policy/dsl.cr` defines a top-level `policy` method that uses Crystal's `with builder yield` so every Builder method is callable receiver-less inside the block:
`src/cre/policy/dsl.cr` declares the DSL inside `module CRE::Policy::Dsl`. Consumers opt in explicitly:
```crystal
def policy(name : String, &block)
builder = CRE::Policy::Builder.new(name)
with builder yield
CRE::Policy::REGISTRY << builder.build
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
```
The Builder methods (in `src/cre/policy/builder.cr`) take typed enum parameters - so `enforce :rotate_immediately` autocasts the symbol to `Action::RotateImmediately` at compile time. Typo'd `:rotate_immediatly` fails the build with `expected Action, got :rotate_immediatly`. The `match {}` block is a real `Proc(Credential, Bool)` so `c.kund` (typo) breaks compilation pointing at the policy file.
`with builder yield` makes every Builder method callable receiver-less inside the block. Two flavors of typo-detection apply:
`Builder#build` validates required fields (`matcher`, `max_age`, `enforce_action`) and raises `BuilderError` if any are missing. This makes a policy literally unable to ship to production in a misformed state.
- **Compile time**`enforce :rotate_immediatly` (single Symbol arg → `Action` enum) 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, :slak` uses a splat-Symbol overload that runs `Channel.parse?` on each value and raises `BuilderError("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. Per-subscriber overflow policy (`Block` or `Drop`) drives whether a slow consumer pauses the dispatcher or quietly loses events.
`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.
The `dispatch` method uses Crystal's `select` to attempt a non-blocking send for `Drop` subscribers and logs a warning when full. `Block` subscribers get `send` directly.
`dispatch` uses Crystal's `select` for both overflow modes:
- `Drop``select … else` drops 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
@ -50,15 +59,27 @@ When a file like `src/cre/rotators/aws_secrets.cr` is required, the `register_as
`src/cre/engine/rotation_orchestrator.cr` runs the contract:
```
generate -> persist pending version
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. On any exception during apply/verify, `rollback_apply` is invoked and `RotationFailed` is published. `RotationCompleted` is the success terminal.
Each step publishes `RotationStepStarted` and either `RotationStepCompleted` or `RotationStepFailed` to the bus.
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.
Failure handling has two regimes:
- **apply or verify fails**`rotator.rollback_apply(c, new_secret)` reverses the cloud-side mutation; rotation moves to `Failed`; bus emits `RotationStepFailed` + `RotationFailed`.
- **commit fails** — partial cross-call commit sequences (e.g., AWS `UpdateSecretVersionStage` 5xx half-way through) cannot be reliably reversed client-side. The rotation transitions to `Inconsistent` (a terminal state distinct from `Failed`), and the orchestrator emits a critical `AlertRaised` so operators know to intervene.
Success path is now the heavyweight one: when the four steps complete, the orchestrator
1. seals `new_secret.ciphertext` with the optional `Crypto::Envelope` (AES-256-GCM, AAD = `cred=<id>|kind=<k>`),
2. inserts a `credential_versions` row with the wrapped DEK + KEK version,
3. updates the credential row to bump `last_rotated_at`, set `current_version_id` to the new version's id, and demote the old one to `previous_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)
@ -75,20 +96,30 @@ 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.
Tested against AWS canonical examples in `spec/unit/aws/signer_spec.cr` for idempotence and format conformance.
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's `get-vanilla` inputs (access key, secret, region, service, fixed timestamp) and locks in a regression vector for the exact signature our signer produces. Because we always emit `X-Amz-Content-SHA256` (required by Secrets Manager and other JSON-protocol services), the signed-headers list is `host;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` orchestrates Layer 1 + 2:
`src/cre/audit/audit_log.cr` writes Layers 1 + 2 on every `append`:
- `latest_hash` from 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_version` get persisted in one `INSERT` per row
`src/cre/audit/batch_sealer.cr` builds Layer 3:
Verification is split into three independently-callable methods:
- `verify_hash_chain` — walks every entry, recomputes `SHA256(prev_hash || payload)`, compares against `content_hash`.
- `verify_hmac_ratchet(seed_key)` — replays the ratchet from the seed `CRE_HMAC_KEY_HEX`, recomputes each row's HMAC against `content_hash`, and checks `hmac_key_version` matches 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 in `audit_batches`, refetch the corresponding `content_hash` leaves, 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's `content_hash`
- Sign `(start_seq, end_seq, root)` with Ed25519 via `Signing::Ed25519Signer`
- Store the signed batch in `audit_batches`
- 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)`.
@ -108,14 +139,20 @@ Decrypting requires the KEK to unwrap the DEK, then the DEK + AAD to decrypt the
## 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).
`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 vs operator tier separates `/status` from `/rotate`. `/rotate` publishes `RotationScheduled` to the bus, where the orchestrator picks it up.
`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).
`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`.
`src/cre/persistence/sqlite/` and `src/cre/persistence/postgres/` mirror each other under the same interface. PG uses `$1, $2` placeholders, BYTEA + JSONB native types, BIGSERIAL audit; SQLite uses `?` placeholders, BLOB + TEXT (with JSON helpers).
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 - PG triggers refuse `UPDATE`, `DELETE`, `TRUNCATE` and the application role doesn't have those grants either. Two independent locks; both must be subverted to forge history.
`audit_events` is the most carefully guarded table in the schema:
- Postgres has the original `audit_events_no_update` trigger (raises `audit_events is append-only` on UPDATE/DELETE/TRUNCATE).
- SQLite gets parity via two `BEFORE UPDATE` / `BEFORE DELETE` triggers using `RAISE(ABORT, '...')`.
- The repo's `INSERT` no longer uses `OR 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 `AlertRaised` and (by default) panics the process via `CRE_AUDIT_FAILURE_MODE=panic`.
Two independent layers; both must be subverted to forge history.

View File

@ -35,10 +35,10 @@ Anchor each `audit_batches` Merkle root to the Bitcoin blockchain via OpenTimest
Add a rotator that *replaces* static credentials with SPIFFE SVIDs (X.509 + JWT). Demonstrates the post-2024 industry shift away from rotation entirely toward attestation-based ephemeral identity. Touch points: new client in `src/cre/spiffe/`, new rotator in `src/cre/rotators/spiffe.cr`, new credential kind in `src/cre/domain/credential.cr`.
### 8. Crash recovery state machine
Implement the recovery protocol described in the spec: on boot, scan `rotations` table for non-terminal states; for each, decide whether to rollback, retry, or mark `inconsistent`. The current orchestrator publishes events but doesn't recover from a daemon crash mid-rotation. Add `src/cre/engine/recovery.cr` with explicit state-machine semantics for each `(rotator_kind, last_step)` pair.
The orchestrator already marks commit-step failures as `Inconsistent` and emits a critical alert, so single-step partial failures surface loudly. What's still missing is the *boot-time* recovery sweep: if the daemon was killed between, say, a successful `apply` and the start of `verify`, the `rotations` row is left in `Verifying` state with no fiber driving it forward. Implement the recovery protocol: on boot, scan `rotations` for non-terminal states; for each, decide based on `(rotator_kind, last_step)` whether to invoke `rollback_apply`, retry from the failed step, or transition to `Inconsistent`. Add `src/cre/engine/recovery.cr` with explicit state-machine semantics, wire it into `Engine#start`, and add a SQL fixture spec in `spec/unit/engine/recovery_spec.cr` that builds each kind of "stale" row and asserts the right outcome.
### 9. Multi-tenant support
Wire `tenant_id` through the schema (already reserved as a column placeholder), the AAD construction, the policy matchers (allow `c.tenant == "tenant-x"`), and the API surface. Postgres row-level security policies enforce isolation at the DB level. The biggest design decision: per-tenant KEKs vs shared KEK with per-tenant DEKs.
Wire `tenant_id` end-to-end: a new migration adds the column on `credentials` / `credential_versions` / `rotations` / `audit_events`; the AAD construction in `RotationOrchestrator#persist_credential_version` extends to `cred=<id>|kind=<k>|tenant=<t>`; policy matchers gain `c.tenant == "tenant-x"`; and Postgres row-level security policies enforce isolation at the DB level. The biggest design decision: per-tenant KEKs (each tenant rotates independently, but bootstrap juggles N keys) vs shared KEK with tenant-bound DEKs (simpler config, the AAD does the isolation work).
### 10. JIT credential broker
Replace the rotation contract entirely for some credential types: instead of rotating, *issue* a fresh ephemeral credential on each access (5-15 minute TTL). Requires a new `Broker` abstraction alongside `Rotator`, integration with AWS STS / GCP service account impersonation / Vault dynamic, and consumer-side token refresh logic in the example apps.

View File

@ -30,6 +30,10 @@ describe CRE::Audit::AuditLog do
log.append("a", "s", nil, {"k" => "v"})
log.append("b", "s", nil, {"k" => "v2"})
# The append-only trigger blocks tampering in the live DB; drop it
# for this test so we can exercise the verify_chain code path against
# a forced inconsistency.
persist.db.exec("DROP TRIGGER audit_events_no_update")
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
@ -37,6 +41,23 @@ describe CRE::Audit::AuditLog do
persist.try(&.close)
end
it "append-only triggers exist on audit_events (SQLite)" do
# The crystal-sqlite3 driver caches prepared statements at the connection
# level; a failed UPDATE leaves the cached statement in error state and the
# error re-surfaces at connection close, which conflates 'expect_raises'
# bookkeeping. We verify the trigger by inspecting sqlite_master directly.
persist = CRE::Persistence::Sqlite::SqlitePersistence.new(":memory:")
persist.migrate!
triggers = persist.db.query_all(
"SELECT name FROM sqlite_master WHERE type='trigger' AND tbl_name='audit_events'",
as: String,
)
triggers.should contain "audit_events_no_update"
triggers.should contain "audit_events_no_delete"
ensure
persist.try(&.close)
end
it "verify_chain returns true on empty log" do
persist = CRE::Persistence::Sqlite::SqlitePersistence.new(":memory:")
persist.migrate!

View File

@ -0,0 +1,123 @@
# ===================
# ©AngelaMos | 2026
# signer_aws_vector_spec.cr
# ===================
require "../../spec_helper"
require "../../../src/cre/aws/signer"
# AWS publishes a reference SigV4 test suite at
# https://docs.aws.amazon.com/general/latest/gr/sigv4_signing.html with
# golden values for canonical request, string-to-sign, and signature.
#
# Our signer always emits X-Amz-Content-SHA256 (required by Secrets Manager
# and all the JSON-protocol services we target), which AWS's "vanilla"
# reference vectors deliberately omit. So instead of matching the vanilla
# vector byte-for-byte, we lock in:
# 1. The exact AWS-spec credential-scope and signed-headers list,
# 2. A regression-stable signature for a known input set with our
# always-on X-Amz-Content-SHA256 header.
# Any change to canonicalization, key derivation, or header ordering
# breaks the regression vector — which is the failure mode we care about.
#
# Inputs match the published reference suite for everything except the
# extra signed header:
# access_key: AKIDEXAMPLE
# secret_key: wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY
# region: us-east-1
# service: service
# date: 20150830T123600Z (UTC)
describe CRE::Aws::SigV4 do
it "produces the AWS-spec credential-scope and signed-headers list" do
signer = CRE::Aws::SigV4.new(
access_key_id: "AKIDEXAMPLE",
secret_access_key: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
region: "us-east-1",
service: "service",
)
headers = HTTP::Headers.new
uri = URI.parse("https://example.amazonaws.com/")
fixed_time = Time.utc(2015, 8, 30, 12, 36, 0)
signer.sign("GET", uri, headers, "", fixed_time)
headers["X-Amz-Date"].should eq "20150830T123600Z"
headers["Host"].should eq "example.amazonaws.com"
auth = headers["Authorization"]
auth.should start_with "AWS4-HMAC-SHA256 "
auth.should contain "Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request"
auth.should contain "SignedHeaders=host;x-amz-content-sha256;x-amz-date"
auth.should match(/Signature=[a-f0-9]{64}\z/)
end
it "regression vector: locks in the bytewise signature for a fixed input" do
signer = CRE::Aws::SigV4.new(
access_key_id: "AKIDEXAMPLE",
secret_access_key: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
region: "us-east-1",
service: "service",
)
headers = HTTP::Headers.new
uri = URI.parse("https://example.amazonaws.com/")
fixed_time = Time.utc(2015, 8, 30, 12, 36, 0)
signer.sign("GET", uri, headers, "", fixed_time)
# If any of canonicalization / key derivation / signed-headers
# ordering / content-sha256 logic changes, this assertion catches it.
expected = "726c5c4879a6b4ccbbd3b24edbd6b8826d34f87450fbbf4e85546fc7ba9c1642"
headers["Authorization"].should contain "Signature=#{expected}"
end
it "matches a POST with body — content-sha256 changes the signature" do
signer = CRE::Aws::SigV4.new(
access_key_id: "AKIDEXAMPLE",
secret_access_key: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
region: "us-east-1",
service: "service",
)
fixed_time = Time.utc(2015, 8, 30, 12, 36, 0)
body = "Param1=value1"
h_a = HTTP::Headers{"Content-Type" => "application/x-www-form-urlencoded"}
h_b = HTTP::Headers{"Content-Type" => "application/x-www-form-urlencoded"}
signer.sign("POST", URI.parse("https://example.amazonaws.com/"), h_a, body, fixed_time)
signer.sign("POST", URI.parse("https://example.amazonaws.com/"), h_b, "different-body", fixed_time)
# Two bodies, two different content-sha256 inputs, two different
# signatures — proves the body actually flows into the signature.
h_a["X-Amz-Content-SHA256"].should_not eq h_b["X-Amz-Content-SHA256"]
h_a["Authorization"].should_not eq h_b["Authorization"]
end
it "different regions produce different signing keys (and signatures)" do
east = CRE::Aws::SigV4.new("AKIDEXAMPLE", "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", "us-east-1", "service")
west = CRE::Aws::SigV4.new("AKIDEXAMPLE", "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", "us-west-2", "service")
fixed = Time.utc(2015, 8, 30, 12, 36, 0)
h_e = HTTP::Headers.new
h_w = HTTP::Headers.new
east.sign("GET", URI.parse("https://example.amazonaws.com/"), h_e, "", fixed)
west.sign("GET", URI.parse("https://example.amazonaws.com/"), h_w, "", fixed)
h_e["Authorization"].should_not eq h_w["Authorization"]
end
it "session token participates in the signed-headers list" do
signer = CRE::Aws::SigV4.new(
access_key_id: "AKIDEXAMPLE",
secret_access_key: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
region: "us-east-1",
service: "service",
session_token: "TOKENVALUE",
)
h = HTTP::Headers.new
fixed = Time.utc(2015, 8, 30, 12, 36, 0)
signer.sign("GET", URI.parse("https://example.amazonaws.com/"), h, "", fixed)
h["Authorization"].should contain "x-amz-security-token"
end
end

View File

@ -39,7 +39,7 @@ describe CRE::Domain::Credential do
tags: {} of String => String,
)
c.kind.github_pat?.should be_true
c.kind.aws_iam_key?.should be_false
c.kind.env_file?.should be_false
end
it "tag() accepts both string and symbol keys" do
@ -51,4 +51,31 @@ describe CRE::Domain::Credential do
c.tag("foo").should eq "bar"
c.tag(:foo).should eq "bar"
end
it "rotation_anchor falls back to created_at when never rotated" do
created = Time.utc - 3.days
c = CRE::Domain::Credential.new(
id: UUID.random,
external_id: "x",
kind: CRE::Domain::CredentialKind::EnvFile,
name: "n",
tags: {} of String => String,
created_at: created,
)
c.rotation_anchor.should eq created
end
it "rotation_anchor uses last_rotated_at once set" do
rotated = Time.utc - 1.hour
c = CRE::Domain::Credential.new(
id: UUID.random,
external_id: "x",
kind: CRE::Domain::CredentialKind::EnvFile,
name: "n",
tags: {} of String => String,
created_at: Time.utc - 30.days,
last_rotated_at: rotated,
)
c.rotation_anchor.should eq rotated
end
end

View File

@ -7,6 +7,8 @@ require "../../spec_helper"
require "../../../src/cre/engine/rotation_orchestrator"
require "../../../src/cre/persistence/sqlite/sqlite_persistence"
require "../../../src/cre/rotators/env_file"
require "../../../src/cre/crypto/envelope"
require "../../../src/cre/crypto/kek"
private def drain(ch : ::Channel(CRE::Events::Event)) : Array(CRE::Events::Event)
out = [] of CRE::Events::Event
@ -91,6 +93,123 @@ describe CRE::Engine::RotationOrchestrator do
persist.try(&.close)
tmp.try(&.delete)
end
it "bumps credential.last_rotated_at on successful rotation" do
tmp = File.tempfile("cre_rot_anchor_") { |f| f << "K=v\n" }
cred = env_credential(tmp.path, "K")
persist = CRE::Persistence::Sqlite::SqlitePersistence.new(":memory:")
persist.migrate!
persist.credentials.insert(cred)
bus = CRE::Engine::EventBus.new
bus.run
floor = Time.utc.at_beginning_of_second
state = CRE::Engine::RotationOrchestrator.new(bus, persist).run(cred, CRE::Rotators::EnvFileRotator.new)
sleep 0.05.seconds
state.completed?.should be_true
refreshed = persist.credentials.find(cred.id).not_nil!
refreshed.last_rotated_at.not_nil!.should be >= floor
refreshed.rotation_anchor.should be >= floor
ensure
bus.try(&.stop)
persist.try(&.close)
tmp.try(&.delete)
end
it "writes an encrypted credential_version when an Envelope is configured" do
ENV["TEST_KEK_ROT"] = "0" * 64
kek = CRE::Crypto::Kek::EnvKek.new("TEST_KEK_ROT", version: 1)
envelope = CRE::Crypto::Envelope.new(kek)
tmp = File.tempfile("cre_rot_env_") { |f| f << "K=v\n" }
cred = env_credential(tmp.path, "K")
persist = CRE::Persistence::Sqlite::SqlitePersistence.new(":memory:")
persist.migrate!
persist.credentials.insert(cred)
bus = CRE::Engine::EventBus.new
bus.run
state = CRE::Engine::RotationOrchestrator.new(bus, persist, envelope).run(cred, CRE::Rotators::EnvFileRotator.new)
sleep 0.05.seconds
state.completed?.should be_true
versions = persist.versions.for_credential(cred.id)
versions.size.should eq 1
v = versions.first
v.kek_version.should eq 1
v.algorithm_id.should eq CRE::Crypto::ALGORITHM_AES_256_GCM
v.ciphertext.size.should be > 0
sealed = CRE::Crypto::SealedSecret.new(v.ciphertext, v.dek_wrapped, v.kek_version, v.algorithm_id)
plaintext = envelope.open(sealed, "cred=#{cred.id}|kind=#{cred.kind}".to_slice)
plaintext.size.should be > 0
refreshed = persist.credentials.find(cred.id).not_nil!
refreshed.current_version_id.should eq v.id
ensure
ENV.delete("TEST_KEK_ROT")
bus.try(&.stop)
persist.try(&.close)
tmp.try(&.delete)
end
it "marks rotation Inconsistent when commit step fails" do
tmp = File.tempfile("cre_rot_commit_fail_") { |f| f << "K=v\n" }
cred = env_credential(tmp.path, "K")
persist = CRE::Persistence::Sqlite::SqlitePersistence.new(":memory:")
persist.migrate!
persist.credentials.insert(cred)
bus = CRE::Engine::EventBus.new
ch = bus.subscribe
bus.run
state = CRE::Engine::RotationOrchestrator.new(bus, persist).run(cred, CommitFailingRotator.new)
sleep 0.05.seconds
state.should eq CRE::Persistence::RotationState::Inconsistent
persist.rotations.in_flight.size.should eq 0
types = drain(ch).map(&.class.name)
types.should contain "CRE::Events::AlertRaised"
ensure
bus.try(&.stop)
persist.try(&.close)
tmp.try(&.delete)
end
end
class CommitFailingRotator < CRE::Rotators::Rotator
def kind : Symbol
:env_file
end
def can_rotate?(c : CRE::Domain::Credential) : Bool
_ = c
true
end
def generate(c : CRE::Domain::Credential) : CRE::Domain::NewSecret
_ = c
CRE::Domain::NewSecret.new(ciphertext: "x".to_slice)
end
def apply(c : CRE::Domain::Credential, s : CRE::Domain::NewSecret) : Nil
_ = {c, s}
end
def verify(c : CRE::Domain::Credential, s : CRE::Domain::NewSecret) : Bool
_ = {c, s}
true
end
def commit(c : CRE::Domain::Credential, s : CRE::Domain::NewSecret) : Nil
_ = {c, s}
raise CRE::Rotators::RotatorError.new("commit network 503")
end
end
class FailingRotator < CRE::Rotators::Rotator

View File

@ -0,0 +1,197 @@
# ===================
# ©AngelaMos | 2026
# rotation_worker_spec.cr
# ===================
require "../../spec_helper"
require "../../../src/cre/engine/event_bus"
require "../../../src/cre/engine/rotation_worker"
require "../../../src/cre/engine/rotation_orchestrator"
require "../../../src/cre/persistence/sqlite/sqlite_persistence"
require "../../../src/cre/rotators/env_file"
private def env_credential(path : String, key : String) : CRE::Domain::Credential
CRE::Domain::Credential.new(
id: UUID.random,
external_id: "#{path}::#{key}",
kind: CRE::Domain::CredentialKind::EnvFile,
name: key,
tags: {"path" => path, "key" => key} of String => String,
)
end
private def setup_worker
persist = CRE::Persistence::Sqlite::SqlitePersistence.new(":memory:")
persist.migrate!
bus = CRE::Engine::EventBus.new
bus.run
orchestrator = CRE::Engine::RotationOrchestrator.new(bus, persist)
worker = CRE::Engine::RotationWorker.new(bus, orchestrator, persist)
{persist, bus, worker}
end
class StubRotator < CRE::Rotators::Rotator
property generate_count = 0
property apply_count = 0
property commit_count = 0
property declined_credentials = [] of UUID
def initialize(@kind_sym : Symbol = :env_file, @can_rotate : Bool = true)
end
def kind : Symbol
@kind_sym
end
def can_rotate?(c : CRE::Domain::Credential) : Bool
@declined_credentials << c.id unless @can_rotate
@can_rotate
end
def generate(c : CRE::Domain::Credential) : CRE::Domain::NewSecret
_ = c
@generate_count += 1
CRE::Domain::NewSecret.new(ciphertext: "x".to_slice)
end
def apply(c : CRE::Domain::Credential, s : CRE::Domain::NewSecret) : Nil
_ = {c, s}
@apply_count += 1
end
def verify(c : CRE::Domain::Credential, s : CRE::Domain::NewSecret) : Bool
_ = {c, s}
true
end
def commit(c : CRE::Domain::Credential, s : CRE::Domain::NewSecret) : Nil
_ = {c, s}
@commit_count += 1
end
end
describe CRE::Engine::RotationWorker do
it "dispatches RotationScheduled to the registered rotator" do
persist, bus, worker = setup_worker
persist.credentials.insert(env_credential("/tmp/x.env", "K"))
rotator = StubRotator.new
worker.register(:env_file, rotator)
worker.start
cred = persist.credentials.all.first
bus.publish CRE::Events::RotationScheduled.new(cred.id, "env_file")
sleep 0.15.seconds
rotator.generate_count.should eq 1
ensure
worker.try(&.stop)
bus.try(&.stop)
persist.try(&.close)
end
it "skips when no rotator is registered for the credential's kind" do
persist, bus, worker = setup_worker
persist.credentials.insert(env_credential("/tmp/y.env", "K"))
worker.start
cred = persist.credentials.all.first
bus.publish CRE::Events::RotationScheduled.new(cred.id, "env_file")
sleep 0.1.seconds
persist.rotations.in_flight.size.should eq 0
ensure
worker.try(&.stop)
bus.try(&.stop)
persist.try(&.close)
end
it "skips when rotator.can_rotate? returns false" do
persist, bus, worker = setup_worker
persist.credentials.insert(env_credential("/tmp/z.env", "K"))
rotator = StubRotator.new(can_rotate: false)
worker.register(:env_file, rotator)
worker.start
cred = persist.credentials.all.first
bus.publish CRE::Events::RotationScheduled.new(cred.id, "env_file")
sleep 0.1.seconds
rotator.generate_count.should eq 0
rotator.declined_credentials.should contain(cred.id)
ensure
worker.try(&.stop)
bus.try(&.stop)
persist.try(&.close)
end
it "ignores events that aren't RotationScheduled" do
persist, bus, worker = setup_worker
persist.credentials.insert(env_credential("/tmp/w.env", "K"))
rotator = StubRotator.new
worker.register(:env_file, rotator)
worker.start
cred = persist.credentials.all.first
bus.publish CRE::Events::RotationCompleted.new(cred.id, UUID.random)
sleep 0.1.seconds
rotator.generate_count.should eq 0
ensure
worker.try(&.stop)
bus.try(&.stop)
persist.try(&.close)
end
it "deduplicates: a duplicate schedule while one is in_flight is dropped" do
persist, bus, worker = setup_worker
persist.credentials.insert(env_credential("/tmp/dedup.env", "K"))
cred = persist.credentials.all.first
record = CRE::Persistence::RotationRecord.new(
id: UUID.random,
credential_id: cred.id,
rotator_kind: CRE::Persistence::RotatorKind::EnvFile,
state: CRE::Persistence::RotationState::Generating,
started_at: Time.utc,
completed_at: nil,
failure_reason: nil,
)
persist.rotations.insert(record)
rotator = StubRotator.new
worker.register(:env_file, rotator)
worker.start
bus.publish CRE::Events::RotationScheduled.new(cred.id, "env_file")
sleep 0.1.seconds
rotator.generate_count.should eq 0 # blocked by in_flight check
ensure
worker.try(&.stop)
bus.try(&.stop)
persist.try(&.close)
end
it "warns and skips when credential is missing from persistence" do
persist, bus, worker = setup_worker
rotator = StubRotator.new
worker.register(:env_file, rotator)
worker.start
bus.publish CRE::Events::RotationScheduled.new(UUID.random, "env_file")
sleep 0.1.seconds
rotator.generate_count.should eq 0
ensure
worker.try(&.stop)
bus.try(&.stop)
persist.try(&.close)
end
it "rotator_for_kind returns nil for unregistered kinds" do
_, _, worker = setup_worker
worker.register(:env_file, StubRotator.new)
worker.rotator_for_kind(CRE::Domain::CredentialKind::EnvFile).should_not be_nil
worker.rotator_for_kind(CRE::Domain::CredentialKind::AwsSecretsmgr).should be_nil
end
end

View File

@ -6,13 +6,15 @@
require "../../spec_helper"
require "../../../src/cre/policy/dsl"
include CRE::Policy::Dsl
describe "Policy DSL" do
before_each { CRE::Policy.clear_registry! }
it "registers a policy with full DSL syntax" do
policy "production-databases" do
description "Prod DB rotation"
match { |c| c.kind.database? && c.tag(:env) == "prod" }
policy "production-aws-secrets" do
description "Prod AWS secret rotation"
match { |c| c.kind.aws_secretsmgr? && c.tag(:env) == "prod" }
max_age 30.days
warn_at 25.days
enforce :rotate_immediately
@ -22,8 +24,8 @@ describe "Policy DSL" do
CRE::Policy.registry.size.should eq 1
p = CRE::Policy.registry.first
p.name.should eq "production-databases"
p.description.should eq "Prod DB rotation"
p.name.should eq "production-aws-secrets"
p.description.should eq "Prod AWS secret rotation"
p.max_age.should eq 30.days
p.warn_at.should eq 25.days
p.enforce_action.should eq CRE::Policy::Action::RotateImmediately

View File

@ -8,6 +8,8 @@ require "../../../src/cre/policy/evaluator"
require "../../../src/cre/policy/dsl"
require "../../../src/cre/persistence/sqlite/sqlite_persistence"
include CRE::Policy::Dsl
private def fresh_persistence
persist = CRE::Persistence::Sqlite::SqlitePersistence.new(":memory:")
persist.migrate!

View File

@ -32,7 +32,7 @@ describe CRE::Policy::Policy do
p.matches?(other).should be_false
end
it "detects overdue based on updated_at + max_age" do
it "detects overdue based on rotation_anchor + max_age" do
p = CRE::Policy::Policy.new(
name: "p", description: nil,
matcher: ->(_c : CRE::Domain::Credential) { true },
@ -46,19 +46,59 @@ describe CRE::Policy::Policy do
id: UUID.random, external_id: "f",
kind: CRE::Domain::CredentialKind::EnvFile,
name: "n", tags: {} of String => String,
updated_at: Time.utc - 1.day,
last_rotated_at: Time.utc - 1.day,
)
stale = CRE::Domain::Credential.new(
id: UUID.random, external_id: "s",
kind: CRE::Domain::CredentialKind::EnvFile,
name: "n", tags: {} of String => String,
updated_at: Time.utc - 30.days,
last_rotated_at: Time.utc - 30.days,
)
p.overdue?(fresh).should be_false
p.overdue?(stale).should be_true
end
it "treats never-rotated credentials by created_at" do
p = CRE::Policy::Policy.new(
name: "p", description: nil,
matcher: ->(_c : CRE::Domain::Credential) { true },
max_age: 7.days, warn_at: nil,
enforce_action: CRE::Policy::Action::NotifyOnly,
notify_channels: [] of CRE::Policy::Channel,
triggers: {} of CRE::Policy::Trigger => CRE::Policy::Action,
)
aged = CRE::Domain::Credential.new(
id: UUID.random, external_id: "a",
kind: CRE::Domain::CredentialKind::EnvFile,
name: "n", tags: {} of String => String,
created_at: Time.utc - 30.days,
)
p.overdue?(aged).should be_true
end
it "ignores updated_at (renaming a credential does not reset rotation clock)" do
p = CRE::Policy::Policy.new(
name: "p", description: nil,
matcher: ->(_c : CRE::Domain::Credential) { true },
max_age: 7.days, warn_at: nil,
enforce_action: CRE::Policy::Action::NotifyOnly,
notify_channels: [] of CRE::Policy::Channel,
triggers: {} of CRE::Policy::Trigger => CRE::Policy::Action,
)
just_renamed = CRE::Domain::Credential.new(
id: UUID.random, external_id: "x",
kind: CRE::Domain::CredentialKind::EnvFile,
name: "n", tags: {} of String => String,
created_at: Time.utc - 30.days,
updated_at: Time.utc, # tag/name was just edited
last_rotated_at: Time.utc - 30.days,
)
p.overdue?(just_renamed).should be_true
end
it "computes warning window" do
p = CRE::Policy::Policy.new(
name: "p", description: nil,
@ -72,17 +112,17 @@ describe CRE::Policy::Policy do
young = CRE::Domain::Credential.new(
id: UUID.random, external_id: "y", kind: CRE::Domain::CredentialKind::EnvFile,
name: "n", tags: {} of String => String,
updated_at: Time.utc - 10.days,
last_rotated_at: Time.utc - 10.days,
)
warning = CRE::Domain::Credential.new(
id: UUID.random, external_id: "w", kind: CRE::Domain::CredentialKind::EnvFile,
name: "n", tags: {} of String => String,
updated_at: Time.utc - 27.days,
last_rotated_at: Time.utc - 27.days,
)
overdue = CRE::Domain::Credential.new(
id: UUID.random, external_id: "o", kind: CRE::Domain::CredentialKind::EnvFile,
name: "n", tags: {} of String => String,
updated_at: Time.utc - 31.days,
last_rotated_at: Time.utc - 31.days,
)
p.in_warning_window?(young).should be_false

View File

@ -32,11 +32,11 @@ describe CRE::Rotators::EnvFileRotator do
new_secret.ciphertext.size.should be > 0
rotator.apply(cred, new_secret)
File.exists?("#{path}.pending").should be_true
File.exists?("#{path}.pending.#{Process.pid}").should be_true
rotator.verify(cred, new_secret).should be_true
rotator.commit(cred, new_secret)
File.exists?("#{path}.pending").should be_false
File.exists?("#{path}.pending.#{Process.pid}").should be_false
final = File.read(path)
new_value = String.new(new_secret.ciphertext)
@ -56,10 +56,10 @@ describe CRE::Rotators::EnvFileRotator do
s = rotator.generate(cred)
rotator.apply(cred, s)
File.exists?("#{tmp.path}.pending").should be_true
File.exists?("#{tmp.path}.pending.#{Process.pid}").should be_true
rotator.rollback_apply(cred, s)
File.exists?("#{tmp.path}.pending").should be_false
File.exists?("#{tmp.path}.pending.#{Process.pid}").should be_false
File.read(tmp.path).should eq "K=v\n"
ensure
tmp.try(&.delete)

View File

@ -5,17 +5,39 @@
require "json"
require "uuid"
require "openssl/hmac"
require "./hash_chain"
require "./hmac_ratchet"
require "./merkle"
require "./signing"
require "../crypto/random"
require "../persistence/persistence"
require "../persistence/repos"
module CRE::Audit
# AuditLog is the append-only, tamper-evident write API used by the
# AuditSubscriber. Verification is split across three layers, each
# callable independently:
#
# verify_hash_chain SHA-256 chain over (prev_hash || payload)
# verify_hmac_ratchet HMAC-SHA256 of every content_hash, with
# the ratcheting key replayed from the
# initial seed
# verify_batches Ed25519-signed Merkle-root batches
#
# Together they answer 'has this log been mutated since it was written':
# - hash chain catches edits to any single row (recompute everything),
# - HMAC ratchet catches edits an attacker who recomputed hashes might
# have made (they don't have the seed key),
# - Merkle batches give an external auditor an O(1) commitment to a
# range of entries that they can verify offline with a public key.
class AuditLog
@ratchet : HmacRatchet
@mutex : Mutex
@initial_hmac_key : Bytes
def initialize(@persistence : Persistence::Persistence, initial_hmac_key : Bytes, @hmac_version : Int32, @ratchet_every : Int32)
@initial_hmac_key = initial_hmac_key.dup
@ratchet = HmacRatchet.new(initial_hmac_key, @hmac_version, @ratchet_every)
@mutex = Mutex.new
end
@ -45,7 +67,15 @@ module CRE::Audit
end
end
# Backwards-compatible alias: returns true iff hash chain + HMAC ratchet
# both verify against the seed key the log was constructed with.
def verify_chain : Bool
verify_hash_chain && verify_hmac_ratchet(@initial_hmac_key)
end
# Verify only the SHA-256 chain. Catches tampering when an attacker
# didn't recompute hashes; doesn't catch tampering when they did.
def verify_hash_chain : Bool
latest = @persistence.audit.latest_seq
return true if latest == 0
entries = @persistence.audit.range(1_i64, latest)
@ -55,6 +85,47 @@ module CRE::Audit
HashChain.verify(pairs, payloads)
end
# Verify the HMAC ratchet by replaying it from the seed key. An
# attacker who modified rows AND recomputed hashes still doesn't have
# the seed key, so any HMAC mismatch is dispositive evidence of
# tampering.
def verify_hmac_ratchet(seed_key : Bytes) : Bool
raise ArgumentError.new("seed key must be 32 bytes") unless seed_key.size == 32
latest = @persistence.audit.latest_seq
return true if latest == 0
ratchet = HmacRatchet.new(seed_key, version: 1, ratchet_every: @ratchet_every)
entries = @persistence.audit.range(1_i64, latest)
entries.each do |entry|
return false unless entry.hmac_key_version == ratchet.version
expected = OpenSSL::HMAC.digest(:sha256, ratchet.current_key, entry.content_hash)
return false unless CRE::Crypto::Random.constant_time_equal?(expected, entry.hmac)
ratchet.sign(entry.content_hash) # advance counter; trigger rotation at threshold
end
true
end
# Verify all sealed Merkle batches against a public key. Each batch
# commits to a Merkle root over content_hashes from start_seq..end_seq;
# we re-derive the root from the live entries and check the signature.
def verify_batches(verifier : Signing::Ed25519Verifier) : Bool
batches = @persistence.audit.all_batches
return true if batches.empty?
batches.each do |batch|
entries = @persistence.audit.range(batch.start_seq, batch.end_seq)
return false if entries.size != (batch.end_seq - batch.start_seq + 1)
leaves = entries.map(&.content_hash)
recomputed_root = Merkle.root(leaves)
return false unless CRE::Crypto::Random.constant_time_equal?(recomputed_root, batch.merkle_root)
msg = BatchSealer.pack_message(batch.start_seq, batch.end_seq, batch.merkle_root)
return false unless verifier.verify(msg, batch.signature)
end
true
end
def ratchet_version : Int32
@ratchet.version
end

View File

@ -0,0 +1,64 @@
# ===================
# ©AngelaMos | 2026
# batch_sealer_scheduler.cr
# ===================
require "log"
require "./batch_sealer"
require "../engine/event_bus"
require "../events/credential_events"
require "../events/system_events"
module CRE::Audit
# BatchSealerScheduler runs an idle fiber that periodically calls
# BatchSealer.seal_pending. Without this fiber the audit_batches table
# never grows and 'cre audit verify --merkle' has nothing to verify.
#
# On each successful seal we publish AlertRaised(:info) with the sealed
# range; the AuditSubscriber then writes a 'audit.batch.sealed' event
# into the audit log itself, which closes the loop for compliance
# frameworks that key on that event_type.
class BatchSealerScheduler
Log = ::Log.for("cre.batch_sealer")
@running : Bool
def initialize(@bus : Engine::EventBus, @sealer : BatchSealer, @interval : Time::Span = 5.minutes)
@running = false
end
def start : Nil
@running = true
spawn(name: "batch-sealer") do
seal_once
while @running
sleep @interval
break unless @running
seal_once
end
end
end
def stop : Nil
@running = false
seal_once # final seal on shutdown
end
def seal_once : Nil
batch = @sealer.seal_pending
return if batch.nil?
@bus.publish Events::AuditBatchSealed.new(
start_seq: batch.start_seq,
end_seq: batch.end_seq,
signing_key_version: batch.signing_key_version,
)
rescue ex
Log.error(exception: ex) { "batch_sealer.seal_pending failed" }
@bus.publish(Events::AlertRaised.new(
severity: Events::Severity::Critical,
message: "audit batch sealing failed: #{ex.message}",
)) rescue nil
end
end
end

View File

@ -7,6 +7,7 @@ require "http/client"
require "json"
require "uuid"
require "./signer"
require "../http/retry"
module CRE::Aws
class AwsApiError < Exception
@ -77,7 +78,7 @@ module CRE::Aws
}
@signer.sign("POST", uri, headers, body)
response = HTTP::Client.post(uri.to_s, headers: headers, body: body)
response = CRE::Http.request("POST", uri.to_s, headers, body, label: "aws.#{action}")
raise AwsApiError.new(error_message(response), response.status_code, error_code(response)) unless response.status_code < 300
response.body.empty? ? JSON::Any.new(Hash(String, JSON::Any).new) : JSON.parse(response.body)

View File

@ -0,0 +1,122 @@
# ===================
# ©AngelaMos | 2026
# bootstrap.cr
# ===================
require "../crypto/kek"
require "../crypto/envelope"
require "../audit/signing"
require "../persistence/persistence"
require "../persistence/sqlite/sqlite_persistence"
require "../persistence/postgres/postgres_persistence"
require "../engine/rotation_worker"
require "../rotators/env_file"
require "../rotators/aws_secrets"
require "../rotators/vault_dynamic"
require "../rotators/github_pat"
require "../aws/secrets_client"
require "../vault/client"
require "../github/client"
module CRE::Cli::Bootstrap
HMAC_KEY_VAR = "CRE_HMAC_KEY_HEX"
KEK_HEX_VAR = "CRE_KEK_HEX"
KEK_VERSION_VAR = "CRE_KEK_VERSION"
SIGNING_KEY_VAR = "CRE_SIGNING_KEY_HEX"
SEAL_INTERVAL_VAR = "CRE_SEAL_INTERVAL_SECONDS"
class ConfigError < Exception; end
# Loads the 32-byte HMAC seed key from CRE_HMAC_KEY_HEX. Hard-fails when
# missing; the prior all-zero default left audit logs trivially forgeable
# by anyone with read access to the source.
def self.require_hmac_key : Bytes
hex = ENV[HMAC_KEY_VAR]?
if hex.nil? || hex.empty?
raise ConfigError.new(
"#{HMAC_KEY_VAR} is required for cre to start. Generate one with:\n openssl rand -hex 32\nThen export it before invoking cre.",
)
end
if hex.size != 64
raise ConfigError.new("#{HMAC_KEY_VAR} must be 64 hex chars (32 bytes); got #{hex.size}")
end
hex.hexbytes
end
# Returns an Envelope when CRE_KEK_HEX is set; nil otherwise. nil disables
# at-rest encryption — appropriate for the demo path, but cre run/watch
# should refuse to start without it.
def self.envelope : Crypto::Envelope?
hex = ENV[KEK_HEX_VAR]?
return nil if hex.nil? || hex.empty?
raise ConfigError.new("#{KEK_HEX_VAR} must be 64 hex chars (32 bytes); got #{hex.size}") unless hex.size == 64
version = (ENV[KEK_VERSION_VAR]? || "1").to_i
kek = Crypto::Kek::EnvKek.new(KEK_HEX_VAR, version)
Crypto::Envelope.new(kek)
end
def self.require_envelope : Crypto::Envelope
env = envelope
return env unless env.nil?
raise ConfigError.new(
"#{KEK_HEX_VAR} is required for cre run/watch. Generate with:\n openssl rand -hex 32\nKEK rotation: bump #{KEK_VERSION_VAR}.",
)
end
# Returns an Ed25519 signer when CRE_SIGNING_KEY_HEX is set, nil otherwise.
# When nil, batch sealing is disabled and 'cre audit verify' will skip the
# Merkle layer.
def self.signer : Audit::Signing::Ed25519Signer?
hex = ENV[SIGNING_KEY_VAR]?
return nil if hex.nil? || hex.empty?
raise ConfigError.new("#{SIGNING_KEY_VAR} must be 64 hex chars (32 bytes); got #{hex.size}") unless hex.size == 64
Audit::Signing::Ed25519Signer.new(hex.hexbytes, version: 1)
end
def self.seal_interval : Time::Span
seconds = (ENV[SEAL_INTERVAL_VAR]? || "300").to_i
seconds.seconds
end
def self.build_persistence(url : String) : Persistence::Persistence
if url.starts_with?("sqlite:")
Persistence::Sqlite::SqlitePersistence.new(url.lchop("sqlite:"))
elsif url.starts_with?("postgres://") || url.starts_with?("postgresql://")
Persistence::Postgres::PostgresPersistence.new(url)
else
raise ConfigError.new("unknown database URL: #{url} (expected sqlite:PATH or postgres://...)")
end
end
def self.register_rotators(worker : Engine::RotationWorker, io : IO) : Nil
worker.register(:env_file, Rotators::EnvFileRotator.new)
if (aws_id = ENV["AWS_ACCESS_KEY_ID"]?) && (aws_secret = ENV["AWS_SECRET_ACCESS_KEY"]?)
client = Aws::SecretsManagerClient.new(
access_key_id: aws_id,
secret_access_key: aws_secret,
region: ENV["AWS_REGION"]? || "us-east-1",
endpoint: ENV["AWS_ENDPOINT"]?,
session_token: ENV["AWS_SESSION_TOKEN"]?,
)
worker.register(:aws_secretsmgr, Rotators::AwsSecretsRotator.new(client))
end
if (vault_addr = ENV["VAULT_ADDR"]?) && (vault_token = ENV["VAULT_TOKEN"]?)
client = Vault::Client.new(addr: vault_addr, token: vault_token)
worker.register(:vault_dynamic, Rotators::VaultDynamicRotator.new(client))
end
if gh_token = ENV["GITHUB_TOKEN"]?
api = ENV["GITHUB_API_BASE"]? || "https://api.github.com"
client = Github::Client.new(token: gh_token, api_base: api)
worker.register(:github_pat, Rotators::GithubPatRotator.new(client))
end
rescue ex
io.puts "warning: rotator wiring failed: #{ex.message}"
end
def self.redact_db_url(url : String) : String
url.gsub(/:\/\/[^:]+:[^@]+@/) { |_| "://****:****@" }
end
end

View File

@ -23,6 +23,7 @@ module CRE::Cli
policy show <name> inspect one policy
export --framework=<name> generate signed compliance evidence bundle
audit verify verify hash chain + HMAC ratchet + Merkle batches
verify-bundle <zip> verify a compliance evidence ZIP offline
demo tier-1 zero-deps demo (SQLite + .env rotator)
tui-demo 8-second TUI preview with synthetic events
version print version
@ -44,15 +45,16 @@ module CRE::Cli
when "version"
io.puts CRE::VERSION
0
when "run" then Commands::Run.new.execute(argv, io)
when "watch" then Commands::Watch.new.execute(argv, io)
when "check" then Commands::Check.new.execute(argv, io)
when "rotate" then Commands::Rotate.new.execute(argv, io)
when "policy" then Commands::Policy.new.execute(argv, io)
when "export" then Commands::Export.new.execute(argv, io)
when "audit" then Commands::Audit.new.execute(argv, io)
when "demo" then Commands::Demo.new.execute(argv, io)
when "tui-demo" then Commands::TuiDemo.new.execute(argv, io)
when "run" then Commands::Run.new.execute(argv, io)
when "watch" then Commands::Watch.new.execute(argv, io)
when "check" then Commands::Check.new.execute(argv, io)
when "rotate" then Commands::Rotate.new.execute(argv, io)
when "policy" then Commands::Policy.new.execute(argv, io)
when "export" then Commands::Export.new.execute(argv, io)
when "audit" then Commands::Audit.new.execute(argv, io)
when "verify-bundle" then Commands::VerifyBundle.new.execute(argv, io)
when "demo" then Commands::Demo.new.execute(argv, io)
when "tui-demo" then Commands::TuiDemo.new.execute(argv, io)
else
io.puts "unknown subcommand: #{subcommand}"
io.puts USAGE

View File

@ -10,6 +10,7 @@ require "./commands/rotate"
require "./commands/policy"
require "./commands/export"
require "./commands/audit"
require "./commands/verify_bundle"
require "./commands/demo"
require "./commands/tui_demo"
require "./commands/version"

View File

@ -4,7 +4,9 @@
# ===================
require "../../audit/audit_log"
require "../../audit/signing"
require "../../persistence/sqlite/sqlite_persistence"
require "../bootstrap"
module CRE::Cli::Commands
class Audit
@ -13,7 +15,15 @@ module CRE::Cli::Commands
case sub
when "verify" then verify(argv, io)
when nil, "--help", "-h"
io.puts "Usage: cre audit verify [--db=PATH]"
io.puts <<-USAGE
Usage: cre audit verify [--db=PATH] [--public-key=PATH]
Verifies the local audit log in three layers:
- hash chain (always)
- HMAC ratchet (always; requires CRE_HMAC_KEY_HEX)
- Merkle batch signatures (when --public-key=PATH or
CRE_AUDIT_PUBLIC_KEY_HEX is set)
USAGE
0
else
io.puts "unknown audit subcommand: #{sub}"
@ -23,21 +33,53 @@ module CRE::Cli::Commands
private def verify(argv : Array(String), io : IO) : Int32
db_path = ENV["CRE_DB_PATH"]? || "cre.db"
hmac_hex = ENV["CRE_HMAC_KEY_HEX"]? || "0" * 64
public_key_hex = ENV["CRE_AUDIT_PUBLIC_KEY_HEX"]?
public_key_path = nil
OptionParser.parse(argv) do |parser|
parser.on("--db=PATH", "") { |p| db_path = p }
parser.on("--public-key=PATH", "Ed25519 public key (32 bytes hex) for batch signature verification") { |p| public_key_path = p }
end
hmac_key = Bootstrap.require_hmac_key
persist = CRE::Persistence::Sqlite::SqlitePersistence.new(db_path)
persist.migrate!
log = CRE::Audit::AuditLog.new(persist, hmac_hex.hexbytes, 1, 1024)
ok = log.verify_chain
log = CRE::Audit::AuditLog.new(persist, hmac_key, 1, 1024)
hash_ok = log.verify_hash_chain
hmac_ok = log.verify_hmac_ratchet(hmac_key)
latest_seq = persist.audit.latest_seq
verifier_pem_hex = if path = public_key_path
File.read(path).strip
else
public_key_hex
end
batches_ok = true
if (hex = verifier_pem_hex) && !hex.empty?
if hex.size != 64
io.puts "✗ public key must be 64 hex chars (32 bytes); got #{hex.size}"
persist.close
return 2
end
verifier = CRE::Audit::Signing::Ed25519Verifier.new(hex.hexbytes)
batches_ok = log.verify_batches(verifier)
end
persist.close
if ok
print_result(io, "hash chain", hash_ok)
print_result(io, "HMAC ratchet", hmac_ok)
if verifier_pem_hex
print_result(io, "Merkle batches", batches_ok)
else
io.puts " - Merkle batches not checked (no public key supplied)"
end
if hash_ok && hmac_ok && batches_ok
io.puts "✓ audit chain valid: #{latest_seq} entries"
0
else
@ -45,5 +87,9 @@ module CRE::Cli::Commands
2
end
end
private def print_result(io : IO, label : String, ok : Bool) : Nil
io.puts " #{ok ? "" : ""} #{label}: #{ok ? "OK" : "FAILED"}"
end
end
end

View File

@ -3,10 +3,10 @@
# rotate.cr
# ===================
require "../bootstrap"
require "../../engine/event_bus"
require "../../engine/rotation_orchestrator"
require "../../persistence/sqlite/sqlite_persistence"
require "../../rotators/env_file"
require "../../engine/rotation_worker"
module CRE::Cli::Commands
class Rotate
@ -17,7 +17,7 @@ module CRE::Cli::Commands
OptionParser.parse(argv) do |parser|
parser.banner = "Usage: cre rotate <credential-id> [options]"
parser.on("--db=URL", "") { |u| db_url = u }
parser.on("--db=URL", "database URL (sqlite:path or postgres://...)") { |u| db_url = u }
parser.on("-h", "--help") { _help_requested = true; io.puts parser }
parser.unknown_args { |args| cred_id_str = args.first? }
end
@ -34,34 +34,31 @@ module CRE::Cli::Commands
return 64
end
persist = if db_url.starts_with?("sqlite:")
CRE::Persistence::Sqlite::SqlitePersistence.new(db_url.lchop("sqlite:"))
else
raise "rotate currently supports SQLite only via CLI shortcut"
end
envelope = CRE::Cli::Bootstrap.envelope
persist = CRE::Cli::Bootstrap.build_persistence(db_url)
persist.migrate!
cred = persist.credentials.find(cred_id)
if cred.nil?
io.puts "credential not found: #{cred_id}"
return 1
end
rotator_class = CRE::Rotators::Rotator.for(rotator_kind_for(cred.kind))
if rotator_class.nil?
io.puts "no rotator registered for kind=#{cred.kind}"
persist.close
return 1
end
bus = CRE::Engine::EventBus.new
bus.run
orchestrator = CRE::Engine::RotationOrchestrator.new(bus, persist)
orchestrator = CRE::Engine::RotationOrchestrator.new(bus, persist, envelope)
worker = CRE::Engine::RotationWorker.new(bus, orchestrator, persist)
CRE::Cli::Bootstrap.register_rotators(worker, io)
rotator = case cred.kind
when CRE::Domain::CredentialKind::EnvFile then CRE::Rotators::EnvFileRotator.new
else
raise "this CLI shortcut only supports env_file via direct rotation; cloud rotators need full daemon config"
end
rotator = worker.rotator_for_kind(cred.kind)
if rotator.nil?
io.puts "no rotator registered for #{cred.kind} (set the matching env vars; see README)"
bus.stop
persist.close
return 1
end
io.puts "Rotating #{cred.name} (#{cred.id}) via #{rotator.kind}..."
state = orchestrator.run(cred, rotator)
@ -70,21 +67,14 @@ module CRE::Cli::Commands
persist.close
case state
when CRE::Persistence::RotationState::Completed then io.puts "✓ rotation completed"; 0
when CRE::Persistence::RotationState::Failed then io.puts "✗ rotation failed"; 1
else io.puts "rotation ended in unexpected state #{state}"; 2
end
end
private def rotator_kind_for(kind : CRE::Domain::CredentialKind) : Symbol
case kind
in .aws_secretsmgr? then :aws_secretsmgr
in .vault_dynamic? then :vault_dynamic
in .github_pat? then :github_pat
in .env_file? then :env_file
in .aws_iam_key? then :aws_iam_key
in .database? then :database
when CRE::Persistence::RotationState::Completed then io.puts "✓ rotation completed"; 0
when CRE::Persistence::RotationState::Failed then io.puts "✗ rotation failed"; 1
when CRE::Persistence::RotationState::Inconsistent then io.puts "✗ rotation INCONSISTENT — manual intervention required"; 2
else io.puts "rotation ended in unexpected state #{state}"; 2
end
rescue ex : CRE::Cli::Bootstrap::ConfigError
io.puts "configuration error: #{ex.message}"
78
end
end
end

View File

@ -3,24 +3,18 @@
# run.cr
# ===================
require "../bootstrap"
require "../../engine/engine"
require "../../engine/scheduler"
require "../../engine/rotation_orchestrator"
require "../../engine/rotation_worker"
require "../../persistence/sqlite/sqlite_persistence"
require "../../persistence/postgres/postgres_persistence"
require "../../audit/batch_sealer"
require "../../audit/batch_sealer_scheduler"
require "../../policy/evaluator"
require "../../notifiers/log_notifier"
require "../../notifiers/telegram"
require "../../notifiers/telegram_subscriber"
require "../../notifiers/telegram_bot"
require "../../rotators/env_file"
require "../../rotators/aws_secrets"
require "../../rotators/vault_dynamic"
require "../../rotators/github_pat"
require "../../aws/secrets_client"
require "../../vault/client"
require "../../github/client"
module CRE::Cli::Commands
class Run
@ -40,7 +34,6 @@ module CRE::Cli::Commands
def execute(argv : Array(String), io : IO) : Int32
_help_requested = false
db_url = ENV["DATABASE_URL"]? || "sqlite:cre.db"
hmac_hex = ENV["CRE_HMAC_KEY_HEX"]? || "0" * 64
interval = (ENV["CRE_TICK_SECONDS"]? || "60").to_i
OptionParser.parse(argv) do |parser|
@ -51,17 +44,26 @@ module CRE::Cli::Commands
end
return 0 if _help_requested
persist = build_persistence(db_url)
hmac_key = Bootstrap.require_hmac_key
envelope = Bootstrap.require_envelope
signer = Bootstrap.signer
persist = Bootstrap.build_persistence(db_url)
persist.migrate!
engine = CRE::Engine::Engine.new(persist, hmac_hex.hexbytes)
engine = CRE::Engine::Engine.new(persist, hmac_key)
log_notifier = CRE::Notifiers::LogNotifier.new(engine.bus)
evaluator = CRE::Policy::Evaluator.new(engine.bus, persist)
scheduler = CRE::Engine::Scheduler.new(engine.bus, interval.seconds)
orchestrator = CRE::Engine::RotationOrchestrator.new(engine.bus, persist)
orchestrator = CRE::Engine::RotationOrchestrator.new(engine.bus, persist, envelope)
worker = CRE::Engine::RotationWorker.new(engine.bus, orchestrator, persist)
register_rotators(worker, io)
Bootstrap.register_rotators(worker, io)
sealer_scheduler = if signer_obj = signer
sealer = CRE::Audit::BatchSealer.new(persist, signer_obj)
CRE::Audit::BatchSealerScheduler.new(engine.bus, sealer, Bootstrap.seal_interval)
end
telegram_pieces = wire_telegram(engine.bus, persist, io)
@ -70,10 +72,13 @@ module CRE::Cli::Commands
worker.start
evaluator.start
scheduler.start
sealer_scheduler.try(&.start)
telegram_pieces.each(&.start)
io.puts "cre running. PID #{Process.pid}, tick #{interval}s, db #{redact(db_url)}"
io.puts "cre running. PID #{Process.pid}, tick #{interval}s, db #{Bootstrap.redact_db_url(db_url)}"
io.puts "rotators: #{worker.kinds.map(&.to_s).join(", ")}"
io.puts "envelope: AES-256-GCM (KEK v#{ENV[Bootstrap::KEK_VERSION_VAR]? || "1"})"
io.puts "audit batches: #{sealer_scheduler.nil? ? "(disabled — set #{Bootstrap::SIGNING_KEY_VAR})" : "every #{Bootstrap.seal_interval.total_seconds.to_i}s"}"
io.puts "telegram: #{telegram_pieces.empty? ? "(disabled)" : "enabled"}"
stop_signal = Channel(Nil).new
@ -82,6 +87,7 @@ module CRE::Cli::Commands
scheduler.stop
evaluator.stop
worker.stop
sealer_scheduler.try(&.stop)
log_notifier.stop
telegram_pieces.each(&.stop)
engine.stop
@ -91,44 +97,9 @@ module CRE::Cli::Commands
stop_signal.receive
0
end
private def build_persistence(url : String) : CRE::Persistence::Persistence
if url.starts_with?("sqlite:")
CRE::Persistence::Sqlite::SqlitePersistence.new(url.lchop("sqlite:"))
elsif url.starts_with?("postgres://") || url.starts_with?("postgresql://")
CRE::Persistence::Postgres::PostgresPersistence.new(url)
else
raise "unknown database URL: #{url}"
end
end
private def register_rotators(worker : CRE::Engine::RotationWorker, io : IO) : Nil
worker.register(:env_file, CRE::Rotators::EnvFileRotator.new)
if (aws_id = ENV["AWS_ACCESS_KEY_ID"]?) && (aws_secret = ENV["AWS_SECRET_ACCESS_KEY"]?)
client = CRE::Aws::SecretsManagerClient.new(
access_key_id: aws_id,
secret_access_key: aws_secret,
region: ENV["AWS_REGION"]? || "us-east-1",
endpoint: ENV["AWS_ENDPOINT"]?,
session_token: ENV["AWS_SESSION_TOKEN"]?,
)
worker.register(:aws_secretsmgr, CRE::Rotators::AwsSecretsRotator.new(client))
end
if (vault_addr = ENV["VAULT_ADDR"]?) && (vault_token = ENV["VAULT_TOKEN"]?)
client = CRE::Vault::Client.new(addr: vault_addr, token: vault_token)
worker.register(:vault_dynamic, CRE::Rotators::VaultDynamicRotator.new(client))
end
if gh_token = ENV["GITHUB_TOKEN"]?
api = ENV["GITHUB_API_BASE"]? || "https://api.github.com"
client = CRE::Github::Client.new(token: gh_token, api_base: api)
worker.register(:github_pat, CRE::Rotators::GithubPatRotator.new(client))
end
rescue ex
io.puts "warning: rotator wiring failed: #{ex.message}"
rescue ex : CRE::Cli::Bootstrap::ConfigError
io.puts "configuration error: #{ex.message}"
78 # EX_CONFIG
end
private def wire_telegram(bus : CRE::Engine::EventBus, persist : CRE::Persistence::Persistence, io : IO) : Array(StartStop)
@ -153,8 +124,8 @@ module CRE::Cli::Commands
viewer_chats: viewer_chats, operator_chats: operator_chats,
)
pieces << StartStop.new(start_proc: ->{ sub.start }, stop_proc: ->{ sub.stop })
pieces << StartStop.new(start_proc: ->{ bot.start }, stop_proc: ->{ bot.stop })
pieces << StartStop.new(start_proc: -> { sub.start }, stop_proc: -> { sub.stop })
pieces << StartStop.new(start_proc: -> { bot.start }, stop_proc: -> { bot.stop })
pieces
end
@ -162,9 +133,5 @@ module CRE::Cli::Commands
return [] of Int64 if raw.nil? || raw.empty?
raw.split(',').map(&.strip).reject(&.empty?).map(&.to_i64)
end
private def redact(url : String) : String
url.gsub(/:\/\/[^:]+:[^@]+@/) { |_| "://****:****@" }
end
end
end

View File

@ -0,0 +1,218 @@
# ===================
# ©AngelaMos | 2026
# verify_bundle.cr
# ===================
require "compress/zip"
require "json"
require "openssl/digest"
require "../../audit/hash_chain"
require "../../audit/signing"
require "../../audit/merkle"
module CRE::Cli::Commands
# VerifyBundle reads a compliance evidence ZIP produced by 'cre export'
# and re-runs every check the bundle's README documents:
#
# 1. Per-file SHA-256 vs manifest.json
# 2. Manifest Ed25519 signature vs public_key.pem (if both present)
# 3. Audit log hash chain reconstruction
# 4. Audit batch Merkle-root + signature reconstruction
#
# Exit codes:
# 0 - all checks passed
# 2 - one or more checks failed
# 64 - usage error
class VerifyBundle
def execute(argv : Array(String), io : IO) : Int32
_help_requested = false
bundle_path = nil
OptionParser.parse(argv) do |parser|
parser.banner = "Usage: cre verify-bundle <evidence.zip>"
parser.on("-h", "--help") { _help_requested = true; io.puts parser }
parser.unknown_args { |args| bundle_path = args.first? }
end
return 0 if _help_requested
if bundle_path.nil?
io.puts "usage: cre verify-bundle <evidence.zip>"
return 64
end
bundle = bundle_path.not_nil!
unless File.exists?(bundle)
io.puts "bundle not found: #{bundle}"
return 2
end
contents = read_zip(bundle)
manifest_text = contents["manifest.json"]?
if manifest_text.nil?
io.puts "✗ bundle is missing manifest.json"
return 2
end
manifest = JSON.parse(manifest_text)
checks_passed = true
checks_passed &= verify_checksums(io, contents, manifest)
checks_passed &= verify_manifest_signature(io, contents, manifest_text)
checks_passed &= verify_hash_chain(io, contents)
checks_passed &= verify_batches(io, contents)
if checks_passed
io.puts "✓ bundle valid"
0
else
io.puts "✗ bundle FAILED verification"
2
end
end
private def read_zip(path : String) : Hash(String, String)
out = {} of String => String
Compress::Zip::File.open(path) do |zip|
zip.entries.each do |entry|
out[entry.filename] = entry.open(&.gets_to_end)
end
end
out
end
private def verify_checksums(io : IO, contents : Hash(String, String), manifest : JSON::Any) : Bool
ok = true
manifest["files"].as_a.each do |entry|
name = entry["name"].as_s
expected = entry["sha256"].as_s
body = contents[name]?
if body.nil?
io.puts " ✗ checksum: missing #{name}"
ok = false
next
end
actual = sha256_hex(body)
if actual != expected
io.puts " ✗ checksum mismatch on #{name}: expected #{expected}, got #{actual}"
ok = false
end
end
io.puts "#{manifest["files"].as_a.size} file checksums match" if ok
ok
end
private def verify_manifest_signature(io : IO, contents : Hash(String, String), manifest_text : String) : Bool
sig_b64 = contents["manifest.sig"]?
pubkey_hex = contents["public_key.pem"]?
if sig_b64.nil? || pubkey_hex.nil?
io.puts " - manifest.sig / public_key.pem absent — signature step skipped"
return true
end
sig = Base64.decode(sig_b64.strip)
key_bytes = pubkey_hex.strip.hexbytes
verifier = CRE::Audit::Signing::Ed25519Verifier.new(key_bytes)
if verifier.verify(manifest_text.to_slice, sig)
io.puts " ✓ manifest signature valid"
true
else
io.puts " ✗ manifest signature INVALID"
false
end
rescue ex
io.puts " ✗ manifest signature verification error: #{ex.message}"
false
end
private def verify_hash_chain(io : IO, contents : Hash(String, String)) : Bool
ndjson = contents["audit_log.ndjson"]?
if ndjson.nil? || ndjson.empty?
io.puts " - audit_log.ndjson empty — hash chain step skipped"
return true
end
prev = Bytes.new(32, 0_u8)
lineno = 0
ndjson.each_line do |line|
lineno += 1
next if line.empty?
row = JSON.parse(line)
# The exporter writes 'payload' as the canonical JSON the log used
# when computing content_hash, so we hash that string verbatim.
canonical = row["payload"].as_s
expected = CRE::Audit::HashChain.next_hash(prev, canonical.to_slice)
actual = row["content_hash_hex"].as_s.hexbytes
unless expected == actual
io.puts " ✗ hash chain broken at seq=#{row["seq"]?} (line #{lineno})"
return false
end
prev = actual
end
io.puts " ✓ hash chain valid (#{lineno} entries)"
true
end
private def verify_batches(io : IO, contents : Hash(String, String)) : Bool
batches_json = contents["audit_batches.json"]?
pubkey_hex = contents["public_key.pem"]?
if batches_json.nil? || batches_json == "[]"
io.puts " - audit_batches.json empty — batch verify skipped"
return true
end
if pubkey_hex.nil?
io.puts " ✗ batches present but public_key.pem absent — cannot verify"
return false
end
verifier = CRE::Audit::Signing::Ed25519Verifier.new(pubkey_hex.strip.hexbytes)
ndjson = contents["audit_log.ndjson"]? || ""
content_hashes_by_seq = {} of Int64 => Bytes
ndjson.each_line do |line|
next if line.empty?
row = JSON.parse(line)
content_hashes_by_seq[row["seq"].as_i64] = row["content_hash_hex"].as_s.hexbytes
end
batches = JSON.parse(batches_json).as_a
batches.each do |b|
start_seq = b["start_seq"].as_i64
end_seq = b["end_seq"].as_i64
leaves = (start_seq..end_seq).map do |seq|
h = content_hashes_by_seq[seq]?
if h.nil?
io.puts " ✗ batch covers seq=#{seq} but ndjson is missing it"
return false
end
h
end
recomputed = CRE::Audit::Merkle.root(leaves)
stored = b["merkle_root_hex"].as_s.hexbytes
unless recomputed == stored
io.puts " ✗ Merkle root mismatch on batch [#{start_seq}..#{end_seq}]"
return false
end
msg = pack_batch_message(start_seq, end_seq, stored)
sig = b["signature_hex"].as_s.hexbytes
unless verifier.verify(msg, sig)
io.puts " ✗ batch signature invalid for [#{start_seq}..#{end_seq}]"
return false
end
end
io.puts "#{batches.size} audit batches verified"
true
end
private def pack_batch_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 sha256_hex(content : String) : String
d = OpenSSL::Digest.new("SHA256")
d.update(content)
d.hexfinal
end
end
end

View File

@ -3,10 +3,13 @@
# watch.cr
# ===================
require "../bootstrap"
require "../../engine/engine"
require "../../engine/scheduler"
require "../../persistence/sqlite/sqlite_persistence"
require "../../persistence/postgres/postgres_persistence"
require "../../engine/rotation_orchestrator"
require "../../engine/rotation_worker"
require "../../audit/batch_sealer"
require "../../audit/batch_sealer_scheduler"
require "../../policy/evaluator"
require "../../tui/tui"
@ -15,38 +18,55 @@ module CRE::Cli::Commands
def execute(argv : Array(String), io : IO) : Int32
_help_requested = false
db_url = ENV["DATABASE_URL"]? || "sqlite:cre.db"
hmac_hex = ENV["CRE_HMAC_KEY_HEX"]? || "0" * 64
interval = (ENV["CRE_TICK_SECONDS"]? || "60").to_i
OptionParser.parse(argv) do |parser|
parser.banner = "Usage: cre watch [options]"
parser.on("--db=URL", "") { |u| db_url = u }
parser.on("--db=URL", "database URL (sqlite:path or postgres://...)") { |u| db_url = u }
parser.on("--interval=SECONDS", "scheduler tick interval") { |i| interval = i.to_i }
parser.on("-h", "--help") { _help_requested = true; io.puts parser }
end
return 0 if _help_requested
persist = if db_url.starts_with?("sqlite:")
CRE::Persistence::Sqlite::SqlitePersistence.new(db_url.lchop("sqlite:"))
elsif db_url.starts_with?("postgres://") || db_url.starts_with?("postgresql://")
CRE::Persistence::Postgres::PostgresPersistence.new(db_url)
else
raise "unknown database URL"
end
hmac_key = Bootstrap.require_hmac_key
envelope = Bootstrap.require_envelope
signer = Bootstrap.signer
persist = Bootstrap.build_persistence(db_url)
persist.migrate!
engine = CRE::Engine::Engine.new(persist, hmac_hex.hexbytes)
engine = CRE::Engine::Engine.new(persist, hmac_key)
evaluator = CRE::Policy::Evaluator.new(engine.bus, persist)
scheduler = CRE::Engine::Scheduler.new(engine.bus, 60.seconds)
tui = CRE::Tui::Tui.new(engine.bus)
scheduler = CRE::Engine::Scheduler.new(engine.bus, interval.seconds)
orchestrator = CRE::Engine::RotationOrchestrator.new(engine.bus, persist, envelope)
worker = CRE::Engine::RotationWorker.new(engine.bus, orchestrator, persist)
Bootstrap.register_rotators(worker, io)
sealer_scheduler = if signer_obj = signer
sealer = CRE::Audit::BatchSealer.new(persist, signer_obj)
CRE::Audit::BatchSealerScheduler.new(engine.bus, sealer, Bootstrap.seal_interval)
end
kek_version = (ENV[Bootstrap::KEK_VERSION_VAR]? || "1").to_i
tui_state = CRE::Tui::State.new(kek_version: kek_version)
tui_snapshotter = CRE::Tui::Snapshotter.new(tui_state, persist)
tui = CRE::Tui::Tui.new(engine.bus, tui_state)
engine.start
worker.start
evaluator.start
scheduler.start
sealer_scheduler.try(&.start)
tui_snapshotter.start
tui.start
Signal::INT.trap do
tui.stop
tui_snapshotter.stop
scheduler.stop
evaluator.stop
worker.stop
sealer_scheduler.try(&.stop)
engine.stop
persist.close
exit 0
@ -54,6 +74,9 @@ module CRE::Cli::Commands
sleep
0
rescue ex : CRE::Cli::Bootstrap::ConfigError
io.puts "configuration error: #{ex.message}"
78
end
end
end

View File

@ -19,7 +19,9 @@ module CRE::Compliance
# manifest.json - file checksums + signature
# audit_log.ndjson - raw audit events with hash-chain fields
# audit_batches.json - signed Merkle batch roots over the period
# public_key.pem - Ed25519 public key (for verification)
# public_key.pem - Ed25519 public key (32 hex bytes; SHA-256
# covered by manifest so substitution shows
# up as a manifest checksum mismatch)
# control_mapping.json - event_type -> framework controls
class Bundle
record FileEntry, name : String, sha256_hex : String, size : Int32
@ -28,49 +30,53 @@ module CRE::Compliance
@persistence : Persistence::Persistence,
@framework : String,
@signer : Audit::Signing::Ed25519Signer? = nil,
@public_key_pem : String? = nil,
@public_key_hex : String? = nil,
)
end
def write(path : String) : Nil
File.open(path, "w") do |fp|
Compress::Zip::Writer.open(fp) do |zip|
entries = [] of FileEntry
add_file(zip, entries, "audit_log.ndjson", build_audit_log_ndjson)
add_file(zip, entries, "audit_batches.json", build_audit_batches_json)
add_file(zip, entries, "control_mapping.json", build_control_mapping_json)
add_file(zip, entries, "README.md", build_readme(entries))
manifest = build_manifest(entries)
add_file(zip, entries, "manifest.json", manifest)
if pem = @public_key_pem
add_file(zip, entries, "public_key.pem", pem)
# 1) build raw bytes for every file we plan to ship
payload_files = [] of {String, String}
payload_files << {"audit_log.ndjson", build_audit_log_ndjson}
payload_files << {"audit_batches.json", build_audit_batches_json}
payload_files << {"control_mapping.json", build_control_mapping_json}
if pem = @public_key_hex
payload_files << {"public_key.pem", pem}
end
# 2) compute checksums and build manifest covering ALL payload files
# (including public_key.pem so substitution invalidates the manifest sig)
payload_entries = payload_files.map do |(name, content)|
FileEntry.new(name: name, sha256_hex: sha256_hex(content), size: content.bytesize)
end
readme = build_readme(payload_entries)
payload_files << {"README.md", readme}
payload_entries << FileEntry.new(name: "README.md", sha256_hex: sha256_hex(readme), size: readme.bytesize)
manifest = build_manifest(payload_entries)
# 3) emit zip in dependency order
payload_files.each { |(name, content)| add_file(zip, name, content) }
add_file(zip, "manifest.json", manifest)
if signer = @signer
sig = signer.sign(manifest.to_slice)
add_file(zip, entries, "manifest.sig", Base64.encode(sig))
add_file(zip, "manifest.sig", Base64.encode(sig))
end
end
end
end
private def add_file(zip, entries : Array(FileEntry), name : String, content : String) : Nil
private def add_file(zip, name : String, content : String) : Nil
zip.add(name) { |io| io << content }
entries << FileEntry.new(
name: name,
sha256_hex: sha256_hex(content),
size: content.bytesize,
)
end
private def build_audit_log_ndjson : String
latest = @persistence.audit.latest_seq
return "" if latest == 0
io = IO::Memory.new
@persistence.audit.range(1_i64, latest).each do |entry|
@persistence.audit.each_in_range(1_i64, latest) do |entry|
row = {
"seq" => entry.seq,
"event_id" => entry.event_id.to_s,
@ -91,12 +97,20 @@ module CRE::Compliance
end
private def build_audit_batches_json : String
sealed = @persistence.audit.last_sealed_seq
return "[]" if sealed == 0
# We need a way to enumerate batches; AuditRepo currently exposes
# last_sealed_seq but not the batch list. For now write the most-recent
# batch metadata. Future work: add AuditRepo#all_batches.
"[]"
batches = @persistence.audit.all_batches
return "[]" if batches.empty?
rows = batches.map do |b|
{
"id" => b.id.to_s,
"start_seq" => b.start_seq,
"end_seq" => b.end_seq,
"merkle_root_hex" => b.merkle_root.hexstring,
"signature_hex" => b.signature.hexstring,
"signing_key_version" => b.signing_key_version,
"sealed_at" => b.sealed_at.to_rfc3339,
}
end
rows.to_json
end
private def build_control_mapping_json : String
@ -124,18 +138,28 @@ module CRE::Compliance
- audit_log.ndjson raw audit events with hash-chain fields
- audit_batches.json signed Merkle batches over the period
- control_mapping.json event_type -> framework controls
- manifest.json per-file SHA-256 checksums
- public_key.pem Ed25519 public key (32 hex bytes)
- manifest.json per-file SHA-256 checksums (covers public_key.pem)
- manifest.sig Ed25519 signature of manifest.json (if signed)
- public_key.pem Ed25519 public key (if signed)
Verification:
1. Recompute SHA-256 of each file and compare against manifest.json.
2. If manifest.sig is present, verify with public_key.pem.
3. Walk audit_log.ndjson and recompute the hash chain - each row's
content_hash should equal SHA256(prev_hash || canonical(payload+meta)).
For automated verification, run:
cre verify-bundle <this-zip>
Manual verification:
1. Recompute SHA-256 of every file listed in manifest.json and compare.
2. Verify manifest.sig over manifest.json using public_key.pem.
3. Walk audit_log.ndjson and recompute the hash chain - each row's
content_hash should equal SHA256(prev_hash || canonical(payload)).
4. For each row in audit_batches.json, recompute the Merkle root over
content_hashes in [start_seq, end_seq] and verify signature_hex
with public_key.pem.
Note on public_key.pem trust:
The bundled public key is fingerprint-protected by manifest.json's
SHA-256 entry. The manifest itself is Ed25519-signed; if an adversary
substitutes the key+sig pair, manifest.json's checksum no longer
matches the bundled file. Auditors should still verify the in-bundle
public key's SHA-256 against an out-of-band fingerprint they trust.
MD
end

View File

@ -8,11 +8,9 @@ require "uuid"
module CRE::Domain
enum CredentialKind
AwsSecretsmgr
AwsIamKey
VaultDynamic
GithubPat
EnvFile
Database
end
struct Credential
@ -26,6 +24,7 @@ module CRE::Domain
getter previous_version_id : UUID?
getter created_at : Time
getter updated_at : Time
getter last_rotated_at : Time?
def initialize(
@id : UUID,
@ -38,11 +37,16 @@ module CRE::Domain
@previous_version_id : UUID? = nil,
@created_at : Time = Time.utc,
@updated_at : Time = Time.utc,
@last_rotated_at : Time? = nil,
)
end
def tag(key : String | Symbol) : String?
@tags[key.to_s]?
end
def rotation_anchor : Time
@last_rotated_at || @created_at
end
end
end

View File

@ -39,7 +39,13 @@ module CRE::Engine
return unless @started
Log.info { "engine stopping" }
@bus.publish(Events::ShutdownRequested.new) rescue nil
sleep 0.05.seconds # let subscribers see it
# Wait until the audit subscriber has drained everything queued
# before ShutdownRequested. Bounded so a stuck subscriber can't
# hang shutdown forever.
drained = @audit_subscriber.await_drain(2.seconds)
Log.warn { "audit subscriber did not drain in time during shutdown" } unless drained
@audit_subscriber.stop
@bus.stop
@started = false

View File

@ -22,7 +22,7 @@ module CRE::Engine
@subs_mutex : Mutex
@running : Bool
def initialize(inbox_capacity : Int32 = 1024)
def initialize(inbox_capacity : Int32 = 1024, @block_send_timeout : Time::Span = 5.seconds)
@inbox = Channel(Events::Event).new(capacity: inbox_capacity)
@subs = [] of Subscription
@subs_mutex = Mutex.new
@ -61,10 +61,24 @@ module CRE::Engine
end
end
# Dispatch isolates each subscriber so a slow one cannot block the bus
# for everyone else (head-of-line blocking).
#
# Drop overflow uses a non-blocking select+default; rejections are
# logged. Block overflow uses a select with a timeout; if the
# subscriber's buffer stays full beyond the timeout, the event is
# logged as dropped and the bus moves on rather than freezing the
# entire pipeline. Operators tune the timeout up for slow downstreams
# they trust (DB writes) and down for unreliable ones (Telegram).
private def dispatch(sub : Subscription, ev : Events::Event) : Nil
case sub.overflow
in Overflow::Block
sub.channel.send(ev)
select
when sub.channel.send(ev)
# delivered
when timeout(@block_send_timeout)
Log.warn { "subscriber stalled past #{@block_send_timeout.total_seconds}s; dropped: #{ev.class.name}" }
end
in Overflow::Drop
select
when sub.channel.send(ev)

View File

@ -8,16 +8,33 @@ require "uuid"
require "./event_bus"
require "../events/credential_events"
require "../rotators/rotator"
require "../crypto/envelope"
require "../persistence/persistence"
require "../persistence/repos"
module CRE::Engine
class VerifyFailed < Exception; end
# The orchestrator drives a credential through the four-step rotation
# contract (generate -> apply -> verify -> commit). On success it
# 1. seals the new secret with the optional Envelope and writes a
# credential_versions row,
# 2. bumps the credential's last_rotated_at and current/previous
# version pointers so the policy evaluator no longer sees it as
# overdue.
#
# Failures roll the credential back to its previous state. Failures in
# apply or verify call rotator.rollback_apply; commit failures additionally
# mark the rotation as Inconsistent because partial cloud-side stage
# transitions cannot always be undone client-side.
class RotationOrchestrator
Log = ::Log.for("cre.rotator")
def initialize(@bus : EventBus, @persistence : Persistence::Persistence)
def initialize(
@bus : EventBus,
@persistence : Persistence::Persistence,
@envelope : Crypto::Envelope? = nil,
)
end
def run(c : Domain::Credential, rotator : Rotators::Rotator) : Persistence::RotationState
@ -58,28 +75,90 @@ module CRE::Engine
current_step = :commit
@bus.publish Events::RotationStepStarted.new(c.id, rotation_id, :commit)
rotator.commit(c, new_secret)
@persistence.rotations.update_state(rotation_id, Persistence::RotationState::Completed)
@bus.publish Events::RotationStepCompleted.new(c.id, rotation_id, :commit)
@bus.publish Events::RotationCompleted.new(c.id, rotation_id)
finalize_success(c, new_secret, rotation_id)
Persistence::RotationState::Completed
rescue ex
if ns = new_secret
if current_step == :apply || current_step == :verify
begin
rotator.rollback_apply(c, ns)
rescue rb
Log.error(exception: rb) { "rollback_apply failed for credential #{c.id}" }
end
if (ns = new_secret) && (current_step == :apply || current_step == :verify)
begin
rotator.rollback_apply(c, ns)
rescue rb
Log.error(exception: rb) { "rollback_apply failed for credential #{c.id}" }
end
end
@persistence.rotations.update_state(rotation_id, Persistence::RotationState::Failed, ex.message || ex.class.name)
@bus.publish Events::RotationStepFailed.new(c.id, rotation_id, current_step, ex.message || ex.class.name)
@bus.publish Events::RotationFailed.new(c.id, rotation_id, ex.message || ex.class.name)
Persistence::RotationState::Failed
finalize_failure(c, rotation_id, current_step, ex)
end
end
private def finalize_success(c : Domain::Credential, secret : Domain::NewSecret, rotation_id : UUID) : Nil
version_id = persist_credential_version(c, secret)
bump_credential(c, version_id)
@persistence.rotations.update_state(rotation_id, Persistence::RotationState::Completed)
@bus.publish Events::RotationCompleted.new(c.id, rotation_id)
end
private def finalize_failure(
c : Domain::Credential,
rotation_id : UUID,
current_step : Symbol,
ex : Exception,
) : Persistence::RotationState
reason = ex.message || ex.class.name
terminal_state = current_step == :commit ? Persistence::RotationState::Inconsistent : Persistence::RotationState::Failed
@persistence.rotations.update_state(rotation_id, terminal_state, reason)
@bus.publish Events::RotationStepFailed.new(c.id, rotation_id, current_step, reason)
@bus.publish Events::RotationFailed.new(c.id, rotation_id, reason)
if terminal_state == Persistence::RotationState::Inconsistent
@bus.publish Events::AlertRaised.new(
severity: Events::Severity::Critical,
message: "rotation #{rotation_id} for credential #{c.id} left in inconsistent state at commit step: #{reason}",
)
end
terminal_state
end
private def persist_credential_version(c : Domain::Credential, secret : Domain::NewSecret) : UUID?
env = @envelope
return nil if env.nil?
aad = "cred=#{c.id}|kind=#{c.kind}".to_slice
sealed = env.seal(secret.ciphertext, aad)
version = Domain::CredentialVersion.new(
id: UUID.random,
credential_id: c.id,
ciphertext: sealed.ciphertext,
dek_wrapped: sealed.dek_wrapped,
kek_version: sealed.kek_version,
algorithm_id: sealed.algorithm_id,
metadata: secret.metadata,
generated_at: secret.generated_at,
)
@persistence.versions.insert(version)
version.id
end
private def bump_credential(c : Domain::Credential, new_version_id : UUID?) : Nil
now = Time.utc
updated = Domain::Credential.new(
id: c.id,
external_id: c.external_id,
kind: c.kind,
name: c.name,
tags: c.tags,
current_version_id: new_version_id || c.current_version_id,
pending_version_id: nil,
previous_version_id: new_version_id ? c.current_version_id : c.previous_version_id,
created_at: c.created_at,
updated_at: now,
last_rotated_at: now,
)
@persistence.credentials.update(updated)
end
private def kind_to_enum(kind : Symbol) : Persistence::RotatorKind
case kind
when :aws_secretsmgr then Persistence::RotatorKind::AwsSecretsmgr

View File

@ -38,6 +38,10 @@ module CRE::Engine
@rotators.keys
end
def rotator_for_kind(kind : Domain::CredentialKind) : Rotators::Rotator?
@rotators[symbol_for_kind(kind)]?
end
def start : Nil
@running = true
ch = @bus.subscribe(buffer: 32, overflow: EventBus::Overflow::Block)
@ -79,6 +83,11 @@ module CRE::Engine
return
end
if @persistence.rotations.in_flight.any? { |r| r.credential_id == cred.id }
Log.info { "rotation already in flight for credential #{cred.id}; skipping duplicate schedule" }
return
end
@orchestrator.run(cred, rotator)
rescue ex
Log.error(exception: ex) { "rotation_worker.handle failed for event #{ev.class.name}" }
@ -90,8 +99,6 @@ module CRE::Engine
in .vault_dynamic? then :vault_dynamic
in .github_pat? then :github_pat
in .env_file? then :env_file
in .aws_iam_key? then :aws_iam_key
in .database? then :database
end
end
end

View File

@ -12,9 +12,11 @@ module CRE::Engine::Subscribers
class AuditSubscriber
@ch : Channel(Events::Event)?
@running : Bool
@drain_ready : ::Channel(Nil)
def initialize(@bus : EventBus, @log : Audit::AuditLog, @actor : String = "system")
@running = false
@drain_ready = ::Channel(Nil).new(1)
end
def start : Nil
@ -38,8 +40,24 @@ module CRE::Engine::Subscribers
@ch.try(&.close)
end
# Block until either the subscriber receives ShutdownRequested (i.e.
# has drained everything published before the shutdown signal) or the
# timeout elapses. Lets engine.stop deterministically wait instead of
# sleeping for a magic 50ms.
def await_drain(timeout_span : Time::Span = 2.seconds) : Bool
select
when @drain_ready.receive
true
when timeout(timeout_span)
false
end
end
private def handle(ev : Events::Event) : Nil
case ev
when Events::ShutdownRequested
@drain_ready.send(nil) rescue nil
return
when Events::RotationCompleted
@log.append("rotation.completed", @actor, ev.credential_id, {
"rotation_id" => ev.rotation_id.to_s,
@ -77,9 +95,25 @@ module CRE::Engine::Subscribers
"severity" => ev.severity.to_s,
"message" => ev.message,
})
when Events::AuditBatchSealed
@log.append("audit.batch.sealed", @actor, nil, {
"start_seq" => ev.start_seq.to_s,
"end_seq" => ev.end_seq.to_s,
"signing_key_version" => ev.signing_key_version.to_s,
})
end
rescue ex
EventBus::Log.error(exception: ex) { "audit subscriber failed to write" }
@bus.publish(Events::AlertRaised.new(
severity: Events::Severity::Critical,
message: "audit log write failed: #{ex.message} (event=#{ev.class.name})",
)) rescue nil
mode = ENV["CRE_AUDIT_FAILURE_MODE"]? || "panic"
if mode == "panic"
STDERR.puts "FATAL: audit log write failed in panic mode: #{ex.message} (event=#{ev.class.name})"
Process.exit(2)
end
end
end
end

View File

@ -26,4 +26,14 @@ module CRE::Events
class ShutdownRequested < Event
end
class AuditBatchSealed < Event
getter start_seq : Int64
getter end_seq : Int64
getter signing_key_version : Int32
def initialize(@start_seq : Int64, @end_seq : Int64, @signing_key_version : Int32)
super()
end
end
end

View File

@ -5,6 +5,7 @@
require "http/client"
require "json"
require "../http/retry"
module CRE::Github
class GithubError < Exception
@ -15,10 +16,11 @@ module CRE::Github
end
end
# Thin GitHub REST client. We target fine-grained PATs for rotation since the
# /user/personal-access-tokens endpoint accepts programmatic creation /
# deletion when the bearer token has the appropriate Apps-managed permission.
# For the test/portfolio path we mock these endpoints directly.
# Thin GitHub REST client. We target fine-grained PATs for rotation since
# the /user/personal-access-tokens endpoint accepts programmatic creation
# and deletion when the bearer has the appropriate Apps-managed
# permission. For the test/portfolio path we mock these endpoints
# directly.
class Client
record Token, id : Int64, token_value : String, expires_at : String?
@ -50,19 +52,19 @@ module CRE::Github
end
private def get(path : String) : JSON::Any
response = HTTP::Client.get(url(path), headers: headers)
response = CRE::Http.request("GET", url(path), headers, label: "github.GET#{path}")
raise GithubError.new("GET #{path}: #{response.body[0, 200]?}", response.status_code) unless response.status_code < 300
JSON.parse(response.body)
end
private def post(path : String, body : String) : JSON::Any
response = HTTP::Client.post(url(path), headers: headers, body: body)
response = CRE::Http.request("POST", url(path), headers, body, label: "github.POST#{path}")
raise GithubError.new("POST #{path}: #{response.body[0, 200]?}", response.status_code) unless response.status_code < 300
JSON.parse(response.body)
end
private def delete(path : String) : Nil
response = HTTP::Client.delete(url(path), headers: headers)
response = CRE::Http.request("DELETE", url(path), headers, label: "github.DELETE#{path}")
raise GithubError.new("DELETE #{path}: #{response.body[0, 200]?}", response.status_code) unless response.status_code < 300
end

View File

@ -0,0 +1,90 @@
# ===================
# ©AngelaMos | 2026
# retry.cr
# ===================
require "http/client"
require "log"
module CRE::Http
# Transient HTTP statuses where retrying makes sense. We do NOT retry
# on 401/403/404 — those mean the request is bad, not flaky.
RETRY_STATUSES = {408, 425, 429, 500, 502, 503, 504}.to_set
# Retry wraps a Proc with bounded retry-with-jitter. Default policy:
# 3 attempts (initial + 2 retries), 200ms..2s exponential backoff with
# full jitter, retries network errors and the canonical transient
# statuses listed above.
module Retry
Log = ::Log.for("cre.http.retry")
def self.with(
*,
attempts : Int32 = 3,
base_delay : Time::Span = 200.milliseconds,
max_delay : Time::Span = 2.seconds,
retry_statuses : Set(Int32) = RETRY_STATUSES,
label : String = "http",
&block : -> HTTP::Client::Response
) : HTTP::Client::Response
attempt = 0
loop do
attempt += 1
begin
response = block.call
if retry_statuses.includes?(response.status_code) && attempt < attempts
Log.warn { "#{label}: HTTP #{response.status_code} on attempt #{attempt}; retrying" }
sleep_with_jitter(base_delay, max_delay, attempt)
next
end
return response
rescue ex : IO::TimeoutError | Socket::ConnectError | IO::Error
if attempt < attempts
Log.warn(exception: ex) { "#{label}: transient error on attempt #{attempt}; retrying" }
sleep_with_jitter(base_delay, max_delay, attempt)
next
end
raise ex
end
end
end
private def self.sleep_with_jitter(base : Time::Span, ceiling : Time::Span, attempt : Int32) : Nil
backoff_ms = base.total_milliseconds * (1 << (attempt - 1))
capped_ms = Math.min(backoff_ms, ceiling.total_milliseconds)
jittered = (capped_ms * Random.rand).to_i
sleep jittered.milliseconds
end
end
# Connect/read timeouts come from env so operators can tune per
# environment without recompiling. Defaults are conservative for cloud
# APIs where 5xx blips are normal.
CONNECT_TIMEOUT = ((ENV["CRE_HTTP_CONNECT_TIMEOUT_S"]? || "5").to_f).seconds
READ_TIMEOUT = ((ENV["CRE_HTTP_READ_TIMEOUT_S"]? || "30").to_f).seconds
# Single-shot HTTP request with timeouts + retry. Returns the
# HTTP::Client::Response. The HTTP::Client instance is created per
# call and closed in an ensure block so a hung peer can't pin
# resources past the timeout. Webmock intercepts via instance method
# override so test stubs continue to apply unchanged.
def self.request(method : String, url : String, headers : HTTP::Headers, body : String = "", label : String = "http") : HTTP::Client::Response
uri = URI.parse(url)
request_target = String.build do |s|
s << (uri.path.empty? ? "/" : uri.path)
if (q = uri.query) && !q.empty?
s << "?" << q
end
end
Retry.with(label: label) do
client = HTTP::Client.new(uri)
client.connect_timeout = CONNECT_TIMEOUT
client.read_timeout = READ_TIMEOUT
begin
client.exec(method, request_target, headers: headers, body: body)
ensure
client.close
end
end
end
end

View File

@ -6,11 +6,18 @@
require "http/client"
require "json"
require "log"
require "../http/retry"
module CRE::Notifiers
# Thin Telegram Bot API client. We hit api.telegram.org/bot<TOKEN>/<METHOD>
# directly with HTTP::Client; no tourmaline dependency for the notification
# path keeps the footprint small.
# directly with HTTP::Client; no tourmaline dependency for the
# notification path keeps the footprint small.
#
# Telegram requires the bot token to live in the URL path. To prevent
# leaks, error messages substitute the token with '****' before
# surfacing any URL fragment. The token is still in the underlying
# request's URI; operators wiring a logging proxy in front of cre
# should redact at the proxy layer too.
class Telegram
Log = ::Log.for("cre.telegram")
DEFAULT_API = "https://api.telegram.org"
@ -55,11 +62,18 @@ module CRE::Notifiers
end
private def call(method : String, body : String) : JSON::Any
uri = "#{@api_base}/bot#{@token}/#{method}"
url = "#{@api_base}/bot#{@token}/#{method}"
headers = HTTP::Headers{"Content-Type" => "application/json"}
response = HTTP::Client.post(uri, headers: headers, body: body)
raise TelegramError.new("telegram #{method} #{response.status_code}: #{response.body[0, 200]?}", response.status_code) unless response.status_code < 300
response = CRE::Http.request("POST", url, headers, body, label: "telegram.#{method}")
unless response.status_code < 300
raise TelegramError.new("telegram #{method} #{response.status_code}: #{redact(response.body[0, 200]?)}", response.status_code)
end
JSON.parse(response.body)
end
private def redact(text : String?) : String?
return nil if text.nil?
text.gsub(@token, "****")
end
end
end

View File

@ -68,7 +68,6 @@ module CRE::Notifiers
when "alerts" then alerts_message
when "help", "" then help_message
when "rotate" then handle_rotate(chat_id, rest)
when "snooze" then handle_snooze(chat_id, rest)
when "history" then history_message(rest)
else
"unknown command: /#{cmd} (try /help)"
@ -112,7 +111,6 @@ module CRE::Notifiers
/history <id> - last events for a credential
/alerts - critical alerts pointer
/rotate <id> - force rotation (operator)
/snooze <id> 24h - defer scheduled rotation (operator)
MD
end
@ -126,11 +124,6 @@ module CRE::Notifiers
"rotation scheduled for #{uuid}"
end
private def handle_snooze(chat_id : Int64, rest : String) : String
return "operator-only command" unless authorized_operator?(chat_id)
"snooze is not yet implemented; track via /queue"
end
private def history_message(rest : String) : String
id_str = rest.strip
return "usage: /history <credential-id>" if id_str.empty?

View File

@ -11,12 +11,15 @@ module CRE::Persistence::Postgres
class AuditRepo < CRE::Persistence::AuditRepo
GENESIS_HASH = Bytes.new(32, 0_u8)
SELECT_ENTRY_COLS = "seq, event_id::text, occurred_at, event_type, actor, target_id::text, payload::text, prev_hash, content_hash, hmac, hmac_key_version"
SELECT_BATCH_COLS = "id::text, start_seq, end_seq, merkle_root, signature, signing_key_version, sealed_at"
def initialize(@db : DB::Database)
end
def append(entry : AuditEntry) : Nil
@db.exec(
"INSERT INTO audit_events (event_id, occurred_at, event_type, actor, target_id, payload, prev_hash, content_hash, hmac, hmac_key_version) VALUES ($1::uuid, $2, $3, $4, $5::uuid, $6::jsonb, $7, $8, $9, $10) ON CONFLICT (event_id) DO NOTHING",
"INSERT INTO audit_events (event_id, occurred_at, event_type, actor, target_id, payload, prev_hash, content_hash, hmac, hmac_key_version) VALUES ($1::uuid, $2, $3, $4, $5::uuid, $6::jsonb, $7, $8, $9, $10)",
entry.event_id.to_s, entry.occurred_at,
entry.event_type, entry.actor,
entry.target_id.try(&.to_s),
@ -44,12 +47,36 @@ module CRE::Persistence::Postgres
def range(start_seq : Int64, end_seq : Int64) : Array(AuditEntry)
@db.query_all(
"SELECT seq, event_id::text, occurred_at, event_type, actor, target_id::text, payload::text, prev_hash, content_hash, hmac, hmac_key_version FROM audit_events WHERE seq >= $1 AND seq <= $2 ORDER BY seq ASC",
"SELECT #{SELECT_ENTRY_COLS} FROM audit_events WHERE seq >= $1 AND seq <= $2 ORDER BY seq ASC",
start_seq, end_seq,
as: {Int64, String, Time, String, String, String?, String, Bytes, Bytes, Bytes, Int32},
).map { |row| row_to_entry(row) }
end
def each_in_range(start_seq : Int64, end_seq : Int64, &block : AuditEntry ->) : Nil
@db.query(
"SELECT #{SELECT_ENTRY_COLS} FROM audit_events WHERE seq >= $1 AND seq <= $2 ORDER BY seq ASC",
start_seq, end_seq,
) do |rs|
rs.each do
entry = AuditEntry.new(
seq: rs.read(Int64),
event_id: UUID.new(rs.read(String)),
occurred_at: rs.read(Time),
event_type: rs.read(String),
actor: rs.read(String),
target_id: rs.read(String?).try { |s| UUID.new(s) },
payload: rs.read(String),
prev_hash: rs.read(Bytes),
content_hash: rs.read(Bytes),
hmac: rs.read(Bytes),
hmac_key_version: rs.read(Int32),
)
block.call(entry)
end
end
end
def insert_batch(batch : AuditBatch) : Nil
@db.exec(
"INSERT INTO audit_batches (id, start_seq, end_seq, merkle_root, signature, signing_key_version, sealed_at) VALUES ($1::uuid, $2, $3, $4, $5, $6, $7)",
@ -67,6 +94,13 @@ module CRE::Persistence::Postgres
result || 0_i64
end
def all_batches : Array(AuditBatch)
@db.query_all(
"SELECT #{SELECT_BATCH_COLS} FROM audit_batches ORDER BY start_seq ASC",
as: {String, Int64, Int64, Bytes, Bytes, Int32, Time},
).map { |row| row_to_batch(row) }
end
private def row_to_entry(row) : AuditEntry
AuditEntry.new(
seq: row[0],
@ -82,5 +116,17 @@ module CRE::Persistence::Postgres
hmac_key_version: row[10],
)
end
private def row_to_batch(row) : AuditBatch
AuditBatch.new(
id: UUID.new(row[0]),
start_seq: row[1],
end_seq: row[2],
merkle_root: row[3],
signature: row[4],
signing_key_version: row[5],
sealed_at: row[6],
)
end
end
end

View File

@ -11,25 +11,31 @@ require "../../domain/credential"
module CRE::Persistence::Postgres
class CredentialsRepo < CRE::Persistence::CredentialsRepo
SELECT_COLS = "id::text, external_id, kind, name, tags::text, current_version_id::text, pending_version_id::text, previous_version_id::text, created_at, updated_at"
SELECT_COLS = "id::text, external_id, kind, name, tags::text, current_version_id::text, pending_version_id::text, previous_version_id::text, created_at, updated_at, last_rotated_at"
def initialize(@db : DB::Database)
end
def insert(c : Domain::Credential) : Nil
@db.exec(
"INSERT INTO credentials (id, external_id, kind, name, tags, created_at, updated_at) VALUES ($1::uuid, $2, $3, $4, $5::jsonb, $6, $7)",
"INSERT INTO credentials (id, external_id, kind, name, tags, current_version_id, pending_version_id, previous_version_id, created_at, updated_at, last_rotated_at) VALUES ($1::uuid, $2, $3, $4, $5::jsonb, $6::uuid, $7::uuid, $8::uuid, $9, $10, $11)",
c.id.to_s, c.external_id, c.kind.to_s, c.name,
c.tags.to_json, c.created_at, c.updated_at,
c.tags.to_json,
c.current_version_id.try(&.to_s),
c.pending_version_id.try(&.to_s),
c.previous_version_id.try(&.to_s),
c.created_at, c.updated_at, c.last_rotated_at,
)
end
def update(c : Domain::Credential) : Nil
@db.exec(
"UPDATE credentials SET name = $1, tags = $2::jsonb, current_version_id = $3::uuid, pending_version_id = $4::uuid, previous_version_id = $5::uuid, updated_at = $6 WHERE id = $7::uuid",
"UPDATE credentials SET name = $1, tags = $2::jsonb, current_version_id = $3::uuid, pending_version_id = $4::uuid, previous_version_id = $5::uuid, updated_at = $6, last_rotated_at = $7 WHERE id = $8::uuid",
c.name, c.tags.to_json,
c.current_version_id.try(&.to_s), c.pending_version_id.try(&.to_s), c.previous_version_id.try(&.to_s),
Time.utc, c.id.to_s,
c.current_version_id.try(&.to_s),
c.pending_version_id.try(&.to_s),
c.previous_version_id.try(&.to_s),
Time.utc, c.last_rotated_at, c.id.to_s,
)
end
@ -37,7 +43,7 @@ module CRE::Persistence::Postgres
@db.query_one?(
"SELECT #{SELECT_COLS} FROM credentials WHERE id = $1::uuid",
id.to_s,
as: {String, String, String, String, String, String?, String?, String?, Time, Time},
as: {String, String, String, String, String, String?, String?, String?, Time, Time, Time?},
).try { |row| row_to_credential(row) }
end
@ -45,23 +51,23 @@ module CRE::Persistence::Postgres
@db.query_one?(
"SELECT #{SELECT_COLS} FROM credentials WHERE kind = $1 AND external_id = $2",
kind.to_s, external_id,
as: {String, String, String, String, String, String?, String?, String?, Time, Time},
as: {String, String, String, String, String, String?, String?, String?, Time, Time, Time?},
).try { |row| row_to_credential(row) }
end
def all : Array(Domain::Credential)
@db.query_all(
"SELECT #{SELECT_COLS} FROM credentials",
as: {String, String, String, String, String, String?, String?, String?, Time, Time},
as: {String, String, String, String, String, String?, String?, String?, Time, Time, Time?},
).map { |row| row_to_credential(row) }
end
def overdue(now : Time, max_age : Time::Span) : Array(Domain::Credential)
cutoff = now - max_age
@db.query_all(
"SELECT #{SELECT_COLS} FROM credentials WHERE updated_at < $1",
"SELECT #{SELECT_COLS} FROM credentials WHERE COALESCE(last_rotated_at, created_at) < $1",
cutoff,
as: {String, String, String, String, String, String?, String?, String?, Time, Time},
as: {String, String, String, String, String, String?, String?, String?, Time, Time, Time?},
).map { |row| row_to_credential(row) }
end
@ -78,6 +84,7 @@ module CRE::Persistence::Postgres
previous_version_id: row[7].try { |s| UUID.new(s) },
created_at: row[8],
updated_at: row[9],
last_rotated_at: row[10],
)
end
end

View File

@ -7,105 +7,134 @@ require "db"
module CRE::Persistence::Postgres
module Migrations
SCHEMA = [
<<-SQL,
CREATE TABLE IF NOT EXISTS credentials (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
external_id TEXT NOT NULL,
kind TEXT NOT NULL,
name TEXT NOT NULL,
tags JSONB NOT NULL DEFAULT '{}'::jsonb,
current_version_id UUID,
pending_version_id UUID,
previous_version_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (kind, external_id)
)
SQL
"CREATE INDEX IF NOT EXISTS credentials_tags_gin ON credentials USING gin (tags jsonb_path_ops)",
<<-SQL,
CREATE TABLE IF NOT EXISTS credential_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
credential_id UUID NOT NULL REFERENCES credentials(id),
ciphertext BYTEA NOT NULL,
dek_wrapped BYTEA NOT NULL,
kek_version INT NOT NULL,
algorithm_id SMALLINT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
generated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
revoked_at TIMESTAMPTZ
)
SQL
<<-SQL,
CREATE TABLE IF NOT EXISTS rotations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
credential_id UUID NOT NULL REFERENCES credentials(id),
rotator_kind TEXT NOT NULL,
state TEXT NOT NULL,
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ,
step_outcomes JSONB NOT NULL DEFAULT '{}'::jsonb,
failure_reason TEXT
)
SQL
<<-SQL,
CREATE INDEX IF NOT EXISTS rotations_in_flight
ON rotations(state)
WHERE state NOT IN ('completed','failed','aborted')
SQL
<<-SQL,
CREATE TABLE IF NOT EXISTS audit_events (
seq BIGSERIAL PRIMARY KEY,
event_id UUID UNIQUE NOT NULL DEFAULT gen_random_uuid(),
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
event_type TEXT NOT NULL,
actor TEXT NOT NULL,
target_id UUID,
payload JSONB NOT NULL,
prev_hash BYTEA NOT NULL,
content_hash BYTEA NOT NULL,
hmac BYTEA NOT NULL,
hmac_key_version INT NOT NULL
)
SQL
<<-SQL,
CREATE TABLE IF NOT EXISTS audit_batches (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
start_seq BIGINT NOT NULL,
end_seq BIGINT NOT NULL,
merkle_root BYTEA NOT NULL,
signature BYTEA NOT NULL,
signing_key_version INT NOT NULL,
sealed_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
SQL
<<-SQL,
CREATE TABLE IF NOT EXISTS kek_versions (
version INT PRIMARY KEY,
source TEXT NOT NULL,
source_ref TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
retired_at TIMESTAMPTZ
)
SQL
<<-SQL,
CREATE OR REPLACE FUNCTION audit_no_modify() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN RAISE EXCEPTION 'audit_events is append-only'; END $$
SQL
<<-SQL,
DROP TRIGGER IF EXISTS audit_events_no_update ON audit_events
SQL
<<-SQL,
CREATE TRIGGER audit_events_no_update
BEFORE UPDATE OR DELETE OR TRUNCATE
ON audit_events
EXECUTE FUNCTION audit_no_modify()
SQL
record Step, version : Int32, statements : Array(String)
MIGRATIONS = [
Step.new(1, [
<<-SQL,
CREATE TABLE IF NOT EXISTS credentials (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
external_id TEXT NOT NULL,
kind TEXT NOT NULL,
name TEXT NOT NULL,
tags JSONB NOT NULL DEFAULT '{}'::jsonb,
current_version_id UUID,
pending_version_id UUID,
previous_version_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (kind, external_id)
)
SQL
"CREATE INDEX IF NOT EXISTS credentials_tags_gin ON credentials USING gin (tags jsonb_path_ops)",
<<-SQL,
CREATE TABLE IF NOT EXISTS credential_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
credential_id UUID NOT NULL REFERENCES credentials(id),
ciphertext BYTEA NOT NULL,
dek_wrapped BYTEA NOT NULL,
kek_version INT NOT NULL,
algorithm_id SMALLINT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
generated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
revoked_at TIMESTAMPTZ
)
SQL
<<-SQL,
CREATE TABLE IF NOT EXISTS rotations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
credential_id UUID NOT NULL REFERENCES credentials(id),
rotator_kind TEXT NOT NULL,
state TEXT NOT NULL,
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ,
step_outcomes JSONB NOT NULL DEFAULT '{}'::jsonb,
failure_reason TEXT
)
SQL
<<-SQL,
CREATE INDEX IF NOT EXISTS rotations_in_flight
ON rotations(state)
WHERE state NOT IN ('completed','failed','aborted','inconsistent')
SQL
<<-SQL,
CREATE TABLE IF NOT EXISTS audit_events (
seq BIGSERIAL PRIMARY KEY,
event_id UUID UNIQUE NOT NULL DEFAULT gen_random_uuid(),
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
event_type TEXT NOT NULL,
actor TEXT NOT NULL,
target_id UUID,
payload JSONB NOT NULL,
prev_hash BYTEA NOT NULL,
content_hash BYTEA NOT NULL,
hmac BYTEA NOT NULL,
hmac_key_version INT NOT NULL
)
SQL
<<-SQL,
CREATE TABLE IF NOT EXISTS audit_batches (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
start_seq BIGINT NOT NULL,
end_seq BIGINT NOT NULL,
merkle_root BYTEA NOT NULL,
signature BYTEA NOT NULL,
signing_key_version INT NOT NULL,
sealed_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
SQL
<<-SQL,
CREATE TABLE IF NOT EXISTS kek_versions (
version INT PRIMARY KEY,
source TEXT NOT NULL,
source_ref TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
retired_at TIMESTAMPTZ
)
SQL
<<-SQL,
CREATE OR REPLACE FUNCTION audit_no_modify() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN RAISE EXCEPTION 'audit_events is append-only'; END $$
SQL
"DROP TRIGGER IF EXISTS audit_events_no_update ON audit_events",
<<-SQL,
CREATE TRIGGER audit_events_no_update
BEFORE UPDATE OR DELETE OR TRUNCATE
ON audit_events
EXECUTE FUNCTION audit_no_modify()
SQL
]),
Step.new(2, [
"ALTER TABLE credentials ADD COLUMN IF NOT EXISTS last_rotated_at TIMESTAMPTZ",
"UPDATE credentials SET last_rotated_at = updated_at WHERE last_rotated_at IS NULL",
]),
Step.new(3, [
"CREATE INDEX IF NOT EXISTS credentials_last_rotated_at ON credentials(last_rotated_at)",
"CREATE INDEX IF NOT EXISTS credential_versions_credential_id ON credential_versions(credential_id)",
<<-SQL,
CREATE UNIQUE INDEX IF NOT EXISTS rotations_one_in_flight_per_cred
ON rotations(credential_id)
WHERE state NOT IN ('completed','failed','aborted','inconsistent')
SQL
]),
]
def self.run(db : DB::Database) : Nil
SCHEMA.each { |stmt| db.exec(stmt) }
db.exec("CREATE TABLE IF NOT EXISTS schema_migrations (version INT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now())")
applied = applied_versions(db)
MIGRATIONS.each do |step|
next if applied.includes?(step.version)
step.statements.each { |stmt| db.exec(stmt) }
db.exec("INSERT INTO schema_migrations (version) VALUES ($1)", step.version)
end
end
private def self.applied_versions(db : DB::Database) : Set(Int32)
versions = Set(Int32).new
db.query("SELECT version FROM schema_migrations") do |rs|
rs.each { versions << rs.read(Int32) }
end
versions
end
end
end

View File

@ -33,7 +33,7 @@ module CRE::Persistence::Postgres
def in_flight : Array(RotationRecord)
@db.query_all(
"SELECT #{SELECT_COLS} FROM rotations WHERE state NOT IN ('completed','failed','aborted')",
"SELECT #{SELECT_COLS} FROM rotations WHERE state NOT IN ('completed','failed','aborted','inconsistent')",
as: {String, String, String, String, Time, Time?, String?},
).map { |row| row_to_record(row) }
end

View File

@ -27,7 +27,7 @@ module CRE::Persistence
Inconsistent
end
TERMINAL_STATES = [RotationState::Completed, RotationState::Failed, RotationState::Aborted]
TERMINAL_STATES = [RotationState::Completed, RotationState::Failed, RotationState::Aborted, RotationState::Inconsistent]
record RotationRecord,
id : UUID,
@ -87,7 +87,9 @@ module CRE::Persistence
abstract def latest_hash : Bytes
abstract def latest_seq : Int64
abstract def range(start_seq : Int64, end_seq : Int64) : Array(AuditEntry)
abstract def each_in_range(start_seq : Int64, end_seq : Int64, &block : AuditEntry ->) : Nil
abstract def insert_batch(batch : AuditBatch) : Nil
abstract def last_sealed_seq : Int64
abstract def all_batches : Array(AuditBatch)
end
end

View File

@ -11,12 +11,15 @@ module CRE::Persistence::Sqlite
class AuditRepo < CRE::Persistence::AuditRepo
GENESIS_HASH = Bytes.new(32, 0_u8)
SELECT_ENTRY_COLS = "seq, event_id, occurred_at, event_type, actor, target_id, payload, prev_hash, content_hash, hmac, hmac_key_version"
SELECT_BATCH_COLS = "id, start_seq, end_seq, merkle_root, signature, signing_key_version, sealed_at"
def initialize(@db : DB::Database)
end
def append(entry : AuditEntry) : Nil
@db.exec(
"INSERT OR IGNORE INTO audit_events (event_id, occurred_at, event_type, actor, target_id, payload, prev_hash, content_hash, hmac, hmac_key_version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"INSERT INTO audit_events (event_id, occurred_at, event_type, actor, target_id, payload, prev_hash, content_hash, hmac, hmac_key_version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
entry.event_id.to_s, entry.occurred_at.to_rfc3339,
entry.event_type, entry.actor,
entry.target_id.try(&.to_s),
@ -44,12 +47,36 @@ module CRE::Persistence::Sqlite
def range(start_seq : Int64, end_seq : Int64) : Array(AuditEntry)
@db.query_all(
"SELECT seq, event_id, occurred_at, event_type, actor, target_id, payload, prev_hash, content_hash, hmac, hmac_key_version FROM audit_events WHERE seq >= ? AND seq <= ? ORDER BY seq ASC",
"SELECT #{SELECT_ENTRY_COLS} FROM audit_events WHERE seq >= ? AND seq <= ? ORDER BY seq ASC",
start_seq, end_seq,
as: {Int64, String, String, String, String, String?, String, Bytes, Bytes, Bytes, Int32},
).map { |row| row_to_entry(row) }
end
def each_in_range(start_seq : Int64, end_seq : Int64, &block : AuditEntry ->) : Nil
@db.query(
"SELECT #{SELECT_ENTRY_COLS} FROM audit_events WHERE seq >= ? AND seq <= ? ORDER BY seq ASC",
start_seq, end_seq,
) do |rs|
rs.each do
entry = AuditEntry.new(
seq: rs.read(Int64),
event_id: UUID.new(rs.read(String)),
occurred_at: Time.parse_rfc3339(rs.read(String)),
event_type: rs.read(String),
actor: rs.read(String),
target_id: rs.read(String?).try { |s| UUID.new(s) },
payload: rs.read(String),
prev_hash: rs.read(Bytes),
content_hash: rs.read(Bytes),
hmac: rs.read(Bytes),
hmac_key_version: rs.read(Int32),
)
block.call(entry)
end
end
end
def insert_batch(batch : AuditBatch) : Nil
@db.exec(
"INSERT INTO audit_batches (id, start_seq, end_seq, merkle_root, signature, signing_key_version, sealed_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
@ -67,6 +94,13 @@ module CRE::Persistence::Sqlite
result || 0_i64
end
def all_batches : Array(AuditBatch)
@db.query_all(
"SELECT #{SELECT_BATCH_COLS} FROM audit_batches ORDER BY start_seq ASC",
as: {String, Int64, Int64, Bytes, Bytes, Int32, String},
).map { |row| row_to_batch(row) }
end
private def row_to_entry(row) : AuditEntry
AuditEntry.new(
seq: row[0],
@ -82,5 +116,17 @@ module CRE::Persistence::Sqlite
hmac_key_version: row[10],
)
end
private def row_to_batch(row) : AuditBatch
AuditBatch.new(
id: UUID.new(row[0]),
start_seq: row[1],
end_seq: row[2],
merkle_root: row[3],
signature: row[4],
signing_key_version: row[5],
sealed_at: Time.parse_rfc3339(row[6]),
)
end
end
end

View File

@ -11,25 +11,34 @@ require "../../domain/credential"
module CRE::Persistence::Sqlite
class CredentialsRepo < CRE::Persistence::CredentialsRepo
SELECT_COLS = "id, external_id, kind, name, tags, current_version_id, pending_version_id, previous_version_id, created_at, updated_at"
SELECT_COLS = "id, external_id, kind, name, tags, current_version_id, pending_version_id, previous_version_id, created_at, updated_at, last_rotated_at"
def initialize(@db : DB::Database)
end
def insert(c : Domain::Credential) : Nil
@db.exec(
"INSERT INTO credentials (id, external_id, kind, name, tags, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
"INSERT INTO credentials (id, external_id, kind, name, tags, current_version_id, pending_version_id, previous_version_id, created_at, updated_at, last_rotated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
c.id.to_s, c.external_id, c.kind.to_s, c.name,
c.tags.to_json, c.created_at.to_rfc3339, c.updated_at.to_rfc3339,
c.tags.to_json,
c.current_version_id.try(&.to_s),
c.pending_version_id.try(&.to_s),
c.previous_version_id.try(&.to_s),
c.created_at.to_rfc3339, c.updated_at.to_rfc3339,
c.last_rotated_at.try(&.to_rfc3339),
)
end
def update(c : Domain::Credential) : Nil
@db.exec(
"UPDATE credentials SET name = ?, tags = ?, current_version_id = ?, pending_version_id = ?, previous_version_id = ?, updated_at = ? WHERE id = ?",
"UPDATE credentials SET name = ?, tags = ?, current_version_id = ?, pending_version_id = ?, previous_version_id = ?, updated_at = ?, last_rotated_at = ? WHERE id = ?",
c.name, c.tags.to_json,
c.current_version_id.try(&.to_s), c.pending_version_id.try(&.to_s), c.previous_version_id.try(&.to_s),
Time.utc.to_rfc3339, c.id.to_s,
c.current_version_id.try(&.to_s),
c.pending_version_id.try(&.to_s),
c.previous_version_id.try(&.to_s),
Time.utc.to_rfc3339,
c.last_rotated_at.try(&.to_rfc3339),
c.id.to_s,
)
end
@ -37,7 +46,7 @@ module CRE::Persistence::Sqlite
@db.query_one?(
"SELECT #{SELECT_COLS} FROM credentials WHERE id = ?",
id.to_s,
as: {String, String, String, String, String, String?, String?, String?, String, String},
as: {String, String, String, String, String, String?, String?, String?, String, String, String?},
).try { |row| row_to_credential(row) }
end
@ -45,23 +54,23 @@ module CRE::Persistence::Sqlite
@db.query_one?(
"SELECT #{SELECT_COLS} FROM credentials WHERE kind = ? AND external_id = ?",
kind.to_s, external_id,
as: {String, String, String, String, String, String?, String?, String?, String, String},
as: {String, String, String, String, String, String?, String?, String?, String, String, String?},
).try { |row| row_to_credential(row) }
end
def all : Array(Domain::Credential)
@db.query_all(
"SELECT #{SELECT_COLS} FROM credentials",
as: {String, String, String, String, String, String?, String?, String?, String, String},
as: {String, String, String, String, String, String?, String?, String?, String, String, String?},
).map { |row| row_to_credential(row) }
end
def overdue(now : Time, max_age : Time::Span) : Array(Domain::Credential)
cutoff = (now - max_age).to_rfc3339
@db.query_all(
"SELECT #{SELECT_COLS} FROM credentials WHERE updated_at < ?",
"SELECT #{SELECT_COLS} FROM credentials WHERE COALESCE(last_rotated_at, created_at) < ?",
cutoff,
as: {String, String, String, String, String, String?, String?, String?, String, String},
as: {String, String, String, String, String, String?, String?, String?, String, String, String?},
).map { |row| row_to_credential(row) }
end
@ -78,6 +87,7 @@ module CRE::Persistence::Sqlite
previous_version_id: row[7].try { |s| UUID.new(s) },
created_at: Time.parse_rfc3339(row[8]),
updated_at: Time.parse_rfc3339(row[9]),
last_rotated_at: row[10].try { |s| Time.parse_rfc3339(s) },
)
end
end

View File

@ -7,86 +7,124 @@ require "db"
module CRE::Persistence::Sqlite
module Migrations
SCHEMA = [
<<-SQL,
CREATE TABLE IF NOT EXISTS credentials (
id TEXT PRIMARY KEY,
external_id TEXT NOT NULL,
kind TEXT NOT NULL,
name TEXT NOT NULL,
tags TEXT NOT NULL DEFAULT '{}',
current_version_id TEXT,
pending_version_id TEXT,
previous_version_id TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (kind, external_id)
)
SQL
<<-SQL,
CREATE TABLE IF NOT EXISTS credential_versions (
id TEXT PRIMARY KEY,
credential_id TEXT NOT NULL REFERENCES credentials(id),
ciphertext BLOB NOT NULL,
dek_wrapped BLOB NOT NULL,
kek_version INTEGER NOT NULL,
algorithm_id INTEGER NOT NULL,
metadata TEXT NOT NULL DEFAULT '{}',
generated_at TEXT NOT NULL,
revoked_at TEXT
)
SQL
<<-SQL,
CREATE TABLE IF NOT EXISTS rotations (
id TEXT PRIMARY KEY,
credential_id TEXT NOT NULL,
rotator_kind TEXT NOT NULL,
state TEXT NOT NULL,
started_at TEXT NOT NULL,
completed_at TEXT,
step_outcomes TEXT NOT NULL DEFAULT '{}',
failure_reason TEXT
)
SQL
<<-SQL,
CREATE TABLE IF NOT EXISTS audit_events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
event_id TEXT UNIQUE NOT NULL,
occurred_at TEXT NOT NULL,
event_type TEXT NOT NULL,
actor TEXT NOT NULL,
target_id TEXT,
payload TEXT NOT NULL,
prev_hash BLOB NOT NULL,
content_hash BLOB NOT NULL,
hmac BLOB NOT NULL,
hmac_key_version INTEGER NOT NULL
)
SQL
<<-SQL,
CREATE TABLE IF NOT EXISTS audit_batches (
id TEXT PRIMARY KEY,
start_seq INTEGER NOT NULL,
end_seq INTEGER NOT NULL,
merkle_root BLOB NOT NULL,
signature BLOB NOT NULL,
signing_key_version INTEGER NOT NULL,
sealed_at TEXT NOT NULL
)
SQL
<<-SQL,
CREATE TABLE IF NOT EXISTS kek_versions (
version INTEGER PRIMARY KEY,
source TEXT NOT NULL,
source_ref TEXT,
created_at TEXT NOT NULL,
retired_at TEXT
)
SQL
record Step, version : Int32, statements : Array(String)
MIGRATIONS = [
Step.new(1, [
<<-SQL,
CREATE TABLE IF NOT EXISTS credentials (
id TEXT PRIMARY KEY,
external_id TEXT NOT NULL,
kind TEXT NOT NULL,
name TEXT NOT NULL,
tags TEXT NOT NULL DEFAULT '{}',
current_version_id TEXT,
pending_version_id TEXT,
previous_version_id TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (kind, external_id)
)
SQL
<<-SQL,
CREATE TABLE IF NOT EXISTS credential_versions (
id TEXT PRIMARY KEY,
credential_id TEXT NOT NULL REFERENCES credentials(id),
ciphertext BLOB NOT NULL,
dek_wrapped BLOB NOT NULL,
kek_version INTEGER NOT NULL,
algorithm_id INTEGER NOT NULL,
metadata TEXT NOT NULL DEFAULT '{}',
generated_at TEXT NOT NULL,
revoked_at TEXT
)
SQL
<<-SQL,
CREATE TABLE IF NOT EXISTS rotations (
id TEXT PRIMARY KEY,
credential_id TEXT NOT NULL,
rotator_kind TEXT NOT NULL,
state TEXT NOT NULL,
started_at TEXT NOT NULL,
completed_at TEXT,
step_outcomes TEXT NOT NULL DEFAULT '{}',
failure_reason TEXT
)
SQL
<<-SQL,
CREATE TABLE IF NOT EXISTS audit_events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
event_id TEXT UNIQUE NOT NULL,
occurred_at TEXT NOT NULL,
event_type TEXT NOT NULL,
actor TEXT NOT NULL,
target_id TEXT,
payload TEXT NOT NULL,
prev_hash BLOB NOT NULL,
content_hash BLOB NOT NULL,
hmac BLOB NOT NULL,
hmac_key_version INTEGER NOT NULL
)
SQL
<<-SQL,
CREATE TABLE IF NOT EXISTS audit_batches (
id TEXT PRIMARY KEY,
start_seq INTEGER NOT NULL,
end_seq INTEGER NOT NULL,
merkle_root BLOB NOT NULL,
signature BLOB NOT NULL,
signing_key_version INTEGER NOT NULL,
sealed_at TEXT NOT NULL
)
SQL
<<-SQL,
CREATE TABLE IF NOT EXISTS kek_versions (
version INTEGER PRIMARY KEY,
source TEXT NOT NULL,
source_ref TEXT,
created_at TEXT NOT NULL,
retired_at TEXT
)
SQL
]),
Step.new(2, [
<<-SQL,
CREATE TRIGGER IF NOT EXISTS audit_events_no_update
BEFORE UPDATE ON audit_events
BEGIN SELECT RAISE(ABORT, 'audit_events is append-only'); END
SQL
<<-SQL,
CREATE TRIGGER IF NOT EXISTS audit_events_no_delete
BEFORE DELETE ON audit_events
BEGIN SELECT RAISE(ABORT, 'audit_events is append-only'); END
SQL
]),
Step.new(3, [
"ALTER TABLE credentials ADD COLUMN last_rotated_at TEXT",
"UPDATE credentials SET last_rotated_at = updated_at WHERE last_rotated_at IS NULL",
]),
Step.new(4, [
"CREATE INDEX IF NOT EXISTS credentials_last_rotated_at ON credentials(last_rotated_at)",
"CREATE INDEX IF NOT EXISTS credential_versions_credential_id ON credential_versions(credential_id)",
]),
]
def self.run(db : DB::Database) : Nil
SCHEMA.each { |stmt| db.exec(stmt) }
db.exec("CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP)")
applied = applied_versions(db)
MIGRATIONS.each do |step|
next if applied.includes?(step.version)
step.statements.each { |stmt| db.exec(stmt) }
db.exec("INSERT INTO schema_migrations (version) VALUES (?)", step.version)
end
end
private def self.applied_versions(db : DB::Database) : Set(Int32)
versions = Set(Int32).new
db.query("SELECT version FROM schema_migrations") do |rs|
rs.each { versions << rs.read(Int32) }
end
versions
end
end
end

View File

@ -6,24 +6,28 @@
require "./builder"
require "./policy"
# Top-level `policy` method makes the DSL feel native:
#
# require "cre/policy/dsl"
#
# policy "production-databases" do
# description "All prod DB credentials rotate every 30 days"
# match { |c| c.kind.database? && c.tag(:env) == "prod" }
# max_age 30.days
# enforce :rotate_immediately
# notify_via :telegram, :structured_log
# end
#
# The `with builder yield` makes every Builder method (description, match,
# max_age, enforce, notify_via, on_rotation_failure, on_drift_detected) callable
# without a receiver inside the block. Symbol literals autocast to enum values
# so typos like `enforce :rotate_immediatly` fail at compile time.
def policy(name : String, &block)
builder = CRE::Policy::Builder.new(name)
with builder yield
CRE::Policy::REGISTRY << builder.build
module CRE::Policy::Dsl
# Top-level-feeling DSL for declaring policies. Users opt in:
#
# require "cre/policy/dsl"
# include CRE::Policy::Dsl
#
# policy "production-aws-secrets" do
# description "All prod AWS secrets rotate every 30 days"
# match { |c| c.kind.aws_secretsmgr? && c.tag(:env) == "prod" }
# max_age 30.days
# enforce :rotate_immediately
# notify_via :telegram, :structured_log
# end
#
# Single-symbol args (`enforce :rotate_immediately`) autocast to enum
# values via Crystal's parameter typing — typos like
# `enforce :rotate_immediatly` fail at compile time. Splat-symbol args
# (`notify_via :telegram, :structured_log`) are validated at policy
# registration time and raise `BuilderError` on typos.
def policy(name : String, &block)
builder = CRE::Policy::Builder.new(name)
with builder yield
CRE::Policy::REGISTRY << builder.build
end
end

View File

@ -11,6 +11,8 @@ require "../events/system_events"
require "../persistence/persistence"
module CRE::Policy
class PolicyConflictError < Exception; end
class Evaluator
Log = ::Log.for("cre.policy.evaluator")
@ -46,22 +48,45 @@ module CRE::Policy
@ch.try(&.close)
end
# evaluate_all groups policies by max_age and uses the persistence layer's
# 'overdue' query so we don't pull every credential into memory each tick.
def evaluate_all(now : Time = Time.utc) : Nil
@persistence.credentials.all.each { |c| evaluate(c, now) }
return if @policies.empty?
seen = Set(UUID).new
@policies.group_by(&.max_age).each do |max_age, policies_with_age|
@persistence.credentials.overdue(now, max_age).each do |c|
next if seen.includes?(c.id)
seen << c.id
evaluate(c, now)
end
end
end
# Evaluates a single credential. When more than one policy matches
# we raise PolicyConflictError instead of silently picking by
# registration order; conflicting policies are an operator bug, not
# a runtime decision.
def evaluate(c : Domain::Credential, now : Time = Time.utc) : Nil
matching = @policies.select(&.matches?(c))
return if matching.empty?
# Most specific match wins on conflicts (last-match wins as a simple tiebreaker)
policy = matching.last
if matching.size > 1
names = matching.map(&.name).join(", ")
@bus.publish Events::AlertRaised.new(
severity: Events::Severity::Critical,
message: "credential #{c.id} matches #{matching.size} policies (#{names}); refusing to act until ambiguity is resolved",
)
raise PolicyConflictError.new("credential #{c.id} matches #{matching.size} policies: #{names}")
end
policy = matching.first
return unless policy.overdue?(c, now)
@bus.publish Events::PolicyViolation.new(
c.id,
policy.name,
"credential exceeded max_age=#{policy.max_age} (last update #{c.updated_at.to_rfc3339})",
"credential exceeded max_age=#{policy.max_age} (rotation_anchor #{c.rotation_anchor.to_rfc3339})",
)
case policy.enforce_action
@ -78,6 +103,8 @@ module CRE::Policy
message: "policy '#{policy.name}' triggered quarantine on credential '#{c.id}'",
)
end
rescue ex : PolicyConflictError
Log.error(exception: ex) { "policy conflict for #{c.id}" }
rescue ex
Log.error(exception: ex) { "policy evaluation failed for #{c.id}" }
end

View File

@ -54,12 +54,12 @@ module CRE::Policy
end
def overdue?(c : Domain::Credential, now : Time = Time.utc) : Bool
(now - c.updated_at) > @max_age
(now - c.rotation_anchor) > @max_age
end
def in_warning_window?(c : Domain::Credential, now : Time = Time.utc) : Bool
return false unless w = @warn_at
age = now - c.updated_at
age = now - c.rotation_anchor
age > w && age <= @max_age
end

View File

@ -11,6 +11,12 @@ module CRE::Rotators
# The rotation produces fresh random bytes (base64-encoded) and atomically
# swaps the live file on commit using temp+rename.
#
# Cross-process safety: each instance writes to a per-PID pending file
# so two daemons targeting the same .env never collide on the staging
# write. The commit-time rename is serialized through an exclusive
# flock(2) on a sibling .lock file so the live file is updated by at
# most one process at a time.
#
# Credential.tags must include:
# "path" - absolute path to the .env file
# "key" - the key whose value rotates
@ -41,23 +47,23 @@ module CRE::Rotators
def apply(c : Domain::Credential, s : Domain::NewSecret) : Nil
path = c.tag("path").not_nil!
key = c.tag("key").not_nil!
pending_path = "#{path}.pending"
pending = pending_path(path)
existing = File.exists?(path) ? File.read(path) : ""
lines = existing.lines(chomp: true).reject { |line| line.strip.starts_with?("#{key}=") }
new_value = String.new(s.ciphertext)
lines << "#{key}=#{new_value}"
File.write(pending_path, lines.join('\n') + "\n", perm: 0o600)
File.write(pending, lines.join('\n') + "\n", perm: 0o600)
end
def verify(c : Domain::Credential, s : Domain::NewSecret) : Bool
path = c.tag("path").not_nil!
key = c.tag("key").not_nil!
pending_path = "#{path}.pending"
return false unless File.exists?(pending_path)
pending = pending_path(path)
return false unless File.exists?(pending)
content = File.read(pending_path)
content = File.read(pending)
expected_line = "#{key}=#{String.new(s.ciphertext)}"
content.includes?(expected_line) && content.bytesize > 0
end
@ -65,16 +71,30 @@ module CRE::Rotators
def commit(c : Domain::Credential, s : Domain::NewSecret) : Nil
_ = s
path = c.tag("path").not_nil!
pending_path = "#{path}.pending"
raise RotatorError.new("pending file missing at commit time: #{pending_path}") unless File.exists?(pending_path)
File.rename(pending_path, path)
pending = pending_path(path)
raise RotatorError.new("pending file missing at commit time: #{pending}") unless File.exists?(pending)
with_lock(path) do
File.rename(pending, path)
end
end
def rollback_apply(c : Domain::Credential, s : Domain::NewSecret) : Nil
_ = s
path = c.tag("path").not_nil!
pending_path = "#{path}.pending"
File.delete(pending_path) if File.exists?(pending_path)
pending = pending_path(path)
File.delete(pending) if File.exists?(pending)
end
private def pending_path(path : String) : String
"#{path}.pending.#{Process.pid}"
end
private def with_lock(path : String, & : -> _) : Nil
lock_path = "#{path}.lock"
File.open(lock_path, "w+") do |lock|
lock.flock_exclusive { yield }
end
end
end
end

View File

@ -45,12 +45,16 @@ module CRE::Tui
private def render_status : Nil
header = " STATUS CREDS DUE-NOW OVERDUE ROTATED-24h KEK"
values = " #{Ansi.green("● live")} ? ? ? #{@state.completed_24h.to_s.ljust(13)} v#{@state.kek_version}"
values = " #{Ansi.green("● live")} #{cell(@state.creds_total, 8)}#{cell(@state.due_now, 11)}#{cell(@state.overdue, 11)}#{cell(@state.completed_24h, 15)}v#{@state.kek_version}"
@io << PANEL_VL << pad(header, WIDTH - 2) << PANEL_VL << '\n'
@io << PANEL_VL << pad(values, WIDTH - 2) << PANEL_VL << '\n'
@io << PANEL_LMID << " Active Rotations " << PANEL_HR * (WIDTH - 21) << PANEL_RMID << '\n'
end
private def cell(n : Int, width : Int) : String
n.to_s.ljust(width)
end
private def render_active : Nil
if @state.active.empty?
@io << PANEL_VL << pad(" (no active rotations)", WIDTH - 2) << PANEL_VL << '\n'
@ -78,7 +82,7 @@ module CRE::Tui
private def render_footer : Nil
@io << PANEL_BL << PANEL_HR * (WIDTH - 2) << PANEL_BR << '\n'
@io << Ansi.dim(" q=quit r=refresh ?=help") << '\n'
@io << Ansi.dim(" Press Ctrl+C to exit") << '\n'
end
private def colorize(text : String, color : String) : String

View File

@ -0,0 +1,69 @@
# ===================
# ©AngelaMos | 2026
# snapshotter.cr
# ===================
require "log"
require "./state"
require "../persistence/persistence"
require "../policy/policy"
module CRE::Tui
# Snapshotter polls the persistence layer at a low frequency and updates
# State#creds_total / due_now / overdue. The TUI render path reads the
# cached snapshot, never the database — so a slow query can't stall
# frame paint.
class Snapshotter
Log = ::Log.for("cre.tui.snapshotter")
@running : Bool
def initialize(
@state : State,
@persistence : Persistence::Persistence,
@policies : Array(Policy::Policy) = Policy.registry,
@interval : Time::Span = 5.seconds,
)
@running = false
end
def start : Nil
@running = true
spawn(name: "tui-snapshotter") do
refresh
while @running
sleep @interval
break unless @running
refresh
end
end
end
def stop : Nil
@running = false
end
def refresh : Nil
now = Time.utc
creds = @persistence.credentials.all
total = creds.size
due_now = 0
overdue = 0
creds.each do |c|
matching = @policies.select(&.matches?(c))
next if matching.empty?
policy = matching.last
if policy.overdue?(c, now)
overdue += 1
elsif policy.in_warning_window?(c, now)
due_now += 1
end
end
@state.update_counts(total, due_now, overdue)
rescue ex
Log.warn(exception: ex) { "tui snapshot refresh failed" }
end
end
end

View File

@ -29,14 +29,26 @@ module CRE::Tui
@completed_24h : Int32
@started_at : Time
@kek_version : Int32
@creds_total : Int32
@due_now : Int32
@overdue : Int32
def initialize(@kek_version : Int32 = 0)
@recent = [] of EventRow
@active = {} of UUID => RotationRow
@completed_24h = 0
@creds_total = 0
@due_now = 0
@overdue = 0
@started_at = Time.utc
end
def update_counts(creds_total : Int32, due_now : Int32, overdue : Int32) : Nil
@creds_total = creds_total
@due_now = due_now
@overdue = overdue
end
def apply(ev : Events::Event) : Nil
case ev
when Events::RotationStarted
@ -90,6 +102,9 @@ module CRE::Tui
getter completed_24h : Int32
getter started_at : Time
getter kek_version : Int32
getter creds_total : Int32
getter due_now : Int32
getter overdue : Int32
def uptime : Time::Span
Time.utc - @started_at

View File

@ -6,6 +6,7 @@
require "./ansi"
require "./state"
require "./renderer"
require "./snapshotter"
require "../engine/event_bus"
module CRE::Tui

View File

@ -5,6 +5,7 @@
require "http/client"
require "json"
require "../http/retry"
module CRE::Vault
class VaultError < Exception
@ -54,20 +55,18 @@ module CRE::Vault
end
private def http_get(path : String) : JSON::Any
uri = URI.parse(@addr + path)
headers = HTTP::Headers{"X-Vault-Token" => @token}
response = HTTP::Client.get(uri.to_s, headers: headers)
response = CRE::Http.request("GET", @addr + path, headers, label: "vault.GET#{path}")
raise VaultError.new("vault GET #{path}: #{response.body[0, 200]?}", response.status_code) unless response.status_code < 300
JSON.parse(response.body)
end
private def http_put(path : String, body : String) : JSON::Any
uri = URI.parse(@addr + path)
headers = HTTP::Headers{
"X-Vault-Token" => @token,
"Content-Type" => "application/json",
}
response = HTTP::Client.put(uri.to_s, headers: headers, body: body)
response = CRE::Http.request("PUT", @addr + path, headers, body, label: "vault.PUT#{path}")
raise VaultError.new("vault PUT #{path}: #{response.body[0, 200]?}", response.status_code) unless response.status_code < 300
response.body.empty? ? JSON::Any.new(Hash(String, JSON::Any).new) : JSON.parse(response.body)
end