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

feat: crypto implementation

ovyerus.com bf7ce3f6 15d582e7

verified
+658
+1
CHANGELOG.md
··· 36 37 - Fix a problem where generated `%<LexiconId>.Params` structs could not be 38 passed to an XRPC call due to not having the Enumerable protocol implemented. 39 40 ## [0.7.1] - 2026-02-06 41
··· 36 37 - Fix a problem where generated `%<LexiconId>.Params` structs could not be 38 passed to an XRPC call due to not having the Enumerable protocol implemented. 39 + - Add `Atex.Crypto` module for performing AT Protocol-related cryptographic operations. 40 41 ## [0.7.1] - 2026-02-06 42
+383
lib/atex/crypto.ex
···
··· 1 + defmodule Atex.Crypto do 2 + @moduledoc """ 3 + Cryptographic operations for the AT Protocol. 4 + 5 + Supports the two elliptic curves required by atproto: 6 + 7 + - `p256` - NIST P-256 / secp256r1 (JWK curve `"P-256"`) 8 + - `k256` - secp256k1 (JWK curve `"secp256k1"`) 9 + 10 + ## Key encoding 11 + 12 + Public keys are represented as `JOSE.JWK` structs throughout this module. 13 + The multikey / `did:key` encoding used in DID documents is the canonical 14 + external representation: a base58btc-encoded (multibase `z` prefix) binary 15 + consisting of a varint multicodec prefix followed by the 33-byte compressed 16 + EC point. 17 + 18 + ## Signing and verification 19 + 20 + Signatures are DER-encoded ECDSA byte sequences as produced by Erlang's 21 + `:public_key` application. All produced signatures are normalised to the 22 + low-S form required by the atproto specification. 23 + """ 24 + 25 + alias Multiformats.{Multibase, Multicodec} 26 + 27 + # Curve parameters 28 + 29 + # P-256 (secp256r1 / prime256v1) 30 + @p256_p 0xFFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF 31 + @p256_a @p256_p - 3 32 + @p256_b 0x5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B 33 + @p256_n 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551 34 + @p256_oid {1, 2, 840, 10045, 3, 1, 7} 35 + 36 + # secp256k1 37 + @k256_p 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F 38 + @k256_a 0 39 + @k256_b 7 40 + @k256_n 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 41 + @k256_oid {1, 3, 132, 0, 10} 42 + 43 + @typedoc """ 44 + A multikey-encoded public key string, optionally prefixed with `did:key:`. 45 + 46 + Examples: 47 + 48 + - `"zDnaembgSGUhZULN2Caob4HLJPaxBh92N7rtH21TErzqf8HQo"` (P-256 multikey) 49 + - `"did:key:zQ3shqwJEJyMBsBXCWyCBpUBMqxcon9oHB7mCvx4sSpMdLJwc"` (K-256 did:key) 50 + """ 51 + @type multikey :: String.t() 52 + 53 + @doc """ 54 + Decodes a multikey or `did:key` string into a `JOSE.JWK` public key struct. 55 + 56 + Accepts both bare multikey strings (e.g. `"z..."`) and full `did:key` URIs 57 + (e.g. `"did:key:z..."`). Supports P-256 (`p256-pub`) and secp256k1 58 + (`secp256k1-pub`) keys. 59 + 60 + ## Examples 61 + 62 + iex> {:ok, jwk} = Atex.Crypto.decode_did_key("zDnaembgSGUhZULN2Caob4HLJPaxBh92N7rtH21TErzqf8HQo") 63 + iex> match?(%JOSE.JWK{}, jwk) 64 + true 65 + 66 + iex> {:ok, jwk} = Atex.Crypto.decode_did_key("did:key:zQ3shqwJEJyMBsBXCWyCBpUBMqxcon9oHB7mCvx4sSpMdLJwc") 67 + iex> match?(%JOSE.JWK{}, jwk) 68 + true 69 + 70 + iex> Atex.Crypto.decode_did_key("not-a-valid-key") 71 + {:error, :invalid_multikey} 72 + """ 73 + @spec decode_did_key(multikey()) :: {:ok, JOSE.JWK.t()} | {:error, term()} 74 + def decode_did_key(input) when is_binary(input) do 75 + multikey = strip_did_key_prefix(input) 76 + 77 + with {:ok, raw} <- multibase_decode(multikey), 78 + {:ok, codec, compressed} <- parse_multicodec(raw), 79 + {:ok, curve_params} <- curve_params_for_codec(codec), 80 + {:ok, x_bytes, y_bytes} <- decompress_point(compressed, curve_params) do 81 + jwk = 82 + JOSE.JWK.from_map(%{ 83 + "kty" => "EC", 84 + "crv" => curve_params.jwk_crv, 85 + "x" => Base.url_encode64(x_bytes, padding: false), 86 + "y" => Base.url_encode64(y_bytes, padding: false) 87 + }) 88 + 89 + {:ok, jwk} 90 + end 91 + end 92 + 93 + @doc """ 94 + Encodes a `JOSE.JWK` public key as a multikey string. 95 + 96 + Accepts both public and private key JWKs; the private component is 97 + discarded. Supports P-256 and secp256k1 keys. 98 + 99 + ## Options 100 + 101 + - `:as_did_key` — when `true`, prepends the `did:key:` URI scheme to the 102 + returned string. Defaults to `false`. 103 + 104 + ## Examples 105 + 106 + iex> jwk = JOSE.JWK.generate_key({:ec, "P-256"}) 107 + iex> {:ok, mk} = Atex.Crypto.encode_did_key(jwk) 108 + iex> String.starts_with?(mk, "z") 109 + true 110 + 111 + iex> jwk = JOSE.JWK.generate_key({:ec, "P-256"}) 112 + iex> {:ok, mk} = Atex.Crypto.encode_did_key(jwk, as_did_key: true) 113 + iex> String.starts_with?(mk, "did:key:z") 114 + true 115 + """ 116 + @spec encode_did_key(JOSE.JWK.t(), keyword()) :: {:ok, multikey()} | {:error, term()} 117 + def encode_did_key(jwk, opts \\ []) do 118 + as_did_key = Keyword.get(opts, :as_did_key, false) 119 + 120 + with {:ok, map} <- public_jwk_map(jwk), 121 + {:ok, codec_name} <- codec_name_for_crv(map["crv"]), 122 + {:ok, x_bytes} <- decode_jwk_coord(map["x"]), 123 + {:ok, y_bytes} <- decode_jwk_coord(map["y"]) do 124 + prefix = if rem(:binary.last(y_bytes), 2) == 0, do: 0x02, else: 0x03 125 + compressed = <<prefix>> <> x_bytes 126 + prefixed = Multicodec.encode!(compressed, codec_name) 127 + multikey = Multibase.encode(prefixed, :base58btc) 128 + 129 + result = if as_did_key, do: "did:key:" <> multikey, else: multikey 130 + {:ok, result} 131 + end 132 + end 133 + 134 + @doc """ 135 + Verifies a DER-encoded ECDSA signature against a payload and a public key. 136 + 137 + The payload is hashed with SHA-256 internally before verification, matching 138 + the atproto signing convention. 139 + 140 + Returns `:ok` on success, or `{:error, :invalid_signature}` if the 141 + signature does not match. 142 + 143 + ## Examples 144 + 145 + iex> jwk = JOSE.JWK.generate_key({:ec, "P-256"}) 146 + iex> {:ok, sig} = Atex.Crypto.sign("hello", jwk) 147 + iex> Atex.Crypto.verify("hello", sig, JOSE.JWK.to_public(jwk)) 148 + :ok 149 + 150 + iex> jwk = JOSE.JWK.generate_key({:ec, "P-256"}) 151 + iex> {:ok, sig} = Atex.Crypto.sign("hello", jwk) 152 + iex> Atex.Crypto.verify("tampered", sig, JOSE.JWK.to_public(jwk)) 153 + {:error, :invalid_signature} 154 + """ 155 + @spec verify(payload :: binary(), signature :: binary(), public_key :: JOSE.JWK.t()) :: 156 + :ok | {:error, term()} 157 + def verify(payload, signature, public_key) when is_binary(payload) and is_binary(signature) do 158 + {_meta, pub_record} = JOSE.JWK.to_public_key(public_key) 159 + {:ECPoint, pub_bytes} = elem(pub_record, 0) 160 + {:namedCurve, oid} = elem(pub_record, 1) 161 + digest = :crypto.hash(:sha256, payload) 162 + 163 + with {:ok, curve} <- oid_to_curve(oid) do 164 + if :crypto.verify(:ecdsa, :sha256, {:digest, digest}, signature, [pub_bytes, curve]) do 165 + :ok 166 + else 167 + {:error, :invalid_signature} 168 + end 169 + end 170 + rescue 171 + _ -> {:error, :invalid_signature} 172 + end 173 + 174 + @doc """ 175 + Signs a payload with a private key, returning a low-S DER-encoded ECDSA 176 + signature. 177 + 178 + The payload is hashed with SHA-256 internally before signing, matching the 179 + atproto signing convention. 180 + 181 + ## Examples 182 + 183 + iex> jwk = JOSE.JWK.generate_key({:ec, "P-256"}) 184 + iex> {:ok, sig} = Atex.Crypto.sign("hello", jwk) 185 + iex> is_binary(sig) 186 + true 187 + """ 188 + @spec sign(payload :: binary(), private_key :: JOSE.JWK.t()) :: 189 + {:ok, binary()} | {:error, term()} 190 + def sign(payload, private_key) when is_binary(payload) do 191 + {_meta, priv_record} = JOSE.JWK.to_key(private_key) 192 + {:ECPrivateKey, _ver, priv_bytes, {:namedCurve, oid}, _pub, _} = priv_record 193 + digest = :crypto.hash(:sha256, payload) 194 + 195 + with {:ok, curve} <- oid_to_curve(oid), 196 + {:ok, curve_order} <- curve_order_for_oid(oid) do 197 + signature = :crypto.sign(:ecdsa, :sha256, {:digest, digest}, [priv_bytes, curve]) 198 + {:ok, normalize_low_s(signature, curve_order)} 199 + end 200 + rescue 201 + _ -> {:error, :sign_failed} 202 + end 203 + 204 + # Private helpers 205 + 206 + @spec strip_did_key_prefix(String.t()) :: String.t() 207 + defp strip_did_key_prefix("did:key:" <> rest), do: rest 208 + defp strip_did_key_prefix(input), do: input 209 + 210 + @spec multibase_decode(String.t()) :: {:ok, binary()} | {:error, :invalid_multikey} 211 + defp multibase_decode(multikey) do 212 + {:ok, Multibase.decode!(multikey)} 213 + rescue 214 + _ -> {:error, :invalid_multikey} 215 + end 216 + 217 + @spec parse_multicodec(binary()) :: 218 + {:ok, String.t(), binary()} | {:error, :invalid_multikey} 219 + defp parse_multicodec(raw) do 220 + {codec_meta, key_bytes} = Multicodec.parse_prefix(raw) 221 + 222 + if is_nil(codec_meta) do 223 + {:error, :invalid_multikey} 224 + else 225 + {:ok, codec_meta[:name], key_bytes} 226 + end 227 + rescue 228 + _ -> {:error, :invalid_multikey} 229 + end 230 + 231 + @spec curve_params_for_codec(String.t()) :: {:ok, map()} | {:error, :unsupported_curve} 232 + defp curve_params_for_codec("p256-pub") do 233 + {:ok, 234 + %{ 235 + p: @p256_p, 236 + a: @p256_a, 237 + b: @p256_b, 238 + n: @p256_n, 239 + oid: @p256_oid, 240 + jwk_crv: "P-256" 241 + }} 242 + end 243 + 244 + defp curve_params_for_codec("secp256k1-pub") do 245 + {:ok, 246 + %{ 247 + p: @k256_p, 248 + a: @k256_a, 249 + b: @k256_b, 250 + n: @k256_n, 251 + oid: @k256_oid, 252 + jwk_crv: "secp256k1" 253 + }} 254 + end 255 + 256 + defp curve_params_for_codec(_), do: {:error, :unsupported_curve} 257 + 258 + # Decompress a 33-byte EC point into separate x and y byte strings (32 bytes each). 259 + # Both P-256 and secp256k1 have field primes p ≡ 3 (mod 4), so the modular 260 + # square root is: y = rhs^((p+1)/4) mod p. 261 + @spec decompress_point(binary(), map()) :: 262 + {:ok, binary(), binary()} | {:error, :invalid_point} 263 + defp decompress_point(<<prefix, x_bytes::binary-size(32)>>, %{ 264 + p: p, 265 + a: a, 266 + b: b 267 + }) 268 + when prefix in [0x02, 0x03] do 269 + x = :binary.decode_unsigned(x_bytes) 270 + rhs = Integer.mod(x * x * x + a * x + b, p) 271 + exp = div(p + 1, 4) 272 + y_candidate = :binary.decode_unsigned(:crypto.mod_pow(rhs, exp, p)) 273 + 274 + # Select the candidate matching the parity bit encoded in the prefix. 275 + # prefix 0x02 = even y, 0x03 = odd y. 276 + expected_parity = prefix - 2 277 + 278 + y = 279 + if rem(y_candidate, 2) == expected_parity do 280 + y_candidate 281 + else 282 + p - y_candidate 283 + end 284 + 285 + y_bytes = pad_to_32(:binary.encode_unsigned(y)) 286 + {:ok, x_bytes, y_bytes} 287 + end 288 + 289 + defp decompress_point(_, _), do: {:error, :invalid_point} 290 + 291 + @spec pad_to_32(binary()) :: binary() 292 + defp pad_to_32(bin) do 293 + padding = 32 - byte_size(bin) 294 + :binary.copy(<<0>>, padding) <> bin 295 + end 296 + 297 + @spec public_jwk_map(JOSE.JWK.t()) :: {:ok, map()} | {:error, :unsupported_key} 298 + defp public_jwk_map(jwk) do 299 + {_, map} = jwk |> JOSE.JWK.to_public() |> JOSE.JWK.to_map() 300 + 301 + if map["kty"] == "EC" and map["crv"] in ["P-256", "secp256k1"] do 302 + {:ok, map} 303 + else 304 + {:error, :unsupported_key} 305 + end 306 + rescue 307 + _ -> {:error, :unsupported_key} 308 + end 309 + 310 + @spec codec_name_for_crv(String.t()) :: {:ok, String.t()} | {:error, :unsupported_curve} 311 + defp codec_name_for_crv("P-256"), do: {:ok, "p256-pub"} 312 + defp codec_name_for_crv("secp256k1"), do: {:ok, "secp256k1-pub"} 313 + defp codec_name_for_crv(_), do: {:error, :unsupported_curve} 314 + 315 + @spec decode_jwk_coord(String.t() | nil) :: {:ok, binary()} | {:error, :invalid_key} 316 + defp decode_jwk_coord(nil), do: {:error, :invalid_key} 317 + 318 + defp decode_jwk_coord(b64url) do 319 + {:ok, Base.url_decode64!(b64url, padding: false)} 320 + rescue 321 + _ -> {:error, :invalid_key} 322 + end 323 + 324 + @spec oid_to_curve(tuple()) :: {:ok, atom()} | {:error, :unsupported_curve} 325 + defp oid_to_curve(@p256_oid), do: {:ok, :secp256r1} 326 + defp oid_to_curve(@k256_oid), do: {:ok, :secp256k1} 327 + defp oid_to_curve(_), do: {:error, :unsupported_curve} 328 + 329 + @spec curve_order_for_oid(tuple()) :: {:ok, pos_integer()} | {:error, :unsupported_curve} 330 + defp curve_order_for_oid(@p256_oid), do: {:ok, @p256_n} 331 + defp curve_order_for_oid(@k256_oid), do: {:ok, @k256_n} 332 + defp curve_order_for_oid(_), do: {:error, :unsupported_curve} 333 + 334 + # Normalise an ECDSA DER signature to low-S form. 335 + # If s > n/2, replace s with n - s and re-encode the DER sequence. 336 + @spec normalize_low_s(binary(), pos_integer()) :: binary() 337 + defp normalize_low_s(der_sig, curve_order) do 338 + with {:ok, r_bin, s_bin} <- parse_der_ecdsa(der_sig) do 339 + s = :binary.decode_unsigned(s_bin) 340 + 341 + if s <= div(curve_order, 2) do 342 + der_sig 343 + else 344 + new_s = curve_order - s 345 + new_s_bin = :binary.encode_unsigned(new_s) 346 + encode_der_ecdsa(r_bin, new_s_bin) 347 + end 348 + else 349 + _ -> der_sig 350 + end 351 + end 352 + 353 + # Parse a DER-encoded ECDSA signature: SEQUENCE { INTEGER r, INTEGER s } 354 + @spec parse_der_ecdsa(binary()) :: {:ok, binary(), binary()} | :error 355 + defp parse_der_ecdsa( 356 + <<0x30, _seq_len, 0x02, r_len, r::binary-size(r_len), 0x02, s_len, 357 + s::binary-size(s_len)>> 358 + ) do 359 + {:ok, r, s} 360 + end 361 + 362 + defp parse_der_ecdsa(_), do: :error 363 + 364 + # Re-encode r and s as a DER SEQUENCE { INTEGER r, INTEGER s }. 365 + # DER INTEGER encoding requires a leading 0x00 byte when the high bit is set. 366 + @spec encode_der_ecdsa(binary(), binary()) :: binary() 367 + defp encode_der_ecdsa(r_bin, s_bin) do 368 + r_der = der_integer(r_bin) 369 + s_der = der_integer(s_bin) 370 + seq_body = r_der <> s_der 371 + <<0x30, byte_size(seq_body)>> <> seq_body 372 + end 373 + 374 + @spec der_integer(binary()) :: binary() 375 + defp der_integer(<<high, _::binary>> = bin) when high >= 0x80 do 376 + payload = <<0x00>> <> bin 377 + <<0x02, byte_size(payload)>> <> payload 378 + end 379 + 380 + defp der_integer(bin) do 381 + <<0x02, byte_size(bin)>> <> bin 382 + end 383 + end
+274
test/atex/crypto_test.exs
···
··· 1 + defmodule Atex.CryptoTest do 2 + use ExUnit.Case, async: true 3 + alias Atex.Crypto 4 + doctest Crypto 5 + 6 + # --------------------------------------------------------------------------- 7 + # AT Protocol spec example keys (from https://atproto.com/specs/cryptography) 8 + # --------------------------------------------------------------------------- 9 + 10 + # P-256 compressed public key as multikey 11 + @p256_multikey "zDnaembgSGUhZULN2Caob4HLJPaxBh92N7rtH21TErzqf8HQo" 12 + # secp256k1 compressed public key as multikey 13 + @k256_multikey "zQ3shqwJEJyMBsBXCWyCBpUBMqxcon9oHB7mCvx4sSpMdLJwc" 14 + 15 + # --------------------------------------------------------------------------- 16 + # decode_did_key/1 17 + # --------------------------------------------------------------------------- 18 + 19 + describe "decode_did_key/1" do 20 + test "decodes a P-256 multikey into a JOSE JWK" do 21 + assert {:ok, jwk} = Crypto.decode_did_key(@p256_multikey) 22 + assert %JOSE.JWK{} = jwk 23 + {_, map} = JOSE.JWK.to_map(jwk) 24 + assert map["kty"] == "EC" 25 + assert map["crv"] == "P-256" 26 + assert is_binary(map["x"]) and map["x"] != "" 27 + assert is_binary(map["y"]) and map["y"] != "" 28 + end 29 + 30 + test "decodes a secp256k1 multikey into a JOSE JWK" do 31 + assert {:ok, jwk} = Crypto.decode_did_key(@k256_multikey) 32 + assert %JOSE.JWK{} = jwk 33 + {_, map} = JOSE.JWK.to_map(jwk) 34 + assert map["kty"] == "EC" 35 + assert map["crv"] == "secp256k1" 36 + end 37 + 38 + test "accepts a did:key: prefixed URI" do 39 + assert {:ok, jwk_bare} = Crypto.decode_did_key(@p256_multikey) 40 + assert {:ok, jwk_did} = Crypto.decode_did_key("did:key:" <> @p256_multikey) 41 + 42 + {_, map_bare} = JOSE.JWK.to_map(jwk_bare) 43 + {_, map_did} = JOSE.JWK.to_map(jwk_did) 44 + assert map_bare == map_did 45 + end 46 + 47 + test "accepts a full did:key secp256k1 URI" do 48 + assert {:ok, _jwk} = Crypto.decode_did_key("did:key:" <> @k256_multikey) 49 + end 50 + 51 + test "returns an error for an invalid multikey string" do 52 + assert {:error, :invalid_multikey} = Crypto.decode_did_key("not-a-valid-key") 53 + end 54 + 55 + test "returns an error for an unsupported curve codec" do 56 + # A well-formed multibase string that decodes to an unknown codec 57 + # Encode some random bytes with a non-key multicodec prefix 58 + raw = <<0x12, 32>> <> :crypto.strong_rand_bytes(32) 59 + bad_key = Multiformats.Multibase.encode(raw, :base58btc) 60 + assert {:error, _} = Crypto.decode_did_key(bad_key) 61 + end 62 + end 63 + 64 + # --------------------------------------------------------------------------- 65 + # encode_did_key/1 66 + # --------------------------------------------------------------------------- 67 + 68 + describe "encode_did_key/1" do 69 + test "encodes a P-256 JWK back to the canonical multikey" do 70 + {:ok, jwk} = Crypto.decode_did_key(@p256_multikey) 71 + assert {:ok, encoded} = Crypto.encode_did_key(jwk) 72 + assert encoded == @p256_multikey 73 + end 74 + 75 + test "encodes a secp256k1 JWK back to the canonical multikey" do 76 + {:ok, jwk} = Crypto.decode_did_key(@k256_multikey) 77 + assert {:ok, encoded} = Crypto.encode_did_key(jwk) 78 + assert encoded == @k256_multikey 79 + end 80 + 81 + test "produces a z-prefixed multibase string" do 82 + jwk = JOSE.JWK.generate_key({:ec, "P-256"}) 83 + assert {:ok, encoded} = Crypto.encode_did_key(jwk) 84 + assert String.starts_with?(encoded, "z") 85 + end 86 + 87 + test "strips private key component before encoding" do 88 + priv_jwk = JOSE.JWK.generate_key({:ec, "P-256"}) 89 + pub_jwk = JOSE.JWK.to_public(priv_jwk) 90 + 91 + assert {:ok, from_priv} = Crypto.encode_did_key(priv_jwk) 92 + assert {:ok, from_pub} = Crypto.encode_did_key(pub_jwk) 93 + assert from_priv == from_pub 94 + end 95 + 96 + test "as_did_key: false (default) returns bare multikey" do 97 + jwk = JOSE.JWK.generate_key({:ec, "P-256"}) 98 + assert {:ok, mk} = Crypto.encode_did_key(jwk) 99 + refute String.starts_with?(mk, "did:key:") 100 + end 101 + 102 + test "as_did_key: true returns a did:key URI" do 103 + {:ok, jwk} = Crypto.decode_did_key(@p256_multikey) 104 + assert {:ok, did_key} = Crypto.encode_did_key(jwk, as_did_key: true) 105 + assert did_key == "did:key:" <> @p256_multikey 106 + end 107 + 108 + test "as_did_key: true works for secp256k1" do 109 + {:ok, jwk} = Crypto.decode_did_key(@k256_multikey) 110 + assert {:ok, did_key} = Crypto.encode_did_key(jwk, as_did_key: true) 111 + assert did_key == "did:key:" <> @k256_multikey 112 + end 113 + end 114 + 115 + # --------------------------------------------------------------------------- 116 + # encode_did_key/decode_did_key round-trip 117 + # --------------------------------------------------------------------------- 118 + 119 + describe "encode_did_key/decode_did_key round-trip" do 120 + test "round-trips a freshly generated P-256 key" do 121 + jwk = JOSE.JWK.generate_key({:ec, "P-256"}) |> JOSE.JWK.to_public() 122 + assert {:ok, mk} = Crypto.encode_did_key(jwk) 123 + assert {:ok, jwk2} = Crypto.decode_did_key(mk) 124 + 125 + {_, orig_map} = JOSE.JWK.to_map(jwk) 126 + {_, decoded_map} = JOSE.JWK.to_map(jwk2) 127 + assert orig_map == decoded_map 128 + end 129 + 130 + test "round-trips a freshly generated secp256k1 key" do 131 + jwk = JOSE.JWK.generate_key({:ec, "secp256k1"}) |> JOSE.JWK.to_public() 132 + assert {:ok, mk} = Crypto.encode_did_key(jwk) 133 + assert {:ok, jwk2} = Crypto.decode_did_key(mk) 134 + 135 + {_, orig_map} = JOSE.JWK.to_map(jwk) 136 + {_, decoded_map} = JOSE.JWK.to_map(jwk2) 137 + assert orig_map == decoded_map 138 + end 139 + end 140 + 141 + # --------------------------------------------------------------------------- 142 + # sign/2 143 + # --------------------------------------------------------------------------- 144 + 145 + describe "sign/2" do 146 + test "returns a DER-encoded binary for a P-256 key" do 147 + priv = JOSE.JWK.generate_key({:ec, "P-256"}) 148 + assert {:ok, sig} = Crypto.sign("payload", priv) 149 + assert is_binary(sig) 150 + # DER SEQUENCE tag 151 + assert <<0x30, _::binary>> = sig 152 + end 153 + 154 + test "returns a DER-encoded binary for a secp256k1 key" do 155 + priv = JOSE.JWK.generate_key({:ec, "secp256k1"}) 156 + assert {:ok, sig} = Crypto.sign("payload", priv) 157 + assert is_binary(sig) 158 + assert <<0x30, _::binary>> = sig 159 + end 160 + 161 + test "produces a low-S signature for P-256" do 162 + p256_n = 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551 163 + priv = JOSE.JWK.generate_key({:ec, "P-256"}) 164 + 165 + Enum.each(1..20, fn _ -> 166 + {:ok, sig} = Crypto.sign("test", priv) 167 + 168 + <<0x30, _tl, 0x02, r_len, _r::binary-size(r_len), 0x02, s_len, s::binary-size(s_len)>> = 169 + sig 170 + 171 + s_int = :binary.decode_unsigned(s) 172 + assert s_int <= div(p256_n, 2), "expected low-S but got high-S" 173 + end) 174 + end 175 + 176 + test "produces a low-S signature for secp256k1" do 177 + k256_n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 178 + priv = JOSE.JWK.generate_key({:ec, "secp256k1"}) 179 + 180 + Enum.each(1..20, fn _ -> 181 + {:ok, sig} = Crypto.sign("test", priv) 182 + 183 + <<0x30, _tl, 0x02, r_len, _r::binary-size(r_len), 0x02, s_len, s::binary-size(s_len)>> = 184 + sig 185 + 186 + s_int = :binary.decode_unsigned(s) 187 + assert s_int <= div(k256_n, 2), "expected low-S but got high-S" 188 + end) 189 + end 190 + end 191 + 192 + # --------------------------------------------------------------------------- 193 + # verify/3 194 + # --------------------------------------------------------------------------- 195 + 196 + describe "verify/3" do 197 + test "returns :ok for a valid P-256 signature" do 198 + priv = JOSE.JWK.generate_key({:ec, "P-256"}) 199 + pub = JOSE.JWK.to_public(priv) 200 + {:ok, sig} = Crypto.sign("hello world", priv) 201 + 202 + assert :ok = Crypto.verify("hello world", sig, pub) 203 + end 204 + 205 + test "returns :ok for a valid secp256k1 signature" do 206 + priv = JOSE.JWK.generate_key({:ec, "secp256k1"}) 207 + pub = JOSE.JWK.to_public(priv) 208 + {:ok, sig} = Crypto.sign("hello world", priv) 209 + 210 + assert :ok = Crypto.verify("hello world", sig, pub) 211 + end 212 + 213 + test "returns :ok when verifying against the private key directly" do 214 + priv = JOSE.JWK.generate_key({:ec, "P-256"}) 215 + {:ok, sig} = Crypto.sign("payload", priv) 216 + 217 + assert :ok = Crypto.verify("payload", sig, priv) 218 + end 219 + 220 + test "returns {:error, :invalid_signature} for a tampered payload" do 221 + priv = JOSE.JWK.generate_key({:ec, "P-256"}) 222 + pub = JOSE.JWK.to_public(priv) 223 + {:ok, sig} = Crypto.sign("original", priv) 224 + 225 + assert {:error, :invalid_signature} = Crypto.verify("tampered", sig, pub) 226 + end 227 + 228 + test "returns {:error, :invalid_signature} for a wrong key" do 229 + priv_a = JOSE.JWK.generate_key({:ec, "P-256"}) 230 + priv_b = JOSE.JWK.generate_key({:ec, "P-256"}) 231 + pub_b = JOSE.JWK.to_public(priv_b) 232 + {:ok, sig} = Crypto.sign("payload", priv_a) 233 + 234 + assert {:error, :invalid_signature} = Crypto.verify("payload", sig, pub_b) 235 + end 236 + 237 + test "returns {:error, :invalid_signature} for a corrupted signature" do 238 + priv = JOSE.JWK.generate_key({:ec, "P-256"}) 239 + pub = JOSE.JWK.to_public(priv) 240 + {:ok, sig} = Crypto.sign("payload", priv) 241 + 242 + corrupted = :binary.replace(sig, <<0x02>>, <<0x00>>, [:global]) 243 + assert {:error, :invalid_signature} = Crypto.verify("payload", corrupted, pub) 244 + end 245 + 246 + test "verifies a signature produced from a decoded multikey" do 247 + # Simulate the atproto flow: decode a public key from a DID document, 248 + # then verify a payload signed by whoever controls that key. 249 + priv = JOSE.JWK.generate_key({:ec, "P-256"}) 250 + {:ok, mk} = Crypto.encode_did_key(priv) 251 + {:ok, decoded_pub} = Crypto.decode_did_key(mk) 252 + 253 + {:ok, sig} = Crypto.sign("atproto payload", priv) 254 + assert :ok = Crypto.verify("atproto payload", sig, decoded_pub) 255 + end 256 + end 257 + 258 + # --------------------------------------------------------------------------- 259 + # sign/verify symmetry across curves 260 + # --------------------------------------------------------------------------- 261 + 262 + describe "sign/verify symmetry" do 263 + for curve <- ["P-256", "secp256k1"] do 264 + test "#{curve}: sign then verify succeeds" do 265 + priv = JOSE.JWK.generate_key({:ec, unquote(curve)}) 266 + pub = JOSE.JWK.to_public(priv) 267 + payload = :crypto.strong_rand_bytes(128) 268 + 269 + {:ok, sig} = Crypto.sign(payload, priv) 270 + assert :ok = Crypto.verify(payload, sig, pub) 271 + end 272 + end 273 + end 274 + end