From cdb071ea67a442f9e2252baea8d27e73ad631794 Mon Sep 17 00:00:00 2001 From: rebecca Date: Sun, 19 Jul 2026 01:09:52 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20add=20at/did=20module.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/at/did.gleam | 260 +++++++++++++++++++++++++++++++++++++++++ test/at/did_test.gleam | 84 +++++++++++++ 2 files changed, 344 insertions(+) create mode 100644 src/at/did.gleam create mode 100644 test/at/did_test.gleam diff --git a/src/at/did.gleam b/src/at/did.gleam new file mode 100644 index 0000000..cb881e0 --- /dev/null +++ b/src/at/did.gleam @@ -0,0 +1,260 @@ +// IMPORTS --------------------------------------------------------------------- + +import at/internal/base32 +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/uri.{type Uri, Uri} + +// TYPES ----------------------------------------------------------------------- + +/// A `Did` or _decentralised identifier_ is a persistent account identifier. +/// The core DID standard was developed by the W3C. +/// +/// A `Did` consists of three parts: +/// +/// ``` +/// did:plc:ewvi7nxzyoun6zhxrhs64oiz +/// └┬┘ └┬┘ └───────────┬──────────┘ +/// URI method identifier +/// scheme +/// ``` +/// +/// Atproto supports two "blessed" DID methods: +/// +/// - `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. +/// +/// 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 opaque type Did { + Plc(BitArray) + Web(host: String, port: Option(Int)) +} + +// CONSTANTS ------------------------------------------------------------------- + +const zero = Plc(<<0:120>>) + +// CONSTRUCTORS ---------------------------------------------------------------- + +/// Attempt to parse a [`Did`](#Did) from a string. +/// +/// This function accepts 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. +/// `localhost` and `localhost%3A` are also accepted for development. +/// +/// Other DID methods are rejected, even if they conform to the generic DID +/// identifier syntax. +/// +pub fn from_string(value: String) -> Result(Did, Nil) { + case value { + "did:plc:" <> id -> parse_plc_id(id) + "did:web:" <> host -> parse_web_host(host) + _ -> Error(Nil) + } +} + +fn parse_plc_id(id: String) -> Result(Did, Nil) { + case base32.decode(id) { + // 24 base32 characters encode 15 bytes. + Ok(<>) -> Ok(Plc(id)) + _ -> Error(Nil) + } +} + +fn parse_web_host(host: String) -> Result(Did, Nil) { + case host { + "localhost" -> Ok(Web(host, 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) + } + } + + _ -> + case valid_web_host(host) { + True -> Ok(Web(host, None)) + False -> Error(Nil) + } + } +} + +fn valid_web_host(host: String) -> Bool { + valid_host_loop(host, "", 0, 0, False) +} + +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) + + // hostnames cannot be longer than 253 characters + _ if host_length >= 253 -> False + + // Labels cannot be empty or begin with a hyphen + "." <> _ if segment_length == 0 -> False + "-" <> _ if segment_length == 0 -> False + + // a dot starts a new segment + "." <> rest if !trailing_hyphen -> + valid_host_loop(rest, rest, host_length + 1, 0, False) + + // a single segment cannot be longer than 63 characters + _ if segment_length >= 63 -> False + + // 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) + + // lowercase ascii and digits are valid + "0" <> rest + | "1" <> rest + | "2" <> rest + | "3" <> rest + | "4" <> rest + | "5" <> rest + | "6" <> rest + | "7" <> rest + | "8" <> rest + | "9" <> rest + | "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 -> + valid_host_loop(rest, tld, host_length + 1, segment_length + 1, False) + + // everything else isn't. + _ -> False + } +} + +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 + } +} + +/// A [`Decoder`](https://gleam-stdlib.hexdocs.pm/gleam/dynamic/decode.html#Decoder) +/// for [`Did`](#Did) strings. You'll need this any time you want to decode a +/// [`Did`](#Did) from a JSON payload, for example. +/// +pub fn decoder() -> Decoder(Did) { + use string <- decode.then(decode.string) + + case from_string(string) { + Ok(did) -> decode.success(did) + Error(_) -> decode.failure(zero, "Did") + } +} + +// 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) + } +} + +/// 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(_) -> + Uri( + scheme: Some("https"), + userinfo: None, + host: Some("plc.directory"), + port: None, + path: "/" <> to_string(did), + query: None, + fragment: None, + ) + + Web(host:, port:) -> + Uri( + scheme: Some("https"), + userinfo: None, + host: Some(host), + port: port, + path: "/.well-known/did.json", + query: None, + fragment: None, + ) + } +} + +/// Serialise a [`Did`](#Did) to JSON. +/// +pub fn to_json(did: Did) -> Json { + did + |> to_string + |> json.string +} diff --git a/test/at/did_test.gleam b/test/at/did_test.gleam new file mode 100644 index 0000000..09152aa --- /dev/null +++ b/test/at/did_test.gleam @@ -0,0 +1,84 @@ +import at/did +import gleam/json +import gleam/list +import gleam/uri + +pub fn valid_dids_test() { + let examples = [ + "did:plc:ewvi7nxzyoun6zhxrhs64oiz", + "did:plc:7iza6de2dwap2sbkpav7c6c6", + "did:web:user.example.com", + "did:web:john-john.test", + "did:web:8.cn", + "did:web:localhost%3A1234", + ] + + list.each(examples, fn(example) { + let assert Ok(parsed) = did.from_string(example) as example + assert did.to_string(parsed) == example + }) + + let assert Ok(localhost) = did.from_string("did:web:localhost%3a3000") + assert did.to_string(localhost) == "did:web:localhost%3A3000" +} + +pub fn invalid_dids_test() { + let examples = [ + // Only the two blessed atproto methods are supported. + "did:key:zQ3shZc2QzApp2oymGvQbzP8eKheVshBHbU4ZYjeXqwSKEn6N", + // did:plc is exactly 24 lowercase base32 characters + "did:plc:ewvi7nxzyoun6zhxrhs64oi", + "did:plc:EWVI7NXZYOUN6ZHXHRS64OIZ", + "did:plc:ewvi7nxzyoun6zhxrhs64oi0", + // did:web uses lowercase, hostname-level identifiers + "did:web:EXAMPLE.COM", + "did:web:john", + "did:web:john..test", + "did:web:-john.test", + "did:web:john-.test", + "did:web:john.0", + "did:web:bücher.test", + "did:web:example.com:user", + // Reserved TLDs fail atproto resolution policy + "did:web:blah.arpa", + // Ports are only available on localhost in development + "did:web:example.com%3A3000", + "did:web:localhost%3A65536", + ] + + list.each(examples, fn(example) { + assert did.from_string(example) == Error(Nil) as example + }) +} + +pub fn document_uri_examples_test() { + let examples = [ + #( + "did:plc:ewvi7nxzyoun6zhxrhs64oiz", + "https://plc.directory/did:plc:ewvi7nxzyoun6zhxrhs64oiz", + ), + #("did:web:example.com", "https://example.com/.well-known/did.json"), + #("did:web:localhost%3A3000", "https://localhost:3000/.well-known/did.json"), + ] + + use example <- list.each(examples) + let #(input, expected) = example + let assert Ok(parsed) = did.from_string(input) + + assert parsed |> did.document_uri |> uri.to_string == expected +} + +pub fn json_conversion_and_decoder_test() { + let input = "did:web:example.com" + let assert Ok(parsed) = did.from_string(input) + + assert parsed + |> did.to_json + |> json.to_string + == "\"" <> input <> "\"" + + let assert Ok(decoded) = json.parse("\"" <> input <> "\"", did.decoder()) + assert did.to_string(decoded) == input + + let assert Error(_) = json.parse("\"did:web:invalid\"", did.decoder()) +} -- 2.51.2