Author-side signing workflow for mob plugins.
Produces priv/mob_plugin.sig for a plugin directory by:
- Loading the manifest (
priv/mob_plugin.exs). - Computing SHA-256 hashes for every file the native build reads from
the plugin (
build_inputs/2), including the manifest bytes themselves, plus the build-input coverage marker (see below). - Building the canonical payload (sorted file hashes + envelope version).
- Signing the canonical encoding of the payload via
Crypto.sign/2. - Writing a binary
priv/mob_plugin.sigcontaining the signature and the signed file_hashes list, so verifiers can check integrity without needing toCode.eval_filethe manifest first (see MOB-74).
build_inputs/2, compute_file_hashes/2 and build_payload/1 are
exposed for tests and for Verify; they are deterministic given their
inputs and the plugin directory's contents.
Envelope versions
- v1 (deprecated, MOB-74) — payload was
%{manifest: <map>, file_hashes: [...]}and the envelope on disk carried only the signature. Verifiers needed the eval'd manifest map to rebuild the payload, so the eval had to run before verification could — letting a maliciouspriv/mob_plugin.exsexecute arbitrary code at build time. Refused byMobDev.Plugin.Verifysince mob_dev 0.7.2 (the MOB-287 transition that accepted some Hex plugins was removed by MOB-301). Never produced any more. - v2 (current) — payload is
%{file_hashes: [...], envelope_version: 2};file_hashesincludespriv/mob_plugin.exs; envelope on disk embedsfile_hashesalongside the signature. Verifiers can check every on-disk file against the signed hashes without touching the manifest map, so verification is safe to run before eval.
Build-input coverage (MOB-297)
Signatures made before MOB-297 listed only some of the files the build
reads (a native_dir was filtered to .c/.h/.cpp/.zig, so every
iOS .m NIF was unsigned, and cpp_archive sources were skipped), and
the verifier checked only listed files. A signature now lists every
build_inputs/2 file and a coverage marker entry — a file_hashes
entry for a path that never exists, hashed as empty bytes. Because the
marker is inside the signed list it cannot be stripped, and a verifier
that sees it also requires every build input to be listed. mob_dev 0.7.2
rehashes the marker path, finds nothing, gets the empty-bytes hash and
accepts it, so hosts that have not upgraded still verify new signatures.
See decisions/2026-09-30-plugin-signature-coverage.md.
Summary
Types
SHA-256 digest of a single file (raw 32-byte binary).
Sorted list of {relative_path, sha256} tuples.
Relative path inside the plugin directory.
Functions
Plugin-relative paths of every file the native build reads from the plugin, sorted. The signer lists all of them; a verifier seeing the coverage marker refuses a signature that omits any.
Builds the canonical payload term that gets signed.
Returns the relative-path-sorted {relative_path, sha256} list a new
signature carries: every build_inputs/2 file plus the coverage marker.
True when a signed file_hashes list carries the build-input coverage
marker, i.e. the signer listed every build_inputs/2 file.
Current signing envelope version.
Relative path inside a plugin dir where the manifest lives.
Signs plugin_dir and writes priv/mob_plugin.sig.
Relative path inside a plugin dir where the signature lives.
Types
Functions
Plugin-relative paths of every file the native build reads from the plugin, sorted. The signer lists all of them; a verifier seeing the coverage marker refuses a signature that omits any.
Derived from the same MobDev.Plugin.Merge gatherers the build uses,
called with an empty plugin dir so they return the declared relative
paths:
priv/mob_plugin.exsitself (evaluated by every consumer).ios.swift_files,android.bridge_kt,android.res_files.- Every compiled C-family source: each C / ObjC / Zig NIF's primary
source (
<native_dir>/<module>.<ext>, defaultnative_dirapplied),android.jni_source, andlang: :cpp_archivesources:— and every file under each one's directory, whatever its extension, because a quoted#include/@importresolves there first. migrations.migrations_dir/*.exs(copied into the host and run on device),assets.fonts,assets.images,default_font.file.
Not covered: {:dep, app, path} entries (another package's files) and
cpp_archive includes: roots. An include root can be provisioned on
the host at build time rather than shipped — mob_nx_eigen's
eigen-3.4.0 is downloaded into the plugin's own tree by its Mix
compiler — so its contents cannot be signed, and requiring them listed
would refuse the plugin on every host that has compiled it. Headers a
plugin ships belong beside its sources, where they are covered.
Directory expansion skips dotfiles: they are editor/OS litter
(.DS_Store appears on hosts that browse deps/ in Finder), and
requiring them listed would fail verification for a harmless file.
A symlink is listed as an entry and never followed. priv/mob_plugin.sig
is never an input.
@spec build_payload(file_hashes()) :: map()
Builds the canonical payload term that gets signed.
Shape:
%{
file_hashes: [{rel_path, sha256}, ...],
envelope_version: 2
}Authoritative for what's inside the signature — any new field added here needs both author and host updates.
Note that the payload no longer includes the manifest term itself
(see MOB-74). The manifest is one of the files hashed in
file_hashes, so its bytes are covered — and dropping the map from
the payload lets Verify recompute the payload without eval'ing
the manifest first.
@spec compute_file_hashes(Path.t(), map() | nil) :: file_hashes()
Returns the relative-path-sorted {relative_path, sha256} list a new
signature carries: every build_inputs/2 file plus the coverage marker.
Missing files hash as empty bytes (see sha256!/1) —
Validator.validate_plugin/3 refuses to publish a plugin with missing
declared paths, and a missing file that later appears no longer matches.
@spec declares_build_input_coverage?(file_hashes()) :: boolean()
True when a signed file_hashes list carries the build-input coverage
marker, i.e. the signer listed every build_inputs/2 file.
@spec envelope_version() :: integer()
Current signing envelope version.
Relative path inside a plugin dir where the manifest lives.
@spec sign_plugin(Path.t(), MobDev.Plugin.Crypto.priv_key()) :: :ok | {:error, term()}
Signs plugin_dir and writes priv/mob_plugin.sig.
Orchestrates the full author workflow: loads the manifest (to know
which files it references), computes file hashes (including the
manifest bytes themselves), builds the v2 payload, signs it, wraps
the signature and the file_hashes list in the envelope binary,
and writes the file. Returns :ok on success or {:error, reason}
if the manifest is missing/invalid.
The envelope carries file_hashes on disk so Verify.verify_plugin/1
can check tampering without needing to Code.eval_file the manifest
first — see MOB-74.
Relative path inside a plugin dir where the signature lives.