Skip to content

yp3y5akh0v/citadel

Citadel

Citadel

Encrypted-first embedded database engine that outperforms unencrypted SQLite.

crates.io npm CI License

Every page is encrypted and authenticated before it hits disk. The database file is always opaque. Wins all 38 head-to-head benchmarks against unencrypted SQLite at equal cache budgets.

Features

  • Encrypted at rest - AES-256-CTR + HMAC-SHA256 per page, verified before decryption
  • SQL - JOINs, subqueries, CTEs (recursive), UNION/INTERSECT/EXCEPT, window functions, views, aggregates, indexes, constraints, generated columns (STORED + VIRTUAL), ALTER TABLE, UPSERT (ON CONFLICT), RETURNING (with OLD/NEW), prepared statements
  • ACID - Copy-on-Write B+ tree, shadow paging, no WAL. Snapshot isolation with concurrent readers
  • P2P sync - Merkle-based table diffing over Noise-encrypted channels with PSK auth
  • CLI - SQL shell with tab completion, syntax highlighting, dot-commands (.backup, .verify, .rekey, .sync, .dump, ...)
  • 3-tier key hierarchy - Passphrase -> Argon2id -> Master Key -> AES-KW -> REK -> HKDF -> DEK + MAC
  • FIPS 140-3 - PBKDF2-HMAC-SHA256 + AES-256-CTR when compliance requires it
  • Audit log - HMAC-SHA256 chained, tamper-evident
  • Hot backup - Consistent snapshots via MVCC, no write blocking
  • Overflow pages - Large values handled transparently, no size limits
  • Cross-platform - Windows, Linux, macOS. C FFI (37 functions), WebAssembly bindings
  • 3,100+ tests - Unit, integration, torture tests across 10 crates

Benchmarks

Single-threaded on 100K rows, schema (id INTEGER PK, name TEXT, age INTEGER). Ratio = SQLite / Citadel time.

Benchmark              Citadel        SQLite         Ratio
----------------------------------------------------------
correlated_in          5.92 ms        1.87 s         315.5x
count                  146 ns         21.4 us        146.6x
correlated_scalar      292 us         17.96 ms       61.5x
point                  772 ns         12.4 us        16.0x
group_by               1.27 ms        9.89 ms        7.81x
cte                    1.16 ms        5.85 ms        5.03x
view_point             2.78 us        12.4 us        4.44x
upsert_returning       55.5 us        166 us         2.99x
insert_returning       57.6 us        165 us         2.86x
window_agg             31.7 ms        74.0 ms        2.34x
filter                 769 us         1.76 ms        2.29x
view_filter            768 us         1.74 ms        2.26x
savepoint_create       316 ns         687 ns         2.17x
sort                   1.22 ms        2.58 ms        2.11x
upsert_counter         25.0 us        52.8 us        2.11x
window_rank            60.2 ms        121 ms         2.01x
delete_returning       86.5 us        161 us         1.86x
upsert_dedup           18.7 us        31.7 us        1.70x
update                 18.2 us        28.4 us        1.56x
insert_select          720 us         1.12 ms        1.55x
delete                 43.5 us        66.5 us        1.53x
correlated_exists      4.32 ms        6.50 ms        1.50x
savepoint_nested       216 us         303 us         1.41x
savepoint_rollback     1.52 ms        2.10 ms        1.39x
distinct               2.67 ms        3.63 ms        1.36x
insert                 36.9 us        49.9 us        1.35x
update_returning       110 us         144 us         1.31x
scan                   6.35 ms        7.70 ms        1.21x
union                  119 us         140 us         1.18x
insert_gen_stored      47.6 us        55.7 us        1.17x
insert_gen_virtual     44.9 us        52.4 us        1.17x
sum                    1.60 ms        1.85 ms        1.16x
update_gen_propagate   38.2 us        44.3 us        1.16x
upsert_all_new         44.1 us        49.3 us        1.12x
select_gen_virtual     15.5 us        17.5 us        1.12x
recursive_cte          104 us         117 us         1.12x
upsert_mixed           51.5 us        56.2 us        1.09x
join                   91.4 us        93.8 us        1.025x

Native DATE / TIMESTAMP (Citadel only, SQLite has no native type)

Benchmark          Citadel
-------------------------------
date_sort          1.32 ms
date_range_scan    1.75 ms
date_arith         1.76 ms
date_groupby       9.12 ms
date_extract       12.68 ms
Methodology

