From bbca327c6c571d4f8c60afc913256ed63147b096 Mon Sep 17 00:00:00 2001 From: Hayleigh Thompson Date: Tue, 21 Jul 2026 10:38:48 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20support=20for=20did=20urls=20?= =?UTF-8?q?and=20did=20documents.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/at/did.gleam | 488 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 391 insertions(+), 97 deletions(-) diff --git a/src/at/did.gleam b/src/at/did.gleam index cb881e0..fe84530 100644 --- a/src/at/did.gleam +++ b/src/at/did.gleam @@ -1,52 +1,121 @@ // IMPORTS --------------------------------------------------------------------- +import at/handle.{type Handle} +import at/internal/ascii import at/internal/base32 +import at/internal/hostname import gleam/dynamic/decode.{type Decoder} import gleam/int import gleam/json.{type Json} import gleam/option.{type Option, None, Some} import gleam/result +import gleam/string import gleam/uri.{type Uri, Uri} +import ywt/verify_key.{type VerifyKey} // TYPES ----------------------------------------------------------------------- -/// A `Did` or _decentralised identifier_ is a persistent account identifier. -/// The core DID standard was developed by the W3C. +/// A `Did` or _decentralised identifier_ is a persistent account identifier used +/// throughout atproto. A `Did` is represented as a string made up of three key +/// parts: /// -/// A `Did` consists of three parts: -/// -/// ``` +/// ```txt +/// ┌─┬─ did uri scheme /// did:plc:ewvi7nxzyoun6zhxrhs64oiz -/// └┬┘ └┬┘ └───────────┬──────────┘ -/// URI method identifier -/// scheme +/// └┬┘ └──────────┬───────────┘ +/// method identifier /// ``` +/// +/// The scheme is always `did` and while the W3C DID specification allows for +/// many different methods, atproto supports only two: +/// +/// - `plc` (preferred) is a self-authenticating base32-encoded identifier, +/// designed specifically for atproto. PLC DIDs are typically resolved through +/// [plc.directory](https://web.plc.directory) to produce a [`Document`](#Document). +/// +/// - `web` is a human-readable alternative that uses a domain name to identify +/// and locate a [`Document`](#Document). When resolving a `"web"` `Did` +/// it's important to check the resolved document's `also_known_as` field to +/// ensure that the `Did` is actually associated with the domain name used. /// -/// Atproto supports two "blessed" DID methods: +pub opaque type Did { + Did(method: Method, identifier: String, port: Option(Int)) +} + +/// Signifies which DID method is used for a given [`Did`](#Did). This is important +/// to know as `Web` DIDs are not self-authenticating and should be verified after +/// the linked [`Document`](#Document) is resolved. +/// +/// You can query the method of a [`Did`](#Did) using the [`method`](#method) +/// function. +/// +pub type Method { + Plc + Web +} + /// -/// - `did:plc` is a special DID type developed for atproto, allowing -/// self-authenticating identifiers. -/// - `did:web` uses an HTTPS domain name to identify and locate a DID document. +/// +pub type Url { + Url( + did: Option(Did), + path: String, + query: Option(String), + fragment: Option(String), + ) +} + /// -/// This module represents only the forms supported by atproto. In particular, -/// `did:web` identifiers are hostname-level and cannot contain paths. The -/// `localhost` hostname and an optional percent-encoded port are accepted for -/// testing and development. +/// +pub type Document { + Document( + id: Did, + controller: List(Did), + also_known_as: List(Handle), + verification_method: List(EmbeddedVerificationMethod), + authentication: List(VerificationMethod), + assertion_method: List(VerificationMethod), + key_agreement: List(VerificationMethod), + capability_invocation: List(VerificationMethod), + capability_delegation: List(VerificationMethod), + service: List(ServiceEndpoint), + ) +} + +pub type ServiceEndpoint { + ServiceEndpoint(id: Url, type_: String, service_endpoint: String) +} + /// -pub opaque type Did { - Plc(BitArray) - Web(host: String, port: Option(Int)) +/// +pub type VerificationMethod { + Reference(Url) + Embedded(EmbeddedVerificationMethod) +} + +/// +/// +pub type EmbeddedVerificationMethod { + EmbeddedVerificationMethod( + id: Url, + controller: Did, + type_: String, + public_key_jwk: Option(VerifyKey), + public_key_multibase: Option(String), + ) } // CONSTANTS ------------------------------------------------------------------- -const zero = Plc(<<0:120>>) +@internal +pub const zero = Did(method: Plc, identifier: "", port: None) + +const zero_url = Url(did: None, path: "", query: None, fragment: None) // CONSTRUCTORS ---------------------------------------------------------------- -/// Attempt to parse a [`Did`](#Did) from a string. -/// -/// This function accepts the two DID methods supported by atproto: +/// Attempt to parse a [`Did`](#Did) from a string. This function accepts only +/// the two DID methods supported by atproto: /// /// - `did:plc` identifiers containing exactly 24 lowercase base32 characters. /// - `did:web` identifiers containing a valid lowercase atproto hostname. @@ -57,7 +126,7 @@ const zero = Plc(<<0:120>>) /// pub fn from_string(value: String) -> Result(Did, Nil) { case value { - "did:plc:" <> id -> parse_plc_id(id) + "did:plc:" <> identifier -> parse_plc_id(identifier) "did:web:" <> host -> parse_web_host(host) _ -> Error(Nil) } @@ -66,66 +135,192 @@ pub fn from_string(value: String) -> Result(Did, Nil) { fn parse_plc_id(id: String) -> Result(Did, Nil) { case base32.decode(id) { // 24 base32 characters encode 15 bytes. - Ok(<>) -> Ok(Plc(id)) + Ok(<<_:bytes-size(15)>>) -> Ok(Did(method: Plc, identifier: id, port: None)) _ -> Error(Nil) } } fn parse_web_host(host: String) -> Result(Did, Nil) { case host { - "localhost" -> Ok(Web(host, None)) + // special case: localhost is the only valid hostname with only one segment. + "localhost" -> Ok(Did(method: Web, identifier: host, port: None)) // special case: localhost with a percent-encoded colon for a port. - "localhost%3A" <> port | "localhost%3a" <> port -> { - use port <- result.try(int.parse(port)) - - case port { - port if port >= 0 && port <= 65_535 -> Ok(Web("localhost", Some(port))) - _ -> Error(Nil) + "localhost%3A" <> port | "localhost%3a" <> port -> + case int.parse(port) { + Ok(port) if port >= 0 && port <= 65_535 -> + Ok(Did(method: Web, identifier: "localhost", port: Some(port))) + Ok(_) | Error(_) -> Error(Nil) } - } _ -> - case valid_web_host(host) { - True -> Ok(Web(host, None)) - False -> Error(Nil) + case hostname.parse(host) { + Ok(hostname.Normal) | Ok(hostname.Test) -> + Ok(Did(method: Web, identifier: host, port: None)) + Ok(hostname.Reserved) | Error(Nil) -> Error(Nil) } } } -fn valid_web_host(host: String) -> Bool { - valid_host_loop(host, "", 0, 0, False) +/// +/// +pub fn url_from_string(value: String) -> Result(Url, Nil) { + let #(authority, path) = + string.split_once(value, "/") + |> result.lazy_or(fn() { string.split_once(value, "?") }) + |> result.lazy_or(fn() { string.split_once(value, "#") }) + |> result.unwrap(#(value, "")) + + case from_string(authority) { + Ok(did) -> + parse_path( + path, + Url(did: Some(did), path: "", query: None, fragment: None), + ) + + Error(_) if authority == "" -> + parse_path(path, Url(did: None, path: "", query: None, fragment: None)) + + Error(_) -> Error(Nil) + } +} + +fn parse_path(uri_string: String, url: Url) -> Result(Url, Nil) { + parse_path_loop(uri_string, uri_string, url, 0) } -fn valid_host_loop( - remaining: String, - tld: String, - host_length: Int, - segment_length: Int, - trailing_hyphen: Bool, -) -> Bool { - case remaining { - "" if !trailing_hyphen -> valid_tld(tld) +fn parse_path_loop( + original: String, + uri_string: String, + url: Url, + size: Int, +) -> Result(Url, Nil) { + case uri_string { + // `?` marks the beginning of the query with question mark. + "?" <> rest -> { + let path = ascii.slice(original, at: 0, length: size) + let url = Url(..url, path:) + + parse_query_with_question_mark(rest, url) + } + + // `#` marks the beginning of the fragment part. + "#" <> rest -> { + let path = ascii.slice(original, at: 0, length: size) + let url = Url(..url, path: path) + + parse_fragment(rest, url) + } + + // If the string is over that means the entirety of the string was the path + // and it has an empty query and fragment. + "" -> Ok(Url(..url, path: original)) - // hostnames cannot be longer than 253 characters - _ if host_length >= 253 -> False + // In all other cases the character is allowed to be part of the path so we + // just keep munching until we reach to its end. + "0" <> rest + | "1" <> rest + | "2" <> rest + | "3" <> rest + | "4" <> rest + | "5" <> rest + | "6" <> rest + | "7" <> rest + | "8" <> rest + | "9" <> rest + | // [A-Z] + "A" <> rest + | "B" <> rest + | "C" <> rest + | "D" <> rest + | "E" <> rest + | "F" <> rest + | "G" <> rest + | "H" <> rest + | "I" <> rest + | "J" <> rest + | "K" <> rest + | "L" <> rest + | "M" <> rest + | "N" <> rest + | "O" <> rest + | "P" <> rest + | "Q" <> rest + | "R" <> rest + | "S" <> rest + | "T" <> rest + | "U" <> rest + | "V" <> rest + | "W" <> rest + | "X" <> rest + | "Y" <> rest + | "Z" <> rest + | // [a-z] + "a" <> rest + | "b" <> rest + | "c" <> rest + | "d" <> rest + | "e" <> rest + | "f" <> rest + | "g" <> rest + | "h" <> rest + | "i" <> rest + | "j" <> rest + | "k" <> rest + | "l" <> rest + | "m" <> rest + | "n" <> rest + | "o" <> rest + | "p" <> rest + | "q" <> rest + | "r" <> rest + | "s" <> rest + | "t" <> rest + | "u" <> rest + | "v" <> rest + | "w" <> rest + | "x" <> rest + | "y" <> rest + | "z" <> rest + | // [.-_:~] + "." <> rest + | "-" <> rest + | "_" <> rest + | ":" <> rest + | "~" <> rest -> parse_path_loop(original, rest, url, size + 1) - // Labels cannot be empty or begin with a hyphen - "." <> _ if segment_length == 0 -> False - "-" <> _ if segment_length == 0 -> False + _ -> Error(Nil) + } +} - // a dot starts a new segment - "." <> rest if !trailing_hyphen -> - valid_host_loop(rest, rest, host_length + 1, 0, False) +fn parse_query_with_question_mark( + uri_string: String, + url: Url, +) -> Result(Url, Nil) { + parse_query_with_question_mark_loop(uri_string, uri_string, url, 0) +} - // a single segment cannot be longer than 63 characters - _ if segment_length >= 63 -> False +fn parse_query_with_question_mark_loop( + original: String, + uri_string: String, + url: Url, + size: Int, +) -> Result(Url, Nil) { + case uri_string { + // `#` marks the beginning of the fragment part. + "#" <> rest if size == 0 -> parse_fragment(rest, url) + "#" <> rest -> { + let query = ascii.slice(original, at: 0, length: size) + let url = Url(..url, query: Some(query)) + parse_fragment(rest, url) + } - // A hyphen is valid inside a segment, but cannot be its final character - "-" <> rest -> - valid_host_loop(rest, tld, host_length + 1, segment_length + 1, True) + // If the string is over that means the entirety of the string was the query + // and it has an empty fragment. + "" -> Ok(Url(..url, query: Some(original))) - // lowercase ascii and digits are valid + // In all other cases the character is allowed to be part of the query so we + // just keep munching until we reach to its end. "0" <> rest | "1" <> rest | "2" <> rest @@ -136,7 +331,8 @@ fn valid_host_loop( | "7" <> rest | "8" <> rest | "9" <> rest - | "a" <> rest + | // [a-z] + "a" <> rest | "b" <> rest | "c" <> rest | "d" <> rest @@ -161,36 +357,30 @@ fn valid_host_loop( | "w" <> rest | "x" <> rest | "y" <> rest - | "z" <> rest -> - valid_host_loop(rest, tld, host_length + 1, segment_length + 1, False) + | "z" <> rest + | // [.-_:~] + "." <> rest + | "-" <> rest + | "_" <> rest + | ":" <> rest + | "~" <> rest -> + parse_query_with_question_mark_loop(original, rest, url, size + 1) - // everything else isn't. - _ -> False + _ -> Error(Nil) } } -fn valid_tld(tld: String) -> Bool { - case tld { - "" - | "0" <> _ - | "1" <> _ - | "2" <> _ - | "3" <> _ - | "4" <> _ - | "5" <> _ - | "6" <> _ - | "7" <> _ - | "8" <> _ - | "9" <> _ - | "alt" - | "arpa" - | "example" - | "internal" - | "invalid" - | "local" - | "localhost" - | "onion" -> False - _ -> True +fn parse_fragment(rest: String, url: Url) -> Result(Url, Nil) { + Ok(Url(..url, fragment: Some(rest))) +} + +/// +/// +pub fn from_handle(handle: handle.Handle) -> Result(Did, Nil) { + case handle.kind(handle) { + handle.Normal | handle.Test -> + Ok(Did(method: Web, identifier: handle.to_string(handle), port: None)) + handle.Reserved | handle.Invalid -> Error(Nil) } } @@ -203,42 +393,146 @@ pub fn decoder() -> Decoder(Did) { case from_string(string) { Ok(did) -> decode.success(did) - Error(_) -> decode.failure(zero, "Did") + Error(_) -> decode.failure(zero, "did.Did") + } +} + +/// +/// +pub fn url_decoder() -> Decoder(Url) { + use string <- decode.then(decode.string) + + case url_from_string(string) { + Ok(url) -> decode.success(url) + Error(_) -> decode.failure(zero_url, "did.Url") } } +pub fn document_decoder() -> Decoder(Document) { + use id <- decode.field("id", decoder()) + use controller <- decode.optional_field("controller", [], { + decode.list(decoder()) + }) + use also_known_as <- decode.optional_field("alsoKnownAs", [], { + decode.list(handle.at_decoder()) + }) + use verification_method <- decode.optional_field("verificationMethod", [], { + decode.list(embedded_verification_method_decoder()) + }) + use authentication <- decode.optional_field("authentication", [], { + decode.list(verification_method_decoder()) + }) + use assertion_method <- decode.optional_field("assertionMethod", [], { + decode.list(verification_method_decoder()) + }) + use key_agreement <- decode.optional_field("keyAgreement", [], { + decode.list(verification_method_decoder()) + }) + use capability_invocation <- decode.optional_field( + "capabilityInvocation", + [], + { decode.list(verification_method_decoder()) }, + ) + use capability_delegation <- decode.optional_field( + "capabilityDelegation", + [], + { decode.list(verification_method_decoder()) }, + ) + use service <- decode.optional_field("service", [], { + decode.list(service_endpoint_decoder()) + }) + + decode.success(Document( + id:, + controller:, + also_known_as:, + verification_method:, + authentication:, + assertion_method:, + key_agreement:, + capability_invocation:, + capability_delegation:, + service:, + )) +} + +fn verification_method_decoder() -> Decoder(VerificationMethod) { + decode.one_of(decode.map(url_decoder(), Reference), [ + decode.map(embedded_verification_method_decoder(), Embedded), + ]) +} + +fn embedded_verification_method_decoder() -> Decoder(EmbeddedVerificationMethod) { + use id <- decode.field("id", url_decoder()) + use controller <- decode.field("controller", decoder()) + use type_ <- decode.field("type", decode.string) + use public_key_jwk <- decode.optional_field("publicKeyJwk", None, { + decode.map(verify_key.decoder(), Some) + }) + use public_key_multibase <- decode.optional_field( + "publicKeyMultibase", + None, + { decode.map(decode.string, Some) }, + ) + + decode.success(EmbeddedVerificationMethod( + id:, + controller:, + type_:, + public_key_jwk:, + public_key_multibase:, + )) +} + +fn service_endpoint_decoder() -> Decoder(ServiceEndpoint) { + use id <- decode.field("id", url_decoder()) + use type_ <- decode.field("type", decode.string) + use service_endpoint <- decode.field("serviceEndpoint", decode.string) + + decode.success(ServiceEndpoint(id:, type_:, service_endpoint:)) +} + +// QUERIES --------------------------------------------------------------------- + +/// Get the method of a [`Did`](#Did). This can be used to determine if further +/// validation is required in the case of a [`Web`](#Method) [`Did`](#Did). +/// +pub fn method(did: Did) -> Method { + did.method +} + // CONVERSIONS ----------------------------------------------------------------- /// Convert a [`Did`](#Did) to its canonical string representation. /// pub fn to_string(did: Did) -> String { case did { - Plc(id) -> "did:plc:" <> base32.encode(id, False) - Web(host:, port: None) -> "did:web:" <> host - Web(host:, port: Some(port)) -> - "did:web:" <> host <> "%3A" <> int.to_string(port) + Did(method: Plc, identifier:, port: _) -> "did:plc:" <> identifier + Did(method: Web, identifier:, port: None) -> "did:web:" <> identifier + Did(method: Web, identifier:, port: Some(port)) -> + "did:web:" <> identifier <> "%3A" <> int.to_string(port) } } -/// Get the HTTPS URI used to resolve a [`Did`](#Did) document. +/// Get the HTTPS URI used to resolve a [`Did`](#Did) document. /// /// `did:plc` documents are resolved through `plc.directory`. `did:web` /// documents use `/.well-known/did.json` on the identifier's hostname. /// pub fn document_uri(did: Did) -> Uri { case did { - Plc(_) -> + Did(method: Plc, identifier:, ..) -> Uri( scheme: Some("https"), userinfo: None, host: Some("plc.directory"), port: None, - path: "/" <> to_string(did), + path: "/did:plc:" <> identifier, query: None, fragment: None, ) - Web(host:, port:) -> + Did(method: Web, identifier: host, port:) -> Uri( scheme: Some("https"), userinfo: None, -- 2.51.2