diff --git a/lib/gakimint/core/token.ex b/lib/gakimint/core/token.ex new file mode 100644 index 0000000..3f8cdbb --- /dev/null +++ b/lib/gakimint/core/token.ex @@ -0,0 +1,110 @@ +defmodule Gakimint.Cashu.Token do + @moduledoc """ + Handles the serialization and deserialization of Cashu tokens. + """ + alias CBOR + alias Gakimint.Cashu.Proof + + @type v3_token :: %{ + token: [%{mint: String.t(), proofs: [Proof.t()]}], + unit: String.t() | nil, + memo: String.t() | nil + } + + @doc """ + Serializes a V3 token to a string. + """ + @spec serialize_v3(v3_token()) :: String.t() + def serialize_v3(token) do + json = Jason.encode!(token) + base64 = Base.url_encode64(json, padding: false) + "cashuA#{base64}" + end + + @doc """ + Deserializes a V3 token string to a token struct. + """ + @spec deserialize_v3(String.t()) :: {:ok, v3_token()} | {:error, String.t()} + def deserialize_v3("cashuA" <> base64) do + case Base.url_decode64(base64, padding: false) do + {:ok, json} -> + case Jason.decode(json) do + {:ok, token} -> {:ok, token} + {:error, _} -> {:error, "Invalid JSON in token"} + end + + :error -> + {:error, "Invalid base64 encoding"} + end + end + + def deserialize_v3(_), do: {:error, "Invalid token format"} +end + +defmodule Gakimint.Cashu.TokenV4 do + @moduledoc """ + Handles the serialization and deserialization of Cashu V4 tokens. + """ + + @type v4_token :: %{ + m: String.t(), + u: String.t() | nil, + d: String.t() | nil, + t: [ + %{ + i: binary(), + p: [ + %{ + a: non_neg_integer(), + s: String.t(), + c: binary(), + d: %{e: binary(), s: binary(), r: binary()} | nil, + w: String.t() | nil + } + ] + } + ] + } + + @doc """ + Serializes a V4 token to a string. + """ + @spec serialize(v4_token()) :: String.t() + def serialize(token) do + cbor = CBOR.encode(token) + base64 = Base.url_encode64(cbor, padding: false) + "cashuB#{base64}" + end + + @doc """ + Deserializes a V4 token string to a token struct. + """ + @spec deserialize(String.t()) :: {:ok, v4_token()} | {:error, String.t()} + def deserialize("cashuB" <> base64) do + case Base.url_decode64(base64, padding: false) do + {:ok, cbor} -> + case CBOR.decode(cbor) do + {:ok, token, ""} -> {:ok, keys_to_atoms(token)} + _ -> {:error, "Invalid CBOR in token"} + end + + :error -> + {:error, "Invalid base64 encoding"} + end + end + + def deserialize(_), do: {:error, "Invalid token format"} + + defp keys_to_atoms(map) when is_map(map) do + Map.new(map, fn + {key, value} when is_binary(key) -> {String.to_existing_atom(key), keys_to_atoms(value)} + {key, value} -> {key, keys_to_atoms(value)} + end) + end + + defp keys_to_atoms(list) when is_list(list) do + Enum.map(list, &keys_to_atoms/1) + end + + defp keys_to_atoms(value), do: value +end diff --git a/lib/gakimint/core/types.ex b/lib/gakimint/core/types.ex new file mode 100644 index 0000000..1df6117 --- /dev/null +++ b/lib/gakimint/core/types.ex @@ -0,0 +1,69 @@ +defmodule Gakimint.Cashu.Types do + @moduledoc """ + Defines the core data types used in the Cashu protocol. + """ + + @derive Jason.Encoder + defstruct [:amount, :id, :B_] + + @type t :: %__MODULE__{ + amount: non_neg_integer(), + id: String.t(), + B_: String.t() + } + + @doc """ + Creates a new BlindedMessage struct. + """ + @spec new_blinded_message(non_neg_integer(), String.t(), String.t()) :: t() + def new_blinded_message(amount, id, B_) do + %__MODULE__{amount: amount, id: id, B_: B_} + end +end + +defmodule Gakimint.Cashu.BlindSignature do + @moduledoc """ + Represents a BlindSignature in the Cashu protocol. + """ + + @derive Jason.Encoder + defstruct [:amount, :id, :C_] + + @type t :: %__MODULE__{ + amount: non_neg_integer(), + id: String.t(), + C_: String.t() + } + + @doc """ + Creates a new BlindSignature struct. + """ + @spec new(non_neg_integer(), String.t(), String.t()) :: t() + def new(amount, id, C_) do + %__MODULE__{amount: amount, id: id, C_: C_} + end +end + +defmodule Gakimint.Cashu.Proof do + @moduledoc """ + Represents a Proof in the Cashu protocol. + """ + + @derive Jason.Encoder + defstruct [:amount, :id, :secret, :C] + + @type t :: %__MODULE__{ + amount: non_neg_integer(), + id: String.t(), + secret: String.t(), + C: String.t() + } + + @doc """ + Creates a new Proof struct. + """ + @spec new(non_neg_integer(), String.t(), String.t(), String.t()) :: t() + def new(amount, id, secret, C) do + %__MODULE__{amount: amount, id: id, secret: secret, C: C} + end +end diff --git a/lib/gakimint/crypto/bdhke.ex b/lib/gakimint/crypto/bdhke.ex index cf919c3..0639eb9 100644 --- a/lib/gakimint/crypto/bdhke.ex +++ b/lib/gakimint/crypto/bdhke.ex @@ -1,6 +1,12 @@ defmodule Gakimint.Crypto.BDHKE do @moduledoc """ Cryptographic functions for the Gakimint mint, including BDHKE implementation. + Check [NUT-00](https://cashubtc.github.io/nuts/00/) for more information. + Protocol summary: + - Alice (user) blinds the message and sends it to Bob (mint). + - Bob signs the blinded message. + - Alice unblinds the signature. + - Carol (user) verifies the signature. """ require Logger @@ -56,19 +62,26 @@ defmodule Gakimint.Crypto.BDHKE do defp hash_to_curve_loop(_, _), do: raise("No valid point found") @doc """ - Alice's step 1: Blind the message + Alice's step 1: Blind the message. + Alice is the sending user. + Alice picks secret x and computes Y = hash_to_curve(x). + Alice sends to Bob: B_ = Y + rG with r being a random blinding factor (blinding). + This operation is called blinding. """ def step1_alice(secret_msg, blinding_factor \\ nil) do y = hash_to_curve(secret_msg) r = blinding_factor || generate_keypair() |> elem(0) {:ok, r_pub} = ExSecp256k1.create_public_key(r) {:ok, r_pub_compressed} = ExSecp256k1.public_key_compress(r_pub) + # B_ = Y + rG {:ok, b_prime} = Secp256k1Utils.point_add(y, r_pub_compressed) {b_prime, r} end @doc """ - Bob's step 2: Sign the blinded message + Bob's step 2: Sign the blinded message. + Bob is the mint. + This operation is called signing. """ def step2_bob(b_prime, a) do with {:ok, c_prime} <- Secp256k1Utils.point_mul(b_prime, a), @@ -81,7 +94,9 @@ defmodule Gakimint.Crypto.BDHKE do end @doc """ - Alice's step 3: Unblind the signature + Alice's step 3: Unblind the signature. + Alice can calculate the unblinded key as C_ - rK = kY + krG - krG = kY = C. + This operation is called unblinding. """ def step3_alice(c_prime, r, a_pub) do with {:ok, r_a_pub} <- Secp256k1Utils.point_mul(a_pub, r), @@ -93,7 +108,9 @@ defmodule Gakimint.Crypto.BDHKE do end @doc """ - Verify the signature + Verify the signature. + Carol is the receiving user. + This operation is called verification. """ def verify(a, c, secret_msg) do y = hash_to_curve(secret_msg) diff --git a/lib/gakimint/mix/tasks/bench.ex b/lib/gakimint/mix/tasks/bench.ex index bfd97b5..27839b9 100644 --- a/lib/gakimint/mix/tasks/bench.ex +++ b/lib/gakimint/mix/tasks/bench.ex @@ -4,15 +4,17 @@ defmodule Mix.Tasks.Bench do """ use Mix.Task + alias Gakimint.Crypto.BDHKE + @shortdoc "Runs the BDHKE benchmark" def run(_args) do Mix.Task.run("app.start") # Setup before benchmark - {a, a_pub} = Gakimint.Crypto.generate_keypair(<<1::256>>) + {a, a_pub} = BDHKE.generate_keypair(<<1::256>>) secret_msg = "test_message" - {r, _r_pub} = Gakimint.Crypto.generate_keypair(<<1::256>>) + {r, _r_pub} = BDHKE.generate_keypair(<<1::256>>) # Run the BDHKE benchmark Benchee.run( @@ -32,16 +34,16 @@ defmodule Mix.Tasks.Bench do def run_bdhke_flow(a, a_pub, secret_msg, r) do # STEP 1: Alice blinds the message - {b_prime, _} = Gakimint.Crypto.step1_alice(secret_msg, r) + {b_prime, _} = BDHKE.step1_alice(secret_msg, r) # STEP 2: Bob signs the blinded message - {c_prime, e, s} = Gakimint.Crypto.step2_bob(b_prime, a) + {c_prime, _, _} = BDHKE.step2_bob(b_prime, a) # STEP 3: Alice unblinds the signature - c = Gakimint.Crypto.step3_alice(c_prime, r, a_pub) + c = BDHKE.step3_alice(c_prime, r, a_pub) # CAROL VERIFY: Carol verifies the unblinded signature - carol_verification = Gakimint.Crypto.carol_verify_dleq(secret_msg, r, c, e, s, a_pub) + carol_verification = BDHKE.verify(a, c, secret_msg) if carol_verification do :ok diff --git a/lib/gakimint/mix/tasks/test.unit.cashu.ex b/lib/gakimint/mix/tasks/test.unit.cashu.ex new file mode 100644 index 0000000..5d9d90c --- /dev/null +++ b/lib/gakimint/mix/tasks/test.unit.cashu.ex @@ -0,0 +1,14 @@ +defmodule Mix.Tasks.Test.Unit.Cashu do + @moduledoc """ + Test for the Cashu token serialization and deserialization. + """ + use Mix.Task + + @shortdoc "Runs the Cashu unit tests" + def run(_) do + Mix.Task.run("app.start", ["--no-start"]) + ExUnit.start() + Code.require_file("test/cashu_token_serde_test.exs") + ExUnit.run() + end +end diff --git a/mix.exs b/mix.exs index 80a3eb7..47ff84a 100644 --- a/mix.exs +++ b/mix.exs @@ -40,6 +40,7 @@ defmodule Gakimint.MixProject do {:block_keys, "~> 1.0.2"}, {:benchee, "~> 1.0", only: :dev}, {:benchee_html, "~> 1.0", only: :dev}, + {:cbor, "~> 1.0.0"}, {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, {:sobelow, "~> 0.13", only: [:dev, :test], runtime: false} diff --git a/mix.lock b/mix.lock index c508f3c..10e1af0 100644 --- a/mix.lock +++ b/mix.lock @@ -5,6 +5,7 @@ "block_keys": {:hex, :block_keys, "1.0.2", "8ec4808256af826e407f1011571682d941e14c38b7a9241a1ce4af724d5d3f43", [:mix], [{:ed25519, "~> 1.3", [hex: :ed25519, repo: "hexpm", optional: false]}, {:ex_keccak, "~> 0.7.3", [hex: :ex_keccak, repo: "hexpm", optional: false]}, {:ex_secp256k1, "~> 0.7.2", [hex: :ex_secp256k1, repo: "hexpm", optional: false]}], "hexpm", "eda5508f7d2c65cad58baebc79fa27a88d1ec6f781cb34a76597794d7598dcd1"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "castore": {:hex, :castore, "1.0.8", "dedcf20ea746694647f883590b82d9e96014057aff1d44d03ec90f36a5c0dc6e", [:mix], [], "hexpm", "0b2b66d2ee742cb1d9cb8c8be3b43c3a70ee8651f37b75a8b982e036752983f1"}, + "cbor": {:hex, :cbor, "1.0.1", "39511158e8ea5a57c1fcb9639aaa7efde67129678fee49ebbda780f6f24959b0", [:mix], [], "hexpm", "5431acbe7a7908f17f6a9cd43311002836a34a8ab01876918d8cfb709cd8b6a2"}, "cowboy": {:hex, :cowboy, "2.12.0", "f276d521a1ff88b2b9b4c54d0e753da6c66dd7be6c9fca3d9418b561828a3731", [:make, :rebar3], [{:cowlib, "2.13.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "8a7abe6d183372ceb21caa2709bec928ab2b72e18a3911aa1771639bef82651e"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, "cowlib": {:hex, :cowlib, "2.13.0", "db8f7505d8332d98ef50a3ef34b34c1afddec7506e4ee4dd4a3a266285d282ca", [:make, :rebar3], [], "hexpm", "e1e1284dc3fc030a64b1ad0d8382ae7e99da46c3246b815318a4b848873800a4"}, diff --git a/test/cashu_token_serde_test.exs b/test/cashu_token_serde_test.exs new file mode 100644 index 0000000..1499615 --- /dev/null +++ b/test/cashu_token_serde_test.exs @@ -0,0 +1,75 @@ +defmodule Gakimint.Cashu.TokenTest do + use ExUnit.Case, async: true + alias Gakimint.Cashu.TokenV4 + + describe "V4 Token" do + @tag :v4_token + test "serialization and deserialization" do + token = %{ + m: "https://example.com", + u: "sat", + d: "Test token", + t: [ + %{ + i: <<1, 2, 3>>, + p: [ + %{ + a: 10, + s: "secret1", + c: <<4, 5, 6>>, + d: %{e: <<7, 8, 9>>, s: <<10, 11, 12>>, r: <<13, 14, 15>>}, + w: "witness1" + }, + %{ + a: 20, + s: "secret2", + c: <<16, 17, 18>>, + w: "witness2" + } + ] + } + ] + } + + serialized = TokenV4.serialize(token) + assert String.starts_with?(serialized, "cashuB") + + {:ok, deserialized} = TokenV4.deserialize(serialized) + assert normalize_token(deserialized) == normalize_token(token) + end + + test "deserialization of invalid token" do + assert {:error, "Invalid token format"} = TokenV4.deserialize("invalid_token") + assert {:error, "Invalid CBOR in token"} = TokenV4.deserialize("cashuBinvalid_base64") + + assert {:error, "Invalid CBOR in token"} = + TokenV4.deserialize("cashuB" <> Base.url_encode64(<<1, 2, 3>>, padding: false)) + end + end + + # Helper function to normalize binary representations + defp normalize_token(token) do + token + |> normalize_binary_keys([:i, :c, :e, :s, :r]) + end + + defp normalize_binary_keys(map, keys) when is_map(map) do + Enum.reduce(keys, map, fn key, acc -> + if Map.has_key?(acc, key) do + Map.update!(acc, key, &normalize_binary/1) + else + acc + end + end) + |> Map.new(fn {k, v} -> {k, normalize_binary_keys(v, keys)} end) + end + + defp normalize_binary_keys(list, keys) when is_list(list) do + Enum.map(list, &normalize_binary_keys(&1, keys)) + end + + defp normalize_binary_keys(value, _keys), do: value + + defp normalize_binary(value) when is_binary(value), do: Base.encode16(value, case: :lower) + defp normalize_binary(value), do: value +end