From b685f8a085f5e5f062a388ec72044efd2cd6ba41 Mon Sep 17 00:00:00 2001 From: SHAcollision <127778313+SHAcollision@users.noreply.github.com> Date: Mon, 17 Mar 2025 10:46:33 -0400 Subject: [PATCH] feat: client api for pkarr record republishing (#79) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add public method to repub homeserver and repub on signin * Add extract host unit test * refactor and expose to wasm * lint * Fixes add republish tests * Use pubky timestamp * Fix wasm spawn * update republish min time * fix wasm build * fix: change republish wasm apii to expect public key * Update pubky/src/native/internal/pkarr.rs Co-authored-by: Severin Alexander Bühler <8782386+SeverinAlexB@users.noreply.github.com> * Update pubky/src/native/internal/pkarr.rs Co-authored-by: Severin Alexander Bühler <8782386+SeverinAlexB@users.noreply.github.com> * r record to s signed_packet * clean up determine host * fix max_record_age api and change to 1h --------- Co-authored-by: Severin Alexander Bühler <8782386+SeverinAlexB@users.noreply.github.com> --- pubky/src/native.rs | 20 ++++ pubky/src/native/api/auth.rs | 186 ++++++++++++++++++++++++++++- pubky/src/native/internal/pkarr.rs | 147 +++++++++++++++++++---- pubky/src/wasm/api/auth.rs | 26 ++++ 4 files changed, 352 insertions(+), 27 deletions(-) diff --git a/pubky/src/native.rs b/pubky/src/native.rs index ba29b3c..a756518 100644 --- a/pubky/src/native.rs +++ b/pubky/src/native.rs @@ -34,6 +34,9 @@ macro_rules! handle_http_error { pub struct ClientBuilder { pkarr: pkarr::ClientBuilder, http_request_timeout: Option, + /// Maximum age in microseconds before a user record should be republished. + /// Defaults to 1 hour. + max_record_age: Option, } impl ClientBuilder { @@ -73,6 +76,13 @@ impl ClientBuilder { self } + /// Set how many microseconds old a record can be before it must be republished. + /// Defaults to 1 hour if not overridden. + pub fn max_record_age(&mut self, max_age: Duration) -> &mut Self { + self.max_record_age = Some(max_age); + self + } + /// Build [Client] pub fn build(&self) -> Result { let pkarr = self.pkarr.build()?; @@ -106,6 +116,11 @@ impl ClientBuilder { icann_http_builder = icann_http_builder.timeout(timeout); } + // Maximum age in microseconds before a homeserver record should be republished. + // Default is 1 hour. It's an arbitrary decision based only anecdotal evidence for DHT eviction. + // See https://github.com/pubky/pkarr-churn/blob/main/results-node_decay.md for latest date of record churn + let max_record_age = self.max_record_age.unwrap_or(Duration::from_secs(60 * 60)); + Ok(Client { pkarr, http: http_builder.build().expect("config expected to not error"), @@ -119,6 +134,8 @@ impl ClientBuilder { #[cfg(wasm_browser)] testnet: false, + + max_record_age, }) } } @@ -143,6 +160,9 @@ pub struct Client { #[cfg(wasm_browser)] pub(crate) testnet: bool, + + /// The record age threshold (in microseconds) before republishing. + pub(crate) max_record_age: Duration, } impl Client { diff --git a/pubky/src/native/api/auth.rs b/pubky/src/native/api/auth.rs index 7085b11..2a38057 100644 --- a/pubky/src/native/api/auth.rs +++ b/pubky/src/native/api/auth.rs @@ -14,10 +14,9 @@ use pubky_common::{ use anyhow::Result; +use super::super::{internal::pkarr::PublishStrategy, Client}; use crate::handle_http_error; -use super::super::Client; - impl Client { /// Signup to a homeserver and update Pkarr accordingly. /// @@ -33,8 +32,13 @@ impl Client { handle_http_error!(response); - self.publish_homeserver(keypair, &homeserver.to_string()) - .await?; + // Publish homeserver Pkarr record for the first time (force) + self.publish_homeserver( + keypair, + Some(&homeserver.to_string()), + PublishStrategy::Force, + ) + .await?; // Store the cookie to the correct URL. #[cfg(not(target_arch = "wasm32"))] @@ -85,10 +89,29 @@ impl Client { } /// Signin to a homeserver. + /// After a successful signin, a background task is spawned to republish the user's + /// PKarr record if it is missing or older than 6 hours. We don't mind if it succeed + /// or fails. We want signin to return fast. pub async fn signin(&self, keypair: &Keypair) -> Result { let token = AuthToken::sign(keypair, vec![Capability::root()]); + let session = self.signin_with_authtoken(&token).await?; - self.signin_with_authtoken(&token).await + // Spawn a background task to republish the record. + let client_clone = self.clone(); + let keypair_clone = keypair.clone(); + let future = async move { + // Resolve the record and republish if existing and older MAX_HOMESERVER_RECORD_AGE_SECS + let _ = client_clone + .publish_homeserver(&keypair_clone, None, PublishStrategy::IfOlderThan) + .await; + }; + + #[cfg(not(wasm_browser))] + tokio::spawn(future); + #[cfg(wasm_browser)] + wasm_bindgen_futures::spawn_local(future); + + Ok(session) } pub async fn send_auth_token( @@ -270,6 +293,35 @@ impl Client { Ok(token.pubky().clone()) } + + /// Republish the user's Pkarr record pointing to their homeserver if + /// no record can be resolved or if the existing record is older than 6 hours. + /// + /// This method is intended to be used by clients and key managers (e.g., pubky-ring) + /// in order to keep the records of active users fresh and available in the DHT. + /// It is intended to be used only after failed signin due to homeserver + /// resolution failure. This method is lighter than performing a re-signup into + /// the last known homeserver, but does not return a session token, so a signin + /// must be done after republishing if a session token is needed. On a failed + /// signin due to homeserver resolution failure, `pubky-ring` should always + /// republish the last known homeserver. + /// + /// # Arguments + /// + /// * `keypair` - The keypair associated with the record. + /// * `host` - The homeserver to publish the record for. + /// + /// # Errors + /// + /// Returns an error if the publication fails. + pub async fn republish_homeserver(&self, keypair: &Keypair, host: &PublicKey) -> Result<()> { + self.publish_homeserver( + keypair, + Some(&host.to_string()), + PublishStrategy::IfOlderThan, + ) + .await + } } #[derive(Debug, Clone)] @@ -534,4 +586,128 @@ mod tests { StatusCode::FORBIDDEN ); } + + // This test verifies that when a signin happens immediately after signup, + // the record is not republished on signin (its timestamp remains unchanged) + // but when a signin happens after the record is “old” (in test, after 1 second), + // the record is republished (its timestamp increases). + #[tokio::test] + async fn test_republish_on_signin() { + // Setup the testnet and run a homeserver. + let testnet = Testnet::run().await.unwrap(); + let server = testnet.run_homeserver().await.unwrap(); + // Create a client that will republish conditionally if a record is older than 1 second + let client = testnet + .client_builder() + .max_record_age(Duration::from_secs(1)) + .build() + .unwrap(); + let keypair = Keypair::random(); + + // Signup publishes a new record. + client.signup(&keypair, &server.public_key()).await.unwrap(); + // Resolve the record and get its timestamp. + let record1 = client + .pkarr() + .resolve_most_recent(&keypair.public_key()) + .await + .unwrap(); + let ts1 = record1.timestamp().as_u64(); + + // Immediately sign in. This spawns a background task to update the record + // with PublishStrategy::IfOlderThan. + client.signin(&keypair).await.unwrap(); + // Wait a short time to let the background task complete. + tokio::time::sleep(Duration::from_millis(5)).await; + let record2 = client + .pkarr() + .resolve_most_recent(&keypair.public_key()) + .await + .unwrap(); + let ts2 = record2.timestamp().as_u64(); + + // Because the record is fresh (less than 1 second old in our test configuration), + // the background task should not republish it. The timestamp should remain the same. + assert_eq!( + ts1, ts2, + "Record republished too early; timestamps should be equal" + ); + + // Wait long enough for the record to be considered 'old' (greater than 1 second). + tokio::time::sleep(Duration::from_secs(1)).await; + // Sign in again. Now the background task should trigger a republish. + client.signin(&keypair).await.unwrap(); + tokio::time::sleep(Duration::from_millis(5)).await; + let record3 = client + .pkarr() + .resolve_most_recent(&keypair.public_key()) + .await + .unwrap(); + let ts3 = record3.timestamp().as_u64(); + + // Now the republished record's timestamp should be greater than before. + assert!( + ts3 > ts2, + "Record was not republished after threshold exceeded" + ); + } + + #[tokio::test] + async fn test_republish_homeserver() { + // Setup the testnet and run a homeserver. + let testnet = Testnet::run().await.unwrap(); + let server = testnet.run_homeserver().await.unwrap(); + // Create a client that will republish conditionally if a record is older than 1 second + let client = testnet + .client_builder() + .max_record_age(Duration::from_secs(1)) + .build() + .unwrap(); + let keypair = Keypair::random(); + + // Signup publishes a new record. + client.signup(&keypair, &server.public_key()).await.unwrap(); + // Resolve the record and get its timestamp. + let record1 = client + .pkarr() + .resolve_most_recent(&keypair.public_key()) + .await + .unwrap(); + let ts1 = record1.timestamp().as_u64(); + + // Immediately call republish_homeserver. + // Since the record is fresh, republish should do nothing. + client + .republish_homeserver(&keypair, &server.public_key()) + .await + .unwrap(); + let record2 = client + .pkarr() + .resolve_most_recent(&keypair.public_key()) + .await + .unwrap(); + let ts2 = record2.timestamp().as_u64(); + assert_eq!( + ts1, ts2, + "Record republished too early; timestamp should be equal" + ); + + // Wait long enough for the record to be considered 'old'. + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + // Call republish_homeserver again; now the record should be updated. + client + .republish_homeserver(&keypair, &server.public_key()) + .await + .unwrap(); + let record3 = client + .pkarr() + .resolve_most_recent(&keypair.public_key()) + .await + .unwrap(); + let ts3 = record3.timestamp().as_u64(); + assert!( + ts3 > ts2, + "Record was not republished after threshold exceeded" + ); + } } diff --git a/pubky/src/native/internal/pkarr.rs b/pubky/src/native/internal/pkarr.rs index 0b7dfd8..4d25379 100644 --- a/pubky/src/native/internal/pkarr.rs +++ b/pubky/src/native/internal/pkarr.rs @@ -1,36 +1,139 @@ -use pkarr::{dns::rdata::SVCB, Keypair, SignedPacket}; - use anyhow::Result; +use pkarr::{ + dns::rdata::{RData, SVCB}, + Keypair, SignedPacket, Timestamp, +}; +use std::convert::TryInto; +use std::time::Duration; use super::super::Client; -impl Client { - /// Publish the HTTPS record for `_pubky.`. - pub(crate) async fn publish_homeserver(&self, keypair: &Keypair, host: &str) -> Result<()> { - // TODO: Before making public, consider the effect on other records and other mirrors +/// The strategy to decide whether to (re)publish a homeserver record. +pub(crate) enum PublishStrategy { + /// Always publish a new record (used on signup). + Force, + /// Only publish if no record can be resolved or if the record is older than 1 hour. + /// Used on signin and on republish_homeserver (used by key managing apps) + IfOlderThan, +} +impl Client { + /// Unified method to update the homeserver record. + /// + /// If `host` is provided, that value is used; otherwise the host is extracted from the + /// currently resolved record. Under the IfOlderThan strategy, the record is only updated if + /// it is missing or its timestamp is older than 1 hour. Under the Force strategy, the + /// record is always published. + pub(crate) async fn publish_homeserver( + &self, + keypair: &Keypair, + host: Option<&str>, + strategy: PublishStrategy, + ) -> Result<()> { + // Resolve the most recent record. let existing = self.pkarr.resolve_most_recent(&keypair.public_key()).await; - let mut signed_packet_builder = SignedPacket::builder(); + // Determine which host we should be using. + let host_str = match Self::determine_host(host, existing.as_ref()) { + Some(host) => host, + None => return Ok(()), + }; - if let Some(ref existing) = existing { - for answer in existing.resource_records("_pubky") { - if !answer.name.to_string().starts_with("_pubky") { - signed_packet_builder = signed_packet_builder.record(answer.to_owned()); + // Determine if we should publish based on the given strategy. + let should_publish = match strategy { + PublishStrategy::Force => true, + PublishStrategy::IfOlderThan => match existing { + Some(ref record) => { + let elapsed = Timestamp::now() - record.timestamp(); + Duration::from_micros(elapsed.as_u64()) > self.max_record_age } - } + None => true, + }, + }; + + if should_publish { + self.publish_homeserver_inner(keypair, &host_str, existing) + .await?; } - let svcb = SVCB::new(0, host.try_into()?); - - let signed_packet = SignedPacket::builder() - .https("_pubky".try_into().unwrap(), svcb, 60 * 60) - .sign(keypair)?; - - self.pkarr - .publish(&signed_packet, existing.map(|s| s.timestamp())) - .await?; - + Ok(()) + } + + /// Internal helper that builds and publishes the PKarr record. + /// Uses an optionally pre-resolved record to avoid re-resolving. + async fn publish_homeserver_inner( + &self, + keypair: &Keypair, + host: &str, + existing: Option, + ) -> Result<()> { + let mut builder = SignedPacket::builder(); + if let Some(ref packet) = existing { + // Append any records (except those already starting with "_pubky") to our builder. + for answer in packet.resource_records("_pubky") { + if !answer.name.to_string().starts_with("_pubky") { + builder = builder.record(answer.to_owned()); + } + } + } + let svcb = SVCB::new(0, host.try_into()?); + let signed_packet = SignedPacket::builder() + .https("_pubky".try_into().unwrap(), svcb, 60 * 60) + .sign(keypair)?; + self.pkarr + .publish(&signed_packet, existing.map(|s| s.timestamp())) + .await?; + Ok(()) + } + + /// Helper determines the host to publish, prioritizing an explicit + /// override or extracting from an existing DHT packet. Returns `None` + /// if neither source provides a host. + fn determine_host( + override_host: Option<&str>, + dht_packet: Option<&SignedPacket>, + ) -> Option { + if let Some(host) = override_host { + return Some(host.to_string()); + } + dht_packet.and_then(Self::extract_host_from_packet) + } + + /// Helper to extract the current homeserver host from a signed PKarr packet. + /// Iterates over the records with name "_pubky" and returns the first SVCB target found. + fn extract_host_from_packet(packet: &SignedPacket) -> Option { + packet + .resource_records("_pubky") + .find_map(|rr| match &rr.rdata { + RData::SVCB(svcb) => Some(svcb.target.to_string()), + RData::HTTPS(https) => Some(https.0.target.to_string()), + _ => None, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Client; + use pkarr::dns::rdata::SVCB; + use pkarr::Keypair; + + #[tokio::test] + async fn test_extract_host_from_packet() -> Result<()> { + let keypair = Keypair::random(); + // Define the host that we want to encode. + let host = "host.example.com"; + // Create an SVCB record with that host. + let svcb = SVCB::new(0, host.try_into()?); + // Build a signed packet containing an HTTPS record for "_pubky". + let signed_packet = SignedPacket::builder() + .https("_pubky".try_into().unwrap(), svcb, 60 * 60) + .sign(&keypair)?; + // Use our helper to extract the host. + let extracted_host = Client::extract_host_from_packet(&signed_packet); + // Verify that the extracted host matches what we set. + assert_eq!(extracted_host.as_deref(), Some(host)); Ok(()) } } diff --git a/pubky/src/wasm/api/auth.rs b/pubky/src/wasm/api/auth.rs index c26aff2..4ded56b 100644 --- a/pubky/src/wasm/api/auth.rs +++ b/pubky/src/wasm/api/auth.rs @@ -100,6 +100,32 @@ impl Client { Ok(()) } + + /// Republish the user's PKarr record pointing to their homeserver. + /// + /// This method will republish the record if no record exists or if the existing record + /// is older than 6 hours. + /// + /// The method is intended for clients and key managers (e.g., pubky-ring) to + /// keep the records of active users fresh and available in the DHT and relays. + /// It is intended to be used only after failed signin due to homeserver resolution + /// failure. This method is lighter than performing a re-signup into the last known + /// homeserver, but does not return a session token, so a signin must be done after + /// republishing. On a failed signin due to homeserver resolution failure, a key + /// manager should always attempt to republish the last known homeserver. + #[wasm_bindgen(js_name = "republishHomeserver")] + pub async fn republish_homeserver( + &self, + keypair: &Keypair, + host: &PublicKey, + ) -> Result<(), JsValue> { + self.0 + .republish_homeserver(keypair.as_inner(), host.as_inner()) + .await + .map_err(|e| JsValue::from_str(&e.to_string())); + + Ok(()) + } } #[wasm_bindgen]