Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
5529b8d
fix: Fix a flaky test that occurs due to a race condition.
evntd-jake Apr 2, 2026
d0ffa1e
feat: Adds encryption at rest
evntd-jake Apr 2, 2026
ed34d41
fix: prevent DEK exposure in crash dumps, :sys.get_state, observer, a…
evntd-jake Apr 2, 2026
7725433
fix: add decryption for verification
evntd-jake Apr 2, 2026
7f3bb9b
chore: update doc attributes
evntd-jake Apr 2, 2026
95eccc3
feat: Adds support for custom indexers
evntd-jake Apr 2, 2026
fedcc1d
chore: update guide with new metadata indexer.
evntd-jake Apr 2, 2026
d385e9b
chore: update doc attributes
evntd-jake Apr 2, 2026
afdc628
feat: Adds Fact.EventMetadataIndexer for indexing by correlation_id, …
evntd-jake Apr 2, 2026
1aeaf5c
chore: update doc attributes
evntd-jake Apr 2, 2026
1ca81c8
feat: Adds encryption to the write ahead log entries.
evntd-jake Apr 8, 2026
b676c14
chore: format code
evntd-jake Apr 8, 2026
255f6fd
fix: deduplicate event tags before appending.
evntd-jake Apr 18, 2026
8721419
doc: organize module docs
evntd-jake Apr 18, 2026
fce494d
chore: compute version based on tag
evntd-jake Apr 18, 2026
7722939
chore: bump version to 0.4.0
evntd-jake Apr 18, 2026
abb38e2
Merge pull request #72 from evntd/fix/flaky-test
evntd-jake Apr 18, 2026
c77007b
Merge branch 'rel/v0.4.0' into fix/duplicate-tags
evntd-jake Apr 18, 2026
32a7215
Merge pull request #74 from evntd/fix/duplicate-tags
evntd-jake Apr 18, 2026
b5ad228
Merge pull request #63 from evntd/custom-indexers
evntd-jake Apr 18, 2026
e7f5000
Merge pull request #65 from evntd/metadata-indexer
evntd-jake Apr 18, 2026
84ea503
Merge branch 'rel/v0.4.0' into encryption-at-rest
evntd-jake Apr 18, 2026
7025b19
Merge pull request #60 from evntd/encryption-at-rest
evntd-jake Apr 18, 2026
558aaf1
chore: add fetch-depth so checkout to clone tags
evntd-jake Apr 18, 2026
9ccb267
Merge pull request #75 from evntd/chore/version
evntd-jake Apr 18, 2026
52250c2
Merge branch 'rel/v0.4.0' into chore/bump-version
evntd-jake Apr 18, 2026
e7b4ed7
Merge pull request #76 from evntd/chore/bump-version
evntd-jake Apr 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ jobs:

steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0

