Date: 2026-04-16
Scope: fula-client/src/encryption.rs, fula-crypto/src/*
Method: Line-by-line code path audit (not comment/doc review)
Audited paths:
- encrypt → upload → list folder → download → decrypt
- share send → share receive
- collaborate share → collaborate accept
| Step | Location | What happens |
|---|---|---|
| Generate DEK | encryption.rs:1629 |
key_manager.generate_dek() — random 256-bit AES key |
| Derive storage key | encryption.rs:1632-1641 |
forest.generate_key(key, &dek) → generate_flat_key(path, dek, salt) (private_forest.rs:80-89) — BLAKE3 KDF produces Qm<hex> |
| Encrypt (small file) | encryption.rs:1673-1677 |
AES-256-GCM with random nonce, AAD = fula:v4:content:<storage_key> |
| Encrypt (chunked) | encryption.rs:1660-1670 → put_object_chunked_internal:1756-1892 |
Split at 256KB, each chunk: random nonce + AES-GCM + AAD fula:v4:chunk:<storage_key>:<index> (chunked.rs:244-268) |
| Wrap DEK | encryption.rs:1644-1646 |
HPKE encrypt DEK for owner's public key (X25519+ChaCha20Poly1305) |
| Upload | encryption.rs:1696-1712 |
PUT to S3 with x-fula-encrypted: true, x-fula-encryption: {JSON metadata} |
| Update forest | encryption.rs:1718-1750 |
forest.upsert_file(entry) — insert/replace in in-memory forest, mark dirty |
| Flush forest | encryption.rs:2367-2452 |
Serialize forest → AES-GCM encrypt (private_forest.rs:600-612) → PUT to S3 |
| List folder | encryption.rs list_objects_decrypted |
load_forest() (encryption.rs:1121-1204) → filter by prefix → return entries with decrypted metadata |
| Download | encryption.rs:466-541 get_object_decrypted_by_storage_key |
Fetch from S3 → parse x-fula-encryption JSON → HPKE unwrap DEK (hpke.rs Decryptor) |
| Decrypt (single) | encryption.rs:506-518 |
AES-GCM decrypt with version-aware AAD (v4: with AAD, <v4: without) |
| Decrypt (chunked) | encryption.rs:3346-3420 get_object_chunked |
Sequential loop: fetch each _chunk_N → AES-GCM decrypt per chunk → concatenate |
| Verify integrity | chunked.rs:575-660 VerifiedStreamingDecoder |
Bao hash verification per 1KiB block (streaming.rs:191-248) + root hash check |
| Step | Location | What happens |
|---|---|---|
| Send side | ||
| Create ShareBuilder | sharing.rs:302-431 |
Owner provides: &owner_keypair, recipient.public_key(), &dek, path_scope, expiry, permissions |
| Wrap DEK for recipient | sharing.rs:414-415 |
Encryptor::new(recipient_pk).encrypt_dek(&dek) — HPKE encrypts DEK for recipient's X25519 public key |
| Build ShareToken | sharing.rs:417-431 |
Token = {id, wrapped_key, path_scope, expires_at, permissions, mode, nonce, chunked_metadata, encryption_version} |
| Attach encryption info | sharing.rs:354-393 |
.with_nonce() embeds the content nonce; .with_chunked_metadata() embeds chunk layout — allows recipient to decrypt without S3 metadata headers |
| Receive side | ||
| Accept share | sharing.rs:574-593 ShareRecipient.accept_share() |
Check expiry → Decryptor::from_secret_key(&sk).decrypt_dek(&token.wrapped_key) → returns AcceptedShare{dek, path_scope, permissions, nonce, chunked_metadata} |
| Validate access | encryption.rs:2800-2821 get_object_with_share() |
Check is_valid() (expiry), is_path_allowed(original_key) (scope), can_read (permission) |
| Decrypt with token nonce | encryption.rs:2830-2853 |
If token has nonce: fetch raw data → AES-GCM decrypt with share's DEK, version-aware AAD |
| Decrypt chunked with token | encryption.rs:2825-2828 |
If token has chunked_metadata: call get_object_chunked_with_share_token() — uses chunk layout from token |
| Fallback: S3 metadata | encryption.rs:2855-2916 |
Old tokens without embedded nonce/chunks: fetch S3 headers → parse x-fula-encryption → decrypt using share's DEK |
Security properties verified:
- DEK is wrapped per-recipient (HPKE,
sharing.rs:414-415); no raw key in token - Path scope enforced before any data fetch (
encryption.rs:2808-2814) - Expiry checked at both accept (
sharing.rs:576-578) and use (encryption.rs:2801-2804) - Snapshot mode can bind to specific content hash/size/timestamp (
sharing.rs:54-95)
| Step | Location | What happens |
|---|---|---|
| Share side | ||
| Build share envelope | inbox.rs:445-614 ShareEnvelopeBuilder |
Fluent API: set owner, recipient, DEK, path, permissions, expiry |
| Create ShareToken | inbox.rs → sharing.rs:402-431 |
Same as Path 2: HPKE-wrap DEK for recipient |
| Create ShareEnvelope | inbox.rs:550-570 |
Envelope = {token, sender_public_key, bucket, message, created_at} |
| Create InboxEntry | inbox.rs:573-602 |
InboxEntry::create(envelope, recipient_pk): serialize envelope → HPKE encrypt for recipient → set recipient_id = BLAKE3(recipient_pk)[..16] |
| Store inbox entry | Client stores at inbox/<recipient_hash>/<entry_id> |
Encrypted blob; server sees only the recipient hash prefix, not content |
| Accept side | ||
| Load inbox | Client fetches entries at inbox/<own_hash>/ |
List + download encrypted inbox entries |
| Verify recipient | inbox.rs:362-363 accept_entry() |
entry.is_for_recipient(recipient_keypair.public_key()) — BLAKE3 hash match (128 bits) |
| Decrypt envelope | inbox.rs:367 |
entry.decrypt(recipient_keypair.secret_key()) — HPKE decrypt |
| Extract token | Envelope contains ShareToken |
Same structure as Path 2 |
| Accept token | sharing.rs:574-593 ShareRecipient.accept_share() |
Same as Path 2: check expiry → HPKE decrypt DEK → return AcceptedShare |
| Use share | encryption.rs:2793-2917 |
Same as Path 2's receive side |
Security properties verified:
- Recipient ownership verified via
is_for_recipient()before ANY mutation (inbox.rs:362-363,378-382,395-397,412-414) - Envelope encrypted end-to-end; server sees only
recipient_idhash - Cross-user isolation: tests in
inbox.rs:750-900verify entries for user A are inaccessible to user B - Dismiss/remove also verify recipient (
inbox.rs:395-414)
Location: private_forest.rs:687-691 (compute_initial_shard_count), private_forest.rs:59 (MAX_SHARDS=256), private_forest.rs:60 (RESHARD_THRESHOLD=10000)
The sharded forest caps at MAX_SHARDS(256) × RESHARD_THRESHOLD(10000) = 2,560,000 files. After this, should_reshard() returns false and shards grow without bound.
- By design? Partially. The constants are tunable, but MAX_SHARDS=256 is hard-coded and tied to the single-byte shard routing (
hash.as_bytes()[0] as usize % num_shardsat line 679). Raising MAX_SHARDS above 256 requires changing to a multi-byte shard key. - Backward compatible? Yes — existing data unaffected. Raising limits is a forward-only change; old forests stay valid.
Location: private_forest.rs:437-460 (monolithic), private_forest.rs:1204-1240 (sharded)
Reverse lookup (storage key → original path) iterates every file entry. In the sharded variant, it loads every shard from S3 to scan. Used during get_object_decrypted when called with a storage key.
- By design? Yes — the forest is indexed by original path, not by storage key. A reverse index would add complexity.
- Backward compatible? N/A (behavioral, no data change needed).
Location: private_forest.rs:1181-1202 (list_subdirs → reconstruct_directories)
reconstruct_directories() iterates ALL file entries across ALL shards to rebuild the directory tree. For 100K+ files, this is expensive — every list_objects_decrypted call for a subfolder triggers this.
- By design? Yes — the shard key is a hash of the file path, not the directory. There is no directory-level index.
- Backward compatible? N/A.
Location: encryption.rs:2410-2430 (inside flush_forest)
Auto-migration from monolithic (v1/v2) to sharded (v3) triggers at 5,000 files. During migration, the entire forest is re-encrypted and re-uploaded as 16+ shard objects in a single flush. This is a heavy operation that could timeout on slow connections.
- By design? Yes — one-time migration cost. The threshold is reasonable.
- Backward compatible? Yes — old monolithic forests remain readable; migration is transparent.
Location: private_forest.rs:600-612 (EncryptedForest::encrypt), private_forest.rs:822-835 (EncryptedShardManifest::encrypt), private_forest.rs:875-888 (EncryptedForestShard::encrypt)
All three use Aead::encrypt with no Additional Authenticated Data. The encryption key is derived from dek + bucket, so cross-user/cross-bucket swaps are prevented. However, a malicious storage server can replay a stale but validly encrypted forest blob — the client would silently decrypt and see an old snapshot of the file tree (deleted files reappear, renames revert).
- By design? No. This is a gap. The content encryption path (v4) correctly binds AAD to
fula:v4:content:<storage_key>. Forest encryption should bind to a monotonic counter or version tag. - Backward compatible? Adding AAD to forest encryption is a breaking change for existing forests. Would require a version bump (v4 forest format) with fallback decryption for v1-v3.
Location: hpke.rs — the aead field on HpkeConfig exists but the actual AEAD algorithm is hardcoded to ChaCha20Poly1305 in the HPKE suite construction.
A caller setting config.aead = AeadAlgorithm::Aes256Gcm would believe they selected AES-GCM, but ChaCha20Poly1305 is always used. This is a correctness trap, not a vulnerability (ChaCha20Poly1305 is secure).
- By design? Partially — HPKE RFC 9180 specifies the full suite as a unit. The field exists for future flexibility but is misleading.
- Backward compatible? N/A — no data format change.
Location: sharing.rs — ShareToken struct, encryption_version: Option<u8> field
When a share token is created for content encrypted before v4, the version can be None. The recipient path in get_object_with_share (encryption.rs:2793-2917) handles this by falling back to no-AAD decryption. This is correct behavior but means pre-v4 shared content lacks AAD binding.
- By design? Yes — backward compatibility with pre-v4 encrypted content.
- Backward compatible? Yes — old tokens continue to work.
Location: streaming.rs:176 (track_chunk)
track_chunk() only counts bytes without verifying hashes. It is properly marked #[deprecated] with a note to use verify_chunk() instead. No live call sites use it — VerifiedStreamingDecoder in chunked.rs calls verify_chunk().
- By design? Yes — retained for API compat, correctly deprecated.
- Backward compatible? N/A.
Location: subtree_keys.rs:224-243 (resolve_dek)
Subtree key resolution finds the most-specific matching prefix via linear scan of subtree_keys HashMap. Falls back to master DEK when no subtree matches. The SubtreeShareBuilder (subtree_keys.rs:419-503) wraps the subtree DEK for the recipient using HPKE (same pattern as per-file shares). SubtreeShareRecipient.accept_share() (subtree_keys.rs:518-533) checks expiry before decrypting.
One note: resolve_dek() does a linear scan of all subtree prefixes (O(n) where n = number of subtree keys). This is fine for typical use (few subtrees) but would degrade with many hundreds of subtree keys.
- Status: No security issue. Subtree rotation (
subtree_keys.rs:268-300) correctly increments version and generates a fresh DEK.
Location: secret_link.rs:30-100
Secret links embed the full ShareToken (including wrapped DEK) in the URL fragment (#<base64url payload>). The fragment is never sent to the server per RFC 3986 — the server only sees the opaque_id in the path. The payload uses base64url-no-pad encoding (secret_link.rs:213-215), verified by tests (secret_link.rs:515-527).
SecretLink::parse() (secret_link.rs:159-209) validates the URL has a fragment and correct path prefix before decoding. No XSS or injection risk — the payload is decoded as JSON, not evaluated.
- Status: No issue. The design correctly keeps key material client-side.
Location: inbox.rs — all mutable operations (mark_read, dismiss_entry, remove_entry, accept_entry) verify entry.is_for_recipient(&recipient_id) before modification.
is_for_recipient uses BLAKE3(recipient_public_key)[..16] — 128 bits of collision resistance, adequate for recipient binding.
- Status: No issue found. The collaborate path (ShareEnvelopeBuilder → InboxEntry → ShareInbox.accept_entry → ShareRecipient.accept_share) is correctly isolated per-recipient.
Location: encryption.rs:1614-1753 (put_object_flat_deferred)
Mechanism: Each upload generates a new random DEK (encryption.rs:1629). The storage key is derived from BLAKE3-KDF(dek || salt, original_path) (private_forest.rs:80-89). Since the DEK is different each time, the storage key is different on every overwrite of the same path. The flow:
- Generate new DEK (line 1629) → new storage key (lines 1632-1641)
- Encrypt and upload to the new storage key (lines 1660-1712)
forest.upsert_file()replaces the forest entry with the new storage key (lines 1718-1750)
Result: The old storage key — and ALL its chunk objects (<old_key>_chunk_0, _chunk_1, etc.) — remain in S3 with no reference. The old DEK is lost (the forest entry that held it is overwritten), making the orphaned data unrecoverable. Every file overwrite leaks the entire old upload as orphaned S3 objects.
- By design? No — this is a gap. The per-upload random DEK is correct for security (prevents DEK reuse), but the function should look up and delete the old storage key before overwriting the forest entry.
- Backward compatible? Yes — adding a pre-overwrite cleanup step affects only new overwrites. Existing orphaned objects from past overwrites would need a separate garbage-collection sweep (scan S3 for objects not referenced by the current forest).
Location: private_forest.rs:600-612 (monolithic), private_forest.rs:822-835 (sharded manifest)
The encrypted forest/manifest object size is proportional to the number of files. A storage server can observe the forest object growing and estimate how many files a user has, even though individual file names and paths are encrypted.
For sharded forests, the number of shard objects (16, 32, 64, ...) directly reveals the approximate file count range (since resharding thresholds are public knowledge).
- By design? Accepted tradeoff. Padding the forest to fixed sizes would be expensive and complex.
- Backward compatible? N/A.
Location: private_forest.rs:69-93 (generate_flat_key), private_metadata.rs:233-244
Storage keys are derived as BLAKE3-KDF(dek || salt, original_key) with domain fula/flat-namespace/key/v1, output formatted as Qm<44 hex chars>. The empty salt in obfuscate_key() is documented (audit comment M-001 at private_metadata.rs:237-241) — cross-bucket correlation is prevented because each user has a unique DEK.
- Status: No issue. The derivation provides proper domain separation and determinism within a user's scope.
Location: private_metadata.rs:126-151
EncryptedPrivateMetadata encrypts the full PrivateMetadata struct (original filename, size, timestamps, content type, user metadata) with the per-file DEK using AES-256-GCM. The server sees only the obfuscated storage key and ciphertext size.
- Status: No issue.
Location: encryption.rs:3346-3420 (get_object_chunked)
This function downloads chunks in a sequential for loop — one HTTP request at a time. It is a live code path called from:
fula-flutter/src/api/chunked.rs:44encryption.rs:514(main decrypt path)encryption.rs:2827(share flow)
A parallel path exists (download_chunks_windowed_to_writer at encryption.rs:606-676 with semaphore of 16), but it is NOT used in the primary get_object_decrypted_by_storage_key flow when the caller hasn't opted in via the writer-based API.
- By design? No — the parallel windowed downloader exists but isn't wired into the main download path. Likely an incremental development artifact.
- Backward compatible? Yes — changing download concurrency is transparent to stored data.
Location: encryption.rs — the list flow loads the forest, enumerates matching entries, then for each file calls the S3 API to get metadata. These calls happen sequentially.
For a folder with hundreds of files, this means hundreds of sequential HTTP round-trips.
- By design? Partially — S3 LIST returns metadata inline, but the encrypted metadata is stored in a custom header requiring a HEAD per object. Could be batched or cached.
- Backward compatible? Yes.
Location: encryption.rs:3117-3168
Key rotation lists all objects, then iterates sequentially: fetch metadata → unwrap DEK with old KEK → rewrap with new KEK → update metadata. For a bucket with thousands of objects, this is very slow and has no parallelism.
- By design? Partially — sequential processing is simpler and avoids rate limiting, but a semaphore-bounded parallel approach (like upload chunking) would be safer and faster.
- Backward compatible? Yes.
Location: encryption.rs:1756-1892 (put_object_chunked_internal)
Chunk uploads use a semaphore of 16 concurrent uploads with tokio::spawn + JoinSet. This is well-implemented.
- Status: No issue.
Each user has:
- Independent X25519 keypair (KEK)
- Independent per-file DEKs
- Independent forest (encrypted index) in their own bucket
- No shared mutable state between users
The EncryptedClient uses DashMap for in-memory forest caching (keyed by bucket), and tokio::sync::RwLock for migration locks — both are per-process, not cross-user.
- Status: No scalability issue found. User count scales independently of per-user data.
Location: inbox.rs, sharing.rs
Share tokens contain a wrapped DEK (encrypted for the recipient's public key). The recipient decrypts independently — no shared lock, no shared index, no back-channel to the sender.
Inbox entries are stored at a path derived from the recipient's public key hash. Multiple senders can write to the same inbox prefix, but each entry is independently encrypted.
- Status: No issue.
Location: private_forest.rs:385-425 (ensure_directory)
Directory creation is recursive: for /a/b/c/d/file.txt, it creates entries for /a/, /a/b/, /a/b/c/, /a/b/c/d/. Each is an upsert into the forest. This is correct but means:
- Deep nesting creates many directory entries in the forest
list_subdirs(F-1.3) must scan all entries to reconstruct the tree
There is no folder-count limit, but very deep or very wide directory trees degrade listing performance linearly.
- By design? Yes — flat file-based indexing avoids the complexity of a tree-structured index.
- Backward compatible? N/A.
Location: private_forest.rs:675-682 (shard_for_path)
Shard assignment uses BLAKE3(path)[0] % num_shards. Files in the same directory are distributed across different shards. Listing a directory requires loading all shards (since any shard may contain files from any directory).
A directory-prefix-based sharding scheme would allow loading only relevant shards for a given folder listing.
- By design? Yes — uniform distribution prevents hot shards. The tradeoff is listing performance.
- Backward compatible? Changing shard routing requires a full reshard migration (supported by
reshard()but it changes all shard assignments).
Location: private_forest.rs:349-382 (upsert_file)
The monolithic forest auto-migrates from FlatMapV1 (BTreeMap) to HamtV2 (hash array mapped trie) at 1,000 files. This is a good optimization — HAMT provides O(log32 n) lookups vs O(log n) for BTreeMap, and more importantly enables future partial-tree loading.
- Status: No issue — this is well-designed.
Location: encryption.rs:1614-1753 (put_object_flat_deferred), encryption.rs:2367-2452 (flush_forest)
The deferred-save pattern works as:
- Upload encrypted object to S3 ✓
- Update in-memory forest cache (mark dirty) ✓
- Later:
flush_forestsaves encrypted forest to S3
If the process crashes between steps 2 and 3, the uploaded object exists in S3 but the forest doesn't reference it. The data is:
- Irrecoverable — without the forest entry, the original path and DEK are lost
- Storage-leaking — the orphaned object persists in S3
There is no write-ahead log (WAL), no journaling, and no recovery mechanism.
- By design? The deferred pattern is intentional for performance (batching forest writes). The lack of recovery tooling is a gap.
- Backward compatible? Adding a WAL or immediate-flush option would not affect existing data.
Location: encryption.rs:3117-3168 (rotate_bucket)
If rotate_bucket fails midway (network error, process crash), some objects have DEKs wrapped with the new KEK and some with the old. There is no tracking of which objects were re-wrapped, so:
-
Recovery requires re-scanning all objects to determine their KEK version
-
Both old and new KEKs must be retained until rotation completes
-
There is no resume capability
-
By design? No — this is a robustness gap. A rotation journal (tracking progress) would allow safe resume.
-
Backward compatible? Yes — adding rotation tracking doesn't affect existing data.
Location: private_forest.rs:1244-1289 (reshard)
When resharding (e.g., from 16 to 32 shards), new shard objects are written with a new shard_salt, but old shard objects remain in S3. These are:
-
Encrypted with the same forest DEK (so they could theoretically be decrypted)
-
Referenced by no manifest (the new manifest points to new shard keys)
-
Never cleaned up
-
By design? Partially — leaving old shards avoids deletion failures blocking resharding. But there's no deferred cleanup either.
-
Backward compatible? Yes — adding post-reshard cleanup is additive.
Location: encryption.rs:1121-1204 (load_forest), private_forest.rs:614-637 (EncryptedForest::decrypt)
AES-GCM's authentication tag ensures the ciphertext wasn't tampered with, but there's no versioning or sequence check. Combined with F-2.1 (no AAD), a replayed old forest blob will pass AES-GCM verification.
- By design? No — this compounds F-2.1. A monotonic version counter stored inside the encrypted forest (and verified after decryption) would detect replay.
- Backward compatible? Adding an internal version counter is backward compatible if the absence of a counter is treated as "version 0" during migration.
Location: encryption.rs:2367-2452 (flush_forest)
flush_forest takes a read lock on the forest cache, checks dirty flag, serializes, encrypts, and uploads. Two concurrent flushes of the same forest can race — both read the same dirty state, both upload, and the last S3 PUT wins. No S3 conditional write (If-None-Match) is used.
In practice, this is unlikely with single-client usage but could cause silent data loss with multiple client instances using the same bucket.
- By design? Single-client assumption is implicit but not enforced.
- Backward compatible? N/A.
| ID | Category | Severity | By Design? | Backward Compatible? |
|---|---|---|---|---|
| F-1.1 | Scalability/files | Low | Partially | Yes |
| F-1.2 | Scalability/files | Low | Yes | N/A |
| F-1.3 | Scalability/files | Medium | Yes | N/A |
| F-1.4 | Scalability/files | Info | Yes | Yes |
| F-2.1 | Security | Medium | No | Breaking for forest |
| F-2.2 | Security | Low | Partially | N/A |
| F-2.3 | Security | Info | Yes | Yes |
| F-2.4 | Security | Info | Yes | N/A |
| F-2.5 | Security | None | N/A | N/A |
| F-2.6 | Security | None | N/A | N/A |
| F-2.7 | Security | None | N/A | N/A |
| F-3.1 | Privacy | Medium | No | Yes |
| F-3.2 | Privacy | Low | Accepted | N/A |
| F-3.3 | Privacy | None | N/A | N/A |
| F-3.4 | Privacy | None | N/A | N/A |
| F-4.1 | Speed | Medium | No | Yes |
| F-4.2 | Speed | Low | Partially | Yes |
| F-4.3 | Speed | Low | Partially | Yes |
| F-4.4 | Speed | None | N/A | N/A |
| F-5.1 | Users | None | N/A | N/A |
| F-5.2 | Users | None | N/A | N/A |
| F-6.1 | Folders | Low | Yes | N/A |
| F-6.2 | Folders | Low | Yes | Reshard needed |
| F-6.3 | Folders | None | N/A | N/A |
| F-7.1 | Recovery | Medium | Partially | Yes |
| F-7.2 | Recovery | Medium | No | Yes |
| F-7.3 | Recovery | Low | Partially | Yes |
| F-7.4 | Recovery | Medium | No | Yes |
| F-7.5 | Recovery | Low | Implicit | N/A |
High priority (security/correctness):
- F-2.1 — Add AAD to forest/shard encryption (version-gated, with v3 fallback decryption)
- F-3.1 — In
put_object_flat_deferred, look up and delete old storage key (+ chunk objects) before upserting the forest entry - F-7.1 — Add immediate-flush option or write-ahead log for critical uploads
Medium priority (performance):
4. F-4.1 — Wire download_chunks_windowed_to_writer into the main get_object_decrypted_by_storage_key path
5. F-7.2 — Add rotation progress journal for resumable key rotation
Lower priority (scalability): 6. F-1.1 — Plan for multi-byte shard routing when approaching 256 shard limit 7. F-1.3 / F-6.2 — Consider directory-aware shard routing or a separate directory index
| ID | Status | Notes |
|---|---|---|
| F-2.1 + F-7.4 | Fixed | New formats: EncryptedForest v4 (monolithic-AAD), EncryptedShardManifest v5 (sharded-AAD, legacy routing), v6 (sharded-AAD, directory-aware routing); EncryptedForestShard v2. AAD is fula:forest:v4:<bucket>:<seq>, fula:manifest:v{5,6}:<bucket>:<seq>, fula:shard:v2:<bucket>:<shard_index>:<seq>. Manifest-seq + per-shard-seq (shard_sequences vouched inside v5/v6 manifest plaintext) close replay holes including cold-start-before-cache shard replay. Distinct v5/v6 AAD prefixes prevent cross-version downgrade at AEAD verification; cached_prior_manifest_version gate additionally rejects v5 once v6 has been observed for a bucket. Legacy v1/v2/v3 remain decryptable for backward compatibility. |
| F-2.2 | Fixed | Marked HpkeConfig.aead field as deprecated; Default now returns ChaCha20Poly1305 to match actual behavior. |
| F-3.1 | Fixed | put_object_flat_deferred and put_object_chunked_internal capture the prior storage key before upsert; post-save cleanup deletes chunk objects + main object + IPFS pin (refcount-checked via in-forest scan). |
| F-NEW | Fixed | delete_object_flat runs the same refcount-safe cleanup helper; server-side delete_object handler unpins IPFS after refcount check. |
| F-4.1 | Fixed | get_object_chunked now calls download_chunks_windowed_to_writer (same parallel helper used by the main path); pre-v3 ChunkedDecoder::new path preserved via format dispatch. |
| F-4.2 | Fixed | list_objects_decrypted uses futures::stream::for_each_concurrent at MAX_CONCURRENT_HEADS=16. No caching (per scope guidance). |
| F-4.3 | Fixed | rotate_bucket uses for_each_concurrent at MAX_CONCURRENT_REWRAPS=8; RotationReport collected via Arc<Mutex<_>>. |
| F-6.2 | Fixed | ShardedV6 (manifest v6, format sharded-v6) routes by parent directory (BLAKE3-KDF(dek‖parent_dir‖salt)[0..2] % num_shards). All files under one directory land in one shard — locality without new metadata leak (shard IDs remain QmXXXX-opaque because DEK+salt are mixed). Auto-migration v1/v2→v6 on next flush when file count ≥ SHARDED_MIGRATION_THRESHOLD; v3/v5→v6 on next flush regardless of count (one-time reshard, old shard blobs best-effort deleted after new manifest is written). |
| F-1.3 | Partial / deferred | v6 routing gives list_directory(dir) one-shard locality (big win), but list_subdirs(dir) still iterates all loaded shards to rebuild the directory tree — subdirs of a given dir may live in any shard, which v6 routing does not localize. A dedicated directory index (~50 bytes/dir, maintained on upsert/remove with refcount) would deliver the advertised "O(files in that dir)" bound; deferred here to avoid adding bug surface in refcount maintenance. Tracked for a follow-up fix. |
| F-7.1 | Fixed | New put_object_flat_immediate variant that flushes per-call; background auto-flush task in EncryptedClient runs every AUTO_FLUSH_INTERVAL_SECS. Existing put_object_flat_deferred unchanged. |
| F-7.2 | Fixed | RotationReport gained an optional journal_path; on resume, rewraps already listed as complete (by matching current object kek_version) are skipped; journal is deleted on clean completion. |
| F-7.3 | Fixed | After a reshard (in both flush-forest and v6 auto-migration paths), old shard keys computed from the prior salt are deleted best-effort once the new manifest is written. |
| F-7.5 | Fixed | save_sharded_forest and monolithic save_forest now use conditional PUT with the prior ETag; 412 → ClientError::ConcurrentModification, which evicts the cache and asks the caller to reload + retry. delete_object_flat on sharded buckets routes through flush_forest so v3/v5→v6 auto-migration also fires on delete-only flows. Server-side: PUT now honors RFC 7232 If-Match / If-None-Match and returns 412 via S3ErrorCode::PreconditionFailed (crates/fula-cli/src/handlers/object.rs); the client's retry-on-412 loop is now wired end-to-end. |
| F-1.1, F-1.2, F-1.4 | No change | Per scope; raising the cap and adding a storage-key reverse index are tracked for future work. |
| F-2.3 / F-3.2 / F-4.4 / F-5.x / F-6.1 / F-6.3 | No change | Per scope — no action required. |
Format-version matrix:
| Format name | Type | Version tag | Writes produced by this client | Reads still supported |
|---|---|---|---|---|
| Monolithic (no AAD) | Forest | 1, 2 | No | Yes (legacy fallback) |
| Monolithic (AAD) | Forest | 4 | Yes | Yes |
| Sharded (no AAD) | Manifest | 3 | No | Yes (auto-migrates to v6 on next flush) |
| Sharded (AAD, legacy routing) | Manifest | 5 | Only during a save that precedes the v3/v5→v6 auto-migration — the subsequent flush writes v6 | Yes (auto-migrates to v6 on next flush) |
| Sharded (AAD, directory-aware routing) | Manifest | 6 | Yes (new default) | Yes |
| Shard (no AAD) | Shard blob | 1 | No | Yes (legacy fallback) |
| Shard (AAD) | Shard blob | 2 | Yes | Yes |
A client that has observed manifest v6 for a bucket will refuse a subsequent v5 response for that bucket (rollback guard backed by cached_prior_manifest_version).