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:
parent
a7d6934528
commit
d52107f951
@ -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"
|
||||
|
||||
|
||||
@ -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,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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?;
|
||||
|
||||
313
core/archipelago/src/network/did_dht.rs
Normal file
313
core/archipelago/src/network/did_dht.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
pub mod did_dht;
|
||||
pub mod dns;
|
||||
pub mod dwn_store;
|
||||
pub mod dwn_sync;
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user