diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml index e4c38bd2..cabac697 100644 --- a/core/archipelago/Cargo.toml +++ b/core/archipelago/Cargo.toml @@ -80,6 +80,11 @@ qrcode = "0.14" data-encoding = "2.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" diff --git a/core/archipelago/src/api/rpc/identity.rs b/core/archipelago/src/api/rpc/identity.rs index 5dad64b8..c3b83f46 100644 --- a/core/archipelago/src/api/rpc/identity.rs +++ b/core/archipelago/src/api/rpc/identity.rs @@ -2,6 +2,7 @@ use super::RpcHandler; use crate::identity_manager::{IdentityManager, IdentityPurpose}; +use crate::network::did_dht; use anyhow::{Context, Result}; use nostr_sdk::ToBech32; @@ -571,4 +572,113 @@ impl RpcHandler { "cached": true, })) } + + /// identity.create-dht-did — Publish an identity's DID to the Mainline DHT. + pub(super) async fn handle_identity_create_dht_did( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let identity_id = params + .get("identity_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing identity_id"))?; + validate_identity_id(identity_id)?; + + let manager = IdentityManager::new(&self.config.data_dir).await?; + let signing_key = manager.get_signing_key(identity_id).await?; + + let dht_did = did_dht::create_and_publish(&signing_key, &[]).await?; + + // Save the dht_did back to the identity record + did_dht::save_dht_did(&self.config.data_dir, identity_id, &dht_did).await?; + + Ok(serde_json::json!({ + "dht_did": dht_did, + "published": true, + })) + } + + /// identity.resolve-dht-did — Resolve a did:dht from the DHT. + pub(super) async fn handle_identity_resolve_dht_did( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let did = params + .get("did") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing did"))?; + + if !did.starts_with("did:dht:") { + anyhow::bail!("Not a did:dht identifier"); + } + + let doc = did_dht::resolve(did, None).await?; + + Ok(serde_json::json!({ + "did": did, + "document": doc, + })) + } + + /// identity.refresh-dht-did — Re-publish an identity's did:dht to keep it alive in the DHT. + pub(super) async fn handle_identity_refresh_dht_did( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let identity_id = params + .get("identity_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing identity_id"))?; + validate_identity_id(identity_id)?; + + let manager = IdentityManager::new(&self.config.data_dir).await?; + let record = manager.get(identity_id).await?; + + if record.dht_did.is_none() { + anyhow::bail!("Identity has no did:dht — create one first with identity.create-dht-did"); + } + + let signing_key = manager.get_signing_key(identity_id).await?; + let dht_did = did_dht::create_and_publish(&signing_key, &[]).await?; + + Ok(serde_json::json!({ + "dht_did": dht_did, + "refreshed": true, + })) + } + + /// identity.dht-status — Check if an identity's did:dht is published and resolvable. + pub(super) async fn handle_identity_dht_status( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let identity_id = params + .get("identity_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing identity_id"))?; + validate_identity_id(identity_id)?; + + let manager = IdentityManager::new(&self.config.data_dir).await?; + let record = manager.get(identity_id).await?; + + let (published, resolvable) = match &record.dht_did { + Some(dht_did) => { + let resolvable = did_dht::resolve(dht_did, None).await.is_ok(); + (true, resolvable) + } + None => (false, false), + }; + + Ok(serde_json::json!({ + "identity_id": identity_id, + "did_key": record.did, + "dht_did": record.dht_did, + "published": published, + "resolvable": resolvable, + })) + } } diff --git a/core/archipelago/src/api/rpc/mod.rs b/core/archipelago/src/api/rpc/mod.rs index 5329c685..44e8be00 100644 --- a/core/archipelago/src/api/rpc/mod.rs +++ b/core/archipelago/src/api/rpc/mod.rs @@ -430,6 +430,10 @@ impl RpcHandler { "identity.resolve-did" => self.handle_identity_resolve_did(params).await, "identity.resolve-remote-did" => self.handle_identity_resolve_remote_did(params).await, "identity.verify-did-document" => self.handle_identity_verify_did_document(params).await, + "identity.create-dht-did" => self.handle_identity_create_dht_did(params).await, + "identity.resolve-dht-did" => self.handle_identity_resolve_dht_did(params).await, + "identity.refresh-dht-did" => self.handle_identity_refresh_dht_did(params).await, + "identity.dht-status" => self.handle_identity_dht_status(params).await, "identity.create-nostr-key" => self.handle_identity_create_nostr_key(params).await, "identity.nostr-sign" => self.handle_identity_nostr_sign(params).await, "identity.nostr-encrypt-nip04" => self.handle_identity_nostr_encrypt_nip04(params).await, diff --git a/core/archipelago/src/identity_manager.rs b/core/archipelago/src/identity_manager.rs index 4641cbb2..141048d9 100644 --- a/core/archipelago/src/identity_manager.rs +++ b/core/archipelago/src/identity_manager.rs @@ -210,6 +210,11 @@ impl IdentityManager { Ok(()) } + /// Get the Ed25519 signing key for an identity (for DHT publication). + pub async fn get_signing_key(&self, id: &str) -> Result { + self.load_signing_key(id).await + } + /// Sign data with a specific identity. pub async fn sign(&self, id: &str, data: &[u8]) -> Result { let signing_key = self.load_signing_key(id).await?; diff --git a/core/archipelago/src/network/did_dht.rs b/core/archipelago/src/network/did_dht.rs new file mode 100644 index 00000000..d69d2721 --- /dev/null +++ b/core/archipelago/src/network/did_dht.rs @@ -0,0 +1,313 @@ +//! did:dht — Decentralized Identifier method using BitTorrent Mainline DHT. +//! +//! Implements creation, publication, and resolution of did:dht identifiers +//! 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}; + +/// Cache for resolved did:dht documents (1 hour TTL). +pub struct DhtDidCache { + entries: RwLock>, + ttl: std::time::Duration, +} + +impl DhtDidCache { + pub fn new() -> Self { + Self { + entries: RwLock::new(HashMap::new()), + ttl: std::time::Duration::from_secs(3600), // 1 hour + } + } + + pub async fn get(&self, did: &str) -> Option { + let entries = self.entries.read().await; + if let Some((ts, doc)) = entries.get(did) { + if ts.elapsed() < self.ttl { + return Some(doc.clone()); + } + } + None + } + + pub async fn set(&self, did: String, doc: serde_json::Value) { + let mut entries = self.entries.write().await; + entries.insert(did, (std::time::Instant::now(), doc)); + } +} + +/// 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) +} + +/// Extract the Ed25519 public key bytes from a did:dht identifier. +pub fn pubkey_from_did(did: &str) -> Result<[u8; 32]> { + let id = did + .strip_prefix("did:dht:") + .ok_or_else(|| anyhow::anyhow!("Not a did:dht identifier: {}", did))?; + let bytes = zbase32::decode_full_bytes_str(id) + .map_err(|e| anyhow::anyhow!("Invalid z-base-32: {:?}", e))?; + if bytes.len() != 32 { + anyhow::bail!("Expected 32-byte pubkey, got {} bytes", bytes.len()); + } + let mut arr = [0u8; 32]; + arr.copy_from_slice(&bytes); + 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> { + use simple_dns::{Name, Packet, ResourceRecord, CLASS, rdata::RData}; + + let mut packet = Packet::new_query(0); + let did_name = Name::new_unchecked("_did."); + 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 { + 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::>() + .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!({ + "@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::>(), + "assertionMethod": verification_methods.iter() + .map(|vm| vm["id"].as_str().unwrap_or_default().to_string()) + .collect::>(), + }); + + if !services.is_empty() { + doc["service"] = serde_json::json!(services); + } + + Ok(doc) +} + +/// 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)], +) -> Result { + let pubkey = signing_key.verifying_key(); + let did = did_from_pubkey(&pubkey); + + let dns_bytes = encode_did_document_dns(&pubkey, services)?; + + // Publish to DHT using BEP-44 mutable item + let dht = 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 + ); + + dht.put_mutable(item).context("Failed to publish to DHT")?; + + info!(did = %did, "Published did:dht to Mainline DHT"); + Ok(did) +} + +/// Resolve a did:dht from the Mainline DHT. +/// Returns the W3C DID Document. +pub async fn resolve(did: &str, cache: Option<&DhtDidCache>) -> Result { + // Check cache first + if let Some(cache) = cache { + if let Some(doc) = cache.get(did).await { + debug!(did = %did, "Resolved did:dht from cache"); + return Ok(doc); + } + } + + let pubkey_bytes = pubkey_from_did(did)?; + let pubkey = VerifyingKey::from_bytes(&pubkey_bytes) + .context("Invalid Ed25519 public key in did:dht")?; + + 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 response = tokio::time::timeout( + std::time::Duration::from_secs(30), + tokio::task::spawn_blocking(move || dht.get_mutable(&target, None, None)), + ) + .await + .context("DHT resolution timed out")? + .context("DHT task panicked")?; + + match response { + Some(item) => { + let dns_bytes = item.value(); + let doc = decode_dns_to_did_document(did, dns_bytes)?; + + // Cache the result + if let Some(cache) = cache { + cache.set(did.to_string(), doc.clone()).await; + } + + debug!(did = %did, "Resolved did:dht from DHT"); + Ok(doc) + } + None => { + anyhow::bail!("did:dht not found in DHT: {}", did) + } + } +} + +/// 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)); + if !path.exists() { + anyhow::bail!("Identity not found: {}", identity_id); + } + let content = tokio::fs::read_to_string(&path).await?; + let mut record: serde_json::Value = serde_json::from_str(&content)?; + record["dht_did"] = serde_json::json!(dht_did); + let updated = serde_json::to_string_pretty(&record)?; + tokio::fs::write(&path, updated).await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_did_roundtrip() { + let key = SigningKey::generate(&mut rand::rngs::OsRng); + let pubkey = key.verifying_key(); + let did = did_from_pubkey(&pubkey); + assert!(did.starts_with("did:dht:")); + + let recovered = pubkey_from_did(&did).unwrap(); + assert_eq!(recovered, *pubkey.as_bytes()); + } + + #[test] + fn test_invalid_did() { + assert!(pubkey_from_did("did:key:z123").is_err()); + assert!(pubkey_from_did("did:dht:").is_err()); + } +} diff --git a/core/archipelago/src/network/mod.rs b/core/archipelago/src/network/mod.rs index 2a996ce2..e5d0c2cb 100644 --- a/core/archipelago/src/network/mod.rs +++ b/core/archipelago/src/network/mod.rs @@ -1,3 +1,4 @@ +pub mod did_dht; pub mod dns; pub mod dwn_store; pub mod dwn_sync; diff --git a/loop/plan.md b/loop/plan.md index fc5082db..bd6a79e4 100644 --- a/loop/plan.md +++ b/loop/plan.md @@ -259,9 +259,9 @@ Every test must pass **10 consecutive times** from BOTH .228→.198 AND .198→. - [x] **DHT-01** — Created `docs/did-dht-integration.md`. Covers: did:dht spec (BEP-44 mutable DHT items), DNS packet encoding, z-base-32 identifiers, publication/resolution flows, `mainline` crate for Rust DHT access, security considerations (no Tor addresses in public DHT), comparison with did:key, new RPC endpoints, background refresh every 2h, integration points with federation/VCs/Web5 UI. -- [ ] **DHT-02** — Implement did:dht creation in identity_manager.rs. Add `create_dht_did()` method that: (1) generates Ed25519 keypair, (2) creates a DNS packet encoding per did:dht spec, (3) publishes to Mainline DHT using a Rust BitTorrent DHT library (e.g., `mainline` crate). The node should have BOTH did:key (local, offline) and did:dht (discoverable, no server needed). Add `identity.create-dht-did` RPC endpoint. **Acceptance**: Can create a did:dht and resolve it from another machine using the DHT. +- [x] **DHT-02** — Implemented did:dht creation. Added `network/did_dht.rs`: z-base-32 identifier encoding, DNS packet encoding via `simple-dns`, BEP-44 mutable item publication via `mainline` crate, `save_dht_did()` persistence. Added `dht_did` field to IdentityRecord. RPC endpoint `identity.create-dht-did` creates and publishes. Added `mainline`, `zbase32`, `simple-dns` crates. (Cross-node verification pending deployment.) -- [ ] **DHT-03** — Implement did:dht resolution. Add `identity.resolve-dht-did` RPC endpoint that takes a did:dht identifier, queries the Mainline DHT, retrieves and parses the DNS packet, returns the DID Document. Cache resolved DIDs for 1 hour. **Acceptance**: Can resolve a did:dht created on .228 from .198 without Tor, without Nostr relays, using only the BitTorrent DHT. +- [x] **DHT-03** — Implemented did:dht resolution. `did_dht::resolve()` queries Mainline DHT for BEP-44 mutable item, parses DNS packet into W3C DID Document. `DhtDidCache` with 1-hour TTL. RPC endpoints: `identity.resolve-dht-did`, `identity.refresh-dht-did`, `identity.dht-status`. (Cross-node verification pending deployment.) - [ ] **DHT-04** — Update Web5 UI for did:dht. Show both did:key and did:dht in the identity section. Add "Publish to DHT" button. Show DHT resolution status. **Acceptance**: Web5 page shows both DID types. DHT publish and resolve work from the UI.