Skip to content

Commit 6dfedb9

Browse files
ehsan6shaclaude
andcommitted
F-A1 Phase 1: derive_key_argon2id_with_salt (Rust crypto + FFI)
Adds the cryptographic primitive and the FFI entry point that Mode B sign-up will rely on. See issue #14 for the full design context. The existing `derive_key_argon2id(context, input)` uses the `context` bytes as the Argon2 salt — which is a constant string for FxFiles (`"fula-files-v1"`). Every input component (`provider:sub:email`) is a public identity attribute, so anyone who obtains a single artifact carrying all three computes the master key in one Argon2id invocation — that is the F-A1 finding. The fix is to add a Mode B opt-in that introduces a per-user random salt and a user-entered seed. This commit lands the crypto + FFI side only: - `fula_crypto::hashing::derive_key_argon2id_with_salt(context, input, salt)`: new function taking an explicit salt. Internally it BLAKE3-hashes `(context || 0x00 || salt)` to produce a 16-byte effective salt for Argon2, preserving both domain separation (context is consumed) and cross-platform consistency (effective salt is always 16 bytes regardless of caller-salt length). - `derive_key_argon2id_with_salt_checked` is the non-panicking variant returning `Result<[u8; 32], &'static str>` for short-salt rejection. - 7 unit tests cover: basic shape, distinct salts → distinct keys, distinct contexts → distinct keys, determinism, short-salt rejection, panic-on-short-salt convenience variant, and the explicit non-equivalence to the legacy function (so Mode A and Mode B never share a derived key even given byte-equivalent inputs — Mode B users go through the rotation). - `fula_flutter::api::encrypted::derive_key_with_salt(context, input, salt)`: additive FFI surface that delegates to the new crypto function and surfaces the short-salt error as a `Result<Vec<u8>, String>`. Mode A users are not affected — the legacy `derive_key_argon2id` and the legacy `derive_key` FFI are kept in place and unchanged. A Mode A user's derivation continues to produce the same bytes as before. What this commit does NOT do (Phase 1.5 follow-up): - Regenerate the `packages/fula_client/lib/src` Dart bindings — the dev environment needs LLVM / libclang for `flutter_rust_bridge_codegen` to complete (ffigen step). The Rust FFI is in place; once codegen is run on an LLVM-equipped dev environment, the Dart binding `deriveKeyWithSalt` will appear in `encrypted.dart` and the FxFiles app can call it. - FxFiles `auth_service.dart` Mode B service-layer wiring — gated on the Dart binding being available. The audit-findings doc records the call-site changes; once the binding is generated, applying them is mechanical. - Server-side derivation-profile API for cross-device — Phase 2, to be sequenced after the client-side Mode B opt-in is functional. - UI screens (tier-action layout per Gemini advisor's UX review, partial mnemonic verification, less-secure / recommended labels) — Phase 2. What ships in this commit IS safe in isolation: the new function is additive, has no callers in production paths yet, and is exercised only by unit tests. Mode A users see no behavior change. Refs: - Audit finding F-A1 in fula-client-audit-findings.md - Issue #14 (the design + phasing) - Gemini advisor UX review (2026-05-18) — informs Phase 2 UI - Codex advisor correctness review (2026-05-18) — confirmed `derive_key_with_salt` as the right FFI shape (over encoding salt in input) and the staged-migration pattern for the eventual A→B rotation Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 45cf90c commit 6dfedb9

2 files changed

Lines changed: 207 additions & 0 deletions

File tree

crates/fula-crypto/src/hashing.rs

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,93 @@ pub fn derive_key_argon2id(context: &str, input: &[u8]) -> [u8; 32] {
257257
output
258258
}
259259

