the freenet layer: adapter, persona and profile services, the contracts bundled as commitments

This commit is contained in:
KeelrLeek committed 2026-09-02 13:43:16 -05:00
1 parent 63a0357651
commit 6dce4e2ac7
16 files changed
+3337 -10

No files matched your search

+395
View File
@@ -0,0 +1,395 @@
//! 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);
}
}