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, status: u16) -> Self { Self { name, message: message.into(), status, } } pub fn auth(message: impl Into) -> Self { Self::new("AuthError", message, 403) } pub fn internal(message: impl Into) -> 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 = 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, 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 = 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, 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, #[serde(default)] exp: i64, } struct ParsedToken { iss: String, alg: String, signature: Vec, signing_input: String, } fn parse_jwt( token: &str, nsid: &str, audience: &str, now_unix: i64, ) -> Result { 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), 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, #[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)] struct VerificationMethod { #[serde(default)] id: String, #[serde(default, rename = "publicKeyMultibase")] public_key_multibase: String, } fn signing_key_from_doc(doc: &DidDoc) -> Option { 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 { 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 { 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 { 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::().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 { 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 { 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( req: &Request, nsid: &str, audience: &str, resolve: F, ) -> Result where F: Fn(String) -> Fut, Fut: Future>, { 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( token: &str, nsid: &str, audience: &str, resolve: F, ) -> Result where F: Fn(String) -> Fut, Fut: Future>, { 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> + '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:")); } }