260+
/// Derive a key from the given input, context, and an EXPLICIT salt
261+
/// using Argon2id.
262+
///
263+
/// Compared to [`derive_key_argon2id`], which uses the `context` bytes
264+
/// as the salt, this variant takes the salt as a separate parameter so
265+
/// callers can supply a per-user random salt. This is the load-bearing
266+
/// piece of the audit F-A1 fix (Mode B sign-up): a per-user random
267+
/// salt ensures that the master key is not derivable from public
268+
/// identity attributes alone, even when an attacker knows the input.
269+
///
270+
/// Salt requirements (validated):
271+
/// - Minimum 8 bytes (Argon2 spec)
272+
/// - Recommended 16+ bytes; 32 bytes is what Mode B uses
273+
///
274+
/// # Panics
275+
///
276+
/// Panics if `salt.len() < 8` (caller error — surface this in tests).
277+
/// Use [`derive_key_argon2id_with_salt_checked`] for a non-panicking
278+
/// variant.
279+
///
280+
/// # Example
281+
/// ```
282+
/// use fula_crypto::hashing::derive_key_argon2id_with_salt;
283+
///
284+
/// let salt = [0u8; 32]; // Production: 32 random bytes per user.
285+
/// let key = derive_key_argon2id_with_salt(
286+
/// "fula-files-v1-google-pw",
287+
/// b"google:123456:user@example.com:passphrase",
288+
/// &salt,
289+
/// );
290+
/// assert_eq!(key.len(), 32);
291+
/// ```
292+
pub fn derive_key_argon2id_with_salt(context: &str, input: &[u8], salt: &[u8]) -> [u8; 32] {
293+
derive_key_argon2id_with_salt_checked(context, input, salt)
294+
.expect("derive_key_argon2id_with_salt: salt must be >= 8 bytes")
295+
}
296+
297+
/// Non-panicking variant of [`derive_key_argon2id_with_salt`].
298+
/// Returns `Err` if the salt is shorter than 8 bytes.
299+
///
300+
/// `context` is incorporated into the salt as a domain-separation
301+
/// prefix: `actual_salt = blake3_keyed("fula-argon2id-v2:"||context)(salt)[..16]`
302+
/// — concretely, we hash `(context || 0x00 || salt)` with BLAKE3 and
303+
/// take the first 16 bytes as the Argon2 salt. This way:
304+
/// 1. Distinct contexts derive distinct keys even from the same
305+
/// (input, salt) pair — preserves the domain-separation property
306+
/// of the original `derive_key_argon2id`.
307+
/// 2. The caller-supplied salt is consumed.
308+
/// 3. The Argon2 salt is always exactly 16 bytes regardless of the
309+
/// caller's salt length, so cross-platform consistency is
310+
/// preserved (FxFiles is on native, WebUI on WASM).
311+
pub fn derive_key_argon2id_with_salt_checked(
312+
context: &str,
313+
input: &[u8],
314+
salt: &[u8],
315+
) -> std::result::Result<[u8; 32], &'static str> {
316+
use argon2::{Algorithm, Argon2, Params, Version};
317+
318+
if salt.len() < 8 {
319+
return Err("salt must be at least 8 bytes");
320+
}
321+
322+
let params = Params::new(65536, 3, 1, Some(32)).expect("Invalid Argon2 parameters");
323+
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
324+
325+
// Build a deterministic 16-byte effective salt that incorporates
326+
// BOTH the context (domain separation) and the caller's salt. See
327+
// doc comment above for the rationale.
328+
let mut effective_salt = [0u8; 16];
329+
let combined = {
330+
let mut buf = Vec::with_capacity(context.len() + 1 + salt.len());
331+
buf.extend_from_slice(context.as_bytes());
332+
buf.push(0u8);
333+
buf.extend_from_slice(salt);
334+
buf
335+
};
336+
let h = blake3::hash(&combined);
337+
effective_salt.copy_from_slice(&h.as_bytes()[..16]);
338+
339+
let mut output = [0u8; 32];
340+
argon2
341+
.hash_password_into(input, &effective_salt, &mut output)
342+
.expect("Argon2 hashing failed");
343+
344+
Ok(output)
345+
}
346+
260347
/// Calculate an MD5 hash for S3 ETag compatibility
261348
pub fn md5_hash(data: &[u8]) -> String {
262349
use md5::{Md5, Digest};
@@ -387,4 +474,87 @@ mod tests {
387474
let key = derive_key_argon2id("short", b"input");
388475
assert_eq!(key.len(), 32);
389476
}
477+
478+
// ---------------------------------------------------------------
479+
// Tests for `derive_key_argon2id_with_salt` (audit F-A1 / issue #14).
480+
//
481+
// The Mode B fix relies on this function consuming a per-user
482+
// random salt so the master key is not derivable from public
483+
// identity attributes alone.
484+
// ---------------------------------------------------------------
485+
486+
#[test]
487+
fn argon2id_with_salt_basic_shape() {
488+
let salt = [0u8; 32];
489+
let key = derive_key_argon2id_with_salt(
490+
"fula-files-v1-google-pw",
491+
b"google:123:user@example.com:passphrase",
492+
&salt,
493+
);
494+
assert_eq!(key.len(), 32);
495+
}
496+
497+
#[test]
498+
fn argon2id_with_salt_distinct_salts_produce_distinct_keys() {
499+
let input = b"google:123:user@example.com:passphrase";
500+
let salt_a = [0xAAu8; 32];
501+
let salt_b = [0xBBu8; 32];
502+
let key_a = derive_key_argon2id_with_salt("ctx", input, &salt_a);
503+
let key_b = derive_key_argon2id_with_salt("ctx", input, &salt_b);
504+
assert_ne!(
505+
key_a, key_b,
506+
"salt is the load-bearing parameter for the F-A1 fix"
507+
);
508+
}
509+
510+
#[test]
511+
fn argon2id_with_salt_distinct_contexts_produce_distinct_keys() {
512+
// Same input + same salt, different context → different key.
513+
// Preserves domain separation from the legacy function.
514+
let input = b"google:123:user@example.com:passphrase";
515+
let salt = [0x11u8; 32];
516+
let key_a = derive_key_argon2id_with_salt("ctx-a", input, &salt);
517+
let key_b = derive_key_argon2id_with_salt("ctx-b", input, &salt);
518+
assert_ne!(key_a, key_b);
519+
}
520+
521+
#[test]
522+
fn argon2id_with_salt_deterministic() {
523+
let input = b"google:123:user@example.com:passphrase";
524+
let salt = [0x55u8; 32];
525+
let key_a = derive_key_argon2id_with_salt("ctx", input, &salt);
526+
let key_b = derive_key_argon2id_with_salt("ctx", input, &salt);
527+
assert_eq!(key_a, key_b);
528+
}
529+
530+
#[test]
531+
fn argon2id_with_salt_rejects_short_salt() {
532+
let res = derive_key_argon2id_with_salt_checked("ctx", b"input", &[0u8; 7]);
533+
assert!(res.is_err());
534+
}
535+
536+
#[test]
537+
#[should_panic(expected = "salt must be >= 8 bytes")]
538+
fn argon2id_with_salt_panics_on_short_salt() {
539+
let _ = derive_key_argon2id_with_salt("ctx", b"input", &[0u8; 7]);
540+
}
541+
542+
#[test]
543+
fn argon2id_with_salt_does_not_match_legacy_even_with_context_as_salt() {
544+
// The new function applies a BLAKE3 domain-separation step to
545+
// the (context, salt) pair before passing to Argon2, so it is
546+
// intentionally NOT byte-equivalent to the legacy function. The
547+
// legacy function is kept in place for Mode A users (unchanged).
548+
let input = b"google:123:user@example.com";
549+
let legacy = derive_key_argon2id("fula-files-v1", input);
550+
let with_salt = derive_key_argon2id_with_salt(
551+
"fula-files-v1",
552+
input,
553+
b"fula-files-v1\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0",
554+
);
555+
assert_ne!(
556+
legacy, with_salt,
557+
"Mode A and Mode B derive distinct keys even given byte-equivalent inputs — Mode B users go through the rotation"
558+
);
559+
}
390560
}

