feat: client api for pkarr record republishing (#79)

* 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>
This commit is contained in:
SHAcollision
2025-03-17 10:46:33 -04:00
committed by GitHub
parent 3407a90756
commit b685f8a085
4 changed files with 352 additions and 27 deletions

View File

@@ -34,6 +34,9 @@ macro_rules! handle_http_error {
pub struct ClientBuilder {
pkarr: pkarr::ClientBuilder,
http_request_timeout: Option<Duration>,
/// Maximum age in microseconds before a user record should be republished.
/// Defaults to 1 hour.
max_record_age: Option<Duration>,
}
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<Client, BuildError> {
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 {

View File

@@ -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<Session> {
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<T: IntoUrl>(
@@ -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"
);
}
}

View File

@@ -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.<public_key>`.
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<SignedPacket>,
) -> 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<String> {
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<String> {
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(())
}
}

View File

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