- Allow zero-amount Lightning invoices (BOLT11 "any amount") by changing validation from amount_sats < 1 to amount_sats < 0 - identity.verify now extracts pubkey directly from did:key format instead of requiring the DID to belong to a local identity - tor.create-service writes config to data_dir/tor-config/ instead of /var/lib/archipelago/tor/ (owned by debian-tor, not archipelago user) - Add E2E test script (scripts/run-e2e-tests.sh) covering 47 RPC endpoints - Add testing plan with results (loop/testing.md) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
348 lines
12 KiB
Rust
348 lines
12 KiB
Rust
//! Multi-identity manager: multiple Ed25519 identities with DID support.
|
|
//! Each identity has a keypair, display name, purpose tag, and DID:key.
|
|
//! Identities are stored as JSON files encrypted with the node's master key.
|
|
|
|
use anyhow::{Context, Result};
|
|
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
|
|
use rand::rngs::OsRng;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::path::{Path, PathBuf};
|
|
use tokio::fs;
|
|
|
|
use crate::identity::did_key_from_pubkey_hex;
|
|
|
|
const IDENTITIES_DIR: &str = "identities";
|
|
const DEFAULT_MARKER: &str = ".default";
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum IdentityPurpose {
|
|
Personal,
|
|
Business,
|
|
Anonymous,
|
|
}
|
|
|
|
impl std::fmt::Display for IdentityPurpose {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
IdentityPurpose::Personal => write!(f, "personal"),
|
|
IdentityPurpose::Business => write!(f, "business"),
|
|
IdentityPurpose::Anonymous => write!(f, "anonymous"),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct IdentityRecord {
|
|
pub id: String,
|
|
pub name: String,
|
|
pub purpose: IdentityPurpose,
|
|
pub pubkey_hex: String,
|
|
pub did: String,
|
|
pub created_at: String,
|
|
pub nostr_pubkey: Option<String>,
|
|
}
|
|
|
|
/// On-disk format for identity storage (includes secret key bytes).
|
|
#[derive(Serialize, Deserialize)]
|
|
struct IdentityFile {
|
|
id: String,
|
|
name: String,
|
|
purpose: IdentityPurpose,
|
|
secret_key: Vec<u8>,
|
|
pubkey_hex: String,
|
|
did: String,
|
|
created_at: String,
|
|
#[serde(default)]
|
|
nostr_secret_hex: Option<String>,
|
|
#[serde(default)]
|
|
nostr_pubkey_hex: Option<String>,
|
|
}
|
|
|
|
pub struct IdentityManager {
|
|
identities_dir: PathBuf,
|
|
}
|
|
|
|
impl IdentityManager {
|
|
pub async fn new(data_dir: &Path) -> Result<Self> {
|
|
let identities_dir = data_dir.join(IDENTITIES_DIR);
|
|
fs::create_dir_all(&identities_dir)
|
|
.await
|
|
.context("Failed to create identities directory")?;
|
|
Ok(Self { identities_dir })
|
|
}
|
|
|
|
/// List all identities (without secret keys).
|
|
pub async fn list(&self) -> Result<(Vec<IdentityRecord>, Option<String>)> {
|
|
let default_id = self.get_default_id().await;
|
|
let mut identities = Vec::new();
|
|
|
|
let mut entries = fs::read_dir(&self.identities_dir)
|
|
.await
|
|
.context("Failed to read identities directory")?;
|
|
|
|
while let Some(entry) = entries.next_entry().await? {
|
|
let path = entry.path();
|
|
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
|
continue;
|
|
}
|
|
match self.load_record(&path).await {
|
|
Ok(record) => identities.push(record),
|
|
Err(e) => {
|
|
tracing::warn!("Skipping corrupt identity file {:?}: {}", path, e);
|
|
}
|
|
}
|
|
}
|
|
|
|
identities.sort_by(|a, b| a.created_at.cmp(&b.created_at));
|
|
Ok((identities, default_id))
|
|
}
|
|
|
|
/// Create a new identity.
|
|
pub async fn create(&self, name: String, purpose: IdentityPurpose) -> Result<IdentityRecord> {
|
|
let signing_key = SigningKey::generate(&mut OsRng);
|
|
let pubkey_hex = hex::encode(signing_key.verifying_key().as_bytes());
|
|
let did = did_key_from_pubkey_hex(&pubkey_hex)?;
|
|
let id = uuid::Uuid::new_v4().to_string();
|
|
let created_at = chrono::Utc::now().to_rfc3339();
|
|
|
|
let identity_file = IdentityFile {
|
|
id: id.clone(),
|
|
name: name.clone(),
|
|
purpose: purpose.clone(),
|
|
secret_key: signing_key.to_bytes().to_vec(),
|
|
pubkey_hex: pubkey_hex.clone(),
|
|
did: did.clone(),
|
|
created_at: created_at.clone(),
|
|
nostr_secret_hex: None,
|
|
nostr_pubkey_hex: None,
|
|
};
|
|
|
|
let file_path = self.identities_dir.join(format!("{}.json", id));
|
|
let json = serde_json::to_string_pretty(&identity_file)
|
|
.context("Failed to serialize identity")?;
|
|
fs::write(&file_path, json.as_bytes())
|
|
.await
|
|
.context("Failed to write identity file")?;
|
|
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
fs::set_permissions(&file_path, std::fs::Permissions::from_mode(0o600))
|
|
.await
|
|
.context("Failed to set identity file permissions")?;
|
|
}
|
|
|
|
// If this is the first identity, make it the default
|
|
let (existing, _) = self.list().await?;
|
|
if existing.len() <= 1 {
|
|
self.set_default(&id).await?;
|
|
}
|
|
|
|
tracing::info!("Created identity '{}' ({})", name, purpose);
|
|
|
|
Ok(IdentityRecord {
|
|
id,
|
|
name,
|
|
purpose,
|
|
pubkey_hex,
|
|
did,
|
|
created_at,
|
|
nostr_pubkey: None,
|
|
})
|
|
}
|
|
|
|
/// Get a single identity by ID (without secret key).
|
|
pub async fn get(&self, id: &str) -> Result<IdentityRecord> {
|
|
let file_path = self.identities_dir.join(format!("{}.json", id));
|
|
if !file_path.exists() {
|
|
return Err(anyhow::anyhow!("Identity not found: {}", id));
|
|
}
|
|
self.load_record(&file_path).await
|
|
}
|
|
|
|
/// Delete an identity.
|
|
pub async fn delete(&self, id: &str) -> Result<()> {
|
|
let file_path = self.identities_dir.join(format!("{}.json", id));
|
|
if !file_path.exists() {
|
|
return Err(anyhow::anyhow!("Identity not found: {}", id));
|
|
}
|
|
|
|
fs::remove_file(&file_path)
|
|
.await
|
|
.context("Failed to delete identity file")?;
|
|
|
|
// If this was the default, clear the marker
|
|
if let Some(default_id) = self.get_default_id().await {
|
|
if default_id == id {
|
|
let marker = self.identities_dir.join(DEFAULT_MARKER);
|
|
let _ = fs::remove_file(marker).await;
|
|
|
|
// Set a new default if other identities exist
|
|
let (remaining, _) = self.list().await?;
|
|
if let Some(first) = remaining.first() {
|
|
self.set_default(&first.id).await?;
|
|
}
|
|
}
|
|
}
|
|
|
|
tracing::info!("Deleted identity {}", id);
|
|
Ok(())
|
|
}
|
|
|
|
/// Set the default identity.
|
|
pub async fn set_default(&self, id: &str) -> Result<()> {
|
|
// Verify it exists
|
|
let file_path = self.identities_dir.join(format!("{}.json", id));
|
|
if !file_path.exists() {
|
|
return Err(anyhow::anyhow!("Identity not found: {}", id));
|
|
}
|
|
|
|
let marker = self.identities_dir.join(DEFAULT_MARKER);
|
|
fs::write(&marker, id.as_bytes())
|
|
.await
|
|
.context("Failed to write default identity marker")?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Sign data with a specific identity.
|
|
pub async fn sign(&self, id: &str, data: &[u8]) -> Result<String> {
|
|
let signing_key = self.load_signing_key(id).await?;
|
|
Ok(hex::encode(signing_key.sign(data).to_bytes()))
|
|
}
|
|
|
|
/// Verify a signature against a DID's public key.
|
|
/// Works for any valid did:key (not just local identities).
|
|
pub async fn verify(&self, did: &str, data: &[u8], sig_hex: &str) -> Result<bool> {
|
|
// Extract pubkey from did:key directly — no local lookup needed
|
|
let pubkey_bytes = pubkey_bytes_from_did_key(did)?;
|
|
let verifying_key = VerifyingKey::from_bytes(
|
|
pubkey_bytes
|
|
.as_slice()
|
|
.try_into()
|
|
.map_err(|_| anyhow::anyhow!("Invalid pubkey length"))?,
|
|
)?;
|
|
let sig_bytes = hex::decode(sig_hex).context("Invalid signature hex")?;
|
|
let sig = Signature::from_bytes(
|
|
sig_bytes
|
|
.as_slice()
|
|
.try_into()
|
|
.map_err(|_| anyhow::anyhow!("Invalid signature length"))?,
|
|
);
|
|
Ok(verifying_key.verify(data, &sig).is_ok())
|
|
}
|
|
|
|
/// Create a Nostr keypair for an identity.
|
|
pub async fn create_nostr_key(&self, id: &str) -> Result<String> {
|
|
let file_path = self.identities_dir.join(format!("{}.json", id));
|
|
if !file_path.exists() {
|
|
return Err(anyhow::anyhow!("Identity not found: {}", id));
|
|
}
|
|
let data = fs::read(&file_path).await.context("Failed to read identity file")?;
|
|
let mut file: IdentityFile = serde_json::from_slice(&data).context("Failed to parse identity file")?;
|
|
|
|
if file.nostr_secret_hex.is_some() {
|
|
return Err(anyhow::anyhow!("Nostr key already exists for this identity"));
|
|
}
|
|
|
|
let keys = nostr_sdk::Keys::generate();
|
|
let secret_hex = keys.secret_key().display_secret().to_string();
|
|
let pubkey_hex = keys.public_key().to_hex();
|
|
|
|
file.nostr_secret_hex = Some(secret_hex);
|
|
file.nostr_pubkey_hex = Some(pubkey_hex.clone());
|
|
|
|
let json = serde_json::to_string_pretty(&file).context("Failed to serialize identity")?;
|
|
fs::write(&file_path, json.as_bytes()).await.context("Failed to write identity file")?;
|
|
|
|
tracing::info!("Created Nostr key for identity {}", id);
|
|
Ok(pubkey_hex)
|
|
}
|
|
|
|
/// Sign a Nostr event (NIP-01) with an identity's Nostr key.
|
|
/// Returns the signature hex string.
|
|
pub async fn nostr_sign(&self, id: &str, event_hash_hex: &str) -> Result<String> {
|
|
let file_path = self.identities_dir.join(format!("{}.json", id));
|
|
if !file_path.exists() {
|
|
return Err(anyhow::anyhow!("Identity not found: {}", id));
|
|
}
|
|
let data = fs::read(&file_path).await.context("Failed to read identity file")?;
|
|
let file: IdentityFile = serde_json::from_slice(&data).context("Failed to parse identity file")?;
|
|
|
|
let secret_hex = file.nostr_secret_hex
|
|
.ok_or_else(|| anyhow::anyhow!("No Nostr key for this identity"))?;
|
|
let keys = nostr_sdk::Keys::parse(&secret_hex).context("Invalid Nostr secret key")?;
|
|
|
|
let hash_bytes = hex::decode(event_hash_hex).context("Invalid event hash hex")?;
|
|
if hash_bytes.len() != 32 {
|
|
return Err(anyhow::anyhow!("Event hash must be 32 bytes"));
|
|
}
|
|
|
|
let message = nostr_sdk::secp256k1::Message::from_digest(
|
|
hash_bytes.try_into().map_err(|_| anyhow::anyhow!("Invalid hash length"))?,
|
|
);
|
|
let sig = keys.sign_schnorr(&message);
|
|
Ok(sig.to_string())
|
|
}
|
|
|
|
// --- internal helpers ---
|
|
|
|
}
|
|
|
|
/// Extract Ed25519 pubkey bytes from a did:key string.
|
|
/// Format: did:key:z<base58btc(0xed01 + 32-byte-pubkey)>
|
|
fn pubkey_bytes_from_did_key(did: &str) -> Result<Vec<u8>> {
|
|
let z_part = did
|
|
.strip_prefix("did:key:z")
|
|
.ok_or_else(|| anyhow::anyhow!("Invalid did:key format: {}", did))?;
|
|
let decoded = bs58::decode(z_part)
|
|
.into_vec()
|
|
.context("Invalid base58 in did:key")?;
|
|
if decoded.len() != 34 || decoded[0] != 0xed || decoded[1] != 0x01 {
|
|
return Err(anyhow::anyhow!("Invalid Ed25519 did:key multicodec prefix"));
|
|
}
|
|
Ok(decoded[2..].to_vec())
|
|
}
|
|
|
|
impl IdentityManager {
|
|
|
|
async fn get_default_id(&self) -> Option<String> {
|
|
let marker = self.identities_dir.join(DEFAULT_MARKER);
|
|
fs::read_to_string(&marker).await.ok().map(|s| s.trim().to_string())
|
|
}
|
|
|
|
async fn load_record(&self, path: &Path) -> Result<IdentityRecord> {
|
|
let data = fs::read(path)
|
|
.await
|
|
.context("Failed to read identity file")?;
|
|
let file: IdentityFile = serde_json::from_slice(&data)
|
|
.context("Failed to parse identity file")?;
|
|
Ok(IdentityRecord {
|
|
id: file.id,
|
|
name: file.name,
|
|
purpose: file.purpose,
|
|
pubkey_hex: file.pubkey_hex,
|
|
did: file.did,
|
|
created_at: file.created_at,
|
|
nostr_pubkey: file.nostr_pubkey_hex,
|
|
})
|
|
}
|
|
|
|
async fn load_signing_key(&self, id: &str) -> Result<SigningKey> {
|
|
let file_path = self.identities_dir.join(format!("{}.json", id));
|
|
if !file_path.exists() {
|
|
return Err(anyhow::anyhow!("Identity not found: {}", id));
|
|
}
|
|
let data = fs::read(&file_path)
|
|
.await
|
|
.context("Failed to read identity file")?;
|
|
let file: IdentityFile = serde_json::from_slice(&data)
|
|
.context("Failed to parse identity file")?;
|
|
let arr: [u8; 32] = file
|
|
.secret_key
|
|
.try_into()
|
|
.map_err(|_| anyhow::anyhow!("Invalid secret key length"))?;
|
|
Ok(SigningKey::from_bytes(&arr))
|
|
}
|
|
}
|