MOB-74 — plugin envelope v2: verify signature before eval - #81
Merged
Merged
Conversation
Before this change, `MobDev.Plugin.Manifest.load/1` called
`Code.eval_file("priv/mob_plugin.exs")` before any signature check. The
signature (v1) covered the eval'd manifest map plus a list of referenced
source hashes but NOT the manifest bytes themselves, so a malicious
plugin could put a side-effect expression alongside an innocent-looking
map literal: the eval executed the side effect on the consumer's
machine, the returning map matched what was signed, verification passed
clean, and the CVE was never surfaced. `MobDev.Plugin.activated/0`,
called from every `mix mob.deploy`, was the primary attack path.
Envelope v2 flips the trust chain:
- **Manifest bytes join the signed file_hashes list.** `priv/mob_plugin.exs`
is now hashed and signed like every other source. Tampering with the
manifest bytes shifts its hash and fails verification.
- **The v2 envelope on disk carries the file_hashes list alongside the
signature.** Payload signed = `%{file_hashes: [...], envelope_version: 2}`
— the eval'd manifest map is no longer in the payload.
- **`Verify.verify_plugin/1` no longer takes a manifest arg.** It reads
the envelope, rebuilds the payload from the envelope's own
file_hashes, and re-hashes each listed file on disk. Neither step
calls `Code.eval_file/1`.
- **`Verify.load_verified/1`** is the safe consumer entry point:
verify → then eval. A plugin that fails verification never has its
`.exs` bytes evaluated on the consumer's machine.
- **v1 envelopes are refused** with a distinguished
`:envelope_v1_unsupported` error; accepting them silently reopens
the CVE, so there is no fallback path.
Consumer migrations:
- `MobDev.Plugin.activated/0` (build-time gate — the primary CVE
surface) uses `Verify.load_verified/1`.
- `mix mob.plugins`, `mix mob.audit_plugins`, `MobDev.Plugin.Report`
same.
- `SignatureGate.check_activated/1` now uses
`Manifest.manifest_present?/1` for tier-0 detection because a nil
manifest can now also mean "verify refused eval" — the old
`is_map(manifest)` filter would have silently skipped tampered
plugins.
- `mix mob.plugin.sign` and `mix mob.plugin.keygen` still call
`Manifest.load` directly — they're author-side, no attack vector.
- `mix mob.plugin.trust` is the first-trust decision and inherently
reviews unknown code; addressed by MOB-185/186/187 (linter →
migration → static format) as separate follow-ups.
**Breaking change**: every plugin signed with mob_dev ≤ 0.7.1 must be
re-signed. First-party plugins get re-signed as part of the mob_dev
0.7.2 release; third-party authors see the friendly error message with
the re-sign hint on their next deploy against this mob_dev.
Tests: two revert-verified assertions guard the fix — the manifest-
bytes-in-file_hashes coverage (`sign_test.exs`) and the
refuse-to-eval-on-bad-sig marker file (`verify_test.exs`). Full suite
2459 pass, credo strict clean, warnings-as-errors clean. Pre-existing
mob.security_scan drift finding on ios_device exqlite is inherited
from master, not from this PR.
See decisions/2026-09-11-plugin-envelope-v2-verify-before-eval.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Pre-commit adversarial review caught a blocker in the initial commit:
`MobDev.Plugin.activated/0` routed every plugin through
`Verify.load_verified/1`, which for an unsigned plugin returned
`{:error, :missing_signature}` — so the manifest map was dropped even
though `SignatureGate.check_plugin/4` would still let acknowledged
unsigned plugins through (`if name in acknowledged, do: :ok`). Result:
downstream `Merge.with_manifests/1`, `AndroidBootstrap`, and
`RuntimeManifest` (all filter `is_map(manifest)`) silently skipped the
plugin. The documented `:acknowledge_unsafe_plugins` escape hatch was
silently broken — acknowledged plugins appeared activated but
contributed no NIFs, gradle deps, permissions, or swift files, producing
`UnsatisfiedLinkError` / missing-permission failures with no message.
Fix:
- `Verify.load_verified/2` takes `acknowledged_unsafe:` keyword. When
`true`, only `:missing_signature` maps to a successful eval — every
other failure (`:invalid_signature`, `:missing_pubkey`,
`:envelope_v1_unsupported`) still refuses. The opt-in is scoped to
"user opted into an unsigned plugin", not "user opted into arbitrary
attacker code".
- `SignatureGate.acknowledged_unsafe/0` promoted from private to
public so `MobDev.Plugin` can consume it.
- `MobDev.Plugin.activated_with_verify/0` fetches the acknowledged
list and passes `acknowledged_unsafe: true` to `load_verified/2`
for plugins that are in it. All other plugins keep the strict
verify-before-eval behavior.
- Two new tests: acknowledged-unsafe loads an unsigned manifest
(revert-verified — flips on removing the `when` clause);
acknowledged-unsafe does NOT bypass tamper detection (a mutated
file still fails `:invalid_signature`).
Also folded in reviewer notes:
- `SignatureGate.format_error/1` for `:envelope_v1_unsupported` no
longer names a specific mob_dev version (envelope v2 is the
invariant; mob_dev version is a moving target). Same fix in
CHANGELOG.
- Comment on `manifest_name/2` fallback explaining why
`String.to_atom` on a bounded set (Mix deps paths) is safe here.
- Removed a weaker duplicate test that overlapped with the
marker-file `Code.eval_file` regression guard.
- ADR extended to document the acknowledged-unsafe interaction.
Full suite 2460 pass, credo strict clean, warnings-as-errors clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- mix.plugin.sign moduledoc: was 'signature covers the loaded manifest'; v2 no longer signs the map. Rewrote to name the file_hashes list and point at Verify.verify_plugin/1. - decision record: extend the author-side allowlist to include mix mob.validate_plugin (missing from initial ADR). - CHANGELOG: consolidate MOB-74 under existing '### Fixed' (Keep-a- Changelog puts Security last; MOB-74 fits Fixed alongside MOB-71). - Two new tests locking the acknowledged_unsafe narrow scope: refuses a missing pubkey, refuses envelope_v1_unsupported. Neither should fall through to a manifest eval just because the user acknowledged an unsigned dev plugin. Full suite 2462 pass. All reviewer nits/should-fixes addressed except the branch rebase note (gh reports MERGEABLE/CLEAN, --squash on merge will keep history tidy). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Security fix. Before this change,
MobDev.Plugin.Manifest.load/1calledCode.eval_file("priv/mob_plugin.exs")before any signature check. The v1 signature covered the eval'd manifest map plus source hashes but not the manifest bytes themselves, so a malicious plugin could put a side-effect expression alongside an innocent map literal:Code.eval_fileexecuted the side effect on the consumer's machine, the returning map matched what was signed, verification passed clean — RCE at build time.MobDev.Plugin.activated/0(called from everymix mob.deploy),mix mob.plugins,mix mob.audit_plugins, andMobDev.Plugin.Reportwere all attack surfaces.Envelope v2
file_hasheslist.Sign.referenced_files/2now always prependspriv/mob_plugin.exs.%{file_hashes, envelope_version: 2}. No manifest map field — its authoritative representation is the on-disk bytes.file_hashesalongside the signature. Verifiers rebuild the payload from the envelope's own file_hashes; they don't need the eval'd manifest.Verify.verify_plugin/1(was/2) reads the envelope, verifies the signature against the reconstructed payload, then re-hashes each listed file on disk to catch tampering. NoCode.eval_filein the whole path.Verify.load_verified/2is the new safe consumer entry point: verify → then eval. A plugin that fails verification never has its.exsbytes evaluated on the consumer's machine.:envelope_v1_unsupported— accepting them silently reopens the CVE. Every plugin signed with a mob_dev that predates this change must be re-signed on the consumer's next build.Consumer migrations
MobDev.Plugin.activated/0→Verify.load_verified/2(primary CVE surface).mix mob.plugins,mix mob.audit_plugins,MobDev.Plugin.Reportsame.SignatureGate.check_activated/1tier-0 filter switched fromis_map(manifest)toManifest.manifest_present?/1— a nil manifest can now also mean "verify refused eval", and skipping those silently would defeat the fix.mix mob.plugin.sign,mix mob.plugin.keygen,mix mob.plugin.trust,mix mob.validate_pluginstill callManifest.load/1directly — they're author-side / first-trust-decision, no attack vector. Documented in the ADR; MOB-185/186/187 walk the longer-term path to data-only manifests (safe by construction).Acknowledged-unsafe escape hatch preserved
Pre-commit adversarial review caught a regression in the initial commit — every unsigned plugin was stripped from the build silently, breaking the documented
:acknowledge_unsafe_pluginsopt-in for local dev. Fixed as follow-up commit:Verify.load_verified/2takesacknowledged_unsafe:keyword. Whentrue, only:missing_signaturemaps to a successful eval — every other failure (:invalid_signature,:missing_pubkey,:envelope_v1_unsupported) still refuses.MobDev.Plugin.activated_with_verify/0fetches the acknowledged list viaSignatureGate.acknowledged_unsafe/0(now public) and passes the opt for those plugins.Tests
Load-bearing revert-verified tests:
sign_test.exs: manifest bytes are in signed file_hashes; the priv/mob_plugin.exs contents alter the hash.verify_test.exs: marker-file side effect never runs when signature fails (the CVE class the fix closes).verify_test.exs:acknowledged_unsafe: trueloads an unsigned manifest; still refuses tampered.signature_gate_test.exs: nil-manifest tier-1 plugins produce named errors (not silent skip).Full suite: 2460 pass, 0 fail. Credo strict: clean. Warnings-as-errors: clean.
Pre-existing
mix mob.security_scanHIGH finding onios_deviceexqlitedrift is inherited from master, not from this PR (verified by checking outorigin/masterfor the scan file).Breaking change
Every plugin signed with a mob_dev that predates envelope v2 must be re-signed. First-party plugins get re-signed as part of the next mob_dev release; third-party authors see the friendly
SignatureGateerror with the re-sign hint on their next consumer build.See:
decisions/2026-09-11-plugin-envelope-v2-verify-before-eval.mdMOB_PLUGIN_SECURITY.md(existing)Follow-ups on file: MOB-185 (linter), MOB-186 (migrate first-party plugins to declarative subset), MOB-187 (static manifest at 1.0).
Closes MOB-74.