Skip to content

MOB-74 — plugin envelope v2: verify signature before eval - #81

Merged
GenericJam merged 3 commits into
masterfrom
fix/MOB-74-verify-before-eval
Sep 12, 2026
Merged

GenericJam merged 3 commits into
masterfrom
fix/MOB-74-verify-before-eval

Conversation

@GenericJam

Copy link
Copy Markdown
Owner

Security fix. Before this change, MobDev.Plugin.Manifest.load/1 called Code.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:

# priv/mob_plugin.exs
File.write!("/tmp/pwned", "evil ran")   # side effect
%{name: :innocent, mob_version: "~> 0.7", ...}

Code.eval_file executed 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 every mix mob.deploy), mix mob.plugins, mix mob.audit_plugins, and MobDev.Plugin.Report were all attack surfaces.

Envelope v2

  • Manifest bytes join the signed file_hashes list. Sign.referenced_files/2 now always prepends priv/mob_plugin.exs.
  • v2 payload signed = %{file_hashes, envelope_version: 2}. No manifest map field — its authoritative representation is the on-disk bytes.
  • v2 envelope on disk carries file_hashes alongside 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. No Code.eval_file in the whole path.
  • Verify.load_verified/2 is the new 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 hard-refused with :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/0Verify.load_verified/2 (primary CVE surface).
  • mix mob.plugins, mix mob.audit_plugins, MobDev.Plugin.Report same.
  • SignatureGate.check_activated/1 tier-0 filter switched from is_map(manifest) to Manifest.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_plugin still call Manifest.load/1 directly — 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_plugins opt-in for local dev. Fixed as follow-up commit:

  • 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.
  • MobDev.Plugin.activated_with_verify/0 fetches the acknowledged list via SignatureGate.acknowledged_unsafe/0 (now public) and passes the opt for those plugins.
  • Two revert-verified tests cover the escape hatch and its narrow scope.

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: true loads 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_scan HIGH finding on ios_device exqlite drift is inherited from master, not from this PR (verified by checking out origin/master for 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 SignatureGate error with the re-sign hint on their next consumer build.

See:

  • decisions/2026-09-11-plugin-envelope-v2-verify-before-eval.md
  • MOB_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.

GenericJam and others added 3 commits September 11, 2026 20:25
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>
@GenericJam
GenericJam merged commit 137fe20 into master Sep 12, 2026
3 checks passed
@GenericJam
GenericJam deleted the fix/MOB-74-verify-before-eval branch September 12, 2026 02:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant