No description
  • Rust 94.9%
  • C 1.7%
  • Scheme 1.4%
  • Shell 1.3%
  • Python 0.7%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Cristian Cezar Moisés 01f7767c8d
Some checks failed
/ fuzz-soak (push) Has been skipped
/ gate (push) Has been cancelled
/ supply-chain (push) Has been cancelled
/ ffi-sanitizers (push) Has been cancelled
/ fuzz-smoke (push) Has been cancelled
gui: desktop launcher entry + icon; ship in Guix package
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>
2026-07-15 14:26:05 -03:00
.forgejo/workflows Release 1.1.0: query features, samples, desktop GUI, cross-platform packaging, docs 2026-07-15 13:34:02 -03:00
.github/workflows Release 1.1.0: query features, samples, desktop GUI, cross-platform packaging, docs 2026-07-15 13:34:02 -03:00
benches Initial import: mirim 1.0.0 2026-06-14 05:13:19 -03:00
docs Release 1.1.0: query features, samples, desktop GUI, cross-platform packaging, docs 2026-07-15 13:34:02 -03:00
examples Release 1.1.0: query features, samples, desktop GUI, cross-platform packaging, docs 2026-07-15 13:34:02 -03:00
ffi Release 1.1.0: query features, samples, desktop GUI, cross-platform packaging, docs 2026-07-15 13:34:02 -03:00
fuzz Initial import: mirim 1.0.0 2026-06-14 05:13:19 -03:00
gui gui: desktop launcher entry + icon; ship in Guix package 2026-07-15 14:26:05 -03:00
keys Release 1.1.0: query features, samples, desktop GUI, cross-platform packaging, docs 2026-07-15 13:34:02 -03:00
samples Release 1.1.0: query features, samples, desktop GUI, cross-platform packaging, docs 2026-07-15 13:34:02 -03:00
scripts Release 1.1.0: query features, samples, desktop GUI, cross-platform packaging, docs 2026-07-15 13:34:02 -03:00
src Release 1.1.0: query features, samples, desktop GUI, cross-platform packaging, docs 2026-07-15 13:34:02 -03:00
tests Release 1.1.0: query features, samples, desktop GUI, cross-platform packaging, docs 2026-07-15 13:34:02 -03:00
.gitignore Release 1.1.0: query features, samples, desktop GUI, cross-platform packaging, docs 2026-07-15 13:34:02 -03:00
Cargo.lock Release 1.1.0: query features, samples, desktop GUI, cross-platform packaging, docs 2026-07-15 13:34:02 -03:00
Cargo.toml Release 1.1.0: query features, samples, desktop GUI, cross-platform packaging, docs 2026-07-15 13:34:02 -03:00
CHANGELOG.md docs: GNU Guix package (mirim.scm) + README Install section 2026-07-15 14:18:29 -03:00
deny.toml Initial import: mirim 1.0.0 2026-06-14 05:13:19 -03:00
LICENSE Initial import: mirim 1.0.0 2026-06-14 05:13:19 -03:00
LICENSING.md Initial import: mirim 1.0.0 2026-06-14 05:13:19 -03:00
mirim.scm gui: desktop launcher entry + icon; ship in Guix package 2026-07-15 14:26:05 -03:00
README.md gui: desktop launcher entry + icon; ship in Guix package 2026-07-15 14:26:05 -03:00
RELEASE.md Initial import: mirim 1.0.0 2026-06-14 05:13:19 -03:00
rust-toolchain.toml Initial import: mirim 1.0.0 2026-06-14 05:13:19 -03:00
sbom.cdx.json Release 1.1.0: query features, samples, desktop GUI, cross-platform packaging, docs 2026-07-15 13:34:02 -03:00
SECURITY.md Initial import: mirim 1.0.0 2026-06-14 05:13:19 -03:00

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-column PRIMARY KEY and UNIQUE constraints), INSERT (with column lists; unlisted columns get NULL), SELECT (projection, WHERE with AND-chained comparisons and IS [NOT] NULL, plus ORDER BY … [ASC|DESC], LIMIT … [OFFSET …], and COUNT(*)), UPDATE (atomic per statement: a constraint violation changes nothing), DELETE. -- line and /* */ block comments are skipped. Types: INTEGER (i64), TEXT (UTF-8). NULL never matches any comparison and sorts first under ORDER BY ASC. 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, prepared Statements): 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 an Ok survives kill -9 and 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 flock per 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-recipient pq::seal output is unchanged (format v1).
  • Optional ML-DSA-87 (FIPS 204) signed manifests (sign feature): detached .mf files binding a target's SHA-256 and a counter under a post-quantum signature, via the mirim-sign tool. The enforcing verification path persists the highest accepted counter in a trust file and rejects replayed older files as Error::Rollback; see SECURITY.md for 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.