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
3347use 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.
4190pub 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+ }
0 commit comments