MobDev.Plugin.Sign (mob_dev v0.7.7)

Copy Markdown View Source

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 native build reads from the plugin (build_inputs/2), including the manifest bytes themselves, plus the build-input coverage marker (see below).
  3. Building the canonical payload (sorted file hashes + envelope version).
  4. Signing the canonical encoding of the payload via Crypto.sign/2.
  5. Writing a binary priv/mob_plugin.sig containing the signature and the signed file_hashes list, so verifiers can check integrity without needing to Code.eval_file the 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 malicious priv/mob_plugin.exs execute arbitrary code at build time. Refused by MobDev.Plugin.Verify since 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_hashes includes priv/mob_plugin.exs; envelope on disk embeds file_hashes alongside 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

file_hash()

@type file_hash() :: binary()

SHA-256 digest of a single file (raw 32-byte binary).

file_hashes()

@type file_hashes() :: [{rel_path(), file_hash()}]

Sorted list of {relative_path, sha256} tuples.

rel_path()

@type rel_path() :: String.t()

Relative path inside the plugin directory.

Functions

build_inputs(plugin_dir, manifest)

@spec build_inputs(Path.t(), map() | nil) :: [rel_path()]

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.exs itself (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>, default native_dir applied), android.jni_source, and lang: :cpp_archive sources: — and every file under each one's directory, whatever its extension, because a quoted #include/@import resolves 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.

build_payload(file_hashes)

@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.

compute_file_hashes(plugin_dir, manifest)

@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.

declares_build_input_coverage?(file_hashes)

@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.

envelope_version()

@spec envelope_version() :: integer()

Current signing envelope version.

manifest_path(plugin_dir)

@spec manifest_path(Path.t()) :: Path.t()

Relative path inside a plugin dir where the manifest lives.

sign_plugin(plugin_dir, priv_key)

@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.

signature_path(plugin_dir)

@spec signature_path(Path.t()) :: Path.t()

Relative path inside a plugin dir where the signature lives.