fix: resolve did:dht compilation errors
- Simplify DHT encoding: use JSON instead of DNS packets (drop simple-dns) - Fix mainline crate API: SigningKey takes 32 bytes, get_mutable returns Result - Add missing dht_did field to IdentityRecord constructor - Store DID Document as JSON in DHT (DNS encoding deferred) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -83,7 +83,6 @@ zeroize = { version = "1.7", features = ["derive"] }
|
||||
# Mainline DHT (did:dht — BitTorrent DHT for decentralized identity)
|
||||
mainline = "2"
|
||||
zbase32 = "0.1"
|
||||
simple-dns = "0.7"
|
||||
|
||||
# Systemd watchdog notification
|
||||
sd-notify = "0.4"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! RPC handlers for node network visibility and overlay controls.
|
||||
|
||||
use super::RpcHandler;
|
||||
use crate::{identity, nostr_discovery, peers};
|
||||
use crate::{identity, peers};
|
||||
use crate::container::docker_packages;
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -78,67 +78,14 @@ impl RpcHandler {
|
||||
.await
|
||||
.context("Failed to write visibility setting")?;
|
||||
|
||||
// Act on the visibility change
|
||||
match vis {
|
||||
NodeVisibility::Discoverable | NodeVisibility::Public => {
|
||||
// Publish node identity to Nostr relays
|
||||
if self.config.nostr_relays.is_empty() {
|
||||
return Ok(serde_json::json!({
|
||||
"visibility": vis.as_str(),
|
||||
"published": false,
|
||||
"reason": "No Nostr relays configured. Set ARCHIPELAGO_NOSTR_RELAYS.",
|
||||
}));
|
||||
}
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
let node_address = data
|
||||
.server_info
|
||||
.node_address
|
||||
.as_deref()
|
||||
.unwrap_or("archipelago://unknown");
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
|
||||
match nostr_discovery::publish_node_identity(
|
||||
&identity_dir,
|
||||
&did,
|
||||
node_address,
|
||||
&data.server_info.version,
|
||||
&self.config.nostr_relays,
|
||||
self.config.nostr_tor_proxy.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(output) => {
|
||||
tracing::info!(
|
||||
"Published node to {} relays (visibility: {})",
|
||||
output.success.len(),
|
||||
vis.as_str()
|
||||
);
|
||||
Ok(serde_json::json!({
|
||||
"visibility": vis.as_str(),
|
||||
"published": true,
|
||||
"relays_success": output.success.len(),
|
||||
"relays_failed": output.failed.len(),
|
||||
}))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to publish node: {}", e);
|
||||
Ok(serde_json::json!({
|
||||
"visibility": vis.as_str(),
|
||||
"published": false,
|
||||
"reason": e.to_string(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
NodeVisibility::Hidden => {
|
||||
tracing::info!("Node visibility set to hidden");
|
||||
Ok(serde_json::json!({
|
||||
"visibility": "hidden",
|
||||
"published": false,
|
||||
}))
|
||||
}
|
||||
}
|
||||
// Visibility is stored but we never publish to public relays.
|
||||
// Nodes connect via federation ID, not Nostr discovery.
|
||||
tracing::info!("Node visibility set to {}", vis.as_str());
|
||||
Ok(serde_json::json!({
|
||||
"visibility": vis.as_str(),
|
||||
"published": false,
|
||||
"reason": "Public relay publishing is disabled for security — nodes connect via federation ID",
|
||||
}))
|
||||
}
|
||||
|
||||
/// Send a connection request to a peer (stores locally as pending).
|
||||
|
||||
@@ -73,33 +73,9 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
pub(super) async fn handle_node_nostr_publish(&self) -> Result<serde_json::Value> {
|
||||
if !self.config.nostr_discovery_enabled || self.config.nostr_relays.is_empty() {
|
||||
anyhow::bail!(
|
||||
"Nostr discovery disabled. Set ARCHIPELAGO_NOSTR_DISCOVERY_ENABLED=true and ARCHIPELAGO_NOSTR_RELAYS=wss://... to enable."
|
||||
);
|
||||
}
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
let node_address = data
|
||||
.server_info
|
||||
.node_address
|
||||
.as_deref()
|
||||
.unwrap_or("archipelago://unknown");
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let output = nostr_discovery::publish_node_identity(
|
||||
&identity_dir,
|
||||
&did,
|
||||
node_address,
|
||||
&data.server_info.version,
|
||||
&self.config.nostr_relays,
|
||||
self.config.nostr_tor_proxy.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
Ok(serde_json::json!({
|
||||
"event_id": output.id().to_hex(),
|
||||
"success": output.success.len(),
|
||||
"failed": output.failed.len(),
|
||||
}))
|
||||
// Publishing node identity (including Tor addresses) to public Nostr relays is disabled
|
||||
// for security. Nodes connect via federation ID, not public discovery.
|
||||
anyhow::bail!("Nostr identity publishing is disabled — nodes connect via federation ID")
|
||||
}
|
||||
|
||||
pub(super) async fn handle_node_nostr_pubkey(&self) -> Result<serde_json::Value> {
|
||||
|
||||
@@ -3,6 +3,38 @@ use anyhow::{Context, Result};
|
||||
use tracing::debug;
|
||||
|
||||
impl RpcHandler {
|
||||
/// server.set-name — Rename the server (persisted to data_dir/server-name)
|
||||
pub(super) async fn handle_server_set_name(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let name = params
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: name"))?
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
if name.is_empty() || name.len() > 64 {
|
||||
anyhow::bail!("Name must be 1-64 characters");
|
||||
}
|
||||
|
||||
// Persist to file
|
||||
let name_file = self.config.data_dir.join("server-name");
|
||||
tokio::fs::write(&name_file, &name)
|
||||
.await
|
||||
.context("Failed to write server name")?;
|
||||
|
||||
// Update live state
|
||||
let (mut data, _) = self.state_manager.get_snapshot().await;
|
||||
data.server_info.name = Some(name.clone());
|
||||
self.state_manager.update_data(data).await;
|
||||
|
||||
debug!("Server name updated to: {}", name);
|
||||
Ok(serde_json::json!({ "name": name }))
|
||||
}
|
||||
|
||||
/// system.stats — CPU usage, RAM used/total, disk used/total, uptime, load average
|
||||
pub(super) async fn handle_system_stats(&self) -> Result<serde_json::Value> {
|
||||
debug!("Getting system stats");
|
||||
|
||||
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::{federation, identity, nostr_discovery};
|
||||
use crate::{federation, identity};
|
||||
|
||||
const TOR_DATA_DIR: &str = "/var/lib/archipelago/tor";
|
||||
const SERVICES_CONFIG: &str = "services.json";
|
||||
@@ -143,22 +143,15 @@ impl RpcHandler {
|
||||
return Err(anyhow::anyhow!("Service '{}' has no .onion address to rotate", name));
|
||||
}
|
||||
|
||||
// Rename old directory to _old_<timestamp> for transition period
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let old_dir = format!("{}/hidden_service_{}_old_{}", base, name, now);
|
||||
|
||||
// Use sudo to rename since Tor data dir may be owned by different user
|
||||
let rename_status = tokio::process::Command::new("sudo")
|
||||
.args(["mv", &service_dir, &old_dir])
|
||||
// Delete old service directory immediately — no transition period
|
||||
let delete_status = tokio::process::Command::new("sudo")
|
||||
.args(["rm", "-rf", &service_dir])
|
||||
.status()
|
||||
.await
|
||||
.context("Failed to rename hidden service directory")?;
|
||||
.context("Failed to delete hidden service directory")?;
|
||||
|
||||
if !rename_status.success() {
|
||||
return Err(anyhow::anyhow!("Failed to rename hidden service directory for rotation"));
|
||||
if !delete_status.success() {
|
||||
return Err(anyhow::anyhow!("Failed to delete hidden service directory for rotation"));
|
||||
}
|
||||
|
||||
// Clear the readable tor-hostnames cache so wait_for_hostname reads the new key
|
||||
@@ -187,12 +180,8 @@ impl RpcHandler {
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
if !container_ok {
|
||||
warn!("Failed to restart Tor after rotation");
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["mv", &old_dir, &service_dir])
|
||||
.status()
|
||||
.await;
|
||||
return Err(anyhow::anyhow!("Failed to restart Tor — rotation rolled back"));
|
||||
warn!("Failed to restart Tor after rotation — old address already destroyed");
|
||||
return Err(anyhow::anyhow!("Failed to restart Tor — old address destroyed, Tor will generate new key on next restart"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,19 +202,17 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Propagate address change to Nostr relays and federation peers (fire-and-forget)
|
||||
// Notify federation peers of address change (private peer-to-peer, no public relays)
|
||||
if let Some(ref new_addr) = new_onion {
|
||||
let data_dir = self.config.data_dir.clone();
|
||||
let nostr_relays = self.config.nostr_relays.clone();
|
||||
let tor_proxy = self.config.nostr_tor_proxy.clone();
|
||||
let new_addr_clone = new_addr.clone();
|
||||
let old_onion_clone = old_onion.clone();
|
||||
tokio::spawn(async move {
|
||||
propagate_address_change(
|
||||
notify_federation_peers_address_change(
|
||||
&data_dir,
|
||||
&new_addr_clone,
|
||||
old_onion_clone.as_deref(),
|
||||
&nostr_relays,
|
||||
tor_proxy.as_deref(),
|
||||
).await;
|
||||
});
|
||||
@@ -236,7 +223,6 @@ impl RpcHandler {
|
||||
"name": name,
|
||||
"old_onion": old_onion,
|
||||
"new_onion": new_onion,
|
||||
"transition_hours": ROTATION_TRANSITION_SECS / 3600,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -391,23 +377,26 @@ async fn list_services(config_dir: &std::path::Path) -> Result<Vec<TorService>>
|
||||
}
|
||||
|
||||
// Then, scan filesystem for any hidden_service_* dirs not in config
|
||||
if let Ok(entries) = std::fs::read_dir(&base) {
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.starts_with("hidden_service_") && entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
|
||||
let service_name = name.strip_prefix("hidden_service_").unwrap_or(&name).to_string();
|
||||
if seen.contains(&service_name) {
|
||||
continue;
|
||||
// Check both /var/lib/tor/ and /var/lib/archipelago/tor/
|
||||
for scan_dir in ["/var/lib/tor", &base] {
|
||||
if let Ok(entries) = std::fs::read_dir(scan_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.starts_with("hidden_service_") && entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
|
||||
let service_name = name.strip_prefix("hidden_service_").unwrap_or(&name).to_string();
|
||||
if seen.contains(&service_name) {
|
||||
continue;
|
||||
}
|
||||
let onion = read_onion_address(&service_name);
|
||||
let port = known_service_port(&service_name);
|
||||
seen.insert(service_name.clone());
|
||||
services.push(TorService {
|
||||
name: service_name,
|
||||
local_port: port,
|
||||
onion_address: onion,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
let onion = read_onion_address(&service_name);
|
||||
// Infer port from known services
|
||||
let port = known_service_port(&service_name);
|
||||
services.push(TorService {
|
||||
name: service_name,
|
||||
local_port: port,
|
||||
onion_address: onion,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -416,7 +405,7 @@ async fn list_services(config_dir: &std::path::Path) -> Result<Vec<TorService>>
|
||||
}
|
||||
|
||||
/// Read .onion address from hostname file.
|
||||
/// Checks tor-hostnames readable copy first, then hidden service dir (with sudo fallback).
|
||||
/// Checks tor-hostnames readable copy, then /var/lib/tor/, then /var/lib/archipelago/tor/.
|
||||
fn read_onion_address(service_name: &str) -> Option<String> {
|
||||
let base = tor_data_dir();
|
||||
let base_path = std::path::Path::new(&base);
|
||||
@@ -435,22 +424,33 @@ fn read_onion_address(service_name: &str) -> Option<String> {
|
||||
return Some(addr);
|
||||
}
|
||||
|
||||
// Fall back to hidden service directory (direct read, then sudo)
|
||||
let path = base_path
|
||||
.join(format!("hidden_service_{}", service_name))
|
||||
.join("hostname");
|
||||
std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.or_else(|| {
|
||||
std::process::Command::new("sudo")
|
||||
.args(["cat", &path.to_string_lossy()])
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
})
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| s.ends_with(".onion") && s.len() >= 60)
|
||||
// Check both /var/lib/tor/ (AppArmor-safe default) and /var/lib/archipelago/tor/
|
||||
let search_bases = [
|
||||
std::path::PathBuf::from("/var/lib/tor"),
|
||||
base_path.to_path_buf(),
|
||||
];
|
||||
for search_base in &search_bases {
|
||||
let path = search_base
|
||||
.join(format!("hidden_service_{}", service_name))
|
||||
.join("hostname");
|
||||
if let Some(addr) = std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.or_else(|| {
|
||||
std::process::Command::new("sudo")
|
||||
.args(["cat", &path.to_string_lossy()])
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
})
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| s.ends_with(".onion") && s.len() >= 60)
|
||||
{
|
||||
return Some(addr);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Known default ports for built-in services.
|
||||
@@ -485,34 +485,17 @@ async fn save_services_config(config_dir: &std::path::Path, config: &ServicesCon
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Propagate address change: publish to Nostr relays and notify federation peers.
|
||||
async fn propagate_address_change(
|
||||
/// Notify federation peers of address change (private peer-to-peer only, never public relays).
|
||||
async fn notify_federation_peers_address_change(
|
||||
data_dir: &std::path::Path,
|
||||
new_onion: &str,
|
||||
old_onion: Option<&str>,
|
||||
relays: &[String],
|
||||
tor_proxy: Option<&str>,
|
||||
) {
|
||||
// 1. Publish updated identity to Nostr relays
|
||||
let identity_dir = data_dir.join("identity");
|
||||
match identity::NodeIdentity::load_or_create(&identity_dir).await {
|
||||
Ok(node_id) => {
|
||||
let did = node_id.did_key();
|
||||
if !relays.is_empty() {
|
||||
match nostr_discovery::publish_node_identity(
|
||||
&identity_dir,
|
||||
&did,
|
||||
new_onion,
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
relays,
|
||||
tor_proxy,
|
||||
).await {
|
||||
Ok(_) => info!("Published updated .onion to Nostr relays"),
|
||||
Err(e) => warn!("Failed to publish to Nostr relays: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Notify federation peers via the old address (still works during transition)
|
||||
let proxy = tor_proxy.unwrap_or("127.0.0.1:9050");
|
||||
match federation::load_nodes(data_dir).await {
|
||||
Ok(peers) => {
|
||||
@@ -520,7 +503,6 @@ async fn propagate_address_change(
|
||||
if peer.onion.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let target_onion = &peer.onion;
|
||||
let payload = serde_json::json!({
|
||||
"method": "federation.peer-address-changed",
|
||||
"params": {
|
||||
@@ -529,7 +511,7 @@ async fn propagate_address_change(
|
||||
"old_onion": old_onion,
|
||||
}
|
||||
});
|
||||
let url = format!("http://{}/rpc/v1", target_onion);
|
||||
let url = format!("http://{}/rpc/v1", &peer.onion);
|
||||
let client = match reqwest::Client::builder()
|
||||
.proxy(reqwest::Proxy::all(format!("socks5h://{}", proxy)).unwrap_or_else(|_| reqwest::Proxy::all("socks5h://127.0.0.1:9050").expect("valid proxy")))
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
|
||||
@@ -391,6 +391,7 @@ impl IdentityManager {
|
||||
purpose: file.purpose,
|
||||
pubkey_hex: file.pubkey_hex,
|
||||
did: file.did,
|
||||
dht_did: None,
|
||||
created_at: file.created_at,
|
||||
nostr_pubkey: file.nostr_pubkey_hex,
|
||||
nostr_npub,
|
||||
|
||||
@@ -4,16 +4,13 @@
|
||||
//! using BEP-44 mutable items on the Mainline DHT.
|
||||
//!
|
||||
//! The did:dht identifier is the z-base-32 encoding of the Ed25519 public key.
|
||||
//! DID Documents are stored as DNS TXT records in the DHT.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use ed25519_dalek::{SigningKey, VerifyingKey};
|
||||
use mainline::Dht;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, info, warn};
|
||||
use tracing::{debug, info};
|
||||
|
||||
/// Cache for resolved did:dht documents (1 hour TTL).
|
||||
pub struct DhtDidCache {
|
||||
@@ -25,7 +22,7 @@ impl DhtDidCache {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
entries: RwLock::new(HashMap::new()),
|
||||
ttl: std::time::Duration::from_secs(3600), // 1 hour
|
||||
ttl: std::time::Duration::from_secs(3600),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +43,6 @@ impl DhtDidCache {
|
||||
}
|
||||
|
||||
/// Generate a did:dht identifier from an Ed25519 public key.
|
||||
/// Format: did:dht:{z-base-32 encoded 32-byte pubkey}
|
||||
pub fn did_from_pubkey(pubkey: &VerifyingKey) -> String {
|
||||
let encoded = zbase32::encode_full_bytes(pubkey.as_bytes());
|
||||
format!("did:dht:{}", encoded)
|
||||
@@ -67,158 +63,52 @@ pub fn pubkey_from_did(did: &str) -> Result<[u8; 32]> {
|
||||
Ok(arr)
|
||||
}
|
||||
|
||||
/// Encode a DID Document as DNS TXT records for DHT publication.
|
||||
/// Returns the serialized DNS packet bytes.
|
||||
fn encode_did_document_dns(pubkey: &VerifyingKey, services: &[(&str, &str)]) -> Result<Vec<u8>> {
|
||||
use simple_dns::{Name, Packet, ResourceRecord, CLASS, rdata::RData};
|
||||
|
||||
let mut packet = Packet::new_query(0);
|
||||
let did_name = Name::new_unchecked("_did.");
|
||||
/// Build a DID Document JSON for an Ed25519 key.
|
||||
fn build_did_document(did: &str, pubkey: &VerifyingKey) -> serde_json::Value {
|
||||
let pubkey_b64 = base64::Engine::encode(
|
||||
&base64::engine::general_purpose::URL_SAFE_NO_PAD,
|
||||
pubkey.as_bytes(),
|
||||
);
|
||||
|
||||
// Root TXT: verification method and relationships
|
||||
let root_txt = format!("vm=k0;auth=0;asm=0;inv=0;del=0");
|
||||
packet.answers.push(ResourceRecord::new(
|
||||
did_name.clone(),
|
||||
CLASS::IN,
|
||||
7200,
|
||||
RData::TXT(simple_dns::rdata::TXT::new().with_string(&root_txt)?),
|
||||
));
|
||||
|
||||
// Key 0: Ed25519 verification key
|
||||
let key_name = Name::new_unchecked("_k0._did.");
|
||||
let key_txt = format!("id=0;t=0;k={}", pubkey_b64);
|
||||
packet.answers.push(ResourceRecord::new(
|
||||
key_name,
|
||||
CLASS::IN,
|
||||
7200,
|
||||
RData::TXT(simple_dns::rdata::TXT::new().with_string(&key_txt)?),
|
||||
));
|
||||
|
||||
// Service endpoints
|
||||
for (i, (id, endpoint)) in services.iter().enumerate() {
|
||||
let svc_name = Name::new_unchecked(&format!("_s{}._did.", i));
|
||||
let svc_txt = format!("id={};t=LinkedDomains;se={}", id, endpoint);
|
||||
packet.answers.push(ResourceRecord::new(
|
||||
svc_name,
|
||||
CLASS::IN,
|
||||
7200,
|
||||
RData::TXT(simple_dns::rdata::TXT::new().with_string(&svc_txt)?),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(packet.build_bytes_vec()?)
|
||||
}
|
||||
|
||||
/// Parse a DNS packet back into a DID Document.
|
||||
fn decode_dns_to_did_document(did: &str, dns_bytes: &[u8]) -> Result<serde_json::Value> {
|
||||
use simple_dns::Packet;
|
||||
|
||||
let packet = Packet::parse(dns_bytes).context("Failed to parse DNS packet")?;
|
||||
|
||||
let mut verification_methods = Vec::new();
|
||||
let mut services = Vec::new();
|
||||
|
||||
for answer in &packet.answers {
|
||||
if let simple_dns::rdata::RData::TXT(txt) = answer.rdata.clone() {
|
||||
let name = answer.name.to_string();
|
||||
let text = txt.attributes().into_iter()
|
||||
.map(|(k, v)| format!("{}={}", k, v.unwrap_or_default()))
|
||||
.collect::<Vec<_>>()
|
||||
.join(";");
|
||||
|
||||
if name.starts_with("_k") && name.contains("._did") {
|
||||
// Parse key record
|
||||
let attrs: HashMap<&str, &str> = text
|
||||
.split(';')
|
||||
.filter_map(|p| p.split_once('='))
|
||||
.collect();
|
||||
if let (Some(id), Some(key_type), Some(key_b64)) =
|
||||
(attrs.get("id"), attrs.get("t"), attrs.get("k"))
|
||||
{
|
||||
let method_type = match *key_type {
|
||||
"0" => "Ed25519VerificationKey2020",
|
||||
_ => "JsonWebKey2020",
|
||||
};
|
||||
verification_methods.push(serde_json::json!({
|
||||
"id": format!("{}#key-{}", did, id),
|
||||
"type": method_type,
|
||||
"controller": did,
|
||||
"publicKeyMultibase": format!("z{}", key_b64),
|
||||
}));
|
||||
}
|
||||
} else if name.starts_with("_s") && name.contains("._did") {
|
||||
// Parse service record
|
||||
let attrs: HashMap<&str, &str> = text
|
||||
.split(';')
|
||||
.filter_map(|p| p.split_once('='))
|
||||
.collect();
|
||||
if let (Some(id), Some(svc_type), Some(endpoint)) =
|
||||
(attrs.get("id"), attrs.get("t"), attrs.get("se"))
|
||||
{
|
||||
services.push(serde_json::json!({
|
||||
"id": format!("{}#{}", did, id),
|
||||
"type": svc_type,
|
||||
"serviceEndpoint": endpoint,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut doc = serde_json::json!({
|
||||
serde_json::json!({
|
||||
"@context": [
|
||||
"https://www.w3.org/ns/did/v1",
|
||||
"https://w3id.org/security/suites/ed2020/v1"
|
||||
],
|
||||
"id": did,
|
||||
"verificationMethod": verification_methods,
|
||||
"authentication": verification_methods.iter()
|
||||
.map(|vm| vm["id"].as_str().unwrap_or_default().to_string())
|
||||
.collect::<Vec<_>>(),
|
||||
"assertionMethod": verification_methods.iter()
|
||||
.map(|vm| vm["id"].as_str().unwrap_or_default().to_string())
|
||||
.collect::<Vec<_>>(),
|
||||
});
|
||||
"verificationMethod": [{
|
||||
"id": format!("{}#key-0", did),
|
||||
"type": "Ed25519VerificationKey2020",
|
||||
"controller": did,
|
||||
"publicKeyMultibase": format!("z{}", pubkey_b64),
|
||||
}],
|
||||
"authentication": [format!("{}#key-0", did)],
|
||||
"assertionMethod": [format!("{}#key-0", did)],
|
||||
"capabilityInvocation": [format!("{}#key-0", did)],
|
||||
"capabilityDelegation": [format!("{}#key-0", did)],
|
||||
})
|
||||
}
|
||||
|
||||
if !services.is_empty() {
|
||||
doc["service"] = serde_json::json!(services);
|
||||
}
|
||||
|
||||
Ok(doc)
|
||||
/// Encode the DID Document as bytes for DHT storage.
|
||||
fn encode_for_dht(did_doc: &serde_json::Value) -> Vec<u8> {
|
||||
serde_json::to_vec(did_doc).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Create and publish a did:dht to the Mainline DHT.
|
||||
/// Returns the did:dht identifier.
|
||||
pub async fn create_and_publish(
|
||||
signing_key: &SigningKey,
|
||||
services: &[(&str, &str)],
|
||||
_services: &[(&str, &str)],
|
||||
) -> Result<String> {
|
||||
let pubkey = signing_key.verifying_key();
|
||||
let did = did_from_pubkey(&pubkey);
|
||||
|
||||
let dns_bytes = encode_did_document_dns(&pubkey, services)?;
|
||||
let did_doc = build_did_document(&did, &pubkey);
|
||||
let payload = encode_for_dht(&did_doc);
|
||||
|
||||
// Publish to DHT using BEP-44 mutable item
|
||||
let dht = Dht::client().context("Failed to create DHT client")?;
|
||||
let dht = mainline::Dht::client().context("Failed to create DHT client")?;
|
||||
|
||||
// Sign and put the mutable item
|
||||
let secret_key_bytes: [u8; 64] = {
|
||||
let mut combined = [0u8; 64];
|
||||
combined[..32].copy_from_slice(&signing_key.to_bytes());
|
||||
combined[32..].copy_from_slice(pubkey.as_bytes());
|
||||
combined
|
||||
};
|
||||
|
||||
let item = mainline::MutableItem::new(
|
||||
mainline::SigningKey::from_bytes(&secret_key_bytes),
|
||||
dns_bytes,
|
||||
0, // seq number
|
||||
None, // no salt
|
||||
);
|
||||
let signer = mainline::SigningKey::from_bytes(&signing_key.to_bytes());
|
||||
let item = mainline::MutableItem::new(signer, payload, 0, None);
|
||||
|
||||
dht.put_mutable(item).context("Failed to publish to DHT")?;
|
||||
|
||||
@@ -227,7 +117,6 @@ pub async fn create_and_publish(
|
||||
}
|
||||
|
||||
/// Resolve a did:dht from the Mainline DHT.
|
||||
/// Returns the W3C DID Document.
|
||||
pub async fn resolve(did: &str, cache: Option<&DhtDidCache>) -> Result<serde_json::Value> {
|
||||
// Check cache first
|
||||
if let Some(cache) = cache {
|
||||
@@ -238,20 +127,21 @@ pub async fn resolve(did: &str, cache: Option<&DhtDidCache>) -> Result<serde_jso
|
||||
}
|
||||
|
||||
let pubkey_bytes = pubkey_from_did(did)?;
|
||||
let pubkey = VerifyingKey::from_bytes(&pubkey_bytes)
|
||||
.context("Invalid Ed25519 public key in did:dht")?;
|
||||
let verifying_key = mainline::VerifyingKey::from_bytes(&pubkey_bytes)
|
||||
.map_err(|e| anyhow::anyhow!("Invalid Ed25519 key: {:?}", e))?;
|
||||
let target = mainline::MutableItem::target_from_key(&verifying_key, &None);
|
||||
|
||||
let dht = Dht::client().context("Failed to create DHT client")?;
|
||||
|
||||
// Get the mutable item from DHT
|
||||
let target = mainline::MutableItem::target_from_key(
|
||||
&mainline::VerifyingKey::from_bytes(&pubkey_bytes).context("Invalid key")?,
|
||||
&None,
|
||||
);
|
||||
let dht = mainline::Dht::client().context("Failed to create DHT client")?;
|
||||
|
||||
let response = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(30),
|
||||
tokio::task::spawn_blocking(move || dht.get_mutable(&target, None, None)),
|
||||
tokio::task::spawn_blocking(move || {
|
||||
// get_mutable returns a Result<IntoIter<MutableItem>>
|
||||
match dht.get_mutable(&target, None, None) {
|
||||
Ok(mut iter) => iter.next(),
|
||||
Err(_) => None,
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.context("DHT resolution timed out")?
|
||||
@@ -259,10 +149,9 @@ pub async fn resolve(did: &str, cache: Option<&DhtDidCache>) -> Result<serde_jso
|
||||
|
||||
match response {
|
||||
Some(item) => {
|
||||
let dns_bytes = item.value();
|
||||
let doc = decode_dns_to_did_document(did, dns_bytes)?;
|
||||
let doc: serde_json::Value = serde_json::from_slice(item.value())
|
||||
.context("Failed to parse DID Document from DHT")?;
|
||||
|
||||
// Cache the result
|
||||
if let Some(cache) = cache {
|
||||
cache.set(did.to_string(), doc.clone()).await;
|
||||
}
|
||||
@@ -278,7 +167,9 @@ pub async fn resolve(did: &str, cache: Option<&DhtDidCache>) -> Result<serde_jso
|
||||
|
||||
/// Store the did:dht identifier for an identity record.
|
||||
pub async fn save_dht_did(data_dir: &Path, identity_id: &str, dht_did: &str) -> Result<()> {
|
||||
let path = data_dir.join("identities").join(format!("{}.json", identity_id));
|
||||
let path = data_dir
|
||||
.join("identities")
|
||||
.join(format!("{}.json", identity_id));
|
||||
if !path.exists() {
|
||||
anyhow::bail!("Identity not found: {}", identity_id);
|
||||
}
|
||||
@@ -308,6 +199,16 @@ mod tests {
|
||||
#[test]
|
||||
fn test_invalid_did() {
|
||||
assert!(pubkey_from_did("did:key:z123").is_err());
|
||||
assert!(pubkey_from_did("did:dht:").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_did_document() {
|
||||
let key = SigningKey::generate(&mut rand::rngs::OsRng);
|
||||
let pubkey = key.verifying_key();
|
||||
let did = did_from_pubkey(&pubkey);
|
||||
let doc = build_did_document(&did, &pubkey);
|
||||
|
||||
assert_eq!(doc["id"], did);
|
||||
assert!(doc["verificationMethod"].as_array().unwrap().len() > 0);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user