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.) and for use in Rust-based Linux FUSE mounts.
Supports five storage backends: in-memory (for testing), regular files (disk images), raw block devices (e.g. AWS EBS volumes), and network-backed storage via TLS to a doublecrypt-server — with an optional write-back LRU cache layer that can sit in front of any backend. Network sessions are authenticated with a token derived from the master encryption key, so anyone who holds the key can connect from any device without managing client certificates.
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 │
├─────────────────────────────────────────┤
│ cached_store Write-back LRU cache │
├─────────────────────────────────────────┤
│ block_store trait BlockStore │
│ ├ MemoryBlockStore (testing) │
│ ├ DiskBlockStore (file images) │
│ ├ DeviceBlockStore (raw devices) │
│ └ NetworkBlockStore (mTLS, opt) │
├─────────────────────────────────────────┤
│ proto wire protocol types │
├─────────────────────────────────────────┤
│ model / error on-disk types │
└─────────────────────────────────────────┘
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:
The read_blocks/write_blocks methods have default sequential implementations. The network backend overrides them with pipelined 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.
cached_store — Write-back LRU cache
CachedBlockStore<S> wraps any BlockStore with an in-memory LRU cache. Reads are served from cache when possible; writes are marked dirty and flushed in batch on sync(). If a dirty entry is evicted by cache pressure, it is written-back to the inner store immediately.
use CachedBlockStore;
// Wrap any BlockStore with a 1024-block LRU cache.
let cached = new;
cached.write_block?; // dirty, in cache only
cached.sync?; // batch-flush to inner store
The cache uses write_blocks() during sync(), so a NetworkBlockStore underneath benefits from pipelined batch writes.
network_store — Remote TLS block store (feature: network)
NetworkBlockStore connects to a doublecrypt-server over TLS using a 4-byte little-endian length-prefixed protobuf protocol. After the TLS handshake the client authenticates with a token derived from the master key (see derive_auth_token).
use Path;
use NetworkBlockStore;
let master_key = ;
let store = connect?;
Or with the builder for full control:
use Duration;
use ;
let master_key = ;
let store = from_config?;
Features:
- Key-derived authentication — the master key is used to derive an auth token via HKDF. The server stores only a hash of this token, never the encryption key.
- Request pipelining —
read_blocks()/write_blocks()send a full batch of requests before reading any responses (up to 64 at a time). - Automatic reconnection — one transparent retry with a fresh TLS handshake + re-authentication on I/O failure.
- Configurable timeouts — connect timeout (default 10 s) and I/O timeout (default 30 s).
Typical production setup (network + cache):
use Arc;
use NetworkBlockStore;
use CachedBlockStore;
use ChaChaEngine;
use FilesystemCore;
let net = connect?;
let store = new;
let crypto = new;
let mut fs = new;
fs.open?;
proto — Wire protocol types
The proto module contains hand-written prost structs defining the length-prefixed protobuf wire protocol used between NetworkBlockStore and doublecrypt-server. It is always available (not feature-gated).
Key types:
| Type | Purpose |
|---|---|
Request |
Top-level message: one-of Command variants |
Response |
Top-level message: one-of Result variants |
request::Command |
ReadBlock, WriteBlock, Sync, GetInfo, Authenticate |
response::Result |
ReadBlock, WriteBlock, Sync, GetInfo, Error, Authenticate |
ReadBlockRequest |
block_id: u64 |
ReadBlockResponse |
block_id: u64, data: Vec<u8> |
WriteBlockRequest |
block_id: u64, data: Vec<u8> |
WriteBlockResponse |
(empty — success acknowledgement) |
SyncResponse |
(empty — success acknowledgement) |
GetInfoResponse |
block_size: u64, total_blocks: u64 |
AuthenticateRequest |
auth_token: Vec<u8> (32-byte HKDF-derived token) |
AuthenticateResponse |
(empty — success acknowledgement) |
ErrorResponse |
code: i32, message: String |
See Sharing protocol types in the Features section for cross-crate usage.
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.
derive_auth_token — derives a 32-byte auth token from the same master key using a separate HKDF info string ("server-auth-token"). This token is cryptographically independent of the encryption key — learning it reveals nothing about the key used to encrypt blocks.
use derive_auth_token;
let auth_token: = derive_auth_token?;
// Send to server via Authenticate RPC.
// Server stores BLAKE3(auth_token) and compares on connect.
This means the master key is the only secret needed to both encrypt data and authenticate to the server. Two users who share a master key can connect from different devices without exchanging client certificates.
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
Features
| Feature | Default | What it enables |
|---|---|---|
network |
yes | NetworkBlockStore (rustls TLS client) |
| (none) | always | proto module (prost structs for the wire protocol) |
# Default — includes network support:
= "0.1"
# Local-only (no TLS dependencies, but proto types are still available):
= { = "0.1", = false }
Sharing protocol types with doublecrypt-server
The proto module is always available regardless of feature flags. It contains hand-written prost structs that define the wire protocol (requests, responses, error codes), so there is no need for protoc or prost-build at compile time.
This makes doublecrypt-core the single source of truth for the protocol. The server can import the types directly:
# In doublecrypt-server/Cargo.toml:
[]
= { = "0.1", = false } # proto only, no TLS client
use ;
With default-features = false, only prost (for derive macros and encoding/decoding) is pulled in — no TLS dependencies.
Limitations (v0.1)
- 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
Create a local disk image:
Connect to a remote doublecrypt-server (network-backed):
See examples/create_image.rs and examples/network_mount.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