Severity: HIGH (per audit findings fula-client-audit-findings.md — finding F-A1)
Bug
auth_service.dart:535-549 (FxFiles) derives the master encryption key with:
final input = '${_currentUser!.provider.name}:${_currentUser!.id}:$email';
await fula.deriveKey(context: 'fula-files-v1', input: utf8.encode(input));
Inside fula-crypto::hashing::derive_key_argon2id, the context bytes double as the salt (padded to 8 bytes if shorter). So the actual KDF is:
master_KEK = Argon2id(
input = "google:<sub>:<email>",
salt = "fula-files-v1\0...", // constant, global
m=64MiB, t=3, p=1
)
Every input component is a public identity attribute. Anyone who obtains a single artifact carrying (provider, sub, email) together — an OAuth ID token, a JWT, a crash dump, a leaked profile row — computes the master key directly in one Argon2id invocation (~0.5–1 s). No brute force, no probabilistic search. Argon2id's memory-hardness is wasted because there is no password.
Compounds with F-A3 (the global users-index is enumerable by hashed user_id, so an attacker who confirms a target is a Fula user can then derive their key).
Scope of this fix
Per user decision (2026-05-18, with consultation from Gemini and Codex advisors):
- Mode A — OAuth-only is unchanged for existing users. We do NOT run an auto-migration on the ~10K Mode-A users — the salt-only "improvement" is marginal (salt isn't secret) and a forced rotation has more downside than upside.
- Mode B — OAuth + seed (passphrase) is added as an opt-in path. Users tap "Add a password for stronger security" in Settings, set a seed, and the app re-wraps every per-file DEK from K_old (Mode A) to K_new (Mode B). After migration, K_new = Argon2id(input="provider:sub:email:seed", salt=per-user-random).
- Mode C — Seed-only (no OAuth) is out of scope for this release. Mode C requires a new server-side challenge-response auth endpoint (no OAuth to anchor identity) — substantial work that is best designed and shipped separately.
The Codex advisor also flagged that the audit's earlier note about "delete the chunks" in F-A5 was unsafe — that observation has already been applied to F-A5. For F-A1 the equivalent concern is migration safety under partial failure — addressed below by a staged migration with persisted state.
Design
Client side (FxFiles + fula-flutter FFI)
- New FFI
derive_key_with_salt(context: String, input: Vec<u8>, salt: Vec<u8>) -> Vec<u8> (additive). Backed by fula_crypto::hashing::derive_key_argon2id_with_salt(context, input, salt) -> [u8; 32]. The existing derive_key is left in place (Mode A users keep deriving as today).
auth_service.dart gains:
enableModeB(String seed) — runs the A→B migration with staged state:
- Read current K_old (Mode A).
- Generate per-user random 32-byte salt.
- Derive K_new = Argon2id("fula-files-v1-google-pw", "provider:sub:email:seed", salt).
- Persist
derivationMigrationState = migrating_to_v2_mode_B + new salt to SecureStorage.
- Call
FulaApiService.rotateAllBuckets(K_old, K_new) — rotates every bucket. Resumable: if killed mid-way, next enableModeB resumes from the last completed bucket.
- Verify a read with K_new succeeds across all buckets.
- Atomically persist
keyDerivationVersion = 2_mode_B, then zeroize K_old.
signInModeB(String seed) — used on subsequent launches and new devices once profile is fetched.
- Persistent state in SecureStorage:
keyDerivationVersion: 1_mode_A (default for all existing users) or 2_mode_B
derivationSalt (base64) — present only for Mode B
derivationMigrationState — transient, set during A→B rotation
- Cross-device (Phase 2 in this issue — see below): server-side derivation profile lookup so a new device can fetch (mode, salt) on first sign-in.
Server side (fula-api)
Phase 2 — server-side derivation profile API for cross-device:
- Add
derivation_profile: Option<DerivationProfile> field to the per-user bucketsIndex CBOR (additive — old clients ignore it).
DerivationProfile = { mode: ModeTag, salt: [u8; 32], updated_at: u64 }. mode is either ModeA (legacy, no salt) or ModeB (with salt). salt is not a secret — its purpose is uniqueness, not confidentiality.
- Client POSTs the derivation profile alongside the existing bucketsIndex update on every Mode B sign-in / change.
- Server validates: one profile per authenticated user (JWT-scoped). Server cannot verify the BLAKE3 of the seed (it doesn't have the seed) — it simply trusts the client's claim. The worst a malicious client can do is publish wrong data for their own user, which is self-harm.
Recovery mnemonic (Phase 2)
24-word BIP39 mnemonic generated by the system at Mode B opt-in time. Stored ONLY on the user's device until shown, never persisted to disk after. UX per Gemini advisor:
- Partial verification (3 of 24 words at random positions) — not full re-type.
- Clear UI separation between "your password" (daily use) and "your recovery key" (backup).
- Hold-to-confirm full-screen red warning before enabling Mode B.
Migration safety guarantees
No existing Mode A user is affected. Mode A's KDF is unchanged. Existing users see no UI difference unless they explicitly opt in to Mode B in Settings.
Mode B opt-in is staged + resumable. The derivationMigrationState flag in SecureStorage ensures that if the app is killed mid-rotation:
- On next launch, the app sees
migrating_to_v2_mode_B + both keys derivable (K_old via mode A, K_new via stored salt + the seed re-entered by the user on resume) → resumes rotation from the last-completed bucket.
- The
keyDerivationVersion = 2_mode_B flag is set ONLY after a verify-read succeeds with K_new across all buckets.
- K_old is zeroized only AFTER
keyDerivationVersion = 2_mode_B is persisted.
Forest sync prerequisite met: fula-api v0.5.4 (issue #12 fix, commit b63c58c) keeps forest user_metadata in sync during rotate_bucket — required for Mode A → Mode B migration's per-file DEK rewrap.
Tests
Failing-first tests (compile against post-fix API):
fula-crypto: derive_key_argon2id_with_salt produces distinct outputs for distinct salts on the same input.
fula-crypto: derive_key_argon2id_with_salt matches derive_key_argon2id when salt is the context bytes (back-compat).
fula-flutter: FFI binding for derive_key_with_salt round-trips bytes correctly.
Phase 2 tests (after server + UI integration):
- Mode A → Mode B opt-in: data uploaded in Mode A still readable after migration.
- Cross-device: Device A migrates A→B; Device B signs in with seed, decrypts.
- Partial-failure resume: kill app mid-rotation; relaunch resumes and completes.
- Wrong-seed rejection on Mode B sign-in.
Phasing
| Phase |
Scope |
Ships with |
| Phase 1 (this session) |
derive_key_argon2id_with_salt in fula-crypto + FFI; service-layer enableModeB / signInModeB skeleton in auth_service.dart (no UI polish yet); tests |
fula-api 0.5.5 + a non-user-facing Mode B beta hook in FxFiles |
| Phase 2 |
Server-side derivation profile API (cross-device); FxFiles UI screens (tier-action layout, partial mnemonic verification, less-secure / recommended labels per Gemini); recovery flow; full-screen Mode-B warnings; opt-in entry point in Settings |
Follow-up release |
Phase 1 does NOT expose Mode B to end users yet — the wiring lands without UI so the crypto + service layer can be unit-tested and reviewed in isolation. Phase 2 ships the UX once the underlying mechanism is validated.
References
- Audit finding F-A1 in
fula-client-audit-findings.md
- Gemini advisor UX review (2026-05-18): tier-action layout, partial mnemonic verification, "yellow shield" Mode A warning, server-stored derivation profile for cross-device
- Codex advisor correctness review (2026-05-18): server-stored salt is the right call; staged migration with persisted state;
derive_key_with_salt FFI rather than encoding salt in input; Mode C requires a new auth endpoint (scope-out for this release)
Severity: HIGH (per audit findings
fula-client-audit-findings.md— finding F-A1)Bug
auth_service.dart:535-549(FxFiles) derives the master encryption key with:Inside
fula-crypto::hashing::derive_key_argon2id, the context bytes double as the salt (padded to 8 bytes if shorter). So the actual KDF is:Every input component is a public identity attribute. Anyone who obtains a single artifact carrying
(provider, sub, email)together — an OAuth ID token, a JWT, a crash dump, a leaked profile row — computes the master key directly in one Argon2id invocation (~0.5–1 s). No brute force, no probabilistic search. Argon2id's memory-hardness is wasted because there is no password.Compounds with F-A3 (the global users-index is enumerable by hashed user_id, so an attacker who confirms a target is a Fula user can then derive their key).
Scope of this fix
Per user decision (2026-05-18, with consultation from Gemini and Codex advisors):
The Codex advisor also flagged that the audit's earlier note about "delete the chunks" in F-A5 was unsafe — that observation has already been applied to F-A5. For F-A1 the equivalent concern is migration safety under partial failure — addressed below by a staged migration with persisted state.
Design
Client side (FxFiles + fula-flutter FFI)
derive_key_with_salt(context: String, input: Vec<u8>, salt: Vec<u8>) -> Vec<u8>(additive). Backed byfula_crypto::hashing::derive_key_argon2id_with_salt(context, input, salt) -> [u8; 32]. The existingderive_keyis left in place (Mode A users keep deriving as today).auth_service.dartgains:enableModeB(String seed)— runs the A→B migration with staged state:derivationMigrationState = migrating_to_v2_mode_B+ new salt to SecureStorage.FulaApiService.rotateAllBuckets(K_old, K_new)— rotates every bucket. Resumable: if killed mid-way, nextenableModeBresumes from the last completed bucket.keyDerivationVersion = 2_mode_B, then zeroize K_old.signInModeB(String seed)— used on subsequent launches and new devices once profile is fetched.keyDerivationVersion:1_mode_A(default for all existing users) or2_mode_BderivationSalt(base64) — present only for Mode BderivationMigrationState— transient, set during A→B rotationServer side (fula-api)
Phase 2 — server-side derivation profile API for cross-device:
derivation_profile: Option<DerivationProfile>field to the per-user bucketsIndex CBOR (additive — old clients ignore it).DerivationProfile = { mode: ModeTag, salt: [u8; 32], updated_at: u64 }.modeis eitherModeA(legacy, no salt) orModeB(with salt).saltis not a secret — its purpose is uniqueness, not confidentiality.Recovery mnemonic (Phase 2)
24-word BIP39 mnemonic generated by the system at Mode B opt-in time. Stored ONLY on the user's device until shown, never persisted to disk after. UX per Gemini advisor:
Migration safety guarantees
No existing Mode A user is affected. Mode A's KDF is unchanged. Existing users see no UI difference unless they explicitly opt in to Mode B in Settings.
Mode B opt-in is staged + resumable. The
derivationMigrationStateflag in SecureStorage ensures that if the app is killed mid-rotation:migrating_to_v2_mode_B+ both keys derivable (K_old via mode A, K_new via stored salt + the seed re-entered by the user on resume) → resumes rotation from the last-completed bucket.keyDerivationVersion = 2_mode_Bflag is set ONLY after a verify-read succeeds with K_new across all buckets.keyDerivationVersion = 2_mode_Bis persisted.Forest sync prerequisite met: fula-api v0.5.4 (issue #12 fix, commit b63c58c) keeps forest
user_metadatain sync duringrotate_bucket— required for Mode A → Mode B migration's per-file DEK rewrap.Tests
Failing-first tests (compile against post-fix API):
fula-crypto:derive_key_argon2id_with_saltproduces distinct outputs for distinct salts on the same input.fula-crypto:derive_key_argon2id_with_saltmatchesderive_key_argon2idwhensaltis the context bytes (back-compat).fula-flutter: FFI binding forderive_key_with_saltround-trips bytes correctly.Phase 2 tests (after server + UI integration):
Phasing
derive_key_argon2id_with_saltin fula-crypto + FFI; service-layerenableModeB/signInModeBskeleton inauth_service.dart(no UI polish yet); testsPhase 1 does NOT expose Mode B to end users yet — the wiring lands without UI so the crypto + service layer can be unit-tested and reviewed in isolation. Phase 2 ships the UX once the underlying mechanism is validated.
References
fula-client-audit-findings.mdderive_key_with_saltFFI rather than encoding salt in input; Mode C requires a new auth endpoint (scope-out for this release)