- name: Set up Elixir
id: beam
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Add `fact` to your dependencies:
```elixir
def deps do
[
{:fact, "~> 0.3.1"},
{:fact, "~> 0.4.0"},
{:jason, "~> 1.4"} # required on Elixir 1.17 and earlier
]
end
Expand Down Expand Up @@ -226,13 +226,13 @@ Look up a database by name at runtime:
| `mix fact.merkle.create_proof -p <path> --position <n>` | Create an inclusion proof |
| `mix fact.merkle.verify_proof --proof <file>` | Verify an inclusion proof |

## Coming in v0.4.0
## New in v0.4.0

- **Encryption at rest** — AES-256-GCM authenticated encryption for event record files, with envelope encryption (DEK/KEK) and recovery key support
- **Custom indexers** — define application-specific indexes with a single callback, configured via `Fact.open/2`
- **Metadata indexer** — built-in indexer for event metadata fields (correlation IDs, tenant IDs, etc.)

## Roadmap
## Road to v1.0.0

- Telemetry
- Full stack example application
Expand Down
224 changes: 224 additions & 0 deletions guides/components/encryption.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
# Encryption at Rest

This guide explains Fact's encryption-at-rest feature, which protects event record files on disk using
authenticated encryption. It covers the motivation, how to create and open an encrypted database, key
management, and recovery procedures.

At a high level:

- Event record files are encrypted using **AES-256-GCM** (authenticated encryption)
- Encryption is configured at **database creation time** and backed by the seam architecture
- Keys use **envelope encryption** — a Data Encryption Key (DEK) encrypts records, and a Key Encryption
Key (KEK) wraps the DEK
- The DEK never appears in plaintext on disk
- A **recovery KEK** is required at creation time to prevent permanent data loss

## Motivation

Event stores accumulate history that is never deleted. This makes them high-value targets for data
exfiltration — a single disk image or backup copy exposes every event ever written. Encryption at rest
ensures that even if the underlying storage is compromised, the event data remains unreadable without
the encryption key.

Fact encrypts at the **record file level**. Each event record file on disk is individually encrypted with
its own random nonce, providing strong confidentiality guarantees even if an attacker obtains partial
access to the storage directory.

Ledger files, index files, checkpoint files, and lock files are **not encrypted**. These contain only
record IDs (opaque identifiers) and operational metadata, not event payloads.

## Envelope Encryption

Fact uses envelope encryption with two layers of keys:

- **Data Encryption Key (DEK)** — A 256-bit key generated at database creation time. Used to encrypt
and decrypt every event record file. One DEK per database.
- **Key Encryption Key (KEK)** — Supplied by the operator at runtime. Wraps (encrypts) the DEK so it
can be stored safely on disk. Never touches disk in plaintext.
- **Recovery KEK** — A second KEK that can also unwrap the DEK. Required at creation time. Stored
separately by the operator for disaster recovery.

The wrapped DEK (encrypted with both the primary and recovery KEKs) is stored in the `.bootstrap` file
alongside the database configuration. The KEKs themselves are never written to disk.

```
┌────────────────┐
│ Primary KEK │ (runtime only, never on disk)
└────────┬───────┘
│ wraps
▼
┌────────────────┐
│ DEK │ (wrapped form stored in .bootstrap)
└────────┬───────┘
│ encrypts
▼
┌────────────────┐
│ Record Files │ (encrypted on disk)
└────────────────┘
```

## Creating an Encrypted Database

Use `mix fact.create` with the `--encrypted` flag:

```sh
$ mix fact.create -p data/secure_turtles --encrypted
```

The primary key and recovery key are **generated automatically** and displayed in the output:

```
================================================================
🔐 ENCRYPTION KEYS — SAVE THESE NOW
================================================================

KEY: dGhpcyBpcyBhIHNhbXBsZSBrZXkgZm9yIGRvY3M=
RECOVERY: cmVjb3Zlcnkga2V5IGZvciBkb2NzIHNhbXBsZQ==

================================================================

Store the key securely (secrets manager, environment variable).
Store the recovery key separately in a safe, offline location.

These keys will NOT be shown again. If you lose both keys,
your data is unrecoverable.
================================================================
```

You can also provide your own keys via `--key` and `--recovery-key` (base64-encoded, 16, 24, or
32 bytes decoded):

```sh
$ mix fact.create -p data/secure_turtles --encrypted \
--key=$MY_KEY \
--recovery-key=$MY_RECOVERY_KEY
```

Store the primary key securely (e.g., in a secrets manager or vault). Store the recovery key
in a separate, offline location (e.g., a printed key in a safe, a hardware security module).

## Opening an Encrypted Database

Supply the base64-encoded key via the `:encryption_key` option. The bootstrapper reads the
wrapped DEK from the `.bootstrap` file automatically — you only need to provide the key itself:

```elixir
{:ok, db} = Fact.open("data/secure_turtles",
encryption_key: System.fetch_env!("FACT_KEY")
)
```

### In a Supervision Tree

```elixir
# application.ex
def start(_type, _args) do
children = [
{Fact.Supervisor,
databases: [
{"data/secure_turtles",
encryption_key: System.fetch_env!("FACT_KEY")}
]}
]

