use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use k256::ecdsa::{Signature, SigningKey, signature::Signer}; use serde::Deserialize; use serde_json::json; use std::time::{SystemTime, UNIX_EPOCH}; use crate::{Error, now, relay}; use axum::http::StatusCode; #[derive(Deserialize)] struct Committers { committers: Vec, } /// Registration policy gating delegated account creation on Tranquil. /// /// Build with [`Gate::builder`], then call [`Gate::check`] for each request. pub struct Gate { http: reqwest::Client, gate_did: String, signing_key: SigningKey, deliberi_url: String, deliberi_did: String, require_verified_email: bool, } pub struct GateBuilder { http: reqwest::Client, gate_did: String, signing_key: SigningKey, deliberi_url: String, deliberi_did: String, require_verified_email: bool, } impl Gate { pub fn builder( http: reqwest::Client, gate_did: impl Into, signing_key: SigningKey, deliberi_url: impl Into, deliberi_did: impl Into, ) -> GateBuilder { GateBuilder { http, gate_did: gate_did.into(), signing_key, deliberi_url: deliberi_url.into(), deliberi_did: deliberi_did.into(), require_verified_email: true, } } /// Tranquil enforces the per-controller delegate cap itself, keyed on the /// authenticated controller, so the gate only checks what Tranquil cannot. pub async fn check(&self, did: &str) -> Result<(), Error> { if self.require_verified_email { self.check_verified_email(did).await?; } Ok(()) } async fn check_verified_email(&self, did: &str) -> Result<(), Error> { let response = self .http .post(format!( "{}/xrpc/sh.tangled.identity.resolveCommitters", self.deliberi_url )) .bearer_auth(self.resolver_token()) .json(&json!({"actor": did})) .send() .await?; let verified: Committers = relay(response).await?.json().await?; if !verified.committers.iter().any(|value| is_email(value)) { return Err(Error::code(StatusCode::FORBIDDEN, "VerifiedEmailRequired")); } Ok(()) } fn resolver_token(&self) -> String { let timestamp = now(); let claims = json!({"iss": self.gate_did, "aud": self.deliberi_did, "iat": timestamp, "exp": timestamp + 60, "lxm": "sh.tangled.identity.resolveCommitters", "jti": format!("{}", SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos())}); let input = format!( "{}.{}", URL_SAFE_NO_PAD.encode(br#"{"alg":"ES256K","typ":"JWT"}"#), URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).unwrap()) ); let signature: Signature = self.signing_key.sign(input.as_bytes()); format!("{input}.{}", URL_SAFE_NO_PAD.encode(signature.to_bytes())) } } impl GateBuilder { pub fn require_verified_email(mut self, require: bool) -> Self { self.require_verified_email = require; self } pub fn build(self) -> Gate { Gate { http: self.http, gate_did: self.gate_did, signing_key: self.signing_key, deliberi_url: self.deliberi_url, deliberi_did: self.deliberi_did, require_verified_email: self.require_verified_email, } } } fn is_email(value: &str) -> bool { let value = value.trim(); !value.starts_with("did:") && value.len() <= 320 && !value.chars().any(char::is_whitespace) && !value.chars().any(char::is_control) && value.split_once('@').is_some_and(|(local, domain)| { !local.is_empty() && !domain.is_empty() && !domain.contains('@') }) } #[cfg(test)] mod tests { use super::*; #[test] fn email_filter() { for value in [ "did:plc:abcdefghijklmnopqrstuvwx", "", "@host", "user@", "a@@b", "a\0@b", "a b@host", ] { assert!(!is_email(value)); } assert!(is_email("person@example.com")); } }