use std::future::Future;
use base64::Engine;
use chrono::Utc;
use serde::Deserialize;
use sha2::{Digest, Sha256};
use worker::*;
#[derive(Debug, Clone)]
pub struct XrpcError {
pub name: &'static str,
pub message: String,
pub status: u16,
}
impl XrpcError {
pub fn new(name: &'static str, message: impl Into<String>, status: u16) -> Self {
Self {
name,
message: message.into(),
status,
}
}
pub fn auth(message: impl Into<String>) -> Self {
Self::new("AuthError", message, 403)
}
pub fn internal(message: impl Into<String>) -> Self {
Self::new("Internal", message, 500)
}
}
impl std::fmt::Display for XrpcError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.name, self.message)
}
}
const BASE58_ALPHABET: &[u8; 58] = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
fn base58_encode(data: &[u8]) -> String {
let mut zeros = 0;
while zeros < data.len() && data[zeros] == 0 {
zeros += 1;
}
let mut digits: Vec<u8> = Vec::new();
for &byte in &data[zeros..] {
let mut carry = byte as u32;
for d in digits.iter_mut() {
let v = (*d as u32) * 256 + carry;
*d = (v % 58) as u8;
carry = v / 58;
}
while carry > 0 {
digits.push((carry % 58) as u8);
carry /= 58;
}
}
let mut out = String::with_capacity(zeros + digits.len());
for _ in 0..zeros {
out.push('1');
}
for d in digits.iter().rev() {
out.push(BASE58_ALPHABET[*d as usize] as char);
}
out
}
fn base58_decode(input: &str) -> Result<Vec<u8>, XrpcError> {
let bytes = input.as_bytes();
let mut zeroes = 0;
let mut i = 0;
while i < bytes.len() && bytes[i] == b'1' {
zeroes += 1;
i += 1;
}
let mut num: Vec<u8> = Vec::new();
for &c in &bytes[i..] {
let digit = BASE58_ALPHABET
.iter()
.position(|&a| a == c)
.ok_or_else(|| XrpcError::auth("invalid base58 character in DID key"))?
as u32;
let mut carry = digit;
for b in num.iter_mut() {
let v = (*b as u32) * 58 + carry;
*b = (v & 0xff) as u8;
carry = v >> 8;
}
while carry > 0 {
num.push((carry & 0xff) as u8);
carry >>= 8;
}
}
let mut out = Vec::with_capacity(zeroes + num.len());
out.resize(zeroes, 0);
out.extend(num.iter().rev());
Ok(out)
}
fn base64url_decode(input: &str) -> Result<Vec<u8>, XrpcError> {
let mut s = input.to_string();
match s.len() % 4 {
2 => s.push_str("=="),
3 => s.push_str("="),
1 => return Err(XrpcError::auth("malformed JWT segment")),
_ => {}
}
base64::engine::general_purpose::URL_SAFE
.decode(s.as_bytes())
.map_err(|_| XrpcError::auth("malformed JWT encoding"))
}
#[derive(Deserialize)]
struct JwtHeader {
alg: String,
}
#[derive(Deserialize)]
struct JwtClaims {
#[serde(default)]
iss: String,
#[serde(default)]
aud: String,
#[serde(default)]
lxm: Option<String>,
#[serde(default)]
exp: i64,
}
struct ParsedToken {
iss: String,
alg: String,
signature: Vec<u8>,
signing_input: String,
}
fn parse_jwt(
token: &str,
nsid: &str,
audience: &str,
now_unix: i64,
) -> Result<ParsedToken, XrpcError> {
let mut parts = token.split('.');
let header_b64 = parts.next();
let payload_b64 = parts.next();
let sig_b64 = parts.next();
let rest = parts.next();
let (header_b64, payload_b64, sig_b64) = match (header_b64, payload_b64, sig_b64, rest) {
(Some(h), Some(p), Some(s), None) if !h.is_empty() && !p.is_empty() && !s.is_empty() => {
(h, p, s)
}
_ => {
return Err(XrpcError::auth(
"malformed JWT: expected header.payload.signature",
));
}
};
let header: JwtHeader = serde_json::from_slice(&base64url_decode(header_b64)?)
.map_err(|_| XrpcError::auth("malformed JWT header"))?;
let claims: JwtClaims = serde_json::from_slice(&base64url_decode(payload_b64)?)
.map_err(|_| XrpcError::auth("malformed JWT claims"))?;
if !claims.iss.starts_with("did:") || claims.iss.len() < 8 {
return Err(XrpcError::auth("JWT is missing a valid 'iss' DID"));
}
if claims.aud != audience {
return Err(XrpcError::auth("JWT audience does not match this service"));
}
if claims.lxm.as_deref() != Some(nsid) {
return Err(XrpcError::auth(
"JWT lexicon method does not match this endpoint",
));
}
if claims.exp <= now_unix - 60 {
return Err(XrpcError::auth("JWT has expired"));
}
Ok(ParsedToken {
iss: claims.iss,
alg: header.alg,
signature: base64url_decode(sig_b64)?,
signing_input: format!("{}.{}", header_b64, payload_b64),
})
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum KeyAlg {
P256,
K256,
Ed25519,
}
pub fn key_from_multibase(multibase: &str) -> Result<(KeyAlg, Vec<u8>), XrpcError> {
let payload = multibase
.strip_prefix('z')
.ok_or_else(|| XrpcError::auth("unsupported multibase encoding (want base58btc)"))?;
let bytes = base58_decode(payload)?;
let (code, key_bytes) = parse_varint(&bytes)
.ok_or_else(|| XrpcError::auth("malformed multicodec prefix in key"))?;
match code {
0xe7 => Ok((KeyAlg::K256, key_bytes.to_vec())),
0xed => Ok((KeyAlg::Ed25519, key_bytes.to_vec())),
0x1200 => Ok((KeyAlg::P256, key_bytes.to_vec())),
_ => Err(XrpcError::auth(format!(
"unsupported public key multicodec 0x{:x} in DID key",
code
))),
}
}
fn parse_varint(bytes: &[u8]) -> Option<(u64, &[u8])> {
let mut value: u64 = 0;
for (i, &b) in bytes.iter().enumerate() {
if i >= 10 {
return None;
}
if i == 9 && b > 1 {
return None;
}
value |= u64::from(b & 0x7f) << (7 * i);
if b & 0x80 == 0 {
return Some((value, &bytes[i + 1..]));
}
}
None
}
#[cfg(test)]
fn did_key_from_bytes(tagged: &[u8]) -> String {
format!("did:key:z{}", base58_encode(tagged))
}
#[derive(Deserialize)]
pub(crate) struct DidDoc {
#[serde(default)]
id: String,
#[serde(default, rename = "verificationMethod")]
verification_method: Vec<VerificationMethod>,
#[serde(default, rename = "alsoKnownAs")]
also_known_as: Vec<String>,
#[serde(default)]
service: Vec<DidService>,
}
#[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<Option<String>, 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)]
struct VerificationMethod {
#[serde(default)]
id: String,
#[serde(default, rename = "publicKeyMultibase")]
public_key_multibase: String,
}
fn signing_key_from_doc(doc: &DidDoc) -> Option<String> {
let first_matching = |suffix: &str| {
doc.verification_method
.iter()
.find(|vm| vm.id == suffix || vm.id.ends_with(suffix))
.map(|vm| vm.public_key_multibase.clone())
};
first_matching("#atproto")
.or_else(|| first_matching("#atproto_pds"))
.or_else(|| {
doc.verification_method
.first()
.map(|vm| vm.public_key_multibase.clone())
})
.filter(|mb| !mb.is_empty())
}
const MAX_DID_DOC_BYTES: usize = 1 << 20;
fn valid_did_web_authority(authority: &str) -> bool {
let (host, port) = match authority.rsplit_once(':') {
Some((h, p)) => {
if p.is_empty() || !p.bytes().all(|b| b.is_ascii_digit()) || p.len() > 5 {
return false;
}
(h, true)
}
None => (authority, false),
};
if host.is_empty()
|| host.contains(':')
|| authority.contains('@')
|| authority.contains('?')
|| authority.contains('#')
{
return false;
}
let mut any = false;
for label in host.split('.') {
if label.is_empty() || label.len() > 63 {
return false;
}
let bytes = label.as_bytes();
if !bytes[0].is_ascii_alphanumeric()
|| !bytes[bytes.len() - 1].is_ascii_alphanumeric()
|| !bytes
.iter()
.all(|b| b.is_ascii_alphanumeric() || *b == b'-')
{
return false;
}
any = true;
}
let _ = (port, any);
true
}
fn did_web_doc_url(method_id: &str) -> Option<String> {
let mut segments = method_id.split(':');
let authority = segments.next()?.replace("%3A", ":");
if !valid_did_web_authority(&authority) {
return None;
}
let path: Vec<&str> = segments.collect();
if path.is_empty() {
Some(format!("https://{}/.well-known/did.json", authority))
} else {
Some(format!("https://{}/{}/did.json", authority, path.join("/")))
}
}
pub(crate) fn did_doc_url_for(env: &Env, iss: &str) -> Result<String, XrpcError> {
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)))
} else if iss.starts_with("did:plc:") {
let plc = env
.var("SITES_PLC_URL")
.map(|v| v.to_string())
.ok()
.filter(|v| !v.is_empty())
.unwrap_or_else(|| "https://plc.directory".to_string());
Ok(format!("{}/{}", plc, iss))
} else {
Err(XrpcError::auth(format!(
"unsupported DID method in DID: {}",
iss
)))
}
}
async fn fetch_did_doc(url: &str, iss: &str) -> Result<DidDoc, XrpcError> {
let mut resp = Fetch::Url(
url.parse()
.map_err(|_| XrpcError::auth(format!("invalid DID document URL for {}", iss)))?,
)
.send()
.await
.map_err(|_| XrpcError::auth(format!("failed to resolve DID document for {}", iss)))?;
let status = resp.status_code();
if !(200..300).contains(&status) {
return Err(XrpcError::auth(format!(
"unreadable DID document for {}",
iss
)));
}
let content_len = resp
.headers()
.get("Content-Length")
.ok()
.flatten()
.and_then(|v| v.trim().parse::<usize>().ok());
if content_len.map_or(false, |len| len > MAX_DID_DOC_BYTES) {
return Err(XrpcError::auth(format!(
"oversized DID document for {}",
iss
)));
}
let bytes = resp
.bytes()
.await
.map_err(|_| XrpcError::auth(format!("unreadable DID document for {}", iss)))?;
if bytes.len() > MAX_DID_DOC_BYTES {
return Err(XrpcError::auth(format!(
"oversized DID document for {}",
iss
)));
}
let doc: DidDoc = serde_json::from_slice(&bytes)
.map_err(|_| XrpcError::auth(format!("unreadable DID document for {}", iss)))?;
if doc.id != iss {
return Err(XrpcError::auth(format!(
"DID document identity mismatch: {}",
iss
)));
}
Ok(doc)
}
async fn resolve_issuer_key(env: &Env, kv: &KvStore, iss: &str) -> Result<String, XrpcError> {
let cache_key = format!("did:{}", iss);
if let Some(cached) = kv
.get(&cache_key)
.text()
.await
.map_err(|e| XrpcError::internal(format!("KV cache read failed: {}", e)))?
{
return Ok(cached);
}
let multibase = if let Some(rest) = iss.strip_prefix("did:key:") {
rest.to_string()
} else {
let url = did_doc_url_for(env, iss)?;
let doc = fetch_did_doc(&url, iss).await?;
signing_key_from_doc(&doc).ok_or_else(|| {
XrpcError::auth(format!("DID document for {} has no signing key", iss))
})?
};
key_from_multibase(&multibase)
.map_err(|e| XrpcError::auth(format!("invalid signing key for {}: {}", iss, e.message)))?;
kv.put(&cache_key, &multibase)
.map_err(|e| XrpcError::internal(format!("KV cache write failed: {}", e)))?
.expiration_ttl(300)
.execute()
.await
.map_err(|e| XrpcError::internal(format!("KV cache write failed: {}", e)))?;
Ok(multibase)
}
fn verify_signature(
alg: &str,
key_alg: KeyAlg,
key_bytes: &[u8],
signing_input: &str,
signature: &[u8],
) -> Result<(), XrpcError> {
match (alg, key_alg) {
("ES256", KeyAlg::P256) => {
let digest = Sha256::digest(signing_input.as_bytes());
let sig = p256::ecdsa::Signature::from_slice(signature)
.map_err(|_| XrpcError::auth("malformed ES256 signature"))?;
let vk = p256::ecdsa::VerifyingKey::from_sec1_bytes(key_bytes)
.map_err(|_| XrpcError::auth("malformed P-256 public key"))?;
use p256::ecdsa::signature::hazmat::PrehashVerifier;
vk.verify_prehash(&digest, &sig)
.map_err(|_| XrpcError::auth("signature verification failed"))
}
("ES256K", KeyAlg::K256) => {
let digest = Sha256::digest(signing_input.as_bytes());
let sig = k256::ecdsa::Signature::from_slice(signature)
.map_err(|_| XrpcError::auth("malformed ES256K signature"))?;
let vk = k256::ecdsa::VerifyingKey::from_sec1_bytes(key_bytes)
.map_err(|_| XrpcError::auth("malformed secp256k1 public key"))?;
use k256::ecdsa::signature::hazmat::PrehashVerifier;
vk.verify_prehash(&digest, &sig)
.map_err(|_| XrpcError::auth("signature verification failed"))
}
("EdDSA", KeyAlg::Ed25519) => {
let key: [u8; 32] = key_bytes
.try_into()
.map_err(|_| XrpcError::auth("malformed Ed25519 public key"))?;
let vk = ed25519_dalek::VerifyingKey::from_bytes(&key)
.map_err(|_| XrpcError::auth("malformed Ed25519 public key"))?;
let sig = ed25519_dalek::Signature::from_slice(signature)
.map_err(|_| XrpcError::auth("malformed EdDSA signature"))?;
vk.verify_strict(signing_input.as_bytes(), &sig)
.map_err(|_| XrpcError::auth("signature verification failed"))
}
_ => Err(XrpcError::auth(format!(
"unsupported JWT algorithm {:?}",
alg
))),
}
}
// `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<String, XrpcError> {
let audience = env
.var("SITES_AUDIENCE_DID")
.map_err(|_| XrpcError::internal("SITES_AUDIENCE_DID is not configured"))?
.to_string();
let kv = env
.kv("SITES")
.map_err(|_| XrpcError::internal("SITES KV is not configured"))?;
verify_with(req, nsid, &audience, move |iss: String| {
let kv = kv.clone();
async move { resolve_issuer_key(env, &kv, &iss).await }
})
.await
}
pub async fn verify_with<F, Fut>(
req: &Request,
nsid: &str,
audience: &str,
resolve: F,
) -> Result<String, XrpcError>
where
F: Fn(String) -> Fut,
Fut: Future<Output = Result<String, XrpcError>>,
{
let token = req
.headers()
.get("Authorization")
.map_err(|e| XrpcError::internal(e.to_string()))?;
let token = match token.as_deref() {
Some(token) => match token.strip_prefix("Bearer ") {
Some(rest) => rest.to_string(),
None => return Err(XrpcError::auth("missing Bearer token")),
},
None => return Err(XrpcError::auth("missing Authorization header")),
};
verify_with_token(&token, nsid, audience, resolve).await
}
pub async fn verify_with_token<F, Fut>(
token: &str,
nsid: &str,
audience: &str,
resolve: F,
) -> Result<String, XrpcError>
where
F: Fn(String) -> Fut,
Fut: Future<Output = Result<String, XrpcError>>,
{
let parsed = parse_jwt(token, nsid, audience, Utc::now().timestamp())?;
let multibase = resolve(parsed.iss.clone()).await?;
let (key_alg, key_bytes) = key_from_multibase(&multibase)?;
verify_signature(
&parsed.alg,
key_alg,
&key_bytes,
&parsed.signing_input,
&parsed.signature,
)?;
Ok(parsed.iss)
}
#[cfg(test)]
mod tests {
use super::*;
use p256::ecdsa::SigningKey;
use p256::ecdsa::signature::Signer;
fn mint_jwt(signing_key: &SigningKey, iss: &str, aud: &str, lxm: &str, exp: i64) -> String {
let header = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(r#"{"alg":"ES256","typ":"JWT"}"#);
let payload_obj = format!(
r#"{{"iss":"{}","aud":"{}","lxm":"{}","exp":{}}}"#,
iss, aud, lxm, exp
);
let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload_obj);
let signing_input = format!("{}.{}", header, payload);
let digest = Sha256::digest(signing_input.as_bytes());
let (sig, _recovery_id) = signing_key.sign_prehash_recoverable(&digest).unwrap();
let sig_bytes: &[u8] = &sig.to_bytes();
let signature = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(sig_bytes);
format!("{}.{}.{}", header, payload, signature)
}
fn p256_did_key(signing_key: &SigningKey) -> String {
let point = p256::EncodedPoint::from(signing_key.verifying_key()).compress();
let mut tagged = Vec::with_capacity(35);
tagged.extend_from_slice(&[0x80, 0x24]);
tagged.extend_from_slice(point.as_bytes());
did_key_from_bytes(&tagged)
}
fn signing_key() -> SigningKey {
SigningKey::from_slice(&[0x42; 32]).unwrap()
}
fn resolve_did_key(iss: String) -> impl Future<Output = Result<String, XrpcError>> + 'static {
async move { Ok(iss.trim_start_matches("did:key:").to_string()) }
}
#[test]
fn base58_roundtrip() {
for data in [
&b""[..],
&b"\x00"[..],
&b"\x00\x00hello world\x00"[..],
b"the quick brown fox jumps over the lazy dog",
] {
let enc = base58_encode(data);
let dec = base58_decode(&enc).unwrap();
assert_eq!(dec, data, "roundtrip failed for {:?}", data);
}
assert_eq!(base58_encode(b"hello"), "Cn8eVZg");
assert_eq!(base58_decode("Cn8eVZg").unwrap(), b"hello");
}
#[test]
fn did_key_multibase_roundtrip() {
let key = signing_key();
let did = p256_did_key(&key);
let (alg, bytes) = key_from_multibase(did.trim_start_matches("did:key:")).unwrap();
assert_eq!(alg, KeyAlg::P256);
assert_eq!(bytes.len(), 33);
}
fn multibase_of(tagged: &[u8]) -> String {
format!("z{}", base58_encode(tagged))
}
#[test]
fn k256_and_ed25519_multicodecs_bind_to_the_right_curves() {
let mut k256_tagged = vec![0xe7, 0x01, 0x02];
k256_tagged.extend_from_slice(&[0xff; 32]);
let (alg, bytes) = key_from_multibase(&multibase_of(&k256_tagged)).unwrap();
assert_eq!(alg, KeyAlg::K256);
assert_eq!(bytes.len(), 33);
let mut ed_tagged = vec![0xed, 0x01];
ed_tagged.extend_from_slice(&[0xaa; 32]);
let (alg, bytes) = key_from_multibase(&multibase_of(&ed_tagged)).unwrap();
assert_eq!(alg, KeyAlg::Ed25519);
assert_eq!(bytes.len(), 32);
let mut priv_tagged = vec![0xe1, 0x01];
priv_tagged.extend_from_slice(&[0xbb; 32]);
assert!(key_from_multibase(&multibase_of(&priv_tagged)).is_err());
}
#[test]
fn varint_ten_byte_overflow_is_rejected() {
let bytes = vec![0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f];
assert!(parse_varint(&bytes).is_none());
let mut ok = vec![
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x01, 0x00,
];
let (code, rest) = parse_varint(&ok).unwrap();
assert_eq!(code, u64::MAX);
assert_eq!(rest.len(), 1);
}
#[test]
fn did_web_doc_urls() {
assert_eq!(
did_web_doc_url("sites.tangled.org").as_deref(),
Some("https://sites.tangled.org/.well-known/did.json")
);
assert_eq!(
did_web_doc_url("example.com:user:alice").as_deref(),
Some("https://example.com/user/alice/did.json")
);
assert_eq!(
did_web_doc_url("example.com%3A3000").as_deref(),
Some("https://example.com:3000/.well-known/did.json")
);
assert!(did_web_doc_url("").is_none());
assert!(did_web_doc_url("host/evil").is_none());
}
#[tokio::test]
async fn verify_accepts_valid_es256_token() {
let key = signing_key();
let did = p256_did_key(&key);
let token = mint_jwt(
&key,
&did,
"did:web:sites.tangled.org",
"org.tangled.temp.site.claimDomain",
Utc::now().timestamp() + 60,
);
let verified = verify_with_token(
&token,
"org.tangled.temp.site.claimDomain",
"did:web:sites.tangled.org",
resolve_did_key,
)
.await
.unwrap();
assert_eq!(verified, did);
}
#[tokio::test]
async fn verify_rejects_wrong_audience() {
let key = signing_key();
let did = p256_did_key(&key);
let token = mint_jwt(
&key,
&did,
"did:web:somewhere-else.example",
"org.tangled.temp.site.claimDomain",
Utc::now().timestamp() + 60,
);
let err = verify_with_token(
&token,
"org.tangled.temp.site.claimDomain",
"did:web:sites.tangled.org",
resolve_did_key,
)
.await
.unwrap_err();
assert_eq!(err.name, "AuthError");
assert_eq!(err.status, 403);
}
#[tokio::test]
async fn verify_rejects_expired_token() {
let key = signing_key();
let did = p256_did_key(&key);
let token = mint_jwt(
&key,
&did,
"did:web:sites.tangled.org",
"org.tangled.temp.site.claimDomain",
Utc::now().timestamp() - 120,
);
let err = verify_with_token(
&token,
"org.tangled.temp.site.claimDomain",
"did:web:sites.tangled.org",
resolve_did_key,
)
.await
.unwrap_err();
assert_eq!(err.name, "AuthError");
assert_eq!(err.status, 403);
}
#[tokio::test]
async fn verify_rejects_wrong_lxm() {
let key = signing_key();
let did = p256_did_key(&key);
let token = mint_jwt(
&key,
&did,
"did:web:sites.tangled.org",
"org.tangled.temp.somethingElse.doThing",
Utc::now().timestamp() + 60,
);
let err = verify_with_token(
&token,
"org.tangled.temp.site.claimDomain",
"did:web:sites.tangled.org",
resolve_did_key,
)
.await
.unwrap_err();
assert_eq!(err.name, "AuthError");
}
#[tokio::test]
async fn verify_rejects_empty_and_malformed_tokens() {
let err = verify_with_token(
"",
"org.tangled.temp.site.claimDomain",
"did:web:sites.tangled.org",
resolve_did_key,
)
.await
.unwrap_err();
assert_eq!(err.name, "AuthError");
let err = verify_with_token(
"not-a-jwt",
"org.tangled.temp.site.claimDomain",
"did:web:sites.tangled.org",
resolve_did_key,
)
.await
.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:"));
}
}