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