Multi-identity infrastructure: IdentityManager + Tauri AppState refactor
New identity.rs: IdentityManager manages per-identity data directories
under identities/{node_id_hex}/. Supports create, list, switch, delete,
import from key. Legacy flat layout auto-migrates on first launch.
Tauri AppState refactored from Arc<Node> to AppNode (Arc<RwLock<Arc<Node>>>)
+ AppIdentity (Arc<Mutex<IdentityManager>>). All 76 IPC commands updated
to get_node(&state).await pattern. Enables hot-swap identity switching.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1030dc21a7
commit
75ce965b63
3 changed files with 610 additions and 188 deletions
392
crates/core/src/identity.rs
Normal file
392
crates/core/src/identity.rs
Normal file
|
|
@ -0,0 +1,392 @@
|
||||||
|
//! Multi-identity management: create, list, switch, delete identities.
|
||||||
|
//! Each identity gets its own data directory with identity.key, itsgoin.db, and blobs/.
|
||||||
|
//! One identity is active at a time — hot-swap via Node teardown/rebuild.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use crate::node::Node;
|
||||||
|
use crate::types::NodeId;
|
||||||
|
|
||||||
|
/// Metadata about a stored identity.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct IdentityInfo {
|
||||||
|
pub node_id: NodeId,
|
||||||
|
pub node_id_hex: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub created_at: u64,
|
||||||
|
pub last_used_at: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-identity metadata stored in meta.json.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
struct IdentityMeta {
|
||||||
|
display_name: String,
|
||||||
|
created_at: u64,
|
||||||
|
last_used_at: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manages multiple identities on a single device.
|
||||||
|
/// One identity is active at a time. Switching tears down the current Node
|
||||||
|
/// and builds a new one from the target identity's data directory.
|
||||||
|
pub struct IdentityManager {
|
||||||
|
base_dir: PathBuf,
|
||||||
|
active_node: Option<Arc<Node>>,
|
||||||
|
active_id: Option<NodeId>,
|
||||||
|
bind_addr: Option<std::net::SocketAddr>,
|
||||||
|
profile: crate::types::DeviceProfile,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IdentityManager {
|
||||||
|
/// Create a new IdentityManager rooted at the given base directory.
|
||||||
|
/// Performs legacy layout migration if needed (flat itsgoin-data/ → per-identity subdirs).
|
||||||
|
pub async fn open(
|
||||||
|
base_dir: impl AsRef<Path>,
|
||||||
|
bind_addr: Option<std::net::SocketAddr>,
|
||||||
|
profile: crate::types::DeviceProfile,
|
||||||
|
) -> anyhow::Result<Self> {
|
||||||
|
let base_dir = base_dir.as_ref().to_path_buf();
|
||||||
|
std::fs::create_dir_all(&base_dir)?;
|
||||||
|
|
||||||
|
let mut mgr = Self {
|
||||||
|
base_dir: base_dir.clone(),
|
||||||
|
active_node: None,
|
||||||
|
active_id: None,
|
||||||
|
bind_addr,
|
||||||
|
profile,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Migrate legacy flat layout if needed
|
||||||
|
mgr.migrate_legacy_layout()?;
|
||||||
|
|
||||||
|
// Ensure identities/ directory exists
|
||||||
|
let identities_dir = base_dir.join("identities");
|
||||||
|
std::fs::create_dir_all(&identities_dir)?;
|
||||||
|
|
||||||
|
// Auto-start the active identity (if any)
|
||||||
|
if let Some(active_id) = mgr.read_active_identity()? {
|
||||||
|
let id_dir = mgr.identity_dir(&active_id);
|
||||||
|
if id_dir.exists() {
|
||||||
|
info!(identity = hex::encode(active_id), "Starting active identity");
|
||||||
|
let node = Arc::new(
|
||||||
|
Node::open_with_bind(&id_dir, bind_addr, profile).await?
|
||||||
|
);
|
||||||
|
mgr.active_id = Some(active_id);
|
||||||
|
mgr.active_node = Some(node);
|
||||||
|
mgr.update_last_used(&active_id)?;
|
||||||
|
} else {
|
||||||
|
warn!(identity = hex::encode(active_id), "Active identity directory missing");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No active identity — check if there's exactly one, auto-select it
|
||||||
|
let identities = mgr.list_identities()?;
|
||||||
|
if identities.len() == 1 {
|
||||||
|
let id = identities[0].node_id;
|
||||||
|
let id_dir = mgr.identity_dir(&id);
|
||||||
|
info!(identity = hex::encode(id), "Auto-selecting only available identity");
|
||||||
|
let node = Arc::new(
|
||||||
|
Node::open_with_bind(&id_dir, bind_addr, profile).await?
|
||||||
|
);
|
||||||
|
mgr.active_id = Some(id);
|
||||||
|
mgr.active_node = Some(node);
|
||||||
|
mgr.write_active_identity(&id)?;
|
||||||
|
mgr.update_last_used(&id)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(mgr)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the currently active Node, if any.
|
||||||
|
pub fn active_node(&self) -> Option<&Arc<Node>> {
|
||||||
|
self.active_node.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the currently active Node, or error.
|
||||||
|
pub fn node(&self) -> anyhow::Result<&Arc<Node>> {
|
||||||
|
self.active_node.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("No active identity"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the active identity's NodeId.
|
||||||
|
pub fn active_id(&self) -> Option<NodeId> {
|
||||||
|
self.active_id
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Identity CRUD ---
|
||||||
|
|
||||||
|
/// List all available identities.
|
||||||
|
pub fn list_identities(&self) -> anyhow::Result<Vec<IdentityInfo>> {
|
||||||
|
let identities_dir = self.base_dir.join("identities");
|
||||||
|
if !identities_dir.exists() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut result = Vec::new();
|
||||||
|
for entry in std::fs::read_dir(&identities_dir)? {
|
||||||
|
let entry = entry?;
|
||||||
|
if !entry.file_type()?.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let dir_name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
let key_path = entry.path().join("identity.key");
|
||||||
|
if !key_path.exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive node_id from key
|
||||||
|
let key_bytes = std::fs::read(&key_path)?;
|
||||||
|
let seed: [u8; 32] = key_bytes.try_into()
|
||||||
|
.map_err(|_| anyhow::anyhow!("invalid key in {}", dir_name))?;
|
||||||
|
let secret_key = iroh::SecretKey::from_bytes(&seed);
|
||||||
|
let node_id: NodeId = *secret_key.public().as_bytes();
|
||||||
|
let node_id_hex = hex::encode(node_id);
|
||||||
|
|
||||||
|
// Read metadata
|
||||||
|
let meta_path = entry.path().join("meta.json");
|
||||||
|
let meta = if meta_path.exists() {
|
||||||
|
serde_json::from_str::<IdentityMeta>(
|
||||||
|
&std::fs::read_to_string(&meta_path)?
|
||||||
|
).unwrap_or(IdentityMeta {
|
||||||
|
display_name: node_id_hex[..12].to_string(),
|
||||||
|
created_at: 0,
|
||||||
|
last_used_at: 0,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
IdentityMeta {
|
||||||
|
display_name: node_id_hex[..12].to_string(),
|
||||||
|
created_at: 0,
|
||||||
|
last_used_at: 0,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
result.push(IdentityInfo {
|
||||||
|
node_id,
|
||||||
|
node_id_hex,
|
||||||
|
display_name: meta.display_name,
|
||||||
|
created_at: meta.created_at,
|
||||||
|
last_used_at: meta.last_used_at,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by last_used descending
|
||||||
|
result.sort_by(|a, b| b.last_used_at.cmp(&a.last_used_at));
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new identity with the given display name.
|
||||||
|
/// Returns the new NodeId.
|
||||||
|
pub fn create_identity(&self, name: &str) -> anyhow::Result<NodeId> {
|
||||||
|
let key = iroh::SecretKey::generate(&mut rand::rng());
|
||||||
|
let seed = key.to_bytes();
|
||||||
|
let node_id: NodeId = *key.public().as_bytes();
|
||||||
|
let node_id_hex = hex::encode(node_id);
|
||||||
|
|
||||||
|
let id_dir = self.base_dir.join("identities").join(&node_id_hex);
|
||||||
|
std::fs::create_dir_all(&id_dir)?;
|
||||||
|
|
||||||
|
// Write identity key
|
||||||
|
std::fs::write(id_dir.join("identity.key"), seed)?;
|
||||||
|
|
||||||
|
// Write metadata
|
||||||
|
let now = now_ms();
|
||||||
|
let meta = IdentityMeta {
|
||||||
|
display_name: name.to_string(),
|
||||||
|
created_at: now,
|
||||||
|
last_used_at: now,
|
||||||
|
};
|
||||||
|
std::fs::write(
|
||||||
|
id_dir.join("meta.json"),
|
||||||
|
serde_json::to_string_pretty(&meta)?,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
info!(identity = node_id_hex, name, "Created new identity");
|
||||||
|
Ok(node_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Import an identity from a hex-encoded secret key.
|
||||||
|
pub fn import_identity_from_key(&self, key_hex: &str, name: &str) -> anyhow::Result<NodeId> {
|
||||||
|
let seed_bytes = hex::decode(key_hex)?;
|
||||||
|
let seed: [u8; 32] = seed_bytes.try_into()
|
||||||
|
.map_err(|_| anyhow::anyhow!("key must be exactly 32 bytes (64 hex chars)"))?;
|
||||||
|
|
||||||
|
let secret_key = iroh::SecretKey::from_bytes(&seed);
|
||||||
|
let node_id: NodeId = *secret_key.public().as_bytes();
|
||||||
|
let node_id_hex = hex::encode(node_id);
|
||||||
|
|
||||||
|
let id_dir = self.base_dir.join("identities").join(&node_id_hex);
|
||||||
|
if id_dir.exists() {
|
||||||
|
anyhow::bail!("Identity {} already exists", &node_id_hex[..12]);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::fs::create_dir_all(&id_dir)?;
|
||||||
|
std::fs::write(id_dir.join("identity.key"), seed)?;
|
||||||
|
|
||||||
|
let now = now_ms();
|
||||||
|
let meta = IdentityMeta {
|
||||||
|
display_name: name.to_string(),
|
||||||
|
created_at: now,
|
||||||
|
last_used_at: now,
|
||||||
|
};
|
||||||
|
std::fs::write(
|
||||||
|
id_dir.join("meta.json"),
|
||||||
|
serde_json::to_string_pretty(&meta)?,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
info!(identity = node_id_hex, name, "Imported identity from key");
|
||||||
|
Ok(node_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Switch to a different identity. Shuts down the current Node and starts a new one.
|
||||||
|
pub async fn switch_identity(&mut self, target_id: &NodeId) -> anyhow::Result<Arc<Node>> {
|
||||||
|
let target_hex = hex::encode(target_id);
|
||||||
|
let id_dir = self.identity_dir(target_id);
|
||||||
|
if !id_dir.exists() {
|
||||||
|
anyhow::bail!("Identity {} not found", &target_hex[..12]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shutdown current node if running
|
||||||
|
if let Some(ref node) = self.active_node {
|
||||||
|
info!(old = hex::encode(node.node_id), new = target_hex, "Switching identity");
|
||||||
|
// Node::shutdown would go here once implemented
|
||||||
|
// For now, dropping the Arc triggers cleanup when last reference is released
|
||||||
|
}
|
||||||
|
self.active_node = None;
|
||||||
|
self.active_id = None;
|
||||||
|
|
||||||
|
// Start new node
|
||||||
|
let node = Arc::new(
|
||||||
|
Node::open_with_bind(&id_dir, self.bind_addr, self.profile).await?
|
||||||
|
);
|
||||||
|
let node_arc = Arc::clone(&node);
|
||||||
|
self.active_node = Some(node);
|
||||||
|
self.active_id = Some(*target_id);
|
||||||
|
self.write_active_identity(target_id)?;
|
||||||
|
self.update_last_used(target_id)?;
|
||||||
|
|
||||||
|
info!(identity = target_hex, "Identity switch complete");
|
||||||
|
Ok(node_arc)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete an identity. Refuses to delete the active identity.
|
||||||
|
pub fn delete_identity(&self, target_id: &NodeId) -> anyhow::Result<()> {
|
||||||
|
if self.active_id.as_ref() == Some(target_id) {
|
||||||
|
anyhow::bail!("Cannot delete the active identity — switch to another first");
|
||||||
|
}
|
||||||
|
|
||||||
|
let id_dir = self.identity_dir(target_id);
|
||||||
|
if !id_dir.exists() {
|
||||||
|
anyhow::bail!("Identity not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&id_dir)?;
|
||||||
|
info!(identity = hex::encode(target_id), "Deleted identity");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Internal helpers ---
|
||||||
|
|
||||||
|
fn identity_dir(&self, node_id: &NodeId) -> PathBuf {
|
||||||
|
self.base_dir.join("identities").join(hex::encode(node_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_active_identity(&self) -> anyhow::Result<Option<NodeId>> {
|
||||||
|
let path = self.base_dir.join("active_identity");
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let hex_str = std::fs::read_to_string(&path)?.trim().to_string();
|
||||||
|
if hex_str.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let bytes = hex::decode(&hex_str)?;
|
||||||
|
let id: NodeId = bytes.try_into()
|
||||||
|
.map_err(|_| anyhow::anyhow!("invalid active_identity file"))?;
|
||||||
|
Ok(Some(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_active_identity(&self, node_id: &NodeId) -> anyhow::Result<()> {
|
||||||
|
let path = self.base_dir.join("active_identity");
|
||||||
|
std::fs::write(&path, hex::encode(node_id))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_last_used(&self, node_id: &NodeId) -> anyhow::Result<()> {
|
||||||
|
let meta_path = self.identity_dir(node_id).join("meta.json");
|
||||||
|
if meta_path.exists() {
|
||||||
|
let mut meta: IdentityMeta = serde_json::from_str(
|
||||||
|
&std::fs::read_to_string(&meta_path)?
|
||||||
|
)?;
|
||||||
|
meta.last_used_at = now_ms();
|
||||||
|
std::fs::write(&meta_path, serde_json::to_string_pretty(&meta)?)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Migrate from flat itsgoin-data/ layout to per-identity subdirectories.
|
||||||
|
/// Only runs if identity.key exists in the base_dir (legacy layout).
|
||||||
|
fn migrate_legacy_layout(&self) -> anyhow::Result<()> {
|
||||||
|
let legacy_key = self.base_dir.join("identity.key");
|
||||||
|
if !legacy_key.exists() {
|
||||||
|
return Ok(()); // Already migrated or fresh install
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Migrating legacy flat layout to per-identity directories");
|
||||||
|
|
||||||
|
// Read the key to determine node_id
|
||||||
|
let key_bytes = std::fs::read(&legacy_key)?;
|
||||||
|
let seed: [u8; 32] = key_bytes.try_into()
|
||||||
|
.map_err(|_| anyhow::anyhow!("invalid legacy identity.key"))?;
|
||||||
|
let secret_key = iroh::SecretKey::from_bytes(&seed);
|
||||||
|
let node_id: NodeId = *secret_key.public().as_bytes();
|
||||||
|
let node_id_hex = hex::encode(node_id);
|
||||||
|
|
||||||
|
let id_dir = self.base_dir.join("identities").join(&node_id_hex);
|
||||||
|
std::fs::create_dir_all(&id_dir)?;
|
||||||
|
|
||||||
|
// Move files
|
||||||
|
let files_to_move = ["identity.key", "itsgoin.db", "itsgoin.db-wal", "itsgoin.db-shm", "bootstrap.json"];
|
||||||
|
for name in &files_to_move {
|
||||||
|
let src = self.base_dir.join(name);
|
||||||
|
if src.exists() {
|
||||||
|
let dst = id_dir.join(name);
|
||||||
|
std::fs::rename(&src, &dst)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move blobs directory
|
||||||
|
let blobs_src = self.base_dir.join("blobs");
|
||||||
|
if blobs_src.exists() {
|
||||||
|
let blobs_dst = id_dir.join("blobs");
|
||||||
|
std::fs::rename(&blobs_src, &blobs_dst)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write metadata
|
||||||
|
let now = now_ms();
|
||||||
|
let meta = IdentityMeta {
|
||||||
|
display_name: node_id_hex[..12].to_string(),
|
||||||
|
created_at: now,
|
||||||
|
last_used_at: now,
|
||||||
|
};
|
||||||
|
std::fs::write(
|
||||||
|
id_dir.join("meta.json"),
|
||||||
|
serde_json::to_string_pretty(&meta)?,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Write active identity
|
||||||
|
self.write_active_identity(&node_id)?;
|
||||||
|
|
||||||
|
info!(identity = node_id_hex, "Legacy migration complete");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_ms() -> u64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis() as u64
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ pub mod connection;
|
||||||
pub mod content;
|
pub mod content;
|
||||||
pub mod crypto;
|
pub mod crypto;
|
||||||
pub mod http;
|
pub mod http;
|
||||||
|
pub mod identity;
|
||||||
pub mod network;
|
pub mod network;
|
||||||
pub mod node;
|
pub mod node;
|
||||||
pub mod protocol;
|
pub mod protocol;
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue