Packages

Aether ATProto is a set of common & shared logic to implement the AT Protocol

Current section

Files

Jump to
aether_atproto lib aether atproto crypto dpop.ex
Raw

lib/aether/atproto/crypto/dpop.ex

defmodule Aether.ATProto.Crypto.DPoP do
@moduledoc """
DPoP (Demonstrating Proof of Possession) implementation for AT Protocol OAuth.
Handles generation of DPoP proofs and keys according to RFC 9449.
"""
@doc """
Generates a new ES256 key pair for DPoP.
"""
def generate_key do
jwk = JOSE.JWK.generate_key({:ec, "P-256"})
JOSE.JWK.to_map(jwk) |> elem(1)
end
@doc """
Calculates the JWK thumbprint (jkt) for a DPoP key.
Used to verify the access token's cnf claim matches the DPoP proof.
"""
def calculate_jkt(dpop_key) do
dpop_key
|> JOSE.JWK.from_map()
|> JOSE.JWK.thumbprint()
end
@doc """
Generates a DPoP proof JWT.
## Parameters
- method: HTTP method (e.g., "GET", "POST")
- url: Target URL
- dpop_key: JWK map for signing
- nonce: Optional nonce from authorization server
- access_token: Optional access token (for resource requests)
"""
def generate_proof(method, url, dpop_key, nonce \\ nil, access_token \\ nil) do
jwk = JOSE.JWK.from_map(dpop_key)
header = %{
"typ" => "dpop+jwt",
"alg" => "ES256",
"jwk" => Map.take(dpop_key, ["kty", "crv", "x", "y"])
}
claims =
%{
"htm" => method,
"htu" => url,
"jti" => generate_jti(),
"iat" => System.system_time(:second)
}
|> maybe_add_nonce(nonce)
|> maybe_add_ath(access_token)
{_modules, jwt} = JOSE.JWT.sign(jwk, header, claims)
JOSE.JWS.compact(jwt) |> elem(1)
end
defp maybe_add_nonce(claims, nil), do: claims
defp maybe_add_nonce(claims, nonce), do: Map.put(claims, "nonce", nonce)
defp maybe_add_ath(claims, nil), do: claims
defp maybe_add_ath(claims, access_token) do
ath = :crypto.hash(:sha256, access_token) |> Base.url_encode64(padding: false)
Map.put(claims, "ath", ath)
end
defp generate_jti do
:crypto.strong_rand_bytes(16) |> Base.url_encode64(padding: false)
end
@doc """
Verifies a DPoP proof JWT (server-side validation).
## Parameters
* dpop_jwt: The DPoP proof JWT from the client
* method: Expected HTTP method (e.g., "POST")
* url: Expected target URL
* access_token: Optional access token to verify ath claim
## Returns
* `{:ok, dpop_key}` - The JWK from the proof if valid
* `{:error, reason}` - If verification fails
"""
def verify_proof(dpop_jwt, method, url, access_token \\ nil) do
with {:ok, header, claims} <- decode_and_parse(dpop_jwt),
:ok <- verify_typ(header),
{:ok, jwk} <- extract_jwk(header),
:ok <- verify_signature(dpop_jwt, jwk),
:ok <- verify_claims(claims, method, url, access_token) do
# Return the JWK map (not the JOSE.JWK struct)
{:ok, JOSE.JWK.to_map(jwk) |> elem(1)}
end
end
@doc """
Extracts the JWK thumbprint from a DPoP proof without full verification.
Useful for quick binding checks.
"""
def extract_jkt(dpop_jwt) do
with {:ok, header, _claims} <- decode_and_parse(dpop_jwt),
{:ok, jwk} <- extract_jwk(header) do
{:ok, calculate_jkt(JOSE.JWK.to_map(jwk) |> elem(1))}
end
end
# Private verification functions
defp decode_and_parse(dpop_jwt) do
case String.split(dpop_jwt, ".") do
[header_b64, payload_b64, _sig_b64] ->
with {:ok, header_json} <- Base.url_decode64(header_b64, padding: false),
{:ok, header} <- Jason.decode(header_json),
{:ok, payload_json} <- Base.url_decode64(payload_b64, padding: false),
{:ok, claims} <- Jason.decode(payload_json) do
{:ok, header, claims}
else
_ -> {:error, :invalid_jwt_format}
end
_ ->
{:error, :invalid_jwt_format}
end
end
defp verify_typ(%{"typ" => "dpop+jwt"}), do: :ok
defp verify_typ(_), do: {:error, :invalid_typ}
defp extract_jwk(%{"jwk" => jwk_map}) when is_map(jwk_map) do
# Reconstruct JWK from the header
try do
{:ok, JOSE.JWK.from_map(jwk_map)}
rescue
_ -> {:error, :invalid_jwk}
end
end
defp extract_jwk(_), do: {:error, :missing_jwk}
defp verify_signature(dpop_jwt, jwk) do
case JOSE.JWT.verify_strict(jwk, ["ES256"], dpop_jwt) do
{true, _jwt, _jws} -> :ok
{false, _, _} -> {:error, :invalid_signature}
end
end
defp verify_claims(claims, method, url, access_token) do
with :ok <- verify_htm(claims, method),
:ok <- verify_htu(claims, url),
:ok <- verify_iat(claims),
:ok <- verify_jti(claims),
:ok <- verify_ath(claims, access_token) do
:ok
end
end
defp verify_htm(%{"htm" => htm}, expected_method) when htm == expected_method, do: :ok
defp verify_htm(_, _), do: {:error, :htm_mismatch}
defp verify_htu(%{"htu" => htu}, expected_url) when htu == expected_url, do: :ok
defp verify_htu(_, _), do: {:error, :htu_mismatch}
defp verify_iat(%{"iat" => iat}) when is_integer(iat) do
now = System.system_time(:second)
# Accept timestamps within 60 seconds (past or future for clock skew)
if abs(now - iat) < 60 do
:ok
else
{:error, :invalid_timestamp}
end
end
defp verify_iat(_), do: {:error, :missing_iat}
defp verify_jti(%{"jti" => jti}) when is_binary(jti) and byte_size(jti) > 0, do: :ok
defp verify_jti(_), do: {:error, :missing_jti}
defp verify_ath(_claims, nil) do
# If no access token expected, ath should not be present (token endpoint)
# or can be present (resource server)
:ok
end
defp verify_ath(%{"ath" => ath}, access_token) when is_binary(ath) do
expected_ath =
:crypto.hash(:sha256, access_token)
|> Base.url_encode64(padding: false)
if ath == expected_ath do
:ok
else
{:error, :invalid_ath}
end
end
defp verify_ath(_, access_token) when is_binary(access_token) do
# Access token provided but no ath claim
{:error, :missing_ath}
end
defp verify_ath(_, _), do: :ok
end