//! PersonaService — the SoulStream identity, held by the bundled Freenet //! identity delegate and reached only through the FreenetAdapter. //! //! The delegate wasm ships as an app asset (`assets/identity_delegate.wasm`, //! sha256 84dd8dbb…, the generation every F1–F2b spike validated). Its key //! namespace is (code hash, params), so THESE BYTES ARE A COMMITMENT: //! changing them re-keys the delegate and strands every user's persona — //! and the platform's copy-forward is disabled (GHSA-824h-7x5x-wfmf), so //! the only migration is export-artifact → new generation → import, //! verified by reading the identity back (F1's record). Treat any change //! as a migration event, never a routine rebuild. //! //! Privilege rules, same as everywhere on this boundary: //! - the PRIVATE key never leaves the delegate; what crosses to the webview //! is the public key, the key version, and status strings; //! - the passphrase-sealed recovery artifact is written/read by RUST at a //! caller-chosen path — its bytes never transit the webview; //! - signing stays Rust-internal (`sign` is pub(crate) for future //! Community services, not a Tauri command). //! //! Wire pin: `InMsg`/`OutMsg` are bincode enums whose VARIANT ORDER must //! match research/freenet-f1/identity-delegate/src/lib.rs exactly — bincode //! encodes the variant index, so reordering is a silent protocol break. use freenet_adapter::FreenetAdapter; use serde::{Deserialize, Serialize}; use crate::state::AppState; const DELEGATE_WASM: &[u8] = include_bytes!("../assets/identity_delegate.wasm"); const DELEGATE_PARAMS: &[u8] = b"soulstream-identity-v1"; /// Recovery-artifact KDF defaults: Argon2id inside the delegate's wasm. /// 64 MiB / 3 passes measured comfortably inside the delegate's caps /// (8–64 MiB, t ≤ 8 — F1). The artifact itself is 93 bytes. const EXPORT_M_COST_KIB: u32 = 64 * 1024; const EXPORT_T_COST: u32 = 3; // ---- delegate protocol (bincode; variant order is the wire) ---- #[derive(Debug, Serialize, Deserialize)] enum InMsg { CreateIdentity { force: bool }, GetPublicKey, Sign { domain: String, payload: Vec }, ExportIdentity { passphrase: String, m_cost_kib: u32, t_cost: u32 }, ImportIdentity { artifact: Vec, passphrase: String, force: bool }, RotateIdentity, GetTransition, RemoveIdentity, } #[derive(Debug, Serialize, Deserialize)] enum OutMsg { Identity { public_key: [u8; 32], key_version: u32, created: bool }, NotInitialized, Signature { public_key: [u8; 32], key_version: u32, sig: Vec }, Artifact { bytes: Vec }, Rotated { old_public: [u8; 32], new_public: [u8; 32], key_version: u32, transition_sig: Vec }, Transition { old_public: [u8; 32], new_public: [u8; 32], key_version: u32, transition_sig: Vec }, NoTransition, Removed, Refused { reason: String }, } // ---- public shapes (webview-safe: public data only) ---- #[derive(Debug, Clone, Serialize)] pub struct PersonaInfo { pub public_key: String, pub key_version: u32, } #[derive(Debug, Serialize)] pub struct PersonaStatus { /// Adapter reachable and enabled — persona ops can run at all. pub available: bool, pub adapter: freenet_adapter::AdapterStatus, /// None when available but no identity exists yet. pub persona: Option, /// This build's delegate generation (b58 key) — diagnostics, and the /// value that must not drift between releases. pub delegate_key: String, } #[derive(Debug, Serialize)] pub struct RotationInfo { pub old_public: String, pub new_public: String, pub key_version: u32, } // ---- core (adapter-facing, testable without Tauri) ---- async fn call(adapter: &FreenetAdapter, msg: InMsg) -> Result { // Registration is idempotent and cheap; doing it per-op means the // service self-heals across node restarts and adapter reconnects. adapter .delegate_register(DELEGATE_WASM.to_vec(), DELEGATE_PARAMS) .await .map_err(|e| e.to_string())?; let payload = bincode::serialize(&msg).map_err(|e| e.to_string())?; let reply = adapter .delegate_message(DELEGATE_WASM.to_vec(), DELEGATE_PARAMS, payload) .await .map_err(|e| e.to_string())?; bincode::deserialize(&reply).map_err(|e| format!("delegate reply did not decode: {e}")) } fn info(public_key: [u8; 32], key_version: u32) -> PersonaInfo { PersonaInfo { public_key: bs58::encode(public_key).into_string(), key_version, } } pub(crate) async fn current(adapter: &FreenetAdapter) -> Result, String> { match call(adapter, InMsg::GetPublicKey).await? { OutMsg::Identity { public_key, key_version, .. } => Ok(Some(info(public_key, key_version))), OutMsg::NotInitialized => Ok(None), OutMsg::Refused { reason } => Err(reason), other => Err(format!("unexpected delegate reply: {other:?}")), } } pub(crate) async fn create(adapter: &FreenetAdapter) -> Result { match call(adapter, InMsg::CreateIdentity { force: false }).await? { OutMsg::Identity { public_key, key_version, .. } => Ok(info(public_key, key_version)), OutMsg::Refused { reason } => Err(reason), other => Err(format!("unexpected delegate reply: {other:?}")), } } /// Seal the identity to a passphrase and write the artifact at `path`. /// The bytes go disk-direct from Rust; nothing crosses the webview. pub(crate) async fn export_to( adapter: &FreenetAdapter, passphrase: &str, path: &str, ) -> Result { match call( adapter, InMsg::ExportIdentity { passphrase: passphrase.to_string(), m_cost_kib: EXPORT_M_COST_KIB, t_cost: EXPORT_T_COST, }, ) .await? { OutMsg::Artifact { bytes } => { let n = bytes.len(); std::fs::write(path, bytes).map_err(|e| format!("writing artifact: {e}"))?; Ok(n) } OutMsg::NotInitialized => Err("no persona exists yet".into()), OutMsg::Refused { reason } => Err(reason), other => Err(format!("unexpected delegate reply: {other:?}")), } } /// Restore from a recovery artifact file. Refuses to overwrite an existing /// persona unless `force` — same trust-reset semantics the F1 record set. pub(crate) async fn import_from( adapter: &FreenetAdapter, passphrase: &str, path: &str, force: bool, ) -> Result { let artifact = std::fs::read(path).map_err(|e| format!("reading artifact: {e}"))?; match call( adapter, InMsg::ImportIdentity { artifact, passphrase: passphrase.to_string(), force }, ) .await? { OutMsg::Identity { public_key, key_version, .. } => Ok(info(public_key, key_version)), OutMsg::Refused { reason } => Err(reason), other => Err(format!("unexpected delegate reply: {other:?}")), } } pub(crate) async fn rotate(adapter: &FreenetAdapter) -> Result { match call(adapter, InMsg::RotateIdentity).await? { OutMsg::Rotated { old_public, new_public, key_version, .. } => Ok(RotationInfo { old_public: bs58::encode(old_public).into_string(), new_public: bs58::encode(new_public).into_string(), key_version, }), OutMsg::NotInitialized => Err("no persona exists yet".into()), OutMsg::Refused { reason } => Err(reason), other => Err(format!("unexpected delegate reply: {other:?}")), } } pub(crate) async fn remove(adapter: &FreenetAdapter) -> Result<(), String> { match call(adapter, InMsg::RemoveIdentity).await? { OutMsg::Removed => Ok(()), OutMsg::Refused { reason } => Err(reason), other => Err(format!("unexpected delegate reply: {other:?}")), } } /// Domain-separated signing for the Rust services (profiles today; /// playlists, links next). Deliberately not a Tauri command. pub(crate) async fn sign( adapter: &FreenetAdapter, domain: &str, payload: Vec, ) -> Result, String> { match call(adapter, InMsg::Sign { domain: domain.to_string(), payload }).await? { OutMsg::Signature { sig, .. } => Ok(sig), OutMsg::NotInitialized => Err("no persona exists yet".into()), OutMsg::Refused { reason } => Err(reason), other => Err(format!("unexpected delegate reply: {other:?}")), } } // ---- Tauri surface ---- #[tauri::command] pub async fn persona_status(state: tauri::State<'_, AppState>) -> Result { let adapter = state.freenet.clone(); let delegate_key = FreenetAdapter::delegate_key_b58(DELEGATE_WASM.to_vec(), DELEGATE_PARAMS); let adapter_status = adapter.status(); let available = adapter.is_enabled() && adapter_status == freenet_adapter::AdapterStatus::Connected; let persona = if available { current(&adapter).await.ok().flatten() } else { None }; Ok(PersonaStatus { available, adapter: adapter_status, persona, delegate_key }) } #[tauri::command] pub async fn persona_create( app: tauri::AppHandle, state: tauri::State<'_, AppState>, ) -> Result { let info = create(&state.freenet.clone()).await?; // Minted here: the profile ledger knows nothing can exist on the // network yet, so no search is ever spent looking for it. crate::profile::ledger_note_persona(&app, &info.public_key, true); Ok(info) } #[tauri::command] pub async fn persona_export( state: tauri::State<'_, AppState>, passphrase: String, path: String, ) -> Result { export_to(&state.freenet.clone(), &passphrase, &path).await } #[tauri::command] pub async fn persona_import( app: tauri::AppHandle, state: tauri::State<'_, AppState>, passphrase: String, path: String, force: bool, ) -> Result { let info = import_from(&state.freenet.clone(), &passphrase, &path, force).await?; // Imported: another device may have published — the first read asks. crate::profile::ledger_note_persona(&app, &info.public_key, false); Ok(info) } #[tauri::command] pub async fn persona_rotate( app: tauri::AppHandle, state: tauri::State<'_, AppState>, ) -> Result { let info = rotate(&state.freenet.clone()).await?; // A new key addresses a new profile contract: nothing published there yet. crate::profile::ledger_note_persona(&app, &info.new_public, true); crate::profile::ledger_forget_persona(&app, &info.old_public); Ok(info) } #[tauri::command] pub async fn persona_remove( app: tauri::AppHandle, state: tauri::State<'_, AppState>, ) -> Result<(), String> { let adapter = state.freenet.clone(); let gone = current(&adapter).await.ok().flatten(); remove(&adapter).await?; if let Some(info) = gone { crate::profile::ledger_forget_persona(&app, &info.public_key); } Ok(()) } // ---- tests ---- #[cfg(test)] mod tests { use super::*; #[test] fn wire_shapes_roundtrip() { // The variant ORDER is the wire; this pins encode/decode symmetry // for the message we depend on most. let msg = InMsg::ImportIdentity { artifact: vec![1, 2, 3], passphrase: "pw".into(), force: true, }; let bytes = bincode::serialize(&msg).unwrap(); // Variant index 4 (0-based) leads the encoding as a u32. assert_eq!(&bytes[..4], &4u32.to_le_bytes()); let out = OutMsg::Identity { public_key: [7; 32], key_version: 3, created: false }; let bytes = bincode::serialize(&out).unwrap(); let back: OutMsg = bincode::deserialize(&bytes).unwrap(); assert!(matches!(back, OutMsg::Identity { key_version: 3, .. })); } #[test] fn bundled_delegate_generation_is_pinned() { // If this changes, a migration event is happening — see the module // docs. Update deliberately, never in passing. let key = FreenetAdapter::delegate_key_b58(DELEGATE_WASM.to_vec(), DELEGATE_PARAMS); // The gen-2 identity delegate — the generation every F2→F2b spike // validated (research/freenet-f1's upgrade probe minted it). assert_eq!(key, "EtHTvPUtxbZ8TXGZJrmkj21xjQNqVmWdWAohVgydBgst"); assert_eq!(DELEGATE_WASM.len(), 202026); } #[tokio::test] async fn ops_refuse_while_disabled() { let adapter = FreenetAdapter::default(); let err = current(&adapter).await.unwrap_err(); assert!(err.contains("disabled"), "{err}"); } /// Live: needs a local freenet node on 7509. /// cargo test -p app --release persona_live -- --ignored /// Mutates the node's persona namespace (create/remove/rotate) — run /// against a throwaway node, not one holding an identity you keep. #[tokio::test] #[ignore = "needs a local freenet node on 7509; mutates its persona"] async fn persona_live_full_loop() { let adapter = FreenetAdapter::default(); adapter.enable(); let mut tries = 0; while adapter.status() != freenet_adapter::AdapterStatus::Connected { tokio::time::sleep(std::time::Duration::from_millis(200)).await; tries += 1; assert!(tries < 50, "adapter never connected: {:?}", adapter.status()); } // Clean slate, then the full lifecycle. let _ = remove(&adapter).await; assert!(current(&adapter).await.unwrap().is_none()); let created = create(&adapter).await.expect("create"); assert_eq!(created.key_version, 1); // No silent overwrite. let dup = create(&adapter).await; assert!(dup.is_err()); // Export → remove → import reproduces the fingerprint. let dir = std::env::temp_dir().join("soulstream-persona-test"); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("recovery.ssid"); let path_str = path.to_string_lossy().to_string(); let n = export_to(&adapter, "correct horse battery", &path_str).await.expect("export"); assert_eq!(n, 93, "the artifact is 93 bytes by construction"); remove(&adapter).await.expect("remove"); let restored = import_from(&adapter, "correct horse battery", &path_str, false) .await .expect("import"); assert_eq!(restored.public_key, created.public_key); // Wrong passphrase refuses. let bad = import_from(&adapter, "wrong passphrase!", &path_str, true).await; assert!(bad.is_err()); // Rotation bumps the version and changes the key. let rot = rotate(&adapter).await.expect("rotate"); assert_eq!(rot.old_public, created.public_key); assert_eq!(rot.key_version, 2); let now = current(&adapter).await.unwrap().unwrap(); assert_eq!(now.public_key, rot.new_public); // Signing works for Rust callers under an allowed domain. let sig = sign(&adapter, "soulstream.profile/v1", b"payload".to_vec()) .await .expect("sign"); assert_eq!(sig.len(), 64); // ...and refuses an unapproved domain with a typed reason. let refused = sign(&adapter, "evil.domain/v1", b"payload".to_vec()).await; assert!(refused.is_err()); adapter.disable(); let _ = std::fs::remove_file(&path); } }