forked from Keelrleek/SoulStream
396 lines
15 KiB
Rust
396 lines
15 KiB
Rust
//! 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<u8> },
|
||
ExportIdentity { passphrase: String, m_cost_kib: u32, t_cost: u32 },
|
||
ImportIdentity { artifact: Vec<u8>, 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<u8> },
|
||
Artifact { bytes: Vec<u8> },
|
||
Rotated { old_public: [u8; 32], new_public: [u8; 32], key_version: u32, transition_sig: Vec<u8> },
|
||
Transition { old_public: [u8; 32], new_public: [u8; 32], key_version: u32, transition_sig: Vec<u8> },
|
||
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<PersonaInfo>,
|
||
/// 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<OutMsg, String> {
|
||
// 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<Option<PersonaInfo>, 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<PersonaInfo, String> {
|
||
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<usize, String> {
|
||
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<PersonaInfo, String> {
|
||
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<RotationInfo, String> {
|
||
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<u8>,
|
||
) -> Result<Vec<u8>, 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<PersonaStatus, String> {
|
||
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<PersonaInfo, String> {
|
||
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<usize, String> {
|
||
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<PersonaInfo, String> {
|
||
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<RotationInfo, String> {
|
||
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);
|
||
}
|
||
}
|