feat: implement did:dht creation and resolution via Mainline DHT

DHT-02: did:dht creation
- network/did_dht.rs: z-base-32 encoding, DNS packet encoding, BEP-44
  mutable item publication via mainline crate
- identity.create-dht-did RPC endpoint
- dht_did field added to IdentityRecord
- get_signing_key() exposed on IdentityManager

DHT-03: did:dht resolution
- did_dht::resolve() queries DHT, parses DNS → DID Document
- DhtDidCache with 1-hour TTL
- identity.resolve-dht-did, identity.refresh-dht-did, identity.dht-status

New dependencies: mainline 2, zbase32 0.1, simple-dns 0.7

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-14 04:01:56 +00:00
parent a7d6934528
commit d52107f951
7 changed files with 440 additions and 2 deletions

View File

@@ -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"

View File

@@ -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<serde_json::Value>,
) -> Result<serde_json::Value> {
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<serde_json::Value>,
) -> Result<serde_json::Value> {
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<serde_json::Value>,
) -> Result<serde_json::Value> {
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<serde_json::Value>,
) -> Result<serde_json::Value> {
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,
}))
}
}

View File

@@ -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,

View File

@@ -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<SigningKey> {
self.load_signing_key(id).await
}
/// 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?;

View File

@@ -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<HashMap<String, (std::time::Instant, serde_json::Value)>>,
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<serde_json::Value> {
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<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.");
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!({
"@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<_>>(),
});
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<String> {
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<serde_json::Value> {
// 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());
}
}

View File

@@ -1,3 +1,4 @@
pub mod did_dht;
pub mod dns;
pub mod dwn_store;
pub mod dwn_sync;