Skip to content

Argon2id derives master key from zero user-secret entropy - add Mode B opt-in (F-A1) #14

@ehsan6sha

Description

@ehsan6sha

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)

  1. 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).
  2. auth_service.dart gains:
    • enableModeB(String seed) — runs the A→B migration with staged state:
      1. Read current K_old (Mode A).
      2. Generate per-user random 32-byte salt.
      3. Derive K_new = Argon2id("fula-files-v1-google-pw", "provider:sub:email:seed", salt).
      4. Persist derivationMigrationState = migrating_to_v2_mode_B + new salt to SecureStorage.
      5. Call FulaApiService.rotateAllBuckets(K_old, K_new) — rotates every bucket. Resumable: if killed mid-way, next enableModeB resumes from the last completed bucket.
      6. Verify a read with K_new succeeds across all buckets.
      7. Atomically persist keyDerivationVersion = 2_mode_B, then zeroize K_old.
    • signInModeB(String seed) — used on subsequent launches and new devices once profile is fetched.
  3. 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
  4. 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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions