Packages
mob_dev
0.6.22
0.6.23
0.6.22
0.6.21
0.6.20
0.6.19
0.6.18
0.6.17
0.6.16
0.6.15
0.6.14
0.6.13
0.6.12
0.6.11
0.6.10
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.2
0.6.1
0.6.0
0.5.17
0.5.16
0.5.15
0.5.14
0.5.13
0.5.12
0.5.11
0.5.10
0.5.9
0.5.8
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.0
0.3.37
0.3.35
0.3.34
0.3.33
0.3.28
0.3.26
0.3.23
0.3.21
0.3.19
0.3.18
0.3.17
0.3.16
0.3.15
0.3.14
0.3.13
0.3.12
0.3.11
0.3.10
0.3.9
0.3.8
0.3.7
0.3.6
0.3.5
0.3.4
0.3.3
0.3.2
0.3.1
0.3.0
0.2.18
0.2.17
0.2.15
0.2.14
0.2.13
0.2.12
0.2.11
0.2.10
0.2.9
0.2.8
0.2.7
0.2.6
0.2.5
0.2.4
0.2.3
0.2.2
0.2.1
0.2.0
0.1.0
Development tooling for the Mob mobile framework
Current section
Files
Jump to
Current section
Files
lib/mob_dev/plugin/verify.ex
defmodule MobDev.Plugin.Verify do
@moduledoc """
Host-side signature verification for activated mob plugins.
Given a plugin directory + its loaded manifest, this module:
1. Loads `priv/mob_plugin.sig` (the signed envelope).
2. Loads `priv/mob_plugin.pub` (the plugin author's public key).
3. Recomputes the file-hash list via `Sign.compute_file_hashes/2`.
4. Reconstructs the canonical payload and runs `Crypto.verify/3`.
Failure modes are distinguished:
- `:missing_signature` — no `priv/mob_plugin.sig`.
- `:missing_pubkey` — no `priv/mob_plugin.pub`.
- `:invalid_signature` — sig file present but the signature doesn't
verify against the canonical payload reconstructed from disk. This
is the failure mode for both manifest tampering and source-file
tampering: the recomputed `file_hashes` no longer match what was
signed, so the payload differs and the signature check fails.
Trust (mapping a verified public key to "the host operator approved
it") lives in `TrustStore` and is layered on top of this module.
"""
alias MobDev.Plugin.{Crypto, Sign}
@signature_file "priv/mob_plugin.sig"
@pubkey_file "priv/mob_plugin.pub"
# Atom keys that appear in the signed envelope term (see `Sign.sign_plugin/2`).
# `load_signature/1` decodes the envelope with `binary_to_term(_, [:safe])`,
# which refuses to *create* atoms — every atom in the encoded term must
# already exist in the runtime atom table or the decode raises `badarg` and a
# valid signature is misreported as `:corrupt`. `Verify` matches `:signature`
# directly, but nothing here references `:envelope_version`; only `Sign` did.
# Because `verify_plugin/2` calls `load_signature/1` *before* it ever touches
# `Sign`, decoding succeeded or failed depending on whether `Sign` happened to
# be loaded earlier in the BEAM — a load-order-dependent intermittent
# "invalid signature" across builds. Naming the atoms in this module-level
# literal interns them at `Verify`-load (guaranteed before any decode), making
# the decode deterministic while keeping `:safe` (sig files are
# attacker-controlled). See decisions/2026-05-31-verify-safe-atom-intern.md.
@envelope_atoms [:signature, :envelope_version]
@typedoc "Errors `load_signature/1` can return."
@type sig_error :: :missing | :corrupt
@typedoc "Errors `load_pubkey/1` can return."
@type pubkey_error :: :missing | :malformed
@typedoc "Errors `verify_plugin/2` can return."
@type verify_error :: :missing_signature | :missing_pubkey | :invalid_signature
@doc """
Loads the raw 64-byte signature from `priv/mob_plugin.sig`.
The file is the `Crypto.canonical_encode/1` of an envelope map
(`%{signature: <64-byte sig>, envelope_version: 1}`); this function
decodes the envelope and returns the inner signature binary.
"""
@spec load_signature(Path.t()) :: {:ok, Crypto.signature()} | {:error, sig_error()}
def load_signature(plugin_dir) do
path = Path.join(plugin_dir, @signature_file)
case File.read(path) do
{:ok, bytes} -> decode_signature_envelope(bytes)
{:error, :enoent} -> {:error, :missing}
{:error, _} -> {:error, :corrupt}
end
end
defp decode_signature_envelope(bytes) do
{:ok, decode_envelope_term!(bytes)}
rescue
_ -> {:error, :corrupt}
end
defp decode_envelope_term!(bytes) do
# Touch the literal so the envelope atoms are guaranteed interned before the
# :safe decode runs (see @envelope_atoms above).
_ = @envelope_atoms
case :erlang.binary_to_term(bytes, [:safe]) do
%{signature: sig} when is_binary(sig) and byte_size(sig) == 64 -> sig
_ -> raise "corrupt"
end
end
@doc false
# Atoms the signed envelope can contain; exposed so the interning guarantee is
# regression-testable (see verify_test.exs).
@spec envelope_atoms() :: [atom()]
def envelope_atoms, do: @envelope_atoms
@doc """
Loads the raw 32-byte public key from `priv/mob_plugin.pub`.
Format: a single line of base64 (with `=` padding) of the raw 32-byte
Ed25519 public key, optionally followed by a trailing newline. Plain
text so plugin authors can `cat` it or paste it into a release note.
"""
@spec load_pubkey(Path.t()) :: {:ok, Crypto.pub_key()} | {:error, pubkey_error()}
def load_pubkey(plugin_dir) do
path = Path.join(plugin_dir, @pubkey_file)
case File.read(path) do
{:ok, contents} -> decode_pubkey(contents)
{:error, :enoent} -> {:error, :missing}
{:error, _} -> {:error, :malformed}
end
end
defp decode_pubkey(contents) do
trimmed = String.trim(contents)
case Base.decode64(trimmed) do
{:ok, pub} when byte_size(pub) == 32 -> {:ok, pub}
_ -> {:error, :malformed}
end
end
@doc """
Verifies that the plugin in `plugin_dir` has a valid signature for the
given `manifest` + the current file contents on disk.
Returns `:ok` on success or one of the distinguished error reasons
(see `t:verify_error/0`). The caller is responsible for any trust
decision; this function only proves that the bytes on disk match
what the plugin author signed.
"""
@spec verify_plugin(Path.t(), map() | nil) :: :ok | {:error, verify_error()}
def verify_plugin(plugin_dir, manifest) do
with {:ok, signature} <- need(load_signature(plugin_dir), :missing_signature),
{:ok, pub} <- need(load_pubkey(plugin_dir), :missing_pubkey),
file_hashes = Sign.compute_file_hashes(plugin_dir, manifest),
payload = Sign.build_payload(manifest, file_hashes),
:ok <- normalise_verify(Crypto.verify(payload, signature, pub)) do
:ok
end
end
# Both load_signature and load_pubkey return :missing for a missing file;
# other errors (:corrupt, :malformed) collapse into :invalid_signature
# because they all mean "the bytes that should certify this plugin are
# not usable".
defp need({:ok, value}, _missing_reason), do: {:ok, value}
defp need({:error, :missing}, missing_reason), do: {:error, missing_reason}
defp need({:error, _}, _missing_reason), do: {:error, :invalid_signature}
defp normalise_verify(:ok), do: :ok
defp normalise_verify({:error, :invalid_signature}), do: {:error, :invalid_signature}
end