Skip to content
This repository has been archived by the owner on Nov 21, 2024. It is now read-only.

Commit

Permalink
✨ cashu token serde
Browse files Browse the repository at this point in the history
  • Loading branch information
AbdelStark committed Sep 24, 2024
1 parent a474dc7 commit 88ff12a
Show file tree
Hide file tree
Showing 8 changed files with 299 additions and 10 deletions.
110 changes: 110 additions & 0 deletions lib/gakimint/core/token.ex
Original file line number Diff line number Diff line change
@@ -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
69 changes: 69 additions & 0 deletions lib/gakimint/core/types.ex
Original file line number Diff line number Diff line change
@@ -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
25 changes: 21 additions & 4 deletions lib/gakimint/crypto/bdhke.ex
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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),
Expand All @@ -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),
Expand All @@ -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)
Expand Down
14 changes: 8 additions & 6 deletions lib/gakimint/mix/tasks/bench.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand Down
14 changes: 14 additions & 0 deletions lib/gakimint/mix/tasks/test.unit.cashu.ex
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
1 change: 1 addition & 0 deletions mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
75 changes: 75 additions & 0 deletions test/cashu_token_serde_test.exs
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 88ff12a

Please sign in to comment.