MobDev.Plugin.SignatureGate (mob_dev v0.7.4)

Copy Markdown View Source

Host-side build-time gate: runs Verify.verify_plugin/2 + the TrustStore check across every activated plugin and refuses the build on any failure.

This is the Phase 2 cryptographic counterpart to the capability drift check in Validator. Both run at the same hook point inside Validator.raise_on_capability_drift!/1 so the iOS-sim, iOS-device, and Android paths all enforce them as a one-liner.

Three distinct failure modes are surfaced (per MOB_PLUGIN_SECURITY.md, Phase 2):

  • Missing signature — author hasn't run mix mob.plugin.sign. Suppressible per-plugin via config :mob, :acknowledge_unsafe_plugins with a persistent banner.
  • Invalid signature — sig is present but doesn't verify; the manifest or sources have been tampered with after signing. Not suppressible.
  • Untrusted fingerprint — signature verifies but the public key isn't in config :mob, :trusted_plugins (or is a different key from the trusted one, the key-rotation case). Not suppressible; user must run mix mob.plugin.trust <name>.

A legacy v1 envelope is refused (:envelope_v1_unsupported) unless it meets the MOB-287 transition rule in MobDev.Plugin.V1Transition; each plugin accepted that way gets a one-line notice per build.

Summary

Types

Errors check_plugin/2 can return.

Functions

The list of plugin names the consumer has opted into loading unsigned via :acknowledge_unsafe_plugins (in Application env or mob.exs).

Runs the signature + trust check across plugins (the MobDev.Plugin.activated/0 shape — [{plugin_dir, manifest}]).

Pure variant of check_activated/1 for tests. v1_opts carries the V1Transition provenance inputs (:scms, :lock, :deps_path); they default to the current Mix project's.

Prints a stderr banner when any activated plugin is allowed only via :acknowledge_unsafe_plugins. Idempotent within a single Mix invocation in spirit — the banner fires every time it's called, so callers should invoke it once per build.

Prints the one-line MOB-287 notice for every plugin in plugins accepted under the transitional v1 rule. Callers invoke it once per build, after raise_on_signature_drift!/1 has passed.

Runs check_activated/1 and raises a Mix.raise/1 with a clear, actionable message when any plugin fails. No-op on success.

Types

gate_error()

@type gate_error() ::
  {:missing_signature, atom()}
  | {:missing_pubkey, atom()}
  | {:invalid_signature, atom()}
  | {:envelope_v1_unsupported, atom()}
  | {:untrusted, atom(), MobDev.Plugin.Crypto.fingerprint(),
     MobDev.Plugin.Crypto.fingerprint() | nil}

Errors check_plugin/2 can return.

Functions

acknowledged_unsafe(project_dir \\ File.cwd!())

@spec acknowledged_unsafe(Path.t()) :: [atom()]

The list of plugin names the consumer has opted into loading unsigned via :acknowledge_unsafe_plugins (in Application env or mob.exs).

Exposed so MobDev.Plugin.activated/0 can pass acknowledged_unsafe: true into Verify.load_verified/2 for these plugins — otherwise a missing signature would silently strip the plugin from the build (its manifest fields would never merge into the app), producing "acknowledged" plugins that actually contribute nothing. See MOB-74's pre-merge review. A mob.exs that fails to evaluate raises rather than reading as "nothing acknowledged" (MOB-280).

check_activated(plugins)

@spec check_activated([{Path.t(), map() | nil}]) :: :ok | {:error, [gate_error()]}

Runs the signature + trust check across plugins (the MobDev.Plugin.activated/0 shape — [{plugin_dir, manifest}]).

Returns :ok when every plugin verifies AND is trusted (or, for missing signatures, is listed in config :mob, :acknowledge_unsafe_plugins). Returns {:error, errors} otherwise — a list of gate_error/0 tagged by plugin name.

Reads the trust map from mob.exs (via TrustStore.load_trusted_plugins/0) and the acknowledgement list from :mob's Application env or mob.exs. Pass the trust_map + acknowledged list explicitly via check_activated/3 from tests that need isolation.

check_activated(plugins, trust_map, acknowledged, v1_opts \\ [])

@spec check_activated(
  [{Path.t(), map() | nil}],
  MobDev.Plugin.TrustStore.trust_map(),
  [atom()],
  MobDev.Plugin.V1Transition.opts()
) :: :ok | {:error, [gate_error()]}

Pure variant of check_activated/1 for tests. v1_opts carries the V1Transition provenance inputs (:scms, :lock, :deps_path); they default to the current Mix project's.

maybe_print_unsafe_banner(plugins)

@spec maybe_print_unsafe_banner([{Path.t(), map() | nil}]) :: :ok

Prints a stderr banner when any activated plugin is allowed only via :acknowledge_unsafe_plugins. Idempotent within a single Mix invocation in spirit — the banner fires every time it's called, so callers should invoke it once per build.

maybe_print_v1_transition_notice(plugins, v1_opts \\ [])

@spec maybe_print_v1_transition_notice(
  [{Path.t(), map() | nil}],
  MobDev.Plugin.V1Transition.opts()
) ::
  :ok

Prints the one-line MOB-287 notice for every plugin in plugins accepted under the transitional v1 rule. Callers invoke it once per build, after raise_on_signature_drift!/1 has passed.

raise_on_signature_drift!(plugins)

@spec raise_on_signature_drift!([{Path.t(), map() | nil}]) :: :ok

Runs check_activated/1 and raises a Mix.raise/1 with a clear, actionable message when any plugin fails. No-op on success.