Packages
mob_dev
0.6.21
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/sign.ex
defmodule MobDev.Plugin.Sign do
@moduledoc """
Author-side signing workflow for mob plugins.
Produces `priv/mob_plugin.sig` for a plugin directory by:
1. Loading the manifest (`priv/mob_plugin.exs`).
2. Computing SHA-256 hashes for every file the manifest references
(Swift sources, Android bridge/JNI sources, NIF native_dir contents).
3. Building the canonical payload (manifest + sorted file hashes).
4. Signing the canonical encoding of the payload via `Crypto.sign/2`.
5. Writing a binary `priv/mob_plugin.sig` containing the signature.
Pure helpers are exposed for tests: `compute_file_hashes/2` and
`build_payload/2` are deterministic given their inputs.
"""
alias MobDev.Plugin.{Crypto, Manifest}
@envelope_version 1
@signature_file "priv/mob_plugin.sig"
@manifest_file "priv/mob_plugin.exs"
# File extensions to include when a manifest entry points at a
# `:native_dir` (NIF C/C++/Zig sources + headers). The set is fixed
# because the build pipeline only ever compiles these extensions.
@nif_extensions [".c", ".h", ".cpp", ".zig"]
@typedoc "Relative path inside the plugin directory."
@type rel_path :: String.t()
@typedoc "SHA-256 digest of a single file (raw 32-byte binary)."
@type file_hash :: binary()
@typedoc "Sorted list of `{relative_path, sha256}` tuples."
@type file_hashes :: [{rel_path(), file_hash()}]
@doc """
Returns the relative-path-sorted list of `{relative_path, sha256}`
tuples for every file the manifest references.
Pure given the plugin dir + manifest. The set covers:
- `manifest.ios.swift_files` — single files (list of paths).
- `manifest.android.bridge_kt` and `manifest.android.jni_source` —
single paths each.
- `manifest.android.res_files` — the resource files copied verbatim
into the app `res/` tree (list of paths).
- `manifest.nifs[].native_dir` — recursive over `.c`, `.h`, `.cpp`,
`.zig` files inside. This is the only case where a directory is
expanded.
Other manifest fields are either name-only (component atoms,
`swift_struct`) or pure data (plist keys, permission strings,
framework names) and are covered by the manifest term itself being
part of the signed payload.
Missing files are skipped silently — `Validator.validate_plugin/3`
is responsible for refusing to publish a plugin with missing
declared paths, so the signing surface assumes paths that exist.
"""
@spec compute_file_hashes(Path.t(), map() | nil) :: file_hashes()
def compute_file_hashes(_plugin_dir, nil), do: []
def compute_file_hashes(plugin_dir, manifest) when is_map(manifest) do
manifest
|> referenced_files(plugin_dir)
|> Enum.uniq()
|> Enum.sort()
|> Enum.map(fn rel -> {rel, sha256!(Path.join(plugin_dir, rel))} end)
end
@doc """
Builds the canonical payload term that gets signed.
Shape:
%{
manifest: <the loaded mob_plugin manifest>,
file_hashes: [{rel_path, sha256}, ...],
envelope_version: 1
}
Authoritative for what's inside the signature — any new field added
here needs both author and host updates.
"""
@spec build_payload(map() | nil, file_hashes()) :: map()
def build_payload(manifest, file_hashes) do
%{
manifest: manifest,
file_hashes: file_hashes,
envelope_version: @envelope_version
}
end
# CAVEAT — atom keys in the signed terms (this payload + the sig envelope in
# `sign_plugin/2`) must also appear in `MobDev.Plugin.Verify`'s
# `@envelope_atoms`. Verify decodes the .sig with binary_to_term(_, [:safe]),
# which won't *create* atoms — any atom key it hasn't interned at load time
# makes a valid signature decode as :corrupt, intermittently (depends on what
# else loaded first). Adding a key here without updating @envelope_atoms
# reintroduces that bug. See decisions/2026-05-31-verify-safe-atom-intern.md.
@doc """
Signs `plugin_dir` and writes `priv/mob_plugin.sig`.
Orchestrates the full author workflow: loads the manifest, computes
file hashes, builds the payload, signs it, wraps the signature in the
envelope binary, and writes the file. Returns `:ok` on success or
`{:error, reason}` if the manifest is missing/invalid.
"""
@spec sign_plugin(Path.t(), Crypto.priv_key()) :: :ok | {:error, term()}
def sign_plugin(plugin_dir, priv_key) when is_binary(priv_key) do
with {:ok, manifest} <- Manifest.load(plugin_dir),
:ok <- refuse_if_no_manifest(manifest, plugin_dir) do
file_hashes = compute_file_hashes(plugin_dir, manifest)
payload = build_payload(manifest, file_hashes)
signature = Crypto.sign(payload, priv_key)
envelope = %{signature: signature, envelope_version: @envelope_version}
sig_path = Path.join(plugin_dir, @signature_file)
File.mkdir_p!(Path.dirname(sig_path))
File.write!(sig_path, Crypto.canonical_encode(envelope))
:ok
end
end
@doc "Relative path inside a plugin dir where the signature lives."
@spec signature_path(Path.t()) :: Path.t()
def signature_path(plugin_dir), do: Path.join(plugin_dir, @signature_file)
@doc "Relative path inside a plugin dir where the manifest lives."
@spec manifest_path(Path.t()) :: Path.t()
def manifest_path(plugin_dir), do: Path.join(plugin_dir, @manifest_file)
@doc "Current signing envelope version."
@spec envelope_version() :: integer()
def envelope_version, do: @envelope_version
defp refuse_if_no_manifest(nil, plugin_dir),
do: {:error, "no priv/mob_plugin.exs in #{plugin_dir}"}
defp refuse_if_no_manifest(_manifest, _plugin_dir), do: :ok
# ── referenced-file collection ────────────────────────────────────────────
defp referenced_files(manifest, plugin_dir) do
swift = list_of_strings(get_in(manifest, [:ios, :swift_files]))
android =
[
get_in(manifest, [:android, :bridge_kt]),
get_in(manifest, [:android, :jni_source])
]
|> Enum.filter(&is_binary/1)
# res_files are copied verbatim into the app res/ tree, so their bytes must
# be tamper-evident too (same as bridge_kt / jni_source).
res = list_of_strings(get_in(manifest, [:android, :res_files]))
nifs = nif_files(manifest, plugin_dir)
swift ++ android ++ res ++ nifs
end
defp nif_files(manifest, plugin_dir) do
for nif <- Map.get(manifest, :nifs, []) || [],
is_map(nif),
rel = nif[:native_dir],
is_binary(rel),
path <- expand_native_dir(plugin_dir, rel) do
path
end
end
defp expand_native_dir(plugin_dir, rel_dir) do
abs_dir = Path.join(plugin_dir, rel_dir)
if File.dir?(abs_dir) do
abs_dir
|> Path.join("**/*")
|> Path.wildcard()
|> Enum.filter(&File.regular?/1)
|> Enum.filter(fn p -> Path.extname(p) in @nif_extensions end)
|> Enum.map(&Path.relative_to(&1, plugin_dir))
else
[]
end
end
defp list_of_strings(value) do
for s <- List.wrap(value), is_binary(s), do: s
end
defp sha256!(path) do
case File.read(path) do
{:ok, bytes} -> :crypto.hash(:sha256, bytes)
{:error, _} -> :crypto.hash(:sha256, <<>>)
end
end
end