Supervisor.start_link(children, strategy: :one_for_one)
end
```

## KEK Providers

For advanced use cases (vault integrations, per-database key resolution), Fact supports a provider
behaviour (`Fact.Encryption.KEKProvider`). The `:encryption_key` option uses the built-in
`Fact.Encryption.KEKProvider.Explicit` provider under the hood.

Custom providers can integrate with external key management systems:

```elixir
defmodule MyApp.VaultKEKProvider do
@behaviour Fact.Encryption.KEKProvider

@impl true
def fetch_kek(opts) do
database_id = Keyword.fetch!(opts, :database_id)
{:ok, MyApp.Vault.get_key("fact/#{database_id}/kek")}
end
end
```

```elixir
Fact.open("data/secure_turtles",
encryption: [
kek_provider: MyApp.VaultKEKProvider,
database_id: "my_db"
]
)
```

## Recovery

If the primary key is lost or compromised, use the `mix fact.recover` task with the recovery key.
This task generates a new DEK, a new primary key, and a new recovery key, then re-encrypts all
record files in place.

```sh
$ mix fact.recover -p data/secure_turtles --recovery-key=$RECOVERY_KEY
```

The recovery task will:

1. Unwrap the existing DEK using the recovery key
2. Decrypt all record files with the old DEK
3. Generate a new DEK, primary key, and recovery key
4. Re-encrypt all record files with the new DEK
5. Update the `.bootstrap` file with the new wrapped DEKs
6. Display the new keys

The recovery key **cannot** be used directly with `Fact.open`. Recovery is an offline operation
that must be performed while the database is not running. This ensures the key rotation is atomic
and no reads or writes occur with stale keys.

## File Format

Each encrypted record file has the following binary layout:

```
┌──────────┬──────────────┬─────────────────┐
│ Nonce │ Auth Tag │ Ciphertext │
│ 12 bytes │ 16 bytes │ variable │
└──────────┴──────────────┴─────────────────┘
```

- **Nonce** (12 bytes) — A random initialization vector, unique per file. Generated using
`:crypto.strong_rand_bytes/1`.
- **Auth Tag** (16 bytes) — The GCM authentication tag. Verifies that the ciphertext has not been
tampered with.
- **Ciphertext** (variable) — The encrypted event record payload.

Since record files are write-once (WORM), each file gets a unique nonce at write time. Nonce reuse
is impossible under normal operation.

## What is Not Encrypted

The following files remain in plaintext:

| File Type | Contents | Why Not Encrypted |
|-----------|----------|-------------------|
| `.bootstrap` | Database config + wrapped DEK | Must be readable to bootstrap |
| `.ledger` | Record IDs (opaque identifiers) | Performance; no event payloads |
| Index files | Record IDs grouped by dimension | Performance; no event payloads |
| Checkpoint files | Integer positions | Operational metadata only |
| Lock files | Process metadata | Operational metadata only |

Record IDs are opaque strings (UUIDs or content hashes) that do not reveal event content. The ledger
and index files enable efficient reads without decrypting every record on disk.

## Security Considerations

- **Key storage**: Never commit KEKs to source control or store them alongside the database. Use a
secrets manager, environment variables injected at deploy time, or a hardware security module.
- **Key rotation**: The DEK is permanent for the lifetime of the database. To rotate the KEK, unwrap
the DEK with the old KEK, re-wrap with a new KEK, and update the bootstrap file.
- **Backup encryption**: Database backups created with `mix fact.backup` include encrypted record files
as-is. The backup is only useful to someone who also has the KEK.
- **Memory**: The DEK is held in memory by the `Fact.KeyRing` process for the lifetime of the database.
It is not written to disk or logged.
Loading