From 44c2d214fde3c04138ed2b6f588dae562f6c2b87 Mon Sep 17 00:00:00 2001 From: dawn Date: Tue, 22 Sep 2026 23:20:04 +0300 Subject: [PATCH] sites/src,lexicons/temp: verify site ownership and deploy triggers Signed-off-by: dawn --- api/org_tangled/sitegetDomainClaim.go | 2 +- lexicons/temp/repo/deploySite.json | 3 + lexicons/temp/repo/disableSite.json | 5 +- lexicons/temp/repo/getDeployHistory.json | 4 +- lexicons/temp/repo/getSiteConfig.json | 4 +- lexicons/temp/repo/updateSiteConfig.json | 13 +- lexicons/temp/site/claimDomain.json | 6 +- lexicons/temp/site/getDomainClaim.json | 9 +- sites/src/claim.rs | 660 ++++++++++++- sites/src/lib.rs | 48 + sites/src/serviceauth.rs | 90 +- sites/src/sites.rs | 9 +- sites/src/xrpc.rs | 1089 +++++++++++++++++++--- 13 files changed, 1768 insertions(+), 174 deletions(-) diff --git a/api/org_tangled/sitegetDomainClaim.go b/api/org_tangled/sitegetDomainClaim.go index 53132a07f..e08676ec4 100644 --- a/api/org_tangled/sitegetDomainClaim.go +++ b/api/org_tangled/sitegetDomainClaim.go @@ -16,7 +16,7 @@ const ( // TempSiteGetDomainClaim_Output is the output of a org.tangled.temp.site.getDomainClaim call. type TempSiteGetDomainClaim_Output struct { - // domain: The claimed domain (e.g. 'alice.sites.tangled.sh'). Absent if no active claim. + // domain: the site domain (e.g. 'alice.tngl.io') or a verified signup handle, absent if neither exists Domain *string `json:"domain,omitempty" cborgen:"domain,omitempty"` } diff --git a/lexicons/temp/repo/deploySite.json b/lexicons/temp/repo/deploySite.json index 8eda653c7..9abce3972 100644 --- a/lexicons/temp/repo/deploySite.json +++ b/lexicons/temp/repo/deploySite.json @@ -22,6 +22,9 @@ "errors": [ { "name": "AuthError", "description": "Serviceauth verification failed." }, { "name": "NotOwner", "description": "Only the repository owner can deploy." }, + { "name": "RepoNotFound", "description": "No repository record publishes this repo DID." }, + { "name": "OwnershipUnverifiable", "description": "Repository ownership could not be verified." }, + { "name": "SiteConfigDisputed", "description": "The stored site config names a different owner and must be re-saved by the repository owner." }, { "name": "SiteNotFound", "description": "The repository has no site configuration or the owner has no active claim." }, { "name": "WorkerUnavailable", "description": "The sites Worker could not be reached." } ] diff --git a/lexicons/temp/repo/disableSite.json b/lexicons/temp/repo/disableSite.json index 881abe00c..dcb4dd089 100644 --- a/lexicons/temp/repo/disableSite.json +++ b/lexicons/temp/repo/disableSite.json @@ -21,7 +21,10 @@ }, "errors": [ { "name": "SiteNotFound", "description": "No site configuration exists for this repository." }, - { "name": "NotOwner", "description": "The repository's site configuration belongs to a different account." } + { "name": "NotOwner", "description": "The account does not own this repository." }, + { "name": "RepoNotFound", "description": "No repository record publishes this repo DID." }, + { "name": "OwnershipUnverifiable", "description": "Repository ownership could not be verified." }, + { "name": "SiteConfigDisputed", "description": "The stored site config names a different owner and must be re-saved by the repository owner." } ] } } diff --git a/lexicons/temp/repo/getDeployHistory.json b/lexicons/temp/repo/getDeployHistory.json index 005e30d59..03ced6519 100644 --- a/lexicons/temp/repo/getDeployHistory.json +++ b/lexicons/temp/repo/getDeployHistory.json @@ -33,7 +33,9 @@ }, "errors": [ { "name": "AuthError", "description": "Serviceauth verification failed." }, - { "name": "NotOwner", "description": "The repository's site configuration belongs to a different account." }, + { "name": "NotOwner", "description": "The account does not own this repository." }, + { "name": "RepoNotFound", "description": "No repository record publishes this repo DID." }, + { "name": "OwnershipUnverifiable", "description": "Repository ownership could not be verified." }, { "name": "SiteNotFound", "description": "The repository has no site configuration or the owner has no active claim." } ] }, diff --git a/lexicons/temp/repo/getSiteConfig.json b/lexicons/temp/repo/getSiteConfig.json index 5e8ab0657..fab28cb94 100644 --- a/lexicons/temp/repo/getSiteConfig.json +++ b/lexicons/temp/repo/getSiteConfig.json @@ -30,7 +30,9 @@ } }, "errors": [ - { "name": "NotOwner", "description": "The repository's site configuration belongs to a different account." } + { "name": "NotOwner", "description": "The account does not own this repository." }, + { "name": "RepoNotFound", "description": "No repository record publishes this repo DID." }, + { "name": "OwnershipUnverifiable", "description": "Repository ownership could not be verified." } ] }, "siteConfig": { diff --git a/lexicons/temp/repo/updateSiteConfig.json b/lexicons/temp/repo/updateSiteConfig.json index cc5d12d7b..90ff7484c 100644 --- a/lexicons/temp/repo/updateSiteConfig.json +++ b/lexicons/temp/repo/updateSiteConfig.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "procedure", - "description": "Create or update the site configuration for a repository owned by the authenticated user. Writing the config queues a deploy of the site; a manual org.tangled.temp.repo.deploySite call or a push to the configured branch also deploys.", + "description": "Create or update the site configuration for a repository owned by the authenticated user. Writing the config schedules a deploy attempt; the deploy outcome is recorded in history. If the deploy service is disabled, the config is saved but WorkerUnavailable is returned.", "input": { "encoding": "application/json", "schema": { @@ -44,9 +44,16 @@ } }, "errors": [ - { "name": "NotOwner", "description": "The repository's site configuration belongs to a different account." }, + { "name": "NotOwner", "description": "The account is not the publisher of this repository's record." }, + { "name": "RepoNotFound", "description": "No repository record publishes this repo DID." }, + { "name": "OwnershipUnverifiable", "description": "Repository ownership could not be verified; the config was not saved." }, + { "name": "RepoIdentityMismatch", "description": "The request's repository name, key or knot does not match its record." }, { "name": "NoDomainClaim", "description": "The account does not have an active domain claim." }, - { "name": "InvalidDirectory", "description": "The deploy directory path is invalid." } + { "name": "HandleNotOwned", "description": "The handle authority identifies a different account." }, + { "name": "HandleUnknown", "description": "The handle authority does not know this handle." }, + { "name": "ClaimUnverifiable", "description": "The handle authority could not be reached to verify this claim." }, + { "name": "InvalidDirectory", "description": "The deploy directory path is invalid." }, + { "name": "WorkerUnavailable", "description": "The config was saved but the deploy service is disabled." } ] } } diff --git a/lexicons/temp/site/claimDomain.json b/lexicons/temp/site/claimDomain.json index 5df51f562..36a736469 100644 --- a/lexicons/temp/site/claimDomain.json +++ b/lexicons/temp/site/claimDomain.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "procedure", - "description": "Claim a full sites domain (e.g. alice.tngl.io) for the authenticated user. A claim is one per account; tngl.sh handle domains are claimed automatically and cannot be released.", + "description": "Claim a full sites domain (e.g. alice.tngl.io) for the authenticated user. A claim is one per account. tngl.sh handles require confirmation from the handle authority and cannot be released.", "input": { "encoding": "application/json", "schema": { @@ -22,7 +22,9 @@ { "name": "DomainTaken", "description": "That domain is already claimed by another user." }, { "name": "DomainCooldown", "description": "That domain was recently released and is in a 30-day cooldown period." }, { "name": "AlreadyClaimed", "description": "You already have a domain claimed. Release it before claiming a new one." }, - { "name": "InvalidSubdomain", "description": "The domain label or TLD is invalid." } + { "name": "InvalidSubdomain", "description": "The domain label or TLD is invalid." }, + { "name": "HandleNotOwned", "description": "The handle authority identifies a different account." }, + { "name": "HandleUnknown", "description": "The handle authority does not know this handle." } ] } } diff --git a/lexicons/temp/site/getDomainClaim.json b/lexicons/temp/site/getDomainClaim.json index ad6c539b3..1457781e4 100644 --- a/lexicons/temp/site/getDomainClaim.json +++ b/lexicons/temp/site/getDomainClaim.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Get the active sites domain claim for the authenticated user, if any.", + "description": "Get the active sites domain or a verified signup handle for the authenticated user, if any. A signup handle may not have a D1 claim row until the first site write.", "output": { "encoding": "application/json", "schema": { @@ -12,11 +12,14 @@ "properties": { "domain": { "type": "string", - "description": "The claimed domain (e.g. 'alice.sites.tangled.sh'). Absent if no active claim." + "description": "the site domain (e.g. 'alice.tngl.io') or a verified signup handle, absent if neither exists" } } } - } + }, + "errors": [ + { "name": "ClaimUnverifiable", "description": "The handle authority could not be reached to verify this claim." } + ] } } } diff --git a/sites/src/claim.rs b/sites/src/claim.rs index 0170cfe26..0d8fb2881 100644 --- a/sites/src/claim.rs +++ b/sites/src/claim.rs @@ -1,4 +1,6 @@ +use std::future::Future; + use parking_lot::Mutex; use chrono::format::SecondsFormat; @@ -11,8 +13,10 @@ pub fn utc_now_rfc3339() -> String { Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true) } -pub fn is_permanent_domain(domain: &str) -> bool { - domain.ends_with(".tngl.sh") +pub const HANDLE_TLDS: &[&str] = &["tngl.sh"]; + +pub fn is_handle_domain(domain: &str) -> bool { + validate_domain(domain, HANDLE_TLDS) } pub fn validate_domain(domain: &str, allowed_tlds: &[&str]) -> bool { @@ -54,6 +58,8 @@ pub enum ClaimError { AlreadyClaimed, InvalidSubdomain, DomainPermanent, + HandleNotOwned, + HandleUnknown, Store(String), } @@ -230,45 +236,57 @@ impl SitesStore { } } - pub async fn delete_claim_by_domain(&self, domain: &str) -> Result<(), String> { + pub async fn delete_claim_if_owned_by(&self, domain: &str, did: &str) -> Result { match &self.inner { StoreInner::D1(db) => { - db.prepare("DELETE FROM domain_claims WHERE domain = ?") - .bind_refs(&[D1Type::Text(domain)]) + let result = db + .prepare("DELETE FROM domain_claims WHERE domain = ? AND did = ?") + .bind_refs(&[D1Type::Text(domain), D1Type::Text(did)]) .map_err(|e| e.to_string())? .run() .await .map_err(|e| e.to_string())?; - Ok(()) + Ok(result + .meta() + .map_err(|e| e.to_string())? + .and_then(|m| m.changes) + .unwrap_or(0) as u64) } StoreInner::Mem(mem) => { let mut mem = mem.lock(); - mem.claims.retain(|c| c.domain != domain); - Ok(()) + let before = mem.claims.len(); + mem.claims.retain(|c| !(c.domain == domain && c.did == did)); + Ok((before - mem.claims.len()) as u64) } } } - pub async fn switch_claim(&self, did: &str, domain: &str) -> Result<(), String> { + pub async fn switch_claim(&self, did: &str, domain: &str) -> Result { match &self.inner { StoreInner::D1(db) => { - db.prepare( - "UPDATE domain_claims SET domain = ?, deleted = NULL WHERE did = ?", - ) - .bind_refs(&[D1Type::Text(domain), D1Type::Text(did)]) - .map_err(|e| e.to_string())? - .run() - .await - .map_err(|e| e.to_string())?; - Ok(()) + let result = db + .prepare("UPDATE domain_claims SET domain = ?, deleted = NULL WHERE did = ?") + .bind_refs(&[D1Type::Text(domain), D1Type::Text(did)]) + .map_err(|e| e.to_string())? + .run() + .await + .map_err(|e| e.to_string())?; + Ok(result + .meta() + .map_err(|e| e.to_string())? + .and_then(|m| m.changes) + .unwrap_or(0) as u64) } StoreInner::Mem(mem) => { let mut mem = mem.lock(); - if let Some(c) = mem.claims.iter_mut().find(|c| c.did == did) { - c.domain = domain.into(); - c.deleted = None; + match mem.claims.iter_mut().find(|c| c.did == did) { + Some(row) => { + row.domain = domain.into(); + row.deleted = None; + Ok(1) + } + None => Ok(0), } - Ok(()) } } } @@ -340,19 +358,146 @@ fn parse_deleted(deleted: &str) -> Result, ClaimError> { .map_err(|_| ClaimError::Store(format!("unparseable deleted timestamp {:?}", deleted))) } -pub async fn claim( +#[derive(Clone, Copy, Debug, PartialEq)] +enum Rights { + Open, + Vouched, + VouchedReplacingStale, +} + +pub async fn claim_vouched( + store: &SitesStore, + now: DateTime, + did: &str, + domain: &str, + allowed_tlds: &[&str], + vouch: F, +) -> Result<(), ClaimError> +where + F: Fn(String) -> Fut, + Fut: Future, String>>, +{ + if !validate_domain(domain, allowed_tlds) { + return Err(ClaimError::InvalidSubdomain); + } + if !is_handle_domain(domain) { + return claim(store, now, did, domain, allowed_tlds, Rights::Open).await; + } + + titled(did, vouch(domain.to_string()).await.map_err(ClaimError::Store)?)?; + + // a renamed account's leftover handle shouldn't block claiming their new one + let rights = match store.select_claim_by_did(did).await.map_err(ClaimError::Store)? { + Some(row) if row.deleted.is_none() && row.domain != domain => { + let still_held = is_handle_domain(&row.domain) + && title_of(did, vouch(row.domain.clone()).await.map_err(ClaimError::Store)?.as_deref()) + .holds(); + if still_held { + Rights::Vouched + } else { + Rights::VouchedReplacingStale + } + } + _ => Rights::Vouched, + }; + claim(store, now, did, domain, allowed_tlds, rights).await +} + +pub async fn resolve_handle(domain: String) -> Result, String> { + if !is_handle_domain(&domain) { + return Err(format!("{} is not a handle domain", domain)); + } + // the zone's origin knows every pds issuing handles under it; asked from + // another zone this goes through the worker, which passes it on there + let url = format!("https://{}/.well-known/atproto-did", domain); + let mut resp = Fetch::Url(url.parse().map_err(|e| format!("bad handle url: {}", e))?) + .send() + .await + .map_err(|e| format!("handle {} unreachable: {}", domain, e))?; + + let status = resp.status_code(); + if !(200..300).contains(&status) { + if !zone_denies_handle(status) { + return Err(format!("handle {} answered HTTP {}", domain, status)); + } + return Ok(None); + } + + let body = resp + .text() + .await + .map_err(|e| format!("handle {} answered an unreadable body: {}", domain, e))?; + let did = body.trim(); + if !did.starts_with("did:") { + return Err(format!("handle {} answered something that isn't a did", domain)); + } + Ok(Some(did.to_string())) +} + +pub fn self_claimed_handle( + did: &str, + declared: Option<&str>, + resolved: Option<&str>, +) -> Option { + let declared = declared?; + (is_handle_domain(declared) && resolved == Some(did)).then(|| declared.to_string()) +} + +fn titled(did: &str, vouched: Option) -> Result<(), ClaimError> { + match title_of(did, vouched.as_deref()) { + Title::Held => Ok(()), + Title::Squatted => Err(ClaimError::HandleNotOwned), + Title::Unknown | Title::Unverifiable => Err(ClaimError::HandleUnknown), + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Title { + Held, + Squatted, + Unknown, + Unverifiable, +} + +fn zone_denies_handle(status: u16) -> bool { + status == 400 || status == 404 +} + +impl Title { + pub fn holds(self) -> bool { + matches!(self, Title::Held) + } +} + +pub fn title_of(did: &str, resolved: Option<&str>) -> Title { + match resolved { + Some(owner) if owner == did => Title::Held, + Some(_) => Title::Squatted, + None => Title::Unknown, + } +} + +pub async fn handle_title(domain: &str, did: &str) -> Title { + match resolve_handle(domain.to_string()).await { + Ok(resolved) => title_of(did, resolved.as_deref()), + Err(_) => Title::Unverifiable, + } +} + +async fn claim( store: &SitesStore, now: DateTime, did: &str, domain: &str, allowed_tlds: &[&str], + rights: Rights, ) -> Result<(), ClaimError> { if !validate_domain(domain, allowed_tlds) { return Err(ClaimError::InvalidSubdomain); } for _ in 0..2 { - match claim_once(store, now, did, domain).await { + match claim_once(store, now, did, domain, rights).await { Err(ClaimError::Store(_)) => continue, other => return other, } @@ -380,6 +525,7 @@ async fn claim_once( now: DateTime, did: &str, domain: &str, + rights: Rights, ) -> Result<(), ClaimError> { let cooldown = Duration::days(CLAIM_COOLDOWN_DAYS); @@ -404,27 +550,63 @@ async fn claim_once( } } match &domain_row.deleted { - None => return Err(ClaimError::DomainTaken), + // clear squatted handle rows if the zone vouches for the caller + None if rights == Rights::Open => return Err(ClaimError::DomainTaken), + None => { + if store + .delete_claim_if_owned_by(domain, &domain_row.did) + .await + .map_err(ClaimError::Store)? + == 0 + { + return Err(ClaimError::Store(format!( + "the claim on {} changed while it was being taken over", + domain + ))); + } + } Some(deleted) => { if now.signed_duration_since(parse_deleted(deleted)?) < cooldown { return Err(ClaimError::DomainCooldown); } - store - .delete_claim_by_domain(domain) + if store + .delete_claim_if_owned_by(domain, &domain_row.did) .await - .map_err(ClaimError::Store)?; + .map_err(ClaimError::Store)? + == 0 + { + return Err(ClaimError::Store(format!( + "the released claim on {} changed while it was being cleared", + domain + ))); + } } } } if let Some(did_row) = store.select_claim_by_did(did).await.map_err(ClaimError::Store)? { - if did_row.deleted.is_none() { + // switching overwrites the tombstone, so cooldown has to finish first + let Some(released) = did_row.deleted.as_deref() else { + if rights == Rights::VouchedReplacingStale { + if store.switch_claim(did, domain).await.map_err(ClaimError::Store)? == 0 { + return Err(ClaimError::Store(format!( + "the row for {} changed while it was being replaced", + did + ))); + } + return Ok(()); + } return Err(ClaimError::AlreadyClaimed); + }; + if now.signed_duration_since(parse_deleted(released)?) < cooldown { + return Err(ClaimError::DomainCooldown); + } + if store.switch_claim(did, domain).await.map_err(ClaimError::Store)? == 0 { + return Err(ClaimError::Store(format!( + "the row for {} changed while it was switching domains", + did + ))); } - store - .switch_claim(did, domain) - .await - .map_err(ClaimError::Store)?; } else { store.insert_claim(did, domain).await.map_err(ClaimError::Store)?; } @@ -437,7 +619,7 @@ pub async fn release( did: &str, domain: &str, ) -> Result { - if is_permanent_domain(domain) { + if is_handle_domain(domain) { return Err(ClaimError::DomainPermanent); } let deleted = now.to_rfc3339_opts(SecondsFormat::Secs, true); @@ -455,6 +637,16 @@ mod tests { const TLDS: &[&str] = &["tngl.io", "tngl.sh"]; + async fn claim_site_name( + store: &SitesStore, + now: DateTime, + did: &str, + domain: &str, + tlds: &[&str], + ) -> Result<(), ClaimError> { + claim(store, now, did, domain, tlds, Rights::Open).await + } + fn t(y: i32, mo: u32, d: u32) -> DateTime { Utc.with_ymd_and_hms(y, mo, d, 0, 0, 0).unwrap() } @@ -462,7 +654,7 @@ mod tests { #[tokio::test] async fn claim_then_get_returns_the_domain() { let store = SitesStore::mem(); - claim(&store, t(2026, 1, 1), "did:alice", "alice.tngl.io", TLDS) + claim_site_name(&store, t(2026, 1, 1), "did:alice", "alice.tngl.io", TLDS) .await .unwrap(); let row = store.select_claim_by_did("did:alice").await.unwrap().unwrap(); @@ -477,10 +669,10 @@ mod tests { #[tokio::test] async fn same_did_same_domain_reclaim_is_an_idempotent_noop() { let store = SitesStore::mem(); - claim(&store, t(2026, 1, 1), "did:alice", "alice.tngl.io", TLDS) + claim_site_name(&store, t(2026, 1, 1), "did:alice", "alice.tngl.io", TLDS) .await .unwrap(); - claim(&store, t(2026, 1, 2), "did:alice", "alice.tngl.io", TLDS) + claim_site_name(&store, t(2026, 1, 2), "did:alice", "alice.tngl.io", TLDS) .await .unwrap(); let row = store.select_claim_by_did("did:alice").await.unwrap().unwrap(); @@ -491,10 +683,10 @@ mod tests { #[tokio::test] async fn same_did_different_domain_is_already_claimed() { let store = SitesStore::mem(); - claim(&store, t(2026, 1, 1), "did:alice", "alice.tngl.io", TLDS) + claim_site_name(&store, t(2026, 1, 1), "did:alice", "alice.tngl.io", TLDS) .await .unwrap(); - let err = claim(&store, t(2026, 1, 2), "did:alice", "blog.tngl.io", TLDS) + let err = claim_site_name(&store, t(2026, 1, 2), "did:alice", "blog.tngl.io", TLDS) .await .unwrap_err(); assert_eq!(err, ClaimError::AlreadyClaimed); @@ -503,10 +695,10 @@ mod tests { #[tokio::test] async fn other_did_claiming_taken_domain_is_domain_taken() { let store = SitesStore::mem(); - claim(&store, t(2026, 1, 1), "did:alice", "alice.tngl.io", TLDS) + claim_site_name(&store, t(2026, 1, 1), "did:alice", "alice.tngl.io", TLDS) .await .unwrap(); - let err = claim(&store, t(2026, 1, 2), "did:bob", "alice.tngl.io", TLDS) + let err = claim_site_name(&store, t(2026, 1, 2), "did:bob", "alice.tngl.io", TLDS) .await .unwrap_err(); assert_eq!(err, ClaimError::DomainTaken); @@ -515,45 +707,411 @@ mod tests { #[tokio::test] async fn release_then_reclaim_within_30_days_is_cooldown() { let store = SitesStore::mem(); - claim(&store, t(2026, 1, 1), "did:alice", "alice.tngl.io", TLDS) + claim_site_name(&store, t(2026, 1, 1), "did:alice", "alice.tngl.io", TLDS) + .await + .unwrap(); + assert!(release(&store, t(2026, 1, 1), "did:alice", "alice.tngl.io") + .await + .unwrap()); + let err = claim_site_name(&store, t(2026, 1, 11), "did:alice", "alice.tngl.io", TLDS) + .await + .unwrap_err(); + assert_eq!(err, ClaimError::DomainCooldown); + } + + #[tokio::test] + async fn a_release_inside_the_cooldown_keeps_the_released_domain_reserved() { + let store = SitesStore::mem(); + claim_site_name(&store, t(2026, 1, 1), "did:alice", "alice.tngl.io", TLDS) .await .unwrap(); assert!(release(&store, t(2026, 1, 1), "did:alice", "alice.tngl.io") .await .unwrap()); - let err = claim(&store, t(2026, 1, 11), "did:alice", "alice.tngl.io", TLDS) + + let err = claim_site_name(&store, t(2026, 1, 3), "did:alice", "blog.tngl.io", TLDS) + .await + .unwrap_err(); + assert_eq!(err, ClaimError::DomainCooldown); + let row = store.select_claim_by_did("did:alice").await.unwrap().unwrap(); + assert_eq!(row.domain, "alice.tngl.io"); + assert!(row.deleted.is_some()); + + let err = claim_site_name(&store, t(2026, 1, 3), "did:bob", "alice.tngl.io", TLDS) .await .unwrap_err(); assert_eq!(err, ClaimError::DomainCooldown); } #[tokio::test] - async fn release_then_claim_a_different_domain_switches_the_did() { + async fn a_release_past_the_cooldown_lets_the_did_switch_and_frees_the_domain() { let store = SitesStore::mem(); - claim(&store, t(2026, 1, 1), "did:alice", "alice.tngl.io", TLDS) + claim_site_name(&store, t(2026, 1, 1), "did:alice", "alice.tngl.io", TLDS) .await .unwrap(); assert!(release(&store, t(2026, 1, 1), "did:alice", "alice.tngl.io") .await .unwrap()); - claim(&store, t(2026, 1, 3), "did:alice", "blog.tngl.io", TLDS) + claim_site_name(&store, t(2026, 2, 1), "did:alice", "blog.tngl.io", TLDS) .await .unwrap(); let row = store.select_claim_by_did("did:alice").await.unwrap().unwrap(); assert_eq!(row.domain, "blog.tngl.io"); assert_eq!(row.deleted, None); + + claim_site_name(&store, t(2026, 2, 1), "did:bob", "alice.tngl.io", TLDS) + .await + .unwrap(); + let row = store + .select_claim_by_domain("alice.tngl.io") + .await + .unwrap() + .unwrap(); + assert_eq!(row.did, "did:bob"); + } + + #[tokio::test] + async fn a_handle_is_claimed_only_by_the_account_its_zone_resolves_it_to() { + let store = SitesStore::mem(); + + let taken = claim_vouched( + &store, + t(2026, 1, 1), + "did:attacker", + "alice.tngl.sh", + TLDS, + |_domain| async { Ok(Some("did:alice".to_string())) }, + ) + .await + .unwrap_err(); + assert_eq!(taken, ClaimError::HandleNotOwned); + assert_eq!(store.select_claim_by_domain("alice.tngl.sh").await.unwrap(), None); + + let unknown = claim_vouched( + &store, + t(2026, 1, 1), + "did:attacker", + "nobody.tngl.sh", + TLDS, + |_domain| async { Ok(None) }, + ) + .await + .unwrap_err(); + assert_eq!(unknown, ClaimError::HandleUnknown); + assert_eq!(store.select_claim_by_domain("nobody.tngl.sh").await.unwrap(), None); + + claim_vouched( + &store, + t(2026, 1, 1), + "did:alice", + "alice.tngl.sh", + TLDS, + |domain| async move { + assert_eq!(domain, "alice.tngl.sh"); + Ok(Some("did:alice".to_string())) + }, + ) + .await + .unwrap(); + let row = store + .select_claim_by_domain("alice.tngl.sh") + .await + .unwrap() + .unwrap(); + assert_eq!(row.did, "did:alice"); + assert_eq!(row.deleted, None); + } + + #[tokio::test] + async fn a_vouched_owner_takes_over_a_squatted_handle_row() { + let store = SitesStore::mem(); + store.insert_claim("did:squatter", "victim.tngl.sh").await.unwrap(); + + claim_vouched( + &store, + t(2026, 1, 1), + "did:victim", + "victim.tngl.sh", + TLDS, + |domain| async move { Ok(Some(format!("did:victim for {}", domain))) }, + ) + .await + .unwrap_err(); + + claim_vouched( + &store, + t(2026, 1, 1), + "did:victim", + "victim.tngl.sh", + TLDS, + |_domain| async { Ok(Some("did:victim".to_string())) }, + ) + .await + .unwrap(); + + let row = store + .select_claim_by_domain("victim.tngl.sh") + .await + .unwrap() + .unwrap(); + assert_eq!(row.did, "did:victim"); + assert_eq!(row.deleted, None); + assert_eq!(store.select_claim_by_did("did:squatter").await.unwrap(), None); + } + + #[tokio::test] + async fn a_foreign_site_name_row_is_not_taken_over() { + let store = SitesStore::mem(); + store.insert_claim("did:squatter", "victim.tngl.io").await.unwrap(); + let err = claim_site_name(&store, t(2026, 1, 1), "did:victim", "victim.tngl.io", TLDS) + .await + .unwrap_err(); + assert_eq!(err, ClaimError::DomainTaken); + assert_eq!( + store + .select_claim_by_domain("victim.tngl.io") + .await + .unwrap() + .unwrap() + .did, + "did:squatter" + ); + } + + #[tokio::test] + async fn a_rename_leaves_a_row_the_new_handle_replaces() { + let store = SitesStore::mem(); + claim_vouched( + &store, + t(2026, 1, 1), + "did:alice", + "alice.tngl.sh", + TLDS, + |_domain| async { Ok(Some("did:alice".to_string())) }, + ) + .await + .unwrap(); + + claim_vouched( + &store, + t(2026, 1, 2), + "did:alice", + "bob.tngl.sh", + TLDS, + |domain| async move { + match domain.as_str() { + "bob.tngl.sh" => Ok(Some("did:alice".to_string())), + _ => Ok(Some("did:carol".to_string())), + } + }, + ) + .await + .unwrap(); + + let row = store.select_claim_by_did("did:alice").await.unwrap().unwrap(); + assert_eq!(row.domain, "bob.tngl.sh"); + assert_eq!(row.deleted, None); + assert_eq!(store.select_claim_by_domain("alice.tngl.sh").await.unwrap(), None); + claim_vouched( + &store, + t(2026, 1, 2), + "did:carol", + "alice.tngl.sh", + TLDS, + |_domain| async { Ok(Some("did:carol".to_string())) }, + ) + .await + .unwrap(); + } + + #[tokio::test] + async fn a_handle_the_account_still_holds_stays_its_own_claim() { + let store = SitesStore::mem(); + claim_vouched( + &store, + t(2026, 1, 1), + "did:alice", + "alice.tngl.sh", + TLDS, + |_domain| async { Ok(Some("did:alice".to_string())) }, + ) + .await + .unwrap(); + + let err = claim_vouched( + &store, + t(2026, 1, 2), + "did:alice", + "bob.tngl.sh", + TLDS, + |_domain| async { Ok(Some("did:alice".to_string())) }, + ) + .await + .unwrap_err(); + assert_eq!(err, ClaimError::AlreadyClaimed); + assert_eq!( + store.select_claim_by_did("did:alice").await.unwrap().unwrap().domain, + "alice.tngl.sh" + ); + } + + #[tokio::test] + async fn a_claim_row_is_only_deleted_while_it_is_still_the_observed_owner() { + let store = SitesStore::mem(); + store.insert_claim("did:squatter", "victim.tngl.sh").await.unwrap(); + + assert_eq!( + store + .delete_claim_if_owned_by("victim.tngl.sh", "did:elsewhere") + .await + .unwrap(), + 0 + ); + assert!(store.select_claim_by_domain("victim.tngl.sh").await.unwrap().is_some()); + + assert_eq!( + store + .delete_claim_if_owned_by("victim.tngl.sh", "did:squatter") + .await + .unwrap(), + 1 + ); + assert_eq!(store.select_claim_by_domain("victim.tngl.sh").await.unwrap(), None); + } + + #[tokio::test] + async fn a_site_name_is_claimed_without_asking_the_handle_authority() { + let store = SitesStore::mem(); + claim_vouched( + &store, + t(2026, 1, 1), + "did:alice", + "anything.tngl.io", + TLDS, + |domain| async move { + panic!("claimed to ask the authority about {}", domain); + }, + ) + .await + .unwrap(); + assert_eq!( + store + .select_claim_by_domain("anything.tngl.io") + .await + .unwrap() + .unwrap() + .did, + "did:alice" + ); + } + + #[tokio::test] + async fn an_unreachable_handle_authority_is_not_a_claim() { + let store = SitesStore::mem(); + let err = claim_vouched( + &store, + t(2026, 1, 1), + "did:alice", + "alice.tngl.sh", + TLDS, + |_domain| async { Err("connection refused".to_string()) }, + ) + .await + .unwrap_err(); + assert!(matches!(err, ClaimError::Store(_))); + assert_eq!(store.select_claim_by_domain("alice.tngl.sh").await.unwrap(), None); + } + + #[tokio::test] + async fn resolving_a_domain_that_is_not_a_handle_is_refused() { + for not_a_handle in ["alice.tngl.io", "example.com", "tngl.sh", ""] { + let err = resolve_handle(not_a_handle.to_string()).await.unwrap_err(); + assert!(err.contains("not a handle domain"), "{}: {}", not_a_handle, err); + } + } + + #[test] + fn only_a_denial_is_an_unknown_handle() { + for denied in [400, 404] { + assert!(zone_denies_handle(denied)); + } + for not_an_answer in [302, 429, 500, 502, 503] { + assert!(!zone_denies_handle(not_an_answer), "{}", not_an_answer); + } + } + + #[test] + fn only_a_held_title_is_a_claim_to_show_or_serve() { + assert!(Title::Held.holds()); + assert!(!Title::Squatted.holds()); + assert!(!Title::Unknown.holds()); + assert!(!Title::Unverifiable.holds()); + } + + #[test] + fn a_stored_claim_is_only_held_while_the_zone_agrees() { + let did = "did:plc:alice"; + assert_eq!(title_of(did, Some(did)), Title::Held); + assert_eq!(title_of(did, Some("did:plc:attacker")), Title::Squatted); + assert_eq!(title_of(did, None), Title::Unknown); + assert_ne!(title_of(did, Some("did:plc:alice")), Title::Unverifiable); + assert_eq!(titled(did, Some("did:plc:attacker".to_string())), Err(ClaimError::HandleNotOwned)); + assert_eq!(titled(did, None), Err(ClaimError::HandleUnknown)); + assert_eq!(titled(did, Some(did.to_string())), Ok(())); + } + + #[test] + fn a_self_claimed_handle_needs_both_directions_to_agree() { + let did = "did:plc:alice"; + assert_eq!( + self_claimed_handle(did, Some("alice.tngl.sh"), Some(did)).as_deref(), + Some("alice.tngl.sh") + ); + assert_eq!(self_claimed_handle(did, Some("victim.tngl.sh"), Some("did:plc:victim")), None); + assert_eq!(self_claimed_handle(did, Some("alice.tngl.sh"), None), None); + assert_eq!(self_claimed_handle(did, Some("alice.tngl.sh"), Some("did:plc:bob")), None); + assert_eq!(self_claimed_handle(did, Some("alice.tngl.io"), Some(did)), None); + assert_eq!(self_claimed_handle(did, None, Some(did)), None); + } + + #[test] + fn a_handle_is_a_single_label_under_a_handle_tld() { + assert!(is_handle_domain("alice.tngl.sh")); + for not_a_handle in [ + "tngl.sh", + "alice.tngl.io", + "alice.other.tngl.sh", + "alice.b.c.tngl.sh", + ".tngl.sh", + "ALICE.tngl.sh", + "-alice.tngl.sh", + "alice-.tngl.sh", + "a b.tngl.sh", + "alice.tngl.sh.evil.example", + "", + ] { + assert!(!is_handle_domain(not_a_handle), "{:?}", not_a_handle); + } + } + + #[tokio::test] + async fn resolving_a_nested_host_under_a_handle_zone_is_refused() { + for nested in ["alice.other.tngl.sh", "alice.b.tngl.sh", ".tngl.sh"] { + let err = resolve_handle(nested.to_string()).await.unwrap_err(); + assert!(err.contains("not a handle domain"), "{}: {}", nested, err); + } } #[tokio::test] async fn released_domain_can_be_reclaimed_after_cooldown() { let store = SitesStore::mem(); - claim(&store, t(2026, 1, 1), "did:alice", "alice.tngl.io", TLDS) + claim_site_name(&store, t(2026, 1, 1), "did:alice", "alice.tngl.io", TLDS) .await .unwrap(); assert!(release(&store, t(2026, 1, 1), "did:alice", "alice.tngl.io") .await .unwrap()); - claim(&store, t(2026, 2, 1), "did:bob", "alice.tngl.io", TLDS) + claim_site_name(&store, t(2026, 2, 1), "did:bob", "alice.tngl.io", TLDS) .await .unwrap(); let row = store.select_claim_by_domain("alice.tngl.io").await.unwrap().unwrap(); @@ -564,7 +1122,7 @@ mod tests { #[tokio::test] async fn tngl_sh_release_is_permanent() { let store = SitesStore::mem(); - claim(&store, t(2026, 1, 1), "did:alice", "alice.tngl.sh", TLDS) + claim_site_name(&store, t(2026, 1, 1), "did:alice", "alice.tngl.sh", TLDS) .await .unwrap(); let err = release(&store, t(2026, 1, 1), "did:alice", "alice.tngl.sh") @@ -578,7 +1136,7 @@ mod tests { #[tokio::test] async fn invalid_tld_is_invalid_subdomain() { let store = SitesStore::mem(); - let err = claim(&store, t(2026, 1, 1), "did:alice", "alice.example.com", TLDS) + let err = claim_site_name(&store, t(2026, 1, 1), "did:alice", "alice.example.com", TLDS) .await .unwrap_err(); assert_eq!(err, ClaimError::InvalidSubdomain); @@ -594,7 +1152,7 @@ mod tests { ".tngl.io", "a b.tngl.io", ] { - let err = claim(&store, t(2026, 1, 1), "did:alice", bad, TLDS) + let err = claim_site_name(&store, t(2026, 1, 1), "did:alice", bad, TLDS) .await .unwrap_err(); assert_eq!(err, ClaimError::InvalidSubdomain, "domain {:?}", bad); diff --git a/sites/src/lib.rs b/sites/src/lib.rs index 50700e20e..381c8f02e 100644 --- a/sites/src/lib.rs +++ b/sites/src/lib.rs @@ -98,6 +98,32 @@ fn preflight_response(origin: &Option) -> Result { Ok(resp) } +const HANDLE_TITLE_TTL: u64 = 300; + +// shorter negative cache so fresh signups aren't delayed +const UNKNOWN_HANDLE_TTL: u64 = 60; + +async fn handle_title(kv: &KvStore, host: &str) -> Result, String> { + let key = format!("handle-title:{}", host); + if let Some(memo) = kv.get(&key).text().await.map_err(|e| e.to_string())? { + let memo = memo.trim(); + return Ok((!memo.is_empty()).then(|| memo.to_string())); + } + + let resolved = claim::resolve_handle(host.to_string()).await?; + kv.put(&key, resolved.as_deref().unwrap_or("")) + .map_err(|e| e.to_string())? + .expiration_ttl(if resolved.is_none() { + UNKNOWN_HANDLE_TTL + } else { + HANDLE_TITLE_TTL + }) + .execute() + .await + .map_err(|e| e.to_string())?; + Ok(resolved) +} + async fn fetch_inner(req: Request, env: Env, ctx: Context) -> Result { let url = req.url()?; let path = url.path(); @@ -123,6 +149,11 @@ async fn fetch_inner(req: Request, env: Env, ctx: Context) -> Result { resp.headers_mut().set("Allow", "GET")?; return Ok(resp); } + // handles are the pdses' to answer, and a fetch on our own zone skips + // the worker and lands on the origin in front of them + if claim::is_handle_domain(&host) { + return Fetch::Request(req).send().await; + } let store = SitesStore::d1(env.d1("SITES_DB")?); return match store.select_claim_by_domain(&host).await { Ok(Some(claim)) if claim.deleted.is_none() => { @@ -158,6 +189,23 @@ async fn fetch_inner(req: Request, env: Env, ctx: Context) -> Result { None => return Response::error("site not found!", 404), }; + if claim::is_handle_domain(&host) { + match handle_title(&kv, &host).await { + Ok(resolved) if resolved.as_deref() == Some(mapping.did.as_str()) => {} + Ok(_) => { + console_warn!( + "refusing to serve {}: the zone does not resolve it to its mapping's owner", + host + ); + return Response::error("Not Found", 404); + } + Err(e) => { + console_warn!("handle authority lookup for {} failed: {}", host, e); + return Response::error("Service Unavailable", 503); + } + } + } + let path = url.path(); let first_segment = path diff --git a/sites/src/serviceauth.rs b/sites/src/serviceauth.rs index 210c95d71..2a5ae9f50 100644 --- a/sites/src/serviceauth.rs +++ b/sites/src/serviceauth.rs @@ -228,11 +228,56 @@ fn did_key_from_bytes(tagged: &[u8]) -> String { } #[derive(Deserialize)] -struct DidDoc { +pub(crate) struct DidDoc { #[serde(default)] id: String, #[serde(default, rename = "verificationMethod")] verification_method: Vec, + #[serde(default, rename = "alsoKnownAs")] + also_known_as: Vec, + #[serde(default)] + service: Vec, +} + +#[derive(Deserialize)] +pub(crate) struct DidService { + #[serde(default)] + id: String, + #[serde(default, rename = "type")] + kind: String, + #[serde(default, rename = "serviceEndpoint")] + endpoint: serde_json::Value, +} + +const KNOT_SERVICE: (&str, &str) = ("tangled_knot", "TangledKnot"); +const LEGACY_KNOT_SERVICE: (&str, &str) = ("atproto_pds", "AtprotoPersonalDataServer"); + +impl DidDoc { + pub(crate) fn id(&self) -> &str { + &self.id + } + + pub(crate) fn knot_endpoint(&self) -> Option<&str> { + let endpoint = |(id, kind): (&str, &str)| { + self.service + .iter() + .find(|service| { + service.id.trim_start_matches('#') == id && service.kind == kind + }) + .and_then(|service| service.endpoint.as_str()) + .filter(|endpoint| !endpoint.is_empty()) + }; + endpoint(KNOT_SERVICE).or_else(|| endpoint(LEGACY_KNOT_SERVICE)) + } +} + +pub async fn account_handle(env: &Env, did: &str) -> Result, XrpcError> { + let url = did_doc_url_for(env, did)?; + let doc = fetch_did_doc(&url, did).await?; + Ok(doc.also_known_as.iter().find_map(|aka| { + let handle = aka.strip_prefix("at://")?; + (!handle.is_empty() && !handle.contains('/')).then(|| handle.to_string()) + })) } #[derive(Deserialize)] @@ -312,7 +357,7 @@ fn did_web_doc_url(method_id: &str) -> Option { } } -fn did_doc_url_for(env: &Env, iss: &str) -> Result { +pub(crate) fn did_doc_url_for(env: &Env, iss: &str) -> Result { if let Some(method_id) = iss.strip_prefix("did:web:") { did_web_doc_url(method_id) .ok_or_else(|| XrpcError::auth(format!("malformed did:web DID {}", iss))) @@ -447,6 +492,11 @@ fn verify_signature( } } +// `did:key` proves key possession, not an account. throwaways can't act as users +pub fn is_account_did(did: &str) -> bool { + did.starts_with("did:plc:") || did.starts_with("did:web:") +} + pub async fn verify(req: &Request, env: &Env, nsid: &str) -> Result { let audience = env .var("SITES_AUDIENCE_DID") @@ -728,4 +778,40 @@ mod tests { .unwrap_err(); assert_eq!(err.name, "AuthError"); } + + #[test] + fn a_document_names_its_knot_service() { + let tangled = r##"{"id":"did:plc:repo","service":[ + {"id":"#atproto_pds","type":"AtprotoPersonalDataServer","serviceEndpoint":"https://pds.example"}, + {"id":"#tangled_knot","type":"TangledKnot","serviceEndpoint":"https://knot.example"}]}"##; + let doc: DidDoc = serde_json::from_str(tangled).unwrap(); + assert_eq!(doc.knot_endpoint(), Some("https://knot.example")); + + let legacy = r##"{"id":"did:plc:repo","service":[ + {"id":"#atproto_pds","type":"AtprotoPersonalDataServer","serviceEndpoint":"https://knot1.tangled.sh"}]}"##; + let doc: DidDoc = serde_json::from_str(legacy).unwrap(); + assert_eq!(doc.knot_endpoint(), Some("https://knot1.tangled.sh")); + + for not_a_knot in [ + r##"{"service":[{"id":"#tangled_knot","type":"AtprotoPersonalDataServer","serviceEndpoint":"https://evil.example"}]}"##, + r##"{"service":[{"id":"tangled_knot","type":"TangledKnot","serviceEndpoint":{"uri":"https://evil.example"}}]}"##, + r##"{"service":[{"id":"#tangled_knot","type":"TangledKnot","serviceEndpoint":42}]}"##, + r##"{"service":[{"id":"#tangled_knot","type":"TangledKnot","serviceEndpoint":""}]}"##, + r##"{"service":[{"id":"#tangled_knot","type":"TangledKnot"}]}"##, + r##"{"service":[]}"##, + r##"{"id":"did:plc:repo"}"##, + ] { + let doc: DidDoc = serde_json::from_str(not_a_knot).unwrap(); + assert_eq!(doc.knot_endpoint(), None, "{}", not_a_knot); + } + } + + #[test] + fn a_did_key_issuer_is_not_an_account() { + assert!(!is_account_did("did:key:zQ3shokFTS3brHcDQrn82RUDfCZESWL1ZdCEJwekUDPQiYBme")); + assert!(is_account_did("did:plc:abc123")); + assert!(is_account_did("did:web:sitesd.tangled.network")); + assert!(!is_account_did("alice.tngl.sh")); + assert!(!is_account_did("did:")); + } } diff --git a/sites/src/sites.rs b/sites/src/sites.rs index 27bd01e8c..161019112 100644 --- a/sites/src/sites.rs +++ b/sites/src/sites.rs @@ -88,7 +88,6 @@ struct DeployRowD1 { created_at: String, } -/// caller-attested: the did comes from the serviceauth token, not the knot's repo record. pub const ERR_NOT_OWNER: &str = "site config belongs to another owner"; impl SitesStore { @@ -145,10 +144,11 @@ impl SitesStore { branch: &str, dir: &str, is_index: bool, + reclaim_disputed_owner: bool, ) -> Result<(), String> { let db = self.d1_db()?; if let Some(existing) = self.get_repo(repo_did).await? { - if existing.owner_did != owner_did { + if existing.owner_did != owner_did && !reclaim_disputed_owner { return Err(ERR_NOT_OWNER.to_string()); } } @@ -157,10 +157,9 @@ impl SitesStore { let mut stmts = vec![db .prepare( - // owner_did is written once "INSERT INTO site_repos (repo_did, owner_did, name, rkey, knot) VALUES (?, ?, ?, ?, ?) \ - ON CONFLICT(repo_did) DO UPDATE SET name = excluded.name, \ - rkey = excluded.rkey, knot = excluded.knot", + ON CONFLICT(repo_did) DO UPDATE SET owner_did = excluded.owner_did, \ + name = excluded.name, rkey = excluded.rkey, knot = excluded.knot", ) .bind_refs(&[ D1Type::Text(repo_did), diff --git a/sites/src/xrpc.rs b/sites/src/xrpc.rs index 6b797b431..581379a5a 100644 --- a/sites/src/xrpc.rs +++ b/sites/src/xrpc.rs @@ -55,6 +55,16 @@ fn claim_error_to_xrpc(err: ClaimError) -> XrpcError { "Your tngl.sh domain is tied to your handle and cannot be released.", 409, ), + ClaimError::HandleNotOwned => XrpcError::new( + "HandleNotOwned", + "That handle belongs to another account.", + 403, + ), + ClaimError::HandleUnknown => XrpcError::new( + "HandleUnknown", + "No account holds that handle yet. tngl.sh handles are assigned at signup.", + 404, + ), ClaimError::Store(msg) => internal(format!("database error: {}", msg)), } } @@ -96,6 +106,16 @@ fn require_non_empty(field: &str, value: &str) -> Result { Ok(trimmed.to_string()) } +async fn verify_caller(req: &Request, env: &Env, nsid: &str) -> Result { + let did = serviceauth::verify(req, env, nsid).await?; + if !serviceauth::is_account_did(&did) { + return Err(XrpcError::auth( + "service auth issuer must be an atproto account DID", + )); + } + Ok(did) +} + async fn verify_sd(req: &Request, env: &Env, nsid: &str) -> Result { let did = serviceauth::verify(req, env, nsid).await?; let sd = env @@ -154,7 +174,7 @@ struct RecordDeployBody { async fn handle_claim_domain(mut req: Request, env: &Env) -> Result { - let did = serviceauth::verify(&req, env, "org.tangled.temp.site.claimDomain").await?; + let did = verify_caller(&req, env, "org.tangled.temp.site.claimDomain").await?; let body: ClaimDomainBody = req .json() .await @@ -163,28 +183,53 @@ async fn handle_claim_domain(mut req: Request, env: &Env) -> Result Result { - let did = serviceauth::verify(&req, env, "org.tangled.temp.site.getDomainClaim").await?; + let did = verify_caller(&req, env, "org.tangled.temp.site.getDomainClaim").await?; let store = store(env)?; - match store - .active_domain_for_did(&did) - .await - .map_err(|e| internal(e))? - { - Some(domain) => ok_json(json!({ "domain": domain })), - None => ok_json(json!({})), + let Some(domain) = store.active_domain_for_did(&did).await.map_err(internal)? else { + return match verified_signup_handle(env, &did).await? { + Some(handle) => ok_json(json!({ "domain": handle })), + None => ok_json(json!({})), + }; + }; + + // only report handle claims the zone still gives this account + if !claim::is_handle_domain(&domain) { + return ok_json(json!({ "domain": domain })); + } + let title = claim::handle_title(&domain, &did).await; + if title.holds() { + return ok_json(json!({ "domain": domain })); + } + match title { + claim::Title::Unverifiable => Err(XrpcError::new( + "ClaimUnverifiable", + format!( + "the zone that owns {} could not be asked, so this claim cannot be shown", + domain + ), + 503, + )), + _ => ok_json(json!({})), } } async fn handle_release_domain(mut req: Request, env: &Env) -> Result { - let did = serviceauth::verify(&req, env, "org.tangled.temp.site.releaseDomain").await?; + let did = verify_caller(&req, env, "org.tangled.temp.site.releaseDomain").await?; let body: ReleaseDomainBody = req .json() .await @@ -212,7 +257,7 @@ async fn handle_release_domain(mut req: Request, env: &Env) -> Result Result { - let did = serviceauth::verify(&req, env, "org.tangled.temp.repo.updateSiteConfig").await?; + let did = verify_caller(&req, env, "org.tangled.temp.repo.updateSiteConfig").await?; let body: UpdateSiteConfigBody = req .json() .await .map_err(|_| XrpcError::new("InvalidRequest", "request body must be JSON", 400))?; let branch = require_non_empty("branch", &body.branch)?; - let rkey = require_non_empty("rkey", &body.rkey)?; - let name = require_non_empty("name", &body.name)?; - let knot = require_non_empty("knot", &body.knot)?; + require_non_empty("rkey", &body.rkey)?; + require_non_empty("knot", &body.knot)?; let store = store(env)?; - let Some(domain) = store - .active_domain_for_did(&did) - .await - .map_err(|e| internal(e))? - else { + let Some(domain) = usable_claim(env, &store, &did).await? else { return Err(XrpcError::new( "NoDomainClaim", "This account has no active domain claim.", @@ -313,6 +353,22 @@ async fn handle_update_site_config( return Err(XrpcError::new("InvalidDirectory", "Invalid directory path.", 400)); }; + // the knot hosting the repo is the authority on who owns it, not d1 + let (access, _, proof) = repo_scope(env, &store, &did, &body.repo_did).await?; + // don't let the client redirect the knot or rkey away from the proof + if let Err(e) = config_identity(&proof, &body.rkey, &body.knot) { + console_warn!("updateSiteConfig for {}: {}", body.repo_did, e.message); + return Err(e); + } + let Some(name) = site_name(&body.name, &proof.rkey) else { + return Err(XrpcError::new( + "InvalidRequest", + "'name' must be a single path segment of letters, digits, '-', '_' or '.'", + 400, + )); + }; + let (rkey, knot) = (proof.rkey.clone(), proof.knot.clone()); + store .update_site_config( &body.repo_did, @@ -323,6 +379,7 @@ async fn handle_update_site_config( &branch, &dir, body.is_index, + access == RepoAccess::Disputed, ) .await .map_err(|e| { @@ -357,22 +414,64 @@ async fn handle_update_site_config( ))); } - let _ = trigger_deploy(env, ctx, body.repo_did, None); - - ok_json(json!({})) + match trigger_deploy( + env, + ctx, + db_handle(env)?, + DeployTarget { + repo_did: body.repo_did, + branch, + dir, + }, + None, + ) { + DeployTrigger::Dispatched => ok_json(json!({})), + DeployTrigger::Disabled => Err(XrpcError::new( + "WorkerUnavailable", + "The config was saved, but site deploys are disabled on this deployment.", + 503, + )), + } } -/// queued = the fetch reached the deploy service; the deploy runs detached (ctx.wait_until) +#[derive(PartialEq, Debug)] enum DeployTrigger { - Queued, + Dispatched, Disabled, } +#[derive(Clone, Copy, Debug, PartialEq)] +enum TriggerAnswer { + Accepted, + Refused, +} + +fn trigger_answer(status: u16) -> TriggerAnswer { + if (200..300).contains(&status) { + TriggerAnswer::Accepted + } else { + TriggerAnswer::Refused + } +} + +struct DeployTarget { + repo_did: String, + branch: String, + dir: String, +} + +// detached task outlives borrowed store, needs its own d1 handle +fn db_handle(env: &Env) -> Result { + env.d1("SITES_DB") + .map_err(|_| XrpcError::internal("SITES_DB is not configured")) +} + /// an omitted trigger is sitesd's config-change deploy fn trigger_deploy( env: &Env, ctx: &Context, - repo_did: String, + db: D1Database, + target: DeployTarget, trigger: Option<&'static str>, ) -> DeployTrigger { let Some(aud) = env @@ -386,7 +485,8 @@ fn trigger_deploy( }; let url = format!("{}/deploy", aud.trim_end_matches('/')); ctx.wait_until(async move { - let mut payload = json!({ "repoDid": repo_did }); + let store = SitesStore::d1(db); + let mut payload = json!({ "repoDid": target.repo_did }); if let Some(trigger) = trigger { payload["trigger"] = json!(trigger); } @@ -400,49 +500,95 @@ fn trigger_deploy( .with_headers(headers) .with_body(Some(wasm_bindgen::JsValue::from_str(&payload))); let Ok(request) = Request::new_with_init(&url, &init) else { + record_unreached(&store, &target, trigger, "the deploy service address is not a URL").await; return; }; match Fetch::Request(request).send().await { - Ok(res) if res.status_code() >= 300 => { - console_warn!("deploy trigger rejected: HTTP {}", res.status_code()); + Ok(res) => { + if trigger_answer(res.status_code()) == TriggerAnswer::Refused { + let status = res.status_code(); + record_unreached( + &store, + &target, + trigger, + &format!( + "the deploy service rejected the trigger before accepting it: HTTP {}", + status + ), + ) + .await; + } } - Ok(_) => {} Err(e) => { - console_warn!("deploy trigger failed: {}", e); + record_unreached( + &store, + &target, + trigger, + &format!("the deploy service was not reached: {}", e), + ) + .await; } } }); - DeployTrigger::Queued + DeployTrigger::Dispatched +} + +async fn record_unreached( + store: &SitesStore, + target: &DeployTarget, + trigger: Option<&str>, + reason: &str, +) { + let trigger = trigger.unwrap_or("config_change"); + if let Err(e) = store + .insert_deploy( + &target.repo_did, + &target.branch, + &target.dir, + "", + "failure", + trigger, + reason, + ) + .await + { + console_warn!( + "recording the failed deploy trigger for {} failed: {}", + target.repo_did, + e + ); + } } async fn handle_deploy_site(mut req: Request, env: &Env, ctx: &Context) -> Result { - let did = serviceauth::verify(&req, env, "org.tangled.temp.repo.deploySite").await?; + let did = verify_caller(&req, env, "org.tangled.temp.repo.deploySite").await?; let body: RepoDidBody = req .json() .await .map_err(|_| XrpcError::new("InvalidRequest", "request body must be JSON", 400))?; let store = store(env)?; - match store.deploy_context(&body.repo_did).await.map_err(internal)? { - Some(dc) if dc.owner_did == did => {} - Some(_) => { - return Err(XrpcError::new( - "NotOwner", - "only the site's owner can redeploy it", - 403, - )) - } - None => { - return Err(XrpcError::new( - "SiteNotFound", - "no site configuration for this repository", - 404, - )) - } - } + let _ = verified_repo(env, &store, &did, &body.repo_did).await?; + let Some(dc) = store.deploy_context(&body.repo_did).await.map_err(internal)? else { + return Err(XrpcError::new( + "SiteNotFound", + "no site configuration for this repository", + 404, + )); + }; - match trigger_deploy(env, ctx, body.repo_did, Some("manual")) { - DeployTrigger::Queued => ok_json(json!({})), + match trigger_deploy( + env, + ctx, + db_handle(env)?, + DeployTarget { + repo_did: body.repo_did, + branch: dc.branch, + dir: dc.dir, + }, + Some("manual"), + ) { + DeployTrigger::Dispatched => ok_json(json!({})), DeployTrigger::Disabled => Err(XrpcError::new( "WorkerUnavailable", "site deploys are disabled on this deployment", @@ -451,8 +597,478 @@ async fn handle_deploy_site(mut req: Request, env: &Env, ctx: &Context) -> Resul } } +async fn usable_claim( + env: &Env, + store: &SitesStore, + did: &str, +) -> Result, XrpcError> { + let Some(domain) = store.active_domain_for_did(did).await.map_err(internal)? else { + return provision_handle_claim(env, store, did).await; + }; + if !claim::is_handle_domain(&domain) { + return Ok(Some(domain)); + } + match claim::handle_title(&domain, did).await { + claim::Title::Held => Ok(Some(domain)), + claim::Title::Squatted => Err(XrpcError::new( + "HandleNotOwned", + "That handle belongs to another account.", + 403, + )), + claim::Title::Unknown => Err(XrpcError::new( + "HandleUnknown", + "No account holds that handle.", + 404, + )), + claim::Title::Unverifiable => Err(XrpcError::new( + "ClaimUnverifiable", + format!("the zone that owns {} could not be asked, so this claim cannot be used", domain), + 503, + )), + } +} + +// provision signup handle if d1 has no claim row yet +async fn verified_signup_handle(env: &Env, did: &str) -> Result, XrpcError> { + let Some(declared) = serviceauth::account_handle(env, did).await? else { + return Ok(None); + }; + if !claim::is_handle_domain(&declared) { + return Ok(None); + } + let resolved = claim::resolve_handle(declared.clone()) + .await + .map_err(|e| XrpcError::new("ClaimUnverifiable", format!("handle authority lookup failed: {}", e), 503))?; + Ok(claim::self_claimed_handle(did, Some(&declared), resolved.as_deref())) +} + +async fn provision_handle_claim( + env: &Env, + store: &SitesStore, + did: &str, +) -> Result, XrpcError> { + let Some(handle) = verified_signup_handle(env, did).await? else { + return Ok(None); + }; + + let allowed = allowed_tlds(env)?; + let vouched = Some(did.to_string()); + match claim::claim_vouched( + store, + Utc::now(), + did, + &handle, + &tlds_ref(&allowed), + move |_domain| { + // already verified above, avoid extra fetch + let vouched = vouched.clone(); + async move { Ok(vouched) } + }, + ) + .await + { + Ok(()) => store.active_domain_for_did(did).await.map_err(internal), + Err(e) => { + console_warn!( + "provisioning the handle claim {} for {} failed: {:?}", + handle, + did, + e + ); + Err(claim_error_to_xrpc(e)) + } + } +} + +fn is_public_host(host: &str) -> bool { + // url hosts bracket ipv6 + let host = host + .trim_start_matches('[') + .trim_end_matches(']') + .trim_end_matches('.') + .to_ascii_lowercase(); + if host.is_empty() { + return false; + } + if [ + "localhost", + "local", + "internal", + "home.arpa", + "in-addr.arpa", + "ip6.arpa", + "onion", + ] + .iter() + .any(|reserved| host == *reserved || host.ends_with(&format!(".{}", reserved))) + { + return false; + } + match host.parse::() { + Ok(std::net::IpAddr::V4(ip)) => is_public_v4(ip), + Ok(std::net::IpAddr::V6(ip)) => match ip.to_ipv4_mapped() { + Some(mapped) => is_public_v4(mapped), + None => { + !(ip.is_loopback() + || ip.is_unspecified() + || ip.is_unique_local() + || ip.is_unicast_link_local() + || ip.is_multicast()) + } + }, + Err(_) => true, + } +} + +fn is_public_v4(ip: std::net::Ipv4Addr) -> bool { + !(ip.is_private() + || ip.is_loopback() + || ip.is_link_local() + || ip.is_broadcast() + || ip.is_documentation() + || ip.is_unspecified() + || ip.is_multicast() + || ip.octets()[0] == 0) +} + +fn public_https_host(raw: &str) -> Option { + let url = Url::parse(raw.trim()).ok()?; + if url.scheme() != "https" + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return None; + } + let host = url.host_str()?; + is_public_host(host).then(|| { + host.trim_start_matches('[') + .trim_end_matches(']') + .to_ascii_lowercase() + }) +} + +fn is_record_key(rkey: &str) -> bool { + !rkey.is_empty() + && rkey != "." + && rkey != ".." + && rkey.len() <= 512 + && rkey + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '~' | ':')) +} + +// don't follow redirects to unvalidated hosts +async fn authoritative_get(url: &str) -> Result { + let url = Url::parse(url).map_err(|e| format!("bad url: {}", e))?; + let mut init = RequestInit::new(); + // workerd rejects redirect "error" when constructing a request. + // "manual" leaves redirects for callers to reject. + init.with_method(Method::Get) + .with_redirect(RequestRedirect::Manual); + let request = Request::new_with_init(url.as_str(), &init).map_err(|e| e.to_string())?; + Fetch::Request(request).send().await.map_err(|e| e.to_string()) +} + +const MAX_DOC_BYTES: u64 = 1024 * 1024; + +fn within_cap(len: u64) -> bool { + len <= MAX_DOC_BYTES +} + +fn oversized(what: &str, len: u64) -> XrpcError { + unverifiable(&format!( + "{} is {} bytes, over the {} byte limit this worker reads", + what, len, MAX_DOC_BYTES + )) +} + +async fn bounded_json( + resp: &mut Response, + what: &str, +) -> Result { + if let Some(declared) = resp + .headers() + .get("content-length") + .ok() + .flatten() + .and_then(|len| len.trim().parse::().ok()) + && !within_cap(declared) + { + return Err(oversized(what, declared)); + } + + let body = resp + .bytes() + .await + .map_err(|e| unverifiable(&format!("{} could not be read: {}", what, e)))?; + if !within_cap(body.len() as u64) { + return Err(oversized(what, body.len() as u64)); + } + serde_json::from_slice(&body) + .map_err(|e| unverifiable(&format!("{} is unreadable: {}", what, e))) +} + +fn knot_endpoint_for<'a>( + doc: &'a serviceauth::DidDoc, + repo_did: &str, +) -> Result, XrpcError> { + if doc.id() != repo_did { + return Err(unverifiable(&format!( + "the DID document fetched for {} says it is {}", + repo_did, + doc.id() + ))); + } + Ok(doc.knot_endpoint()) +} + +#[derive(Clone, Debug, PartialEq)] +struct RepoProof { + owner: String, + rkey: String, + knot: String, +} + +impl RepoProof { + fn disputed_fields(&self, rkey: &str, knot: &str) -> Vec<&'static str> { + let mut disputed = Vec::new(); + if self.rkey != rkey { + disputed.push("rkey"); + } + if self.knot != knot { + disputed.push("knot"); + } + disputed + } +} + +async fn repo_proof(env: &Env, repo_did: &str) -> Result, XrpcError> { + let doc_url = match serviceauth::did_doc_url_for(env, repo_did) { + Ok(url) => url, + // unsupported did methods have no document to resolve + Err(_) => return Ok(None), + }; + if repo_did.starts_with("did:web:") && public_https_host(&doc_url).is_none() { + return Err(unverifiable( + "the did:web host of this repoDid is not a public https host", + )); + } + + let mut resp = authoritative_get(&doc_url).await.map_err(|e| { + unverifiable(&format!("the DID document for {} could not be read: {}", repo_did, e)) + })?; + let status = resp.status_code(); + if status == 404 { + return Ok(None); + } + if !(200..300).contains(&status) { + return Err(unverifiable(&format!( + "the DID document for {} answered HTTP {}", + repo_did, status + ))); + } + let doc: serviceauth::DidDoc = serde_json::from_value( + bounded_json(&mut resp, &format!("the DID document for {}", repo_did)).await?, + ) + .map_err(|e| { + unverifiable(&format!( + "the DID document for {} is unreadable: {}", + repo_did, e + )) + })?; + + let Some(endpoint) = knot_endpoint_for(&doc, repo_did)? else { + return Ok(None); + }; + let Some(knot) = public_https_host(endpoint) else { + return Err(unverifiable(&format!( + "the knot endpoint of {} is not a public https host", + repo_did + ))); + }; + + let mut url = Url::parse(&format!( + "https://{}/xrpc/sh.tangled.repo.describeRepo", + knot + )) + .map_err(|e| XrpcError::internal(format!("bad knot url: {}", e)))?; + url.query_pairs_mut().append_pair("repoDid", repo_did); + let mut resp = authoritative_get(url.as_str()).await.map_err(|e| { + unverifiable(&format!("the knot {} could not be asked: {}", knot, e)) + })?; + let status = resp.status_code(); + if status == 404 { + return Ok(None); + } + if !(200..300).contains(&status) { + return Err(unverifiable(&format!( + "the knot {} answered HTTP {}", + knot, status + ))); + } + let answer = bounded_json(&mut resp, &format!("the answer from the knot {}", knot)).await?; + Ok(Some(repo_proof_from_answer(repo_did, &knot, &answer)?)) +} + +fn repo_proof_from_answer( + repo_did: &str, + knot: &str, + answer: &serde_json::Value, +) -> Result { + let field = |name: &str| { + answer + .get(name) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + }; + match field("repoDid") { + Some(answered) if answered == repo_did => {} + Some(answered) => { + return Err(unverifiable(&format!( + "the knot {} answered for repoDid {}", + knot, answered + ))) + } + None => { + return Err(unverifiable(&format!( + "the knot {} answered without a repoDid", + knot + ))) + } + } + let owner = field("ownerDid") + .filter(|owner| serviceauth::is_account_did(owner)) + .ok_or_else(|| { + unverifiable(&format!( + "the knot {} answered without a usable owner DID", + knot + )) + })?; + let rkey = field("rkey").filter(|rkey| is_record_key(rkey)).ok_or_else(|| { + unverifiable(&format!("the knot {} answered without a usable rkey", knot)) + })?; + Ok(RepoProof { + owner: owner.to_string(), + rkey: rkey.to_string(), + knot: knot.to_string(), + }) +} + +fn site_name(requested: &str, rkey: &str) -> Option { + let name = if requested.trim().is_empty() { + rkey + } else { + requested.trim() + }; + let usable = !name.is_empty() + && name.len() <= 63 + && !name.starts_with(['.', '-']) + && !name.contains("..") + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')); + usable.then(|| name.to_string()) +} + +fn config_identity(proof: &RepoProof, rkey: &str, knot: &str) -> Result<(), XrpcError> { + let mismatched = proof.disputed_fields(rkey, knot); + if mismatched.is_empty() { + return Ok(()); + } + Err(XrpcError::new( + "RepoIdentityMismatch", + format!( + "the request's {} do not match what the knot hosting this repository reports; reload the repository and save again", + mismatched.join(", ") + ), + 409, + )) +} + +fn unverifiable(message: &str) -> XrpcError { + XrpcError::new( + "OwnershipUnverifiable", + format!( + "{}; refusing to act on the caller's word alone", + message + ), + 503, + ) +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum RepoAccess { + Owned, + Disputed, +} + +fn repo_access( + caller: &str, + stored_owner: Option<&str>, + resolved_owner: Option<&str>, +) -> Result { + match resolved_owner { + None => Err(XrpcError::new( + "RepoNotFound", + "No knot reports hosting this repoDid.", + 404, + )), + Some(owner) if owner != caller => Err(XrpcError::new( + "NotOwner", + "This repository's site config belongs to another account.", + 403, + )), + Some(_) => Ok(match stored_owner { + Some(stored) if stored != caller => RepoAccess::Disputed, + _ => RepoAccess::Owned, + }), + } +} + +async fn repo_scope( + env: &Env, + store: &SitesStore, + did: &str, + repo_did: &str, +) -> Result<(RepoAccess, Option, RepoProof), XrpcError> { + let row = store.get_repo(repo_did).await.map_err(internal)?; + let stored_owner = row.as_ref().map(|row| row.owner_did.as_str()); + let Some(proof) = repo_proof(env, repo_did).await? else { + return Err(XrpcError::new( + "RepoNotFound", + "No knot reports hosting this repoDid.", + 404, + )); + }; + let access = repo_access(did, stored_owner, Some(&proof.owner))?; + Ok((access, row, proof)) +} + +async fn verified_repo( + env: &Env, + store: &SitesStore, + did: &str, + repo_did: &str, +) -> Result<(Option, RepoProof), XrpcError> { + match repo_scope(env, store, did, repo_did).await? { + (RepoAccess::Owned, row, proof) => Ok((row, proof)), + (RepoAccess::Disputed, ..) => Err(disputed_config()), + } +} + +fn disputed_config() -> XrpcError { + XrpcError::new( + "SiteConfigDisputed", + "Another account's site configuration is stored for this repository; save the site configuration again to take it over.", + 409, + ) +} + async fn handle_get_site_config(req: Request, env: &Env) -> Result { - let did = serviceauth::verify(&req, env, "org.tangled.temp.repo.getSiteConfig").await?; + let did = verify_caller(&req, env, "org.tangled.temp.repo.getSiteConfig").await?; let url = req.url().map_err(|e| internal(e.to_string()))?; let Some(repo_did) = query_param(&url, "repoDid") else { return Err(XrpcError::new( @@ -462,14 +1078,16 @@ async fn handle_get_site_config(req: Request, env: &Env) -> Result Result { - let did = serviceauth::verify(&req, env, "org.tangled.temp.repo.getDeployHistory").await?; + let did = verify_caller(&req, env, "org.tangled.temp.repo.getDeployHistory").await?; let url = req.url().map_err(|e| internal(e.to_string()))?; let Some(repo_did) = query_param(&url, "repoDid") else { return Err(XrpcError::new( @@ -514,15 +1132,16 @@ async fn handle_get_deploy_history(req: Request, env: &Env) -> Result {} - Some(_) => { - return Err(XrpcError::new( - "NotOwner", - "Only the repository owner can read this site's deploy history.", - 403, - )) - } + Some(_) => {} None => { return Err(XrpcError::new( "SiteNotFound", @@ -552,31 +1171,21 @@ async fn handle_get_deploy_history(req: Request, env: &Env) -> Result Result { - let did = serviceauth::verify(&req, env, "org.tangled.temp.repo.disableSite").await?; + let did = verify_caller(&req, env, "org.tangled.temp.repo.disableSite").await?; let body: RepoDidBody = req .json() .await .map_err(|_| XrpcError::new("InvalidRequest", "request body must be JSON", 400))?; let store = store(env)?; - let Some(row) = store - .get_repo(&body.repo_did) - .await - .map_err(|e| internal(e))? - else { + let (row, proof) = verified_repo(env, &store, &did, &body.repo_did).await?; + let Some(row) = row else { return Err(XrpcError::new( "SiteNotFound", "This repository has no site configuration.", 404, )); }; - if row.owner_did != did { - return Err(XrpcError::new( - "NotOwner", - "Only the repository owner can disable this site.", - 403, - )); - } if store .get_site_config(&body.repo_did) .await @@ -593,7 +1202,8 @@ async fn handle_disable_site(mut req: Request, env: &Env) -> Result Result ok_json(json!({ - "ownerDid": dc.owner_did, - "domain": dc.domain, - "name": dc.name, - "rkey": dc.rkey, - "knot": dc.knot, - "branch": dc.branch, - "dir": dc.dir, - "isIndex": dc.is_index, - })), - None => ok_json(json!({})), + let Some(dc) = store.deploy_context(&repo_did).await.map_err(internal)? else { + return ok_json(json!({})); + }; + + let Some(proof) = repo_proof(env, &repo_did).await? else { + return Err(XrpcError::new( + "RepoNotFound", + "No knot reports hosting this repoDid.", + 404, + )); + }; + if proof.owner != dc.owner_did { + return Err(XrpcError::new( + "NotOwner", + "The stored site configuration for this repository was written by another account.", + 403, + )); } + if claim::is_handle_domain(&dc.domain) { + match claim::handle_title(&dc.domain, &dc.owner_did).await { + claim::Title::Held => {} + claim::Title::Squatted | claim::Title::Unknown => { + return Err(XrpcError::new( + "HandleNotOwned", + "The domain this site would be published on is not held by the account that owns the repository.", + 403, + )) + } + claim::Title::Unverifiable => { + return Err(XrpcError::new( + "ClaimUnverifiable", + format!("the zone that owns {} could not be asked", dc.domain), + 503, + )) + } + } + } + + ok_json(json!({ + "ownerDid": dc.owner_did, + "domain": dc.domain, + "name": dc.name, + "rkey": proof.rkey, + "knot": proof.knot, + "branch": dc.branch, + "dir": dc.dir, + "isIndex": dc.is_index, + })) } async fn handle_record_deploy(mut req: Request, env: &Env) -> Result { @@ -742,4 +1383,244 @@ pub async fn route(req: Request, env: Env, ctx: Context) -> Result { Ok(resp) => Ok(resp), Err(e) => render_error(e), } -} \ No newline at end of file +} + +#[cfg(test)] +mod tests { + use super::*; + + fn err_name(status: u16, name: &str, caller: &str, stored: Option<&str>, resolved: Option<&str>) { + let err = repo_access(caller, stored, resolved).unwrap_err(); + assert_eq!((err.status, err.name), (status, name)); + } + + #[test] + fn a_knot_answer_is_a_proof_only_for_the_repo_it_was_asked_about() { + let repo = "did:plc:repo"; + let knot = "knot1.tangled.sh"; + let answer = serde_json::json!({ + "repoDid": repo, "ownerDid": "did:plc:owner", "rkey": "site" + }); + assert_eq!( + repo_proof_from_answer(repo, knot, &answer).unwrap(), + RepoProof { + owner: "did:plc:owner".to_string(), + rkey: "site".to_string(), + knot: knot.to_string(), + } + ); + + for malformed in [ + serde_json::json!({"ownerDid": "did:plc:owner", "rkey": "site"}), + serde_json::json!({"repoDid": "did:plc:other", "ownerDid": "did:plc:owner", "rkey": "site"}), + serde_json::json!({"repoDid": repo, "rkey": "site"}), + serde_json::json!({"repoDid": repo, "ownerDid": "", "rkey": "site"}), + serde_json::json!({"repoDid": repo, "ownerDid": "did:key:zQ3shokFTS3brHcDQrn82RUDfCZESWL1ZdCEJwekUDPQiYBme", "rkey": "site"}), + serde_json::json!({"repoDid": repo, "ownerDid": "did:plc:owner"}), + serde_json::json!({"repoDid": repo, "ownerDid": "did:plc:owner", "rkey": ""}), + serde_json::json!({"repoDid": repo, "ownerDid": "did:plc:owner", "rkey": "a/b"}), + serde_json::json!({"repoDid": repo, "ownerDid": "did:plc:owner", "rkey": ".."}), + serde_json::json!({"repoDid": repo, "ownerDid": "did:plc:owner", "rkey": 7}), + serde_json::json!([repo, "did:plc:owner", "site"]), + serde_json::json!("site"), + ] { + let err = repo_proof_from_answer(repo, knot, &malformed).unwrap_err(); + assert_eq!( + (err.status, err.name), + (503, "OwnershipUnverifiable"), + "{}", + malformed + ); + } + } + + #[test] + fn a_mirror_binding_is_not_a_proof() { + for forged in [ + serde_json::json!({ + "cid": "bafyrei", + "uri": "at://did:plc:attacker/sh.tangled.repo/evil", + "value": { + "$type": "sh.tangled.repo", + "createdAt": "9999-01-01T00:00:00Z", + "repoDid": "did:plc:victim-repo", + "knot": "evil.example", + "name": "evil" + } + }), + serde_json::json!({"uri": "at://did:plc:attacker/sh.tangled.repo/evil"}), + ] { + let err = repo_proof_from_answer("did:plc:victim-repo", "knot1.tangled.sh", &forged) + .unwrap_err(); + assert_eq!((err.status, err.name), (503, "OwnershipUnverifiable")); + } + } + + #[test] + fn a_document_must_be_about_the_did_it_was_fetched_for() { + let with_service = |id: &str| { + serde_json::from_str::(&format!( + r##"{{"id":"{}","service":[{{"id":"#tangled_knot","type":"TangledKnot","serviceEndpoint":"https://knot.example"}}]}}"##, + id + )) + .unwrap() + }; + + let doc = with_service("did:plc:repo"); + assert_eq!( + knot_endpoint_for(&doc, "did:plc:repo").unwrap(), + Some("https://knot.example") + ); + let err = knot_endpoint_for(&doc, "did:plc:other").unwrap_err(); + assert_eq!((err.status, err.name), (503, "OwnershipUnverifiable")); + + let empty = serde_json::from_str::(r##"{"id":"did:plc:other"}"##).unwrap(); + assert_eq!(knot_endpoint_for(&empty, "did:plc:repo").unwrap_err().status, 503); + + let no_service = serde_json::from_str::( + r##"{"id":"did:plc:repo","service":[{"id":"#other","type":"Other","serviceEndpoint":"https://knot.example"}]}"##, + ) + .unwrap(); + assert_eq!(knot_endpoint_for(&no_service, "did:plc:repo").unwrap(), None); + } + + #[test] + fn a_document_over_the_read_limit_is_refused() { + assert!(within_cap(0)); + assert!(within_cap(MAX_DOC_BYTES)); + assert!(!within_cap(MAX_DOC_BYTES + 1)); + assert_eq!(MAX_DOC_BYTES, 1024 * 1024); + let err = oversized("the DID document for did:plc:repo", MAX_DOC_BYTES + 1); + assert_eq!((err.status, err.name), (503, "OwnershipUnverifiable")); + assert!(err.message.contains("over the 1048576 byte limit")); + } + + #[test] + fn a_proof_refuses_hosts_it_must_not_dial() { + for dialable in [ + "https://knot1.tangled.sh", + "https://knot.gaze.systems", + "https://plc.directory", + "https://1.1.1.1", + "https://knot1.tangled.sh/path", + ] { + assert!( + public_https_host(dialable).is_some(), + "{} should be dialable", + dialable + ); + } + + for refused in [ + "http://knot.example", + "ftp://knot.example", + "knot.example", + "https://localhost", + "https://knot.localhost", + "https://pds.internal", + "https://plc.local", + "https://metadata.home.arpa", + "https://127.0.0.1", + "https://127.1.2.3", + "https://0.0.0.0", + "https://10.1.2.3", + "https://192.168.0.1", + "https://172.16.0.1", + "https://169.254.169.254", + "https://224.0.0.1", + "https://[::1]", + "https://[::ffff:127.0.0.1]", + "https://[fd00::1]", + "https://[fe80::1]", + "https://user:pass@knot.example", + "https://knot.example/?x=1", + "https://knot.example/#f", + "https://", + ] { + assert!( + public_https_host(refused).is_none(), + "{} should be refused", + refused + ); + } + } + + #[test] + fn a_write_that_restates_the_identity_is_refused() { + let proof = RepoProof { + owner: "did:plc:owner".to_string(), + rkey: "site".to_string(), + knot: "knot.gaze.systems".to_string(), + }; + + for (rkey, knot, why) in [ + ("evil-rkey", "knot.gaze.systems", "another rkey"), + ("site", "evil.example", "a knot it controls"), + ("evil-rkey", "evil.example", "everything"), + ] { + let err = config_identity(&proof, rkey, knot).unwrap_err(); + assert_eq!((err.status, err.name), (409, "RepoIdentityMismatch"), "{}", why); + } + + assert!(config_identity(&proof, "site", "knot.gaze.systems").is_ok()); + } + + #[test] + fn a_site_name_is_the_callers_segment_or_the_rkey() { + assert_eq!(site_name("", "site").as_deref(), Some("site")); + assert_eq!(site_name(" ", "site").as_deref(), Some("site")); + assert_eq!(site_name("blog", "site").as_deref(), Some("blog")); + assert_eq!(site_name("under_score.ok", "site").as_deref(), Some("under_score.ok")); + assert_eq!(site_name(&"a".repeat(63), "site").map(|n| n.len()), Some(63)); + + for unusable in ["../x", ".hidden", "a/b", "a b", "-lead", "a".repeat(64).as_str(), ".."] { + assert_eq!(site_name(unusable, "site"), None, "{:?}", unusable); + } + } + + #[test] + fn a_foreign_repodid_is_not_writable_by_its_namer() { + err_name(403, "NotOwner", "did:plc:attacker", None, Some("did:plc:victim")); + err_name(403, "NotOwner", "did:plc:attacker", Some("did:plc:attacker"), Some("did:plc:victim")); + err_name(404, "RepoNotFound", "did:plc:attacker", None, None); + } + + #[test] + fn the_verified_owner_writes_and_takes_over_a_squatted_row() { + let access = + |stored: Option<&str>| repo_access("did:plc:alice", stored, Some("did:plc:alice")); + assert_eq!(access(None).unwrap(), RepoAccess::Owned); + assert_eq!(access(Some("did:plc:alice")).unwrap(), RepoAccess::Owned); + assert_eq!(access(Some("did:plc:attacker")).unwrap(), RepoAccess::Disputed); + } + + #[test] + fn a_blocked_handle_claim_is_reported_not_hidden() { + let err = claim_error_to_xrpc(ClaimError::DomainTaken); + assert_eq!((err.status, err.name), (409, "DomainTaken")); + let err = claim_error_to_xrpc(ClaimError::DomainCooldown); + assert_eq!((err.status, err.name), (409, "DomainCooldown")); + let err = claim_error_to_xrpc(ClaimError::AlreadyClaimed); + assert_eq!((err.status, err.name), (409, "AlreadyClaimed")); + } + + #[test] + fn a_row_written_by_another_account_authorizes_nobody() { + err_name( + 403, + "NotOwner", + "did:plc:attacker", + Some("did:plc:attacker"), + Some("did:plc:victim"), + ); + assert_eq!( + repo_access("did:plc:victim", Some("did:plc:attacker"), Some("did:plc:victim")).unwrap(), + RepoAccess::Disputed + ); + assert_eq!( + repo_access("did:plc:victim", None, Some("did:plc:victim")).unwrap(), + RepoAccess::Owned + ); + err_name(404, "RepoNotFound", "did:plc:victim", Some("did:plc:victim"), None); + } +} -- 2.54.0