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 crypto;
|
||||
pub mod http;
|
||||
pub mod identity;
|
||||
pub mod network;
|
||||
pub mod node;
|
||||
pub mod protocol;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue