chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
Service auth, DID documents and spaces come from the atproto crate
crates/knotless/src/auth.rs+49−78
| @@ -3,12 +3,15 @@ | ||
//! allow (`lxm`). App passwords can get them, so they are how a push reaches | ||
//! a gateway on a collaborator's behalf. | ||
| ||
use crate::crypto::{self, P256Key, PublicKey}; | ||
use crate::crypto::{P256Key, PublicKey}; | ||
use crate::env::Ctx; | ||
use crate::session::Session; | ||
use crate::xrpc::Xrpc; | ||
use crate::{identity, store}; | ||
use anyhow::{Context, Result, bail, ensure}; | ||
use anyhow::{Context, Result, ensure}; | ||
use atproto::service_auth::{self, Claims}; | ||
use atproto::space::SpaceRef; | ||
use atproto::space_auth::{self, Expect, Kind, Token}; | ||
use base64::Engine; | ||
use base64::engine::general_purpose::STANDARD; | ||
use serde::{Deserialize, Serialize}; | ||
| @@ -102,95 +105,65 @@ async fn token(ctx: &Ctx, me: &Session, params: &[(&str, &str)]) -> Result<Strin | ||
Ok(out.token) | ||
} | ||
| ||
#[derive(Deserialize)] | ||
struct Claims { | ||
iss: String, | ||
aud: String, | ||
lxm: Option<String>, | ||
} | ||
| ||
/// Checks a service token for calling `lxm` on `aud`, and returns the DID | ||
/// that sent it. The signature is checked against the key the issuer's DID | ||
/// document names ([`signed_by`]). | ||
pub async fn verify(ctx: &Ctx, token: &str, aud: &str, lxm: &str) -> Result<String> { | ||
let (header, claims) = crypto::jwt_parts(token)?; | ||
let claims: Claims = serde_json::from_value(claims)?; | ||
let did = claims.iss.split('#').next().unwrap_or_default().to_string(); | ||
if claims.aud != aud { | ||
bail!("a token for {}, not {aud}", claims.aud); | ||
} | ||
if claims.lxm.as_deref() != Some(lxm) { | ||
bail!("a token for {:?}, not {lxm}", claims.lxm); | ||
} | ||
let (header, claims) = service_auth::parts(token)?; | ||
let did = service_auth::check(&claims, aud, lxm)?.to_string(); | ||
let now = identity::now_secs(); | ||
match header["typ"].as_str() == Some(LOOPBACK_TYP) { | ||
true => sent_itself(token)?, | ||
false => signed_by(ctx, token, &did).await?, | ||
} | ||
true => sent_itself(token, now)?, | ||
false => signed_by(ctx, &did, |key| service_auth::verify(token, key, now)).await?, | ||
}; | ||
Ok(did) | ||
} | ||
| ||
/// Checks a token this server sent itself: signed with its loopback key. | ||
fn sent_itself(token: &str) -> Result<()> { | ||
fn sent_itself(token: &str, now: u64) -> Result<Claims> { | ||
let loopback = LOOPBACK.lock().unwrap(); | ||
let (key, _) = loopback | ||
.as_ref() | ||
.context("a loopback token, to a server that sends none")?; | ||
crypto::verify_jwt::<serde_json::Value>(token, &key.public())?; | ||
Ok(()) | ||
Ok(service_auth::verify(token, &key.public(), now)?) | ||
} | ||
| ||
/// Checks `token`'s signature against the key `did`'s DID document names: | ||
/// Checks a token with `check` against the key `did`'s DID document names: | ||
/// as read a few minutes ago, and as it is now when that one turns it | ||
/// away, as it does once the key rotated. | ||
async fn signed_by(ctx: &Ctx, token: &str, did: &str) -> Result<()> { | ||
let kept = identity::signing_key(&identity::kept_document(ctx, did).await?)?; | ||
if crypto::verify_jwt::<serde_json::Value>(token, &kept).is_ok() { | ||
return Ok(()); | ||
async fn signed_by<T, E>( | ||
ctx: &Ctx, | ||
did: &str, | ||
check: impl Fn(&PublicKey) -> Result<T, E>, | ||
) -> Result<T> | ||
where | ||
E: std::error::Error + Send + Sync + 'static, | ||
{ | ||
let kept = atproto::did::signing_key(&identity::kept_document(ctx, did).await?)?; | ||
if let Ok(found) = check(&kept) { | ||
return Ok(found); | ||
} | ||
let key: PublicKey = identity::signing_key(&identity::document(ctx, did).await?)?; | ||
crypto::verify_jwt::<serde_json::Value>(token, &key)?; | ||
Ok(()) | ||
} | ||
| ||
/// The type a space delegation token names in its header. | ||
const DELEGATION_TYP: &str = "atproto-space-delegation+jwt"; | ||
| ||
/// A delegation token, as a member's PDS signs one for a space's | ||
/// authority (`com.atproto.space.getDelegationToken`). | ||
#[derive(Deserialize, Debug, Clone)] | ||
pub struct Delegation { | ||
/// The account it names. | ||
#[serde(rename = "iss")] | ||
pub did: String, | ||
/// The space it is for. | ||
#[serde(rename = "sub")] | ||
pub space: String, | ||
/// What spends it: an authority takes each once. | ||
pub jti: String, | ||
/// When it stops, in seconds since the epoch. | ||
pub exp: u64, | ||
aud: String, | ||
let key = atproto::did::signing_key(&identity::document(ctx, did).await?)?; | ||
Ok(check(&key)?) | ||
} | ||
| ||
/// Checks a delegation token, for a space, signed by the account it names | ||
/// with the key its DID document names ([`signed_by`]), and returns what | ||
/// it holds. It names a space's authority, not this reader: whoever holds | ||
/// one may exchange it, so it proves who sent it only together with | ||
/// spending it once. | ||
pub async fn verify_delegation(ctx: &Ctx, token: &str) -> Result<Delegation> { | ||
let (header, claims) = crypto::jwt_parts(token)?; | ||
if header["typ"].as_str() != Some(DELEGATION_TYP) { | ||
bail!("not a delegation token: {}", header["typ"]); | ||
} | ||
let found: Delegation = serde_json::from_value(claims)?; | ||
let authority = found.space.strip_prefix("at://").unwrap_or_default(); | ||
let authority = authority.split('/').next().unwrap_or_default(); | ||
// On one line, as the check and the refusal run together. | ||
let (aud, space) = (&found.aud, &found.space); | ||
let ours = *aud == format!("{authority}#atproto_pds"); | ||
ensure!(ours, "a token for {aud}, not {space}'s authority"); | ||
signed_by(ctx, token, &found.did).await?; | ||
Ok(found) | ||
/// (`iss`) with the key its DID document names ([`signed_by`]), and returns | ||
/// what it holds: the space (`sub`), and the `jti` and `exp` that spend | ||
/// it. It names a space's authority, not this reader: whoever holds one | ||
/// may exchange it, so it proves who sent it only together with spending | ||
/// it once. | ||
pub async fn verify_delegation(ctx: &Ctx, token: &str) -> Result<Token> { | ||
let (_, found) = space_auth::parse(Kind::Delegation, token)?; | ||
let authority = SpaceRef::parse(&found.sub)?.authority; | ||
// Refused before any DID document is looked up for it. | ||
let (aud, space) = (&found.aud, &found.sub); | ||
let ours = aud.as_deref() == Some(space_auth::space_host_aud(&authority).as_str()); | ||
ensure!(ours, "a token for {aud:?}, not {space}'s authority"); | ||
let now = identity::now_secs(); | ||
let check = | ||
|key: &PublicKey| space_auth::verify(Kind::Delegation, token, key, &Expect::default(), now); | ||
signed_by(ctx, &found.iss, check).await | ||
} | ||
| ||
#[cfg(test)] | ||
| @@ -214,21 +187,19 @@ mod tests { | ||
json!({"iss": "did:plc:member", "sub": "at://did:plc:owner/space/dev.knotless.space/r", | ||
"aud": aud, "jti": "1", "exp": 1}) | ||
}; | ||
let typ = |typ: &str| json!({"alg": "ES256K", "typ": typ}); | ||
let typ = |typ: &str| json!({"alg": "ES256K", "typ": typ, "kid": "#atproto"}); | ||
let service = unsigned(typ("JWT"), claims("did:plc:owner#atproto_pds")); | ||
let why = verify_delegation(&ctx, &service).await.unwrap_err(); | ||
assert!( | ||
format!("{why}").starts_with("not a delegation token"), | ||
"{why}" | ||
); | ||
let elsewhere = unsigned(typ(DELEGATION_TYP), claims("did:plc:other#atproto_pds")); | ||
assert!(verify_delegation(&ctx, &service).await.is_err()); | ||
// For another authority: refused before its issuer is looked up. | ||
let delegation = Kind::Delegation.typ(); | ||
let elsewhere = unsigned(typ(delegation), claims("did:plc:other#atproto_pds")); | ||
let why = verify_delegation(&ctx, &elsewhere).await.unwrap_err(); | ||
assert!( | ||
format!("{why}").contains("not at://did:plc:owner/"), | ||
"{why}" | ||
); | ||
// Its type alone is not one: it names whom, which space and when. | ||
let bare = unsigned(typ(DELEGATION_TYP), json!({"iss": "did:plc:member"})); | ||
let bare = unsigned(typ(delegation), json!({"iss": "did:plc:member"})); | ||
assert!(verify_delegation(&ctx, &bare).await.is_err()); | ||
} | ||
} | ||
crates/knotless/src/crypto.rs+5−0
| @@ -36,6 +36,11 @@ impl P256Key { | ||
self.0.public() | ||
} | ||
| ||
/// The crate's keypair, for what `atproto` signs with it. | ||
pub fn keypair(&self) -> &Keypair { | ||
&self.0 | ||
} | ||
| ||
/// The public key as a JWK, as a DPoP proof carries it. | ||
pub fn jwk(&self) -> Value { | ||
jose::jwk(&self.public()) | ||
crates/knotless/src/gateway.rs+1−1
| @@ -62,7 +62,7 @@ pub const PUSH_MIME: &str = "application/x-knotless-push"; | ||
| ||
/// Where the gateway `did` takes requests. | ||
pub async fn endpoint(ctx: &Ctx, did: &str) -> Result<String> { | ||
identity::service(&identity::kept_document(ctx, did).await?, SERVICE) | ||
atproto::did::service(&identity::kept_document(ctx, did).await?, SERVICE) | ||
.with_context(|| format!("{did} names no {SERVICE} service")) | ||
} | ||
| ||
crates/knotless/src/identity.rs+5−112
| @@ -1,7 +1,6 @@ | ||
//! Handle → DID → PDS, cached on disk so a warm push spends no round trip | ||
//! on it. | ||
| ||
use crate::crypto::PublicKey; | ||
use crate::env::{Ctx, HandleResolver}; | ||
use crate::store; | ||
use crate::xrpc::{Xrpc, XrpcError}; | ||
| @@ -34,24 +33,6 @@ impl std::fmt::Display for NoSuchHandle { | ||
| ||
impl std::error::Error for NoSuchHandle {} | ||
| ||
/// Whether `handle` is one an account can have: labels of letters, digits | ||
/// and inner hyphens, two or more, the last starting with a letter. | ||
pub fn valid_handle(handle: &str) -> bool { | ||
let label = |l: &str| { | ||
!l.is_empty() | ||
&& l.len() <= 63 | ||
&& !l.starts_with('-') | ||
&& !l.ends_with('-') | ||
&& l.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') | ||
}; | ||
let labels: Vec<&str> = handle.split('.').collect(); | ||
let tld = labels.last().and_then(|l| l.bytes().next()); | ||
handle.len() <= 253 | ||
&& labels.len() >= 2 | ||
&& labels.iter().all(|l| label(l)) | ||
&& tld.is_some_and(|b| b.is_ascii_alphabetic()) | ||
} | ||
| ||
#[derive(Serialize, Deserialize)] | ||
struct Cached { | ||
did: String, | ||
| @@ -194,8 +175,8 @@ pub async fn handle(ctx: &Ctx, did: &str) -> Option<String> { | ||
/// when `did`'s document cannot be read, which says nothing of its handle. | ||
pub async fn verified_handle(ctx: &Ctx, did: &str) -> Result<Option<String>> { | ||
let doc = document(ctx, did).await?; | ||
let claimed = doc["alsoKnownAs"][0].as_str(); | ||
let claimed = claimed.and_then(|a| a.strip_prefix("at://")).unwrap_or(did); | ||
let claimed = atproto::did::handle(&doc); | ||
let claimed = claimed.as_deref().unwrap_or(did); | ||
let verified = resolve_handle(ctx, claimed).await.ok(); | ||
Ok((verified.as_deref() == Some(did)).then(|| claimed.to_string())) | ||
} | ||
| @@ -234,7 +215,7 @@ pub async fn resolve_handle(ctx: &Ctx, handle: &str) -> Result<String> { | ||
.handle_resolvers | ||
.iter() | ||
.any(|r| matches!(r, HandleResolver::WellKnown { scheme } if scheme == "http")); | ||
if !local && !valid_handle(handle) { | ||
if !local && !atproto::syntax::is_handle(handle) { | ||
return Err(NoSuchHandle(handle.into()).into()); | ||
} | ||
let mut failures = Vec::new(); | ||
| @@ -291,7 +272,7 @@ async fn well_known(ctx: &Ctx, scheme: &str, handle: &str) -> Result<String> { | ||
| ||
pub async fn resolve_pds(ctx: &Ctx, did: &str) -> Result<String> { | ||
let doc = document(ctx, did).await?; | ||
service(&doc, "#atproto_pds").ok_or_else(|| anyhow!("{did} names no #atproto_pds service")) | ||
atproto::did::pds(&doc).ok_or_else(|| anyhow!("{did} names no #atproto_pds service")) | ||
} | ||
| ||
/// Where `did`'s DID document is read: the PLC directory, or its | ||
| @@ -345,73 +326,12 @@ pub async fn kept_document(ctx: &Ctx, did: &str) -> Result<Value> { | ||
} | ||
} | ||
| ||
/// The endpoint of the service whose id ends in `id`, such as `#atproto_pds`. | ||
pub fn service(doc: &Value, id: &str) -> Option<String> { | ||
doc.get("service")? | ||
.as_array()? | ||
.iter() | ||
.find(|s| { | ||
s.get("id") | ||
.and_then(Value::as_str) | ||
.is_some_and(|i| i.ends_with(id)) | ||
})? | ||
.get("serviceEndpoint")? | ||
.as_str() | ||
.map(|s| s.trim_end_matches('/').to_string()) | ||
} | ||
| ||
/// The key the account signs with: its `#atproto` verification method. | ||
pub fn signing_key(doc: &Value) -> Result<PublicKey> { | ||
method_key(doc, "#atproto") | ||
} | ||
| ||
/// The key a syncing service agrees a secret with, for the account's | ||
/// server to vouch for what it reads of the account's repos in spaces: its | ||
/// `#atproto_kem` verification method. | ||
pub fn kem_key(doc: &Value) -> Result<PublicKey> { | ||
method_key(doc, "#atproto_kem") | ||
} | ||
| ||
/// The key of the verification method whose id ends in `id`. | ||
fn method_key(doc: &Value, id: &str) -> Result<PublicKey> { | ||
let method = doc | ||
.get("verificationMethod") | ||
.and_then(Value::as_array) | ||
.and_then(|methods| { | ||
methods.iter().find(|m| { | ||
m.get("id") | ||
.and_then(Value::as_str) | ||
.is_some_and(|i| i.ends_with(id)) | ||
}) | ||
}) | ||
.with_context(|| format!("the DID document has no {id} key"))?; | ||
let multibase = method | ||
.get("publicKeyMultibase") | ||
.and_then(Value::as_str) | ||
.with_context(|| format!("the {id} key has no publicKeyMultibase"))?; | ||
let kind = method | ||
.get("type") | ||
.and_then(Value::as_str) | ||
.unwrap_or("Multikey"); | ||
Ok(PublicKey::from_method(kind, multibase)?) | ||
} | ||
| ||
#[cfg(test)] | ||
mod tests { | ||
use super::*; | ||
use serde_json::json; | ||
| ||
#[test] | ||
fn handles() { | ||
for good in ["alice.test", "a.b.example.com", "x-1.bsky.social"] { | ||
assert!(valid_handle(good), "{good}"); | ||
} | ||
let long = format!("{}.com", "a".repeat(64)); | ||
for bad in [ | ||
"wp-admin", "", "a..b", "-a.com", "a-.com", "a.1com", "a_b.com", &long, | ||
] { | ||
assert!(!valid_handle(bad), "{bad}"); | ||
} | ||
fn no_such_handle_says_so() { | ||
assert_eq!( | ||
NoSuchHandle("wp-admin".into()).to_string(), | ||
"wp-admin: no one's handle" | ||
| @@ -506,31 +426,4 @@ mod tests { | ||
} | ||
assert_eq!(GONE.lock().unwrap().len(), 256); | ||
} | ||
| ||
#[test] | ||
fn service_lookup() { | ||
let doc = json!({"service": [ | ||
{"id": "#other", "serviceEndpoint": "x"}, | ||
{"id": "did:plc:a#atproto_pds", "serviceEndpoint": "https://pds.example/"} | ||
]}); | ||
let pds = |doc: &Value| service(doc, "#atproto_pds"); | ||
assert_eq!(pds(&doc).as_deref(), Some("https://pds.example")); | ||
assert_eq!(pds(&json!({"service": []})), None); | ||
assert_eq!(pds(&json!({})), None); | ||
} | ||
| ||
#[test] | ||
fn signing_keys() { | ||
let key = crate::crypto::P256Key::generate().public(); | ||
let kem = crate::crypto::P256Key::generate().public(); | ||
let doc = json!({"verificationMethod": [ | ||
{"id": "#other", "publicKeyMultibase": "zjunk"}, | ||
{"id": "did:plc:a#atproto_kem", "publicKeyMultibase": kem.to_multibase()}, | ||
{"id": "did:plc:a#atproto", "publicKeyMultibase": key.to_multibase()} | ||
]}); | ||
assert_eq!(signing_key(&doc).unwrap(), key); | ||
assert_eq!(kem_key(&doc).unwrap(), kem); | ||
assert!(signing_key(&json!({})).is_err()); | ||
assert!(signing_key(&json!({"verificationMethod": [{"id": "#atproto"}]})).is_err()); | ||
} | ||
} | ||
crates/knotless/src/mentions.rs+1−1
| @@ -36,7 +36,7 @@ pub fn at_start(text: &str) -> Option<(usize, String)> { | ||
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '.' || c == '-')) | ||
.unwrap_or(rest.len()); | ||
let handle = rest[..end].trim_end_matches(['.', '-']); | ||
let valid = identity::valid_handle(handle); | ||
let valid = atproto::syntax::is_handle(handle); | ||
valid.then(|| (1 + handle.len(), handle.to_ascii_lowercase())) | ||
} | ||
| ||
crates/knotless/src/moderation.rs+1−1
| @@ -109,7 +109,7 @@ async fn moderator(ctx: &Ctx) -> Result<(String, String)> { | ||
(None, None) => bail!("reports go to a moderation service: set KNOTLESS_MODERATOR"), | ||
}; | ||
let did = doc["id"].as_str().context("a DID document names its DID")?; | ||
let endpoint = identity::service(&doc, LABELER) | ||
let endpoint = atproto::did::service(&doc, LABELER) | ||
.with_context(|| format!("{did} names no {LABELER} service"))?; | ||
Ok((did.to_string(), endpoint)) | ||
} | ||
crates/knotless/src/remote.rs+3−1
| @@ -323,7 +323,9 @@ impl Remote { | ||
}; | ||
let auth = match auth { | ||
// A credential's read of one record asks for its MAC too. | ||
Some(auth) if method == "getRecord" => Some(space::want_mac(&auth, space::RECORD_MAC)), | ||
Some(auth) if method == "getRecord" => { | ||
Some(space::want_mac(&auth, atproto::space::RECORD_MAC)) | ||
} | ||
auth => auth, | ||
}; | ||
self.xrpc.query(&nsid, &all, auth.as_ref()).await | ||
crates/knotless/src/space.rs+22−43
| @@ -15,6 +15,8 @@ use crate::session::{self, Session}; | ||
use crate::store; | ||
use crate::xrpc::{Auth, Xrpc, XrpcError}; | ||
use anyhow::{Context, Result, bail}; | ||
use atproto::space::want_mac_header; | ||
use atproto::space_auth; | ||
use base64::Engine; | ||
use base64::engine::general_purpose::STANDARD; | ||
use serde::{Deserialize, Serialize}; | ||
| @@ -28,9 +30,6 @@ pub const SPACE_TYPE: &str = "dev.knotless.space"; | ||
/// ([`Space::register_notify`]). | ||
pub const APPVIEW: &str = "#knotless_appview"; | ||
| ||
/// The signature's label and tag, as the alpha names a credential's use. | ||
const LABEL: &str = "atproto-space-credential"; | ||
| ||
/// The header a query carries a delegation token in, from the asker's own | ||
/// PDS for a private repository's space, for the appview to exchange as | ||
/// the asker would ([`Space::delegated`]). | ||
| @@ -502,25 +501,12 @@ async fn exchange(host: &Xrpc, uri: &str, token: &str, key: &P256Key) -> Result< | ||
/// RFC 9421 message signature headers over `authorization`, a space | ||
/// credential's, and the audience it is used for, as the alpha signs them. | ||
pub fn signed(key: &P256Key, authorization: &str, audience: &str) -> Vec<(String, String)> { | ||
let params = format!(r#"("authorization" "atproto-space-audience");tag="{LABEL}""#); | ||
let base = format!( | ||
"\"authorization\": {authorization}\n\"atproto-space-audience\": {audience}\n\"@signature-params\": {params}" | ||
); | ||
let signature = STANDARD.encode(key.sign(base.as_bytes())); | ||
vec![ | ||
("authorization".to_string(), authorization.to_string()), | ||
("atproto-space-audience".to_string(), audience.to_string()), | ||
("signature-input".to_string(), format!("{LABEL}={params}")), | ||
("signature".to_string(), format!("{LABEL}=:{signature}:")), | ||
] | ||
} | ||
| ||
/// A MAC of a record, which a credential's read of one asks for. | ||
pub const RECORD_MAC: &str = "atproto-space-record"; | ||
| ||
/// A MAC of a repo's commit, which a credential's read of its oplog asks | ||
/// for. | ||
pub const COMMIT_MAC: &str = "atproto-space-repo"; | ||
let headers = space_auth::sign_request(key.keypair(), authorization, audience); | ||
let headers = headers.expect("a P256Key is P-256"); | ||
headers | ||
.map(|(name, value)| (name.to_string(), value)) | ||
.into() | ||
} | ||
| ||
/// `auth` asking along with its read for a MAC of the `kind` named, keyed | ||
/// by a secret agreed with a fresh key, as the alpha asks of a credential's | ||
| @@ -539,10 +525,8 @@ pub fn want_mac_from(auth: &Auth, kind: &str, share: &str) -> Auth { | ||
return auth.clone(); | ||
}; | ||
let mut headers = headers.clone(); | ||
headers.push(( | ||
"atproto-space-want-mac".to_string(), | ||
format!(r#"type="{kind}", ver=1, key_share="{share}""#), | ||
)); | ||
let wanted = want_mac_header(kind, share); | ||
headers.push(("atproto-space-want-mac".to_string(), wanted)); | ||
Auth::Headers(headers) | ||
} | ||
| ||
| @@ -695,6 +679,7 @@ impl Admin<'_> { | ||
#[cfg(test)] | ||
mod tests { | ||
use super::*; | ||
use atproto::space::{COMMIT_MAC, RECORD_MAC}; | ||
| ||
/// A context with nothing kept, whose accounts are all on `pds`. | ||
fn ctx_on(dir: &std::path::Path, pds: &str) -> Ctx { | ||
| @@ -767,22 +752,16 @@ mod tests { | ||
space_headers[1], | ||
("atproto-space-audience".into(), "did:plc:o".into()) | ||
); | ||
let params = r#"("authorization" "atproto-space-audience");tag="atproto-space-credential""#; | ||
assert_eq!( | ||
space_headers[2].1, | ||
format!("atproto-space-credential={params}") | ||
); | ||
let sig = space_headers[3] | ||
.1 | ||
.strip_prefix("atproto-space-credential=:") | ||
.unwrap() | ||
.strip_suffix(':') | ||
.unwrap(); | ||
let base = format!( | ||
"\"authorization\": Atproto-Space-Credential {cred}\n\"atproto-space-audience\": did:plc:o\n\"@signature-params\": {params}" | ||
); | ||
let sig = STANDARD.decode(sig).unwrap(); | ||
assert!(key.public().verify(base.as_bytes(), &sig)); | ||
// Signed with the key the credential is bound to, as its host | ||
// checks it. | ||
let header = |name: &str| { | ||
let found = space_headers.iter().find(|(n, _)| n == name); | ||
found.map(|(_, value)| value.as_str()) | ||
}; | ||
let bound = key.public().did_key(); | ||
assert!(space_auth::verify_request(header, &bound).is_ok()); | ||
let other = P256Key::generate().public().did_key(); | ||
assert!(space_auth::verify_request(header, &other).is_err()); | ||
} | ||
| ||
#[test] | ||
| @@ -791,7 +770,7 @@ mod tests { | ||
let credential = Auth::Headers(vec![("authorization".into(), "c".into())]); | ||
let (name, value) = wanted(&credential)[1].clone(); | ||
assert_eq!(name, "atproto-space-want-mac"); | ||
assert!(value.starts_with(r#"type="atproto-space-record", ver=1, key_share="did:key:z"#)); | ||
assert!(atproto::space::parse_want_mac(&value, RECORD_MAC).is_ok()); | ||
assert_ne!(wanted(&credential)[1].1, value); | ||
let session = Auth::Bearer("t".into()); | ||
let unchanged = want_mac(&session, COMMIT_MAC).headers("GET", ""); | ||
crates/knotless/src/space_sync.rs+179−479
658 lines changed: not loaded until asked for.
crates/knotless/Cargo.toml+2−5
| @@ -21,13 +21,8 @@ serde_json.workspace = true | ||
serde_yaml_ng = "0.10" | ||
semver = "1" | ||
sha2.workspace = true | ||
k256.workspace = true | ||
p256.workspace = true | ||
rand_core.workspace = true | ||
bs58.workspace = true | ||
blake3.workspace = true | ||
ciborium.workspace = true | ||
hmac.workspace = true | ||
hpke.workspace = true | ||
tokio.workspace = true | ||
tokio-tungstenite.workspace = true | ||
| @@ -39,3 +34,5 @@ crc32fast = "1.5" | ||
axum.workspace = true | ||
tempfile.workspace = true | ||
reqwest = { workspace = true, features = ["blocking"] } | ||
k256.workspace = true | ||
p256.workspace = true | ||
crates/knotless-serve/src/accounts.rs+1−1
| @@ -56,7 +56,7 @@ pub async fn gone(ctx: &Ctx, did: &str) -> Asked { | ||
Err(e) if matches!(http_status(&e), Some(404 | 410)) => return Asked::Gone, | ||
Err(e) => return Asked::Unknown(format!("{e:#}")), | ||
}; | ||
let Some(pds) = knotless::identity::service(&doc, "#atproto_pds") else { | ||
let Some(pds) = atproto::did::pds(&doc) else { | ||
return Asked::Unknown(format!("{did} names no #atproto_pds service")); | ||
}; | ||
let xrpc = Xrpc::new(ctx.http.clone(), &pds); | ||