122 lines
7.1 KiB
Markdown
122 lines
7.1 KiB
Markdown
<!--
|
|
©AngelaMos | 2026
|
|
03-IMPLEMENTATION.md
|
|
-->
|
|
|
|
# Implementation Walkthrough
|
|
|
|
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)
|
|
|
|
`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:
|
|
|
|
```crystal
|
|
def policy(name : String, &block)
|
|
builder = CRE::Policy::Builder.new(name)
|
|
with builder yield
|
|
CRE::Policy::REGISTRY << builder.build
|
|
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.
|
|
|
|
`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.
|
|
|
|
## 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.
|
|
|
|
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.
|
|
|
|
## The Rotator Plugin Registration
|
|
|
|
`src/cre/rotators/rotator.cr` declares an abstract base with a class-level `REGISTRY = {} of Symbol => Rotator.class`. The macro `register_as` populates this at compile time:
|
|
|
|
```crystal
|
|
abstract class Rotator
|
|
REGISTRY = {} of Symbol => Rotator.class
|
|
|
|
macro register_as(kind)
|
|
::CRE::Rotators::Rotator::REGISTRY[{{ kind }}] = self
|
|
end
|
|
end
|
|
```
|
|
|
|
When a file like `src/cre/rotators/aws_secrets.cr` is required, the `register_as :aws_secretsmgr` line runs at *compile time* and the class shows up in `Rotator::REGISTRY[:aws_secretsmgr]`. No central list to maintain.
|
|
|
|
## The 4-step Orchestrator
|
|
|
|
`src/cre/engine/rotation_orchestrator.cr` runs the contract:
|
|
|
|
```
|
|
generate -> persist pending version
|
|
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.
|
|
|
|
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.
|
|
|
|
## SigV4 Signer (the AWS-flavored work)
|
|
|
|
`src/cre/aws/signer.cr` implements RFC-style AWS SigV4:
|
|
|
|
```
|
|
canonical_request = method + canonical_uri + canonical_query +
|
|
canonical_headers + signed_headers + payload_hash
|
|
string_to_sign = "AWS4-HMAC-SHA256\n" + amz_date + "\n" +
|
|
credential_scope + "\n" + sha256(canonical_request)
|
|
signing_key = HMAC chain (kSecret -> kDate -> kRegion -> kService -> kSigning)
|
|
signature = HMAC(signing_key, string_to_sign)
|
|
```
|
|
|
|
The `Authorization` header is built from `algorithm + Credential=... + SignedHeaders=... + Signature=...`. Includes `X-Amz-Security-Token` when an STS session token is supplied.
|
|
|
|
Tested against AWS canonical examples in `spec/unit/aws/signer_spec.cr` for idempotence and format conformance.
|
|
|
|
## Audit Log Integrity (three-layer)
|
|
|
|
`src/cre/audit/audit_log.cr` orchestrates Layer 1 + 2:
|
|
- `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
|
|
|
|
`src/cre/audit/batch_sealer.cr` builds Layer 3:
|
|
- 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`
|
|
|
|
Crystal's stdlib OpenSSL doesn't expose Ed25519 high-level wrappers, so `src/cre/audit/signing.cr` reaches into LibCrypto via FFI: `EVP_PKEY_new_raw_private_key`, `EVP_DigestSign`, etc. Public-key verification is symmetrical: `Ed25519Verifier#verify(message, signature)`.
|
|
|
|
## AEAD Envelope Encryption
|
|
|
|
`src/cre/crypto/aead.cr` does AES-256-GCM via LibCrypto FFI (stdlib `OpenSSL::Cipher` doesn't expose `auth_data=` / `auth_tag` for GCM). The envelope (`src/cre/crypto/envelope.cr`) generates a 32-byte DEK per row, encrypts plaintext with AES-256-GCM(plaintext, DEK, AAD), then wraps the DEK with KEK using a separate AEAD (with its own AAD `kek-wrap|v<version>`). Both ciphertexts are `nonce(12) || tag(16) || body`.
|
|
|
|
Decrypting requires the KEK to unwrap the DEK, then the DEK + AAD to decrypt the payload. AAD mismatch fails tag verification at the inner layer; KEK version mismatch fails at unwrap.
|
|
|
|
## TUI
|
|
|
|
`src/cre/tui/state.cr` holds a rolling view of active rotations + recent events. `apply(ev)` is the single entry point that mutates state; pure update logic, easy to test.
|
|
|
|
`src/cre/tui/renderer.cr` paints the four panels to any IO. ANSI escapes via `src/cre/tui/ansi.cr` (stdlib only). The renderer's `pad` helper accounts for ANSI escape widths so column alignment is correct under colors.
|
|
|
|
`src/cre/tui/tui.cr` ties it together: subscribes to the bus (Drop overflow), spawns a tick fiber + an event fiber, both calling `maybe_render` which throttles to `refresh_interval`.
|
|
|
|
## Telegram Bot
|
|
|
|
`src/cre/notifiers/telegram.cr` is a thin HTTP::Client wrapper for the Telegram Bot API (no tourmaline dependency for the notification path).
|
|
|
|
`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.
|
|
|
|
## 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/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).
|
|
|
|
`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.
|