diff --git a/lib/ex_ucan/core/keymaterial.ex b/lib/ex_ucan/core/keymaterial.ex new file mode 100644 index 0000000..4c3f591 --- /dev/null +++ b/lib/ex_ucan/core/keymaterial.ex @@ -0,0 +1,10 @@ +# TODO: docs?? + +defprotocol ExUcan.Core.Keymaterial do + alias ExUcan.Core.Structs.UcanPayload + @spec did(struct()) :: String.t() + def did(type) + + @spec sign(struct(), UcanPayload.t()) :: binary() + def sign(type, payload) +end diff --git a/lib/ex_ucan/core/plugins.ex b/lib/ex_ucan/core/plugins.ex new file mode 100644 index 0000000..108da09 --- /dev/null +++ b/lib/ex_ucan/core/plugins.ex @@ -0,0 +1,20 @@ +defmodule ExUcan.Core.Plugins do + # TODO: docs + + @spec verify_issuer_alg(String.t(), String.t()) :: boolean() + def verify_issuer_alg(did, jwt_alg) do + + end + + @spec parseDidMethod(String.t()) :: String.t() + defp parseDidMethod(did) do + parts = String.split(did, ":") + with {true, _} <- {Enum.at(parts, 0) == "did", 0}, + {true, _} <- {String.length(Enum.at(parts, 1)) >=1, 1} do + {:ok, Enum.at(parts, 2)} + else + {false, 0} -> {:error, "Not a DID: #{did}"} + {false, 1} -> {:error, "No DID method included: #{did}"} + end + end +end diff --git a/lib/ex_ucan/core/structs.ex b/lib/ex_ucan/core/structs.ex new file mode 100644 index 0000000..878848d --- /dev/null +++ b/lib/ex_ucan/core/structs.ex @@ -0,0 +1,71 @@ +defmodule ExUcan.Core.Structs.UcanHeader do + @moduledoc """ + Ucan header + """ + + @type t :: %__MODULE__{ + alg: String.t(), + typ: String.t() + } + + @derive Jason.Encoder + defstruct( + alg: "", + typ: "" + ) + +end + +defmodule ExUcan.Core.Structs.UcanPayload do + @moduledoc """ + Ucan Payload + """ + + @type t :: %__MODULE__{ + ucv: String.t(), + iss: String.t(), + aud: String.t(), + nbf: integer(), + exp: integer(), + nnc: String.t(), + fct: map(), + cap: map(), + prf: list(String.t()) + } + + @derive Jason.Encoder + defstruct( + ucv: "", + iss: "", + aud: "", + nbf: 0, + exp: nil, + nnc: "", + fct: %{}, + cap: %{}, + prf: [] + ) +end + +defmodule ExUcan.Core.Structs.Ucan do + @moduledoc """ + UCAN struct + """ + alias ExUcan.Core.Structs.UcanHeader + alias ExUcan.Core.Structs.UcanPayload + + @type t :: %__MODULE__{ + header: UcanHeader.t(), + payload: UcanPayload.t(), + signed_data: String.t(), + signature: String.t() + } + + @derive Jason.Encoder + defstruct( + header: nil, + payload: nil, + signed_data: "", + signature: "" + ) +end diff --git a/lib/ex_ucan/core/token.ex b/lib/ex_ucan/core/token.ex new file mode 100644 index 0000000..f971270 --- /dev/null +++ b/lib/ex_ucan/core/token.ex @@ -0,0 +1,105 @@ +defmodule ExUcan.Core.Token do + @moduledoc """ + Creates and manages UCAN tokens + """ + alias ExUcan.Core.Structs.UcanHeader + alias ExUcan.Core.Keymaterial + alias ExUcan.Core.Structs.Ucan + alias ExUcan.Core.Utils + alias ExUcan.Core.Structs.UcanPayload + + @token_type "JWT" + @version %{major: 0, minor: 10, patch: 0} + + @spec build(params::%{ + issuer: struct(), + audience: String.t(), + # Add capabilities struct later + capabilities: list(), + life_time_in_seconds: number(), + expiration: number(), + not_before: number(), + # Add Facts struct later + facts: list(), + proofs: list(String.t()), + add_nonce?: boolean() + }) :: Ucan.t() + def build(params) do + {:ok, payload} = build_payload(%{params | issuer: Keymaterial.did(params.issuer)}) + sign_with_payload(payload, params.issuer) + end + + # TODO: docs + @spec build_payload( + params :: %{ + issuer: String.t(), + audience: String.t(), + # Add capabilities struct later + capabilities: list(), + life_time_in_seconds: number(), + expiration: number(), + not_before: number(), + # Add Facts struct later + facts: list(), + proofs: list(String.t()), + add_nonce?: boolean() + } + ) :: UcanPayload.t() + def build_payload(params) do + with {:iss, true} <- {:iss, String.starts_with?(params.issuer, "did")}, + {:aud, true} <- {:aud, String.starts_with?(params.audience, "did")} do + current_time_in_seconds = DateTime.utc_now() |> DateTime.to_unix() + exp = params.expiration || current_time_in_seconds + params.life_time_in_seconds + + {:ok, + %{ + ucv: "#{@version.major}.#{@version.minor}.#{@version.patch}", + iss: params.issuer, + aud: params.audience, + nbf: params[:not_before] || nil, + exp: exp, + nnc: add_nonce(params[:add_nonce] || false), + fct: params[:facts] || [], + cap: params[:capabilities] || [], + prf: params[:proofs] || [] + }} + else + {:iss, false} -> {:error, "The issuer must be a DID"} + {:aud, false} -> {:error, "The audience must be a DID"} + end + end + + @spec encode(Ucan.t()) :: String.t() + def encode(%Ucan{} = ucan) do + "#{ucan.signed_data}.#{ucan.signature}" + end + + defp add_nonce(true), do: Utils.generate_nonce() + defp add_nonce(false), do: nil + + @spec sign_with_payload(payload :: UcanPayload.t(), keypair :: struct()) :: Ucan.t() + defp sign_with_payload(payload, keypair) do + + # TODO ExUcan.Core.Plugins.verify_issuer_alg + header = %UcanHeader{alg: keypair.jwt_alg, typ: @token_type} + encoded_header = encode_ucan_parts(header) + encoded_payload = encode_ucan_parts(payload) + + signed_data = "#{encoded_header}.#{encoded_payload}" + signature = Keymaterial.sign(keypair, signed_data) + %Ucan{ + header: header, + payload: payload, + signed_data: signed_data, + signature: Base.url_encode64(signature, padding: false) + } + end + + @spec encode_ucan_parts(UcanHeader.t() | UcanPayload.t()) :: String.t() + defp encode_ucan_parts(data) do + data + |> Jason.encode!() + |> Base.url_encode64(padding: false) + end + +end diff --git a/lib/ex_ucan/core/utils.ex b/lib/ex_ucan/core/utils.ex new file mode 100644 index 0000000..f392ff9 --- /dev/null +++ b/lib/ex_ucan/core/utils.ex @@ -0,0 +1,15 @@ +defmodule ExUcan.Core.Utils do + @moduledoc """ + Core utils + """ + @chars "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + + # TODO: docs + def generate_nonce(len \\ 6) + + def generate_nonce(len) do + Enum.reduce(1..len, "", fn _, nonce -> + nonce <> String.at(@chars, :rand.uniform(String.length(@chars) - 1)) + end) + end +end diff --git a/lib/ex_ucan/plugins/ed25519/keypair.ex b/lib/ex_ucan/plugins/ed25519/keypair.ex index 6c39a8f..2af4062 100644 --- a/lib/ex_ucan/plugins/ed25519/keypair.ex +++ b/lib/ex_ucan/plugins/ed25519/keypair.ex @@ -2,7 +2,9 @@ defmodule ExUcan.Plugins.Ed25519.Keypair do @moduledoc """ Encapsulates Ed25519 Keypair generation and other utilities """ + alias ExUcan.Core.Keymaterial alias ExUcan.Plugins.Ed25519.Crypto + @behaviour Keymaterial # TODO: more doc.. # TODO: Need type doc @@ -25,17 +27,21 @@ defmodule ExUcan.Plugins.Ed25519.Keypair do def create(exportable?) do {pub, priv} = :crypto.generate_key(:eddsa, :ed25519) - - __MODULE__.__struct__( + %__MODULE__{ jwt_alg: "EdDSA", secret_key: priv, public_key: pub, exportable: exportable? - ) + } end - @spec did(__MODULE__.t()) :: String.t() - def did(keypair) do - Crypto.publickey_to_did(keypair.public_key) + defimpl Keymaterial do + def did(keypair) do + Crypto.publickey_to_did(keypair.public_key) + end + + def sign(keypair, payload) do + :public_key.sign(payload, :ignored, {:ed_pri, :ed25519, keypair.public_key, keypair.secret_key}, []) + end end end diff --git a/lib/ex_ucan/plugins/protocols.ex b/lib/ex_ucan/plugins/protocols.ex deleted file mode 100644 index aaa2c54..0000000 --- a/lib/ex_ucan/plugins/protocols.ex +++ /dev/null @@ -1,5 +0,0 @@ -# TODO: docs?? - -defprotocol ExUcan.Plugins.Protocols.Keygen do - def create(type) -end