Cybersecurity-Projects/PROJECTS/intermediate/credential-rotation-enforcer/learn/02-ARCHITECTURE.md

8.4 KiB

Architecture

System overview

┌─────────────────────────────────────────────────────────────┐
│                       cre  (single Crystal binary)          │
│                                                             │
│   ┌──────────────────────────────────────────────────────┐  │
│   │                   Event Bus                          │  │
│   │             (typed Crystal channels)                 │  │
│   └────┬─────┬─────┬─────┬─────┬─────┬─────┬────────────┘  │
│        │     │     │     │     │     │     │               │
│   ┌────▼─┐ ┌─▼──┐ ┌▼───┐ ┌▼──┐ ┌▼────┐ ┌──▼──┐ ┌──────┐    │
│   │Sched │ │Rot.│ │Pol.│ │TUI│ │Tele.│ │Audit│ │Notify│    │
│   │ulers │ │Reg │ │Eval│ │   │ │Bot  │ │Log  │ │      │    │
│   └──┬───┘ └─┬──┘ └─┬──┘ └───┘ └─────┘ └──┬──┘ └──────┘    │
│      │       │      │                     │                 │
│      └───────┴──────┴─────────────────────┘                 │
│                       │                                     │
│              ┌────────▼─────────┐                           │
│              │  Persistence     │ ◄── SQLite (Tier 1)       │
│              │  (PG / SQLite)   │ ◄── PostgreSQL (T2/T3)    │
│              └──────────────────┘                           │
└─────────────────────────────────────────────────────────────┘

All long-lived components are fibers in one OS process. The bus is in-process - Crystal channels are nanosecond-scale, so the architectural overhead is essentially free.

Components

Event Bus (src/cre/engine/event_bus.cr)

Fanout dispatch via Crystal channels. Each subscriber gets its own bounded channel and chooses an overflow policy:

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

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).

Rotators (src/cre/rotators/)

The abstract Rotator class exposes the four lifecycle methods plus rollback_apply. Concrete rotators self-register at compile time:

class AwsSecretsRotator < Rotator
  register_as :aws_secretsmgr
  ...
end

Adding a fifth rotator means dropping a single file in src/cre/rotators/. The macro hooks it into the registry at compile time. No central wiring to update.

Rotators receive their cloud client through their constructor (DI). The CLI run command wires the right client based on env vars / config.

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.

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.

Crypto layers (src/cre/crypto/, src/cre/audit/)

+--------------------+
|   Plaintext        |
+--------------------+
          |
          | AES-256-GCM(plain, DEK, AAD = tenant||cred||version, nonce 96b)
          v
+--------------------+
|  ciphertext + tag  | -> credential_versions.ciphertext
+--------------------+

+----+
| DEK| (32 random bytes per row)
+----+
   |
   | KEK_v.wrap(DEK)  (envelope encryption)
   v
+--------------------+
|   wrapped DEK       | -> credential_versions.dek_wrapped + kek_version
+--------------------+

KEK source (per tier):
  Tier 1: env var CRE_KEK_HEX (64-hex chars = 32 bytes)
  Tier 2: AWS KMS via LocalStack
  Tier 3: real AWS KMS or HSM

Per-row DEKs collapse the AES-GCM nonce-reuse birthday concern (each row's DEK encrypts exactly one message). AAD-binding prevents ciphertext-swap attacks where an attacker with DB write tries to swap a low-privilege row's ciphertext into a high-privilege row.

algorithm_id is reserved for crypto agility:

  • 0x01 = AES-256-GCM (today)
  • 0x02 = XChaCha20-Poly1305 (long nonce, simpler)
  • 0x03 = ML-KEM hybrid wrap (post-quantum forward secrecy)

Audit log integrity stack

Three layers, increasingly hard to bypass:

+-------------------------------------------------+
|  Layer 3: Ed25519-signed Merkle batches         |
|    audit_batches table, hourly seal, signed     |
|    over (start_seq, end_seq, root)              |
|    Auditor verifies with public key only.       |
+-------------------------------------------------+
                    |
                    | leaves: content_hash[]
                    v
+-------------------------------------------------+
|  Layer 2: HMAC ratchet                          |
|    K_v signs each row's content_hash; every     |
|    1024 rows -> K_{v+1} = HKDF(K_v, "ratchet"); |
|    K_v zeroized in memory                       |
+-------------------------------------------------+
                    |
                    v
+-------------------------------------------------+
|  Layer 1: Hash chain                            |
|    content_hash = SHA256(prev_hash ||           |
|                          canonical_payload)     |
|    rendering single-row tampering visible       |
+-------------------------------------------------+
                    |
                    v
+-------------------------------------------------+
|  PostgreSQL audit_events table                  |
|    - INSERT-only role grant                     |
|    - UPDATE/DELETE/TRUNCATE trigger refuses     |
+-------------------------------------------------+

The PG triggers are not strictly necessary (the chain catches tampering anyway), but they fail loud at write-time which is much friendlier for operators. SQLite tier 1 documents the relaxed guarantee.

Concurrency

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

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)
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

Graceful shutdown: engine.stop publishes ShutdownRequested, gives subscribers ~50ms to flush, then closes the bus inbox and joins each subscriber fiber.