doublecrypt-core 0.1.0

Encrypted filesystem core library with C ABI for cross-platform frontends
Documentation

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 std::sync::Arc;
use doublecrypt_core::block_store::MemoryBlockStore;
use doublecrypt_core::crypto::ChaChaEngine;
use doublecrypt_core::fs::FilesystemCore;
use doublecrypt_core::model::DEFAULT_BLOCK_SIZE;

// 1. Create a block store (in-memory for this example).
let store = Arc::new(MemoryBlockStore::new(DEFAULT_BLOCK_SIZE, 64));

// 2. Create a crypto engine with a 32-byte master key.
let crypto = Arc::new(ChaChaEngine::new(&[0xAA; 32]).unwrap());

// 3. Build the filesystem and format it.
let mut fs = FilesystemCore::new(store, crypto);
fs.init_filesystem().unwrap();

// 4. Create a file, write, read back.
fs.create_file("hello.txt").unwrap();
fs.write_file("hello.txt", 0, b"Hello, encrypted world!").unwrap();

let data = fs.read_file("hello.txt", 0, 4096).unwrap();
assert_eq!(data, b"Hello, encrypted world!");

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 doublecrypt_core::error::{FsError, FsResult};

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:

pub trait BlockStore: Send + Sync {
    fn block_size(&self) -> usize;
    fn total_blocks(&self) -> u64;
    fn read_block(&self, block_id: u64) -> FsResult<Vec<u8>>;
    fn write_block(&self, block_id: u64, data: &[u8]) -> FsResult<()>;
    fn sync(&self) -> FsResult<()> { Ok(()) }  // default no-op
}

MemoryBlockStore — in-memory HashMap<u64, Vec<u8>> behind a Mutex. Good for tests and ephemeral use.

let store = MemoryBlockStore::new(block_size, total_blocks);

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 = DiskBlockStore::create("/path/to/image.dcfs", 65536, 1024)?;

// Open an existing image file.
let store = DiskBlockStore::open("/path/to/image.dcfs", 65536, 1024)?;

// Open and infer total_blocks from file size.
let store = DiskBlockStore::open("/path/to/image.dcfs", 65536, 0)?;

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 = DeviceBlockStore::open("/dev/xvdf", 65536, 0)?;   // infer total_blocks

// Initialize a fresh device (fills with random data — slow on large volumes).
let store = DeviceBlockStore::initialize("/dev/xvdf", 65536, 0)?;

Note: The process must have read/write permissions on the device node. On EC2, you typically need root or membership in the disk group.

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:

pub trait SlotAllocator: Send + Sync {
    fn allocate(&self) -> FsResult<u64>;
    fn free(&self, block_id: u64) -> FsResult<()>;
    fn is_allocated(&self, block_id: u64) -> bool;
}

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

pub trait CryptoEngine: Send + Sync {
    fn encrypt(&self, plaintext: &[u8]) -> FsResult<(Vec<u8>, Vec<u8>)>;  // (nonce, ciphertext)
    fn decrypt(&self, nonce: &[u8], ciphertext: &[u8]) -> FsResult<Vec<u8>>;
}

ChaChaEngine — ChaCha20-Poly1305 AEAD with HKDF-SHA256 key derivation.

// From a caller-provided 32-byte master key:
let engine = ChaChaEngine::new(&master_key_bytes)?;

// Generate a random master key:
let engine = ChaChaEngine::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(&engine, ObjectKind::Inode, &plaintext)?;

// Unwrap back to plaintext:
let plaintext = decrypt_object(&engine, &encrypted)?;

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(&*store, &*crypto, &codec, block_id, ObjectKind::Inode, &inode)?;

// Read block → extract envelope → decrypt → deserialize → return typed struct.
let inode: Inode = read_encrypted_object(&*store, &*crypto, &codec, block_id)?;

// Same pipeline but for raw bytes (file data chunks):
write_encrypted_raw(&*store, &*crypto, &codec, block_id, ObjectKind::FileDataChunk, &raw_data)?;
let raw = read_encrypted_raw(&*store, &*crypto, &codec, block_id)?;

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:

  1. All mutations allocate new blocks (copy-on-write). Old blocks are never modified in place.
  2. commit() writes the new superblock to a fresh encrypted block.
  3. Computes a BLAKE3 checksum of the serialized superblock.
  4. Writes a RootPointer (generation, superblock ref, checksum) to the next A/B slot.
  5. Toggles the next slot.

Recovery on mount:

// Read both root pointer slots, pick highest generation.
let (root_ptr, was_b) = TransactionManager::recover_latest(&*store, &codec)?
    .ok_or(FsError::InvalidRootPointer)?;

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 doublecrypt_core::fs::{FilesystemCore, DirListEntry};

let mut fs = FilesystemCore::new(store, crypto);

Lifecycle:

// Format a new filesystem (call once):
fs.init_filesystem()?;

// Or mount an existing one:
fs.open()?;

File operations:

fs.create_file("document.pdf")?;
fs.write_file("document.pdf", 0, &pdf_bytes)?;
fs.write_file("document.pdf", 1024, &more_bytes)?;   // write at offset
let data = fs.read_file("document.pdf", 0, 1_000_000)?;
fs.rename("document.pdf", "final.pdf")?;
fs.remove_file("final.pdf")?;

Directory operations:

fs.create_directory("photos")?;

let entries: Vec<DirListEntry> = fs.list_directory()?;
for e in &entries {
    println!("{} ({:?}) {} bytes", e.name, e.kind, e.size);
}

fs.remove_file("photos")?;  // 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:

cargo install cbindgen
cbindgen --config cbindgen.toml --crate doublecrypt-core --output include/doublecrypt_core.h

Build the static/dynamic library:

cargo build --release
# 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 writewrite_file reads 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/ODiskBlockStore and DeviceBlockStore use std::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:

  1. Attach an EBS volume to your EC2 instance (e.g. as /dev/xvdf).
  2. Do NOT format or mount it with a traditional filesystem — doublecrypt writes directly to the raw device.
  3. Initialize the device once (fills with random data so free blocks look like ciphertext):
use std::sync::Arc;
use doublecrypt_core::block_store::DeviceBlockStore;
use doublecrypt_core::crypto::ChaChaEngine;
use doublecrypt_core::fs::FilesystemCore;

let store = Arc::new(DeviceBlockStore::initialize("/dev/xvdf", 65536, 0).unwrap());
let crypto = Arc::new(ChaChaEngine::new(&master_key).unwrap());
let mut fs = FilesystemCore::new(store, crypto);
fs.init_filesystem().unwrap();
  1. Open on subsequent boots:
let store = Arc::new(DeviceBlockStore::open("/dev/xvdf", 65536, 0).unwrap());
let crypto = Arc::new(ChaChaEngine::new(&master_key).unwrap());
let mut fs = FilesystemCore::new(store, crypto);
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 root or add the user to the disk group (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: 0 to automatically use the full device.

Examples

A sample program that creates a disk image:

cargo run --example create_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.

./build-swift.sh    # builds static lib + C header
cd swift && swift build

License

MIT