H2H benchmarks (sorted by ratio, highest first):

  • correlated_in - SELECT COUNT(*) FROM t WHERE id IN (SELECT id FROM ref_table WHERE ref_table.val = t.age)
  • count - SELECT COUNT(*) FROM t
  • correlated_scalar - SELECT a.id, (SELECT COUNT(*) FROM b WHERE b.a_id = a.id) FROM a
  • point - SELECT * FROM t WHERE id = 50000
  • group_by - SELECT age, COUNT(*) FROM t GROUP BY age
  • cte - WITH filtered AS (SELECT ... WHERE age < 50) SELECT age, COUNT(*) FROM filtered GROUP BY age
  • view_point - SELECT * FROM v WHERE id = 50000
  • upsert_returning - INSERT ... ON CONFLICT (id) DO UPDATE SET c = c + 1 RETURNING c
  • insert_returning - INSERT INTO t (id, val) VALUES (...) RETURNING id, val
  • window_agg - SUM(age) OVER (ORDER BY id ROWS 50 PRECEDING)
  • filter - SELECT * FROM t WHERE age = 42
  • view_filter - SELECT * FROM v WHERE age = 42
  • savepoint_create - BEGIN; SAVEPOINT sp; RELEASE sp; COMMIT
  • sort - SELECT * FROM t ORDER BY age LIMIT 10
  • upsert_counter - INSERT ... ON CONFLICT (id) DO UPDATE SET c = c + 1
  • window_rank - ROW_NUMBER() OVER (PARTITION BY age ORDER BY id)
  • delete_returning - DELETE ... WHERE id = ? RETURNING id, val
  • upsert_dedup - INSERT ... ON CONFLICT (id) DO NOTHING
  • update - UPDATE t SET age = age + 1 WHERE id BETWEEN 10000 AND 10099
  • insert_select - INSERT INTO sink SELECT id, val FROM a
  • delete - DELETE FROM t WHERE id = ?
  • correlated_exists - SELECT COUNT(*) FROM t WHERE EXISTS (SELECT 1 FROM ref_table WHERE ref_table.id = t.id)
  • savepoint_nested - BEGIN; SAVEPOINT sp1; ... ; RELEASE/ROLLBACK TO sp1; COMMIT
  • savepoint_rollback - BEGIN; INSERT 1K rows; SAVEPOINT sp; INSERT 10K rows; ROLLBACK TO sp; COMMIT
  • distinct - SELECT DISTINCT age FROM t
  • insert - INSERT INTO t (id, val) VALUES (?, ?)
  • update_returning - UPDATE t SET c = c + ? WHERE id = ? RETURNING c
  • scan - SELECT * FROM t
  • union - SELECT id, val FROM a UNION ALL SELECT id, data FROM b
  • insert_gen_stored - INSERT INTO t (id, a, b) VALUES (?, ?, ?)
  • insert_gen_virtual - INSERT INTO t (id, a, b) VALUES (?, ?, ?)
  • sum - SELECT SUM(age) FROM t
  • update_gen_propagate - UPDATE t SET a = a + ? WHERE id = ?
  • upsert_all_new - INSERT ... ON CONFLICT (id) DO NOTHING
  • select_gen_virtual - SELECT id, s FROM t WHERE s > ?
  • recursive_cte - WITH RECURSIVE seq(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM seq WHERE x < 1000) SELECT SUM(x) FROM seq
  • upsert_mixed - INSERT ... ON CONFLICT (id) DO UPDATE SET c = c + 1
  • join - SELECT a.id, b.data FROM a INNER JOIN b ON a.id = b.a_id

Date benchmarks (Citadel-only, sorted by duration):

  • date_sort - SELECT id FROM events ORDER BY ts LIMIT 100
  • date_range_scan - SELECT COUNT(*) FROM events WHERE d BETWEEN DATE '2024-02-01' AND DATE '2024-03-31'
  • date_arith - SELECT COUNT(*) FROM events WHERE ts + INTERVAL '1 day' > TIMESTAMP '2024-06-01 00:00:00'
  • date_groupby - SELECT DATE_TRUNC('month', ts), COUNT(*) FROM events GROUP BY 1
  • date_extract - SELECT AVG(EXTRACT(HOUR FROM ts)) FROM events

SQLite config: journal_mode=OFF, synchronous=OFF, cache_size=8192 (~32 MB). Citadel config: SyncMode::Off, cache_size=4096 (~32 MB). Both run with durability disabled to measure pure engine overhead, not disk I/O.

Reproduce with cargo bench -p citadeldb-sql --bench h2h_bench

Quick Start

Library

use citadel::DatabaseBuilder;
use citadel_sql::Connection;

let db = DatabaseBuilder::new("my.db")
    .passphrase(b"secret")
    .create()?;

