Dorian e3aa95a103 fix: prevent tokio runtime deadlock in credential issue/verify
The credential issuance and verification handlers used
Handle::block_on() directly inside the tokio runtime, causing a
deadlock. Wrapped with block_in_place() to properly yield the
runtime thread.

Also completed full feature verification across all 25 test groups
(~175 checks) on live server.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 07:43:12 +00:00

216 lines
6.3 KiB
Rust

//! Bitcoin domain names management using Nostr NIP-05 verification.
//! Allows users to register human-readable names linked to their DIDs
//! and verify names of peers via the NIP-05 protocol.
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use tokio::fs;
use tracing::debug;
const NAMES_FILE: &str = "names.json";
/// A registered name linked to an identity.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegisteredName {
pub id: String,
pub name: String,
pub domain: String,
pub identity_id: String,
pub did: String,
pub nostr_pubkey: Option<String>,
pub status: NameStatus,
pub registered_at: String,
pub expires_at: Option<String>,
pub nip05: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum NameStatus {
Active,
Pending,
Expired,
Failed,
}
impl std::fmt::Display for NameStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
NameStatus::Active => write!(f, "active"),
NameStatus::Pending => write!(f, "pending"),
NameStatus::Expired => write!(f, "expired"),
NameStatus::Failed => write!(f, "failed"),
}
}
}
/// Stored names data.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct NamesStore {
pub names: Vec<RegisteredName>,
}
/// NIP-05 verification result.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Nip05Resolution {
pub name: String,
pub domain: String,
pub nostr_pubkey: Option<String>,
pub relays: Vec<String>,
pub verified: bool,
}
pub async fn load_names(data_dir: &Path) -> Result<NamesStore> {
let path = data_dir.join(NAMES_FILE);
if !path.exists() {
return Ok(NamesStore::default());
}
let data = fs::read_to_string(&path)
.await
.context("Reading names store")?;
serde_json::from_str(&data).context("Parsing names store")
}
pub async fn save_names(data_dir: &Path, store: &NamesStore) -> Result<()> {
let path = data_dir.join(NAMES_FILE);
let data = serde_json::to_string_pretty(store)?;
fs::write(&path, data).await.context("Writing names store")
}
/// Register a new name linked to an identity.
pub async fn register_name(
data_dir: &Path,
name: &str,
domain: &str,
identity_id: &str,
did: &str,
nostr_pubkey: Option<&str>,
) -> Result<RegisteredName> {
let mut store = load_names(data_dir).await?;
let nip05 = format!("{}@{}", name, domain);
// Check for duplicates
if store.names.iter().any(|n| n.nip05 == nip05) {
return Err(anyhow::anyhow!("Name {} is already registered", nip05));
}
let record = RegisteredName {
id: uuid::Uuid::new_v4().to_string(),
name: name.to_string(),
domain: domain.to_string(),
identity_id: identity_id.to_string(),
did: did.to_string(),
nostr_pubkey: nostr_pubkey.map(|s| s.to_string()),
status: NameStatus::Active,
registered_at: chrono::Utc::now().to_rfc3339(),
expires_at: None,
nip05,
};
debug!(name = %record.nip05, "Registered new name");
store.names.push(record.clone());
save_names(data_dir, &store).await?;
Ok(record)
}
/// Remove a registered name.
pub async fn remove_name(data_dir: &Path, name_id: &str) -> Result<()> {
let mut store = load_names(data_dir).await?;
let original_len = store.names.len();
store.names.retain(|n| n.id != name_id);
if store.names.len() == original_len {
return Err(anyhow::anyhow!("Name not found: {}", name_id));
}
save_names(data_dir, &store).await
}
/// Resolve a NIP-05 identifier (user@domain) to verify it.
pub async fn resolve_nip05(identifier: &str) -> Result<Nip05Resolution> {
let parts: Vec<&str> = identifier.split('@').collect();
if parts.len() != 2 {
return Err(anyhow::anyhow!(
"Invalid NIP-05 identifier: expected user@domain"
));
}
let name = parts[0];
let domain = parts[1];
let url = format!(
"https://{}/.well-known/nostr.json?name={}",
domain, name
);
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()?;
let response = client.get(&url).send().await;
match response {
Ok(resp) if resp.status().is_success() => {
let body: serde_json::Value = resp.json().await?;
let pubkey = body
.get("names")
.and_then(|names| names.get(name))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let relays = if let Some(pk) = &pubkey {
body.get("relays")
.and_then(|r| r.get(pk.as_str()))
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default()
} else {
vec![]
};
Ok(Nip05Resolution {
name: name.to_string(),
domain: domain.to_string(),
nostr_pubkey: pubkey.clone(),
relays,
verified: pubkey.is_some(),
})
}
Ok(resp) => Err(anyhow::anyhow!(
"NIP-05 verification failed: HTTP {}",
resp.status()
)),
Err(_) => Ok(Nip05Resolution {
name: name.to_string(),
domain: domain.to_string(),
nostr_pubkey: None,
relays: vec![],
verified: false,
}),
}
}
/// Link a registered name to a DID by updating its identity association.
pub async fn link_name_to_did(
data_dir: &Path,
name_id: &str,
did: &str,
identity_id: &str,
) -> Result<RegisteredName> {
let mut store = load_names(data_dir).await?;
let name = store
.names
.iter_mut()
.find(|n| n.id == name_id)
.ok_or_else(|| anyhow::anyhow!("Name not found: {}", name_id))?;
name.did = did.to_string();
name.identity_id = identity_id.to_string();
let updated = name.clone();
save_names(data_dir, &store).await?;
Ok(updated)
}