Skip to main content

beam/sea/
user.rs

1#![allow(clippy::await_holding_lock)] // TODO: Refactor in SEA review pass — extract data from MutexGuard before await
2#![allow(deprecated)]
3//! User authentication and session management
4//! Provides create/auth/leave/recall using BEAM's graph persistence
5
6use super::{
7    KeyPair, SeaError, SessionState, SessionStorage, User, WorkOptions, certify, decrypt,
8    decrypt_symmetric, encrypt, encrypt_symmetric, generate_pair, is_pubkey_certified, secret,
9    sign_value, verify_certificate, work,
10};
11use crate::{Node, Value as BeamValue};
12use aes_gcm::{
13    Aes256Gcm, Nonce,
14    aead::{Aead, KeyInit},
15};
16use rand::RngCore;
17use serde_json::{Value as JsonValue, json};
18
19impl User {
20    /// Create a new user with alias and password.
21    /// Stores encrypted key pair in BEAM's graph at `~@alias`.
22    pub async fn create(alias: &str, pass: &str, db: &mut Node) -> Result<Self, SeaError> {
23        let pair = generate_pair().await?;
24
25        // Derive proof from alias + password
26        let proof_input = format!("{}{}", alias, pass);
27        // Generate random 9-byte salt per Gun.js convention
28        let mut salt_bytes = [0u8; 9];
29        rand::rng().fill_bytes(&mut salt_bytes);
30
31        let proof = work(
32            proof_input.as_bytes(),
33            Some(&salt_bytes),
34            WorkOptions::default(),
35        )
36        .await?;
37        // Build key pair data to encrypt
38        let auth_data = json!({
39            "pub": pair.pub_key,
40            "priv": pair.priv_key,
41            "epub": pair.epub_key,
42            "epriv": pair.epriv_key,
43        });
44
45        // Encrypt auth data with proof
46        let encrypted_auth = encrypt_pass(&auth_data, &proof).await?;
47
48        // Store in BEAM at ~@alias as JSON text
49        let alias_payload = json!({
50            "pub": pair.pub_key,
51            "epub": pair.epub_key,
52            "auth": encrypted_auth,
53            "salt": BASE64_URL_SAFE_NO_PAD.encode(salt_bytes),
54        });
55        let mut alias_node = db.get("~@").get(alias);
56        let _ = alias_node
57            .put(BeamValue::Text(alias_payload.to_string()))
58            .await;
59
60        let state = SessionState {
61            pair,
62            alias: Some(alias.to_string()),
63            is_authenticated: true,
64        };
65        Ok(User::from_state(state))
66    }
67
68    /// Authenticate existing user from BEAM's graph.
69    pub async fn auth(alias: &str, pass: &str, db: &mut Node) -> Result<Self, SeaError> {
70        let mut alias_node = db.get("~@").get(alias);
71        let value = alias_node.once(None).await.ok_or(SeaError::AuthFailed)?;
72
73        let text = match value {
74            BeamValue::Text(t) => t,
75            _ => return Err(SeaError::AuthFailed),
76        };
77
78        let alias_data: JsonValue =
79            serde_json::from_str(&text).map_err(|_| SeaError::AuthFailed)?;
80
81        let encrypted_auth = alias_data.get("auth").ok_or(SeaError::AuthFailed)?;
82
83        let salt_decoded = if let Some(salt_b64) = alias_data.get("salt").and_then(|v| v.as_str()) {
84            BASE64_URL_SAFE_NO_PAD
85                .decode(salt_b64)
86                .unwrap_or_else(|_| alias.as_bytes().to_vec())
87        } else {
88            alias.as_bytes().to_vec()
89        };
90
91        let proof_input = format!("{}{}", alias, pass);
92        let proof = work(
93            proof_input.as_bytes(),
94            Some(&salt_decoded),
95            WorkOptions::default(),
96        )
97        .await?;
98        let decrypted = decrypt_pass(encrypted_auth, &proof).await?;
99
100        let pair = KeyPair {
101            pub_key: decrypted
102                .get("pub")
103                .and_then(|v| v.as_str())
104                .unwrap_or_default()
105                .to_string(),
106            priv_key: decrypted
107                .get("priv")
108                .and_then(|v| v.as_str())
109                .unwrap_or_default()
110                .to_string(),
111            epub_key: decrypted
112                .get("epub")
113                .and_then(|v| v.as_str())
114                .map(|s| s.to_string()),
115            epriv_key: decrypted
116                .get("epriv")
117                .and_then(|v| v.as_str())
118                .map(|s| s.to_string()),
119        };
120
121        let state = SessionState {
122            pair,
123            alias: Some(alias.to_string()),
124            is_authenticated: true,
125        };
126        Ok(User::from_state(state))
127    }
128
129    /// Create user directly from existing key pair.
130    pub fn from_pair(pair: KeyPair, alias: Option<&str>) -> Self {
131        let state = SessionState {
132            pair,
133            alias: alias.map(|s| s.to_string()),
134            is_authenticated: true,
135        };
136        User::from_state(state)
137    }
138
139    /// Recall user from session storage.
140    pub async fn recall(alias: &str, storage: &dyn SessionStorage) -> Result<Self, SeaError> {
141        let pair = storage
142            .load(alias)
143            .await
144            .map_err(|e| SeaError::SessionStorage(format!("{}", e)))?;
145
146        if let Some(pair) = pair {
147            let state = SessionState {
148                pair,
149                alias: Some(alias.to_string()),
150                is_authenticated: true,
151            };
152            Ok(User::from_state(state))
153        } else {
154            Err(SeaError::AuthFailed)
155        }
156    }
157
158    /// Save current session to storage.
159    ///
160    /// Note: clones the [`KeyPair`] and alias out of the session lock
161    /// before awaiting `storage.save(...)` to keep the future `Send`.
162    /// Holding the `std::sync::RwLock` read guard across an `.await`
163    /// would make the returned future non-`Send` (the std guard is not
164    /// `Send`), which breaks callers that need to await auth flows
165    /// while holding a tokio lock (e.g. the MCP `identity_auth` tool).
166    pub async fn save_to(&self, storage: &dyn SessionStorage) -> Result<(), SeaError> {
167        let (alias, pair) = {
168            let inner = self
169                .inner
170                .read()
171                .map_err(|_| SeaError::SessionStorage("lock poisoned".to_string()))?;
172            if !inner.is_authenticated {
173                return Err(SeaError::NotAuthenticated);
174            }
175            let alias = inner.alias.clone().ok_or(SeaError::NotAuthenticated)?;
176            let pair = inner.pair.clone();
177            (alias, pair)
178        }; // guard dropped here, before any await
179        storage.save(&alias, &pair).await
180    }
181}
182
183// --- Passphrase-based AES-GCM helpers (no KeyPair needed) ---
184
185async fn encrypt_pass(data: &JsonValue, passphrase: &str) -> Result<JsonValue, SeaError> {
186    let data = data.clone();
187    let passphrase = passphrase.to_string();
188
189    tokio::task::spawn_blocking(move || {
190        let msg = serde_json::to_string(&data)
191            .map_err(|e| SeaError::Encryption(format!("serialization: {}", e)))?;
192
193        let mut salt = [0u8; 9];
194        let mut nonce = [0u8; 12];
195        rand::rng().fill_bytes(&mut salt);
196        rand::rng().fill_bytes(&mut nonce);
197
198        let aes_key = derive_key_sync(&passphrase, &salt)?;
199
200        let cipher = Aes256Gcm::new_from_slice(&aes_key)
201            .map_err(|e| SeaError::Encryption(format!("cipher: {}", e)))?;
202
203        let ciphertext = cipher
204            .encrypt(Nonce::from_slice(&nonce), msg.as_bytes())
205            .map_err(|e| SeaError::Encryption(format!("encrypt: {}", e)))?;
206
207        Ok(json!({
208            "ct": BASE64_URL_SAFE_NO_PAD.encode(&ciphertext),
209            "iv": BASE64_URL_SAFE_NO_PAD.encode(nonce),
210            "s": BASE64_URL_SAFE_NO_PAD.encode(salt),
211        }))
212    })
213    .await
214    .map_err(|e| SeaError::Crypto(format!("task join error: {}", e)))?
215}
216
217async fn decrypt_pass(encrypted: &JsonValue, passphrase: &str) -> Result<JsonValue, SeaError> {
218    let encrypted = encrypted.clone();
219    let passphrase = passphrase.to_string();
220
221    tokio::task::spawn_blocking(move || {
222        let ct = encrypted
223            .get("ct")
224            .and_then(|v| v.as_str())
225            .ok_or_else(|| SeaError::Decryption("missing ct".to_string()))?;
226        let iv = encrypted
227            .get("iv")
228            .and_then(|v| v.as_str())
229            .ok_or_else(|| SeaError::Decryption("missing iv".to_string()))?;
230        let s = encrypted
231            .get("s")
232            .and_then(|v| v.as_str())
233            .ok_or_else(|| SeaError::Decryption("missing s".to_string()))?;
234
235        let ciphertext = BASE64_URL_SAFE_NO_PAD
236            .decode(ct)
237            .map_err(|_| SeaError::Decryption("bad ct".to_string()))?;
238        let nonce = BASE64_URL_SAFE_NO_PAD
239            .decode(iv)
240            .map_err(|_| SeaError::Decryption("bad iv".to_string()))?;
241        let salt = BASE64_URL_SAFE_NO_PAD
242            .decode(s)
243            .map_err(|_| SeaError::Decryption("bad s".to_string()))?;
244
245        let aes_key = derive_key_sync(&passphrase, &salt)?;
246
247        let cipher = Aes256Gcm::new_from_slice(&aes_key)
248            .map_err(|e| SeaError::Decryption(format!("cipher: {}", e)))?;
249
250        let plaintext = cipher
251            .decrypt(Nonce::from_slice(&nonce), ciphertext.as_ref())
252            .map_err(|_| SeaError::Decryption("bad passphrase or tampered".to_string()))?;
253
254        let text = String::from_utf8(plaintext)
255            .map_err(|_| SeaError::Decryption("bad utf8".to_string()))?;
256
257        serde_json::from_str(&text).map_err(|e| SeaError::Decryption(format!("bad json: {}", e)))
258    })
259    .await
260    .map_err(|e| SeaError::Crypto(format!("task join error: {}", e)))?
261}
262
263// Passphrase-based key derivation uses the shared `derive_aes_key_sync` from
264// `encrypt.rs` — SHA-256(key_string + salt_utf8), matching Gun.js aeskey.js.
265use super::encrypt::derive_aes_key_sync as derive_key_sync;
266use base64::prelude::*;
267
268/// Builder for creating or authenticating users from a Node
269pub struct UserBuilder<'a> {
270    node: &'a mut Node,
271}
272
273impl Node {
274    /// Begin user creation or authentication on this node
275    pub fn user(&mut self) -> UserBuilder<'_> {
276        UserBuilder { node: self }
277    }
278}
279
280impl<'a> UserBuilder<'a> {
281    /// Create a new user with alias and password
282    pub async fn create(self, alias: &str, pass: &str) -> Result<User, SeaError> {
283        User::create(alias, pass, self.node).await
284    }
285
286    /// Authenticate an existing user with alias and password
287    pub async fn auth(self, alias: &str, pass: &str) -> Result<User, SeaError> {
288        User::auth(alias, pass, self.node).await
289    }
290}
291
292// ─── Social primitives: trust, grant, verify ───
293
294/// Encode a path string for safe use as a graph key.
295/// Replaces `/` with `__` to avoid segment collision in BEAM's key-path graph.
296fn encode_path(path: &str) -> String {
297    path.replace('/', "__")
298}
299
300impl User {
301    /// Delegate write trust to a recipient for an optional path.
302    /// Stores a capability certificate at `~{pub}/trust/{path}`.
303    pub async fn trust(
304        &self,
305        recipient_pubkey: &str,
306        path: Option<&str>,
307        db: &mut Node,
308    ) -> Result<(), SeaError> {
309        let inner = self
310            .inner
311            .read()
312            .map_err(|_| SeaError::SessionStorage("lock poisoned".to_string()))?;
313        if !inner.is_authenticated {
314            return Err(SeaError::NotAuthenticated);
315        }
316
317        let certificants = vec![recipient_pubkey.to_string()];
318        let policies = path.map(|p| json!({"w": p}));
319        let signed = certify(&certificants, policies.as_ref(), &inner.pair).await?;
320
321        let path_key = path
322            .map(encode_path)
323            .unwrap_or_else(|| "global".to_string());
324        let mut trust_node = db
325            .get(&format!("~{}", inner.pair.pub_key))
326            .get("trust")
327            .get(&path_key);
328        let _ = trust_node.put(BeamValue::Text(signed.to_string())).await;
329
330        Ok(())
331    }
332
333    /// Grant a recipient access to decrypt data at a path.
334    /// Stores signed ECDH-encrypted copies at `~{pub}/grant/{path}/{recipient}` and `~{pub}/grant/{path}/{my_pub}`.
335    pub async fn grant(
336        &self,
337        recipient_pubkey: &str,
338        recipient_epub: &str,
339        data_path: &str,
340        db: &mut Node,
341    ) -> Result<(), SeaError> {
342        let inner = self
343            .inner
344            .read()
345            .map_err(|_| SeaError::SessionStorage("lock poisoned".to_string()))?;
346        if !inner.is_authenticated {
347            return Err(SeaError::NotAuthenticated);
348        }
349        let pair = &inner.pair;
350        let path_key = encode_path(data_path);
351
352        // 1. Retrieve or create a 16-byte random secret for this data path
353        let sec = {
354            let mut secret_node = db
355                .get(&format!("~{}", pair.pub_key))
356                .get("secrets")
357                .get(&path_key);
358            match secret_node.once(None).await {
359                Some(BeamValue::Text(enc_text)) => {
360                    let outer: JsonValue = serde_json::from_str(&enc_text)
361                        .map_err(|e| SeaError::Decryption(format!("bad secret json: {}", e)))?;
362                    let enc = if outer.get("m").is_some() && outer.get("s").is_some() {
363                        let msg = outer["m"]
364                            .as_str()
365                            .ok_or_else(|| SeaError::Decryption("m not string".into()))?;
366                        serde_json::from_str(msg)
367                            .map_err(|e| SeaError::Decryption(format!("bad m: {}", e)))?
368                    } else {
369                        outer
370                    };
371                    decrypt(&enc, pair, None)
372                        .await?
373                        .as_str()
374                        .ok_or_else(|| SeaError::Decryption("secret not string".into()))?
375                        .to_string()
376                }
377                _ => {
378                    let mut bytes = [0u8; 16];
379                    rand::rng().fill_bytes(&mut bytes);
380                    let new_sec = BASE64_URL_SAFE_NO_PAD.encode(bytes);
381                    let enc = encrypt(&json!(new_sec), pair, None).await?;
382                    let signed = sign_value(&enc, pair).await?;
383                    let _ = secret_node.put(signed).await;
384                    new_sec
385                }
386            }
387        };
388
389        // 2. ECDH shared secret with recipient
390        let dh = secret(recipient_epub, pair).await?;
391        let dh_bytes = BASE64_URL_SAFE_NO_PAD
392            .decode(&dh)
393            .map_err(|_| SeaError::Crypto("bad dh".into()))?;
394
395        // 3. Encrypt data secret with shared secret
396        let enc_for_recipient = encrypt_symmetric(&json!(sec), &dh_bytes).await?;
397        let signed_recipient = sign_value(&enc_for_recipient, pair).await?;
398
399        // 4a. Store recipient copy
400        let mut grant_node = db
401            .get(&format!("~{}", pair.pub_key))
402            .get("grant")
403            .get(&path_key)
404            .get(recipient_pubkey);
405        let _ = grant_node.put(signed_recipient).await;
406
407        // 4b. Store owner backup (self-encrypted)
408        let enc_for_owner = encrypt(&json!(sec), pair, None).await?;
409        let signed_owner = sign_value(&enc_for_owner, pair).await?;
410        let mut owner_grant_node = db
411            .get(&format!("~{}", pair.pub_key))
412            .get("grant")
413            .get(&path_key)
414            .get(&pair.pub_key);
415        let _ = owner_grant_node.put(signed_owner).await;
416
417        Ok(())
418    }
419
420    /// Encrypt and store data under a self-derived symmetric key.
421    /// Only this user can decrypt it — the key comes from ECDH(self.pub, self.priv).
422    /// Stored at `~{pub}/secret/{path_key}` as a signed payload.
423    pub async fn secret(
424        &self,
425        data: &JsonValue,
426        path: &str,
427        db: &mut Node,
428    ) -> Result<(), SeaError> {
429        let inner = self
430            .inner
431            .read()
432            .map_err(|_| SeaError::SessionStorage("lock poisoned".to_string()))?;
433        if !inner.is_authenticated {
434            return Err(SeaError::NotAuthenticated);
435        }
436        let pair = &inner.pair;
437        let path_key = encode_path(path);
438        let user_root = format!("~{}", pair.pub_key);
439
440        // Derive self-symmetric key: ECDH with own ephemeral public key
441        let epub = pair
442            .epub_key
443            .as_ref()
444            .ok_or_else(|| SeaError::Crypto("no epub".into()))?;
445        let dh = secret(epub, pair).await?;
446        let dh_bytes = BASE64_URL_SAFE_NO_PAD
447            .decode(&dh)
448            .map_err(|_| SeaError::Crypto("bad dh".into()))?;
449
450        // Encrypt and sign
451        let enc = encrypt_symmetric(data, &dh_bytes).await?;
452        let signed = sign_value(&enc, pair).await?;
453
454        let mut secret_node = db.get(&user_root).get("secret").get(&path_key);
455        let _ = secret_node.put(signed).await;
456
457        Ok(())
458    }
459}
460
461/// Verify a trust certificate from the graph.
462/// Returns true if `writer_pubkey` is certified by `authority_pubkey` for the given path.
463pub async fn verify_trust(
464    authority_pubkey: &str,
465    writer_pubkey: &str,
466    path: Option<&str>,
467    db: &mut Node,
468) -> Result<bool, SeaError> {
469    let path_key = path
470        .map(encode_path)
471        .unwrap_or_else(|| "global".to_string());
472    let mut trust_node = db
473        .get(&format!("~{}", authority_pubkey))
474        .get("trust")
475        .get(&path_key);
476
477    let cert_text = match trust_node.once(None).await {
478        Some(BeamValue::Text(t)) => t,
479        _ => return Ok(false),
480    };
481
482    let cert: JsonValue =
483        serde_json::from_str(&cert_text).map_err(|_| SeaError::VerificationFailed)?;
484
485    let payload =
486        verify_certificate(&cert, authority_pubkey).map_err(|_| SeaError::VerificationFailed)?;
487
488    Ok(is_pubkey_certified(&payload, writer_pubkey))
489}
490
491/// Accept a grant and return the shared decryption secret for a data path.
492pub async fn accept_grant(
493    data_path: &str,
494    owner_pubkey: &str,
495    owner_epub: &str,
496    pair: &KeyPair,
497    db: &mut Node,
498) -> Result<String, SeaError> {
499    let path_key = encode_path(data_path);
500    let mut grant_node = db
501        .get(&format!("~{}", owner_pubkey))
502        .get("grant")
503        .get(&path_key)
504        .get(&pair.pub_key);
505
506    let enc_text = grant_node
507        .once(None)
508        .await
509        .and_then(|v| match v {
510            BeamValue::Text(t) => Some(t),
511            _ => None,
512        })
513        .ok_or_else(|| SeaError::Decryption("no grant found".into()))?;
514
515    let outer: JsonValue = serde_json::from_str(&enc_text)
516        .map_err(|e| SeaError::Decryption(format!("bad grant json: {}", e)))?;
517    let enc_json = if outer.get("m").is_some() && outer.get("s").is_some() {
518        let msg = outer["m"]
519            .as_str()
520            .ok_or_else(|| SeaError::Decryption("m not string".into()))?;
521        serde_json::from_str(msg).map_err(|e| SeaError::Decryption(format!("bad m: {}", e)))?
522    } else {
523        outer
524    };
525
526    let dh = secret(owner_epub, pair).await?;
527    let dh_bytes = BASE64_URL_SAFE_NO_PAD
528        .decode(&dh)
529        .map_err(|_| SeaError::Decryption("bad dh".into()))?;
530
531    let sec_json = decrypt_symmetric(&enc_json, &dh_bytes).await?;
532    let sec = sec_json
533        .as_str()
534        .ok_or_else(|| SeaError::Decryption("secret not string".into()))?;
535    Ok(sec.to_string())
536}