let mut conn = Connection::open(&db)?;
conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL);")?;
conn.execute("INSERT INTO users (id, name) VALUES (1, 'Alice');")?;
let result = conn.query("SELECT * FROM users;")?;

// Key-value API
let mut wtx = db.begin_write()?;
wtx.insert(b"key", b"value")?;
wtx.commit()?;

let mut rtx = db.begin_read();
assert_eq!(rtx.get(b"key")?.unwrap(), b"value");

// Named tables
let mut wtx = db.begin_write()?;
wtx.create_table(b"sessions")?;
wtx.table_insert(b"sessions", b"token-abc", b"user-42")?;
wtx.commit()?;

// In-memory (no file I/O - useful for testing and WASM)
let mem_db = DatabaseBuilder::new("")
    .passphrase(b"secret")
    .create_in_memory()?;

CLI

citadel --create my.db

citadel> CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
citadel> INSERT INTO users (id, name) VALUES (1, 'Alice'), (2, 'Bob');
citadel> SELECT * FROM users;
+----+-------+
| id | name  |
+----+-------+
|  1 | Alice |
|  2 | Bob   |
+----+-------+

citadel> .backup mydb.bak
citadel> .verify
citadel> .stats
citadel> .audit verify
citadel> .rekey
citadel> .compact clean.db
citadel> .dump users

# P2P sync
citadel> .keygen
citadel> .listen 4248 <KEY>              # Terminal A
citadel> .sync 127.0.0.1:4248 <KEY>      # Terminal B

SQL

Statements - CREATE/DROP TABLE, ALTER TABLE (ADD/DROP/RENAME COLUMN, RENAME TABLE), CREATE/DROP INDEX, CREATE/DROP VIEW, INSERT (VALUES, SELECT, ON CONFLICT DO NOTHING/DO UPDATE, ON CONSTRAINT), SELECT, UPDATE, DELETE, RETURNING (with OLD/NEW), BEGIN/COMMIT/ROLLBACK, SAVEPOINT/RELEASE/ROLLBACK TO, SET TIME ZONE, EXPLAIN

Constraints - PRIMARY KEY, NOT NULL, UNIQUE, DEFAULT, CHECK (column + table level), FOREIGN KEY (RESTRICT/NO ACTION), GENERATED ALWAYS AS (...) STORED|VIRTUAL

Types - INTEGER, REAL, TEXT, BLOB, BOOLEAN, DATE, TIME, TIMESTAMP (WITH TIME ZONE), INTERVAL

Clauses - JOINs (INNER, LEFT, RIGHT, CROSS), subqueries (scalar, IN, EXISTS, correlated), CTEs (WITH / WITH RECURSIVE), UNION/INTERSECT/EXCEPT [ALL], CASE, BETWEEN, LIKE, DISTINCT, GROUP BY/HAVING, ORDER BY, LIMIT/OFFSET

Window functions - ROW_NUMBER, RANK, DENSE_RANK, NTILE, LAG, LEAD, FIRST_VALUE, LAST_VALUE, SUM/COUNT/AVG/MIN/MAX OVER with PARTITION BY, ORDER BY, ROWS/RANGE frames

Views - CREATE/DROP VIEW, OR REPLACE, IF NOT EXISTS/IF EXISTS, column aliases, nested views

Functions - COUNT, SUM, AVG, MIN, MAX, LENGTH, UPPER, LOWER, SUBSTR/SUBSTRING, TRIM/LTRIM/RTRIM, REPLACE, INSTR, CONCAT, HEX, ABS, ROUND, CEIL/CEILING, FLOOR, SIGN, SQRT, RANDOM, COALESCE, NULLIF, CAST, TYPEOF, IIF

Date/Time Functions - NOW, CURRENT_TIMESTAMP, CURRENT_DATE, CURRENT_TIME, LOCALTIMESTAMP, LOCALTIME, CLOCK_TIMESTAMP, EXTRACT, DATE_PART, DATE_TRUNC, DATE_BIN, AGE, MAKE_DATE, MAKE_TIME, MAKE_TIMESTAMP, MAKE_INTERVAL, JUSTIFY_DAYS, JUSTIFY_HOURS, JUSTIFY_INTERVAL, ISFINITE, DATE, TIME, DATETIME, STRFTIME, JULIANDAY, UNIXEPOCH, TIMEDIFF, AT TIME ZONE. Supports INTERVAL '1 year 2 months', DATE '2024-01-15', TIMESTAMP '2024-01-15 12:30:00Z', infinity/-infinity sentinels, BC dates, full IANA zone parsing (jiff), PG-normalized INTERVAL comparison.

Prepared statements - $1, $2, ... positional parameters with LRU statement cache

Multi-statement scripts - Connection::execute_script(sql) runs ;-separated statements in one call, returning per-statement outcomes with partial-success preserved. WASM: db.run(sql) returns [{type, ...}, ...].

UPSERT - INSERT ... ON CONFLICT (cols) DO NOTHING / DO UPDATE SET col = excluded.col ... WHERE ... and ON CONFLICT ON CONSTRAINT idx_name. excluded.* refers to the proposed row; bare col refers to the existing row. Single-descent storage primitive: on the canonical DO UPDATE SET counter = counter + 1 pattern, Citadel is ~2× faster than SQLite.

Security

No plaintext on disk. Every page is encrypted before writing and authenticated before reading.

Separate key file. Encryption keys live in {dbname}.citadel-keys, not inside the database. The passphrase derives a master key in memory via Argon2id (or PBKDF2 in FIPS mode) and never touches disk.

Key backup. Export an encrypted key backup with a separate recovery passphrase. Restore access without re-encrypting the entire database.

Instant rekey. Changing the passphrase re-wraps the root encryption key. No page re-encryption - instant regardless of database size.

Encrypted sync. Noise protocol (NNpsk0_25519_ChaChaPoly_BLAKE2s) with a 256-bit pre-shared key. Ephemeral Curve25519 keys per session for forward secrecy.

Architecture

+-------------+---------------+---------------+
| citadel-cli | citadel-ffi   | citadel-wasm  |  CLI, C FFI, WebAssembly
+-------------+---------------+---------------+
|                 citadel-sql                  |  SQL parser, planner, executor
+---------------------------------------------+
|                  citadel                     |  Database API, builder, sync
+--------------+--------------+---------------+
|  citadel-txn | citadel-sync | citadel-crypto|  Transactions, replication, keys
+--------------+--------------+---------------+
|citadel-buffer|         citadel-page         |  Buffer pool (SIEVE), page codec
+--------------+------------------------------+
|              citadel-io                      |  File I/O, fsync, io_uring
+---------------------------------------------+
|              citadel-core                    |  Types, errors, constants
+---------------------------------------------+

Page Layout (8,208 bytes)

+----------+--------------------+----------+
|  IV 16B  |  Ciphertext 8160B  |  MAC 32B |
+----------+--------------------+----------+

Fresh random IV per page. HMAC verified before decryption.

Commit Protocol

Shadow paging with a god byte - one byte selects the active commit slot. Atomic commits without WAL:

  1. Write dirty pages to new locations (CoW)
  2. Compute Merkle hashes bottom-up
  3. Update the inactive commit slot
  4. Flip the god byte

Language Bindings

C / C++

Static or dynamic library with auto-generated citadel.h (cbindgen). All 37 functions are panic-safe.

#include "citadel.h"

CitadelDb *db = NULL;
citadel_create("my.db", "secret", 6, &db);

CitadelWriteTxn *wtx = NULL;
citadel_write_begin(db, &wtx);
citadel_write_put(wtx, (const uint8_t*)"key", 3, (const uint8_t*)"val", 3);
citadel_write_commit(wtx);

CitadelSqlConn *conn = NULL;
citadel_sql_open(db, &conn);
CitadelSqlResult *result = NULL;
citadel_sql_execute(conn, "SELECT * FROM users;", &result);

citadel_close(db);

WebAssembly

import { CitadelDb } from "@citadeldb/wasm";

const db = new CitadelDb("secret");
db.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT);");
db.execute("INSERT INTO t (id, name) VALUES (1, 'Alice');");

const result = db.query("SELECT * FROM t;");
// { columns: ["id", "name"], rows: [[1, "Alice"]] }

db.put(new Uint8Array([1, 2, 3]), new Uint8Array([4, 5, 6]));

Build: wasm-pack build crates/citadel-wasm --target web

Building

Rust 1.75+.

git clone https://github.com/yp3y5akh0v/citadel.git
cd citadel
cargo build --release

Feature Flags

Flag Description
audit-log HMAC-chained tamper-evident audit log (default: on)
fips FIPS 140-3: PBKDF2 + AES-256-CTR only
io-uring Linux io_uring async I/O

License

MIT OR Apache-2.0

About

Encrypted-first embedded database engine that outperforms unencrypted SQLite

Topics

Resources

License

Apache-2.0, MIT licenses found

Licenses found

Apache-2.0
LICENSE-APACHE
MIT
LICENSE-MIT

Security policy

Stars

Watchers

Forks

Sponsor this project

 

Packages

 
 
 

Languages