An Elixir toolkit for the AT Protocol. hexdocs.pm/atex
elixir bluesky atproto decentralization
25
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor: rename DIDDocument, split out service and verification methods, and add JSON.Encoder protocol

+1113 -211
+6 -2
CHANGELOG.md
··· 26 26 ``` 27 27 28 28 - `Atex.Config.IdentityResolver` has been renamed to `Atex.Config`. 29 - - `Atex.IdentityResolver.DIDDocument` has been renamed to `Atex.PLC.DIDDocument`. 29 + - `Atex.IdentityResolver.DIDDocument` has been renamed to `Atex.DID.Document`. 30 + - Replace existing `Atex.DID.Document.new/1` method with the method previously named `from_json/1`. 30 31 31 32 ### Added 32 33 34 + - `Atex.Crypto` module for performing AT Protocol-related cryptographic operations. 33 35 - `Atex.PLC` module for interacting with [a did:plc directory API](https://web.plc.directory/). 34 36 - `Atex.ServiceAuth` module for validating [inter-service authentication tokens](https://atproto.com/specs/xrpc#inter-service-authentication-jwt). 37 + - Various improvements to `Atex.Did.Document` 38 + - Add `Atex.DID.Document.Service` and `Atex.DID.Document.VerificationMethod` sub-structs. 39 + - Add `to_json/1` methods and `JSON.Encoder` protocols for easy conversion to camelCase JSON. 35 40 36 41 ### Fixed 37 42 38 43 - Fix a problem where generated `%<LexiconId>.Params` structs could not be 39 44 passed to an XRPC call due to not having the Enumerable protocol implemented. 40 - - Add `Atex.Crypto` module for performing AT Protocol-related cryptographic operations. 41 45 42 46 ## [0.7.1] - 2026-02-06 43 47
+16 -15
examples/service_auth.ex
··· 5 5 plug :match 6 6 plug :dispatch 7 7 8 - @did_doc JSON.encode!(%{ 9 - "@context" => [ 8 + @did_doc %Atex.DID.Document{ 9 + "@context": [ 10 10 "https://www.w3.org/ns/did/v1", 11 11 "https://w3id.org/security/multikey/v1" 12 12 ], 13 - "id" => "did:web:setsuna.prawn-galaxy.ts.net", 14 - "verificationMethod" => [ 15 - %{ 16 - "id" => "did:web:setsuna.prawn-galaxy.ts.net#atproto", 17 - "type" => "Multikey", 18 - "controller" => "did:web:setsuna.prawn-galaxy.ts.net", 19 - "publicKeyMultibase" => "zDnaeRBG9swcjKP6GjjQF7kqxP6JaJaVbvjTjJ1YbXnKWWLna" 13 + id: "did:web:setsuna.prawn-galaxy.ts.net", 14 + verification_method: [ 15 + %Atex.DID.Document.VerificationMethod{ 16 + id: "did:web:setsuna.prawn-galaxy.ts.net#atproto", 17 + type: "Multikey", 18 + controller: "did:web:setsuna.prawn-galaxy.ts.net", 19 + public_key_jwk: Atex.Config.OAuth.get_key() 20 20 } 21 21 ], 22 - "service" => [ 23 - %{ 24 - "id" => "atex_test", 25 - "type" => "AtexTest", 26 - "serviceEndpoint" => "https://setsuna.prawn-galaxy.ts.net" 22 + service: [ 23 + %Atex.DID.Document.Service{ 24 + id: "atex_test", 25 + type: "AtexTest", 26 + service_endpoint: "https://setsuna.prawn-galaxy.ts.net" 27 27 } 28 28 ] 29 - }) 29 + } 30 + |> JSON.encode!() 30 31 31 32 get "/.well-known/did.json" do 32 33 Logger.info("got did json")
+1 -1
lib/atex/config.ex
··· 9 9 config :atex, 10 10 plc_directory_url: "https://plc.directory" 11 11 12 - - `:plc_directory_url` — Base URL for the did:plc directory server. 12 + - `:plc_directory_url` - Base URL for the did:plc directory server. 13 13 Defaults to `"https://plc.directory"`. 14 14 """ 15 15
+56 -1
lib/atex/crypto.ex
··· 98 98 99 99 ## Options 100 100 101 - - `:as_did_key` — when `true`, prepends the `did:key:` URI scheme to the 101 + - `:as_did_key` - when `true`, prepends the `did:key:` URI scheme to the 102 102 returned string. Defaults to `false`. 103 103 104 104 ## Examples ··· 201 201 _ -> {:error, :sign_failed} 202 202 end 203 203 204 + @doc """ 205 + Decodes a legacy (pre-`Multikey`) atproto verification method public key into a `JOSE.JWK`. 206 + 207 + Legacy `verificationMethod` entries encode the public key as an **uncompressed** EC point 208 + (65 bytes: `0x04 || x || y`) in base58btc multibase, without any multicodec prefix. The 209 + curve is identified by the `type` field of the verification method rather than a multicodec 210 + byte. 211 + 212 + Accepted `type` values: 213 + 214 + - `"EcdsaSecp256r1VerificationKey2019"` - P-256 / secp256r1 215 + - `"EcdsaSecp256k1VerificationKey2019"` - secp256k1 216 + 217 + ## Examples 218 + 219 + iex> {:ok, jwk} = Atex.Crypto.decode_legacy_multibase( 220 + ...> "EcdsaSecp256k1VerificationKey2019", 221 + ...> "zQYEBzXeuTM9UR3rfvNag6L3RNAs5pQZyYPsomTsgQhsxLdEgCrPTLgFna8yqCnxPpNT7DBk6Ym3dgPKNu86vt9GR" 222 + ...> ) 223 + iex> match?(%JOSE.JWK{}, jwk) 224 + true 225 + 226 + iex> Atex.Crypto.decode_legacy_multibase("UnknownType", "zQYEBzXeuTM") 227 + {:error, :unsupported_curve} 228 + """ 229 + @spec decode_legacy_multibase(type :: String.t(), multibase :: String.t()) :: 230 + {:ok, JOSE.JWK.t()} | {:error, term()} 231 + def decode_legacy_multibase(type, multibase) when is_binary(type) and is_binary(multibase) do 232 + with {:ok, crv} <- legacy_crv_for_type(type), 233 + {:ok, raw} <- multibase_decode(multibase), 234 + {:ok, x_bytes, y_bytes} <- split_uncompressed_point(raw) do 235 + jwk = 236 + JOSE.JWK.from_map(%{ 237 + "kty" => "EC", 238 + "crv" => crv, 239 + "x" => Base.url_encode64(x_bytes, padding: false), 240 + "y" => Base.url_encode64(y_bytes, padding: false) 241 + }) 242 + 243 + {:ok, jwk} 244 + end 245 + end 246 + 204 247 def generate_p256() do 205 248 JOSE.JWK.generate_key({:ec, "P-256"}) 206 249 end ··· 210 253 end 211 254 212 255 # Private helpers 256 + 257 + @spec legacy_crv_for_type(String.t()) :: {:ok, String.t()} | {:error, :unsupported_curve} 258 + defp legacy_crv_for_type("EcdsaSecp256r1VerificationKey2019"), do: {:ok, "P-256"} 259 + defp legacy_crv_for_type("EcdsaSecp256k1VerificationKey2019"), do: {:ok, "secp256k1"} 260 + defp legacy_crv_for_type(_), do: {:error, :unsupported_curve} 261 + 262 + @spec split_uncompressed_point(binary()) :: 263 + {:ok, binary(), binary()} | {:error, :invalid_point} 264 + defp split_uncompressed_point(<<0x04, x::binary-size(32), y::binary-size(32)>>), 265 + do: {:ok, x, y} 266 + 267 + defp split_uncompressed_point(_), do: {:error, :invalid_point} 213 268 214 269 @spec strip_did_key_prefix(String.t()) :: String.t() 215 270 defp strip_did_key_prefix("did:key:" <> rest), do: rest
+321
lib/atex/did/document.ex
··· 1 + defmodule Atex.DID.Document do 2 + @moduledoc """ 3 + Struct and schema for a [DID document](https://github.com/w3c/did-wg/blob/main/did-explainer.md#did-documents). 4 + 5 + Covers the subset of DID document fields used by AT Protocol, including support for 6 + parsing the `verificationMethod` and `service` arrays into typed sub-structs. 7 + 8 + ## Sub-structs 9 + 10 + - `Atex.DID.Document.VerificationMethod` - typed representation of a public key entry, 11 + with normalised `JOSE.JWK` storage regardless of wire encoding. 12 + - `Atex.DID.Document.Service` - typed representation of a service endpoint entry. 13 + 14 + ## Parsing 15 + 16 + Use `new/1` to parse a raw map (as returned by a DID resolution response). 17 + The function accepts camelCase keys as returned by the wire protocol, validates the 18 + document structure via Peri, and converts public keys into `JOSE.JWK` structs. 19 + 20 + ## Serialisation 21 + 22 + Use `to_json/1` to produce a camelCase map suitable for JSON encoding. Public keys are 23 + always emitted in the canonical `Multikey` / `publicKeyMultibase` format, regardless of 24 + the format used when the document was originally parsed. 25 + 26 + ## ATProto-specific helpers 27 + 28 + - `validate_for_atproto/2` - checks the document meets minimum atproto requirements. 29 + - `get_atproto_handle/1` - extracts the claimed AT Protocol handle. 30 + - `get_pds_endpoint/1` - extracts the PDS service endpoint URL. 31 + - `get_atproto_signing_key/1` - extracts the atproto signing key as a `JOSE.JWK`. 32 + """ 33 + import Peri 34 + use TypedStruct 35 + 36 + alias Atex.DID.Document.{Service, VerificationMethod} 37 + 38 + defschema :schema, %{ 39 + "@context": {:required, {:list, Atex.Peri.uri()}}, 40 + id: {:required, :string}, 41 + controller: {:either, {Atex.Peri.did(), {:list, Atex.Peri.did()}}}, 42 + also_known_as: {:list, Atex.Peri.uri()}, 43 + verification_method: {:list, :map}, 44 + authentication: {:list, {:either, {Atex.Peri.uri(), :map}}}, 45 + service: {:list, :map} 46 + } 47 + 48 + typedstruct do 49 + field :"@context", list(String.t()), enforce: true 50 + field :id, String.t(), enforce: true 51 + field :controller, String.t() | list(String.t()) 52 + field :also_known_as, list(String.t()) 53 + field :verification_method, list(VerificationMethod.t()) 54 + field :authentication, list(String.t() | VerificationMethod.t()) 55 + field :service, list(Service.t()) 56 + end 57 + 58 + @doc """ 59 + Parses and validates a raw DID document map into a typed `t()` struct. 60 + 61 + Accepts the camelCase wire format as returned by DID resolution endpoints. 62 + `verificationMethod` and `service` entries are parsed into their respective sub-structs. 63 + Public keys are normalised to `JOSE.JWK` regardless of the wire encoding used. 64 + 65 + Returns `{:ok, t()}` on success, or `{:error, Peri.Error.t()}` on validation failure. 66 + 67 + ## Examples 68 + 69 + iex> Atex.DID.Document.new(%{ 70 + ...> "@context" => ["https://www.w3.org/ns/did/v1"], 71 + ...> "id" => "did:plc:abc123", 72 + ...> "verificationMethod" => [], 73 + ...> "service" => [] 74 + ...> }) 75 + {:ok, %Atex.DID.Document{id: "did:plc:abc123", ...}} 76 + """ 77 + @spec new(map()) :: {:ok, t()} | {:error, Peri.Error.t()} 78 + def new(%{} = map) do 79 + map 80 + |> Recase.Enumerable.convert_keys(&Recase.to_snake/1) 81 + |> schema() 82 + |> case do 83 + {:ok, params} -> 84 + verification_methods = 85 + params 86 + |> Map.get(:verification_method, []) 87 + |> Enum.map(&parse_verification_method/1) 88 + |> Enum.reject(&is_nil/1) 89 + 90 + services = 91 + params 92 + |> Map.get(:service, []) 93 + |> Enum.map(&parse_service/1) 94 + |> Enum.reject(&is_nil/1) 95 + 96 + authentication = 97 + params 98 + |> Map.get(:authentication, []) 99 + |> Enum.map(&parse_authentication_entry/1) 100 + |> Enum.reject(&is_nil/1) 101 + 102 + doc = 103 + struct( 104 + __MODULE__, 105 + params 106 + |> Map.put(:verification_method, verification_methods) 107 + |> Map.put(:service, services) 108 + |> Map.put(:authentication, authentication) 109 + ) 110 + 111 + {:ok, doc} 112 + 113 + e -> 114 + e 115 + end 116 + end 117 + 118 + @doc """ 119 + Serialises a `t()` struct to a camelCase map suitable for JSON encoding. 120 + 121 + Public keys in `verificationMethod` are always emitted in the canonical `Multikey` 122 + format with `publicKeyMultibase`. 123 + 124 + ## Examples 125 + 126 + iex> {:ok, doc} = Atex.DID.Document.new(%{ 127 + ...> "@context" => ["https://www.w3.org/ns/did/v1"], 128 + ...> "id" => "did:plc:abc123" 129 + ...> }) 130 + iex> json = Atex.DID.Document.to_json(doc) 131 + iex> json["id"] 132 + "did:plc:abc123" 133 + """ 134 + @spec to_json(t()) :: map() 135 + def to_json(%__MODULE__{} = doc) do 136 + base = %{ 137 + "@context" => Map.get(doc, :"@context"), 138 + "id" => doc.id 139 + } 140 + 141 + base 142 + |> maybe_put("controller", doc.controller) 143 + |> maybe_put("alsoKnownAs", doc.also_known_as) 144 + |> maybe_put( 145 + "verificationMethod", 146 + doc.verification_method && Enum.map(doc.verification_method, &VerificationMethod.to_json/1) 147 + ) 148 + |> maybe_put( 149 + "authentication", 150 + doc.authentication && Enum.map(doc.authentication, &serialise_authentication_entry/1) 151 + ) 152 + |> maybe_put( 153 + "service", 154 + doc.service && Enum.map(doc.service, &Service.to_json/1) 155 + ) 156 + end 157 + 158 + @doc """ 159 + Validates that a DID document meets the minimum requirements for AT Protocol. 160 + 161 + Checks: 162 + 163 + - The document `id` matches the expected DID. 164 + - A valid atproto signing key exists (`verificationMethod` entry with id ending `#atproto` 165 + and `controller` matching the DID). 166 + - A valid PDS service entry exists (`service` entry with id ending `#atproto_pds`, type 167 + `"AtprotoPersonalDataServer"`, and a valid HTTPS or HTTP endpoint URL). 168 + 169 + Returns `:ok` or one of `{:error, :id_mismatch}`, `{:error, :no_signing_key}`, 170 + `{:error, :invalid_pds}`. 171 + """ 172 + @spec validate_for_atproto(t(), String.t()) :: 173 + :ok | {:error, :id_mismatch | :no_signing_key | :invalid_pds} 174 + def validate_for_atproto(%__MODULE__{} = doc, did) do 175 + id_matches = doc.id == did 176 + 177 + valid_signing_key = 178 + Enum.any?(doc.verification_method || [], fn method -> 179 + String.ends_with?(method.id, "#atproto") and method.controller == did 180 + end) 181 + 182 + valid_pds_service = 183 + Enum.any?(doc.service || [], fn service -> 184 + String.ends_with?(service.id, "#atproto_pds") and 185 + service.type == "AtprotoPersonalDataServer" and 186 + valid_pds_endpoint?(service.service_endpoint) 187 + end) 188 + 189 + case {id_matches, valid_signing_key, valid_pds_service} do 190 + {true, true, true} -> :ok 191 + {false, _, _} -> {:error, :id_mismatch} 192 + {_, false, _} -> {:error, :no_signing_key} 193 + {_, _, false} -> {:error, :invalid_pds} 194 + end 195 + end 196 + 197 + @doc """ 198 + Returns the AT Protocol handle claimed by this DID document, or `nil` if none is present. 199 + 200 + The handle is found in the `alsoKnownAs` array as a URI with the `at://` scheme followed 201 + by the handle hostname. Per the atproto specification, only the first syntactically valid 202 + handle in the list is returned. 203 + 204 + > #### Note {: .info} 205 + > 206 + > A handle returned here is only a claim. To confirm it, validate bidirectionally by 207 + > resolving the handle to a DID and checking it matches. See 208 + > `Atex.IdentityResolver.Handle.resolve/2`. 209 + """ 210 + @spec get_atproto_handle(t()) :: String.t() | nil 211 + def get_atproto_handle(%__MODULE__{also_known_as: nil}), do: nil 212 + 213 + def get_atproto_handle(%__MODULE__{} = doc) do 214 + Enum.find_value(doc.also_known_as, fn 215 + "at://" <> handle -> handle 216 + _ -> nil 217 + end) 218 + end 219 + 220 + @doc """ 221 + Returns the PDS service endpoint URL from the DID document, or `nil` if not found. 222 + 223 + Looks for a `service` entry with id ending `#atproto_pds` and type 224 + `"AtprotoPersonalDataServer"`. 225 + """ 226 + @spec get_pds_endpoint(t()) :: String.t() | nil 227 + def get_pds_endpoint(%__MODULE__{} = doc) do 228 + (doc.service || []) 229 + |> Enum.find(fn 230 + %Service{id: id, type: "AtprotoPersonalDataServer"} -> 231 + String.ends_with?(id, "#atproto_pds") 232 + 233 + _ -> 234 + false 235 + end) 236 + |> case do 237 + nil -> nil 238 + pds -> pds.service_endpoint 239 + end 240 + end 241 + 242 + @doc """ 243 + Returns the atproto signing key from the DID document as a `JOSE.JWK`, or `nil`. 244 + 245 + Finds the first `verificationMethod` entry whose id ends with `#atproto`. The public key 246 + is returned as a `JOSE.JWK` struct directly, since key decoding (including legacy formats) 247 + is performed at parse time in `new/1`. 248 + """ 249 + @spec get_atproto_signing_key(t()) :: JOSE.JWK.t() | nil 250 + def get_atproto_signing_key(%__MODULE__{} = doc) do 251 + (doc.verification_method || []) 252 + |> Enum.find(fn %VerificationMethod{id: id} -> String.ends_with?(id, "#atproto") end) 253 + |> case do 254 + nil -> nil 255 + method -> method.public_key_jwk 256 + end 257 + end 258 + 259 + # Parse a raw verification method map, returning nil on failure. 260 + @spec parse_verification_method(map()) :: VerificationMethod.t() | nil 261 + defp parse_verification_method(raw) do 262 + case VerificationMethod.new(raw) do 263 + {:ok, vm} -> vm 264 + _ -> nil 265 + end 266 + end 267 + 268 + # Parse a raw service map, returning nil on failure. 269 + @spec parse_service(map()) :: Service.t() | nil 270 + defp parse_service(raw) do 271 + case Service.new(raw) do 272 + {:ok, svc} -> svc 273 + _ -> nil 274 + end 275 + end 276 + 277 + # Authentication entries can be either a URI string or a verification method map. 278 + @spec parse_authentication_entry(String.t() | map()) :: 279 + String.t() | VerificationMethod.t() | nil 280 + defp parse_authentication_entry(entry) when is_binary(entry), do: entry 281 + 282 + defp parse_authentication_entry(entry) when is_map(entry) do 283 + parse_verification_method(entry) 284 + end 285 + 286 + defp parse_authentication_entry(_), do: nil 287 + 288 + @spec serialise_authentication_entry(String.t() | VerificationMethod.t()) :: String.t() | map() 289 + defp serialise_authentication_entry(entry) when is_binary(entry), do: entry 290 + 291 + defp serialise_authentication_entry(%VerificationMethod{} = vm), 292 + do: VerificationMethod.to_json(vm) 293 + 294 + @spec maybe_put(map(), String.t(), any()) :: map() 295 + defp maybe_put(map, _key, nil), do: map 296 + defp maybe_put(map, _key, []), do: map 297 + defp maybe_put(map, key, value), do: Map.put(map, key, value) 298 + 299 + @spec valid_pds_endpoint?(String.t()) :: boolean() 300 + defp valid_pds_endpoint?(endpoint) do 301 + case URI.new(endpoint) do 302 + {:ok, uri} -> 303 + is_plain_uri = 304 + uri 305 + |> Map.from_struct() 306 + |> Enum.all?(fn 307 + {key, value} when key in [:userinfo, :path, :query, :fragment] -> is_nil(value) 308 + _ -> true 309 + end) 310 + 311 + uri.scheme in ["https", "http"] and is_plain_uri 312 + 313 + _ -> 314 + false 315 + end 316 + end 317 + end 318 + 319 + defimpl JSON.Encoder, for: Atex.DID.Document do 320 + def encode(value, encoder), do: JSON.encode!(Atex.DID.Document.to_json(value), encoder) 321 + end
+102
lib/atex/did/document/service.ex
··· 1 + defmodule Atex.DID.Document.Service do 2 + @moduledoc """ 3 + Struct and schema for a `service` entry in a DID document. 4 + 5 + Each service entry describes a network endpoint associated with the DID subject. 6 + In atproto, the most relevant service is the PDS (Personal Data Server), identified 7 + by the `#atproto_pds` fragment and type `"AtprotoPersonalDataServer"`. 8 + 9 + ## Fields 10 + 11 + - `:id` - URI identifying the service, typically a DID fragment (e.g. `"#atproto_pds"` 12 + or the fully-qualified form `"did:plc:abc123#atproto_pds"`). 13 + - `:type` - Service type string or list of type strings. 14 + - `:service_endpoint` - The endpoint URI, a map of URIs, or a list of either. 15 + """ 16 + import Peri 17 + use TypedStruct 18 + 19 + @typedoc "A service endpoint: a URI string, a map of URI strings, or a list of either." 20 + @type endpoint() :: 21 + String.t() 22 + | %{String.t() => String.t()} 23 + | list(String.t() | %{String.t() => String.t()}) 24 + 25 + defschema :schema, %{ 26 + id: {:required, Atex.Peri.uri()}, 27 + type: {:required, {:either, {:string, {:list, :string}}}}, 28 + service_endpoint: 29 + {:required, 30 + {:oneof, 31 + [ 32 + Atex.Peri.uri(), 33 + {:map, Atex.Peri.uri()}, 34 + {:list, {:either, {Atex.Peri.uri(), {:map, Atex.Peri.uri()}}}} 35 + ]}} 36 + } 37 + 38 + typedstruct do 39 + field :id, String.t(), enforce: true 40 + field :type, String.t() | list(String.t()), enforce: true 41 + field :service_endpoint, endpoint(), enforce: true 42 + end 43 + 44 + @doc """ 45 + Validates and builds a `Service` struct from a map (snake_case or camelCase keys). 46 + 47 + Returns `{:ok, t()}` on success, or `{:error, Peri.Error.t()}` on validation failure. 48 + 49 + ## Examples 50 + 51 + iex> Atex.DID.Document.Service.new(%{ 52 + ...> "id" => "#atproto_pds", 53 + ...> "type" => "AtprotoPersonalDataServer", 54 + ...> "serviceEndpoint" => "https://pds.example.com" 55 + ...> }) 56 + {:ok, %Atex.DID.Document.Service{ 57 + id: "#atproto_pds", 58 + type: "AtprotoPersonalDataServer", 59 + service_endpoint: "https://pds.example.com" 60 + }} 61 + """ 62 + @spec new(map()) :: {:ok, t()} | {:error, Peri.Error.t()} 63 + def new(%{} = map) do 64 + map 65 + |> Recase.Enumerable.convert_keys(&Recase.to_snake/1) 66 + |> schema() 67 + |> case do 68 + {:ok, params} -> {:ok, struct(__MODULE__, params)} 69 + e -> e 70 + end 71 + end 72 + 73 + @doc """ 74 + Converts a `Service` struct to a camelCase map suitable for JSON serialisation. 75 + 76 + ## Examples 77 + 78 + iex> svc = %Atex.DID.Document.Service{ 79 + ...> id: "#atproto_pds", 80 + ...> type: "AtprotoPersonalDataServer", 81 + ...> service_endpoint: "https://pds.example.com" 82 + ...> } 83 + iex> Atex.DID.Document.Service.to_json(svc) 84 + %{ 85 + "id" => "#atproto_pds", 86 + "type" => "AtprotoPersonalDataServer", 87 + "serviceEndpoint" => "https://pds.example.com" 88 + } 89 + """ 90 + @spec to_json(t()) :: map() 91 + def to_json(%__MODULE__{} = service) do 92 + %{ 93 + "id" => service.id, 94 + "type" => service.type, 95 + "serviceEndpoint" => service.service_endpoint 96 + } 97 + end 98 + end 99 + 100 + defimpl JSON.Encoder, for: Atex.DID.Document.Service do 101 + def encode(value, encoder), do: JSON.encode!(Atex.DID.Document.Service.to_json(value), encoder) 102 + end
+152
lib/atex/did/document/verification_method.ex
··· 1 + defmodule Atex.DID.Document.VerificationMethod do 2 + @moduledoc """ 3 + Struct and schema for a `verificationMethod` entry in a DID document. 4 + 5 + Internally, public keys are always stored as `JOSE.JWK` structs regardless of the 6 + wire encoding. Both the current `Multikey` format and the legacy 7 + `EcdsaSecp256r1VerificationKey2019` / `EcdsaSecp256k1VerificationKey2019` formats 8 + are accepted during parsing. 9 + 10 + ## Wire formats 11 + 12 + **Current (`Multikey`)** 13 + 14 + ```json 15 + { 16 + "id": "did:plc:abc123#atproto", 17 + "type": "Multikey", 18 + "controller": "did:plc:abc123", 19 + "publicKeyMultibase": "zDnaembgSGUhZULN2Caob4HLJPaxBh92N7rtH21TErzqf8HQo" 20 + } 21 + ``` 22 + 23 + **Legacy (uncompressed multibase, curve identified by `type`)** 24 + 25 + ```json 26 + { 27 + "id": "#atproto", 28 + "type": "EcdsaSecp256k1VerificationKey2019", 29 + "controller": "did:plc:abc123", 30 + "publicKeyMultibase": "zQYEBzXeuTM9UR3rfvNag6L3RNAs5pQZyYPsomTsgQhsxLdEgCrPTLgFna8yqCnxPpNT7DBk6Ym3dgPKNu86vt9GR" 31 + } 32 + ``` 33 + 34 + ## Fields 35 + 36 + - `:id` - URI identifying the key, typically a DID fragment (e.g. `"#atproto"`). 37 + - `:type` - Key type string (e.g. `"Multikey"`). 38 + - `:controller` - DID of the entity that controls this key. 39 + - `:public_key_jwk` - The public key as a `JOSE.JWK` struct, or `nil` if the wire 40 + format could not be decoded. 41 + """ 42 + import Peri 43 + use TypedStruct 44 + 45 + @legacy_types ~w(EcdsaSecp256r1VerificationKey2019 EcdsaSecp256k1VerificationKey2019) 46 + 47 + defschema :schema, %{ 48 + id: {:required, Atex.Peri.uri()}, 49 + type: {:required, :string}, 50 + controller: {:required, Atex.Peri.did()}, 51 + public_key_multibase: :string 52 + } 53 + 54 + typedstruct do 55 + field :id, String.t(), enforce: true 56 + field :type, String.t(), enforce: true 57 + field :controller, String.t(), enforce: true 58 + field :public_key_jwk, JOSE.JWK.t() | nil 59 + end 60 + 61 + @doc """ 62 + Validates and builds a `VerificationMethod` struct from a raw map. 63 + 64 + Accepts camelCase or snake_case keys. The public key in `publicKeyMultibase` - whether 65 + in the current `Multikey` format or the legacy uncompressed format - is decoded and stored 66 + as `public_key_jwk`. 67 + 68 + Returns `{:ok, t()}` on success, or `{:error, term()}` on validation or decode failure. 69 + 70 + ## Examples 71 + 72 + iex> Atex.DID.Document.VerificationMethod.new(%{ 73 + ...> "id" => "did:plc:abc123#atproto", 74 + ...> "type" => "Multikey", 75 + ...> "controller" => "did:plc:abc123", 76 + ...> "publicKeyMultibase" => "zDnaembgSGUhZULN2Caob4HLJPaxBh92N7rtH21TErzqf8HQo" 77 + ...> }) 78 + {:ok, %Atex.DID.Document.VerificationMethod{ 79 + id: "did:plc:abc123#atproto", 80 + type: "Multikey", 81 + controller: "did:plc:abc123", 82 + public_key_jwk: %JOSE.JWK{} 83 + }} 84 + """ 85 + @spec new(map()) :: {:ok, t()} | {:error, term()} 86 + def new(%{} = map) do 87 + snake = Recase.Enumerable.convert_keys(map, &Recase.to_snake/1) 88 + 89 + with {:ok, params} <- schema(snake) do 90 + jwk = resolve_public_key(params) 91 + {:ok, struct(__MODULE__, Map.put(params, :public_key_jwk, jwk))} 92 + end 93 + end 94 + 95 + @doc """ 96 + Converts a `VerificationMethod` struct to a camelCase map for JSON serialisation. 97 + 98 + The public key is always emitted in the canonical `Multikey` / `publicKeyMultibase` 99 + format. If no public key is present, `"publicKeyMultibase"` is omitted. 100 + 101 + ## Examples 102 + 103 + iex> {:ok, vm} = Atex.DID.Document.VerificationMethod.new(%{ 104 + ...> "id" => "did:plc:abc123#atproto", 105 + ...> "type" => "Multikey", 106 + ...> "controller" => "did:plc:abc123", 107 + ...> "publicKeyMultibase" => "zDnaembgSGUhZULN2Caob4HLJPaxBh92N7rtH21TErzqf8HQo" 108 + ...> }) 109 + iex> json = Atex.DID.Document.VerificationMethod.to_json(vm) 110 + iex> json["type"] 111 + "Multikey" 112 + iex> is_binary(json["publicKeyMultibase"]) 113 + true 114 + """ 115 + @spec to_json(t()) :: map() 116 + def to_json(%__MODULE__{} = vm) do 117 + base = %{ 118 + "id" => vm.id, 119 + "type" => "Multikey", 120 + "controller" => vm.controller 121 + } 122 + 123 + case vm.public_key_jwk && Atex.Crypto.encode_did_key(vm.public_key_jwk) do 124 + {:ok, multibase} -> Map.put(base, "publicKeyMultibase", multibase) 125 + _ -> base 126 + end 127 + end 128 + 129 + # Resolve the public key from validated (snake_case) params to a JOSE.JWK or nil. 130 + @spec resolve_public_key(map()) :: JOSE.JWK.t() | nil 131 + defp resolve_public_key(%{type: type, public_key_multibase: multibase}) 132 + when type in @legacy_types and is_binary(multibase) do 133 + case Atex.Crypto.decode_legacy_multibase(type, multibase) do 134 + {:ok, jwk} -> jwk 135 + _ -> nil 136 + end 137 + end 138 + 139 + defp resolve_public_key(%{public_key_multibase: multibase}) when is_binary(multibase) do 140 + case Atex.Crypto.decode_did_key(multibase) do 141 + {:ok, jwk} -> jwk 142 + _ -> nil 143 + end 144 + end 145 + 146 + defp resolve_public_key(_), do: nil 147 + end 148 + 149 + defimpl JSON.Encoder, for: Atex.DID.Document.VerificationMethod do 150 + def encode(value, encoder), 151 + do: JSON.encode!(Atex.DID.Document.VerificationMethod.to_json(value), encoder) 152 + end
+1 -1
lib/atex/identity_resolver.ex
··· 1 1 defmodule Atex.IdentityResolver do 2 2 alias Atex.IdentityResolver.{Cache, DID, Handle, Identity} 3 - alias Atex.PLC.DIDDocument 3 + alias Atex.DID.Document, as: DIDDocument 4 4 5 5 @handle_strategy Application.compile_env(:atex, :handle_resolver_strategy, :dns_first) 6 6 @type options() :: {:skip_cache, boolean()}
+5 -5
lib/atex/identity_resolver/did.ex
··· 1 1 defmodule Atex.IdentityResolver.DID do 2 - alias Atex.PLC 2 + alias Atex.{DID, PLC} 3 3 4 4 @type resolution_result() :: 5 - {:ok, PLC.DIDDocument.t()} 5 + {:ok, DID.Document.t()} 6 6 | {:error, :invalid_did_type | :invalid_did | :not_found | map() | atom() | any()} 7 7 8 8 @spec resolve(String.t()) :: resolution_result() ··· 14 14 @spec resolve_plc(String.t()) :: resolution_result() 15 15 defp resolve_plc("did:plc:" <> _id = did) do 16 16 with {:ok, document} <- PLC.resolve_did(did), 17 - :ok <- PLC.DIDDocument.validate_for_atproto(document, did) do 17 + :ok <- DID.Document.validate_for_atproto(document, did) do 18 18 {:ok, document} 19 19 end 20 20 end ··· 24 24 with {:ok, resp} when resp.status in 200..299 <- 25 25 Req.get("https://#{domain}/.well-known/did.json"), 26 26 {:ok, body} <- decode_body(resp.body), 27 - {:ok, document} <- PLC.DIDDocument.from_json(body), 28 - :ok <- PLC.DIDDocument.validate_for_atproto(document, did) do 27 + {:ok, document} <- DID.Document.new(body), 28 + :ok <- DID.Document.validate_for_atproto(document, did) do 29 29 {:ok, document} 30 30 else 31 31 {:ok, %{status: 404}} -> {:error, :not_found}
+1 -1
lib/atex/identity_resolver/identity.ex
··· 12 12 @typedoc """ 13 13 The resolved DID document for an identity. 14 14 """ 15 - @type document() :: Atex.PLC.DIDDocument.t() 15 + @type document() :: Atex.DID.Document.t() 16 16 17 17 typedstruct do 18 18 field :did, did(), enforce: true
+3 -3
lib/atex/oauth/plug.ex
··· 93 93 require Logger 94 94 use Plug.Router 95 95 require Plug.Router 96 - alias Atex.{IdentityResolver, OAuth, PLC.DIDDocument} 96 + alias Atex.{DID, IdentityResolver, OAuth} 97 97 98 98 @oauth_cookie_opts [path: "/", http_only: true, secure: true, same_site: "lax", max_age: 600] 99 99 @session_name :atex_session ··· 129 129 130 130 case IdentityResolver.resolve(handle) do 131 131 {:ok, identity} -> 132 - pds = DIDDocument.get_pds_endpoint(identity.document) 132 + pds = DID.Document.get_pds_endpoint(identity.document) 133 133 {:ok, authz_server} = OAuth.get_authorization_server(pds) 134 134 {:ok, authz_metadata} = OAuth.get_authorization_server_metadata(authz_server) 135 135 state = OAuth.create_nonce() ··· 195 195 ), 196 196 {:ok, identity} <- IdentityResolver.resolve(tokens.did), 197 197 # Make sure pds' issuer matches the stored one (just in case) 198 - pds <- DIDDocument.get_pds_endpoint(identity.document), 198 + pds <- DID.Document.get_pds_endpoint(identity.document), 199 199 {:ok, authz_server} <- OAuth.get_authorization_server(pds), 200 200 true <- authz_server == stored_issuer do 201 201 session = %OAuth.Session{
+6 -6
lib/atex/plc.ex
··· 25 25 - `:not_found` - The DID is not registered (HTTP 404). 26 26 - `:tombstoned` - The DID has been permanently deactivated (HTTP 410). 27 27 - `:invalid_document` - The server returned a body that could not be parsed 28 - into a `DIDDocument`. 28 + into an `Atex.DID.Document`. 29 29 - `{:invalid_operation, message}` - The submitted operation was rejected by 30 30 the server, with an explanatory message (HTTP 400). 31 31 - `:invalid_operation` - The submitted operation was rejected without a ··· 39 39 @type create_op_error() :: 40 40 {:error, {:invalid_operation, message :: String.t()} | :invalid_operation} | error() 41 41 42 - alias Atex.PLC.DIDDocument 42 + alias Atex.DID 43 43 44 44 @doc """ 45 45 Resolves the DID Document for the given `did:plc` identifier. 46 46 47 47 Fetches the current DID Document from the directory server and parses it into 48 - an `Atex.PLC.DIDDocument` struct. 48 + an `Atex.DID.Document` struct. 49 49 50 50 ## Parameters 51 51 ··· 55 55 ## Examples 56 56 57 57 iex> Atex.PLC.resolve_did("did:plc:ewvi7nxzyoun6zhxrhs64oiz") 58 - {:ok, %Atex.PLC.DIDDocument{...}} 58 + {:ok, %Atex.DID.Document{...}} 59 59 60 60 iex> Atex.PLC.resolve_did("did:plc:doesnotexist") 61 61 {:error, :not_found} 62 62 """ 63 - @spec resolve_did(String.t(), keyword()) :: {:ok, DIDDocument.t()} | error() 63 + @spec resolve_did(String.t(), keyword()) :: {:ok, DID.Document.t()} | error() 64 64 def resolve_did(did, opts \\ []) do 65 65 opts 66 66 |> host() ··· 69 69 |> handle_response() 70 70 |> case do 71 71 {:ok, body} -> 72 - case DIDDocument.from_json(body) do 72 + case DID.Document.new(body) do 73 73 {:ok, document} -> {:ok, document} 74 74 {:error, _reason} -> {:error, :invalid_document} 75 75 end
-174
lib/atex/plc/did_document.ex
··· 1 - defmodule Atex.PLC.DIDDocument do 2 - @moduledoc """ 3 - Struct and schema for describing and validating a [DID document](https://github.com/w3c/did-wg/blob/main/did-explainer.md#did-documents). 4 - """ 5 - import Peri 6 - use TypedStruct 7 - 8 - defschema :schema, %{ 9 - "@context": {:required, {:list, Atex.Peri.uri()}}, 10 - id: {:required, :string}, 11 - controller: {:either, {Atex.Peri.did(), {:list, Atex.Peri.did()}}}, 12 - also_known_as: {:list, Atex.Peri.uri()}, 13 - verification_method: {:list, get_schema(:verification_method)}, 14 - authentication: {:list, {:either, {Atex.Peri.uri(), get_schema(:verification_method)}}}, 15 - service: {:list, get_schema(:service)} 16 - } 17 - 18 - defschema :verification_method, %{ 19 - id: {:required, Atex.Peri.uri()}, 20 - type: {:required, :string}, 21 - controller: {:required, Atex.Peri.did()}, 22 - public_key_multibase: :string, 23 - public_key_jwk: :map 24 - } 25 - 26 - defschema :service, %{ 27 - id: {:required, Atex.Peri.uri()}, 28 - type: {:required, {:either, {:string, {:list, :string}}}}, 29 - service_endpoint: 30 - {:required, 31 - {:oneof, 32 - [ 33 - Atex.Peri.uri(), 34 - {:map, Atex.Peri.uri()}, 35 - {:list, {:either, {Atex.Peri.uri(), {:map, Atex.Peri.uri()}}}} 36 - ]}} 37 - } 38 - 39 - @type verification_method() :: %{ 40 - required(:id) => String.t(), 41 - required(:type) => String.t(), 42 - required(:controller) => String.t(), 43 - optional(:public_key_multibase) => String.t(), 44 - optional(:public_key_jwk) => map() 45 - } 46 - 47 - @type service() :: %{ 48 - required(:id) => String.t(), 49 - required(:type) => String.t() | list(String.t()), 50 - required(:service_endpoint) => 51 - String.t() 52 - | %{String.t() => String.t()} 53 - | list(String.t() | %{String.t() => String.t()}) 54 - } 55 - 56 - typedstruct do 57 - field :"@context", list(String.t()), enforce: true 58 - field :id, String.t(), enforce: true 59 - field :controller, String.t() | list(String.t()) 60 - field :also_known_as, list(String.t()) 61 - field :verification_method, list(verification_method()) 62 - field :authentication, list(String.t() | verification_method()) 63 - field :service, list(service()) 64 - end 65 - 66 - def new(params), do: struct(__MODULE__, params) 67 - 68 - @spec from_json(map()) :: {:ok, t()} | {:error, Peri.Error.t()} 69 - def from_json(%{} = map) do 70 - map 71 - |> Recase.Enumerable.convert_keys(&Recase.to_snake/1) 72 - |> schema() 73 - |> case do 74 - {:ok, params} -> {:ok, new(params)} 75 - e -> e 76 - end 77 - end 78 - 79 - @spec validate_for_atproto(t(), String.t()) :: any() 80 - def validate_for_atproto(%__MODULE__{} = doc, did) do 81 - id_matches = doc.id == did 82 - 83 - valid_signing_key = 84 - Enum.any?(doc.verification_method, fn method -> 85 - String.ends_with?(method.id, "#atproto") and method.controller == did 86 - end) 87 - 88 - valid_pds_service = 89 - Enum.any?(doc.service, fn service -> 90 - String.ends_with?(service.id, "#atproto_pds") and 91 - service.type == "AtprotoPersonalDataServer" and 92 - valid_pds_endpoint?(service.service_endpoint) 93 - end) 94 - 95 - case {id_matches, valid_signing_key, valid_pds_service} do 96 - {true, true, true} -> :ok 97 - {false, _, _} -> {:error, :id_mismatch} 98 - {_, false, _} -> {:error, :no_signing_key} 99 - {_, _, false} -> {:error, :invalid_pds} 100 - end 101 - end 102 - 103 - @doc """ 104 - Get the associated ATProto handle in the DID document. 105 - 106 - ATProto dictates that only the first valid handle is to be used, so this 107 - follows that rule. 108 - 109 - > #### Note {: .info} 110 - > 111 - > While DID documents are fairly authoritative, you need to make sure to 112 - > validate the handle bidirectionally. See 113 - > `Atex.IdentityResolver.Handle.resolve/2`. 114 - """ 115 - @spec get_atproto_handle(t()) :: String.t() | nil 116 - def get_atproto_handle(%__MODULE__{also_known_as: nil}), do: nil 117 - 118 - def get_atproto_handle(%__MODULE__{} = doc) do 119 - Enum.find_value(doc.also_known_as, fn 120 - # TODO: make sure no path or other URI parts 121 - "at://" <> handle -> handle 122 - _ -> nil 123 - end) 124 - end 125 - 126 - @spec get_pds_endpoint(t()) :: String.t() | nil 127 - def get_pds_endpoint(%__MODULE__{} = doc) do 128 - doc.service 129 - |> Enum.find(fn 130 - %{id: "#atproto_pds", type: "AtprotoPersonalDataServer"} -> true 131 - _ -> false 132 - end) 133 - |> case do 134 - nil -> nil 135 - pds -> pds.service_endpoint 136 - end 137 - end 138 - 139 - @spec get_atproto_signing_key(t()) :: JOSE.JWK.t() | nil 140 - def get_atproto_signing_key(%__MODULE__{} = doc) do 141 - doc.verification_method 142 - |> Enum.find(fn 143 - %{id: id} -> String.ends_with?(id, "#atproto") 144 - end) 145 - |> case do 146 - nil -> 147 - nil 148 - 149 - %{public_key_multibase: multibase} -> 150 - {:ok, jwk} = Atex.Crypto.decode_did_key(multibase) 151 - jwk 152 - 153 - # TODO 154 - _ -> 155 - raise ArgumentError, message: "Legacy verification method keys are not yet supported" 156 - # %{public_key_jwk: jwk} -> nil 157 - end 158 - end 159 - 160 - defp valid_pds_endpoint?(endpoint) do 161 - case URI.new(endpoint) do 162 - {:ok, uri} -> 163 - is_plain_uri = 164 - uri 165 - |> Map.from_struct() 166 - |> Enum.all?(fn 167 - {key, value} when key in [:userinfo, :path, :query, :fragment] -> is_nil(value) 168 - _ -> true 169 - end) 170 - 171 - uri.scheme in ["https", "http"] and is_plain_uri 172 - end 173 - end 174 - end
+1 -1
lib/atex/service_auth.ex
··· 123 123 # the signing key from their DID document to verify the token 124 124 {:ok, identity} <- Atex.IdentityResolver.resolve(issuing_did), 125 125 user_jwk when not is_nil(user_jwk) <- 126 - Atex.PLC.DIDDocument.get_atproto_signing_key(identity.document), 126 + Atex.DID.Document.get_atproto_signing_key(identity.document), 127 127 {true, %JOSE.JWT{} = jwt_struct, _jws} <- JOSE.JWT.verify(user_jwk, jwt), 128 128 # Record the nonce atomically after successful verification. insert_new 129 129 # is used under the hood so this returns :seen if the jti was already
+1 -1
mix.exs
··· 67 67 groups_for_modules: [ 68 68 "Data types": [Atex.AtURI, Atex.DID, Atex.Handle, Atex.NSID, Atex.TID], 69 69 XRPC: ~r/^Atex\.XRPC/, 70 - PLC: [Atex.PLC, Atex.PLC.DIDDocument], 70 + PLC: [Atex.PLC], 71 71 OAuth: [Atex.Config.OAuth, ~r/^Atex\.OAuth/], 72 72 Identity: [Atex.Config.IdentityResolver, ~r/^Atex\.IdentityResolver/], 73 73 Lexicons: ~r/^Atex\.Lexicon/,
+77
test/atex/did/document/service_test.exs
··· 1 + defmodule Atex.DID.Document.ServiceTest do 2 + use ExUnit.Case, async: true 3 + alias Atex.DID.Document.Service 4 + doctest Service 5 + 6 + describe "new/1" do 7 + test "parses camelCase wire format" do 8 + assert {:ok, svc} = 9 + Service.new(%{ 10 + "id" => "#atproto_pds", 11 + "type" => "AtprotoPersonalDataServer", 12 + "serviceEndpoint" => "https://pds.example.com" 13 + }) 14 + 15 + assert svc.id == "#atproto_pds" 16 + assert svc.type == "AtprotoPersonalDataServer" 17 + assert svc.service_endpoint == "https://pds.example.com" 18 + end 19 + 20 + test "parses snake_case keys" do 21 + assert {:ok, svc} = 22 + Service.new(%{ 23 + id: "#atproto_pds", 24 + type: "AtprotoPersonalDataServer", 25 + service_endpoint: "https://pds.example.com" 26 + }) 27 + 28 + assert svc.service_endpoint == "https://pds.example.com" 29 + end 30 + 31 + test "accepts a list type" do 32 + assert {:ok, svc} = 33 + Service.new(%{ 34 + "id" => "#multi", 35 + "type" => ["TypeA", "TypeB"], 36 + "serviceEndpoint" => "https://example.com" 37 + }) 38 + 39 + assert svc.type == ["TypeA", "TypeB"] 40 + end 41 + 42 + test "returns error when required field missing" do 43 + assert {:error, _} = 44 + Service.new(%{ 45 + "type" => "AtprotoPersonalDataServer", 46 + "serviceEndpoint" => "https://pds.example.com" 47 + }) 48 + end 49 + end 50 + 51 + describe "to_json/1" do 52 + test "produces camelCase map" do 53 + svc = %Service{ 54 + id: "#atproto_pds", 55 + type: "AtprotoPersonalDataServer", 56 + service_endpoint: "https://pds.example.com" 57 + } 58 + 59 + json = Service.to_json(svc) 60 + assert json["id"] == "#atproto_pds" 61 + assert json["type"] == "AtprotoPersonalDataServer" 62 + assert json["serviceEndpoint"] == "https://pds.example.com" 63 + refute Map.has_key?(json, "service_endpoint") 64 + end 65 + 66 + test "round-trips new -> to_json" do 67 + input = %{ 68 + "id" => "#atproto_pds", 69 + "type" => "AtprotoPersonalDataServer", 70 + "serviceEndpoint" => "https://pds.example.com" 71 + } 72 + 73 + assert {:ok, svc} = Service.new(input) 74 + assert Service.to_json(svc) == input 75 + end 76 + end 77 + end
+159
test/atex/did/document/verification_method_test.exs
··· 1 + defmodule Atex.DID.Document.VerificationMethodTest do 2 + use ExUnit.Case, async: true 3 + alias Atex.DID.Document.VerificationMethod 4 + 5 + # Spec example legacy K-256 key from https://atproto.com/specs/did#legacy-representation 6 + @legacy_k256_multibase "zQYEBzXeuTM9UR3rfvNag6L3RNAs5pQZyYPsomTsgQhsxLdEgCrPTLgFna8yqCnxPpNT7DBk6Ym3dgPKNu86vt9GR" 7 + # The same key in Multikey (compressed) format 8 + @multikey_k256 "zQ3shXjHeiBuRCKmM36cuYnm7YEMzhGnCmCyW92sRJ9pribSF" 9 + 10 + describe "new/1 - Multikey format" do 11 + test "parses a Multikey verificationMethod into a JOSE.JWK" do 12 + assert {:ok, vm} = 13 + VerificationMethod.new(%{ 14 + "id" => "did:plc:abc123#atproto", 15 + "type" => "Multikey", 16 + "controller" => "did:plc:abc123", 17 + "publicKeyMultibase" => @multikey_k256 18 + }) 19 + 20 + assert vm.id == "did:plc:abc123#atproto" 21 + assert vm.type == "Multikey" 22 + assert vm.controller == "did:plc:abc123" 23 + assert %JOSE.JWK{} = vm.public_key_jwk 24 + {_, map} = JOSE.JWK.to_map(vm.public_key_jwk) 25 + assert map["crv"] == "secp256k1" 26 + end 27 + 28 + test "parses a P-256 Multikey" do 29 + p256_mk = "zDnaembgSGUhZULN2Caob4HLJPaxBh92N7rtH21TErzqf8HQo" 30 + 31 + assert {:ok, vm} = 32 + VerificationMethod.new(%{ 33 + "id" => "#atproto", 34 + "type" => "Multikey", 35 + "controller" => "did:plc:abc123", 36 + "publicKeyMultibase" => p256_mk 37 + }) 38 + 39 + {_, map} = JOSE.JWK.to_map(vm.public_key_jwk) 40 + assert map["crv"] == "P-256" 41 + end 42 + end 43 + 44 + describe "new/1 - legacy uncompressed multibase format" do 45 + test "parses a legacy EcdsaSecp256k1VerificationKey2019 entry" do 46 + assert {:ok, vm} = 47 + VerificationMethod.new(%{ 48 + "id" => "#atproto", 49 + "type" => "EcdsaSecp256k1VerificationKey2019", 50 + "controller" => "did:plc:abc123", 51 + "publicKeyMultibase" => @legacy_k256_multibase 52 + }) 53 + 54 + assert %JOSE.JWK{} = vm.public_key_jwk 55 + {_, map} = JOSE.JWK.to_map(vm.public_key_jwk) 56 + assert map["crv"] == "secp256k1" 57 + end 58 + 59 + test "legacy and Multikey entries for the same key produce the same JWK" do 60 + {:ok, vm_legacy} = 61 + VerificationMethod.new(%{ 62 + "id" => "#atproto", 63 + "type" => "EcdsaSecp256k1VerificationKey2019", 64 + "controller" => "did:plc:abc123", 65 + "publicKeyMultibase" => @legacy_k256_multibase 66 + }) 67 + 68 + {:ok, vm_current} = 69 + VerificationMethod.new(%{ 70 + "id" => "#atproto", 71 + "type" => "Multikey", 72 + "controller" => "did:plc:abc123", 73 + "publicKeyMultibase" => @multikey_k256 74 + }) 75 + 76 + {_, legacy_map} = JOSE.JWK.to_map(vm_legacy.public_key_jwk) 77 + {_, current_map} = JOSE.JWK.to_map(vm_current.public_key_jwk) 78 + assert legacy_map["x"] == current_map["x"] 79 + assert legacy_map["y"] == current_map["y"] 80 + end 81 + 82 + test "parses a legacy EcdsaSecp256r1VerificationKey2019 entry" do 83 + priv = JOSE.JWK.generate_key({:ec, "P-256"}) 84 + pub = JOSE.JWK.to_public(priv) 85 + {_, map} = JOSE.JWK.to_map(pub) 86 + 87 + x = Base.url_decode64!(map["x"], padding: false) 88 + y = Base.url_decode64!(map["y"], padding: false) 89 + uncompressed = <<0x04>> <> x <> y 90 + legacy_mb = Multiformats.Multibase.encode(uncompressed, :base58btc) 91 + 92 + assert {:ok, vm} = 93 + VerificationMethod.new(%{ 94 + "id" => "#atproto", 95 + "type" => "EcdsaSecp256r1VerificationKey2019", 96 + "controller" => "did:plc:abc123", 97 + "publicKeyMultibase" => legacy_mb 98 + }) 99 + 100 + {_, decoded_map} = JOSE.JWK.to_map(vm.public_key_jwk) 101 + assert decoded_map["x"] == map["x"] 102 + assert decoded_map["y"] == map["y"] 103 + end 104 + end 105 + 106 + describe "to_json/1" do 107 + test "emits Multikey format regardless of input format" do 108 + assert {:ok, vm} = 109 + VerificationMethod.new(%{ 110 + "id" => "#atproto", 111 + "type" => "EcdsaSecp256k1VerificationKey2019", 112 + "controller" => "did:plc:abc123", 113 + "publicKeyMultibase" => @legacy_k256_multibase 114 + }) 115 + 116 + json = VerificationMethod.to_json(vm) 117 + assert json["type"] == "Multikey" 118 + assert is_binary(json["publicKeyMultibase"]) 119 + assert String.starts_with?(json["publicKeyMultibase"], "z") 120 + refute Map.has_key?(json, "publicKeyJwk") 121 + end 122 + 123 + test "round-trips Multikey -> to_json" do 124 + assert {:ok, vm} = 125 + VerificationMethod.new(%{ 126 + "id" => "did:plc:abc123#atproto", 127 + "type" => "Multikey", 128 + "controller" => "did:plc:abc123", 129 + "publicKeyMultibase" => @multikey_k256 130 + }) 131 + 132 + json = VerificationMethod.to_json(vm) 133 + assert json["publicKeyMultibase"] == @multikey_k256 134 + end 135 + 136 + test "omits publicKeyMultibase when no key is present" do 137 + vm = %VerificationMethod{ 138 + id: "#atproto", 139 + type: "Multikey", 140 + controller: "did:plc:abc123", 141 + public_key_jwk: nil 142 + } 143 + 144 + json = VerificationMethod.to_json(vm) 145 + refute Map.has_key?(json, "publicKeyMultibase") 146 + end 147 + end 148 + 149 + describe "new/1 - error cases" do 150 + test "returns error when required field is missing" do 151 + assert {:error, _} = 152 + VerificationMethod.new(%{ 153 + "type" => "Multikey", 154 + "controller" => "did:plc:abc123", 155 + "publicKeyMultibase" => @multikey_k256 156 + }) 157 + end 158 + end 159 + end
+205
test/atex/did/document_test.exs
··· 1 + defmodule Atex.DID.DocumentTest do 2 + use ExUnit.Case, async: true 3 + alias Atex.DID.Document 4 + alias Atex.DID.Document.{Service, VerificationMethod} 5 + 6 + @p256_multikey "zDnaembgSGUhZULN2Caob4HLJPaxBh92N7rtH21TErzqf8HQo" 7 + 8 + @valid_doc %{ 9 + "@context" => ["https://www.w3.org/ns/did/v1"], 10 + "id" => "did:plc:abc123", 11 + "alsoKnownAs" => ["at://alice.example.com"], 12 + "verificationMethod" => [ 13 + %{ 14 + "id" => "did:plc:abc123#atproto", 15 + "type" => "Multikey", 16 + "controller" => "did:plc:abc123", 17 + "publicKeyMultibase" => @p256_multikey 18 + } 19 + ], 20 + "service" => [ 21 + %{ 22 + "id" => "did:plc:abc123#atproto_pds", 23 + "type" => "AtprotoPersonalDataServer", 24 + "serviceEndpoint" => "https://pds.example.com" 25 + } 26 + ] 27 + } 28 + 29 + describe "new/1" do 30 + test "parses a complete document" do 31 + assert {:ok, doc} = Document.new(@valid_doc) 32 + assert doc.id == "did:plc:abc123" 33 + assert doc.also_known_as == ["at://alice.example.com"] 34 + assert [%VerificationMethod{}] = doc.verification_method 35 + assert [%Service{}] = doc.service 36 + end 37 + 38 + test "converts verification_method public keys to JWK" do 39 + assert {:ok, doc} = Document.new(@valid_doc) 40 + [vm] = doc.verification_method 41 + assert %JOSE.JWK{} = vm.public_key_jwk 42 + end 43 + 44 + test "accepts documents with no optional fields" do 45 + assert {:ok, doc} = 46 + Document.new(%{ 47 + "@context" => ["https://www.w3.org/ns/did/v1"], 48 + "id" => "did:plc:abc123" 49 + }) 50 + 51 + assert doc.id == "did:plc:abc123" 52 + assert is_nil(doc.verification_method) or doc.verification_method == [] 53 + end 54 + 55 + test "returns error when required @context is missing" do 56 + assert {:error, _} = Document.new(%{"id" => "did:plc:abc123"}) 57 + end 58 + 59 + test "drops unparseable verification_method entries silently" do 60 + doc_with_bad_vm = 61 + Map.put(@valid_doc, "verificationMethod", [ 62 + %{"type" => "Multikey"}, 63 + %{ 64 + "id" => "did:plc:abc123#atproto", 65 + "type" => "Multikey", 66 + "controller" => "did:plc:abc123", 67 + "publicKeyMultibase" => @p256_multikey 68 + } 69 + ]) 70 + 71 + assert {:ok, doc} = Document.new(doc_with_bad_vm) 72 + assert length(doc.verification_method) == 1 73 + end 74 + end 75 + 76 + describe "validate_for_atproto/2" do 77 + test "returns :ok for a valid document" do 78 + assert {:ok, doc} = Document.new(@valid_doc) 79 + assert :ok = Document.validate_for_atproto(doc, "did:plc:abc123") 80 + end 81 + 82 + test "returns {:error, :id_mismatch} when id does not match" do 83 + assert {:ok, doc} = Document.new(@valid_doc) 84 + assert {:error, :id_mismatch} = Document.validate_for_atproto(doc, "did:plc:other") 85 + end 86 + 87 + test "returns {:error, :no_signing_key} when atproto vm is missing" do 88 + doc_no_key = Map.put(@valid_doc, "verificationMethod", []) 89 + assert {:ok, doc} = Document.new(doc_no_key) 90 + assert {:error, :no_signing_key} = Document.validate_for_atproto(doc, "did:plc:abc123") 91 + end 92 + 93 + test "returns {:error, :invalid_pds} when PDS service is missing" do 94 + doc_no_pds = Map.put(@valid_doc, "service", []) 95 + assert {:ok, doc} = Document.new(doc_no_pds) 96 + assert {:error, :invalid_pds} = Document.validate_for_atproto(doc, "did:plc:abc123") 97 + end 98 + 99 + test "returns {:error, :invalid_pds} for PDS with a path component" do 100 + doc_bad_pds = 101 + Map.put(@valid_doc, "service", [ 102 + %{ 103 + "id" => "did:plc:abc123#atproto_pds", 104 + "type" => "AtprotoPersonalDataServer", 105 + "serviceEndpoint" => "https://pds.example.com/path" 106 + } 107 + ]) 108 + 109 + assert {:ok, doc} = Document.new(doc_bad_pds) 110 + assert {:error, :invalid_pds} = Document.validate_for_atproto(doc, "did:plc:abc123") 111 + end 112 + end 113 + 114 + describe "get_atproto_handle/1" do 115 + test "returns the handle from alsoKnownAs" do 116 + assert {:ok, doc} = Document.new(@valid_doc) 117 + assert Document.get_atproto_handle(doc) == "alice.example.com" 118 + end 119 + 120 + test "returns nil when alsoKnownAs is nil" do 121 + assert {:ok, doc} = Document.new(Map.delete(@valid_doc, "alsoKnownAs")) 122 + assert is_nil(Document.get_atproto_handle(doc)) 123 + end 124 + 125 + test "returns nil when no at:// URI is present" do 126 + doc_no_handle = Map.put(@valid_doc, "alsoKnownAs", ["https://example.com"]) 127 + assert {:ok, doc} = Document.new(doc_no_handle) 128 + assert is_nil(Document.get_atproto_handle(doc)) 129 + end 130 + 131 + test "returns the first valid at:// handle" do 132 + doc_multi = 133 + Map.put(@valid_doc, "alsoKnownAs", [ 134 + "https://example.com", 135 + "at://first.example.com", 136 + "at://second.example.com" 137 + ]) 138 + 139 + assert {:ok, doc} = Document.new(doc_multi) 140 + assert Document.get_atproto_handle(doc) == "first.example.com" 141 + end 142 + end 143 + 144 + describe "get_pds_endpoint/1" do 145 + test "returns the PDS endpoint" do 146 + assert {:ok, doc} = Document.new(@valid_doc) 147 + assert Document.get_pds_endpoint(doc) == "https://pds.example.com" 148 + end 149 + 150 + test "returns nil when no PDS service is present" do 151 + assert {:ok, doc} = Document.new(Map.put(@valid_doc, "service", [])) 152 + assert is_nil(Document.get_pds_endpoint(doc)) 153 + end 154 + end 155 + 156 + describe "get_atproto_signing_key/1" do 157 + test "returns the signing key as a JOSE.JWK" do 158 + assert {:ok, doc} = Document.new(@valid_doc) 159 + assert %JOSE.JWK{} = Document.get_atproto_signing_key(doc) 160 + end 161 + 162 + test "returns nil when no atproto vm is present" do 163 + assert {:ok, doc} = Document.new(Map.put(@valid_doc, "verificationMethod", [])) 164 + assert is_nil(Document.get_atproto_signing_key(doc)) 165 + end 166 + end 167 + 168 + describe "to_json/1" do 169 + test "produces camelCase keys" do 170 + assert {:ok, doc} = Document.new(@valid_doc) 171 + json = Document.to_json(doc) 172 + 173 + assert json["id"] == "did:plc:abc123" 174 + assert json["alsoKnownAs"] == ["at://alice.example.com"] 175 + assert [vm_json] = json["verificationMethod"] 176 + assert vm_json["type"] == "Multikey" 177 + assert [svc_json] = json["service"] 178 + assert svc_json["serviceEndpoint"] == "https://pds.example.com" 179 + 180 + refute Map.has_key?(json, "also_known_as") 181 + refute Map.has_key?(json, "verification_method") 182 + refute Map.has_key?(json, "service_endpoint") 183 + end 184 + 185 + test "omits nil optional fields" do 186 + assert {:ok, doc} = 187 + Document.new(%{ 188 + "@context" => ["https://www.w3.org/ns/did/v1"], 189 + "id" => "did:plc:minimal" 190 + }) 191 + 192 + json = Document.to_json(doc) 193 + refute Map.has_key?(json, "alsoKnownAs") 194 + refute Map.has_key?(json, "verificationMethod") 195 + refute Map.has_key?(json, "service") 196 + refute Map.has_key?(json, "controller") 197 + end 198 + 199 + test "verification methods in to_json are valid for re-parsing" do 200 + assert {:ok, doc} = Document.new(@valid_doc) 201 + json = Document.to_json(doc) 202 + assert {:ok, _doc2} = Document.new(json) 203 + end 204 + end 205 + end