···2626 ```
27272828- `Atex.Config.IdentityResolver` has been renamed to `Atex.Config`.
2929-- `Atex.IdentityResolver.DIDDocument` has been renamed to `Atex.PLC.DIDDocument`.
2929+- `Atex.IdentityResolver.DIDDocument` has been renamed to `Atex.DID.Document`.
3030+- Replace existing `Atex.DID.Document.new/1` method with the method previously named `from_json/1`.
30313132### Added
32333434+- `Atex.Crypto` module for performing AT Protocol-related cryptographic operations.
3335- `Atex.PLC` module for interacting with [a did:plc directory API](https://web.plc.directory/).
3436- `Atex.ServiceAuth` module for validating [inter-service authentication tokens](https://atproto.com/specs/xrpc#inter-service-authentication-jwt).
3737+- Various improvements to `Atex.Did.Document`
3838+ - Add `Atex.DID.Document.Service` and `Atex.DID.Document.VerificationMethod` sub-structs.
3939+ - Add `to_json/1` methods and `JSON.Encoder` protocols for easy conversion to camelCase JSON.
35403641### Fixed
37423843- Fix a problem where generated `%<LexiconId>.Params` structs could not be
3944 passed to an XRPC call due to not having the Enumerable protocol implemented.
4040-- Add `Atex.Crypto` module for performing AT Protocol-related cryptographic operations.
41454246## [0.7.1] - 2026-02-06
4347
···99 config :atex,
1010 plc_directory_url: "https://plc.directory"
11111212- - `:plc_directory_url` — Base URL for the did:plc directory server.
1212+ - `:plc_directory_url` - Base URL for the did:plc directory server.
1313 Defaults to `"https://plc.directory"`.
1414 """
1515
+56-1
lib/atex/crypto.ex
···98989999 ## Options
100100101101- - `:as_did_key` — when `true`, prepends the `did:key:` URI scheme to the
101101+ - `:as_did_key` - when `true`, prepends the `did:key:` URI scheme to the
102102 returned string. Defaults to `false`.
103103104104 ## Examples
···201201 _ -> {:error, :sign_failed}
202202 end
203203204204+ @doc """
205205+ Decodes a legacy (pre-`Multikey`) atproto verification method public key into a `JOSE.JWK`.
206206+207207+ Legacy `verificationMethod` entries encode the public key as an **uncompressed** EC point
208208+ (65 bytes: `0x04 || x || y`) in base58btc multibase, without any multicodec prefix. The
209209+ curve is identified by the `type` field of the verification method rather than a multicodec
210210+ byte.
211211+212212+ Accepted `type` values:
213213+214214+ - `"EcdsaSecp256r1VerificationKey2019"` - P-256 / secp256r1
215215+ - `"EcdsaSecp256k1VerificationKey2019"` - secp256k1
216216+217217+ ## Examples
218218+219219+ iex> {:ok, jwk} = Atex.Crypto.decode_legacy_multibase(
220220+ ...> "EcdsaSecp256k1VerificationKey2019",
221221+ ...> "zQYEBzXeuTM9UR3rfvNag6L3RNAs5pQZyYPsomTsgQhsxLdEgCrPTLgFna8yqCnxPpNT7DBk6Ym3dgPKNu86vt9GR"
222222+ ...> )
223223+ iex> match?(%JOSE.JWK{}, jwk)
224224+ true
225225+226226+ iex> Atex.Crypto.decode_legacy_multibase("UnknownType", "zQYEBzXeuTM")
227227+ {:error, :unsupported_curve}
228228+ """
229229+ @spec decode_legacy_multibase(type :: String.t(), multibase :: String.t()) ::
230230+ {:ok, JOSE.JWK.t()} | {:error, term()}
231231+ def decode_legacy_multibase(type, multibase) when is_binary(type) and is_binary(multibase) do
232232+ with {:ok, crv} <- legacy_crv_for_type(type),
233233+ {:ok, raw} <- multibase_decode(multibase),
234234+ {:ok, x_bytes, y_bytes} <- split_uncompressed_point(raw) do
235235+ jwk =
236236+ JOSE.JWK.from_map(%{
237237+ "kty" => "EC",
238238+ "crv" => crv,
239239+ "x" => Base.url_encode64(x_bytes, padding: false),
240240+ "y" => Base.url_encode64(y_bytes, padding: false)
241241+ })
242242+243243+ {:ok, jwk}
244244+ end
245245+ end
246246+204247 def generate_p256() do
205248 JOSE.JWK.generate_key({:ec, "P-256"})
206249 end
···210253 end
211254212255 # Private helpers
256256+257257+ @spec legacy_crv_for_type(String.t()) :: {:ok, String.t()} | {:error, :unsupported_curve}
258258+ defp legacy_crv_for_type("EcdsaSecp256r1VerificationKey2019"), do: {:ok, "P-256"}
259259+ defp legacy_crv_for_type("EcdsaSecp256k1VerificationKey2019"), do: {:ok, "secp256k1"}
260260+ defp legacy_crv_for_type(_), do: {:error, :unsupported_curve}
261261+262262+ @spec split_uncompressed_point(binary()) ::
263263+ {:ok, binary(), binary()} | {:error, :invalid_point}
264264+ defp split_uncompressed_point(<<0x04, x::binary-size(32), y::binary-size(32)>>),
265265+ do: {:ok, x, y}
266266+267267+ defp split_uncompressed_point(_), do: {:error, :invalid_point}
213268214269 @spec strip_did_key_prefix(String.t()) :: String.t()
215270 defp strip_did_key_prefix("did:key:" <> rest), do: rest
+321
lib/atex/did/document.ex
···11+defmodule Atex.DID.Document do
22+ @moduledoc """
33+ Struct and schema for a [DID document](https://github.com/w3c/did-wg/blob/main/did-explainer.md#did-documents).
44+55+ Covers the subset of DID document fields used by AT Protocol, including support for
66+ parsing the `verificationMethod` and `service` arrays into typed sub-structs.
77+88+ ## Sub-structs
99+1010+ - `Atex.DID.Document.VerificationMethod` - typed representation of a public key entry,
1111+ with normalised `JOSE.JWK` storage regardless of wire encoding.
1212+ - `Atex.DID.Document.Service` - typed representation of a service endpoint entry.
1313+1414+ ## Parsing
1515+1616+ Use `new/1` to parse a raw map (as returned by a DID resolution response).
1717+ The function accepts camelCase keys as returned by the wire protocol, validates the
1818+ document structure via Peri, and converts public keys into `JOSE.JWK` structs.
1919+2020+ ## Serialisation
2121+2222+ Use `to_json/1` to produce a camelCase map suitable for JSON encoding. Public keys are
2323+ always emitted in the canonical `Multikey` / `publicKeyMultibase` format, regardless of
2424+ the format used when the document was originally parsed.
2525+2626+ ## ATProto-specific helpers
2727+2828+ - `validate_for_atproto/2` - checks the document meets minimum atproto requirements.
2929+ - `get_atproto_handle/1` - extracts the claimed AT Protocol handle.
3030+ - `get_pds_endpoint/1` - extracts the PDS service endpoint URL.
3131+ - `get_atproto_signing_key/1` - extracts the atproto signing key as a `JOSE.JWK`.
3232+ """
3333+ import Peri
3434+ use TypedStruct
3535+3636+ alias Atex.DID.Document.{Service, VerificationMethod}
3737+3838+ defschema :schema, %{
3939+ "@context": {:required, {:list, Atex.Peri.uri()}},
4040+ id: {:required, :string},
4141+ controller: {:either, {Atex.Peri.did(), {:list, Atex.Peri.did()}}},
4242+ also_known_as: {:list, Atex.Peri.uri()},
4343+ verification_method: {:list, :map},
4444+ authentication: {:list, {:either, {Atex.Peri.uri(), :map}}},
4545+ service: {:list, :map}
4646+ }
4747+4848+ typedstruct do
4949+ field :"@context", list(String.t()), enforce: true
5050+ field :id, String.t(), enforce: true
5151+ field :controller, String.t() | list(String.t())
5252+ field :also_known_as, list(String.t())
5353+ field :verification_method, list(VerificationMethod.t())
5454+ field :authentication, list(String.t() | VerificationMethod.t())
5555+ field :service, list(Service.t())
5656+ end
5757+5858+ @doc """
5959+ Parses and validates a raw DID document map into a typed `t()` struct.
6060+6161+ Accepts the camelCase wire format as returned by DID resolution endpoints.
6262+ `verificationMethod` and `service` entries are parsed into their respective sub-structs.
6363+ Public keys are normalised to `JOSE.JWK` regardless of the wire encoding used.
6464+6565+ Returns `{:ok, t()}` on success, or `{:error, Peri.Error.t()}` on validation failure.
6666+6767+ ## Examples
6868+6969+ iex> Atex.DID.Document.new(%{
7070+ ...> "@context" => ["https://www.w3.org/ns/did/v1"],
7171+ ...> "id" => "did:plc:abc123",
7272+ ...> "verificationMethod" => [],
7373+ ...> "service" => []
7474+ ...> })
7575+ {:ok, %Atex.DID.Document{id: "did:plc:abc123", ...}}
7676+ """
7777+ @spec new(map()) :: {:ok, t()} | {:error, Peri.Error.t()}
7878+ def new(%{} = map) do
7979+ map
8080+ |> Recase.Enumerable.convert_keys(&Recase.to_snake/1)
8181+ |> schema()
8282+ |> case do
8383+ {:ok, params} ->
8484+ verification_methods =
8585+ params
8686+ |> Map.get(:verification_method, [])
8787+ |> Enum.map(&parse_verification_method/1)
8888+ |> Enum.reject(&is_nil/1)
8989+9090+ services =
9191+ params
9292+ |> Map.get(:service, [])
9393+ |> Enum.map(&parse_service/1)
9494+ |> Enum.reject(&is_nil/1)
9595+9696+ authentication =
9797+ params
9898+ |> Map.get(:authentication, [])
9999+ |> Enum.map(&parse_authentication_entry/1)
100100+ |> Enum.reject(&is_nil/1)
101101+102102+ doc =
103103+ struct(
104104+ __MODULE__,
105105+ params
106106+ |> Map.put(:verification_method, verification_methods)
107107+ |> Map.put(:service, services)
108108+ |> Map.put(:authentication, authentication)
109109+ )
110110+111111+ {:ok, doc}
112112+113113+ e ->
114114+ e
115115+ end
116116+ end
117117+118118+ @doc """
119119+ Serialises a `t()` struct to a camelCase map suitable for JSON encoding.
120120+121121+ Public keys in `verificationMethod` are always emitted in the canonical `Multikey`
122122+ format with `publicKeyMultibase`.
123123+124124+ ## Examples
125125+126126+ iex> {:ok, doc} = Atex.DID.Document.new(%{
127127+ ...> "@context" => ["https://www.w3.org/ns/did/v1"],
128128+ ...> "id" => "did:plc:abc123"
129129+ ...> })
130130+ iex> json = Atex.DID.Document.to_json(doc)
131131+ iex> json["id"]
132132+ "did:plc:abc123"
133133+ """
134134+ @spec to_json(t()) :: map()
135135+ def to_json(%__MODULE__{} = doc) do
136136+ base = %{
137137+ "@context" => Map.get(doc, :"@context"),
138138+ "id" => doc.id
139139+ }
140140+141141+ base
142142+ |> maybe_put("controller", doc.controller)
143143+ |> maybe_put("alsoKnownAs", doc.also_known_as)
144144+ |> maybe_put(
145145+ "verificationMethod",
146146+ doc.verification_method && Enum.map(doc.verification_method, &VerificationMethod.to_json/1)
147147+ )
148148+ |> maybe_put(
149149+ "authentication",
150150+ doc.authentication && Enum.map(doc.authentication, &serialise_authentication_entry/1)
151151+ )
152152+ |> maybe_put(
153153+ "service",
154154+ doc.service && Enum.map(doc.service, &Service.to_json/1)
155155+ )
156156+ end
157157+158158+ @doc """
159159+ Validates that a DID document meets the minimum requirements for AT Protocol.
160160+161161+ Checks:
162162+163163+ - The document `id` matches the expected DID.
164164+ - A valid atproto signing key exists (`verificationMethod` entry with id ending `#atproto`
165165+ and `controller` matching the DID).
166166+ - A valid PDS service entry exists (`service` entry with id ending `#atproto_pds`, type
167167+ `"AtprotoPersonalDataServer"`, and a valid HTTPS or HTTP endpoint URL).
168168+169169+ Returns `:ok` or one of `{:error, :id_mismatch}`, `{:error, :no_signing_key}`,
170170+ `{:error, :invalid_pds}`.
171171+ """
172172+ @spec validate_for_atproto(t(), String.t()) ::
173173+ :ok | {:error, :id_mismatch | :no_signing_key | :invalid_pds}
174174+ def validate_for_atproto(%__MODULE__{} = doc, did) do
175175+ id_matches = doc.id == did
176176+177177+ valid_signing_key =
178178+ Enum.any?(doc.verification_method || [], fn method ->
179179+ String.ends_with?(method.id, "#atproto") and method.controller == did
180180+ end)
181181+182182+ valid_pds_service =
183183+ Enum.any?(doc.service || [], fn service ->
184184+ String.ends_with?(service.id, "#atproto_pds") and
185185+ service.type == "AtprotoPersonalDataServer" and
186186+ valid_pds_endpoint?(service.service_endpoint)
187187+ end)
188188+189189+ case {id_matches, valid_signing_key, valid_pds_service} do
190190+ {true, true, true} -> :ok
191191+ {false, _, _} -> {:error, :id_mismatch}
192192+ {_, false, _} -> {:error, :no_signing_key}
193193+ {_, _, false} -> {:error, :invalid_pds}
194194+ end
195195+ end
196196+197197+ @doc """
198198+ Returns the AT Protocol handle claimed by this DID document, or `nil` if none is present.
199199+200200+ The handle is found in the `alsoKnownAs` array as a URI with the `at://` scheme followed
201201+ by the handle hostname. Per the atproto specification, only the first syntactically valid
202202+ handle in the list is returned.
203203+204204+ > #### Note {: .info}
205205+ >
206206+ > A handle returned here is only a claim. To confirm it, validate bidirectionally by
207207+ > resolving the handle to a DID and checking it matches. See
208208+ > `Atex.IdentityResolver.Handle.resolve/2`.
209209+ """
210210+ @spec get_atproto_handle(t()) :: String.t() | nil
211211+ def get_atproto_handle(%__MODULE__{also_known_as: nil}), do: nil
212212+213213+ def get_atproto_handle(%__MODULE__{} = doc) do
214214+ Enum.find_value(doc.also_known_as, fn
215215+ "at://" <> handle -> handle
216216+ _ -> nil
217217+ end)
218218+ end
219219+220220+ @doc """
221221+ Returns the PDS service endpoint URL from the DID document, or `nil` if not found.
222222+223223+ Looks for a `service` entry with id ending `#atproto_pds` and type
224224+ `"AtprotoPersonalDataServer"`.
225225+ """
226226+ @spec get_pds_endpoint(t()) :: String.t() | nil
227227+ def get_pds_endpoint(%__MODULE__{} = doc) do
228228+ (doc.service || [])
229229+ |> Enum.find(fn
230230+ %Service{id: id, type: "AtprotoPersonalDataServer"} ->
231231+ String.ends_with?(id, "#atproto_pds")
232232+233233+ _ ->
234234+ false
235235+ end)
236236+ |> case do
237237+ nil -> nil
238238+ pds -> pds.service_endpoint
239239+ end
240240+ end
241241+242242+ @doc """
243243+ Returns the atproto signing key from the DID document as a `JOSE.JWK`, or `nil`.
244244+245245+ Finds the first `verificationMethod` entry whose id ends with `#atproto`. The public key
246246+ is returned as a `JOSE.JWK` struct directly, since key decoding (including legacy formats)
247247+ is performed at parse time in `new/1`.
248248+ """
249249+ @spec get_atproto_signing_key(t()) :: JOSE.JWK.t() | nil
250250+ def get_atproto_signing_key(%__MODULE__{} = doc) do
251251+ (doc.verification_method || [])
252252+ |> Enum.find(fn %VerificationMethod{id: id} -> String.ends_with?(id, "#atproto") end)
253253+ |> case do
254254+ nil -> nil
255255+ method -> method.public_key_jwk
256256+ end
257257+ end
258258+259259+ # Parse a raw verification method map, returning nil on failure.
260260+ @spec parse_verification_method(map()) :: VerificationMethod.t() | nil
261261+ defp parse_verification_method(raw) do
262262+ case VerificationMethod.new(raw) do
263263+ {:ok, vm} -> vm
264264+ _ -> nil
265265+ end
266266+ end
267267+268268+ # Parse a raw service map, returning nil on failure.
269269+ @spec parse_service(map()) :: Service.t() | nil
270270+ defp parse_service(raw) do
271271+ case Service.new(raw) do
272272+ {:ok, svc} -> svc
273273+ _ -> nil
274274+ end
275275+ end
276276+277277+ # Authentication entries can be either a URI string or a verification method map.
278278+ @spec parse_authentication_entry(String.t() | map()) ::
279279+ String.t() | VerificationMethod.t() | nil
280280+ defp parse_authentication_entry(entry) when is_binary(entry), do: entry
281281+282282+ defp parse_authentication_entry(entry) when is_map(entry) do
283283+ parse_verification_method(entry)
284284+ end
285285+286286+ defp parse_authentication_entry(_), do: nil
287287+288288+ @spec serialise_authentication_entry(String.t() | VerificationMethod.t()) :: String.t() | map()
289289+ defp serialise_authentication_entry(entry) when is_binary(entry), do: entry
290290+291291+ defp serialise_authentication_entry(%VerificationMethod{} = vm),
292292+ do: VerificationMethod.to_json(vm)
293293+294294+ @spec maybe_put(map(), String.t(), any()) :: map()
295295+ defp maybe_put(map, _key, nil), do: map
296296+ defp maybe_put(map, _key, []), do: map
297297+ defp maybe_put(map, key, value), do: Map.put(map, key, value)
298298+299299+ @spec valid_pds_endpoint?(String.t()) :: boolean()
300300+ defp valid_pds_endpoint?(endpoint) do
301301+ case URI.new(endpoint) do
302302+ {:ok, uri} ->
303303+ is_plain_uri =
304304+ uri
305305+ |> Map.from_struct()
306306+ |> Enum.all?(fn
307307+ {key, value} when key in [:userinfo, :path, :query, :fragment] -> is_nil(value)
308308+ _ -> true
309309+ end)
310310+311311+ uri.scheme in ["https", "http"] and is_plain_uri
312312+313313+ _ ->
314314+ false
315315+ end
316316+ end
317317+end
318318+319319+defimpl JSON.Encoder, for: Atex.DID.Document do
320320+ def encode(value, encoder), do: JSON.encode!(Atex.DID.Document.to_json(value), encoder)
321321+end
+102
lib/atex/did/document/service.ex
···11+defmodule Atex.DID.Document.Service do
22+ @moduledoc """
33+ Struct and schema for a `service` entry in a DID document.
44+55+ Each service entry describes a network endpoint associated with the DID subject.
66+ In atproto, the most relevant service is the PDS (Personal Data Server), identified
77+ by the `#atproto_pds` fragment and type `"AtprotoPersonalDataServer"`.
88+99+ ## Fields
1010+1111+ - `:id` - URI identifying the service, typically a DID fragment (e.g. `"#atproto_pds"`
1212+ or the fully-qualified form `"did:plc:abc123#atproto_pds"`).
1313+ - `:type` - Service type string or list of type strings.
1414+ - `:service_endpoint` - The endpoint URI, a map of URIs, or a list of either.
1515+ """
1616+ import Peri
1717+ use TypedStruct
1818+1919+ @typedoc "A service endpoint: a URI string, a map of URI strings, or a list of either."
2020+ @type endpoint() ::
2121+ String.t()
2222+ | %{String.t() => String.t()}
2323+ | list(String.t() | %{String.t() => String.t()})
2424+2525+ defschema :schema, %{
2626+ id: {:required, Atex.Peri.uri()},
2727+ type: {:required, {:either, {:string, {:list, :string}}}},
2828+ service_endpoint:
2929+ {:required,
3030+ {:oneof,
3131+ [
3232+ Atex.Peri.uri(),
3333+ {:map, Atex.Peri.uri()},
3434+ {:list, {:either, {Atex.Peri.uri(), {:map, Atex.Peri.uri()}}}}
3535+ ]}}
3636+ }
3737+3838+ typedstruct do
3939+ field :id, String.t(), enforce: true
4040+ field :type, String.t() | list(String.t()), enforce: true
4141+ field :service_endpoint, endpoint(), enforce: true
4242+ end
4343+4444+ @doc """
4545+ Validates and builds a `Service` struct from a map (snake_case or camelCase keys).
4646+4747+ Returns `{:ok, t()}` on success, or `{:error, Peri.Error.t()}` on validation failure.
4848+4949+ ## Examples
5050+5151+ iex> Atex.DID.Document.Service.new(%{
5252+ ...> "id" => "#atproto_pds",
5353+ ...> "type" => "AtprotoPersonalDataServer",
5454+ ...> "serviceEndpoint" => "https://pds.example.com"
5555+ ...> })
5656+ {:ok, %Atex.DID.Document.Service{
5757+ id: "#atproto_pds",
5858+ type: "AtprotoPersonalDataServer",
5959+ service_endpoint: "https://pds.example.com"
6060+ }}
6161+ """
6262+ @spec new(map()) :: {:ok, t()} | {:error, Peri.Error.t()}
6363+ def new(%{} = map) do
6464+ map
6565+ |> Recase.Enumerable.convert_keys(&Recase.to_snake/1)
6666+ |> schema()
6767+ |> case do
6868+ {:ok, params} -> {:ok, struct(__MODULE__, params)}
6969+ e -> e
7070+ end
7171+ end
7272+7373+ @doc """
7474+ Converts a `Service` struct to a camelCase map suitable for JSON serialisation.
7575+7676+ ## Examples
7777+7878+ iex> svc = %Atex.DID.Document.Service{
7979+ ...> id: "#atproto_pds",
8080+ ...> type: "AtprotoPersonalDataServer",
8181+ ...> service_endpoint: "https://pds.example.com"
8282+ ...> }
8383+ iex> Atex.DID.Document.Service.to_json(svc)
8484+ %{
8585+ "id" => "#atproto_pds",
8686+ "type" => "AtprotoPersonalDataServer",
8787+ "serviceEndpoint" => "https://pds.example.com"
8888+ }
8989+ """
9090+ @spec to_json(t()) :: map()
9191+ def to_json(%__MODULE__{} = service) do
9292+ %{
9393+ "id" => service.id,
9494+ "type" => service.type,
9595+ "serviceEndpoint" => service.service_endpoint
9696+ }
9797+ end
9898+end
9999+100100+defimpl JSON.Encoder, for: Atex.DID.Document.Service do
101101+ def encode(value, encoder), do: JSON.encode!(Atex.DID.Document.Service.to_json(value), encoder)
102102+end
+152
lib/atex/did/document/verification_method.ex
···11+defmodule Atex.DID.Document.VerificationMethod do
22+ @moduledoc """
33+ Struct and schema for a `verificationMethod` entry in a DID document.
44+55+ Internally, public keys are always stored as `JOSE.JWK` structs regardless of the
66+ wire encoding. Both the current `Multikey` format and the legacy
77+ `EcdsaSecp256r1VerificationKey2019` / `EcdsaSecp256k1VerificationKey2019` formats
88+ are accepted during parsing.
99+1010+ ## Wire formats
1111+1212+ **Current (`Multikey`)**
1313+1414+ ```json
1515+ {
1616+ "id": "did:plc:abc123#atproto",
1717+ "type": "Multikey",
1818+ "controller": "did:plc:abc123",
1919+ "publicKeyMultibase": "zDnaembgSGUhZULN2Caob4HLJPaxBh92N7rtH21TErzqf8HQo"
2020+ }
2121+ ```
2222+2323+ **Legacy (uncompressed multibase, curve identified by `type`)**
2424+2525+ ```json
2626+ {
2727+ "id": "#atproto",
2828+ "type": "EcdsaSecp256k1VerificationKey2019",
2929+ "controller": "did:plc:abc123",
3030+ "publicKeyMultibase": "zQYEBzXeuTM9UR3rfvNag6L3RNAs5pQZyYPsomTsgQhsxLdEgCrPTLgFna8yqCnxPpNT7DBk6Ym3dgPKNu86vt9GR"
3131+ }
3232+ ```
3333+3434+ ## Fields
3535+3636+ - `:id` - URI identifying the key, typically a DID fragment (e.g. `"#atproto"`).
3737+ - `:type` - Key type string (e.g. `"Multikey"`).
3838+ - `:controller` - DID of the entity that controls this key.
3939+ - `:public_key_jwk` - The public key as a `JOSE.JWK` struct, or `nil` if the wire
4040+ format could not be decoded.
4141+ """
4242+ import Peri
4343+ use TypedStruct
4444+4545+ @legacy_types ~w(EcdsaSecp256r1VerificationKey2019 EcdsaSecp256k1VerificationKey2019)
4646+4747+ defschema :schema, %{
4848+ id: {:required, Atex.Peri.uri()},
4949+ type: {:required, :string},
5050+ controller: {:required, Atex.Peri.did()},
5151+ public_key_multibase: :string
5252+ }
5353+5454+ typedstruct do
5555+ field :id, String.t(), enforce: true
5656+ field :type, String.t(), enforce: true
5757+ field :controller, String.t(), enforce: true
5858+ field :public_key_jwk, JOSE.JWK.t() | nil
5959+ end
6060+6161+ @doc """
6262+ Validates and builds a `VerificationMethod` struct from a raw map.
6363+6464+ Accepts camelCase or snake_case keys. The public key in `publicKeyMultibase` - whether
6565+ in the current `Multikey` format or the legacy uncompressed format - is decoded and stored
6666+ as `public_key_jwk`.
6767+6868+ Returns `{:ok, t()}` on success, or `{:error, term()}` on validation or decode failure.
6969+7070+ ## Examples
7171+7272+ iex> Atex.DID.Document.VerificationMethod.new(%{
7373+ ...> "id" => "did:plc:abc123#atproto",
7474+ ...> "type" => "Multikey",
7575+ ...> "controller" => "did:plc:abc123",
7676+ ...> "publicKeyMultibase" => "zDnaembgSGUhZULN2Caob4HLJPaxBh92N7rtH21TErzqf8HQo"
7777+ ...> })
7878+ {:ok, %Atex.DID.Document.VerificationMethod{
7979+ id: "did:plc:abc123#atproto",
8080+ type: "Multikey",
8181+ controller: "did:plc:abc123",
8282+ public_key_jwk: %JOSE.JWK{}
8383+ }}
8484+ """
8585+ @spec new(map()) :: {:ok, t()} | {:error, term()}
8686+ def new(%{} = map) do
8787+ snake = Recase.Enumerable.convert_keys(map, &Recase.to_snake/1)
8888+8989+ with {:ok, params} <- schema(snake) do
9090+ jwk = resolve_public_key(params)
9191+ {:ok, struct(__MODULE__, Map.put(params, :public_key_jwk, jwk))}
9292+ end
9393+ end
9494+9595+ @doc """
9696+ Converts a `VerificationMethod` struct to a camelCase map for JSON serialisation.
9797+9898+ The public key is always emitted in the canonical `Multikey` / `publicKeyMultibase`
9999+ format. If no public key is present, `"publicKeyMultibase"` is omitted.
100100+101101+ ## Examples
102102+103103+ iex> {:ok, vm} = Atex.DID.Document.VerificationMethod.new(%{
104104+ ...> "id" => "did:plc:abc123#atproto",
105105+ ...> "type" => "Multikey",
106106+ ...> "controller" => "did:plc:abc123",
107107+ ...> "publicKeyMultibase" => "zDnaembgSGUhZULN2Caob4HLJPaxBh92N7rtH21TErzqf8HQo"
108108+ ...> })
109109+ iex> json = Atex.DID.Document.VerificationMethod.to_json(vm)
110110+ iex> json["type"]
111111+ "Multikey"
112112+ iex> is_binary(json["publicKeyMultibase"])
113113+ true
114114+ """
115115+ @spec to_json(t()) :: map()
116116+ def to_json(%__MODULE__{} = vm) do
117117+ base = %{
118118+ "id" => vm.id,
119119+ "type" => "Multikey",
120120+ "controller" => vm.controller
121121+ }
122122+123123+ case vm.public_key_jwk && Atex.Crypto.encode_did_key(vm.public_key_jwk) do
124124+ {:ok, multibase} -> Map.put(base, "publicKeyMultibase", multibase)
125125+ _ -> base
126126+ end
127127+ end
128128+129129+ # Resolve the public key from validated (snake_case) params to a JOSE.JWK or nil.
130130+ @spec resolve_public_key(map()) :: JOSE.JWK.t() | nil
131131+ defp resolve_public_key(%{type: type, public_key_multibase: multibase})
132132+ when type in @legacy_types and is_binary(multibase) do
133133+ case Atex.Crypto.decode_legacy_multibase(type, multibase) do
134134+ {:ok, jwk} -> jwk
135135+ _ -> nil
136136+ end
137137+ end
138138+139139+ defp resolve_public_key(%{public_key_multibase: multibase}) when is_binary(multibase) do
140140+ case Atex.Crypto.decode_did_key(multibase) do
141141+ {:ok, jwk} -> jwk
142142+ _ -> nil
143143+ end
144144+ end
145145+146146+ defp resolve_public_key(_), do: nil
147147+end
148148+149149+defimpl JSON.Encoder, for: Atex.DID.Document.VerificationMethod do
150150+ def encode(value, encoder),
151151+ do: JSON.encode!(Atex.DID.Document.VerificationMethod.to_json(value), encoder)
152152+end
+1-1
lib/atex/identity_resolver.ex
···11defmodule Atex.IdentityResolver do
22 alias Atex.IdentityResolver.{Cache, DID, Handle, Identity}
33- alias Atex.PLC.DIDDocument
33+ alias Atex.DID.Document, as: DIDDocument
4455 @handle_strategy Application.compile_env(:atex, :handle_resolver_strategy, :dns_first)
66 @type options() :: {:skip_cache, boolean()}
+5-5
lib/atex/identity_resolver/did.ex
···11defmodule Atex.IdentityResolver.DID do
22- alias Atex.PLC
22+ alias Atex.{DID, PLC}
3344 @type resolution_result() ::
55- {:ok, PLC.DIDDocument.t()}
55+ {:ok, DID.Document.t()}
66 | {:error, :invalid_did_type | :invalid_did | :not_found | map() | atom() | any()}
7788 @spec resolve(String.t()) :: resolution_result()
···1414 @spec resolve_plc(String.t()) :: resolution_result()
1515 defp resolve_plc("did:plc:" <> _id = did) do
1616 with {:ok, document} <- PLC.resolve_did(did),
1717- :ok <- PLC.DIDDocument.validate_for_atproto(document, did) do
1717+ :ok <- DID.Document.validate_for_atproto(document, did) do
1818 {:ok, document}
1919 end
2020 end
···2424 with {:ok, resp} when resp.status in 200..299 <-
2525 Req.get("https://#{domain}/.well-known/did.json"),
2626 {:ok, body} <- decode_body(resp.body),
2727- {:ok, document} <- PLC.DIDDocument.from_json(body),
2828- :ok <- PLC.DIDDocument.validate_for_atproto(document, did) do
2727+ {:ok, document} <- DID.Document.new(body),
2828+ :ok <- DID.Document.validate_for_atproto(document, did) do
2929 {:ok, document}
3030 else
3131 {:ok, %{status: 404}} -> {:error, :not_found}
+1-1
lib/atex/identity_resolver/identity.ex
···1212 @typedoc """
1313 The resolved DID document for an identity.
1414 """
1515- @type document() :: Atex.PLC.DIDDocument.t()
1515+ @type document() :: Atex.DID.Document.t()
16161717 typedstruct do
1818 field :did, did(), enforce: true
+3-3
lib/atex/oauth/plug.ex
···9393 require Logger
9494 use Plug.Router
9595 require Plug.Router
9696- alias Atex.{IdentityResolver, OAuth, PLC.DIDDocument}
9696+ alias Atex.{DID, IdentityResolver, OAuth}
97979898 @oauth_cookie_opts [path: "/", http_only: true, secure: true, same_site: "lax", max_age: 600]
9999 @session_name :atex_session
···129129130130 case IdentityResolver.resolve(handle) do
131131 {:ok, identity} ->
132132- pds = DIDDocument.get_pds_endpoint(identity.document)
132132+ pds = DID.Document.get_pds_endpoint(identity.document)
133133 {:ok, authz_server} = OAuth.get_authorization_server(pds)
134134 {:ok, authz_metadata} = OAuth.get_authorization_server_metadata(authz_server)
135135 state = OAuth.create_nonce()
···195195 ),
196196 {:ok, identity} <- IdentityResolver.resolve(tokens.did),
197197 # Make sure pds' issuer matches the stored one (just in case)
198198- pds <- DIDDocument.get_pds_endpoint(identity.document),
198198+ pds <- DID.Document.get_pds_endpoint(identity.document),
199199 {:ok, authz_server} <- OAuth.get_authorization_server(pds),
200200 true <- authz_server == stored_issuer do
201201 session = %OAuth.Session{
+6-6
lib/atex/plc.ex
···2525 - `:not_found` - The DID is not registered (HTTP 404).
2626 - `:tombstoned` - The DID has been permanently deactivated (HTTP 410).
2727 - `:invalid_document` - The server returned a body that could not be parsed
2828- into a `DIDDocument`.
2828+ into an `Atex.DID.Document`.
2929 - `{:invalid_operation, message}` - The submitted operation was rejected by
3030 the server, with an explanatory message (HTTP 400).
3131 - `:invalid_operation` - The submitted operation was rejected without a
···3939 @type create_op_error() ::
4040 {:error, {:invalid_operation, message :: String.t()} | :invalid_operation} | error()
41414242- alias Atex.PLC.DIDDocument
4242+ alias Atex.DID
43434444 @doc """
4545 Resolves the DID Document for the given `did:plc` identifier.
46464747 Fetches the current DID Document from the directory server and parses it into
4848- an `Atex.PLC.DIDDocument` struct.
4848+ an `Atex.DID.Document` struct.
49495050 ## Parameters
5151···5555 ## Examples
56565757 iex> Atex.PLC.resolve_did("did:plc:ewvi7nxzyoun6zhxrhs64oiz")
5858- {:ok, %Atex.PLC.DIDDocument{...}}
5858+ {:ok, %Atex.DID.Document{...}}
59596060 iex> Atex.PLC.resolve_did("did:plc:doesnotexist")
6161 {:error, :not_found}
6262 """
6363- @spec resolve_did(String.t(), keyword()) :: {:ok, DIDDocument.t()} | error()
6363+ @spec resolve_did(String.t(), keyword()) :: {:ok, DID.Document.t()} | error()
6464 def resolve_did(did, opts \\ []) do
6565 opts
6666 |> host()
···6969 |> handle_response()
7070 |> case do
7171 {:ok, body} ->
7272- case DIDDocument.from_json(body) do
7272+ case DID.Document.new(body) do
7373 {:ok, document} -> {:ok, document}
7474 {:error, _reason} -> {:error, :invalid_document}
7575 end
-174
lib/atex/plc/did_document.ex
···11-defmodule Atex.PLC.DIDDocument do
22- @moduledoc """
33- Struct and schema for describing and validating a [DID document](https://github.com/w3c/did-wg/blob/main/did-explainer.md#did-documents).
44- """
55- import Peri
66- use TypedStruct
77-88- defschema :schema, %{
99- "@context": {:required, {:list, Atex.Peri.uri()}},
1010- id: {:required, :string},
1111- controller: {:either, {Atex.Peri.did(), {:list, Atex.Peri.did()}}},
1212- also_known_as: {:list, Atex.Peri.uri()},
1313- verification_method: {:list, get_schema(:verification_method)},
1414- authentication: {:list, {:either, {Atex.Peri.uri(), get_schema(:verification_method)}}},
1515- service: {:list, get_schema(:service)}
1616- }
1717-1818- defschema :verification_method, %{
1919- id: {:required, Atex.Peri.uri()},
2020- type: {:required, :string},
2121- controller: {:required, Atex.Peri.did()},
2222- public_key_multibase: :string,
2323- public_key_jwk: :map
2424- }
2525-2626- defschema :service, %{
2727- id: {:required, Atex.Peri.uri()},
2828- type: {:required, {:either, {:string, {:list, :string}}}},
2929- service_endpoint:
3030- {:required,
3131- {:oneof,
3232- [
3333- Atex.Peri.uri(),
3434- {:map, Atex.Peri.uri()},
3535- {:list, {:either, {Atex.Peri.uri(), {:map, Atex.Peri.uri()}}}}
3636- ]}}
3737- }
3838-3939- @type verification_method() :: %{
4040- required(:id) => String.t(),
4141- required(:type) => String.t(),
4242- required(:controller) => String.t(),
4343- optional(:public_key_multibase) => String.t(),
4444- optional(:public_key_jwk) => map()
4545- }
4646-4747- @type service() :: %{
4848- required(:id) => String.t(),
4949- required(:type) => String.t() | list(String.t()),
5050- required(:service_endpoint) =>
5151- String.t()
5252- | %{String.t() => String.t()}
5353- | list(String.t() | %{String.t() => String.t()})
5454- }
5555-5656- typedstruct do
5757- field :"@context", list(String.t()), enforce: true
5858- field :id, String.t(), enforce: true
5959- field :controller, String.t() | list(String.t())
6060- field :also_known_as, list(String.t())
6161- field :verification_method, list(verification_method())
6262- field :authentication, list(String.t() | verification_method())
6363- field :service, list(service())
6464- end
6565-6666- def new(params), do: struct(__MODULE__, params)
6767-6868- @spec from_json(map()) :: {:ok, t()} | {:error, Peri.Error.t()}
6969- def from_json(%{} = map) do
7070- map
7171- |> Recase.Enumerable.convert_keys(&Recase.to_snake/1)
7272- |> schema()
7373- |> case do
7474- {:ok, params} -> {:ok, new(params)}
7575- e -> e
7676- end
7777- end
7878-7979- @spec validate_for_atproto(t(), String.t()) :: any()
8080- def validate_for_atproto(%__MODULE__{} = doc, did) do
8181- id_matches = doc.id == did
8282-8383- valid_signing_key =
8484- Enum.any?(doc.verification_method, fn method ->
8585- String.ends_with?(method.id, "#atproto") and method.controller == did
8686- end)
8787-8888- valid_pds_service =
8989- Enum.any?(doc.service, fn service ->
9090- String.ends_with?(service.id, "#atproto_pds") and
9191- service.type == "AtprotoPersonalDataServer" and
9292- valid_pds_endpoint?(service.service_endpoint)
9393- end)
9494-9595- case {id_matches, valid_signing_key, valid_pds_service} do
9696- {true, true, true} -> :ok
9797- {false, _, _} -> {:error, :id_mismatch}
9898- {_, false, _} -> {:error, :no_signing_key}
9999- {_, _, false} -> {:error, :invalid_pds}
100100- end
101101- end
102102-103103- @doc """
104104- Get the associated ATProto handle in the DID document.
105105-106106- ATProto dictates that only the first valid handle is to be used, so this
107107- follows that rule.
108108-109109- > #### Note {: .info}
110110- >
111111- > While DID documents are fairly authoritative, you need to make sure to
112112- > validate the handle bidirectionally. See
113113- > `Atex.IdentityResolver.Handle.resolve/2`.
114114- """
115115- @spec get_atproto_handle(t()) :: String.t() | nil
116116- def get_atproto_handle(%__MODULE__{also_known_as: nil}), do: nil
117117-118118- def get_atproto_handle(%__MODULE__{} = doc) do
119119- Enum.find_value(doc.also_known_as, fn
120120- # TODO: make sure no path or other URI parts
121121- "at://" <> handle -> handle
122122- _ -> nil
123123- end)
124124- end
125125-126126- @spec get_pds_endpoint(t()) :: String.t() | nil
127127- def get_pds_endpoint(%__MODULE__{} = doc) do
128128- doc.service
129129- |> Enum.find(fn
130130- %{id: "#atproto_pds", type: "AtprotoPersonalDataServer"} -> true
131131- _ -> false
132132- end)
133133- |> case do
134134- nil -> nil
135135- pds -> pds.service_endpoint
136136- end
137137- end
138138-139139- @spec get_atproto_signing_key(t()) :: JOSE.JWK.t() | nil
140140- def get_atproto_signing_key(%__MODULE__{} = doc) do
141141- doc.verification_method
142142- |> Enum.find(fn
143143- %{id: id} -> String.ends_with?(id, "#atproto")
144144- end)
145145- |> case do
146146- nil ->
147147- nil
148148-149149- %{public_key_multibase: multibase} ->
150150- {:ok, jwk} = Atex.Crypto.decode_did_key(multibase)
151151- jwk
152152-153153- # TODO
154154- _ ->
155155- raise ArgumentError, message: "Legacy verification method keys are not yet supported"
156156- # %{public_key_jwk: jwk} -> nil
157157- end
158158- end
159159-160160- defp valid_pds_endpoint?(endpoint) do
161161- case URI.new(endpoint) do
162162- {:ok, uri} ->
163163- is_plain_uri =
164164- uri
165165- |> Map.from_struct()
166166- |> Enum.all?(fn
167167- {key, value} when key in [:userinfo, :path, :query, :fragment] -> is_nil(value)
168168- _ -> true
169169- end)
170170-171171- uri.scheme in ["https", "http"] and is_plain_uri
172172- end
173173- end
174174-end
+1-1
lib/atex/service_auth.ex
···123123 # the signing key from their DID document to verify the token
124124 {:ok, identity} <- Atex.IdentityResolver.resolve(issuing_did),
125125 user_jwk when not is_nil(user_jwk) <-
126126- Atex.PLC.DIDDocument.get_atproto_signing_key(identity.document),
126126+ Atex.DID.Document.get_atproto_signing_key(identity.document),
127127 {true, %JOSE.JWT{} = jwt_struct, _jws} <- JOSE.JWT.verify(user_jwk, jwt),
128128 # Record the nonce atomically after successful verification. insert_new
129129 # is used under the hood so this returns :seen if the jti was already