π Security & Encryption
In-Depth Technical Documentation of Fula's Cryptographic Implementation
Security Overview
Fula implements a Trust-No-One (TNO) security model where all encryption happens client-side. Storage nodes and the gateway never see your plaintext data or encryption keys.
Client-Side Encryption
All encryption/decryption happens on your device before data leaves
User-Held Keys
Only you possess the private keys needed to decrypt your data
Verified Integrity
Cryptographic proofs detect any tampering, even from malicious nodes
Forward Secrecy
Ephemeral keys ensure past communications remain secure
Cryptographic Primitives (what the production client actually runs)
| Layer | Algorithm | Purpose & binding | Where in the code |
|---|---|---|---|
| Key Encapsulation (default) | RFC 9180 HPKE over X25519 (DHKEM X25519 + HkdfSha256 + ChaCha20-Poly1305) |
Wraps the per-file DEK for the owner's keypair and for share recipients. DEK-wrap AAD is fula:v2:dek-wrap; info parameter is fula-storage-v2. |
fula-crypto/src/hpke.rs |
| Content AEAD (single-object) | AES-256-GCM (default) or ChaCha20-Poly1305 | Seals the file body. Nonce: 12 random bytes. Tag: 16 bytes. AAD: fula:v4:content:{storage_key} β an attacker cannot swap ciphertexts across storage keys. |
fula-crypto/src/symmetric.rs |
| Content AEAD (chunked, streaming-v2) | AES-256-GCM per chunk | Default 256 KB chunks (threshold 768 KB). Per-chunk AAD: fula:v4:chunk:{storage_key}:{index}. Bao (BLAKE3 tree) root hash stored in metadata and re-verified at finalize_and_verify. |
fula-crypto/src/chunked.rs |
| Integrity β forest pin | Unkeyed BLAKE3 over plaintext | Written into the forest entry alongside min_version: 4. Defends against (a) a storage backend serving a differently-tagged-but-valid ciphertext, and (b) a backend serving a legacy (pre-AAD) blob at the same storage key (H-1 / H-2). |
fula-crypto/src/private_forest.rs |
| Private index | Sharded HAMT v7 (16-way trie, BLAKE3-keyed) | Encrypted per-bucket index mapping real paths to scrambled storage keys. Per-shard lazy load. Manifest PUT uses If-Match for atomic swap. HAMT logic is vendored from rs-wnfs; the encryption layer and shard routing are Fula's. | fula-crypto/src/sharded_hamt_forest.rs, wnfs_hamt/ |
| Post-quantum KEM (opt-in) | hybrid_kem: X25519 + ML-KEM-768 (libcrux-ml-kem, NIST FIPS 203) |
Available as a standalone primitive β same shape as the X25519 path, but combines the X25519 shared secret with the ML-KEM-768 shared secret via HKDF-SHA256. Not wired into the default EncryptedClient wrap path yet; applications can use it directly. |
fula-crypto/src/hybrid_kem.rs |
EncryptedClient wraps per-file DEKs with X25519 HPKE today. The hybrid X25519 + ML-KEM-768 primitive exists in fula-crypto::hybrid_kem and is exported, but it has no callers inside fula-client, fula-flutter, or fula-js as of v0.4.0. Wiring it into the default path is a planned follow-up. Applications that need PQ wrapping today can call hybrid_encapsulate / hybrid_decapsulate directly.
Trust Model
Security Assumptions
- Your device is secure - If compromised, keys can be extracted
- Cryptographic primitives are secure - X25519, AES-256-GCM, BLAKE3 are industry-standard
- Random number generator is secure - Using OS-provided CSPRNG (OsRng)
Bucket Isolation
Each user's buckets are automatically isolated using transparent per-user namespacing:
- Automatic isolation - Buckets are internally namespaced by hashed user ID
- No name conflicts - Multiple users can create buckets with the same name
- Complete separation - Users cannot see or access other users' buckets
- Transparent to clients - S3 API works normally, isolation is server-side
API Authentication
Fula supports both Bearer tokens and AWS Signature V4, enabling full S3 SDK compatibility.
Authentication Methods
π Bearer Token (Simple)
For HTTP clients, REST APIs, and custom integrations.
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
π¦ AWS Signature V4 (S3 Compatible)
For boto3, AWS CLI, aws-sdk-js, and all S3 tools.
Authorization: AWS4-HMAC-SHA256 Credential=JWT:eyJhbGci.../20231207/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-date, Signature=abc123...
AWS Sig V4 with JWT
Embed your JWT token in the AWS access key with a JWT: prefix. This enables full compatibility with standard S3 clients while maintaining JWT-based authentication.
# Python (boto3)
import boto3
s3 = boto3.client('s3',
endpoint_url='https://gateway.example.com',
aws_access_key_id=f'JWT:{jwt_token}', # JWT embedded here
aws_secret_access_key='not-used', # Not validated
region_name='us-east-1'
)
s3.put_object(Bucket='my-bucket', Key='file.txt', Body=b'Hello!')
# AWS CLI (~/.aws/credentials) [fula] aws_access_key_id = JWT:eyJhbGciOiJIUzI1NiIs... aws_secret_access_key = not-used # Usage: aws s3 cp file.txt s3://my-bucket/ --endpoint-url https://gateway.example.com --profile fula
// JavaScript (AWS SDK v3)
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
const s3 = new S3Client({
endpoint: "https://gateway.example.com",
region: "us-east-1",
forcePathStyle: true,
credentials: {
accessKeyId: `JWT:${jwtToken}`,
secretAccessKey: "not-used"
}
});
await s3.send(new PutObjectCommand({ Bucket: "my-bucket", Key: "file.txt", Body: "Hello!" }));
Security Properties
- JWT Validation: Full signature, expiry, and claims validation
- Replay Protection: x-amz-date must be within 15 minutes
- Scope Enforcement: JWT scopes control read/write/delete permissions
- S3 Compatibility: Works with boto3, AWS CLI, aws-sdk-js, and all S3 tools
Encryption Flow
Complete data flow from plaintext to encrypted storage and back.
π€ Upload (Encryption) Flow
A fresh 256-bit Data Encryption Key (DEK) is generated using CSPRNG for each object.
DEK = random_bytes(32) // 256 bits from OsRng
Plaintext is encrypted using AES-256-GCM with a random 96-bit nonce.
nonce = random_bytes(12) // 96 bits ciphertext = AES-256-GCM.encrypt(DEK, nonce, plaintext) // ciphertext includes 128-bit authentication tag
The DEK is encrypted using HPKE for the owner's public key.
ephemeral_secret = X25519.generate()
ephemeral_public = X25519.public_key(ephemeral_secret)
shared_secret = X25519.dh(ephemeral_secret, recipient_public)
wrap_key = BLAKE3.derive_key("fula-hpke-v1", shared_secret)
wrapped_DEK = AES-256-GCM.encrypt(wrap_key, nonce2, DEK)
Ciphertext is uploaded with encryption metadata (nonce, wrapped DEK, ephemeral public key).
metadata = {
"x-fula-encrypted": "true",
"x-fula-encryption": {
"version": 1,
"algorithm": "AES-256-GCM",
"nonce": base64(nonce),
"wrapped_key": {
"ephemeral_public": base64(ephemeral_public),
"ciphertext": base64(wrapped_DEK)
}
}
}
π₯ Download (Decryption) Flow
Retrieve ciphertext and encryption metadata from the gateway.
Use your secret key to derive shared secret and decrypt the DEK.
shared_secret = X25519.dh(your_secret_key, ephemeral_public)
wrap_key = BLAKE3.derive_key("fula-hpke-v1", shared_secret)
DEK = AES-256-GCM.decrypt(wrap_key, nonce2, wrapped_DEK)
Use the recovered DEK to decrypt the ciphertext.
plaintext = AES-256-GCM.decrypt(DEK, nonce, ciphertext) // Authentication tag verified automatically
Key Architecture (KEK/DEK)
Fula uses a two-tier key hierarchy for efficient and secure encryption.
Files, photos, documents, any content you store
Benefits of KEK/DEK Architecture
Rotate KEK without re-encrypting all data - just re-wrap existing DEKs
Share access by wrapping DEK with recipient's public key
Symmetric DEKs are fast; asymmetric KEK only used for small DEKs
Compromise of one DEK doesn't affect other files
HPKE Implementation
Hybrid Public Key Encryption following RFC 9180 for secure key encapsulation.
HPKE Configuration
| KEM (Key Encapsulation) | DHKEM(X25519, HKDF-SHA256) |
| KDF (Key Derivation) | BLAKE3 with context "fula-hpke-v1" |
| AEAD (Encryption) | AES-256-GCM (default) or ChaCha20-Poly1305 |
HPKE Encryption Process
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β SENDER (Encryptor) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β βββββββββββββββ ββββββββββββββββββββββββββββββββββββββββ β
β β Ephemeral β β Recipient's Public Key β β
β β Secret Key β β (known to sender) β β
β β (random) β ββββββββββββββββββββββββββββββββββββββββ β
β ββββββββ¬βββββββ β β
β β β β
β βΌ βΌ β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β X25519 Diffie-Hellman Key Exchange β β
β β shared_secret = DH(ephemeral_secret, recipient_public) β β
β ββββββββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββ β
β β β
β βΌ β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β BLAKE3 Key Derivation Function β β
β β encryption_key = derive_key("fula-hpke-v1", β β
β β shared_secret) β β
β ββββββββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββ β
β β β
β βΌ β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β AES-256-GCM Encryption β β
β β ciphertext = encrypt(encryption_key, nonce, DEK) β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β
β OUTPUT: { ephemeral_public_key, nonce, ciphertext } β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β RECIPIENT (Decryptor) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β βββββββββββββββ ββββββββββββββββββββββββββββββββββββββββ β
β β Recipient's β β Ephemeral Public Key β β
β β Secret Key β β (from ciphertext) β β
β β (private) β ββββββββββββββββββββββββββββββββββββββββ β
β ββββββββ¬βββββββ β β
β β β β
β βΌ βΌ β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β X25519 Diffie-Hellman Key Exchange β β
β β shared_secret = DH(recipient_secret, ephemeral_public) β β
β ββββββββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββ β
β β β
β βΌ β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Same shared_secret β Same encryption_key β β
β ββββββββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββ β
β β β
β βΌ β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β AES-256-GCM Decryption β β
β β DEK = decrypt(encryption_key, nonce, β β
β β ciphertext) β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Security Properties
Each encryption uses a fresh ephemeral key pair. Compromise of long-term keys doesn't decrypt past messages.
Only the holder of the recipient's secret key can derive the shared secret and decrypt.
AES-GCM provides authenticated encryption - any tampering is detected.
Symmetric Encryption (AEAD)
Authenticated Encryption with Associated Data for file content.
Ciphertext Structure
output_length = plaintext_length + 16 bytes (tag)
Nonce Generation
β οΈ Critical: Nonces must NEVER be reused with the same key.
Fula's Strategy: Random Nonces
- 96-bit nonces generated from CSPRNG (OsRng)
- Collision probability: ~2-48 after 248 encryptions
- Each file gets a unique DEK, so nonce space is per-DEK
- Safe for virtually unlimited encryptions per file
Data Integrity Verification
Cryptographic guarantees that your data hasn't been tampered with.
Multi-Layer Integrity
AEAD Authentication Tag
Every encrypted block includes a 128-bit authentication tag that verifies both ciphertext integrity and the encryption key used.
Content Addressing (CID)
Every block stored in IPFS has a CID derived from its BLAKE3 hash. Retrieving by CID guarantees content matches.
Bao Streaming Verification
For large files, Bao provides Merkle tree-based verification allowing you to verify chunks before downloading the entire file.
BLAKE3 Hashing
Faster than MD5, SHA-1, SHA-256, SHA-3 on modern CPUs
256-bit security level, resistant to length extension
Scales with CPU cores for large data
Built-in key derivation with context separation
Bao Verified Streaming
Root Hash (32 bytes)
β
βββββββββββββββββ΄ββββββββββββββββ
β β
Hash(L||R) Hash(L||R)
β β
ββββββββ΄βββββββ ββββββββ΄βββββββ
β β β β
Hash(L||R) Hash(L||R) Hash(L||R) Hash(L||R)
β β β β
ββββ΄βββ ββββ΄βββ ββββ΄βββ ββββ΄βββ
β β β β β β β β
Chunk Chunk Chunk Chunk Chunk Chunk Chunk Chunk
1 2 3 4 5 6 7 8
β’ Verify any chunk with O(log n) proof from root
β’ Detect corruption before downloading entire file
β’ Stream large files with incremental verification
Walkable HAMT (v8) — Encrypted Link Integrity
How Fula preserves native-IPLD-grade integrity when CIDs live inside encrypted parent plaintext, and how that compares to native IPLD and stock WNFS.
Plain-English Summary
In native IPLD, every block has a CID that is the hash of its bytes. A reader fetches by CID and re-hashes; if the bytes don't match, tampering is rejected. This single property gives IPLD self-verifying integrity.
In stock WNFS, the HAMT internal nodes are stored as plaintext IPLD blocks with plaintext Link(cid) references. Only the leaves (the encrypted PrivateNode blobs) are encrypted. So a non-keyholder with the HAMT root CID can enumerate the tree shape, count entries, and list every leaf-level encrypted-blob CID — even though they cannot decrypt content.
In Fula v7 (today), HAMT internal nodes are AEAD-encrypted; parent → child references are 22-byte StorageKeys routing to master S3 paths, not CIDs. Stronger privacy than WNFS, but not walkable via public IPFS gateways without master.
In Fula v8 (walkable), parent pointers gain an Option<Cid> inside the encrypted parent's plaintext, alongside the existing StorageKey. Keyholders can walk the tree via public IPFS gateways without master. Non-keyholders see only the manifest CID; the link graph remains hidden because the CIDs only exist behind AEAD that requires forest_dek to open.
Three Layers of Integrity
Walkable-v8 stacks three independent integrity binders. Layer 1 reproduces native IPLD's keyless property exactly; Layers 2 and 3 add stronger guarantees the encrypted case requires.
CID Re-Hash on Every Gateway Fetch
Keyless — identical to native IPLD. Every gateway fetch re-hashes the bytes and rejects on mismatch. Anyone with the CID can verify independently.
crates/fula-client/src/gateway_fetch.rs:116-142 verify_cid_against_bytes
AEAD Tag with Bucket+Shard AAD
Key-required — beyond native IPLD. ChaCha20-Poly1305 with AAD fula:hamt-node:v7: || bucket || shard_idx. Forging a tag without shard_dek is computationally infeasible.
crates/fula-crypto/src/wnfs_hamt/v7_store.rs:178-180 + private_forest.rs:1442-1449
Storage-Key Cross-Check
Key-required — beyond native IPLD. After decryption, re-derive BLAKE3(bucket_salt ‖ plaintext)[..22] and compare to the storage_key the parent committed to. Two independent integrity binders for the same node: CID over ciphertext + storage_key over plaintext.
crates/fula-crypto/src/wnfs_hamt/v7_store.rs:182-189
Property Comparison — Native IPLD vs WNFS vs Fula v7 vs Fula v8
| Property | Native IPLD | Stock WNFS | Fula v7 (today) | Fula v8 (walkable) |
|---|---|---|---|---|
hash(bytes) == cid self-verifying | ✅ | ✅ (HAMT internal) | n/a (uses storage_key) | ✅ Layer 1 |
| Anyone keyless can verify integrity | ✅ | ✅ | ❌ (master path) | ✅ Layer 1 |
| Keyless observer can enumerate tree shape from root CID | n/a | ✅ leaks | ❌ hidden | ❌ hidden |
| Keyless observer can see leaf-encrypted-blob CID list | n/a | ✅ leaks | ❌ hidden | ❌ hidden |
| Keyless observer can diff snapshots / infer write locations | n/a | ✅ leaks | ❌ hidden | ❌ hidden |
| Keyless observer can find a specific file by path | n/a | ❌ | ❌ | ❌ |
| Keyless observer can decrypt content | n/a | ❌ | ❌ | ❌ |
| Plaintext tampering blocked | ❌ (no encryption) | per-leaf only | ✅ AEAD | ✅ AEAD (Layer 2) |
| Cross-bucket / cross-shard replay blocked | n/a | scope-dependent | ✅ AAD-bound | ✅ AAD-bound (Layer 2) |
| Plaintext content-address committed by parent | ❌ | ❌ | ✅ storage_key | ✅ storage_key (Layer 3) |
| Walkable from root via public IPFS gateways alone | ✅ | ✅ | ❌ (master required) | ✅ (with forest_dek) |
| Walkable from root without keys (= no privacy gate on tree shape) | ✅ | ✅ | n/a | ❌ (privacy preserved) |
Two takeaways: (1) walkable-v8 reproduces native IPLD's keyless integrity property exactly via Layer 1 on every gateway fetch. (2) Walkable-v8 is strictly more private than stock WNFS at the tree-structure level — keyless observers cannot enumerate HAMT shape, count entries, see leaf CIDs, or diff snapshots, because all of that lives behind AEAD.
Tampering Walk-Through
| Attack class | Defense | Outcome |
|---|---|---|
| Gateway returns wrong bytes for the requested CID | Layer 1 re-hashes bytes; mismatch → reject | REJECT (same as native IPLD) |
| Substitute different bytes + a forged matching CID | Forging a CID is a BLAKE3 / SHA-256 preimage attack | Computationally infeasible |
| Compromised gateway replays a STALE parent ciphertext | Grandparent's pointer commits to specific cid; stale parent has different CID → Layer 1 mismatch | REJECT |
| Cross-bucket replay (bucket A's ciphertext served as bucket B's) | Layer 2 AAD differs → ChaCha20-Poly1305 tag mismatch | REJECT |
| Tamper parent plaintext to point at attacker-controlled child CID | Parent's plaintext is itself AEAD-protected; modifying requires forging a tag without shard_dek | REJECT |
| Compromised master returns WRONG CID at write time | Read fetches by wrong CID; even if attacker placed bytes there, Layer 2 fails (no shard_dek) and Layer 3 storage-key cross-check fails | READ FAILS (DoS); no silent corruption |
Rollback to an older forest_manifest_cid | Phase 3.3 chain anchor (FulaUsersIndexAnchor) enforces strict-monotonic require(newSequence > sequence) | REJECT at chain layer |
What This Means for End Users
- Your data integrity is at least as strong as native IPLD for every block fetched via a gateway, even when master is offline (Layer 1).
- Tampering is detected, not hidden. A malicious gateway, transport corruption, or compromised master triggers a clean error rather than silent data corruption. Even a malicious master cannot make you accept invalid bytes — the worst they can do is cause your read to fail.
- Privacy of the tree structure is preserved. Unlike stock WNFS, an attacker who learns your bucket's
forest_manifest_cidcannot enumerate your file count, observe your folder shape, or watch which subtrees you write to. Withoutforest_dekthey cannot even decrypt the manifest itself; they only see one opaque ciphertext. - Walkability requires the key, by design. This is a deliberate privacy choice: walkability for keyholders, opacity for everyone else. It mirrors the intuition that your filesystem is your own private structure, not just your file contents are your own.
- Chunked files (photos, videos, PDFs > 768 KB) are walkable too as of v0.6.0 / W.9.4–A2 (task #32). Per-chunk CID hints in
ChunkedFileMetadata.chunk_cidslet an offline reader fetch each chunk via gateway race when master is down. Without these hints, the W.9.4 HAMT walker only reaches the file index, not the underlying chunks — the gap that #32 closed.
Operator-facing Caveats (must-read before flipping the writer flag)
- Single-directory cliff at ~60-100k files (tracked as #72). A
ForestDirectoryEntrywith 100k+ filenames in itsfiles: Vec<String>exceeds the 1 MiB IPFS gateway limit. Verified empirically by the W.9.7 stress test (100k entries in a single dir → 1.66 MiB blob). Typical FxFiles users distribute photos / PDFs across folders, so this affects only flat-folder enterprise edge cases. Distributed across many dirs, the same 100k test produces a max blob of 17.1 KiB — well under both the 1 MiB hard ceiling and the 64 KiB architectural early-warning threshold. - Public
put_object_chunkeddebug API doesn't stampstorage_cid(#51 still pending). FxFiles users on the production encrypted chunked path (put_object_encrypted_with_type→put_object_chunked_internal) DO get full per-chunk walkability via #32 — the gap is limited to the public unencrypted-debug API, which is rarely used in production. chunk_cidsprivacy posture: plaintext, by design. Per-chunk CID hints sit alongsidechunk_nonces,root_hash,num_chunks,total_size,chunk_sizein the encrypted index object'schunkedJSON field. Only thewrapped_keyandprivate_metadatasiblings are AEAD-encrypted; thechunkedblock is plaintext-readable to anyone who can fetch the index object. This is not a privacy regression: every existing field in the same plaintext block was already plaintext-readable at the same level pre-v0.6.0. An attacker with the index body could already enumerate child storage paths viachunk_key(storage_key, i)and fetch the same encrypted chunk bytes via gateway. The hints simply make legitimate offline reads cheaper for content already addressable. Future security audits should treatchunk_cidsas joining an existing public set, not introducing a new leak.
Key Management
How Fula handles key generation, storage, rotation, and recovery.
Key Generation
| Random Source | OsRng (Operating System CSPRNG) |
| KEK Generation | 32 random bytes β X25519 secret key |
| DEK Generation | 32 random bytes β AES-256 key |
| Nonce Generation | 12 random bytes per encryption |
Key Rotation
Create a fresh X25519 key pair
Decrypt each DEK with old KEK, re-encrypt with new KEK
Store new wrapped DEKs, increment version counter
Zeroize old secret key from memory
Key Backup & Recovery
Fula uses true end-to-end encryption. There are no backdoors, no "forgot password" recovery, no master keys.
Memory Security
All key types implement Zeroize and ZeroizeOnDrop:
- Keys are automatically zeroed when dropped from memory
- Prevents keys from lingering in memory after use
- Protects against memory dump attacks
// From fula-crypto/src/keys.rs
#[derive(Clone, Zeroize, ZeroizeOnDrop)]
pub struct SecretKey {
bytes: [u8; 32],
}
// When SecretKey is dropped, bytes are overwritten with zeros
Async/Offline Inbox Sharing (WNFS-Inspired)
Store-and-forward sharing where recipients can pick up shares later without the sharer being online.
How It Works
Inspired by WNFS's exchange directories, Fula supports asynchronous sharing where:
- Sharer creates an encrypted
ShareEnvelopeand stores it in the recipient's inbox - Recipient can later list, decrypt, and accept shares when convenient
- No coordination required - both parties can be offline at different times
Sharer Storage Recipient β β β β 1. Create ShareEnvelope β β β + HPKE encrypt for recipientβ β β β β β 2. Store in inbox βββββββββββββββΊ[Encrypted entry stored] β β β β β β [Later, recipient comes online] β β β β ββββ3. List inbox entries ββββββββ β β β β ββββ4. Decrypt & accept ββββββββββ β β β β β 5. Use ShareToken to access β β β shared content β
use fula_crypto::{ShareEnvelopeBuilder, ShareInbox, KekKeyPair, DekKey};
// === SHARER FLOW ===
let sharer = KekKeyPair::generate();
let recipient = KekKeyPair::generate();
let dek = DekKey::generate();
// Create share envelope with metadata
let (envelope, entry) = ShareEnvelopeBuilder::new(
&sharer,
recipient.public_key(),
&dek
)
.path_scope("/photos/vacation/")
.expires_in(7 * 24 * 3600) // 1 week
.read_only()
.label("Vacation Photos 2024")
.message("Check out these pics from Hawaii!")
.sharer_name("Alice")
.build()?;
// Store entry in recipient's inbox (via PrivateForest)
let inbox_path = ShareInbox::entry_storage_path(recipient.public_key(), &entry.id);
// put_object_flat(bucket, &inbox_path, serialize(&entry), ...);
// === RECIPIENT FLOW ===
let mut inbox = ShareInbox::new();
// Load entries from storage
inbox.add_entry(entry);
// List pending shares
let pending = inbox.list_pending(&recipient);
println!("You have {} new shares", pending.len());
// Accept a share
let accepted = inbox.accept_entry(&entry_id, &recipient)?;
println!("From: {:?}", accepted.sharer_name); // "Alice"
println!("Message: {:?}", accepted.message); // "Check out these pics..."
// Now use the ShareToken to access content
let dek = recipient.accept_share(&accepted.token)?;
Key Features
Shares are stored encrypted until recipient comes online
Only recipient can decrypt inbox entries using HPKE
Include labels, messages, and sharer info with each share
Configurable TTL for inbox entries (default: 30 days)
Subtree Keys (Peergos Cryptree-Inspired)
Allocate separate DEKs for major folders for better revocation and least privilege.
What are Subtree Keys?
Inspired by Peergos Cryptree, Fula supports a shallow key hierarchy where top-level folders can have their own DEKs:
Master DEK (bucket-level)
β
βββ /photos/ βββ Subtree DEK A βββ [beach.jpg, sunset.jpg, ...]
β
βββ /documents/ βββ Subtree DEK B βββ [report.pdf, notes.txt, ...]
β
βββ /apps/myapp/ βββ Subtree DEK C βββ [config.json, data.bin, ...]
Sharing /photos/ only exposes Subtree DEK A.
Revoking that share only requires re-keying /photos/, not the whole bucket.
Key Benefits
Re-key just one subtree instead of rotating the entire bucket
A subtree share cannot be escalated to access unrelated data
Still uses a single PrivateForest - no structural changes needed
use fula_crypto::{SubtreeKeyManager, SubtreeShareBuilder, DekKey, KekKeyPair};
// Create manager with master DEK
let master_dek = DekKey::generate();
let mut manager = SubtreeKeyManager::with_master_dek(master_dek);
// Create subtrees with their own DEKs
let (photos_dek, encrypted_photos) = manager.create_subtree("/photos/")?;
let (docs_dek, encrypted_docs) = manager.create_subtree("/documents/")?;
// Resolve DEK for a file path
let dek = manager.resolve_dek("/photos/vacation/beach.jpg"); // Returns photos_dek
// Share a subtree with someone
let owner = KekKeyPair::generate();
let recipient = KekKeyPair::generate();
let share = SubtreeShareBuilder::new(
&owner,
recipient.public_key(),
&photos_dek,
"/photos/",
1, // version
)
.expires_in(86400) // 24 hours
.read_only()
.build()?;
// Rotate subtree key after revocation
let rotation = manager.rotate_subtree("/photos/")?;
// rotation.new_dek is now used for /photos/*
// Old shares become invalid
When to Use Subtree Keys
| Scenario | Recommendation |
|---|---|
| Sharing app-specific data folders | Use subtree DEK per app namespace |
| Sharing project folders with different teams | Use subtree DEK per project |
| Need to revoke access to specific shared folder | Rotate just that subtree's DEK |
| Single user, no sharing | Master DEK is sufficient |
Full Filesystem Key Rotation
Rotate your KEK without re-encrypting file content.
Why Rotate Keys?
- Suspected Compromise: If you think your key might be exposed
- Regular Security Hygiene: Periodic rotation limits exposure window
- Personnel Changes: When someone with key access leaves
- Compliance Requirements: Some regulations mandate key rotation
Rotation Process
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β KEY ROTATION FLOW β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€ β β β BEFORE ROTATION: β β βββββββββββββββ wraps ββββββββββββββ β β β KEK v1 β ββββββββββββββββ DEK A ββββ File A β β β (current) β ββββββββββββββ β β β β wraps ββββββββββββββ β β β β ββββββββββββββββ DEK B ββββ File B β β βββββββββββββββ ββββββββββββββ β β β β ROTATION STEP 1: Generate new KEK β β βββββββββββββββ βββββββββββββββ β β β KEK v1 β β KEK v2 β β β β (previous) β β (current) β β β βββββββββββββββ βββββββββββββββ β β β β ROTATION STEP 2: Re-wrap each DEK β β β β For each file: β β 1. Decrypt DEK with KEK v1 β β 2. Re-encrypt DEK with KEK v2 β β 3. Update wrapped DEK in metadata β β β β AFTER ROTATION: β β βββββββββββββββ wraps ββββββββββββββ β β β KEK v2 β ββββββββββββββββ DEK A ββββ File A β β β (current) β ββββββββββββββ (unchanged) β β β β wraps ββββββββββββββ β β β β ββββββββββββββββ DEK B ββββ File B β β βββββββββββββββ ββββββββββββββ (unchanged) β β β β Note: File content is NEVER re-encrypted! β β Only the DEK wrappers are updated. β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Code Example
use fula_crypto::{KekKeyPair, DekKey, FileSystemRotation};
// Initialize with current keypair
let keypair = KekKeyPair::generate();
let mut fs = FileSystemRotation::new(keypair)
.with_batch_size(100); // Process 100 files per batch
// Register existing files
for file in files {
fs.wrap_new_file(&file.path, &file.dek)?;
}
// Initiate rotation (generates new KEK)
let new_public_key = fs.rotate();
// Rotate in batches (for large filesystems)
while !fs.is_rotation_complete() {
let result = fs.rotate_batch();
println!("Rotated {} files", result.rotated_count);
}
// All files now use KEK v2
// Old KEK is automatically cleared
Key Benefits
Only re-wraps DEKs (32 bytes each), never re-encrypts file content
Process files in batches to avoid overwhelming large systems
Old wrapped keys still work during transition period
Track rotation progress and verify completion
Metadata Privacy
Protect not just your data, but also your file names, sizes, and timestamps.
Why Metadata Privacy Matters
Even with encrypted content, metadata can reveal sensitive information:
- File names like "tax_returns_2024.pdf" or "medical_records.docx" reveal intent
- File sizes can identify document types or media content
- Timestamps show when you accessed or modified files
- Content types indicate what kind of data you're storing
How It Works
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β METADATA PRIVACY FLOW β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€ β β β CLIENT SIDE (Original Metadata): β β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β β β Key: /finances/investment_portfolio_2024.xlsx β β β β Size: 156,789 bytes β β β β Type: application/vnd.openxmlformats... β β β β Modified: 2024-12-07T15:30:00Z β β β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β β β β β βΌ β β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β β β METADATA ENCRYPTION β β β β 1. Create PrivateMetadata struct with original values β β β β 2. Encrypt with file's DEK using AES-256-GCM β β β β 3. Generate obfuscated storage key via BLAKE3 β β β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β β β β β βΌ β β SERVER SIDE (What storage nodes see): β β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β β β Key: e/a7c3f9b2e8d14a6f (obfuscated hash) β β β β Size: 156,821 bytes (ciphertext size) β β β β Type: application/octet-stream (generic) β β β β Metadata: [encrypted blob] β β β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β β β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Key Obfuscation Modes
π FlatNamespace (DEFAULT)
Complete structure hiding. Server sees only random CID-like hashes. Inspired by WNFS and Peergos.
/photos/beach.jpg β QmX7a8f3e2d1c9b4a5
No prefixes, no structure hints. Directory tree stored in encrypted PrivateForest index.
DeterministicHash
Same file path β Same storage key. Allows retrieval without local index.
/photos/beach.jpg β e/a7c3f9b2e8d14a6f
RandomUuid
Each upload gets a random key. Maximum privacy but requires local mapping.
/photos/beach.jpg β e/random-uuid-here
PreserveStructure
Keep directory paths, hash only filenames. Allows folder browsing.
/photos/beach.jpg β /photos/e_a7c3f9b2
Code Example
use fula_client::{EncryptedClient, EncryptionConfig, KeyObfuscation};
// DEFAULT: FlatNamespace for complete structure hiding
let encryption = EncryptionConfig::new(); // FlatNamespace by default!
let client = EncryptedClient::new(config, encryption)?;
// Upload - server sees: QmX7a8f3e2d1c9b4a5e6f7d8...
client.put_object_flat(bucket, "/secret/file.txt", data, None).await?;
// List files from encrypted PrivateForest index
let files = client.list_files_from_forest(bucket).await?;
// Server sees NOTHING about your folder structure!
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// Alternative modes (for specific use cases):
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// DeterministicHash - same path = same key, no local index needed
let encryption = EncryptionConfig::new()
.with_obfuscation_mode(KeyObfuscation::DeterministicHash);
// PreserveStructure - keeps folder paths visible
let encryption = EncryptionConfig::new()
.with_obfuscation_mode(KeyObfuscation::PreserveStructure);
// Disable metadata privacy entirely (not recommended)
let encryption = EncryptionConfig::new_without_privacy();
// Get full metadata including original filename
let info = client.get_object_with_private_metadata(bucket, storage_key).await?;
println!("Original name: {}", info.original_key);
println!("Original size: {}", info.original_size);
Security Guarantees
What the current implementation provides and its limitations.
β Provided
- Confidentiality: Data encrypted with AES-256-GCM; only key holder can decrypt
- Integrity: AEAD tags detect any tampering with ciphertext
- Authenticity: Only holder of secret key can produce valid decryption
- Forward Secrecy: Ephemeral keys in HPKE protect past data
- Key Isolation: Each file has unique DEK; compromise affects only that file
- Semantic Security: Same plaintext encrypts to different ciphertext each time
- Ciphertext Indistinguishability: Encrypted data appears random
- Metadata Privacy: File names, sizes, timestamps encrypted (optional, enabled by default)
β Not Provided
- Sender Authentication: Recipients can't verify who encrypted the data
- Deniability: Encryption proves you had the key
- Traffic Analysis Resistance: Access patterns may be observable
- Post-Quantum Security: X25519 vulnerable to quantum computers
Threat Model
What attacks Fula protects against and what it doesn't.
π‘οΈ Protected Against
Nodes only see encrypted data. They cannot read your files or forge valid data.
All data in transit is encrypted. Even if intercepted, it's unreadable.
AEAD tags and content addressing detect any modifications.
Gateway never sees plaintext or keys. Compromise only affects availability, not confidentiality.
Unique nonces prevent replaying old ciphertexts.
β οΈ Not Protected Against
If your device is compromised, attacker can extract keys.
Coercion/legal compulsion to reveal keys.
Timing, power analysis on the local device.
X25519 can be broken by sufficiently powerful quantum computers.
Multi-Device Key Management
Securely manage encryption keys across multiple devices.
Key Management Patterns
Fula supports multiple patterns for managing keys across devices:
Pattern A: Shared Identity
All devices share the same master key. Simple but all devices are equally trusted.
// All devices use same backed-up secret let master_secret = SecretKey::from_bytes(backed_up_secret)?; let keypair = KekKeyPair::from_secret(master_secret); let key_manager = KeyManager::new(&keypair); // Files encrypted on one device readable on all
Pattern B: Per-Device Keys
Each device has its own KEK. Access granted via ShareToken.
// Device A (primary)
let device_a = KekKeyPair::generate();
// Device B (secondary)
let device_b = KekKeyPair::generate();
// Grant Device B access to specific folders
let share = ShareBuilder::new(
&device_a,
device_b.public_key(),
&folder_dek
)
.path_scope("/shared/")
.build()?;
Handling Device Loss
When a device is lost or stolen:
- Immediate: Rotate KEK on remaining devices
- Re-wrap: Use
FileSystemRotationto re-wrap all DEKs - Revoke: Remove device from any shared access
- Audit: Review what data the device had access to
// On remaining device - rotate immediately
let mut rotation = FileSystemRotation::new(current_keypair);
let new_public_key = rotation.rotate();
// Re-wrap all DEKs with new KEK
while !rotation.is_rotation_complete() {
rotation.rotate_batch();
}
// Lost device's wrapped keys are now useless
Key Backup Strategy
Recovery Scenarios
| Scenario | Recovery Method | Data Loss |
|---|---|---|
| Lost device (backup exists) | Restore from backup | None |
| Lost device (no backup) | Cannot recover | All data |
| Compromised device (detected early) | Rotate + restore | None |
| Compromised device (delayed detection) | Rotate + audit | Potentially exposed |
π Full Documentation: See THREAT_MODEL.md for comprehensive multi-device patterns and threat analysis.
Security Best Practices
Recommendations for securely using Fula encryption.
Key Management
- Generate keys on a secure, trusted device
- Back up your secret key in multiple secure locations
- Never share or transmit your secret key
- Rotate keys periodically or after potential compromise
Client Security
- Keep your device and OS updated
- Use full-disk encryption
- Use a hardware security module if available
- Clear keys from memory after use
Data Handling
- Always enable encryption for sensitive data
- Verify file integrity after download
- Don't store sensitive metadata in unencrypted fields
- Use unique DEKs per file (default behavior)
Operational Security
- Monitor for unauthorized access attempts
- Have an incident response plan for key compromise
- Test key recovery procedures periodically
- Audit encryption settings regularly