Current section
Files
Jump to
Current section
Files
lib/signal_protocol.ex
defmodule SignalProtocol do
@moduledoc """
Thin Elixir facade over the `:libsignal_protocol_nif` Erlang NIF.
All functions return `{:ok, term} | {:error, term}` as the NIF does. A
missing NIF raises `UndefinedFunctionError` at the call site -- callers
must ensure the NIF is built and on the load path before use.
"""
@nif :libsignal_protocol_nif
@spec generate_identity_key_pair() :: {:ok, {binary(), binary()}} | {:error, term()}
def generate_identity_key_pair, do: @nif.generate_identity_key_pair()
@spec generate_pre_key(non_neg_integer()) :: {:ok, {non_neg_integer(), binary()}} | {:error, term()}
def generate_pre_key(key_id) when is_integer(key_id), do: @nif.generate_pre_key(key_id)
@spec generate_signed_pre_key(binary(), non_neg_integer()) ::
{:ok, {non_neg_integer(), binary(), binary()}} | {:error, term()}
def generate_signed_pre_key(identity_key, key_id)
when is_binary(identity_key) and is_integer(key_id) do
@nif.generate_signed_pre_key(identity_key, key_id)
end
@doc """
Performs X3DH key agreement against a remote pre-key bundle.
The bundle is a binary in the format expected by the C NIF:
`remote_identity_pub(32) ++ signed_prekey_pub(32) ++ signature(64)`
with an optional trailing `one_time_prekey(32)`. The signature is
Ed25519 (`crypto_sign_detached`) over `signed_prekey_pub` under the
remote identity key.
Returns `{:ok, {shared_secret(96), ephemeral_pub(32)}}` on success.
The 96-byte shared secret is `X3DH_SK(64) || DR_HE_seed(32)` and is fed
directly into `init_double_ratchet/5` as the Double Ratchet root seed.
"""
@spec process_pre_key_bundle(binary(), binary()) ::
{:ok, {binary(), binary()}} | {:error, term()}
def process_pre_key_bundle(local_identity_priv, bundle)
when is_binary(local_identity_priv) and is_binary(bundle) do
@nif.process_pre_key_bundle(local_identity_priv, bundle)
end
@doc """
Bob's side of X3DH. Recovers the same 96-byte shared secret Alice derived
via `process_pre_key_bundle/2` (64B X3DH SK || 32B shared header-key seed
for DR-HE).
Inputs:
* `identity_priv` - Bob's 64-byte Ed25519 identity private key.
* `signed_pre_key_priv` - 32-byte X25519 private key matching the SPK
Alice consumed from Bob's bundle.
* `one_time_pre_key_priv` - 32-byte X25519 private key matching the OPK
Alice consumed, or `<<>>` if no OPK was used.
* `remote_identity_pub` - Alice's 32-byte Ed25519 identity public key,
extracted from the PreKeySignalMessage.
* `remote_ephemeral_pub` - Alice's 32-byte X25519 ephemeral public key,
also extracted from the PreKeySignalMessage (the `base_key` field).
"""
@spec process_pre_key_bundle_bob(binary(), binary(), binary(), binary(), binary()) ::
{:ok, binary()} | {:error, term()}
def process_pre_key_bundle_bob(
identity_priv,
signed_pre_key_priv,
one_time_pre_key_priv,
remote_identity_pub,
remote_ephemeral_pub
)
when is_binary(identity_priv) and is_binary(signed_pre_key_priv) and
is_binary(one_time_pre_key_priv) and is_binary(remote_identity_pub) and
is_binary(remote_ephemeral_pub) do
@nif.process_pre_key_bundle_bob(
identity_priv,
signed_pre_key_priv,
one_time_pre_key_priv,
remote_identity_pub,
remote_ephemeral_pub
)
end
# ============================================================================
# Double Ratchet
#
# Per the Signal DR spec: Alice (initiator) sends first using Bob's identity
# pub; Bob (responder) holds his identity priv and cannot send until Alice's
# first message arrives. `is_alice` is `1` for the initiator, `0` for the
# responder. `local_identity_pub` and `remote_identity_pub` are stored in DR
# state and folded into every message MAC (Signal-spec scope). For Alice,
# `self_identity_priv` may be `<<>>` (she uses a fresh ephemeral for DH).
# ============================================================================
@spec init_double_ratchet(binary(), binary(), binary(), binary(), 0 | 1) ::
{:ok, binary()} | {:error, term()}
def init_double_ratchet(
shared_secret,
local_identity_pub,
remote_identity_pub,
self_identity_priv,
is_alice
)
when is_binary(shared_secret) and is_binary(local_identity_pub) and
is_binary(remote_identity_pub) and is_binary(self_identity_priv) and
is_alice in [0, 1] do
@nif.dr_init(
shared_secret,
local_identity_pub,
remote_identity_pub,
self_identity_priv,
is_alice
)
end
@spec dr_encrypt_message(binary(), binary()) ::
{:ok, {binary(), binary()}} | {:error, term()}
def dr_encrypt_message(dr_session, message)
when is_binary(dr_session) and is_binary(message) do
@nif.dr_encrypt(dr_session, message)
end
@spec dr_decrypt_message(binary(), binary()) ::
{:ok, {binary(), binary()}} | {:error, term()}
def dr_decrypt_message(dr_session, ciphertext)
when is_binary(dr_session) and is_binary(ciphertext) do
@nif.dr_decrypt(dr_session, ciphertext)
end
@doc """
Encrypt Alice's first message and wrap it in a PreKeySignalMessage envelope
so Bob can recover the X3DH shared secret before decrypting.
`pre_key_info` is a 4-tuple `{registration_id, one_time_pre_key_id_or_nil,
signed_pre_key_id, alice_x3dh_ephemeral_pub}`. The ephemeral pub is the
32-byte X25519 key returned by `process_pre_key_bundle/2`.
Returns `{:ok, {pksm_wire_bytes, new_session}}` on success.
"""
@spec dr_encrypt_prekey(binary(), binary(),
{non_neg_integer(), non_neg_integer() | nil, non_neg_integer(), binary()}) ::
{:ok, {binary(), binary()}} | {:error, term()}
def dr_encrypt_prekey(dr_session, message,
{registration_id, opk_id, spk_id, alice_ephemeral_pub} = _pre_key_info)
when is_binary(dr_session) and is_binary(message) and
is_integer(registration_id) and (is_nil(opk_id) or is_integer(opk_id)) and
is_integer(spk_id) and is_binary(alice_ephemeral_pub) do
opk_term = if opk_id == nil, do: :undefined, else: opk_id
@nif.dr_encrypt_prekey(dr_session, message,
{registration_id, opk_term, spk_id, alice_ephemeral_pub})
end
@doc """
Decode a PreKeySignalMessage wire envelope produced by `dr_encrypt_prekey/3`.
Returns
`{:ok, {registration_id, base_key, identity_key, one_time_pre_key_id_or_nil,
signed_pre_key_id, inner_dr_message}}`
on success, or `{:error, :malformed_message}` on malformed input.
Bob's typical flow:
1. `pksm_decode/1` to extract fields and the inner DR message.
2. Look up his SPK + OPK private keys by id.
3. `process_pre_key_bundle_bob/5` to derive the same SK Alice has.
4. `init_double_ratchet/5` (as Bob, `is_alice = 0`).
5. `dr_decrypt_message/2` on the inner DR message.
"""
@spec pksm_decode(binary()) ::
{:ok, {non_neg_integer(), binary(), binary(),
non_neg_integer() | nil, non_neg_integer(), binary()}}
| {:error, term()}
def pksm_decode(wire) when is_binary(wire) do
case @nif.pksm_decode(wire) do
{:ok, {reg, base, id, :undefined, spk, msg}} ->
{:ok, {reg, base, id, nil, spk, msg}}
other ->
other
end
end
end