Files

396 lines
15 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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);
}
}