lib/vodozemac.ex

defmodule Vodozemac do
  @moduledoc """
  Elixir bindings to Matrix's Olm and Megolm cryptographic primitives,
  built on top of Element's [vodozemac][1] (Rust) via Rustler.

  This library wraps the protocol primitives only — talking to a
  homeserver, persisting serialized session state, and routing
  decrypted events are caller concerns.

  ## Surface

    * **Account** — long-term Curve25519 + Ed25519 identity for a
      device. Generates one-time keys and is the input to outbound
      Olm sessions. (`account_*` functions.)
    * **Olm session** — pairwise channel between two devices, used
      to share Megolm room keys. (`olm_*` functions.)
    * **Megolm group session** — symmetric ratchet for one sender
      fanning out to many receivers. (`*_group_session` functions.)
    * **Cross-signing** — raw Ed25519/Curve25519 helpers used by
      Matrix's cross-signing identities.
    * **SAS** — short-authentication-string verification primitives.

  ## Persistence

  All session state serializes to an opaque pickle binary. The
  pickle is currently wrapped with vodozemac's default zero-key
  (no confidentiality at rest); callers must apply their own
  encryption layer until a `pickle_key` parameter lands in a later
  release.

  ## Stability

  Pre-1.0. Function signatures may move before the API freezes; see
  `CHANGELOG.md` and the README's stability table.

  [1]: https://github.com/matrix-org/vodozemac
  """

  alias Vodozemac.Native

  # ── Account ────────────────────────────────────────────────────────

  @doc "Create a fresh Olm account; returns its libolm-compatible pickle."
  @spec account_create() :: binary()
  defdelegate account_create(), to: Native

  @doc """
  Return the device's identity keys as `{curve25519_b64, ed25519_b64}`.
  """
  @spec account_identity_keys(binary()) :: {binary(), binary()}
  def account_identity_keys(pickle) do
    {:ok, pair} = Native.account_identity_keys(pickle)
    pair
  end

  @doc """
  Generate up to `count` new one-time keys. Returns the updated
  account pickle and a list of `{key_id_b64, public_key_b64}` tuples.
  """
  @spec account_generate_one_time_keys(binary(), non_neg_integer()) ::
          {binary(), [{binary(), binary()}]}
  def account_generate_one_time_keys(pickle, count) do
    {:ok, new_pickle, keys} = Native.account_generate_one_time_keys(pickle, count)
    {new_pickle, keys}
  end

  @doc "Maximum number of one-time keys the account holds (vodozemac uses 50)."
  @spec account_max_one_time_keys(binary()) :: non_neg_integer()
  def account_max_one_time_keys(pickle) do
    {:ok, n} = Native.account_max_one_time_keys(pickle)
    n
  end

  @doc "Mark all unpublished one-time keys as published after `/keys/upload`."
  @spec account_mark_published(binary()) :: binary()
  def account_mark_published(pickle) do
    {:ok, new_pickle} = Native.account_mark_published(pickle)
    new_pickle
  end

  @doc "Sign `message` with the account's Ed25519 key."
  @spec account_sign(binary(), iodata()) :: binary()
  def account_sign(pickle, message) do
    {:ok, sig} = Native.account_sign(pickle, IO.iodata_to_binary(message))
    sig
  end

  @doc "Verify an Ed25519 signature."
  @spec verify_ed25519(binary(), iodata(), binary()) :: :ok | {:error, :bad_message}
  def verify_ed25519(signer_ed25519_b64, message, signature_b64) do
    Native.verify_ed25519(signer_ed25519_b64, IO.iodata_to_binary(message), signature_b64)
  end

  # ── Megolm inbound (decryption) ────────────────────────────────────

  @doc """
  Build an `InboundGroupSession` from the base64 `session_key` field
  of an `m.room_key` to-device event. Returns the session id (which
  matches the timeline event's `session_id`) and the pickle to
  persist.
  """
  @spec inbound_group_session_create(binary()) :: {session_id :: binary(), pickle :: binary()}
  def inbound_group_session_create(session_key_b64) do
    {:ok, sid, pickle} = Native.inbound_group_session_create(session_key_b64)
    {sid, pickle}
  end

  @doc """
  Decrypt a Megolm timeline ciphertext. Returns plaintext, the
  message index for replay defence, and the updated pickle (the
  session ratchets internal state on decrypt).
  """
  @spec inbound_group_session_decrypt(binary(), binary()) ::
          {plaintext :: binary(), message_index :: non_neg_integer(), new_pickle :: binary()}
  def inbound_group_session_decrypt(pickle, ciphertext_b64) do
    {:ok, plaintext, idx, new_pickle} =
      Native.inbound_group_session_decrypt(pickle, ciphertext_b64)

    {plaintext, idx, new_pickle}
  end

  @doc """
  Return the session id of an inbound Megolm group session. Same id
  that the original sender embeds in `m.room.encrypted` events; use
  it to look up the correct inbound session per incoming event.
  """
  @spec inbound_group_session_id(binary()) :: binary()
  def inbound_group_session_id(pickle) do
    {:ok, sid} = Native.inbound_group_session_id(pickle)
    sid
  end

  # ── Megolm outbound (encryption) ───────────────────────────────────

  @doc """
  Create a fresh outbound group session. Returns the session id, the
  base64 `session_key` (to be wrapped in `m.room_key` events and
  shared with peers), and the pickle.
  """
  @spec outbound_group_session_create() ::
          {session_id :: binary(), session_key :: binary(), pickle :: binary()}
  def outbound_group_session_create do
    {:ok, sid, key, pickle} = Native.outbound_group_session_create()
    {sid, key, pickle}
  end

  @doc "Encrypt `plaintext` with the outbound group session."
  @spec outbound_group_session_encrypt(binary(), iodata()) ::
          {ciphertext_b64 :: binary(), new_pickle :: binary()}
  def outbound_group_session_encrypt(pickle, plaintext) do
    {:ok, ciphertext, new_pickle} =
      Native.outbound_group_session_encrypt(pickle, IO.iodata_to_binary(plaintext))

    {ciphertext, new_pickle}
  end

  @doc """
  Index of the next message the outbound session would encrypt
  (i.e. how many ratchet steps have been taken). Used to record
  the starting index when uploading a session to server-side key
  backup; receivers can only decrypt messages at or after the
  index they were keyed at.
  """
  @spec outbound_group_session_message_index(binary()) :: non_neg_integer()
  def outbound_group_session_message_index(pickle) do
    {:ok, idx} = Native.outbound_group_session_message_index(pickle)
    idx
  end

  @doc """
  Extract the current Megolm session key from an outbound session
  pickle. Use the result as the `session_key` field of an
  `m.room_key` to-device payload.
  """
  @spec outbound_group_session_key(binary()) :: binary()
  def outbound_group_session_key(pickle) do
    {:ok, key} = Native.outbound_group_session_key(pickle)
    key
  end

  @doc """
  Return the session id of an outbound Megolm group session. This is
  the value to publish in the `session_id` field of every
  `m.room.encrypted` event you send under this session so that
  recipients can match it to the corresponding inbound session.
  """
  @spec outbound_group_session_id(binary()) :: binary()
  def outbound_group_session_id(pickle) do
    {:ok, sid} = Native.outbound_group_session_id(pickle)
    sid
  end

  # ── Olm pairwise sessions ──────────────────────────────────────────

  @doc """
  Establish an outbound Olm session against a peer device. Returns
  the session id, the session pickle, and a new account pickle (the
  account's internal state advances).
  """
  @spec olm_session_create_outbound(binary(), binary(), binary()) ::
          {session_id :: binary(), session_pickle :: binary(), new_account_pickle :: binary()}
  def olm_session_create_outbound(account_pickle, their_curve25519, their_one_time_key) do
    {:ok, sid, session_pickle, new_account} =
      Native.olm_session_create_outbound(account_pickle, their_curve25519, their_one_time_key)

    {sid, session_pickle, new_account}
  end

  @doc """
  Decrypt an inbound *pre-key* (type 0) Olm message, establishing the
  inbound session in the process. Returns plaintext, the session id,
  the session pickle to persist, and the updated account pickle.
  """
  @spec olm_session_create_inbound(binary(), binary(), binary()) ::
          {plaintext :: binary(), session_id :: binary(), session_pickle :: binary(),
           new_account_pickle :: binary()}
  def olm_session_create_inbound(account_pickle, their_curve25519, ciphertext_b64) do
    {:ok, plaintext, sid, session_pickle, new_account} =
      Native.olm_session_create_inbound(account_pickle, their_curve25519, ciphertext_b64)

    {plaintext, sid, session_pickle, new_account}
  end

  @doc "Encrypt with an existing Olm session. Returns `{type, ciphertext, new_pickle}`."
  @spec olm_session_encrypt(binary(), iodata()) ::
          {message_type :: 0 | 1, ciphertext_b64 :: binary(), new_pickle :: binary()}
  def olm_session_encrypt(pickle, plaintext) do
    {:ok, type, ciphertext, new_pickle} =
      Native.olm_session_encrypt(pickle, IO.iodata_to_binary(plaintext))

    {type, ciphertext, new_pickle}
  end

  @doc "Decrypt with an existing Olm session."
  @spec olm_session_decrypt(binary(), 0 | 1, binary()) ::
          {plaintext :: binary(), new_pickle :: binary()}
  def olm_session_decrypt(pickle, message_type, ciphertext_b64) do
    {:ok, plaintext, new_pickle} =
      Native.olm_session_decrypt(pickle, message_type, ciphertext_b64)

    {plaintext, new_pickle}
  end

  @doc """
  Return the session id of an Olm pairwise session. Both ends of a
  successfully-established session derive the same id, so callers can
  use it as a dedupe key when multiple pre-key messages race.
  """
  @spec olm_session_id(binary()) :: binary()
  def olm_session_id(pickle) do
    {:ok, sid} = Native.olm_session_id(pickle)
    sid
  end

  # ── Cross-signing (Ed25519 keypairs) ───────────────────────────────

  @doc """
  Generate a fresh Ed25519 keypair. Returns
  `{secret_b64, public_b64}` — base64 strings ready to embed in
  Matrix cross-signing payloads. The caller is responsible for
  persisting (and ideally encrypting) the secret.
  """
  @spec ed25519_keypair_new() :: {secret_b64 :: binary(), public_b64 :: binary()}
  def ed25519_keypair_new do
    {:ok, secret, public} = Native.ed25519_keypair_new()
    {secret, public}
  end

  @doc "Sign `message` with the given base64 Ed25519 secret key."
  @spec ed25519_sign(binary(), iodata()) :: binary()
  def ed25519_sign(secret_b64, message) do
    {:ok, sig} = Native.ed25519_sign(secret_b64, IO.iodata_to_binary(message))
    sig
  end

  @doc "Recover the base64 public key from a base64 Ed25519 secret key."
  @spec ed25519_public_key(binary()) :: binary()
  def ed25519_public_key(secret_b64) do
    {:ok, public} = Native.ed25519_public_key(secret_b64)
    public
  end

  # ── Curve25519 (Megolm backup) ─────────────────────────────────────

  @doc """
  Generate a fresh Curve25519 keypair. Returns `{secret_b64, public_b64}`.
  Used by the Megolm key backup (phase 5c) where the public key
  encrypts every stored session under an ephemeral-static ECDH wrap.
  """
  @spec curve25519_keypair_new() :: {secret_b64 :: binary(), public_b64 :: binary()}
  def curve25519_keypair_new do
    {:ok, secret, public} = Native.curve25519_keypair_new()
    {secret, public}
  end

  @doc """
  Perform an X25519 Diffie-Hellman between our base64-encoded secret
  key and a peer's base64-encoded public key. Returns the 32-byte
  shared secret (raw bytes), ready to feed into HKDF.
  """
  @spec curve25519_ecdh(binary(), binary()) :: binary()
  def curve25519_ecdh(our_secret_b64, their_public_b64) do
    {:ok, shared} = Native.curve25519_ecdh(our_secret_b64, their_public_b64)
    shared
  end

  # ── SAS verification (Phase 4b) ────────────────────────────────────

  @doc """
  Create a fresh SAS instance with an ephemeral curve25519 keypair.
  Returns `{handle, public_key_b64}` — the handle is an opaque
  Rust-side reference; persist nothing about it, just thread it
  through subsequent SAS calls in the same BEAM process.
  """
  @spec sas_new() :: {reference(), binary()}
  def sas_new do
    {:ok, ref, public} = Native.sas_new()
    {ref, public}
  end

  @doc """
  Perform ECDH with the peer's curve25519 public key. Consumes the
  pre-DH handle (any subsequent use returns `:sas_consumed`) and
  produces an `established_sas` handle.
  """
  @spec sas_diffie_hellman(reference(), binary()) :: reference()
  def sas_diffie_hellman(sas, their_public_b64) do
    {:ok, established} = Native.sas_diffie_hellman(sas, their_public_b64)
    established
  end

  @doc """
  Derive the SAS bytes for the given info string. Returns the seven
  emoji indices (0–63 each) and a 3-tuple of decimals to show
  alongside.
  """
  @spec sas_bytes(reference(), binary()) ::
          {[non_neg_integer()], {non_neg_integer(), non_neg_integer(), non_neg_integer()}}
  def sas_bytes(established, info) do
    {:ok, emojis, decimals} = Native.sas_bytes(established, info)
    {emojis, decimals}
  end

  @doc "Compute a base64 MAC over `input` with the given `info` string."
  @spec sas_calculate_mac(reference(), binary(), binary()) :: binary()
  def sas_calculate_mac(established, input, info) do
    {:ok, mac} = Native.sas_calculate_mac(established, input, info)
    mac
  end

  @doc "Verify a peer-computed MAC. Returns `:ok` or `{:error, :bad_message}`."
  @spec sas_verify_mac(reference(), binary(), binary(), binary()) :: :ok | {:error, :bad_message}
  def sas_verify_mac(established, input, info, tag_b64),
    do: Native.sas_verify_mac(established, input, info, tag_b64)
end