From b84f720a66ff2e340512592dcbf5686159895fa6 Mon Sep 17 00:00:00 2001 From: Pascal Knoth Date: Thu, 14 Nov 2024 13:25:11 +0100 Subject: [PATCH] signatures interface / adapter with dependency injection --- lib/boruta/adapters/internal/signatures.ex | 216 ++++++++++++++++++ .../adapters/internal/signatures/key_pair.ex | 15 ++ lib/boruta/adapters/signatures.ex | 42 ++++ .../{utils => adapters}/token_generator.ex | 0 lib/boruta/config.ex | 12 +- lib/boruta/oauth/authorization.ex | 8 +- lib/boruta/oauth/contexts/signatures.ex | 19 ++ lib/boruta/oauth/request/base.ex | 2 +- lib/boruta/oauth/schemas/client.ex | 118 +--------- lib/boruta/openid.ex | 4 +- lib/boruta/openid/contexts/signatures.ex | 11 + .../{ => openid}/verifiable_credentials.ex | 130 +++++------ .../{ => openid}/verifiable_presentations.ex | 2 +- mix.exs | 2 +- test/boruta/oauth/schemas/id_token_test.exs | 1 + .../openid/integration/credential_test.exs | 2 +- .../openid/integration/direct_post_test.exs | 2 +- .../verifiable_credentials_test.exs | 108 +++++++-- .../verifiable_presentations_test.exs | 4 +- 19 files changed, 485 insertions(+), 213 deletions(-) create mode 100644 lib/boruta/adapters/internal/signatures.ex create mode 100644 lib/boruta/adapters/internal/signatures/key_pair.ex create mode 100644 lib/boruta/adapters/signatures.ex rename lib/boruta/{utils => adapters}/token_generator.ex (100%) create mode 100644 lib/boruta/oauth/contexts/signatures.ex create mode 100644 lib/boruta/openid/contexts/signatures.ex rename lib/boruta/{ => openid}/verifiable_credentials.ex (88%) rename lib/boruta/{ => openid}/verifiable_presentations.ex (99%) rename test/boruta/{ => openid}/verifiable_credentials_test.exs (87%) rename test/boruta/{ => openid}/verifiable_presentations_test.exs (99%) diff --git a/lib/boruta/adapters/internal/signatures.ex b/lib/boruta/adapters/internal/signatures.ex new file mode 100644 index 00000000..e8e236b7 --- /dev/null +++ b/lib/boruta/adapters/internal/signatures.ex @@ -0,0 +1,216 @@ +defmodule Boruta.Internal.Signatures do + @behaviour Boruta.Oauth.Signatures + + defmodule Token do + @moduledoc false + + use Joken.Config + + def token_config, do: %{} + end + + @moduledoc false + + alias Boruta.Internal.Signatures.KeyPair + alias Boruta.Oauth.Client + + @signature_algorithms [ + ES256: [type: :asymmetric, hash_algorithm: :SHA256, binary_size: 16], + ES384: [type: :asymmetric, hash_algorithm: :SHA384, binary_size: 24], + ES512: [type: :asymmetric, hash_algorithm: :SHA512, binary_size: 32], + RS256: [type: :asymmetric, hash_algorithm: :SHA256, binary_size: 16], + RS384: [type: :asymmetric, hash_algorithm: :SHA384, binary_size: 24], + RS512: [type: :asymmetric, hash_algorithm: :SHA512, binary_size: 32], + HS256: [type: :symmetric, hash_algorithm: :SHA256, binary_size: 16], + HS384: [type: :symmetric, hash_algorithm: :SHA384, binary_size: 24], + HS512: [type: :symmetric, hash_algorithm: :SHA512, binary_size: 32] + ] + + @spec signature_algorithms() :: list(atom()) + def signature_algorithms, do: Keyword.keys(@signature_algorithms) + + @spec hash_alg(Client.t()) :: hash_alg :: atom() + def hash_alg(%Client{id_token_signature_alg: signature_alg}), + do: @signature_algorithms[String.to_atom(signature_alg)][:hash_algorithm] + + @spec hash_binary_size(Client.t()) :: binary_size :: integer() + def hash_binary_size(%Client{id_token_signature_alg: signature_alg}), + do: @signature_algorithms[String.to_atom(signature_alg)][:binary_size] + + @spec hash(string :: String.t(), client :: Client.t()) :: hash :: String.t() + def hash(string, client) do + hash_alg(client) + |> Atom.to_string() + |> String.downcase() + |> String.to_atom() + |> :crypto.hash(string) + |> binary_part(0, hash_binary_size(client)) + |> Base.url_encode64(padding: false) + end + + @spec id_token_sign(payload :: map(), client :: Client.t()) :: + jwt :: String.t() | {:error, reason :: String.t()} + def id_token_sign( + payload, + %Client{ + id_token_signature_alg: signature_alg + } = client + ) do + with {:ok, signing_key} <- get_signing_key(client, :id_token) do + signer = + case id_token_signature_type(client) do + :symmetric -> + Joken.Signer.create(signature_alg, signing_key.secret) + + :asymmetric -> + Joken.Signer.create( + signature_alg, + %{"pem" => signing_key.private_key}, + %{ + "kid" => signing_key.kid, + "trust_chain" => signing_key.trust_chain + } + ) + end + + case Token.encode_and_sign(payload, signer) do + {:ok, token, _payload} -> + token + + {:error, error} -> + {:error, "Could not sign the given payload with client credentials: #{inspect(error)}"} + end + end + end + + @spec verify_id_token_signature(id_token :: String.t(), jwk :: JOSE.JWK.t()) :: + :ok | {:error, reason :: String.t()} + def verify_id_token_signature(id_token, jwk) do + case Joken.peek_header(id_token) do + {:ok, %{"alg" => alg}} -> + signer = Joken.Signer.create(alg, %{"pem" => JOSE.JWK.from_map(jwk) |> JOSE.JWK.to_pem()}) + + case Token.verify(id_token, signer) do + {:ok, claims} -> {:ok, claims} + {:error, reason} -> {:error, inspect(reason)} + end + + {:error, reason} -> + {:error, inspect(reason)} + end + end + + @spec userinfo_sign(payload :: map(), client :: Client.t()) :: + jwt :: String.t() | {:error, reason :: String.t()} + def userinfo_sign( + payload, + %Client{ + userinfo_signed_response_alg: signature_alg + } = client + ) do + with {:ok, signing_key} <- get_signing_key(client, :userinfo) do + signer = + case userinfo_signature_type(client) do + :symmetric -> + Joken.Signer.create(signature_alg, signing_key.secret) + + :asymmetric -> + Joken.Signer.create( + signature_alg, + %{"pem" => signing_key.private_key}, + %{ + "kid" => signing_key.kid, + "trust_chain" => signing_key.trust_chain + } + ) + end + + case Token.encode_and_sign(payload, signer) do + {:ok, token, _payload} -> + token + + {:error, error} -> + {:error, "Could not sign the given payload with client credentials: #{inspect(error)}"} + end + end + end + + @spec verifiable_credential_sign(payload :: map(), client :: Client.t()) :: + jwt :: String.t() | {:error, reason :: String.t()} + def verifiable_credential_sign( + payload, + %Client{ + id_token_signature_alg: signature_alg + } = client + ) do + with {:ok, signing_key} <- get_signing_key(client, :verifiable_credential) do + signer = + case id_token_signature_type(client) do + :symmetric -> + Joken.Signer.create(signature_alg, signing_key.secret) + + :asymmetric -> + Joken.Signer.create( + signature_alg, + %{"pem" => signing_key.private_key}, + %{ + "typ" => "JWT", + "kid" => signing_key.kid, + "trust_chain" => signing_key.trust_chain + } + ) + end + + case Token.encode_and_sign(payload, signer) do + {:ok, token, _payload} -> + token + + {:error, error} -> + {:error, "Could not sign the given payload with client credentials: #{inspect(error)}"} + end + end + end + + @spec kid_from_private_key(private_pem :: String.t()) :: kid :: String.t() + def kid_from_private_key(private_pem) do + :crypto.hash(:md5, private_pem) |> Base.url_encode64() |> String.slice(0..16) + end + + @spec userinfo_signature_type(Client.t()) :: userinfo_token_signature_type :: atom() + def userinfo_signature_type(%Client{userinfo_signed_response_alg: signature_alg}), + do: @signature_algorithms[String.to_atom(signature_alg)][:type] + + @spec id_token_signature_type(Client.t()) :: id_token_signature_type :: atom() + def id_token_signature_type(%Client{id_token_signature_alg: signature_alg}), + do: @signature_algorithms[String.to_atom(signature_alg)][:type] + + defp get_signing_key(client, :id_token) do + {:ok, + %KeyPair{ + type: :internal, + private_key: client.private_key, + secret: client.secret, + kid: client.id_token_kid || kid_from_private_key(client.private_key) + }} + end + + defp get_signing_key(client, :userinfo) do + {:ok, + %KeyPair{ + type: :internal, + private_key: client.private_key, + secret: client.secret, + kid: client.id + }} + end + + defp get_signing_key(client, :verifiable_credential) do + {:ok, + %KeyPair{ + type: :internal, + private_key: client.private_key, + secret: client.secret, + kid: client.did || client.id_token_kid || kid_from_private_key(client.private_key) + }} + end +end diff --git a/lib/boruta/adapters/internal/signatures/key_pair.ex b/lib/boruta/adapters/internal/signatures/key_pair.ex new file mode 100644 index 00000000..3f2e271c --- /dev/null +++ b/lib/boruta/adapters/internal/signatures/key_pair.ex @@ -0,0 +1,15 @@ +defmodule Boruta.Internal.Signatures.KeyPair do + @moduledoc false + + @enforce_keys [:type] + defstruct [:type, :kid, :public_key, :private_key, :secret, :trust_chain] + + @type t :: %__MODULE__{ + type: :external | :internal, + public_key: String.t() | nil, + private_key: String.t() | nil, + kid: String.t() | nil, + secret: String.t() | nil, + trust_chain: list(String.t()) | nil + } +end diff --git a/lib/boruta/adapters/signatures.ex b/lib/boruta/adapters/signatures.ex new file mode 100644 index 00000000..de476742 --- /dev/null +++ b/lib/boruta/adapters/signatures.ex @@ -0,0 +1,42 @@ +defmodule Boruta.SignaturesAdapter do + @moduledoc """ + Encapsulate injected `Boruta.Oauth.Signatures` and `Boruta.Openid.Signatures` adapter in context configuration + """ + + @behaviour Boruta.Oauth.Signatures + @behaviour Boruta.Openid.Signatures + + import Boruta.Config, only: [signatures: 0] + + @impl Boruta.Oauth.Signatures + def signature_algorithms, do: signatures().signature_algorithms() + + @impl Boruta.Oauth.Signatures + def hash_alg(client), do: signatures().hash_alg(client) + + @impl Boruta.Oauth.Signatures + def hash_binary_size(client), do: signatures().hash_binary_size(client) + + @impl Boruta.Oauth.Signatures + def hash(string, client), do: signatures().hash(string, client) + + @impl Boruta.Oauth.Signatures + def id_token_sign(payload, client), do: signatures().id_token_sign(payload, client) + + @impl Boruta.Oauth.Signatures + def verify_id_token_signature(id_token, jwk), + do: signatures().verify_id_token_signature(id_token, jwk) + + @impl Boruta.Oauth.Signatures + def userinfo_sign(payload, client), do: signatures().userinfo_sign(payload, client) + + @impl Boruta.Openid.Signatures + def verifiable_credential_sign(payload, client), + do: signatures().verifiable_credential_sign(payload, client) + + @impl Boruta.Oauth.Signatures + def kid_from_private_key(private_pem), do: signatures().kid_from_private_key(private_pem) + + @impl Boruta.Oauth.Signatures + def userinfo_signature_type(client), do: signatures().userinfo_signature_type(client) +end diff --git a/lib/boruta/utils/token_generator.ex b/lib/boruta/adapters/token_generator.ex similarity index 100% rename from lib/boruta/utils/token_generator.ex rename to lib/boruta/adapters/token_generator.ex diff --git a/lib/boruta/config.ex b/lib/boruta/config.ex index a2e17942..7a2c775c 100644 --- a/lib/boruta/config.ex +++ b/lib/boruta/config.ex @@ -15,7 +15,8 @@ defmodule Boruta.Config do resource_owners: MyApp.ResourceOwners, # mandatory for user flows scopes: Boruta.Ecto.Scopes, requests: Boruta.Ecto.Requests, - credentials: Boruta.Ecto.Credentials + credentials: Boruta.Ecto.Credentials, + signatures: Boruta.Internal.Signatures ], max_ttl: [ authorization_code: 60, @@ -46,7 +47,8 @@ defmodule Boruta.Config do resource_owners: nil, scopes: Boruta.Ecto.Scopes, requests: Boruta.Ecto.Requests, - credentials: Boruta.Ecto.Credentials + credentials: Boruta.Ecto.Credentials, + signatures: Boruta.Internal.Signatures ], max_ttl: [ authorization_request: 300, @@ -155,6 +157,12 @@ defmodule Boruta.Config do Keyword.fetch!(oauth_config(), :contexts)[:credentials] end + @spec signatures() :: module() + @doc false + def signatures do + Keyword.fetch!(oauth_config(), :contexts)[:signatures] + end + @spec resource_owners() :: module() @doc false def resource_owners do diff --git a/lib/boruta/oauth/authorization.ex b/lib/boruta/oauth/authorization.ex index cb7273e8..5d2087a2 100644 --- a/lib/boruta/oauth/authorization.ex +++ b/lib/boruta/oauth/authorization.ex @@ -538,7 +538,7 @@ defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.CodeRequest do alias Boruta.Oauth.Error alias Boruta.Oauth.ResourceOwner alias Boruta.Oauth.Token - alias Boruta.VerifiableCredentials + alias Boruta.Openid.VerifiableCredentials def preauthorize( %CodeRequest{ @@ -657,7 +657,7 @@ defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.AuthorizationRequest do alias Boruta.Oauth.CodeRequest alias Boruta.Oauth.Error alias Boruta.Oauth.Token - alias Boruta.VerifiableCredentials + alias Boruta.Openid.VerifiableCredentials def preauthorize(%AuthorizationRequest{ client_id: client_id, @@ -732,8 +732,8 @@ defimpl Boruta.Oauth.Authorization, for: Boruta.Oauth.PresentationRequest do alias Boruta.Oauth.Error alias Boruta.Oauth.PresentationRequest alias Boruta.Oauth.Token - alias Boruta.VerifiableCredentials - alias Boruta.VerifiablePresentations + alias Boruta.Openid.VerifiableCredentials + alias Boruta.Openid.VerifiablePresentations def preauthorize( %PresentationRequest{ diff --git a/lib/boruta/oauth/contexts/signatures.ex b/lib/boruta/oauth/contexts/signatures.ex new file mode 100644 index 00000000..f39e2ea7 --- /dev/null +++ b/lib/boruta/oauth/contexts/signatures.ex @@ -0,0 +1,19 @@ +defmodule Boruta.Oauth.Signatures do + @moduledoc """ + TODO Utilities to provide signature abilities to OAuth clients + """ + + @callback signature_algorithms() :: list(atom()) + @callback hash_alg(Boruta.Oauth.Client.t()) :: hash_alg :: atom() + @callback hash_binary_size(Boruta.Oauth.Client.t()) :: binary_size :: integer() + @callback hash(string :: String.t(), client :: Boruta.Oauth.Client.t()) :: hash :: String.t() + @callback id_token_sign(payload :: map(), client :: Boruta.Oauth.Client.t()) :: + jwt :: String.t() | {:error, reason :: String.t()} + @callback verify_id_token_signature(id_token :: String.t(), jwk :: JOSE.JWK.t()) :: + :ok | {:error, reason :: String.t()} + @callback userinfo_sign(payload :: map(), client :: Boruta.Oauth.Client.t()) :: + jwt :: String.t() | {:error, reason :: String.t()} + @callback kid_from_private_key(private_pem :: String.t()) :: kid :: String.t() + @callback userinfo_signature_type(Boruta.Oauth.Client.t()) :: + userinfo_token_signature_type :: atom() +end diff --git a/lib/boruta/oauth/request/base.ex b/lib/boruta/oauth/request/base.ex index 4dfbd8d4..724aac5d 100644 --- a/lib/boruta/oauth/request/base.ex +++ b/lib/boruta/oauth/request/base.ex @@ -15,8 +15,8 @@ defmodule Boruta.Oauth.Request.Base do alias Boruta.Oauth.RefreshTokenRequest alias Boruta.Oauth.RevokeRequest alias Boruta.Oauth.TokenRequest + alias Boruta.Openid.VerifiableCredentials alias Boruta.RequestsAdapter - alias Boruta.VerifiableCredentials @spec authorization_header(req_headers :: list()) :: {:ok, header :: String.t()} diff --git a/lib/boruta/oauth/schemas/client.ex b/lib/boruta/oauth/schemas/client.ex index 7d6a9a31..cac13476 100644 --- a/lib/boruta/oauth/schemas/client.ex +++ b/lib/boruta/oauth/schemas/client.ex @@ -200,134 +200,36 @@ defmodule Boruta.Oauth.Client do @moduledoc false alias Boruta.Oauth.Client - - @signature_algorithms [ - ES256: [type: :asymmetric, hash_algorithm: :SHA256, binary_size: 16], - ES384: [type: :asymmetric, hash_algorithm: :SHA384, binary_size: 24], - ES512: [type: :asymmetric, hash_algorithm: :SHA512, binary_size: 32], - RS256: [type: :asymmetric, hash_algorithm: :SHA256, binary_size: 16], - RS384: [type: :asymmetric, hash_algorithm: :SHA384, binary_size: 24], - RS512: [type: :asymmetric, hash_algorithm: :SHA512, binary_size: 32], - HS256: [type: :symmetric, hash_algorithm: :SHA256, binary_size: 16], - HS384: [type: :symmetric, hash_algorithm: :SHA384, binary_size: 24], - HS512: [type: :symmetric, hash_algorithm: :SHA512, binary_size: 32] - ] + alias Boruta.SignaturesAdapter @spec signature_algorithms() :: list(atom()) - def signature_algorithms, do: Keyword.keys(@signature_algorithms) + def signature_algorithms, do: SignaturesAdapter.signature_algorithms() @spec hash_alg(Client.t()) :: hash_alg :: atom() - def hash_alg(%Client{id_token_signature_alg: signature_alg}), - do: @signature_algorithms[String.to_atom(signature_alg)][:hash_algorithm] + def hash_alg(client), do: SignaturesAdapter.hash_alg(client) @spec hash_binary_size(Client.t()) :: binary_size :: integer() - def hash_binary_size(%Client{id_token_signature_alg: signature_alg}), - do: @signature_algorithms[String.to_atom(signature_alg)][:binary_size] + def hash_binary_size(client), do: SignaturesAdapter.hash_binary_size(client) @spec hash(string :: String.t(), client :: Client.t()) :: hash :: String.t() - def hash(string, client) do - hash_alg(client) - |> Atom.to_string() - |> String.downcase() - |> String.to_atom() - |> :crypto.hash(string) - |> binary_part(0, hash_binary_size(client)) - |> Base.url_encode64(padding: false) - end + def hash(string, client), do: SignaturesAdapter.hash(string, client) @spec id_token_sign(payload :: map(), client :: Client.t()) :: jwt :: String.t() | {:error, reason :: String.t()} - def id_token_sign( - payload, - %Client{ - id_token_signature_alg: signature_alg, - private_key: private_key, - id_token_kid: id_token_kid, - secret: secret - } = client - ) do - signer = - case id_token_signature_type(client) do - :symmetric -> - Joken.Signer.create(signature_alg, secret) - - :asymmetric -> - Joken.Signer.create( - signature_alg, - %{"pem" => private_key}, - %{"kid" => id_token_kid || kid_from_private_key(private_key)} - ) - end - - case Token.encode_and_sign(payload, signer) do - {:ok, token, _payload} -> - token - - {:error, error} -> - {:error, "Could not sign the given payload with client credentials: #{inspect(error)}"} - end - end + def id_token_sign(payload, client), do: SignaturesAdapter.id_token_sign(payload, client) @spec verify_id_token_signature(id_token :: String.t(), jwk :: JOSE.JWK.t()) :: :ok | {:error, reason :: String.t()} - def verify_id_token_signature(id_token, jwk) do - case Joken.peek_header(id_token) do - {:ok, %{"alg" => alg}} -> - signer = - Joken.Signer.create(alg, %{"pem" => JOSE.JWK.from_map(jwk) |> JOSE.JWK.to_pem()}) - - case Token.verify(id_token, signer) do - {:ok, claims} -> {:ok, claims} - {:error, reason} -> {:error, inspect(reason)} - end - - {:error, reason} -> - {:error, inspect(reason)} - end - end + def verify_id_token_signature(id_token, jwk), do: SignaturesAdapter.verify_id_token_signature(id_token, jwk) @spec userinfo_sign(payload :: map(), client :: Client.t()) :: jwt :: String.t() | {:error, reason :: String.t()} - def userinfo_sign( - payload, - %Client{ - userinfo_signed_response_alg: signature_alg, - private_key: private_key, - id_token_kid: id_token_kid, - secret: secret - } = client - ) do - signer = - case userinfo_signature_type(client) do - :symmetric -> - Joken.Signer.create(signature_alg, secret) - - :asymmetric -> - Joken.Signer.create(signature_alg, %{"pem" => private_key}, %{ - "kid" => id_token_kid || kid_from_private_key(private_key) - }) - end - - case Token.encode_and_sign(payload, signer) do - {:ok, token, _payload} -> - token - - {:error, error} -> - {:error, "Could not sign the given payload with client credentials: #{inspect(error)}"} - end - end + def userinfo_sign(payload, client), do: SignaturesAdapter.userinfo_sign(payload, client) @spec kid_from_private_key(private_pem :: String.t()) :: kid :: String.t() - def kid_from_private_key(private_pem) do - :crypto.hash(:md5, private_pem) |> Base.url_encode64() |> String.slice(0..16) - end + def kid_from_private_key(private_pem), do: SignaturesAdapter.kid_from_private_key(private_pem) @spec userinfo_signature_type(Client.t()) :: userinfo_token_signature_type :: atom() - def userinfo_signature_type(%Client{userinfo_signed_response_alg: signature_alg}), - do: @signature_algorithms[String.to_atom(signature_alg)][:type] - - @spec id_token_signature_type(Client.t()) :: id_token_signature_type :: atom() - def id_token_signature_type(%Client{id_token_signature_alg: signature_alg}), - do: @signature_algorithms[String.to_atom(signature_alg)][:type] + def userinfo_signature_type(client), do: SignaturesAdapter.userinfo_signature_type(client) end end diff --git a/lib/boruta/openid.ex b/lib/boruta/openid.ex index d06591b6..034c6092 100644 --- a/lib/boruta/openid.ex +++ b/lib/boruta/openid.ex @@ -35,8 +35,8 @@ defmodule Boruta.Openid do alias Boruta.Openid.CredentialResponse alias Boruta.Openid.DeferedCredentialResponse alias Boruta.Openid.UserinfoResponse - alias Boruta.VerifiableCredentials - alias Boruta.VerifiablePresentations + alias Boruta.Openid.VerifiableCredentials + alias Boruta.Openid.VerifiablePresentations def jwks(conn, module) do jwk_keys = ClientsAdapter.list_clients_jwk() diff --git a/lib/boruta/openid/contexts/signatures.ex b/lib/boruta/openid/contexts/signatures.ex new file mode 100644 index 00000000..3fdec7b1 --- /dev/null +++ b/lib/boruta/openid/contexts/signatures.ex @@ -0,0 +1,11 @@ +defmodule Boruta.Openid.Signatures do + @moduledoc """ + Utilities to sign verifiable credentials + """ + + @doc """ + Signs the given payload according tot the given client and generates a verifiable credential + """ + @callback verifiable_credential_sign(payload :: map(), client :: Boruta.Oauth.Client.t()) :: + jwt :: String.t() | {:error, reason :: String.t()} +end diff --git a/lib/boruta/verifiable_credentials.ex b/lib/boruta/openid/verifiable_credentials.ex similarity index 88% rename from lib/boruta/verifiable_credentials.ex rename to lib/boruta/openid/verifiable_credentials.ex index 1df72d8d..26241fc8 100644 --- a/lib/boruta/verifiable_credentials.ex +++ b/lib/boruta/openid/verifiable_credentials.ex @@ -1,4 +1,4 @@ -defmodule Boruta.VerifiableCredentials do +defmodule Boruta.Openid.VerifiableCredentials do defmodule Hotp do @moduledoc """ Implements HOTP generation as described in the IETF RFC @@ -47,6 +47,7 @@ defmodule Boruta.VerifiableCredentials do alias Boruta.Oauth.ResourceOwner alias Boruta.Oauth.Scope alias Boruta.Openid.Credential + alias Boruta.SignaturesAdapter alias ExJsonSchema.Schema alias ExJsonSchema.Validator.Error.BorutaFormatter @@ -105,7 +106,7 @@ defmodule Boruta.VerifiableCredentials do credential_params :: map(), token :: Boruta.Oauth.Token.t(), default_credential_configuration :: map() - ) :: {:ok, map()} | {:error, String.t()} + ) :: {:ok, credential :: Credential.t()} | {:error, reason :: String.t()} def issue_verifiable_credential( resource_owner, credential_params, @@ -187,7 +188,7 @@ defmodule Boruta.VerifiableCredentials do end @spec validate_signature(jwt :: String.t()) :: - {:ok, jwk ::map(), claims :: map()} | {:error, reason :: String.t()} + {:ok, jwk :: map(), claims :: map()} | {:error, reason :: String.t()} def validate_signature(jwt) when is_binary(jwt) do case Joken.peek_header(jwt) do {:ok, %{"alg" => alg} = headers} -> @@ -196,9 +197,10 @@ defmodule Boruta.VerifiableCredentials do error -> {:error, inspect(error)} end - # rescue - # error -> - # {:error, inspect(error)} + + # rescue + # error -> + # {:error, inspect(error)} end def validate_signature(_jwt), do: {:error, "Proof does not contain a valid JWT."} @@ -206,24 +208,26 @@ defmodule Boruta.VerifiableCredentials do defp verify_jwt({:did, did}, alg, jwt) do with {:ok, did_document} <- Did.resolve(did), %{"verificationMethod" => methods} <- did_document do - - Enum.reduce_while( - methods, - {:error, "no did verification method found with did #{did}."}, - fn %{"publicKeyJwk" => jwk}, {:error, errors} -> - signer = - Joken.Signer.create(alg, %{"pem" => JOSE.JWK.from_map(jwk) |> JOSE.JWK.to_pem()}) - - case Client.Token.verify(jwt, signer) do - {:ok, claims} -> {:halt, {:ok, jwk, claims}} - {:error, error} -> {:cont, {:error, errors <> ", #{inspect(error)} with key #{inspect(jwk)}"}} - end + Enum.reduce_while( + methods, + {:error, "no did verification method found with did #{did}."}, + fn %{"publicKeyJwk" => jwk}, {:error, errors} -> + signer = + Joken.Signer.create(alg, %{"pem" => JOSE.JWK.from_map(jwk) |> JOSE.JWK.to_pem()}) + + case Client.Token.verify(jwt, signer) do + {:ok, claims} -> + {:halt, {:ok, jwk, claims}} + + {:error, error} -> + {:cont, {:error, errors <> ", #{inspect(error)} with key #{inspect(jwk)}"}} end - ) - + end + ) else {:error, error} -> {:error, error} + did_document -> {:error, "Invalid did document: \"#{inspect(did_document)}\""} end @@ -318,21 +322,6 @@ defmodule Boruta.VerifiableCredentials do when format in ["jwt_vc_json"] do client = token.client - signer = - Joken.Signer.create( - client.id_token_signature_alg, - %{"pem" => client.private_key}, - %{ - "typ" => "JWT", - "kid" => case client.did do - nil -> - Client.Crypto.kid_from_private_key(client.private_key) - did -> - did <> "#" <> String.replace(did, "did:key:", "") - end - } - ) - sub = case Joken.peek_header(proof) do {:ok, headers} -> @@ -341,12 +330,11 @@ defmodule Boruta.VerifiableCredentials do end end - credential_id = SecureRandom.uuid() - now = :os.system_time(:seconds) - + credential_id = SecureRandom.uuid() sub = sub |> String.split("#") |> List.first() - claims = %{ + + payload = %{ "sub" => sub, # TODO store credential "jti" => Config.issuer() <> "/credentials/#{credential_id}", @@ -367,7 +355,7 @@ defmodule Boruta.VerifiableCredentials do "issuer" => client.did, "validFrom" => DateTime.from_unix!(now) |> DateTime.to_iso8601(), "credentialSubject" => %{ - "id" => sub, + "id" => sub # credential_identifier => # claims # |> Enum.map(fn {name, {claim, _status, _expiration}} -> {name, claim} end) @@ -375,15 +363,20 @@ defmodule Boruta.VerifiableCredentials do # |> Map.put("id", @public_client_did) }, "credentialSchema" => %{ - "id" => "https://api-pilot.ebsi.eu/trusted-schemas-registry/v2/schemas/z3MgUFUkb722uq4x3dv5yAJmnNmzDFeK5UC8x83QoeLJM", + "id" => + "https://api-pilot.ebsi.eu/trusted-schemas-registry/v2/schemas/z3MgUFUkb722uq4x3dv5yAJmnNmzDFeK5UC8x83QoeLJM", "type" => "FullJsonSchemaValidator2021" } } } - credential = Token.generate_and_sign!(claims, signer) + case SignaturesAdapter.verifiable_credential_sign(payload, client) do + {:error, error} -> + {:error, error} - {:ok, credential} + credential -> + {:ok, credential} + end end # https://www.w3.org/TR/vc-data-model-2.0/ @@ -397,17 +390,6 @@ defmodule Boruta.VerifiableCredentials do when format in ["jwt_vc"] do client = token.client - signer = - Joken.Signer.create( - client.id_token_signature_alg, - %{"pem" => client.private_key}, - %{ - "typ" => "JWT", - # TODO craft ebsi compliant dids - "kid" => client.did - } - ) - sub = case Joken.peek_header(proof) do {:ok, headers} -> @@ -418,7 +400,7 @@ defmodule Boruta.VerifiableCredentials do credential_id = SecureRandom.uuid() - claims = %{ + payload = %{ "@context" => [ "https://www.w3.org/ns/credentials/v2", "https://www.w3.org/ns/credentials/examples/v2" @@ -442,9 +424,13 @@ defmodule Boruta.VerifiableCredentials do } } - credential = Token.generate_and_sign!(claims, signer) + case SignaturesAdapter.verifiable_credential_sign(payload, client) do + {:error, error} -> + {:error, error} - {:ok, credential} + credential -> + {:ok, credential} + end end defp generate_credential( @@ -457,16 +443,6 @@ defmodule Boruta.VerifiableCredentials do when format in ["vc+sd-jwt"] do client = token.client - signer = - Joken.Signer.create( - client.id_token_signature_alg, - %{"pem" => client.private_key}, - %{ - "typ" => "JWT", - "kid" => Client.Crypto.kid_from_private_key(client.private_key) - } - ) - sub = case Joken.peek_header(proof) do {:ok, headers} -> @@ -495,7 +471,7 @@ defmodule Boruta.VerifiableCredentials do :crypto.hash(:sha256, disclosure) |> Base.url_encode64(padding: false) end) - claims = %{ + payload = %{ "sub" => sub, "iss" => Config.issuer(), "iat" => :os.system_time(:seconds), @@ -508,15 +484,19 @@ defmodule Boruta.VerifiableCredentials do } } - credential = Token.generate_and_sign!(claims, signer) + case SignaturesAdapter.verifiable_credential_sign(payload, client) do + {:error, error} -> + {:error, error} - tokens = - [credential] ++ - (disclosures - |> Enum.map(&Jason.encode!/1) - |> Enum.map(&Base.url_encode64(&1, padding: false))) + credential -> + tokens = + [credential] ++ + (disclosures + |> Enum.map(&Jason.encode!/1) + |> Enum.map(&Base.url_encode64(&1, padding: false))) - {:ok, Enum.join(tokens, "~") <> "~"} + {:ok, Enum.join(tokens, "~") <> "~"} + end end defp generate_credential(_claims, _credential_configuration, _proof, _client, _format), diff --git a/lib/boruta/verifiable_presentations.ex b/lib/boruta/openid/verifiable_presentations.ex similarity index 99% rename from lib/boruta/verifiable_presentations.ex rename to lib/boruta/openid/verifiable_presentations.ex index af25e65a..642de4da 100644 --- a/lib/boruta/verifiable_presentations.ex +++ b/lib/boruta/openid/verifiable_presentations.ex @@ -1,4 +1,4 @@ -defmodule Boruta.VerifiablePresentations do +defmodule Boruta.Openid.VerifiablePresentations do # TODO add typespec definitions for public functions @moduledoc false diff --git a/mix.exs b/mix.exs index 3a91acd9..6d82ec03 100644 --- a/mix.exs +++ b/mix.exs @@ -188,7 +188,7 @@ defmodule Boruta.MixProject do Boruta.Oauth.Validator, Boruta.Oauth.TokenGenerator, Boruta.Did, - Boruta.VerifiableCredentials.Hotp + Boruta.Openid.VerifiableCredentials.Hotp ], Errors: [ Boruta.Oauth.Error diff --git a/test/boruta/oauth/schemas/id_token_test.exs b/test/boruta/oauth/schemas/id_token_test.exs index 1fd243e9..7ba441fc 100644 --- a/test/boruta/oauth/schemas/id_token_test.exs +++ b/test/boruta/oauth/schemas/id_token_test.exs @@ -1,5 +1,6 @@ defmodule Boruta.Oauth.IdTokenTest do use ExUnit.Case + import Mox alias Boruta.Oauth.Client diff --git a/test/boruta/openid/integration/credential_test.exs b/test/boruta/openid/integration/credential_test.exs index 3a3eab1d..7d8b95c6 100644 --- a/test/boruta/openid/integration/credential_test.exs +++ b/test/boruta/openid/integration/credential_test.exs @@ -13,7 +13,7 @@ defmodule Boruta.OpenidTest.CredentialTest do alias Boruta.Openid alias Boruta.Openid.ApplicationMock alias Boruta.Openid.CredentialResponse - alias Boruta.VerifiableCredentials + alias Boruta.Openid.VerifiableCredentials setup :verify_on_exit! diff --git a/test/boruta/openid/integration/direct_post_test.exs b/test/boruta/openid/integration/direct_post_test.exs index 2483d6f8..4abbb75d 100644 --- a/test/boruta/openid/integration/direct_post_test.exs +++ b/test/boruta/openid/integration/direct_post_test.exs @@ -6,7 +6,7 @@ defmodule Boruta.OpenidTest.DirectPostTest do alias Boruta.ClientsAdapter alias Boruta.Openid alias Boruta.Openid.ApplicationMock - alias Boruta.VerifiablePresentations + alias Boruta.Openid.VerifiablePresentations describe "authenticates with direct post response" do setup do diff --git a/test/boruta/verifiable_credentials_test.exs b/test/boruta/openid/verifiable_credentials_test.exs similarity index 87% rename from test/boruta/verifiable_credentials_test.exs rename to test/boruta/openid/verifiable_credentials_test.exs index 2ed6ffd2..4605d1af 100644 --- a/test/boruta/verifiable_credentials_test.exs +++ b/test/boruta/openid/verifiable_credentials_test.exs @@ -1,11 +1,12 @@ -defmodule Boruta.VerifiableCredentialsTest do +defmodule Boruta.Openid.VerifiableCredentialsTest do use Boruta.DataCase, async: true import Boruta.Factory + import Boruta.Ecto.OauthMapper, only: [to_oauth_schema: 1] alias Boruta.Config alias Boruta.Oauth.ResourceOwner - alias Boruta.VerifiableCredentials + alias Boruta.Openid.VerifiableCredentials describe "issue_verifiable_credential/4" do setup do @@ -76,7 +77,7 @@ defmodule Boruta.VerifiableCredentialsTest do assert VerifiableCredentials.issue_verifiable_credential( resource_owner, Map.put(credential_params, "proof", %{}), - insert(:token), + insert(:token) |> to_oauth_schema(), %{} ) == {:error, @@ -99,7 +100,7 @@ defmodule Boruta.VerifiableCredentialsTest do assert VerifiableCredentials.issue_verifiable_credential( resource_owner, Map.put(credential_params, "proof", proof), - insert(:token), + insert(:token) |> to_oauth_schema(), %{} ) == {:error, @@ -123,7 +124,7 @@ defmodule Boruta.VerifiableCredentialsTest do assert VerifiableCredentials.issue_verifiable_credential( resource_owner, Map.put(credential_params, "proof", proof), - insert(:token), + insert(:token) |> to_oauth_schema(), %{} ) == {:error, "Proof JWT must be asymetrically signed."} end @@ -144,7 +145,7 @@ defmodule Boruta.VerifiableCredentialsTest do assert VerifiableCredentials.issue_verifiable_credential( resource_owner, Map.put(credential_params, "proof", proof), - insert(:token), + insert(:token) |> to_oauth_schema(), %{} ) == {:error, "Proof JWT must have `openid4vci-proof+jwt` typ header."} end @@ -168,7 +169,7 @@ defmodule Boruta.VerifiableCredentialsTest do assert VerifiableCredentials.issue_verifiable_credential( resource_owner, Map.put(credential_params, "proof", proof), - insert(:token), + insert(:token) |> to_oauth_schema(), %{} ) == {:error, "No proof key material found in JWT headers."} end @@ -193,7 +194,7 @@ defmodule Boruta.VerifiableCredentialsTest do assert VerifiableCredentials.issue_verifiable_credential( resource_owner, Map.put(credential_params, "proof", proof), - insert(:token), + insert(:token) |> to_oauth_schema(), %{} ) == {:error, @@ -212,7 +213,7 @@ defmodule Boruta.VerifiableCredentialsTest do VerifiableCredentials.issue_verifiable_credential( resource_owner, credential_params, - insert(:token), + insert(:token) |> to_oauth_schema(), %{} ) @@ -247,7 +248,7 @@ defmodule Boruta.VerifiableCredentialsTest do VerifiableCredentials.issue_verifiable_credential( resource_owner, credential_params, - insert(:token), + insert(:token) |> to_oauth_schema(), %{} ) @@ -282,7 +283,7 @@ defmodule Boruta.VerifiableCredentialsTest do VerifiableCredentials.issue_verifiable_credential( resource_owner, credential_params, - insert(:token), + insert(:token) |> to_oauth_schema(), %{} ) @@ -326,7 +327,8 @@ defmodule Boruta.VerifiableCredentialsTest do } } } - token = insert(:token) + + token = insert(:token) |> to_oauth_schema() assert {:ok, %{ credential: credential, @@ -399,7 +401,7 @@ defmodule Boruta.VerifiableCredentialsTest do } } } - token = insert(:token) + token = insert(:token) |> to_oauth_schema() assert {:ok, %{ credential: credential, @@ -455,7 +457,7 @@ defmodule Boruta.VerifiableCredentialsTest do } } } - token = insert(:token) + token = insert(:token) |> to_oauth_schema() assert {:ok, %{ credential: credential, @@ -511,7 +513,7 @@ defmodule Boruta.VerifiableCredentialsTest do } } } - token = insert(:token) + token = insert(:token) |> to_oauth_schema() assert {:ok, %{ credential: credential, @@ -630,6 +632,82 @@ defmodule Boruta.VerifiableCredentialsTest do end end + # alias Boruta.AgeZkp + + # describe "age verification" do + # test "over 65 today" do + # secret = SecureRandom.hex() + # Enum.map(1..1000, fn _i -> + # year = Enum.random(1900..2024) + # assert year_status_token = AgeZkp.generate_status_token(secret, "#{year}-11-12") + + # assert {_, true} = :timer.tc(fn -> + # AgeZkp.verify_salt(secret, year_status_token, 65) |> dbg + # end) |> dbg + # end) + # end + + # test "over 65 yesterday (year)" do + # secret = SecureRandom.hex() + + # assert year_status_token = AgeZkp.generate_status_token(secret, "1958-11-12") + + # assert {_, true} = :timer.tc(fn -> + # AgeZkp.verify_salt(secret, year_status_token, 65) |> dbg + # end) |> dbg + # end + + # test "over 65 yesterday (month)" do + # secret = SecureRandom.hex() + + # assert year_status_token = AgeZkp.generate_status_token(secret, "1959-10-12") + + # assert {_, true} = :timer.tc(fn -> + # AgeZkp.verify_salt(secret, year_status_token, 65) |> dbg + # end) |> dbg + # end + + # test "over 65 yesterday (day)" do + # secret = SecureRandom.hex() + + # assert year_status_token = AgeZkp.generate_status_token(secret, "1959-11-11") + + # assert {_, true} = :timer.tc(fn -> + # AgeZkp.verify_salt(secret, year_status_token, 65) |> dbg + # end) |> dbg + # end + + # test "over 65 tomorrow (year)" do + # secret = SecureRandom.hex() + + # assert year_status_token = AgeZkp.generate_status_token(secret, "1960-11-12") + + # assert {_, false} = :timer.tc(fn -> + # AgeZkp.verify_salt(secret, year_status_token, 65) |> dbg + # end) |> dbg + # end + + # test "over 65 tomorrow (month)" do + # secret = SecureRandom.hex() + + # assert year_status_token = AgeZkp.generate_status_token(secret, "1959-12-12") + + # assert {_, false} = :timer.tc(fn -> + # AgeZkp.verify_salt(secret, year_status_token, 65) |> dbg + # end) |> dbg + # end + + # test "over 65 tomorrow (day)" do + # secret = SecureRandom.hex() + + # assert year_status_token = AgeZkp.generate_status_token(secret, "1959-11-13") + + # assert {_, false} = :timer.tc(fn -> + # AgeZkp.verify_salt(secret, year_status_token, 65) |> dbg + # end) |> dbg + # end + # end + def public_key_fixture do "-----BEGIN RSA PUBLIC KEY-----\nMIIBCgKCAQEA1PaP/gbXix5itjRCaegvI/B3aFOeoxlwPPLvfLHGA4QfDmVOf8cU\n8OuZFAYzLArW3PnnwWWy39nVJOx42QRVGCGdUCmV7shDHRsr86+2DlL7pwUa9QyH\nsTj84fAJn2Fv9h9mqrIvUzAtEYRlGFvjVTGCwzEullpsB0GJafopUTFby8WdSq3d\nGLJBB1r+Q8QtZnAxxvolhwOmYkBkkidefmm48X7hFXL2cSJm2G7wQyinOey/U8xD\nZ68mgTakiqS2RtjnFD0dnpBl5CYTe4s6oZKEyFiFNiW4KkR1GVjsKwY9oC2tpyQ0\nAEUMvk9T9VdIltSIiAvOKlwFzL49cgwZDwIDAQAB\n-----END RSA PUBLIC KEY-----\n\n" end diff --git a/test/boruta/verifiable_presentations_test.exs b/test/boruta/openid/verifiable_presentations_test.exs similarity index 99% rename from test/boruta/verifiable_presentations_test.exs rename to test/boruta/openid/verifiable_presentations_test.exs index a1e3925b..41e63d6b 100644 --- a/test/boruta/verifiable_presentations_test.exs +++ b/test/boruta/openid/verifiable_presentations_test.exs @@ -1,5 +1,5 @@ -defmodule Boruta.VerifiablePresentationsTest do - alias Boruta.VerifiablePresentations +defmodule Boruta.Openid.VerifiablePresentationsTest do + alias Boruta.Openid.VerifiablePresentations use ExUnit.Case describe "validate_presentation/3" do