MobDev.Plugin.Verify (mob_dev v0.7.5)

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. verify_plugin/1 always reports it; load_verified/2 hands it to MobDev.Plugin.V1Transition, which accepts it only under the MOB-287 transition rule and otherwise keeps this refusal. 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 a re-sign 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 cases, not the "unsigned during dev" case.
  • :scms, :lock, :deps_path, :trust_map — provenance inputs for the transitional v1 rule (see MobDev.Plugin.V1Transition); default to the current Mix project's values.

A v1 envelope is handed to MobDev.Plugin.V1Transition.load/2, which evaluates the manifest only for a hexpm Hex dependency whose public key is trusted, then verifies the v1 signature (MOB-287). Every other v1 envelope is refused unevaluated with :envelope_v1_unsupported.

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.