- Rust 94.9%
- C 1.7%
- Scheme 1.4%
- Shell 1.3%
- Python 0.7%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Add gui/mirim-gui.desktop and a cyan-diamond gui/mirim.png icon, and install them (share/applications + share/icons) from mirim.scm so mirim-gui appears in the applications menu after guix install. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
| .forgejo/workflows | ||
| .github/workflows | ||
| benches | ||
| docs | ||
| examples | ||
| ffi | ||
| fuzz | ||
| gui | ||
| keys | ||
| samples | ||
| scripts | ||
| src | ||
| tests | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| CHANGELOG.md | ||
| deny.toml | ||
| LICENSE | ||
| LICENSING.md | ||
| mirim.scm | ||
| README.md | ||
| RELEASE.md | ||
| rust-toolchain.toml | ||
| sbom.cdx.json | ||
| SECURITY.md | ||
mirim
Tiny embedded SQL database, encrypted at rest by default. Post-quantum
sealed exports. ~3.8 kLOC of safe Rust, #![forbid(unsafe_code)].
mirim — Tupi-Guarani for "small".
Website: https://mirim.securityops.co
Source lives at git.securityops.co/cristiancmoises/mirim, mirrored to Codeberg and GitHub.
What it is
- A SQL subset over in-memory tables:
CREATE TABLE(single-columnPRIMARY KEYandUNIQUEconstraints),INSERT(with column lists; unlisted columns get NULL),SELECT(projection,WHEREwithAND-chained comparisons andIS [NOT] NULL, plusORDER BY … [ASC|DESC],LIMIT … [OFFSET …], andCOUNT(*)),UPDATE(atomic per statement: a constraint violation changes nothing),DELETE.--line and/* */block comments are skipped. Types:INTEGER(i64),TEXT(UTF-8).NULLnever matches any comparison and sorts first underORDER BYASC. No type coercion, ever. PRIMARY KEY and UNIQUE columns carry an in-memory equality index, so duplicate checks and point lookups on them are O(1); equality on other columns scans O(rows). New to mirim? Start with the beginner's guide. ?parameter binding (Db::execute_params, preparedStatements): the supported way to carry untrusted input into a statement. Bound values are data, never SQL.- A vault file format (
.mrm): the whole database snapshot under XChaCha20-Poly1305, key from Argon2id (passphrase) or a caller-held 32-byte raw key. The header is authenticated as AEAD associated data. Saves are atomic (write-temp, fsync, rename). - Post-quantum sealed exports: ML-KEM-768 (FIPS 203) to a recipient's public key. No classical public-key cryptography exists anywhere in any file format, so archived copies are not exposed to harvest-now-decrypt-later collection.
- Durable sessions (
DurableDb): an encrypted write-ahead log next to the vault. Mutations are fsynced before they are acknowledged, so anOksurviveskill -9and power loss;checkpoint()folds the log into a fresh snapshot. A 60-iteration SIGKILL harness (tests/powercut.rs) enforces this in CI fashion: no acknowledged commit may ever be missing after a crash. - Key rotation (
DurableDb::rotate_secret): re-encrypts the vault and rebinds the log under a new passphrase or raw key in one atomic step. - One-writer enforcement on Unix: an advisory
flockper database makes a second opener fail fast (Error::Locked) instead of corrupting the log; the lock dies with the process, so crash recovery never blocks. - Multi-recipient sealed exports (
pq::seal_multi): the snapshot is encrypted once under a random key, which is then wrapped to each ML-KEM-768 recipient; any of them can open it. Single-recipientpq::sealoutput is unchanged (format v1). - Optional ML-DSA-87 (FIPS 204) signed manifests (
signfeature): detached.mffiles binding a target's SHA-256 and a counter under a post-quantum signature, via themirim-signtool. The enforcing verification path persists the highest accepted counter in a trust file and rejects replayed older files asError::Rollback; seeSECURITY.mdfor what that does and does not assume. #![forbid(unsafe_code)]. Library plus an optional CLI REPL.
What it is not
This is not SQLite. There are no joins, no secondary indexes beyond the built-in PRIMARY KEY / UNIQUE equality index, no multi-statement transactions, no concurrent access (one process, one writer), and no query planner; the working set lives in memory. Durability is real, out-of-core storage is not: if your data exceeds RAM, use SQLite. What SQLite does not give you is an at-rest format with zero classical asymmetric crypto and PQ sealed exports; that is the niche.
Footprint, measured here (x86-64 Linux, opt-level = "z", LTO, stripped):
the mirim CLI binary including the full crypto stack is 477 KiB. SQLite's
own documentation states the fully configured library is under 900 KiB.
SQLite implements enormously more SQL per byte; the numbers are footprint
statements, not a capability comparison.
Quickstart
A runnable sample database — a self-hosted service credential store with
an access audit log — lives in examples/quickstart.rs:
cargo run --example quickstart
The first run creates an encrypted vault in your temp directory with three
tables (services, secrets, access_log), seeds them, and runs a few
representative queries (projection, WHERE, IS NULL, a parameter-bound
lookup); it also shows a UNIQUE constraint rejecting a duplicate and
rotates a secret with a bound timestamp. Re-running it finds the data
already present — the write-ahead log made every insert durable — so the
second run demonstrates persistence rather than recreating anything.
A second example, examples/sealed_export.rs, demonstrates the
post-quantum sealed-export path end to end — sealing a database to two
ML-KEM-768 recipients, each opening it, and an outsider being rejected:
cargo run --example sealed_export
Five larger, realistic, richly commented sample databases live in
samples/ — a secrets vault, a CTF scoreboard, a device
fleet, an audit trail, and a password manager. Load one into a live vault
with the CLI's .read command (mirim now accepts -- and /* */
comments, so the files read cleanly):
mirim demo.mrm
mirim> .read samples/secrets_vault.sql
ok: ran 30 statement(s) from samples/secrets_vault.sql
mirim> .schema
Or materialize encrypted .mrm vaults for all five at once (and watch a
post-quantum sealed export round-trip):
cargo run --example gen_samples ./out
From a library, Db::execute_script loads a whole SQL file in one call.
See samples/README.md for what each models and the
queries to try.
Measured performance
Container, x86-64 Linux, rustc 1.96.0, Criterion (30 samples, 3 s
measurement; durable groups 20 samples). Virtualized storage — fsync
numbers especially will differ on real disks. Reproduce:
cargo bench --bench core. Medians; SQLite is rusqlite/bundled.
durable commit (1 stmt, fsynced) mirim 578 µs sqlite 782 µs (WAL, synchronous=FULL)
bulk insert w/ PRIMARY KEY, 1k mirim 1.23 ms sqlite 3.40 ms
bulk insert w/ PRIMARY KEY, 10k mirim 13.4 ms sqlite 33.6 ms — mirim 2.5x faster
point SELECT by PK, 10k rows mirim 1.42 µs sqlite 0.62 µs — sqlite 2.3x faster
checkpoint, 1k rows mirim 4.18 ms (no commensurable sqlite op)
Read it plainly: per-commit durability is fsync-bound, so mirim's per-record encryption costs nothing visible there. Building a table with a PRIMARY KEY is now O(N) rather than O(N²) — each insert's duplicate check is an O(1) index probe, not a row scan — and mirim's append-plus- hash build comes out ahead of SQLite's B-tree on this in-memory workload. Point lookups by an indexed (PRIMARY KEY / UNIQUE) column are O(1) and within ~2.3x of SQLite's rowid B-tree, down from ~33x slower when the lookup was a scan. Equality on non-indexed columns still scans O(rows), and DELETE rebuilds the affected table's index after removing rows (an O(rows) pass on top of the existing scan — asymptotically unchanged, a modest constant). mirim's niche remains small, encrypted, durable data.
How mirim compares
The interesting axis is not raw SQL power — SQLite, DuckDB, and libSQL all
outclass mirim there, by design — but the security posture. mirim is the
only one of these that derives its at-rest key with a memory-hard KDF by
default and that offers post-quantum sealed exports and signatures.
Figures are measured here, quoted from the vendor's docs (with citations),
or plain format facts; the full analysis and sources are in
docs/comparison.md.
| Dimension | mirim 1.1 | SQLite | SQLCipher | DuckDB ≥1.4 | libSQL/Turso | redb/sled |
|---|---|---|---|---|---|---|
| Implementation | safe Rust (forbid(unsafe_code)) |
C | C+ext | C++ | C+Rust | Rust |
| Encrypted at rest | yes, default | no (SEE = paid) | yes | yes (Sept 2025) | yes | no |
| Passphrase KDF | Argon2id (memory-hard) | — | PBKDF2-HMAC-SHA512 ×256k | raw key | raw key | — |
| At-rest cipher | XChaCha20-Poly1305 (whole snapshot) | — | AES-256-CBC + HMAC | AES-256-GCM | AEAD (AEGIS/AES-GCM/ChaCha20) | — |
| Post-quantum | ML-KEM-768 + ML-DSA-87 | no | no | no | no | no |
| Crash durability | WAL, fsync-before-ack, SIGKILL-tested | WAL/journal | (SQLite's) | WAL | WAL | yes |
| SQL surface | minimal subset | full | full | rich/analytical | full | none (KV) |
| Bigger-than-RAM | no | yes | yes | yes | yes | yes |
| Footprint | ~477 KiB incl. crypto | <900 KiB lib | SQLite+crypto | tens of MB | several MB | small |
| Reproducible build + SBOM + signed release | yes | — | — | — | — | — |
| License | AGPL-3.0 / commercial | public domain | BSD | MIT | MIT | MIT/Apache |
Read it honestly: if you need real SQL or data larger than memory, pick SQLite/libSQL/DuckDB. If you need a tiny, safe-Rust, encrypted-by-default store with a memory-hard KDF and post-quantum sealed exports, that is the gap mirim fills.
C API
ffi/ builds libmirim_ffi (cdylib + staticlib) against the header
ffi/include/mirim.h — open (raw key or passphrase), parameterized
execute, result accessors, checkpoint, per-handle errors. Statements
acknowledged MIRIM_OK are durable before the call returns. One handle,
one thread. The core crate keeps #![forbid(unsafe_code)]; the FFI
crate is the single audited unsafe boundary, every site justified, every
entry point panic-fenced. The conformance harness
(ffi/tests/ffi_test.c) runs under ASan+UBSan with the Rust side also
ASan-instrumented (-Zbuild-std -Zsanitizer=address).
Testing
cargo test runs the functional suites (including the query-shaping suite
for ORDER BY/LIMIT/COUNT(*)/comments and a loader that validates every
shipped samples/*.sql), the pinned FIPS 203/204,
RFC 9106, and RFC 8439 vectors, a property-based layer (proptest:
vault/seal/manifest round-trips, WAL replay and checkpoint invariants,
PRIMARY KEY uniqueness, counter-enforcement monotonicity), a
model-based differential test of the SQL evaluator against an independent
reference oracle, a
deterministic randomized robustness harness over every externally
reachable decoder, and a SIGKILL power-cut harness for the durability
contract. fuzz/ holds five libFuzzer
targets (cargo +nightly fuzz run <target>); seed corpora come from
cargo run --example gen_fuzz_corpus --features fuzzing,sign. The SQL,
WAL, and unit suites are Miri-clean. Fuzz coverage reporting:
cargo +nightly fuzz coverage <target>, then llvm-cov report from the
nightly llvm-tools against
target/<triple>/coverage/<triple>/release/<target> with the merged
profdata under fuzz/coverage/ (wire decoder: 72% line coverage from
the current corpus; the gap is the encode path a decode fuzzer cannot
reach).
Signing artifacts
mirim-sign (built with --features sign) produces detached ML-DSA-87
(FIPS 204) signatures over any file, using mirim's own manifest format —
keygen, sign, verify, with --enforce for monotonic-counter
rollback protection. mirim signs its own release binaries with it; each
release ships the binary, a .mf manifest, and the maintainer public
key (keys/mirim-release.pub):
mirim-sign verify mirim mirim.mf mirim-release.pub
Releases and CI
Tagged vX.Y.Z on git.securityops.co/cristiancmoises/mirim, one minor
per sprint, each with a CHANGELOG.md entry. CI (.forgejo/workflows/)
gates every push on fmt, clippy -D warnings, the feature-matrix build,
the full suite, the powercut harness, cargo audit, cargo deny, the
FFI ASan/UBSan harness, and a 60 s-per-target fuzz smoke; a schedule-only
job soaks the fuzzers. A CycloneDX 1.5 SBOM of the runtime dependency closure is committed at
sbom.cdx.json (regenerable with python3 scripts/gen-sbom.py --all-features; CI fails on drift) and shipped with each release.
Release builds are reproducible — pinned
toolchain, --locked, SOURCE_DATE_EPOCH and --remap-path-prefix —
and the release job builds twice and fails unless the SHA-256 digests
match (verified locally: bit-identical). See RELEASE.md and
docs/ADR-001-storage.md.
Install
Prebuilt packages are attached to each release on
GitHub,
Codeberg, and
git.securityops.co
— .deb, .rpm, AppImage and .tar.gz (Linux), .dmg / .pkg (macOS),
.exe / .zip (Windows), and the standalone mirim-gui. Each is listed in
SHA256SUMS and carries a detached ML-DSA-87 .mf signature:
sha256sum -c SHA256SUMS --ignore-missing
mirim-sign verify <artifact> <artifact>.mf mirim-release.pub
sudo apt install ./mirim_1.1.0-1_amd64.deb # Debian/Ubuntu
sudo dnf install ./mirim-1.1.0-1.x86_64.rpm # Fedora/RHEL/openSUSE
GNU Guix — the mirim.scm in this repo installs the CLI,
mirim-sign, and the desktop GUI in one package (it packages the verified,
reproducible release binaries, since mirim's Rust dependency closure —
ml-kem, ml-dsa, eframe — is not yet in Guix proper):
guix install -f mirim.scm # or: guix package -f mirim.scm
mirim notes.mrm # CLI/REPL
mirim-gui # desktop GUI
Build
Rust 1.96.0 is pinned via rust-toolchain.toml.
Debian/Ubuntu:
sudo apt install curl build-essential
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
cargo build --release
Arch:
sudo pacman -S rustup base-devel && rustup default stable
cargo build --release
Fedora:
sudo dnf install rustup gcc && rustup-init -y
cargo build --release
Guix:
guix shell rust rust:cargo gcc-toolchain -- cargo build --release
The binary lands at target/release/mirim. Library-only (no CLI, no
rpassword): cargo build --release --no-default-features.
CLI
$ mirim notes.mrm
creating new vault: notes.mrm
new passphrase:
repeat passphrase:
mirim 1.1.0 — .help for commands
mirim> create table notes (id integer, body text);
ok
mirim> insert into notes values (1, 'first');
1 row(s)
mirim> select * from notes where id >= 1;
id | body
----------
1 | first
mirim> .save
checkpointed notes.mrm
mirim> .quit
.tables lists tables; .schema prints the reconstructed CREATE TABLE
statements; .read <path> runs the statements in a .sql file (handy for
the samples/); .save folds the write-ahead log into a fresh
snapshot; .rotate re-encrypts under a new passphrase. Every statement
that returns ok is already durable, so .quit just checkpoints and
exits. A wrong passphrase, or any tampering with the file, fails with one
indistinguishable error.
Desktop GUI
For a point-and-click way to open and manage a vault, mirim-gui is a
small, secure-by-default desktop app (dark theme, cyan accents) built on
eframe/egui. It lives in its own crate (gui/) so the core stays tiny
and unsafe-free.
cd gui && cargo build --release # binary at gui/target/release/mirim-gui
What "secure by default" means here:
- Offline. No network code exists anywhere in the GUI; it only touches the local vault file you open.
- Encrypted + durable. It opens through
DurableDb, so the vault is XChaCha20-Poly1305 at rest, every acknowledged write is fsynced, and the one-writer lock keeps a second instance out. - Read-only until you opt in. Mutating statements are refused until you tick Allow writes, so a stray query can't change data.
- The passphrase is zeroized the moment the vault is open, and is never written anywhere.
It shows the tables, runs SQL (Ctrl+Enter) with results in a grid, and has
one-click Checkpoint and Change passphrase. Prebuilt GUI binaries ship
with each release (mirim-gui-<version>-<os>), alongside the CLI packages.
Installing via guix install -f mirim.scm (or the shipped
gui/mirim-gui.desktop + gui/mirim.png) adds a desktop launcher entry.
Library
use mirim::vault::{self, Secret};
use mirim::{pq, Db, Output, Value};
let mut db = Db::new();
db.execute("CREATE TABLE k (id INTEGER, v TEXT)")?;
db.execute("INSERT INTO k VALUES (1, 'x')")?;
// Untrusted input is bound, never spliced into SQL text.
db.execute_params("INSERT INTO k VALUES (?, ?)",
&[Value::Int(2), Value::Text("from user".into())])?;
// Encrypted at rest.
vault::save(&db, "data.mrm".as_ref(), &Secret::Passphrase("pw"))?;
let db = vault::load("data.mrm".as_ref(), &Secret::Passphrase("pw"))?;
// PQ sealed export to a recipient.
let (sk, pk) = pq::keygen();
let sealed = pq::seal(&db, &pk)?;
let back = pq::open(&sealed, &sk)?;
# Ok::<(), mirim::Error>(())
File formats (v1)
As of 1.0.0 these layouts are frozen and will remain readable; a format-breaking change would be a new major version.
Vault .mrm — header is AEAD associated data:
0 8 magic "MIRIM1\0\0"
8 1 file version = 1
9 1 kdf: 0 = raw 32-byte key, 1 = Argon2id
10 16 salt (random; zero for raw keys)
26 24 XChaCha20-Poly1305 nonce (random per save)
50 .. AEAD ciphertext of the snapshot
Sealed export — header is AEAD associated data:
0 8 magic "MIRIMSL1"
8 1 format version = 1
9 1088 ML-KEM-768 ciphertext
1097 24 XChaCha20-Poly1305 nonce
1121 .. AEAD ciphertext of the snapshot
AEAD key for sealed exports:
HKDF-SHA256(ikm = ML-KEM shared secret, info = "mirim.v1.seal.mlkem768+xchacha20poly1305").
Write-ahead log (<vault>.wal) — header 41 bytes, then records:
header: magic "MIRIMWL1" | version 1 | SHA-256 of the vault file
record: seq u64 LE | nonce 24 | ct len u32 LE | ciphertext
record key: HKDF-SHA256(ikm = vault master key, salt = snapshot id,
info = "mirim.v1.wal.xchacha20poly1305")
record AAD: header || seq
Keying and AAD bind every record to the exact snapshot bytes it extends; sequence numbers are authenticated and must be consecutive. A torn final record (the only damage an honest crash can produce) is truncated on open; a bad record anywhere else is reported as corruption.
Trust file (verify_manifest_enforcing) — 49 bytes:
magic "MIRIMTS1" | version 1 | highest accepted counter (u64 LE)
| SHA-256 of the last accepted target
Multi-recipient sealed export — format v2:
magic "MIRIMSL1" | version 2 | recipient count u16 LE
| per recipient: ML-KEM-768 ct (1088) | wrapped CEK (48)
| nonce 24 | payload ciphertext
wrap key: HKDF-SHA256(ikm = KEM shared secret,
info = "mirim.v2.seal.wrap.mlkem768+xchacha20poly1305"),
zero nonce (single-use key), AAD = magic|version|count;
payload: XChaCha20-Poly1305 under the random CEK, AAD = full header
Signed manifest (sign feature) — 4677 bytes:
0 8 magic "MIRIMMF1"
8 1 manifest version = 1
9 1 target kind: 0 = vault, 1 = sealed export
10 8 counter (u64 LE, authenticated; enforced via trust file)
18 32 SHA-256 of the target file
50 4627 ML-DSA-87 signature over bytes 0..50,
context "mirim.v1.manifest.mldsa87"
The plaintext snapshot wire format is documented in src/wire.rs; its
decoder treats input as attacker-controlled (bounds-checked before any
allocation, fails closed on every structural violation).
Cryptography inventory
| Role | Primitive | Standard |
|---|---|---|
| KEM | ML-KEM-768 | FIPS 203 |
| Signatures | ML-DSA-87 (sign) |
FIPS 204 |
| AEAD | XChaCha20-Poly1305 | RFC 8439 + ext |
| Password KDF | Argon2id (64 MiB, t=3) | RFC 9106 |
| Key derivation | HKDF-SHA256 | RFC 5869 |
| RNG | OS CSPRNG (getrandom) |
— |
ML-KEM decapsulation is implicit-rejection; key failure and tampering are
indistinguishable. The test suite pins, all machine-extracted from their
sources: RFC 8439 §2.8.2 AEAD vectors, NIST ACVP FIPS 203 KATs (keyGen +
encap/decap, including a modified-ciphertext implicit-rejection case),
NIST ACVP FIPS 204 KATs (keyGen + deterministic sigGen with context +
sigVer including modified-signature negatives), and the RFC 9106 §5.3
Argon2id vector. Read SECURITY.md before
relying on any of this — it states what mirim does not protect
against.
License
Dual-licensed: AGPL-3.0-only, or a commercial license if the AGPL
doesn't fit your use. See LICENSING.md; full AGPL text in LICENSE.
The AGPL's network-use clause (section 13) applies to modified versions
run as a service. Commercial licensing: contact Security Ops via
securityops.co. Copyright (c) 2026 Cristian Cezar Moisés.