~doma/do-auth

d6703343e9c437a51a1133335d5fd421126818cb — Jonn a month ago 21d3273
Problem: no way for the users to sign up

Solution:

 - Make a count-based invite system
 - To do that, we need to track invite grants by the service along with
	the amount of fulfillments (invite.ex)
 - That means that a referencing system between credentials has to exist
	- We start tracking ``misc'' in credential.ex
	- Change the way JSON IDs are generated to read from
	  ``misc.id''
 - That also means that credential store becomes stateful and the same
	request to fulfill an invite may be denied or accepted based on the
	other credentials that are in store. This is why we add ``decrement''
	kind of credential, that attaches to parent and serves as a append-only
	way to decrement the counter of invitations available.
 - We also patch key to be compatible with lookups by canonical DID.
 - As a result, anyone can fulfill an invite they have received, by
	calling `fulfill` as shown in `invite_test`, and, if there are stil
	vaccancies in this invite credential, get their PK64 registered in the
	system, and get a `doma` DID!
A lib/do_auth/invite.ex => lib/do_auth/invite.ex +126 -0
@@ 0,0 1,126 @@
defmodule DoAuth.Invite do
  @moduledoc """
  Functions to make invites.
  So far, it supports number-bound invites.

  To preserve privacy, when an invite is accepted and client certificate is
  generated, a server-signed credential get issued that effectively reduces the
  "invite issued" count.
  """

  alias DoAuth.Repo
  alias DoAuth.Crypto
  alias DoAuth.Subject
  alias DoAuth.DID
  alias DoAuth.Key
  alias DoAuth.Credential

  import Ecto.Query, only: [from: 2]

  @spec grant(%DID{}, pos_integer()) :: %Credential{}
  def grant(did = %DID{}, n) do
    Credential.tx_from_keypair_credential!(
      Crypto.server_keypair(),
      %{
        kind: "invite",
        capacity: n,
        holder: DID.show(did)
      },
      %{
        location:
          "/invites/" <>
            Crypto.salted_hash(
              ~s(invite|#{inspect(n)}|#{DID.show(did)}|#{inspect(DateTime.utc_now())})
            )
      }
    )
    |> Repo.preload(Credential.preload_credential())
  end

  defp fulfill_zero(pk64, invite) do
    did_stored = DID.by_pk64(pk64) |> Repo.one!() |> Repo.preload(:key)

    Credential.tx_from_keypair_credential!(Crypto.server_keypair(), %{
      parent: invite.misc["location"],
      holder: did_stored |> DID.show(),
      kind: "fulfill"
    })
  end

  @doc """
  Checks that invite is valid and still has slots.
  Creates two things: a credential, issued by the server, that links back to the
  grant credential that serves as a downtick for the counter and a credential
  for the new DID to join the network.
  """
  def fulfill(pk64, invite = %Credential{}, mk_cred \\ &fulfill_zero(&1, &2)) do
    {:ok, cred} =
      Repo.transaction(fn ->
        # TODO: Repo.one!() is jeopardising high availability set ups where we don't
        # guaranntee singularity of DID <-> Key relationships
        key = invite.subject.claim["holder"] |> DID.read() |> Key.by_did() |> Repo.one!()

        {_, true} =
          {invite, Credential.verify(invite, key.public_key |> Crypto.read!())}
          |> is_fresh()
          |> is_vacant()

        _cred =
          Credential.tx_from_keypair_credential!(Crypto.server_keypair(), %{
            parent: invite.misc["location"],
            kind: "decrement"
          })

        # TODO: Should this sinful stuff be a function?
        pk_stored =
          case pk64 |> Key.by_pk() |> Repo.all() do
            [] ->
              Key.changeset(%{public_key: pk64}) |> Repo.insert!()

            [x = %DoAuth.DID{}] ->
              x
          end

        _did_stored =
          case DID.by_pk64(pk64) |> Repo.all() do
            [] ->
              DoAuth.DID.from_key(pk_stored) |> Repo.insert!()

            [x = %DoAuth.DID{}] ->
              x
          end

        mk_cred.(pk64, invite)
      end)

    cred
  end

  defp is_fresh({err, false}), do: {err, false}

  # TODO: Check for expiration
  defp is_fresh({i, true}), do: {i, true}

  defp is_vacant({err, false}), do: {err, false}

  defp is_vacant({i, true}) do
    # case(from(c in Subject, where: fragment(~s(? ->> 'parent': ))))
    fulfilled =
      from(s in Subject,
        where:
          fragment(~s(? ->> 'parent' = ?), s.claim, ^i.misc["id"]) and
            fragment(~s(? ->> 'kind' = ?), s.claim, "decrement")
      )
      |> Repo.aggregate(:count)

    i = i |> Repo.preload(Credential.preload_credential())

    capacity = i.subject.claim["capacity"]

    if capacity > fulfilled do
      {i, true}
    else
      {"Invite is over capacity", false}
    end
  end
end

M lib/do_auth/schema/credential.ex => lib/do_auth/schema/credential.ex +41 -8
@@ 35,6 35,7 @@ defmodule DoAuth.Credential do
    many_to_many(:contexts, Context, join_through: CC)
    many_to_many(:types, CredentialType, join_through: CCT)
    field(:timestamp, :utc_datetime)
    field(:misc, :map)
  end

  def tx_import_tofu!(cred_map) do


@@ 126,12 127,18 @@ defmodule DoAuth.Credential do
            optional(:secret) => binary(),
            optional(:signature) => binary(),
            # TODO: Recap how timestamps work in Elixir
            optional(:timestamp) => any()
            optional(:timestamp) => any(),
            optional(:misc) => map()
          },
          map()
        ) ::
          %__MODULE__{}
  def tx_from_keypair_credential!(kp = %{public: pk}, claim) do
  def tx_from_keypair_credential!(x, y, z \\ %{})

  def tx_from_keypair_credential!(kp = %{public: pk}, claim, misc) do
    # require Logger
    # Logger.warn()

    {:ok, {:ok, cred}} =
      Repo.transaction(fn ->
        tau0 =


@@ 149,12 156,16 @@ defmodule DoAuth.Credential do
        # verified, which opens up an option for phishing and for shitty
        # implementations that are tricked by an unverified ID in a verified
        # credential to fetch a bogus one and treat it as correct.
        #
        # TODO: Make it also clear that misc.id will be used instead of auto
        # derived ID if provided
        proofless = %__MODULE__{
          contexts: [],
          types: [],
          issuer: entity,
          timestamp: tau0,
          subject: subject
          subject: subject,
          misc: misc
        }

        sig =


@@ 228,7 239,7 @@ defmodule DoAuth.Credential do
  def to_map(cred = %__MODULE__{proof: proof}, unwrapped: true) do
    to_map(cred, proofless: true)
    |> Map.put_new(:proof, Proof.to_map(proof, unwrapped: true))
    |> Map.put_new(:id, to_url(cred))
    |> Map.put_new(:id, mk_cred_id(cred))
  end

  def to_map(


@@ 257,10 268,32 @@ defmodule DoAuth.Credential do
  @spec to_map(%__MODULE__{}) :: map()
  def to_map(x = %__MODULE__{}), do: %{credential: to_map(x, unwrapped: true)}

  # TODO: nicer ids for credentials
  @spec to_url(%__MODULE__{}) :: String.t()
  def to_url(cred = %__MODULE__{}),
    do: "unavailable/credentials/#{Crypto.salted_hash(cred |> :erlang.term_to_binary())}"
  @doc """
  We have decided against using full URLs for credentials like shown in VC data
  model standard. Not the last reason is that in some high availability
  settings, replicant servers may be accessible through fqdn fallback and not be
  behind the same load balancer.

  Normally, IDs of credentials are just salted hashes of proofless JSON versions
  of credentials. This is done to achieve addressibility without predictability
  of hashes. Indeed, if we would use bland (unsalted) hashes, Malice who forgot
  her friend's Alice's birth date, would be able to, knowing Alice's DID and the
  time when she got her registration, enumerate proofless JSONs, eventually
  finding the information.
  """
  @spec mk_cred_id(%__MODULE__{}) :: String.t()
  def mk_cred_id(x, y \\ [])

  def mk_cred_id(cred = %__MODULE__{}, cloaked: false),
    do:
      Map.get(
        cred.misc,
        "location",
        "/credentials/#{Crypto.salted_hash(cred |> :erlang.term_to_binary())}"
      )

  def mk_cred_id(cred = %__MODULE__{}, _),
    do: Map.get(cred.misc, "location", "/credential/cloaked")

  # TODO: test
  @spec insert(%Entity{}, %Subject{}, %Proof{}, list(%Context{}), list(%CredentialType{})) ::

M lib/do_auth/schema/key.ex => lib/do_auth/schema/key.ex +13 -0
@@ 33,6 33,19 @@ defmodule DoAuth.Key do
    from(k in __MODULE__, where: k.public_key == ^pk)
  end

  @doc """
  Query that retrieves a %Key{} by the corresponding DID.
  """
  @spec by_did(%DID{}) :: Ecto.Query.t()
  def by_did(did) do
    from(d in DID,
      join: k in __MODULE__,
      on: [id: d.key_id],
      where: d.body == ^did.body,
      select: k
    )
  end

  @spec changeset(cauldron(), ingredients()) :: Changeset.t()
  def changeset(c, stuff) do
    c |> cast(stuff, [:public_key, :misc, :purpose]) |> validate_required(:public_key)

A test/invite_test.exs => test/invite_test.exs +43 -0
@@ 0,0 1,43 @@
defmodule InviteTest do
  use DoAuth.RepoCase
  use DoAuth.DBUtils
  alias DoAuth.Crypto

  # alias DoAuth.Issuer
  alias DoAuth.DID
  # alias DoAuth.Entity
  # alias DoAuth.Subject
  alias DoAuth.Credential
  # alias DoAuth.Key
  alias DoAuth.Invite

  test "Invitations can be fulfilled" do
    DoAuth.Persistence.populate_do()
    kp = Crypto.server_keypair()
    did = kp.public |> Crypto.show() |> DID.by_pk64() |> Repo.one!() |> Repo.preload(:key)
    granted = Invite.grant(did, 1)
    granted_map = granted |> Credential.to_map(unwrapped: true)
    # require Logger
    # Logger.warn("#{inspect(granted_map)}")
    refute("/credential/cloaked" == granted_map.id)
    {mkey, _} = Crypto.main_key_init("password123", DoAuthTest.very_weak_params())
    new_kp = mkey |> Crypto.derive_signing_keypair(42)
    new_pk64 = new_kp.public |> Crypto.show()
    cred = Invite.fulfill(new_pk64, granted) |> Repo.preload(Credential.preload_credential())
    cred_map = cred |> Credential.to_map(unwrapped: true)
    # Logger.warn("#{inspect(cred_map)}")
    assert("/credential/cloaked" == cred_map.id)
    assert "fulfill" == cred.subject.claim["kind"]
    assert granted.misc["location"] == cred.subject.claim["parent"]
    assert Credential.verify(granted, kp.public)
    assert Credential.verify(cred, kp.public)
    # Now check that another fulfillment can't happen
    new_kp = mkey |> Crypto.derive_signing_keypair(42)
    new_pk64 = new_kp.public |> Crypto.show()

    catch_error(
      Invite.fulfill(new_pk64, granted)
      |> Repo.preload(Credential.preload_credential())
    )
  end
end