Packages

An independent, OTP-native WhatsApp Web client for Elixir — Noise handshake, Signal end-to-end encryption, multi-device.

Retired package: Security issue - CVE-2026-48063: spoofed self-only protocolMessages. Upgrade to >= 0.4.1

Current section

Files

Jump to
amarula lib amarula protocol app_state patch.ex
Raw

lib/amarula/protocol/app_state/patch.ex

defmodule Amarula.Protocol.AppState.Patch do
@moduledoc """
Decode app-state syncd mutations/patches into `SyncActionData` mutations and the
next LTHash collection state. Ported from Baileys `decodeSyncdMutations` /
`decodeSyncdPatch` (`chat-utils.ts`).
Pure given a key lookup: `get_key.(key_id_base64)` → the expanded
`Amarula.Protocol.AppState.Keys` (or `nil` if the app-state-sync key isn't
available yet — then the record is skipped and the caller may park the
collection until `APP_STATE_SYNC_KEY_SHARE` arrives).
Collection state is `%{version, hash, index_value_map}` where `index_value_map`
maps `base64(index_mac) => value_mac` (so a later REMOVE can subtract the right
value mac from the LTHash).
"""
alias Amarula.Protocol.AppState.{LTHash, Mutation}
alias Amarula.Protocol.Proto
@type state :: %{version: non_neg_integer(), hash: binary(), index_value_map: map()}
@type mutation :: %{operation: :set | :remove, action: struct(), index: [String.t()]}
@doc "A fresh, empty collection state."
@spec new_state() :: state()
def new_state, do: %{version: 0, hash: LTHash.zero(), index_value_map: %{}}
@doc """
Decode `records` (a list of `SyncdRecord` / `SyncdMutation`) against `state`,
returning `{:ok, mutations, new_state}`. `get_key` resolves a record's
app-state-sync key. With `validate_macs: true` (default), the value MAC and
index MAC are checked; a record that fails MAC, can't decrypt, or has a missing
key is skipped.
"""
@spec decode_mutations([struct()], state(), (String.t() -> map() | nil), keyword()) ::
{:ok, [mutation()], state()}
def decode_mutations(records, state, get_key, opts \\ []) do
validate? = Keyword.get(opts, :validate_macs, true)
{mutations, adds, subs, ivm} =
Enum.reduce(records, {[], [], [], state.index_value_map}, fn rec, acc ->
fold_record(rec, get_key, validate?, acc)
end)
new_hash = LTHash.subtract_then_add(state.hash, subs, adds)
new_state = %{state | hash: new_hash, index_value_map: ivm}
{:ok, Enum.reverse(mutations), new_state}
end
# --- internals ---
defp fold_record(rec, get_key, validate?, {muts, adds, subs, ivm} = acc) do
{operation, record} = operation_and_record(rec)
with key when not is_nil(key) <- get_key.(key_id_b64(record)),
{:ok, mutation, value_mac, index_mac} <- decode_one(operation, record, key, validate?) do
{ivm, adds, subs} = mix(ivm, adds, subs, operation, index_mac, value_mac)
{[mutation | muts], adds, subs, ivm}
else
_ -> acc
end
end
# A SyncdMutation carries an operation + record; a bare SyncdRecord is a SET.
defp operation_and_record(%Proto.SyncdMutation{operation: op, record: record}) do
{op_atom(op), record}
end
defp operation_and_record(%Proto.SyncdRecord{} = record), do: {:set, record}
defp decode_one(operation, record, key, validate?) do
blob = record.value.blob
enc = binary_part(blob, 0, byte_size(blob) - 32)
value_mac = binary_part(blob, byte_size(blob) - 32, 32)
key_id = record.keyId.id
with :ok <- check_value_mac(validate?, operation, enc, key_id, key.value_mac_key, value_mac),
plaintext <- Mutation.decrypt_value(enc, key.value_encryption_key),
action = Proto.SyncActionData.decode(plaintext),
:ok <- check_index_mac(validate?, action.index, key.index_key, record.index.blob) do
mutation = %{operation: operation, action: action, index: decode_index(action.index)}
{:ok, mutation, value_mac, record.index.blob}
end
end
defp check_value_mac(false, _op, _enc, _key_id, _k, _mac), do: :ok
defp check_value_mac(true, op, enc, key_id, value_mac_key, expected) do
if Mutation.generate_mac(op, enc, key_id, value_mac_key) == expected, do: :ok, else: :bad_mac
end
defp check_index_mac(false, _index, _key, _expected), do: :ok
defp check_index_mac(true, index, index_key, expected) do
if :crypto.mac(:hmac, :sha256, index_key, index) == expected, do: :ok, else: :bad_index_mac
end
# LTHash mix: SET adds its value mac (and subtracts the prior one for this
# index, if any); REMOVE subtracts the prior value mac and drops the index.
defp mix(ivm, adds, subs, :set, index_mac, value_mac) do
k = Base.encode64(index_mac)
subs = if prev = Map.get(ivm, k), do: [prev | subs], else: subs
{Map.put(ivm, k, value_mac), [value_mac | adds], subs}
end
defp mix(ivm, adds, subs, :remove, index_mac, _value_mac) do
k = Base.encode64(index_mac)
case Map.pop(ivm, k) do
{nil, ivm} -> {ivm, adds, subs}
{prev, ivm} -> {ivm, adds, [prev | subs]}
end
end
defp decode_index(index_bytes) do
case Jason.decode(index_bytes) do
{:ok, list} when is_list(list) -> list
_ -> [to_string(index_bytes)]
end
end
defp key_id_b64(record), do: Base.encode64(record.keyId.id)
defp op_atom(:SET), do: :set
defp op_atom(:REMOVE), do: :remove
defp op_atom(0), do: :set
defp op_atom(1), do: :remove
end