···36363737- Fix a problem where generated `%<LexiconId>.Params` structs could not be
3838 passed to an XRPC call due to not having the Enumerable protocol implemented.
3939+- Add `Atex.Crypto` module for performing AT Protocol-related cryptographic operations.
39404041## [0.7.1] - 2026-02-06
4142
+383
lib/atex/crypto.ex
···11+defmodule Atex.Crypto do
22+ @moduledoc """
33+ Cryptographic operations for the AT Protocol.
44+55+ Supports the two elliptic curves required by atproto:
66+77+ - `p256` - NIST P-256 / secp256r1 (JWK curve `"P-256"`)
88+ - `k256` - secp256k1 (JWK curve `"secp256k1"`)
99+1010+ ## Key encoding
1111+1212+ Public keys are represented as `JOSE.JWK` structs throughout this module.
1313+ The multikey / `did:key` encoding used in DID documents is the canonical
1414+ external representation: a base58btc-encoded (multibase `z` prefix) binary
1515+ consisting of a varint multicodec prefix followed by the 33-byte compressed
1616+ EC point.
1717+1818+ ## Signing and verification
1919+2020+ Signatures are DER-encoded ECDSA byte sequences as produced by Erlang's
2121+ `:public_key` application. All produced signatures are normalised to the
2222+ low-S form required by the atproto specification.
2323+ """
2424+2525+ alias Multiformats.{Multibase, Multicodec}
2626+2727+ # Curve parameters
2828+2929+ # P-256 (secp256r1 / prime256v1)
3030+ @p256_p 0xFFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF
3131+ @p256_a @p256_p - 3
3232+ @p256_b 0x5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B
3333+ @p256_n 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551
3434+ @p256_oid {1, 2, 840, 10045, 3, 1, 7}
3535+3636+ # secp256k1
3737+ @k256_p 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F
3838+ @k256_a 0
3939+ @k256_b 7
4040+ @k256_n 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
4141+ @k256_oid {1, 3, 132, 0, 10}
4242+4343+ @typedoc """
4444+ A multikey-encoded public key string, optionally prefixed with `did:key:`.
4545+4646+ Examples:
4747+4848+ - `"zDnaembgSGUhZULN2Caob4HLJPaxBh92N7rtH21TErzqf8HQo"` (P-256 multikey)
4949+ - `"did:key:zQ3shqwJEJyMBsBXCWyCBpUBMqxcon9oHB7mCvx4sSpMdLJwc"` (K-256 did:key)
5050+ """
5151+ @type multikey :: String.t()
5252+5353+ @doc """
5454+ Decodes a multikey or `did:key` string into a `JOSE.JWK` public key struct.
5555+5656+ Accepts both bare multikey strings (e.g. `"z..."`) and full `did:key` URIs
5757+ (e.g. `"did:key:z..."`). Supports P-256 (`p256-pub`) and secp256k1
5858+ (`secp256k1-pub`) keys.
5959+6060+ ## Examples
6161+6262+ iex> {:ok, jwk} = Atex.Crypto.decode_did_key("zDnaembgSGUhZULN2Caob4HLJPaxBh92N7rtH21TErzqf8HQo")
6363+ iex> match?(%JOSE.JWK{}, jwk)
6464+ true
6565+6666+ iex> {:ok, jwk} = Atex.Crypto.decode_did_key("did:key:zQ3shqwJEJyMBsBXCWyCBpUBMqxcon9oHB7mCvx4sSpMdLJwc")
6767+ iex> match?(%JOSE.JWK{}, jwk)
6868+ true
6969+7070+ iex> Atex.Crypto.decode_did_key("not-a-valid-key")
7171+ {:error, :invalid_multikey}
7272+ """
7373+ @spec decode_did_key(multikey()) :: {:ok, JOSE.JWK.t()} | {:error, term()}
7474+ def decode_did_key(input) when is_binary(input) do
7575+ multikey = strip_did_key_prefix(input)
7676+7777+ with {:ok, raw} <- multibase_decode(multikey),
7878+ {:ok, codec, compressed} <- parse_multicodec(raw),
7979+ {:ok, curve_params} <- curve_params_for_codec(codec),
8080+ {:ok, x_bytes, y_bytes} <- decompress_point(compressed, curve_params) do
8181+ jwk =
8282+ JOSE.JWK.from_map(%{
8383+ "kty" => "EC",
8484+ "crv" => curve_params.jwk_crv,
8585+ "x" => Base.url_encode64(x_bytes, padding: false),
8686+ "y" => Base.url_encode64(y_bytes, padding: false)
8787+ })
8888+8989+ {:ok, jwk}
9090+ end
9191+ end
9292+9393+ @doc """
9494+ Encodes a `JOSE.JWK` public key as a multikey string.
9595+9696+ Accepts both public and private key JWKs; the private component is
9797+ discarded. Supports P-256 and secp256k1 keys.
9898+9999+ ## Options
100100+101101+ - `:as_did_key` — when `true`, prepends the `did:key:` URI scheme to the
102102+ returned string. Defaults to `false`.
103103+104104+ ## Examples
105105+106106+ iex> jwk = JOSE.JWK.generate_key({:ec, "P-256"})
107107+ iex> {:ok, mk} = Atex.Crypto.encode_did_key(jwk)
108108+ iex> String.starts_with?(mk, "z")
109109+ true
110110+111111+ iex> jwk = JOSE.JWK.generate_key({:ec, "P-256"})
112112+ iex> {:ok, mk} = Atex.Crypto.encode_did_key(jwk, as_did_key: true)
113113+ iex> String.starts_with?(mk, "did:key:z")
114114+ true
115115+ """
116116+ @spec encode_did_key(JOSE.JWK.t(), keyword()) :: {:ok, multikey()} | {:error, term()}
117117+ def encode_did_key(jwk, opts \\ []) do
118118+ as_did_key = Keyword.get(opts, :as_did_key, false)
119119+120120+ with {:ok, map} <- public_jwk_map(jwk),
121121+ {:ok, codec_name} <- codec_name_for_crv(map["crv"]),
122122+ {:ok, x_bytes} <- decode_jwk_coord(map["x"]),
123123+ {:ok, y_bytes} <- decode_jwk_coord(map["y"]) do
124124+ prefix = if rem(:binary.last(y_bytes), 2) == 0, do: 0x02, else: 0x03
125125+ compressed = <<prefix>> <> x_bytes
126126+ prefixed = Multicodec.encode!(compressed, codec_name)
127127+ multikey = Multibase.encode(prefixed, :base58btc)
128128+129129+ result = if as_did_key, do: "did:key:" <> multikey, else: multikey
130130+ {:ok, result}
131131+ end
132132+ end
133133+134134+ @doc """
135135+ Verifies a DER-encoded ECDSA signature against a payload and a public key.
136136+137137+ The payload is hashed with SHA-256 internally before verification, matching
138138+ the atproto signing convention.
139139+140140+ Returns `:ok` on success, or `{:error, :invalid_signature}` if the
141141+ signature does not match.
142142+143143+ ## Examples
144144+145145+ iex> jwk = JOSE.JWK.generate_key({:ec, "P-256"})
146146+ iex> {:ok, sig} = Atex.Crypto.sign("hello", jwk)
147147+ iex> Atex.Crypto.verify("hello", sig, JOSE.JWK.to_public(jwk))
148148+ :ok
149149+150150+ iex> jwk = JOSE.JWK.generate_key({:ec, "P-256"})
151151+ iex> {:ok, sig} = Atex.Crypto.sign("hello", jwk)
152152+ iex> Atex.Crypto.verify("tampered", sig, JOSE.JWK.to_public(jwk))
153153+ {:error, :invalid_signature}
154154+ """
155155+ @spec verify(payload :: binary(), signature :: binary(), public_key :: JOSE.JWK.t()) ::
156156+ :ok | {:error, term()}
157157+ def verify(payload, signature, public_key) when is_binary(payload) and is_binary(signature) do
158158+ {_meta, pub_record} = JOSE.JWK.to_public_key(public_key)
159159+ {:ECPoint, pub_bytes} = elem(pub_record, 0)
160160+ {:namedCurve, oid} = elem(pub_record, 1)
161161+ digest = :crypto.hash(:sha256, payload)
162162+163163+ with {:ok, curve} <- oid_to_curve(oid) do
164164+ if :crypto.verify(:ecdsa, :sha256, {:digest, digest}, signature, [pub_bytes, curve]) do
165165+ :ok
166166+ else
167167+ {:error, :invalid_signature}
168168+ end
169169+ end
170170+ rescue
171171+ _ -> {:error, :invalid_signature}
172172+ end
173173+174174+ @doc """
175175+ Signs a payload with a private key, returning a low-S DER-encoded ECDSA
176176+ signature.
177177+178178+ The payload is hashed with SHA-256 internally before signing, matching the
179179+ atproto signing convention.
180180+181181+ ## Examples
182182+183183+ iex> jwk = JOSE.JWK.generate_key({:ec, "P-256"})
184184+ iex> {:ok, sig} = Atex.Crypto.sign("hello", jwk)
185185+ iex> is_binary(sig)
186186+ true
187187+ """
188188+ @spec sign(payload :: binary(), private_key :: JOSE.JWK.t()) ::
189189+ {:ok, binary()} | {:error, term()}
190190+ def sign(payload, private_key) when is_binary(payload) do
191191+ {_meta, priv_record} = JOSE.JWK.to_key(private_key)
192192+ {:ECPrivateKey, _ver, priv_bytes, {:namedCurve, oid}, _pub, _} = priv_record
193193+ digest = :crypto.hash(:sha256, payload)
194194+195195+ with {:ok, curve} <- oid_to_curve(oid),
196196+ {:ok, curve_order} <- curve_order_for_oid(oid) do
197197+ signature = :crypto.sign(:ecdsa, :sha256, {:digest, digest}, [priv_bytes, curve])
198198+ {:ok, normalize_low_s(signature, curve_order)}
199199+ end
200200+ rescue
201201+ _ -> {:error, :sign_failed}
202202+ end
203203+204204+ # Private helpers
205205+206206+ @spec strip_did_key_prefix(String.t()) :: String.t()
207207+ defp strip_did_key_prefix("did:key:" <> rest), do: rest
208208+ defp strip_did_key_prefix(input), do: input
209209+210210+ @spec multibase_decode(String.t()) :: {:ok, binary()} | {:error, :invalid_multikey}
211211+ defp multibase_decode(multikey) do
212212+ {:ok, Multibase.decode!(multikey)}
213213+ rescue
214214+ _ -> {:error, :invalid_multikey}
215215+ end
216216+217217+ @spec parse_multicodec(binary()) ::
218218+ {:ok, String.t(), binary()} | {:error, :invalid_multikey}
219219+ defp parse_multicodec(raw) do
220220+ {codec_meta, key_bytes} = Multicodec.parse_prefix(raw)
221221+222222+ if is_nil(codec_meta) do
223223+ {:error, :invalid_multikey}
224224+ else
225225+ {:ok, codec_meta[:name], key_bytes}
226226+ end
227227+ rescue
228228+ _ -> {:error, :invalid_multikey}
229229+ end
230230+231231+ @spec curve_params_for_codec(String.t()) :: {:ok, map()} | {:error, :unsupported_curve}
232232+ defp curve_params_for_codec("p256-pub") do
233233+ {:ok,
234234+ %{
235235+ p: @p256_p,
236236+ a: @p256_a,
237237+ b: @p256_b,
238238+ n: @p256_n,
239239+ oid: @p256_oid,
240240+ jwk_crv: "P-256"
241241+ }}
242242+ end
243243+244244+ defp curve_params_for_codec("secp256k1-pub") do
245245+ {:ok,
246246+ %{
247247+ p: @k256_p,
248248+ a: @k256_a,
249249+ b: @k256_b,
250250+ n: @k256_n,
251251+ oid: @k256_oid,
252252+ jwk_crv: "secp256k1"
253253+ }}
254254+ end
255255+256256+ defp curve_params_for_codec(_), do: {:error, :unsupported_curve}
257257+258258+ # Decompress a 33-byte EC point into separate x and y byte strings (32 bytes each).
259259+ # Both P-256 and secp256k1 have field primes p ≡ 3 (mod 4), so the modular
260260+ # square root is: y = rhs^((p+1)/4) mod p.
261261+ @spec decompress_point(binary(), map()) ::
262262+ {:ok, binary(), binary()} | {:error, :invalid_point}
263263+ defp decompress_point(<<prefix, x_bytes::binary-size(32)>>, %{
264264+ p: p,
265265+ a: a,
266266+ b: b
267267+ })
268268+ when prefix in [0x02, 0x03] do
269269+ x = :binary.decode_unsigned(x_bytes)
270270+ rhs = Integer.mod(x * x * x + a * x + b, p)
271271+ exp = div(p + 1, 4)
272272+ y_candidate = :binary.decode_unsigned(:crypto.mod_pow(rhs, exp, p))
273273+274274+ # Select the candidate matching the parity bit encoded in the prefix.
275275+ # prefix 0x02 = even y, 0x03 = odd y.
276276+ expected_parity = prefix - 2
277277+278278+ y =
279279+ if rem(y_candidate, 2) == expected_parity do
280280+ y_candidate
281281+ else
282282+ p - y_candidate
283283+ end
284284+285285+ y_bytes = pad_to_32(:binary.encode_unsigned(y))
286286+ {:ok, x_bytes, y_bytes}
287287+ end
288288+289289+ defp decompress_point(_, _), do: {:error, :invalid_point}
290290+291291+ @spec pad_to_32(binary()) :: binary()
292292+ defp pad_to_32(bin) do
293293+ padding = 32 - byte_size(bin)
294294+ :binary.copy(<<0>>, padding) <> bin
295295+ end
296296+297297+ @spec public_jwk_map(JOSE.JWK.t()) :: {:ok, map()} | {:error, :unsupported_key}
298298+ defp public_jwk_map(jwk) do
299299+ {_, map} = jwk |> JOSE.JWK.to_public() |> JOSE.JWK.to_map()
300300+301301+ if map["kty"] == "EC" and map["crv"] in ["P-256", "secp256k1"] do
302302+ {:ok, map}
303303+ else
304304+ {:error, :unsupported_key}
305305+ end
306306+ rescue
307307+ _ -> {:error, :unsupported_key}
308308+ end
309309+310310+ @spec codec_name_for_crv(String.t()) :: {:ok, String.t()} | {:error, :unsupported_curve}
311311+ defp codec_name_for_crv("P-256"), do: {:ok, "p256-pub"}
312312+ defp codec_name_for_crv("secp256k1"), do: {:ok, "secp256k1-pub"}
313313+ defp codec_name_for_crv(_), do: {:error, :unsupported_curve}
314314+315315+ @spec decode_jwk_coord(String.t() | nil) :: {:ok, binary()} | {:error, :invalid_key}
316316+ defp decode_jwk_coord(nil), do: {:error, :invalid_key}
317317+318318+ defp decode_jwk_coord(b64url) do
319319+ {:ok, Base.url_decode64!(b64url, padding: false)}
320320+ rescue
321321+ _ -> {:error, :invalid_key}
322322+ end
323323+324324+ @spec oid_to_curve(tuple()) :: {:ok, atom()} | {:error, :unsupported_curve}
325325+ defp oid_to_curve(@p256_oid), do: {:ok, :secp256r1}
326326+ defp oid_to_curve(@k256_oid), do: {:ok, :secp256k1}
327327+ defp oid_to_curve(_), do: {:error, :unsupported_curve}
328328+329329+ @spec curve_order_for_oid(tuple()) :: {:ok, pos_integer()} | {:error, :unsupported_curve}
330330+ defp curve_order_for_oid(@p256_oid), do: {:ok, @p256_n}
331331+ defp curve_order_for_oid(@k256_oid), do: {:ok, @k256_n}
332332+ defp curve_order_for_oid(_), do: {:error, :unsupported_curve}
333333+334334+ # Normalise an ECDSA DER signature to low-S form.
335335+ # If s > n/2, replace s with n - s and re-encode the DER sequence.
336336+ @spec normalize_low_s(binary(), pos_integer()) :: binary()
337337+ defp normalize_low_s(der_sig, curve_order) do
338338+ with {:ok, r_bin, s_bin} <- parse_der_ecdsa(der_sig) do
339339+ s = :binary.decode_unsigned(s_bin)
340340+341341+ if s <= div(curve_order, 2) do
342342+ der_sig
343343+ else
344344+ new_s = curve_order - s
345345+ new_s_bin = :binary.encode_unsigned(new_s)
346346+ encode_der_ecdsa(r_bin, new_s_bin)
347347+ end
348348+ else
349349+ _ -> der_sig
350350+ end
351351+ end
352352+353353+ # Parse a DER-encoded ECDSA signature: SEQUENCE { INTEGER r, INTEGER s }
354354+ @spec parse_der_ecdsa(binary()) :: {:ok, binary(), binary()} | :error
355355+ defp parse_der_ecdsa(
356356+ <<0x30, _seq_len, 0x02, r_len, r::binary-size(r_len), 0x02, s_len,
357357+ s::binary-size(s_len)>>
358358+ ) do
359359+ {:ok, r, s}
360360+ end
361361+362362+ defp parse_der_ecdsa(_), do: :error
363363+364364+ # Re-encode r and s as a DER SEQUENCE { INTEGER r, INTEGER s }.
365365+ # DER INTEGER encoding requires a leading 0x00 byte when the high bit is set.
366366+ @spec encode_der_ecdsa(binary(), binary()) :: binary()
367367+ defp encode_der_ecdsa(r_bin, s_bin) do
368368+ r_der = der_integer(r_bin)
369369+ s_der = der_integer(s_bin)
370370+ seq_body = r_der <> s_der
371371+ <<0x30, byte_size(seq_body)>> <> seq_body
372372+ end
373373+374374+ @spec der_integer(binary()) :: binary()
375375+ defp der_integer(<<high, _::binary>> = bin) when high >= 0x80 do
376376+ payload = <<0x00>> <> bin
377377+ <<0x02, byte_size(payload)>> <> payload
378378+ end
379379+380380+ defp der_integer(bin) do
381381+ <<0x02, byte_size(bin)>> <> bin
382382+ end
383383+end
+274
test/atex/crypto_test.exs
···11+defmodule Atex.CryptoTest do
22+ use ExUnit.Case, async: true
33+ alias Atex.Crypto
44+ doctest Crypto
55+66+ # ---------------------------------------------------------------------------
77+ # AT Protocol spec example keys (from https://atproto.com/specs/cryptography)
88+ # ---------------------------------------------------------------------------
99+1010+ # P-256 compressed public key as multikey
1111+ @p256_multikey "zDnaembgSGUhZULN2Caob4HLJPaxBh92N7rtH21TErzqf8HQo"
1212+ # secp256k1 compressed public key as multikey
1313+ @k256_multikey "zQ3shqwJEJyMBsBXCWyCBpUBMqxcon9oHB7mCvx4sSpMdLJwc"
1414+1515+ # ---------------------------------------------------------------------------
1616+ # decode_did_key/1
1717+ # ---------------------------------------------------------------------------
1818+1919+ describe "decode_did_key/1" do
2020+ test "decodes a P-256 multikey into a JOSE JWK" do
2121+ assert {:ok, jwk} = Crypto.decode_did_key(@p256_multikey)
2222+ assert %JOSE.JWK{} = jwk
2323+ {_, map} = JOSE.JWK.to_map(jwk)
2424+ assert map["kty"] == "EC"
2525+ assert map["crv"] == "P-256"
2626+ assert is_binary(map["x"]) and map["x"] != ""
2727+ assert is_binary(map["y"]) and map["y"] != ""
2828+ end
2929+3030+ test "decodes a secp256k1 multikey into a JOSE JWK" do
3131+ assert {:ok, jwk} = Crypto.decode_did_key(@k256_multikey)
3232+ assert %JOSE.JWK{} = jwk
3333+ {_, map} = JOSE.JWK.to_map(jwk)
3434+ assert map["kty"] == "EC"
3535+ assert map["crv"] == "secp256k1"
3636+ end
3737+3838+ test "accepts a did:key: prefixed URI" do
3939+ assert {:ok, jwk_bare} = Crypto.decode_did_key(@p256_multikey)
4040+ assert {:ok, jwk_did} = Crypto.decode_did_key("did:key:" <> @p256_multikey)
4141+4242+ {_, map_bare} = JOSE.JWK.to_map(jwk_bare)
4343+ {_, map_did} = JOSE.JWK.to_map(jwk_did)
4444+ assert map_bare == map_did
4545+ end
4646+4747+ test "accepts a full did:key secp256k1 URI" do
4848+ assert {:ok, _jwk} = Crypto.decode_did_key("did:key:" <> @k256_multikey)
4949+ end
5050+5151+ test "returns an error for an invalid multikey string" do
5252+ assert {:error, :invalid_multikey} = Crypto.decode_did_key("not-a-valid-key")
5353+ end
5454+5555+ test "returns an error for an unsupported curve codec" do
5656+ # A well-formed multibase string that decodes to an unknown codec
5757+ # Encode some random bytes with a non-key multicodec prefix
5858+ raw = <<0x12, 32>> <> :crypto.strong_rand_bytes(32)
5959+ bad_key = Multiformats.Multibase.encode(raw, :base58btc)
6060+ assert {:error, _} = Crypto.decode_did_key(bad_key)
6161+ end
6262+ end
6363+6464+ # ---------------------------------------------------------------------------
6565+ # encode_did_key/1
6666+ # ---------------------------------------------------------------------------
6767+6868+ describe "encode_did_key/1" do
6969+ test "encodes a P-256 JWK back to the canonical multikey" do
7070+ {:ok, jwk} = Crypto.decode_did_key(@p256_multikey)
7171+ assert {:ok, encoded} = Crypto.encode_did_key(jwk)
7272+ assert encoded == @p256_multikey
7373+ end
7474+7575+ test "encodes a secp256k1 JWK back to the canonical multikey" do
7676+ {:ok, jwk} = Crypto.decode_did_key(@k256_multikey)
7777+ assert {:ok, encoded} = Crypto.encode_did_key(jwk)
7878+ assert encoded == @k256_multikey
7979+ end
8080+8181+ test "produces a z-prefixed multibase string" do
8282+ jwk = JOSE.JWK.generate_key({:ec, "P-256"})
8383+ assert {:ok, encoded} = Crypto.encode_did_key(jwk)
8484+ assert String.starts_with?(encoded, "z")
8585+ end
8686+8787+ test "strips private key component before encoding" do
8888+ priv_jwk = JOSE.JWK.generate_key({:ec, "P-256"})
8989+ pub_jwk = JOSE.JWK.to_public(priv_jwk)
9090+9191+ assert {:ok, from_priv} = Crypto.encode_did_key(priv_jwk)
9292+ assert {:ok, from_pub} = Crypto.encode_did_key(pub_jwk)
9393+ assert from_priv == from_pub
9494+ end
9595+9696+ test "as_did_key: false (default) returns bare multikey" do
9797+ jwk = JOSE.JWK.generate_key({:ec, "P-256"})
9898+ assert {:ok, mk} = Crypto.encode_did_key(jwk)
9999+ refute String.starts_with?(mk, "did:key:")
100100+ end
101101+102102+ test "as_did_key: true returns a did:key URI" do
103103+ {:ok, jwk} = Crypto.decode_did_key(@p256_multikey)
104104+ assert {:ok, did_key} = Crypto.encode_did_key(jwk, as_did_key: true)
105105+ assert did_key == "did:key:" <> @p256_multikey
106106+ end
107107+108108+ test "as_did_key: true works for secp256k1" do
109109+ {:ok, jwk} = Crypto.decode_did_key(@k256_multikey)
110110+ assert {:ok, did_key} = Crypto.encode_did_key(jwk, as_did_key: true)
111111+ assert did_key == "did:key:" <> @k256_multikey
112112+ end
113113+ end
114114+115115+ # ---------------------------------------------------------------------------
116116+ # encode_did_key/decode_did_key round-trip
117117+ # ---------------------------------------------------------------------------
118118+119119+ describe "encode_did_key/decode_did_key round-trip" do
120120+ test "round-trips a freshly generated P-256 key" do
121121+ jwk = JOSE.JWK.generate_key({:ec, "P-256"}) |> JOSE.JWK.to_public()
122122+ assert {:ok, mk} = Crypto.encode_did_key(jwk)
123123+ assert {:ok, jwk2} = Crypto.decode_did_key(mk)
124124+125125+ {_, orig_map} = JOSE.JWK.to_map(jwk)
126126+ {_, decoded_map} = JOSE.JWK.to_map(jwk2)
127127+ assert orig_map == decoded_map
128128+ end
129129+130130+ test "round-trips a freshly generated secp256k1 key" do
131131+ jwk = JOSE.JWK.generate_key({:ec, "secp256k1"}) |> JOSE.JWK.to_public()
132132+ assert {:ok, mk} = Crypto.encode_did_key(jwk)
133133+ assert {:ok, jwk2} = Crypto.decode_did_key(mk)
134134+135135+ {_, orig_map} = JOSE.JWK.to_map(jwk)
136136+ {_, decoded_map} = JOSE.JWK.to_map(jwk2)
137137+ assert orig_map == decoded_map
138138+ end
139139+ end
140140+141141+ # ---------------------------------------------------------------------------
142142+ # sign/2
143143+ # ---------------------------------------------------------------------------
144144+145145+ describe "sign/2" do
146146+ test "returns a DER-encoded binary for a P-256 key" do
147147+ priv = JOSE.JWK.generate_key({:ec, "P-256"})
148148+ assert {:ok, sig} = Crypto.sign("payload", priv)
149149+ assert is_binary(sig)
150150+ # DER SEQUENCE tag
151151+ assert <<0x30, _::binary>> = sig
152152+ end
153153+154154+ test "returns a DER-encoded binary for a secp256k1 key" do
155155+ priv = JOSE.JWK.generate_key({:ec, "secp256k1"})
156156+ assert {:ok, sig} = Crypto.sign("payload", priv)
157157+ assert is_binary(sig)
158158+ assert <<0x30, _::binary>> = sig
159159+ end
160160+161161+ test "produces a low-S signature for P-256" do
162162+ p256_n = 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551
163163+ priv = JOSE.JWK.generate_key({:ec, "P-256"})
164164+165165+ Enum.each(1..20, fn _ ->
166166+ {:ok, sig} = Crypto.sign("test", priv)
167167+168168+ <<0x30, _tl, 0x02, r_len, _r::binary-size(r_len), 0x02, s_len, s::binary-size(s_len)>> =
169169+ sig
170170+171171+ s_int = :binary.decode_unsigned(s)
172172+ assert s_int <= div(p256_n, 2), "expected low-S but got high-S"
173173+ end)
174174+ end
175175+176176+ test "produces a low-S signature for secp256k1" do
177177+ k256_n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
178178+ priv = JOSE.JWK.generate_key({:ec, "secp256k1"})
179179+180180+ Enum.each(1..20, fn _ ->
181181+ {:ok, sig} = Crypto.sign("test", priv)
182182+183183+ <<0x30, _tl, 0x02, r_len, _r::binary-size(r_len), 0x02, s_len, s::binary-size(s_len)>> =
184184+ sig
185185+186186+ s_int = :binary.decode_unsigned(s)
187187+ assert s_int <= div(k256_n, 2), "expected low-S but got high-S"
188188+ end)
189189+ end
190190+ end
191191+192192+ # ---------------------------------------------------------------------------
193193+ # verify/3
194194+ # ---------------------------------------------------------------------------
195195+196196+ describe "verify/3" do
197197+ test "returns :ok for a valid P-256 signature" do
198198+ priv = JOSE.JWK.generate_key({:ec, "P-256"})
199199+ pub = JOSE.JWK.to_public(priv)
200200+ {:ok, sig} = Crypto.sign("hello world", priv)
201201+202202+ assert :ok = Crypto.verify("hello world", sig, pub)
203203+ end
204204+205205+ test "returns :ok for a valid secp256k1 signature" do
206206+ priv = JOSE.JWK.generate_key({:ec, "secp256k1"})
207207+ pub = JOSE.JWK.to_public(priv)
208208+ {:ok, sig} = Crypto.sign("hello world", priv)
209209+210210+ assert :ok = Crypto.verify("hello world", sig, pub)
211211+ end
212212+213213+ test "returns :ok when verifying against the private key directly" do
214214+ priv = JOSE.JWK.generate_key({:ec, "P-256"})
215215+ {:ok, sig} = Crypto.sign("payload", priv)
216216+217217+ assert :ok = Crypto.verify("payload", sig, priv)
218218+ end
219219+220220+ test "returns {:error, :invalid_signature} for a tampered payload" do
221221+ priv = JOSE.JWK.generate_key({:ec, "P-256"})
222222+ pub = JOSE.JWK.to_public(priv)
223223+ {:ok, sig} = Crypto.sign("original", priv)
224224+225225+ assert {:error, :invalid_signature} = Crypto.verify("tampered", sig, pub)
226226+ end
227227+228228+ test "returns {:error, :invalid_signature} for a wrong key" do
229229+ priv_a = JOSE.JWK.generate_key({:ec, "P-256"})
230230+ priv_b = JOSE.JWK.generate_key({:ec, "P-256"})
231231+ pub_b = JOSE.JWK.to_public(priv_b)
232232+ {:ok, sig} = Crypto.sign("payload", priv_a)
233233+234234+ assert {:error, :invalid_signature} = Crypto.verify("payload", sig, pub_b)
235235+ end
236236+237237+ test "returns {:error, :invalid_signature} for a corrupted signature" do
238238+ priv = JOSE.JWK.generate_key({:ec, "P-256"})
239239+ pub = JOSE.JWK.to_public(priv)
240240+ {:ok, sig} = Crypto.sign("payload", priv)
241241+242242+ corrupted = :binary.replace(sig, <<0x02>>, <<0x00>>, [:global])
243243+ assert {:error, :invalid_signature} = Crypto.verify("payload", corrupted, pub)
244244+ end
245245+246246+ test "verifies a signature produced from a decoded multikey" do
247247+ # Simulate the atproto flow: decode a public key from a DID document,
248248+ # then verify a payload signed by whoever controls that key.
249249+ priv = JOSE.JWK.generate_key({:ec, "P-256"})
250250+ {:ok, mk} = Crypto.encode_did_key(priv)
251251+ {:ok, decoded_pub} = Crypto.decode_did_key(mk)
252252+253253+ {:ok, sig} = Crypto.sign("atproto payload", priv)
254254+ assert :ok = Crypto.verify("atproto payload", sig, decoded_pub)
255255+ end
256256+ end
257257+258258+ # ---------------------------------------------------------------------------
259259+ # sign/verify symmetry across curves
260260+ # ---------------------------------------------------------------------------
261261+262262+ describe "sign/verify symmetry" do
263263+ for curve <- ["P-256", "secp256k1"] do
264264+ test "#{curve}: sign then verify succeeds" do
265265+ priv = JOSE.JWK.generate_key({:ec, unquote(curve)})
266266+ pub = JOSE.JWK.to_public(priv)
267267+ payload = :crypto.strong_rand_bytes(128)
268268+269269+ {:ok, sig} = Crypto.sign(payload, priv)
270270+ assert :ok = Crypto.verify(payload, sig, pub)
271271+ end
272272+ end
273273+ end
274274+end