Skip to content

Commit 0d15d2d

Browse files
committed
resolved offline download userid issue
1 parent f5bbeab commit 0d15d2d

9 files changed

Lines changed: 259 additions & 68 deletions

File tree

Cargo.lock

Lines changed: 8 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ name = "encrypted_upload_test"
7777
path = "examples/encrypted_upload_test.rs"
7878

7979
[workspace.package]
80-
version = "0.4.2"
80+
version = "0.4.3"
8181
edition = "2021"
8282
license = "MIT OR Apache-2.0"
8383
repository = "https://github.com/functionland/fula-api"

crates/fula-client/src/lib.rs

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -99,13 +99,16 @@ pub use health_gate::{HealthCallback, MasterHealthEvent};
9999
#[cfg(not(target_arch = "wasm32"))]
100100
pub use block_cache::{BlockCache, BlockCacheError};
101101

102-
/// Phase 3.3 — `derive_user_key_from_email` available on EVERY
103-
/// target (wasm + native). Apps compute the userKey at sign-in
104-
/// time from the OAuth-provided email and stash it in
105-
/// `Config::users_index_user_key`. The same function is also
106-
/// re-exported via `registry_resolver` on native for backward
107-
/// compatibility with code that imports it from there.
108-
pub use user_key::derive_user_key_from_email;
102+
/// Phase 3.3 — userKey derivation, available on EVERY target
103+
/// (wasm + native). See `user_key.rs` module-level docs for which
104+
/// function to call:
105+
///
106+
/// - **`derive_user_key_from_jwt_sub`** (preferred) — matches master
107+
/// byte-for-byte. Pass the JWT `sub` claim through unchanged.
108+
/// - **`derive_user_key_from_email`** (legacy) — broken for
109+
/// pre-migration-011 users whose JWT sub is plaintext email.
110+
/// Kept for source compatibility with already-shipped apps.
111+
pub use user_key::{derive_user_key_from_email, derive_user_key_from_jwt_sub};
109112

110113
/// Phase 3.3 — cold-start hybrid resolver public API. Native-only;
111114
/// the resolver itself is gated to `cfg(not(target_arch = "wasm32"))`.

crates/fula-client/src/user_key.rs

Lines changed: 131 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,92 @@
11
//! Phase 3.3 — userKey derivation, available on every target.
22
//!
3-
//! `derive_user_key_from_email` was originally inlined in
4-
//! `registry_resolver.rs`, but that module is gated to native via
5-
//! `#![cfg(not(target_arch = "wasm32"))]` because it depends on
6-
//! `reqwest`, `parking_lot`, and other crates that don't compile on
7-
//! wasm. The userKey computation itself is pure: just `sha2` +
8-
//! `blake3` + `hex` — all of which build cleanly on wasm32 (these
9-
//! are already transitive deps of the wasm SDK build).
10-
//!
11-
//! Extracting the helper here lets the FRB and wasm-bindgen
12-
//! bindings expose `derive_user_key_from_email` without having to
13-
//! re-implement the algorithm. Master and SDK both produce the
14-
//! same `userKey` for the same email, regardless of which target
15-
//! the SDK was built for.
16-
//!
17-
//! **Algorithm (must stay in lockstep with master's `state.rs::hash_user_id`):**
3+
//! Two functions live here, and choosing the right one matters — getting
4+
//! it wrong produces a silent cold-start failure (master publishes under
5+
//! userKey A, SDK looks up userKey B, lookup misses, "user has not
6+
//! written yet" error even though they have).
187
//!
8+
//! ### `derive_user_key_from_jwt_sub` (PREFERRED — matches master exactly)
9+
//!
10+
//! Master's `crates/fula-cli/src/state.rs::hash_user_id` does:
11+
//! ```text
12+
//! BLAKE3.derive_key("fula:user_id:", claims.sub.as_bytes())[..16].hex()
13+
//! ```
14+
//! `claims.sub` is fed in as-is, no transformation. Whatever string the
15+
//! JWT carries as `sub` IS the hash input. This function mirrors that
16+
//! exactly. Apps that have access to the JWT sub at sign-in (which is
17+
//! always — they just received the token) should call THIS one.
18+
//!
19+
//! For pre-migration-011 users, `claims.sub` is plaintext email.
20+
//! For post-migration users, `claims.sub` is `sha256(email).hex()`.
21+
//! Either way, hashing the sub directly matches master.
22+
//!
23+
//! ### `derive_user_key_from_email` (LEGACY — breaks for pre-migration users)
24+
//!
25+
//! Originally written assuming all users would be post-migration-011
26+
//! (where `claims.sub == sha256(email).hex()`). That assumption is
27+
//! WRONG for pre-migration users — their JWTs still carry plaintext
28+
//! email as `sub`, and master's `hash_user_id(claims.sub)` therefore
29+
//! produces a different value than the one this function returns.
30+
//! Result: pre-migration users' cold-start lookups always miss.
31+
//!
32+
//! Kept for backward compatibility with apps that have already shipped
33+
//! using it. Apps SHOULD migrate to `derive_user_key_from_jwt_sub`.
34+
//!
35+
//! **Algorithm (legacy, post-migration-only):**
1936
//! ```text
2037
//! email_lower = email.to_lowercase()
2138
//! user_id_digest = sha256(email_lower.as_bytes())
2239
//! user_id_hex = hex(user_id_digest)
23-
//! domain_separated = "fula:user_id:" || user_id_hex
24-
//! user_key = hex( blake3(domain_separated)[..16] )
40+
//! user_key = hex( BLAKE3.derive_key("fula:user_id:", user_id_hex.as_bytes())[..16] )
2541
//! ```
2642
//!
27-
//! Drift here vs. master = silent cold-start failure (master
28-
//! publishes under userKey A, SDK looks up userKey B). The
29-
//! `derive_user_key_matches_master_state_rs_algorithm` test in
30-
//! `registry_resolver.rs` reproduces master's algorithm step-by-step
31-
//! and asserts equality.
43+
//! Both functions are pure (sha2 + blake3 + hex), build cleanly on
44+
//! wasm32, and are exposed through FRB and wasm-bindgen. Master and
45+
//! SDK produce the same userKey when each side uses its correct input.
3246
3347
use sha2::{Digest, Sha256};
3448

