diff --git a/lib/ex_ucan.ex b/lib/ex_ucan.ex index 3d1efd3..27228f6 100644 --- a/lib/ex_ucan.ex +++ b/lib/ex_ucan.ex @@ -17,19 +17,25 @@ defmodule ExUcan do Keypair.create() end - # TODO: docs + @doc """ + Signs the payload with keypair and returns a UCAN struct + + - payload - Ucan payload type + - keypair - A Keymaterial implemented struct + """ @spec sign(payload :: UcanPayload.t(), keypair :: struct()) :: Ucan.t() def sign(payload, keypair) do Token.sign_with_payload(payload, keypair) end - # TODO: to be removed + @doc """ + Encode the Ucan.t() struct to JWT like token + """ @spec encode(Ucan.t()) :: String.t() def encode(ucan) do Token.encode(ucan) end - # TODO: Test this after Builder is setup @doc """ Validate the UCAN token's signature and timestamps diff --git a/lib/ex_ucan/builder.ex b/lib/ex_ucan/builder.ex index 9e32867..7047b73 100644 --- a/lib/ex_ucan/builder.ex +++ b/lib/ex_ucan/builder.ex @@ -14,7 +14,6 @@ defmodule ExUcan.Builder do lifetime: number(), expiration: number(), not_before: number(), - # Add Facts struct later facts: map(), proofs: list(String.t()), add_nonce?: boolean() @@ -143,11 +142,25 @@ defmodule ExUcan.Builder do builder end - # TODO: docs @doc """ + Builds the UCAN `payload` from the `Builder` workflow + A runtime exception is raised if build payloads fails. + + A sample builder workflow to create ucan payload + ```Elixir + alias ExUcan.Builder + + keypair = ExUcan.create_default_keypair() + + ExUcan.Builder.default + |> Builder.issued_by(keypair) + |> Builder.for_audience("did:key:z6MkwDK3M4PxU1FqcSt4quXghquH1MoWXGzTrNkNWTSy2N") + |> Builder.with_lifetime(864000) + |> Builder.build! + ``` """ - @spec build!(__MODULE__.t()) :: String.t() + @spec build!(__MODULE__.t()) :: UcanPayload.t() def build!(builder) do case Token.build_payload(builder) do {:ok, payload} -> payload @@ -155,6 +168,11 @@ defmodule ExUcan.Builder do end end + @doc """ + Builds the UCAN `payload` from the `Builder` struct + + An error tuple with reason is returned if build payloads fails. + """ @spec build(__MODULE__.t()) :: {:ok, UcanPayload.t()} | {:error, String.t()} def build(builder) do Token.build_payload(builder) diff --git a/lib/ex_ucan/core/token.ex b/lib/ex_ucan/core/token.ex index 94a5e71..8599ace 100644 --- a/lib/ex_ucan/core/token.ex +++ b/lib/ex_ucan/core/token.ex @@ -2,6 +2,8 @@ defmodule ExUcan.Core.Token do @moduledoc """ Creates and manages UCAN tokens """ + alias ExUcan.Keymaterial.Ed25519.Keypair + alias ExUcan.Core.Utils alias ExUcan.Builder alias ExUcan.Keymaterial.Ed25519.Crypto alias ExUcan.Core.Structs.UcanHeader @@ -12,11 +14,17 @@ defmodule ExUcan.Core.Token do @token_type "JWT" @version %{major: 0, minor: 10, patch: 0} - # TODO: docs - @spec build_payload(params :: Builder) :: {:ok, UcanPayload.t()} | {:error, String.t()} + @doc """ + Takes a UcanBuilder and generates a UCAN payload + + Returns an error tuple with reason, if failed to generate payload + """ + @spec build_payload(params :: Builder.t()) :: {:ok, UcanPayload.t()} | {:error, String.t()} def build_payload(%Builder{issuer: nil}), do: {:error, "must call issued_by/2"} def build_payload(%Builder{audience: nil}), do: {:error, "must call for_audience/2"} - def build_payload(%Builder{lifetime: nil}), do: {:error, "must call with_lifetime/2"} + + def build_payload(%Builder{lifetime: life, expiration: exp}) when life == nil and exp == nil, + do: {:error, "must call with_lifetime/2 or with_expiration/2"} def build_payload(params) do did = Keymaterial.get_did(params.issuer) @@ -27,13 +35,13 @@ defmodule ExUcan.Core.Token do exp = params.expiration || current_time_in_seconds + params.lifetime {:ok, - %{ + %UcanPayload{ ucv: "#{@version.major}.#{@version.minor}.#{@version.patch}", iss: did, aud: params.audience, nbf: params.not_before, exp: exp, - nnc: params.add_nonce?, + nnc: add_nonce(params.add_nonce? || false), fct: params.facts, cap: params.capabilities, prf: params.proofs @@ -70,7 +78,13 @@ defmodule ExUcan.Core.Token do end end - @spec sign_with_payload(payload :: UcanPayload.t(), keypair :: struct()) :: Ucan.t() + @doc """ + Signs the payload with keypair and returns a UCAN struct + + - payload - Ucan payload type + - keypair - A Keymaterial implemented struct + """ + @spec sign_with_payload(payload :: UcanPayload.t(), keypair :: Keypair.t()) :: Ucan.t() def sign_with_payload(payload, keypair) do header = %UcanHeader{alg: keypair.jwt_alg, typ: @token_type} encoded_header = encode_ucan_parts(header) @@ -101,6 +115,7 @@ defmodule ExUcan.Core.Token do @spec is_too_early?(UcanPayload.t()) :: boolean() defp is_too_early?(%UcanPayload{nbf: nil}), do: false + defp is_too_early?(%UcanPayload{nbf: nbf}) do nbf > DateTime.utc_now() |> DateTime.to_unix() end @@ -134,7 +149,7 @@ defmodule ExUcan.Core.Token do end @spec verify_signature(String.t(), String.t(), String.t()) :: :ok | {:error, String.t()} - def verify_signature(did, data, signature) do + defp verify_signature(did, data, signature) do with {:ok, public_key} <- Crypto.did_to_publickey(did), true <- :public_key.verify(data, :ignored, signature, {:ed_pub, :ed25519, public_key}) do :ok @@ -143,4 +158,7 @@ defmodule ExUcan.Core.Token do err -> err end end + + defp add_nonce(true), do: Utils.generate_nonce() + defp add_nonce(false), do: nil end diff --git a/mix.exs b/mix.exs index c67183d..592ce7c 100644 --- a/mix.exs +++ b/mix.exs @@ -11,6 +11,11 @@ defmodule ExUcan.MixProject do test_coverage: [ summary: [ threshold: 80 + ], + ignore_modules: [ + ExUcan.Core.Structs.Ucan, + ExUcan.Core.Structs.UcanHeader, + ExUcan.Core.Structs.UcanPayload ] ] ] diff --git a/test/builder_test.exs b/test/builder_test.exs new file mode 100644 index 0000000..e7bc9ca --- /dev/null +++ b/test/builder_test.exs @@ -0,0 +1,121 @@ +defmodule BuilderTest do + alias ExUcan.Core.Capability + alias ExUcan.Core.Structs.UcanPayload + alias ExUcan.Builder + use ExUnit.Case + + setup do + keypair = ExUcan.create_default_keypair() + %{keypair: keypair} + end + + @tag :build + test "builder functions, default" do + assert %Builder{issuer: nil} = Builder.default() + end + + @tag :build + test "non-working builder flow", _meta do + assert {:error, "must call issued_by/2"} = Builder.default() |> Builder.build() + end + + @tag :build + test "non-working builder flow, need for_audience", meta do + assert {:error, "must call for_audience/2"} = + Builder.default() + |> Builder.issued_by(meta.keypair) + |> Builder.build() + end + + @tag :build + test "non-working builder flow, need with_lifetime", meta do + assert {:error, "must call with_lifetime/2 or with_expiration/2"} = + Builder.default() + |> Builder.issued_by(meta.keypair) + |> Builder.for_audience("did:key:z6MkwDK3M4PxU1FqcSt4quXghquH1MoWXGzTrNkNWTSy2NLD") + |> Builder.build() + end + + @tag :build + test "working builder flow", meta do + assert {:ok, %UcanPayload{}} = + Builder.default() + |> Builder.issued_by(meta.keypair) + |> Builder.for_audience("did:key:z6MkwDK3M4PxU1FqcSt4quXghquH1MoWXGzTrNkNWTSy2NLD") + |> Builder.with_lifetime(86_400) + |> Builder.build() + end + + @tag :build + test "working builder flow, with expiration", meta do + assert {:ok, %UcanPayload{}} = + Builder.default() + |> Builder.issued_by(meta.keypair) + |> Builder.for_audience("did:key:z6MkwDK3M4PxU1FqcSt4quXghquH1MoWXGzTrNkNWTSy2NLD") + |> Builder.with_expiration((DateTime.utc_now() |> DateTime.to_unix()) + 86_400) + |> Builder.build() + end + + @tag :build + test "more workflows", meta do + assert {:ok, %UcanPayload{fct: %{"door" => "bronze"}, nnc: nnc}} = + Builder.default() + |> Builder.issued_by(meta.keypair) + |> Builder.for_audience("did:key:z6MkwDK3M4PxU1FqcSt4quXghquH1MoWXGzTrNkNWTSy2NLD") + |> Builder.with_expiration((DateTime.utc_now() |> DateTime.to_unix()) + 86_400) + |> Builder.with_fact("door", "bronze") + |> Builder.with_nonce() + |> Builder.not_before((DateTime.utc_now() |> DateTime.to_unix()) - 86_400) + |> Builder.build() + + assert is_binary(nnc) + end + + @tag :build + test "with capabilities", meta do + cap = Capability.new("example://bar", "ability/bar", %{"beep" => 1}) + + assert {:ok, %UcanPayload{cap: caps}} = + Builder.default() + |> Builder.issued_by(meta.keypair) + |> Builder.for_audience("did:key:z6MkwDK3M4PxU1FqcSt4quXghquH1MoWXGzTrNkNWTSy2NLD") + |> Builder.with_expiration((DateTime.utc_now() |> DateTime.to_unix()) + 86_400) + |> Builder.claiming_capability(cap) + |> Builder.build() + + assert [%{resource: "example://bar", ability: "ability/bar"} | _] = caps + end + + @tag :build + test "with bang variant success", meta do + cap = Capability.new("example://bar", "ability/bar", %{"beep" => 1}) + + assert %UcanPayload{cap: caps} = + Builder.default() + |> Builder.issued_by(meta.keypair) + |> Builder.for_audience("did:key:z6MkwDK3M4PxU1FqcSt4quXghquH1MoWXGzTrNkNWTSy2NLD") + |> Builder.with_expiration((DateTime.utc_now() |> DateTime.to_unix()) + 86_400) + |> Builder.claiming_capability(cap) + |> Builder.build!() + + assert [%{resource: "example://bar", ability: "ability/bar"} | _] = caps + end + + @tag :build + test "with bang variant fail", meta do + cap = Capability.new("example://bar", "ability/bar", %{"beep" => 1}) + + res = + try do + Builder.default() + |> Builder.issued_by(meta.keypair) + |> Builder.for_audience("did:key:z6MkwDK3M4PxU1FqcSt4quXghquH1MoWXGzTrNkNWTSy2NLD") + |> Builder.claiming_capability(cap) + |> Builder.build!() + rescue + e in RuntimeError -> e + end + + assert %RuntimeError{message: _} = res + end +end diff --git a/test/ex_ucan_test.exs b/test/ex_ucan_test.exs index b004039..eb9c5da 100644 --- a/test/ex_ucan_test.exs +++ b/test/ex_ucan_test.exs @@ -1,11 +1,60 @@ defmodule ExUcanTest do + alias ExUcan.Builder alias ExUcan.Keymaterial.Ed25519.Keypair use ExUnit.Case doctest ExUcan + setup do + keypair = ExUcan.create_default_keypair() + %{keypair: keypair} + end + test "create_default_keypair" do assert %Keypair{jwt_alg: "EdDSA"} = keypair = Keypair.create() assert is_binary(keypair.public_key) assert is_binary(keypair.secret_key) end + + @tag :exucan + test "validate_token, success", meta do + token = + Builder.default() + |> Builder.issued_by(meta.keypair) + |> Builder.for_audience("did:key:z6MkwDK3M4PxU1FqcSt4quXghquH1MoWXGzTrNkNWTSy2NLD") + |> Builder.with_expiration((DateTime.utc_now() |> DateTime.to_unix()) + 86_400) + |> Builder.build!() + |> ExUcan.sign(meta.keypair) + |> ExUcan.encode() + + assert :ok = ExUcan.validate_token(token) + end + + @tag :exucan + test "invalid token, due to expiry", meta do + token = + Builder.default() + |> Builder.issued_by(meta.keypair) + |> Builder.for_audience("did:key:z6MkwDK3M4PxU1FqcSt4quXghquH1MoWXGzTrNkNWTSy2NLD") + |> Builder.with_expiration((DateTime.utc_now() |> DateTime.to_unix()) - 5) + |> Builder.build!() + |> ExUcan.sign(meta.keypair) + |> ExUcan.encode() + + assert {:error, "Ucan token is already expired"} = ExUcan.validate_token(token) + end + + @tag :exucan + test "invalid token, too early", meta do + token = + Builder.default() + |> Builder.issued_by(meta.keypair) + |> Builder.for_audience("did:key:z6MkwDK3M4PxU1FqcSt4quXghquH1MoWXGzTrNkNWTSy2NLD") + |> Builder.with_expiration((DateTime.utc_now() |> DateTime.to_unix()) + 86_400) + |> Builder.not_before((DateTime.utc_now() |> DateTime.to_unix()) + (div(86400, 2))) + |> Builder.build!() + |> ExUcan.sign(meta.keypair) + |> ExUcan.encode() + + assert {:error, "Ucan token is not yet active"} = ExUcan.validate_token(token) + end end