MobDev.Plugin.Verify (mob_dev v0.7.21)

Copy Markdown View Source

Host-side signature verification for activated mob plugins.

Given a plugin directory, this module:

  1. Loads priv/mob_plugin.sig (the signed envelope).
  2. Loads priv/mob_plugin.pub (the plugin author's public key).
  3. Rebuilds the canonical payload from the envelope's embedded file_hashes list and runs Crypto.verify/3 — proving the envelope on disk came from the author.
  4. Re-hashes each file the envelope declares and compares against the signed hash — proving the on-disk state has not been tampered with since the author signed it.
  5. When the signed list carries the build-input coverage marker (MOB-297, see Sign), evaluates the now-verified manifest and requires every Sign.build_inputs/2 file to be listed — proving no file the build reads was added outside the signature. Envelopes without the marker (signed before MOB-297) skip this step.

Because the envelope carries file_hashes on disk (v2, see Sign moduledoc for the version history), steps 1–4 do not need the eval'd manifest map — so the safe order is verify → then eval, and step 5's eval runs only on manifest bytes that already matched their signed hash. This closes the MOB-74 class of bug where a malicious priv/mob_plugin.exs could execute arbitrary code during plugin activation because the eval ran before the signature check.

Failure modes are distinguished:

  • :missing_signature — no priv/mob_plugin.sig.
  • :missing_pubkey — no priv/mob_plugin.pub.
  • :invalid_signature — sig present but doesn't verify, or on-disk files no longer match the signed hashes (tamper detected), or a build input is missing from a coverage-marked signature (or its manifest cannot be evaluated to check), or the envelope is malformed.
  • :envelope_v1_unsupported — a legacy v1 envelope was found. v1 verification required the eval'd manifest to rebuild the payload, which is the very bug we are closing, so every v1 envelope is refused without evaluating anything (MOB-301 removed the MOB-287 transition that accepted some). Consumers mix deps.update <plugin> to a v2-signed release; authors re-sign with mix mob.plugin.sign on mob_dev 0.7.2 or later.

Trust (mapping a verified public key to "the host operator approved it") lives in TrustStore and is layered on top of this module.

Summary

Types

A decoded v2 envelope.

Errors load_envelope/1 can return.

Errors load_pubkey/1 can return.

Errors verify_plugin/1 can return.

Functions

Loads and decodes the signature envelope from priv/mob_plugin.sig.

Loads the raw 32-byte public key from priv/mob_plugin.pub.

Back-compat shim for mob 0.8.x and the SignatureGate.maybe_print_unsafe_banner/1 path, both of which used the v1 helper that returned just the raw signature. Now returns the same 64-byte signature but extracted from a v2 envelope. Callers that need the full envelope should use load_envelope/1.

Verifies the plugin, then loads and evaluates the manifest.

Verifies that the plugin in plugin_dir has a valid v2 signature and that the on-disk files match what the author signed.

Types

envelope()

@type envelope() :: %{
  signature: MobDev.Plugin.Crypto.signature(),
  file_hashes: MobDev.Plugin.Sign.file_hashes(),
  envelope_version: 2
}

A decoded v2 envelope.

envelope_error()

@type envelope_error() :: :missing | :corrupt | :envelope_v1_unsupported

Errors load_envelope/1 can return.

pubkey_error()

@type pubkey_error() :: :missing | :malformed

Errors load_pubkey/1 can return.

verify_error()

@type verify_error() ::
  :missing_signature
  | :missing_pubkey
  | :invalid_signature
  | :envelope_v1_unsupported

Errors verify_plugin/1 can return.

Functions

load_envelope(plugin_dir)

@spec load_envelope(Path.t()) :: {:ok, envelope()} | {:error, envelope_error()}

Loads and decodes the signature envelope from priv/mob_plugin.sig.

Returns the full envelope map (v2 shape) on success, or a distinguished error. A v1 envelope on disk is reported as :envelope_v1_unsupported so the caller can print an update hint — v1 required the eval'd manifest to verify, which is the bug MOB-74 closes.

load_pubkey(plugin_dir)

@spec load_pubkey(Path.t()) ::
  {:ok, MobDev.Plugin.Crypto.pub_key()} | {:error, pubkey_error()}

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.

load_signature(plugin_dir)

@spec load_signature(Path.t()) ::
  {:ok, MobDev.Plugin.Crypto.signature()} | {:error, envelope_error()}

Back-compat shim for mob 0.8.x and the SignatureGate.maybe_print_unsafe_banner/1 path, both of which used the v1 helper that returned just the raw signature. Now returns the same 64-byte signature but extracted from a v2 envelope. Callers that need the full envelope should use load_envelope/1.

load_verified(plugin_dir, opts \\ [])

@spec load_verified(
  Path.t(),
  keyword()
) :: {:ok, map() | nil} | {:error, verify_error() | String.t()}

Verifies the plugin, then loads and evaluates the manifest.

This is the safe consumer-side path: if verify_plugin/1 refuses, Manifest.load/1 is never called and Code.eval_file/1 on the potentially-malicious priv/mob_plugin.exs never runs.

Options

  • :acknowledged_unsafe (default false) — when true, an unsigned plugin ({:error, :missing_signature}) is loaded anyway. This is the documented escape hatch for :acknowledge_unsafe_plugins (see SignatureGate.check_plugin/4) — the user has explicitly opted into running an unsigned plugin's manifest, and the SignatureGate banner already warns them. Every OTHER failure (:invalid_signature, :missing_pubkey, :envelope_v1_unsupported) still refuses the eval — those are the tamper / mis-key / legacy-envelope cases, not the "unsigned during dev" case.

For plugins with no priv/mob_plugin.exs at all (tier-0 plugins), returns {:ok, nil} without requiring a signature — the acknowledged_unsafe flag has no effect here (no manifest to load).

verify_plugin(plugin_dir)

@spec verify_plugin(Path.t()) :: :ok | {:error, verify_error()}

Verifies that the plugin in plugin_dir has a valid v2 signature and that the on-disk files match what the author signed.

Steps 1–4 (see the moduledoc) run entirely off the envelope's embedded file_hashes list — the manifest is one of those files, and rehashing its bytes on disk detects tampering without any Code.eval_file call. That is the MOB-74 fix: verification is safe to run before eval, closing the RCE window where a malicious priv/mob_plugin.exs could execute arbitrary code during plugin activation.

For a signature carrying the build-input coverage marker (MOB-297), step 5 then evaluates the manifest — only after its bytes matched the signed hash and the signature verified — to recompute Sign.build_inputs/2 and refuse any build input the signature omits.

Returns :ok on success or one of the distinguished error reasons (see 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.