35-
/// Compute the canonical fula `userKey` for cold-start config from a
36-
/// plaintext email. Returns 32 hex chars (16-byte BLAKE3 truncated digest).
49+
/// Compute the canonical fula `userKey` directly from a JWT `sub` claim.
50+
///
51+
/// **Use this for cold-start config whenever the app has access to the
52+
/// JWT sub** — which it does at sign-in (the issued token carries it).
53+
///
54+
/// Mirrors master's `crates/fula-cli/src/state.rs::hash_user_id` byte-for-byte:
55+
/// the input bytes go straight into `BLAKE3.derive_key` with no
56+
/// transformation. Returns 32 hex chars (first 16 bytes of the digest).
3757
///
38-
/// Apps call this at sign-in time (the OAuth flow has plaintext email)
39-
/// and pass the returned string into `Config::users_index_user_key`.
40-
/// The SDK never persists or transmits the raw email.
58+
/// Works correctly for BOTH pre-migration users (whose `sub` is plaintext
59+
/// email) AND post-migration users (whose `sub` is `sha256(email).hex()`).
60+
/// The caller passes the JWT sub through unchanged; this function does
61+
/// not normalize, lowercase, or pre-hash.
62+
///
63+
/// Apps should cache the JWT sub at sign-in and pass it to this function
64+
/// when (re-)configuring `Config::users_index_user_key`. The SDK never
65+
/// persists or transmits the raw sub.
66+
pub fn derive_user_key_from_jwt_sub(jwt_sub: &str) -> String {
67+
let mut hasher = blake3::Hasher::new();
68+
hasher.update(b"fula:user_id:");
69+
hasher.update(jwt_sub.as_bytes());
70+
hex::encode(&hasher.finalize().as_bytes()[..16])
71+
}
72+
73+
/// **DEPRECATED — breaks for pre-migration-011 users.** Use
74+
/// [`derive_user_key_from_jwt_sub`] instead and pass the JWT `sub` claim.
75+
///
76+
/// Computes the userKey from a plaintext email by first applying
77+
/// `sha256(email.lowercase()).hex()` and then hashing that hex string
78+
/// via `BLAKE3.derive_key("fula:user_id:", ...)`. Returns 32 hex chars.
79+
///
80+
/// This produces the correct userKey for users whose JWT `sub` is
81+
/// `sha256(email).hex()` (post-migration-011) by accident — the SDK's
82+
/// extra `sha256` step happens to match what master's auth chain
83+
/// already did upstream. For users whose JWT `sub` is plaintext email
84+
/// (pre-migration-011), the SDK applies `sha256` but master does not,
85+
/// and the two derivations diverge.
86+
///
87+
/// Kept for source compatibility with apps that have already shipped
88+
/// using it. Cold-start may fail for pre-migration users; switch to
89+
/// [`derive_user_key_from_jwt_sub`] to fix.
4190
pub fn derive_user_key_from_email(email: &str) -> String {
4291
let user_id_digest = Sha256::digest(email.to_lowercase().as_bytes());
4392
let user_id_hex = hex::encode(user_id_digest);
@@ -46,3 +95,58 @@ pub fn derive_user_key_from_email(email: &str) -> String {
4695
hasher.update(user_id_hex.as_bytes());
4796
hex::encode(&hasher.finalize().as_bytes()[..16])
4897
}
98+
99+
#[cfg(test)]
100+
mod tests {
101+
use super::*;
102+
103+
/// Pinned test vector reproducing the bug observed in production
104+
/// for pre-migration-011 user `ehsan@fx.land`. Master stored
105+
/// `4da2c0616b1d39660f9f94e145fbce4f` (BLAKE3 over the plaintext
106+
/// email), but the SDK was computing
107+
/// `d2df90894e237aa4ef50618e514e0e37` (BLAKE3 over sha256(email)).
108+
/// Cold-start lookup missed because of this mismatch.
109+
///
110+
/// `derive_user_key_from_jwt_sub` with the plaintext email as the
111+
/// argument MUST produce the master-stored value.
112+
#[test]
113+
fn derive_user_key_from_jwt_sub_matches_master_for_plaintext_email_sub() {
114+
let key = derive_user_key_from_jwt_sub("ehsan@fx.land");
115+
assert_eq!(key, "4da2c0616b1d39660f9f94e145fbce4f");
116+
}
117+
118+
/// For post-migration-011 users the JWT sub is `sha256(email).hex()`.
119+
/// Calling `derive_user_key_from_jwt_sub` with that sub must produce
120+
/// the SAME value as the legacy `derive_user_key_from_email(email)`,
121+
/// because `derive_user_key_from_email` does `sha256(email).hex()`
122+
/// internally before hashing — same input bytes flowing into the
123+
/// same `BLAKE3.derive_key("fula:user_id:", ...)` call.
124+
#[test]
125+
fn derive_user_key_from_jwt_sub_matches_legacy_for_sha256_email_sub() {
126+
let email = "ehsan@fx.land";
127+
let sha256_hex = hex::encode(Sha256::digest(email.to_lowercase().as_bytes()));
128+
let from_sub = derive_user_key_from_jwt_sub(&sha256_hex);
129+
let from_email_legacy = derive_user_key_from_email(email);
130+
assert_eq!(from_sub, from_email_legacy);
131+
}
132+
133+
/// Pinned reference for the legacy function. Documents the value
134+
/// it returns for `ehsan@fx.land` so future refactors can't
135+
/// silently change the algorithm.
136+
#[test]
137+
fn derive_user_key_from_email_pinned_value() {
138+
assert_eq!(
139+
derive_user_key_from_email("ehsan@fx.land"),
140+
"d2df90894e237aa4ef50618e514e0e37"
141+
);
142+
}
143+
144+
/// Empty input must not panic (defense-in-depth — the input
145+
/// shouldn't ever be empty in practice, but BLAKE3 must not be
146+
/// called in a way that aborts on edge inputs).
147+
#[test]
148+
fn derive_user_key_from_jwt_sub_empty_does_not_panic() {
149+
let key = derive_user_key_from_jwt_sub("");
150+
assert_eq!(key.len(), 32); // still 32 hex chars (16 bytes)
151+
}
152+
}

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

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -224,21 +224,48 @@ pub fn create_encrypted_client_with_pinning(
224224
}
225225

226226
// ============================================================================
227-
// Phase 3.3 — derive_user_key_from_email
227+
// Phase 3.3 — userKey derivation
228228
// ============================================================================
229229

230-
/// Compute the canonical fula `userKey` for cold-start config from a
231-
/// plaintext email. Mirrors `fula_client::derive_user_key_from_email`
232-
/// — same domain separator, same hash chain (sha256(lower(email))
233-
/// → BLAKE3("fula:user_id:" || _).bytes[..16] → hex-encode).
230+
/// **PREFERRED** — derive the canonical fula `userKey` directly from a
231+
/// JWT `sub` claim. Mirrors `fula_client::derive_user_key_from_jwt_sub`.
234232
///
235-
/// Apps call this once at sign-in (the OAuth flow has plaintext
236-
/// email), then set `FulaConfig::users_index_user_key` to the
237-
/// returned string. The SDK never sees the raw email.
233+
/// Use this whenever the app has access to the JWT (which is at every
234+
/// sign-in — the issued token carries the sub). Works correctly for
235+
/// BOTH pre-migration-011 users (sub = plaintext email) and modern
236+
/// users (sub = sha256(email).hex()), because master's
237+
/// `state.rs::hash_user_id` does not transform the sub before hashing
238+
/// — and this function does not transform either.
238239
///
239-
/// Native-only — wasm32 surfaces this via the JS-side `deriveKey`
240-
/// helper because the cold-start resolver (Phase 3.3) itself isn't
241-
/// wired on wasm.
240+
/// Apps should cache the JWT sub at sign-in and pass it here whenever
241+
/// (re-)setting `FulaConfig::users_index_user_key`. The SDK never sees
242+
/// the raw email.
243+
#[cfg(not(target_arch = "wasm32"))]
244+
pub fn derive_user_key_from_jwt_sub(jwt_sub: String) -> String {
245+
fula_client::derive_user_key_from_jwt_sub(&jwt_sub)
246+
}
247+
248+
#[cfg(target_arch = "wasm32")]
249+
pub fn derive_user_key_from_jwt_sub(_jwt_sub: String) -> String {
250+
// wasm32 doesn't run the cold-start resolver natively; return an
251+
// empty key so resolver self-disables. Mirrors the wasm stub for
252+
// `derive_user_key_from_email` below.
253+
String::new()
254+
}
255+
256+
/// **DEPRECATED — broken for pre-migration-011 users.** Use
257+
/// [`derive_user_key_from_jwt_sub`] instead.
258+
///
259+
/// Apps that have already shipped using this function continue to
260+
/// work for post-migration users by accident (the SDK's internal
261+
/// `sha256(email)` step happens to match what those users' JWT sub
262+
/// already is). Pre-migration users hit a silent cold-start failure
263+
/// because their JWT sub is plaintext email and master hashes it
264+
/// without the sha256 step.
265+
///
266+
/// Same wire format and length (32 hex chars) as
267+
/// `derive_user_key_from_jwt_sub`; switching the call is a one-line
268+
/// app change.
242269
#[cfg(not(target_arch = "wasm32"))]
243270
pub fn derive_user_key_from_email(email: String) -> String {
244271
fula_client::derive_user_key_from_email(&email)

crates/fula-js/src/lib.rs

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1058,20 +1058,38 @@ pub async fn is_flat_namespace(client: &EncryptedClient) -> bool {
10581058
// Phase 3.3 — userKey derivation
10591059
// ============================================================================
10601060

1061-
/// Compute the canonical fula `userKey` for cold-start config from a
1062-
/// plaintext email. Mirrors `fula_client::derive_user_key_from_email`
1063-
/// — same domain separator + double-hash chain (sha256(lower(email))
1064-
/// → BLAKE3("fula:user_id:" || _).bytes[..16] → hex).
1061+
/// **PREFERRED** — derive the canonical fula `userKey` directly from a
1062+
/// JWT `sub` claim. Mirrors `fula_client::derive_user_key_from_jwt_sub`
1063+
/// and matches master's `state.rs::hash_user_id` byte-for-byte: the
1064+
/// JWT sub bytes feed straight into `BLAKE3.derive_key`, no
1065+
/// transformation.
10651066
///
1066-
/// Apps call this once at sign-in (the OAuth flow has plaintext
1067-
/// email), then set `users_index_user_key` on the config object
1068-
/// passed to `createEncryptedClient`. The SDK never persists or
1069-
/// transmits the raw email.
1067+
/// Works correctly for BOTH pre-migration-011 users (sub = plaintext
1068+
/// email) and post-migration users (sub = sha256(email).hex()). Apps
1069+
/// should cache the JWT sub at sign-in and pass it here whenever
1070+
/// (re-)setting `users_index_user_key`. The SDK never sees the raw
1071+
/// email.
10701072
///
1071-
/// On wasm32 the cold-start RESOLVER itself isn't wired (it depends
1072-
/// on reqwest + parking_lot which aren't compiled for browsers), so
1073-
/// this helper is exposed for API symmetry — apps can compute the
1074-
/// userKey on web for sharing across native + web identity flows.
1073+
/// Use this in preference to `deriveUserKeyFromEmail` — the email
1074+
/// variant is broken for pre-migration users.
1075+
#[wasm_bindgen(js_name = deriveUserKeyFromJwtSub)]
1076+
pub fn derive_user_key_from_jwt_sub(jwt_sub: String) -> String {
1077+
fula_client::derive_user_key_from_jwt_sub(&jwt_sub)
1078+
}
1079+
1080+
/// **DEPRECATED — broken for pre-migration-011 users.** Use
1081+
/// `deriveUserKeyFromJwtSub` instead.
1082+
///
1083+
/// Computes the userKey by first applying `sha256(email.lowercase())`
1084+
/// before BLAKE3. This happens to match master ONLY for
1085+
/// post-migration users whose JWT sub is itself `sha256(email).hex()`.
1086+
/// For pre-migration users whose JWT sub is plaintext email, master's
1087+
/// derivation skips the sha256 step and the two values diverge —
1088+
/// silent cold-start failure.
1089+
///
1090+
/// On wasm32 the cold-start RESOLVER isn't wired (depends on reqwest
1091+
/// + parking_lot which aren't compiled for browsers), so this helper
1092+
/// remains exposed for API symmetry with the native binding.
10751093
#[wasm_bindgen(js_name = deriveUserKeyFromEmail)]
10761094
pub fn derive_user_key_from_email(email: String) -> String {
10771095
fula_client::derive_user_key_from_email(&email)

0 commit comments

Comments
 (0)