crates/fula-flutter/src/api/encrypted.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,43 @@ pub fn derive_key(context: String, input: Vec<u8>) -> Vec<u8> {
275275
fula_crypto::hashing::derive_key_argon2id(&context, &input).to_vec()
276276
}
277277

278+
/// Derive a 32-byte key using Argon2id with an EXPLICIT per-user salt.
279+
///
280+
/// This is the audit F-A1 / issue #14 Mode B variant. Compared to
281+
/// [`derive_key`], which uses the `context` bytes as the salt, this
282+
/// variant takes the salt as a separate parameter so callers can
283+
/// supply a per-user random salt — closing the F-A1 finding (the
284+
/// master key is no longer derivable from public identity attributes
285+
/// alone).
286+
///
287+
/// Cross-platform parity: the underlying
288+
/// `fula_crypto::hashing::derive_key_argon2id_with_salt` is pure Rust
289+
/// and is reachable from both native (FxFiles) and WASM (WebUI) so the
290+
/// derived key is byte-identical across platforms for the same
291+
/// (context, input, salt) triple.
292+
///
293+
/// Mode A users continue to call [`derive_key`] (legacy behavior,
294+
/// unchanged). Mode B users call this function with their per-user
295+
/// random salt.
296+
///
297+
/// # Arguments
298+
/// * `context` — domain-separation tag (e.g., `"fula-files-v1-google-pw"`)
299+
/// * `input` — UTF-8 bytes of the identity-plus-seed string
300+
/// (e.g., `"google:<sub>:<email>:<seed>"`)
301+
/// * `salt` — per-user random salt; minimum 8 bytes, recommended 32
302+
///
303+
/// # Returns
304+
/// * 32-byte derived key, or an error string if the salt is too short
305+
pub fn derive_key_with_salt(
306+
context: String,
307+
input: Vec<u8>,
308+
salt: Vec<u8>,
309+
) -> Result<Vec<u8>, String> {
310+
fula_crypto::hashing::derive_key_argon2id_with_salt_checked(&context, &input, &salt)
311+
.map(|k| k.to_vec())
312+
.map_err(|e| e.to_string())
313+
}
314+
278315
/// Check if client uses FlatNamespace mode
279316
pub async fn is_flat_namespace(client: &EncryptedClientHandle) -> bool {
280317
let guard = client.inner.read().await;

0 commit comments

Comments
 (0)