doublecrypt-core
A minimal encrypted filesystem core in Rust. All data at rest is encrypted with ChaCha20-Poly1305 AEAD; the backing block store sees only opaque ciphertext. Designed for embedding in desktop and mobile apps via a C ABI (Swift, Kotlin/JNI, etc.).
Supports three storage backends: in-memory (for testing), regular files (disk images), and raw block devices (e.g. AWS EBS volumes mounted on EC2 instances).
Quick Start
use Arc;
use MemoryBlockStore;
use ChaChaEngine;
use FilesystemCore;
use DEFAULT_BLOCK_SIZE;
// 1. Create a block store (in-memory for this example).
let store = new;
// 2. Create a crypto engine with a 32-byte master key.
let crypto = new;
// 3. Build the filesystem and format it.
let mut fs = new;
fs.init_filesystem.unwrap;
// 4. Create a file, write, read back.
fs.create_file.unwrap;
fs.write_file.unwrap;
let data = fs.read_file.unwrap;
assert_eq!;
Architecture
The crate is organized as a layered stack. Each layer depends only on the ones below it.
┌─────────────────────────────────────┐
│ ffi C ABI for Swift/etc │
├─────────────────────────────────────┤
│ fs FilesystemCore │
├─────────────────────────────────────┤
│ transaction A/B root pointer │
│ commit manager │
├──────────────┬──────────────────────┤
│ codec │ allocator │
│ serialize + │ block bitmap │
│ encrypt │ │
├──────────────┴──────────────────────┤
│ crypto ChaCha20-Poly1305 │
├─────────────────────────────────────┤
│ block_store trait BlockStore │
│ Memory/Disk/Device │
├─────────────────────────────────────┤
│ model on-disk types │
│ error FsError / FsResult │
└─────────────────────────────────────┘
Modules
error — Error types
All fallible operations return FsResult<T>, which is Result<T, FsError>.
use ;
FsError variants:
| Variant | Meaning |
|---|---|
BlockNotFound(u64) |
Referenced block doesn't exist |
BlockOutOfRange(u64) |
Block ID ≥ total blocks |
NoFreeBlocks |
Allocator exhausted |
BlockSizeMismatch { expected, got } |
Write with wrong-sized buffer |
Serialization(String) |
Encoding failure |
Deserialization(String) |
Decoding failure |
Encryption(String) |
AEAD encrypt failure |
Decryption(String) |
AEAD decrypt failure (wrong key or corruption) |
ObjectNotFound(u64) |
Block has zero-length or invalid envelope |
FileNotFound(String) |
Name not in directory |
DirectoryNotFound(String) |
Directory name not found |
FileAlreadyExists(String) |
Duplicate name on create |
DirectoryAlreadyExists(String) |
Duplicate directory name |
NotAFile(String) |
Tried a file operation on a directory |
NotADirectory(String) |
Tried a directory operation on a file |
DirectoryNotEmpty(String) |
Cannot remove non-empty directory |
NameTooLong(usize, usize) |
Filename exceeds 255 bytes |
NotInitialized |
Filesystem not mounted/formatted |
InvalidSuperblock |
Bad magic, version, or checksum |
InvalidRootPointer |
Neither A nor B root pointer slot is valid |
DataTooLarge(usize) |
Serialized envelope exceeds block size |
Internal(String) |
Catch-all (mutex poison, I/O, etc.) |
FsErrorCode is a #[repr(i32)] enum used by the FFI layer to map errors to integer codes.
model — On-disk data types
All persistent structures live here. They derive Serialize + Deserialize for postcard encoding.
Constants:
| Name | Value | Meaning |
|---|---|---|
DEFAULT_BLOCK_SIZE |
65536 |
64 KiB per block |
MAX_NAME_LEN |
255 |
Max filename bytes (UTF-8) |
BLOCK_STORAGE_HEADER |
0 |
Block 0: storage header |
BLOCK_ROOT_POINTER_A |
1 |
Block 1: root pointer slot A |
BLOCK_ROOT_POINTER_B |
2 |
Block 2: root pointer slot B |
FIRST_DATA_BLOCK |
3 |
First allocatable block |
Key types:
| Type | Purpose |
|---|---|
StorageHeader |
Written to block 0. Contains magic (DBLCRYPT), version, block size, total blocks. |
RootPointer |
Written to blocks 1 or 2. Contains generation counter, superblock reference, BLAKE3 checksum. |
Superblock |
Points to the root inode. Written encrypted to a data block. |
Inode |
Metadata for a file or directory: kind, size, timestamps, refs to directory page / extent map. |
DirectoryPage |
List of DirectoryEntry structs (name, inode ref, kind). |
ExtentMap |
List of ExtentEntry structs mapping chunk index → encrypted data block. |
EncryptedObject |
On-disk envelope: ObjectKind, version, 12-byte nonce, ciphertext. |
ObjectRef |
A typed block pointer (u64). ObjectRef::null() is u64::MAX. |
ObjectKind |
Discriminator: Superblock, RootPointer, Inode, DirectoryPage, ExtentMap, FileDataChunk. |
block_store — Storage backends
The BlockStore trait abstracts fixed-size block I/O:
MemoryBlockStore — in-memory HashMap<u64, Vec<u8>> behind a Mutex. Good for tests and ephemeral use.
let store = new;
DiskBlockStore — file-backed I/O using pread/pwrite (positioned I/O, no seeking). sync() calls file.sync_all().
// Create a new image file (random-filled, fails if file exists).
let store = create?;
// Open an existing image file.
let store = open?;
// Open and infer total_blocks from file size.
let store = open?;
New image files are filled with cryptographically random bytes so free space is indistinguishable from ciphertext.
DeviceBlockStore — raw block device backend for devices such as AWS EBS volumes, local NVMe drives, or any /dev/* block device. Uses pread/pwrite just like DiskBlockStore, but discovers the device size via lseek(SEEK_END) because stat() reports st_size = 0 for block devices.
// Open an existing block device (e.g. an already-initialized EBS volume).
let store = open?; // infer total_blocks
// Initialize a fresh device (fills with random data — slow on large volumes).
let store = initialize?;
Note: The process must have read/write permissions on the device node. On EC2, you typically need
rootor membership in thediskgroup.
Implementing a custom backend: Implement the BlockStore trait. The rest of the stack works with Arc<dyn BlockStore>, so any backend (network, FUSE, database) can be plugged in without changing any other code.
allocator — Block allocation
The SlotAllocator trait manages free/allocated block tracking:
BitmapAllocator is a BTreeSet-backed implementation. Blocks 0..FIRST_DATA_BLOCK (0, 1, 2) are permanently reserved. On mount, the allocator is rebuilt by walking the metadata tree.
crypto — Encryption engine
ChaChaEngine — ChaCha20-Poly1305 AEAD with HKDF-SHA256 key derivation.
// From a caller-provided 32-byte master key:
let engine = new?;
// Generate a random master key:
let engine = generate?;
| Parameter | Value |
|---|---|
| Cipher | ChaCha20-Poly1305 |
| Key | 256-bit, derived via HKDF-SHA256 (salt: doublecrypt-v1, info: block-encryption) |
| Nonce | 96-bit, randomly generated per encryption |
| Auth tag | 128-bit (appended to ciphertext by AEAD) |
The derived key is zeroized on drop.
Helper functions:
// Wrap plaintext into an EncryptedObject envelope:
let encrypted = encrypt_object?;
// Unwrap back to plaintext:
let plaintext = decrypt_object?;
codec — Serialization + encrypt/write pipeline
Combines serialization (postcard), encryption (crypto engine), and block I/O into single calls.
// Write a struct → serialize → encrypt → pad to block → write to store.
write_encrypted_object?;
// Read block → extract envelope → decrypt → deserialize → return typed struct.
let inode: Inode = read_encrypted_object?;
// Same pipeline but for raw bytes (file data chunks):
write_encrypted_raw?;
let raw = read_encrypted_raw?;
Every block is padded with random bytes (not zeroes) so the padding region is indistinguishable from ciphertext.
transaction — Copy-on-write commit
TransactionManager handles atomic commits using alternating A/B root pointer slots.
Commit sequence:
- All mutations allocate new blocks (copy-on-write). Old blocks are never modified in place.
commit()writes the new superblock to a fresh encrypted block.- Computes a BLAKE3 checksum of the serialized superblock.
- Writes a
RootPointer(generation, superblock ref, checksum) to the next A/B slot. - Toggles the next slot.
Recovery on mount:
// Read both root pointer slots, pick highest generation.
let = recover_latest?
.ok_or?;
The single-block root pointer write is the atomic commit point. If a crash happens before it completes, the previous generation's root pointer is still valid.
fs — High-level filesystem API
FilesystemCore is the main entry point for all filesystem operations.
use ;
let mut fs = new;
Lifecycle:
// Format a new filesystem (call once):
fs.init_filesystem?;
// Or mount an existing one:
fs.open?;
File operations:
fs.create_file?;
fs.write_file?;
fs.write_file?; // write at offset
let data = fs.read_file?;
fs.rename?;
fs.remove_file?;
Directory operations:
fs.create_directory?;
let entries: = fs.list_directory?;
for e in &entries
fs.remove_file?; // must be empty
Persistence:
fs.sync?; // flush block store to disk
Every mutation (create, write, rename, remove) automatically commits a new generation via copy-on-write. Call sync() to ensure the underlying BlockStore flushes to durable storage.
ffi — C ABI
The FFI layer exposes a handle-based C API for use from Swift, Kotlin, C, etc. See include/doublecrypt_core.h (generated by cbindgen).
Build the C header:
Build the static/dynamic library:
# Produces:
# target/release/libdoublecrypt_core.a (static)
# target/release/libdoublecrypt_core.dylib (dynamic, macOS)
FFI functions:
| Function | Purpose |
|---|---|
fs_create(total_blocks, key, key_len) → *FsHandle |
Create in-memory FS |
fs_create_disk(path, total_blocks, block_size, create_new, key, key_len) → *FsHandle |
Create/open disk FS |
fs_create_device(path, total_blocks, block_size, initialize, key, key_len) → *FsHandle |
Open/init block device FS |
fs_destroy(handle) |
Free handle |
fs_init_filesystem(handle) → i32 |
Format new FS |
fs_open(handle) → i32 |
Mount existing FS |
fs_create_file(handle, name) → i32 |
Create file |
fs_write_file(handle, name, offset, data, len) → i32 |
Write data |
fs_read_file(handle, name, offset, len, out_buf, out_len) → i32 |
Read into buffer |
fs_list_root(handle, out_error) → *char |
List root dir (JSON) |
fs_create_dir(handle, name) → i32 |
Create directory |
fs_remove_file(handle, name) → i32 |
Remove file/dir |
fs_rename(handle, old, new) → i32 |
Rename |
fs_sync(handle) → i32 |
Flush |
fs_free_string(s) |
Free Rust-allocated string |
All functions return 0 on success or a negative FsErrorCode value on failure.
On-Disk Format
Every block (allocated or free) contains random bytes. Allocated blocks have this structure:
Offset Size Content
────── ─────── ──────────────────────────────────
0 4 bytes u32 LE — length of the envelope (n)
4 n bytes Serialized envelope (postcard)
4+n rest Random padding to fill block_size
Block 0 (storage header, unencrypted):
envelope = StorageHeader { magic: "DBLCRYPT", version: 1, block_size, total_blocks }
Blocks 1–2 (root pointers, unencrypted):
envelope = RootPointer { generation, superblock_ref, checksum }
Blocks 3+ (data blocks, encrypted):
envelope = EncryptedObject { kind, version, nonce: [u8;12], ciphertext }
where ciphertext = AEAD(plaintext || 16-byte Poly1305 tag)
and plaintext = postcard-serialized Inode / DirectoryPage / ExtentMap / raw file data
Limitations (v0.1)
- Flat directory model — all entries live in the root directory. No nested subdirectories.
- No garbage collection — old blocks from previous copy-on-write generations are never reclaimed. Extended use will exhaust the block store.
- Whole-file rewrite on write —
write_filereads all existing chunks, splices the new data, and rewrites every chunk. Fine for small files; not suitable for large sequential appends. - Single directory/extent page — no overflow pages for directories or extent maps.
- Unix-only disk I/O —
DiskBlockStoreandDeviceBlockStoreusestd::os::unix::fs::FileExt(pread/pwrite). Works on Linux and macOS; Windows would need a different implementation.
Block Device Usage (EBS / EC2)
To use an AWS EBS volume (or any raw block device) as the encrypted store:
- Attach an EBS volume to your EC2 instance (e.g. as
/dev/xvdf). - Do NOT format or mount it with a traditional filesystem — doublecrypt writes directly to the raw device.
- Initialize the device once (fills with random data so free blocks look like ciphertext):
use Arc;
use DeviceBlockStore;
use ChaChaEngine;
use FilesystemCore;
let store = new;
let crypto = new;
let mut fs = new;
fs.init_filesystem.unwrap;
- Open on subsequent boots:
let store = new;
let crypto = new;
let mut fs = new;
fs.open.unwrap; // mount existing filesystem
From Swift (via the C FFI):
// First time — initialize the device:
let fs = try DoubleCryptFS.initializeDevice(path: "/dev/xvdf", key: masterKey)
try fs.initFilesystem()
// Subsequent opens:
let fs = try DoubleCryptFS.openDevice(path: "/dev/xvdf", key: masterKey)
try fs.mount()
Permissions: The process needs read/write access to the device node. Run as
rootor add the user to thediskgroup (sudo usermod -aG disk $USER).
Sizing: With the default 64 KiB block size, a 1 GiB EBS volume yields ~16,384 blocks. Pass
total_blocks: 0to automatically use the full device.
Examples
A sample program that creates a disk image:
See examples/create_image.rs.
Swift Integration
A ready-made Swift package wrapping the C ABI is in the swift/ directory. See swift/DOUBLECRYPT.md for usage instructions.
&&
License
MIT