Host-side signature verification for activated mob plugins.
Given a plugin directory, this module:
- Loads
priv/mob_plugin.sig(the signed envelope). - Loads
priv/mob_plugin.pub(the plugin author's public key). - Rebuilds the canonical payload from the envelope's embedded
file_hasheslist and runsCrypto.verify/3— proving the envelope on disk came from the author. - 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.
- When the signed list carries the build-input coverage marker
(MOB-297, see
Sign), evaluates the now-verified manifest and requires everySign.build_inputs/2file 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— nopriv/mob_plugin.sig.:missing_pubkey— nopriv/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). Consumersmix deps.update <plugin>to a v2-signed release; authors re-sign withmix mob.plugin.signon 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
@type envelope() :: %{ signature: MobDev.Plugin.Crypto.signature(), file_hashes: MobDev.Plugin.Sign.file_hashes(), envelope_version: 2 }
A decoded v2 envelope.
@type envelope_error() :: :missing | :corrupt | :envelope_v1_unsupported
Errors load_envelope/1 can return.
@type pubkey_error() :: :missing | :malformed
Errors load_pubkey/1 can return.
@type verify_error() ::
:missing_signature
| :missing_pubkey
| :invalid_signature
| :envelope_v1_unsupported
Errors verify_plugin/1 can return.
Functions
@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.
@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.
@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.
@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(defaultfalse) — whentrue, an unsigned plugin ({:error, :missing_signature}) is loaded anyway. This is the documented escape hatch for:acknowledge_unsafe_plugins(seeSignatureGate.check_plugin/4) — the user has explicitly opted into running an unsigned plugin's manifest, and theSignatureGatebanner 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).
@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.