~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/knotless

git that lives in your Atmosphere account

knotless.dev
1 collaborator

knotless 0.13.0 Latest 2 packages, 1 image

1
10

Service auth, DID documents and spaces come from the atproto crate

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:atproto/shared-primitives
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);
crates/knotless-serve/src/archive.rs+1−1
crates/knotless-serve/src/moderation.rs+1−1
crates/knotless-serve/src/spaces.rs+6−5
crates/knotless-serve/src/viewer.rs+2−2
crates/knotless-serve/tests/gateway.rs+1−1
crates/knotless-serve/Cargo.toml+1−0
AGENTS.md+5−3
Cargo.lock+1−3
Cargo.toml+0−3