// The DID document -- what a DID resolves to, and where an account's PDS is
// written down.
//
// https://atproto.com/specs/did
//
// Deliberately partial, as every atproto implementation's is. A W3C DID
// document has a dozen more members and `serviceEndpoint` may be an object or
// an array; atproto uses four fields and always writes the endpoint as a
// string. Modelling the rest would be modelling a spec this protocol does not
// use, so unknown members are kept in `extra` and returned unchanged rather
// than described.
//
// The one thing this file exists for, past being a type: an id may be written
// RELATIVE (`#atproto_pds`) or ABSOLUTE (`did:plc:xyz#atproto_pds`), and both
// forms are in the wild. Every lookup here accepts either. A resolver that
// checked only one silently fails to find the PDS for half the accounts it
// sees, which is the kind of bug that looks like a network problem.
///|
/// The fragment identifying an account's personal data server.
pub const PDS_SERVICE_ID : String = "#atproto_pds"
///|
/// ... and the `type` it must carry.
pub const PDS_SERVICE_TYPE : String = "AtprotoPersonalDataServer"
///|
/// The fragment identifying an account's repository signing key.
pub const SIGNING_KEY_ID : String = "#atproto"
///|
pub(all) struct VerificationMethod {
id : String
type_ : String
controller : String
public_key_multibase : String?
extra : Map[String, @data.LexValue]
} derive(Eq, Debug)
///|
pub(all) struct Service {
id : String
type_ : String
service_endpoint : String
extra : Map[String, @data.LexValue]
} derive(Eq, Debug)
///|
pub(all) struct DidDocument {
id : @syntax.Did
/// Other names for this subject. An atproto handle appears here as
/// `at://alice.bsky.social`.
also_known_as : Array[String]
verification_method : Array[VerificationMethod]
service : Array[Service]
extra : Map[String, @data.LexValue]
} derive(Eq, Debug)
///|
/// The account's handle, from the first `at://` entry in `alsoKnownAs`.
///
/// This is a CLAIM, not a fact. The document says what handle the account wants
/// and anyone can write anything there; it is only true if resolving that
/// handle leads back to this DID. `verify_handle` below is that second half,
/// and `@client.resolve_identity` is what does both.
pub fn DidDocument::handle(self : Self) -> @syntax.Handle? {
for name in self.also_known_as {
if name.has_prefix("at://") {
// A malformed entry is skipped rather than fatal: `alsoKnownAs` is a list
// of claims from a third party, and one bad one must not hide a good one
// further down.
if handle_or_none(name[5:].to_owned()) is Some(handle) {
return Some(handle)
}
}
}
None
}
///|
fn handle_or_none(text : String) -> @syntax.Handle? {
Some(@syntax.Handle::parse(text)) catch {
_ => None
}
}
///|
/// Whether the document claims this handle -- the second half of bidirectional
/// verification.
///
/// Compared after normalizing, because `Handle::parse` lower-cases and a
/// document may not have.
pub fn DidDocument::verify_handle(self : Self, handle : @syntax.Handle) -> Bool {
self.handle() is Some(claimed) && claimed == handle
}
///|
/// Where this account's repository lives. Every authenticated request goes
/// here, so a session that cannot find it has to fall back on the host it
/// logged in to.
pub fn DidDocument::pds_endpoint(self : Self) -> String? {
self.service_endpoint(PDS_SERVICE_ID, type_=PDS_SERVICE_TYPE)
}
///|
/// An endpoint by service id, accepting the relative and absolute spellings of
/// the id, and optionally requiring a `type`.
///
/// The endpoint must be an `http`/`https` URL. That check is not decoration: a
/// DID document is fetched from a third party and its contents decide where
/// this library sends an access token, so a `file:` or `data:` endpoint is an
/// attack rather than a curiosity.
pub fn DidDocument::service_endpoint(
self : Self,
id : String,
type_? : String,
) -> String? {
let absolute = self.id.to_string() + id
for entry in self.service {
if entry.id != id && entry.id != absolute {
continue
}
if type_ is Some(wanted) && entry.type_ != wanted {
continue
}
if is_http_url(entry.service_endpoint) {
return Some(entry.service_endpoint)
}
}
None
}
///|
/// The repository signing key, as `publicKeyMultibase`. Only useful to a caller
/// verifying commits, which this library does not do -- exposed because the
/// document carries it and dropping it would be lossy.
pub fn DidDocument::signing_key(self : Self) -> VerificationMethod? {
let absolute = self.id.to_string() + SIGNING_KEY_ID
// Spelled `entry` because `method` is a reserved word.
for entry in self.verification_method {
if entry.id == SIGNING_KEY_ID || entry.id == absolute {
return Some(entry)
}
}
None
}
///|
fn is_http_url(endpoint : String) -> Bool {
(endpoint.has_prefix("https://") || endpoint.has_prefix("http://")) &&
endpoint.length() > 8
}
///|
/// Decodes a `didDoc`, which arrives as a lexicon `unknown` -- so it is a
/// `LexValue` until something asks for it as this.
pub fn DidDocument::from_lex(
value : @data.LexValue,
path? : String = "",
) -> DidDocument raise @data.DecodeError {
let rest = @data.object_fields(value, path~)
let id = @data.require_format(rest, "id", path~, @syntax.Did::parse)
let also_known_as = @data.take_string_array(rest, "alsoKnownAs", path~).unwrap_or([],
)
let verification_method = @data.take_array(
rest,
"verificationMethod",
path~,
verification_method_of_lex,
).unwrap_or([])
let service = @data.take_array(rest, "service", path~, service_of_lex).unwrap_or([],
)
{ id, also_known_as, verification_method, service, extra: rest }
}
///|
fn verification_method_of_lex(
value : @data.LexValue,
path : String,
) -> VerificationMethod raise @data.DecodeError {
let rest = @data.object_fields(value, path~)
let id = @data.require_string(rest, "id", path~)
let type_ = @data.require_string(rest, "type", path~)
let controller = @data.require_string(rest, "controller", path~)
let public_key_multibase = @data.take_string(rest, "publicKeyMultibase")
{ id, type_, controller, public_key_multibase, extra: rest }
}
///|
fn service_of_lex(
value : @data.LexValue,
path : String,
) -> Service raise @data.DecodeError {
let rest = @data.object_fields(value, path~)
let id = @data.require_string(rest, "id", path~)
let type_ = @data.require_string(rest, "type", path~)
// An object-or-array `serviceEndpoint` is legal W3C and unused by atproto, so
// it is left in `extra` rather than modelled -- which makes
// `service_endpoint` return None for it, the correct answer.
let service_endpoint = @data.take_string(rest, "serviceEndpoint").unwrap_or(
"",
)
{ id, type_, service_endpoint, extra: rest }
}
///|
pub fn DidDocument::to_lex(self : Self) -> @data.LexValue {
let out : Map[String, @data.LexValue] = Map([])
out["id"] = Str(self.id.to_string())
if self.also_known_as.length() > 0 {
@data.put_string_array(out, "alsoKnownAs", Some(self.also_known_as))
}
if self.verification_method.length() > 0 {
@data.put_array(
out,
"verificationMethod",
Some(self.verification_method),
verification_method_to_lex,
)
}
if self.service.length() > 0 {
@data.put_array(out, "service", Some(self.service), service_to_lex)
}
@data.merge_extra(out, self.extra)
}
///|
fn verification_method_to_lex(entry : VerificationMethod) -> @data.LexValue {
let out : Map[String, @data.LexValue] = Map([])
out["id"] = Str(entry.id)
out["type"] = Str(entry.type_)
out["controller"] = Str(entry.controller)
@data.put_string(out, "publicKeyMultibase", entry.public_key_multibase)
@data.merge_extra(out, entry.extra)
}
///|
fn service_to_lex(entry : Service) -> @data.LexValue {
let out : Map[String, @data.LexValue] = Map([])
out["id"] = Str(entry.id)
out["type"] = Str(entry.type_)
out["serviceEndpoint"] = Str(entry.service_endpoint)
@data.merge_extra(out, entry.extra)
}