# `MobDev.Plugin.Sign`
[🔗](https://github.com/genericjam/mob_dev/blob/master/lib/mob_dev/plugin/sign.ex#L1)

Author-side signing workflow for mob plugins.

Produces `priv/mob_plugin.sig` for a plugin directory by:

1. Loading the manifest (`priv/mob_plugin.exs`).
2. Computing SHA-256 hashes for every file the native build reads from
   the plugin (`build_inputs/2`), **including the manifest bytes
   themselves**, plus the build-input coverage marker (see below).
3. Building the canonical payload (sorted file hashes + envelope
   version).
4. Signing the canonical encoding of the payload via `Crypto.sign/2`.
5. Writing a binary `priv/mob_plugin.sig` containing the signature
   **and the signed file_hashes list**, so verifiers can check
   integrity without needing to `Code.eval_file` the manifest first
   (see MOB-74).

`build_inputs/2`, `compute_file_hashes/2` and `build_payload/1` are
exposed for tests and for `Verify`; they are deterministic given their
inputs and the plugin directory's contents.

## Envelope versions

- **v1** (deprecated, MOB-74) — payload was `%{manifest: <map>,
  file_hashes: [...]}` and the envelope on disk carried only the
  signature. Verifiers needed the eval'd manifest map to rebuild the
  payload, so the eval had to run *before* verification could —
  letting a malicious `priv/mob_plugin.exs` execute arbitrary code
  at build time. Refused by `MobDev.Plugin.Verify` since mob_dev
  0.7.2 (the MOB-287 transition that accepted some Hex plugins was
  removed by MOB-301). Never produced any more.
- **v2** (current) — payload is `%{file_hashes: [...],
  envelope_version: 2}`; `file_hashes` includes
  `priv/mob_plugin.exs`; envelope on disk embeds `file_hashes`
  alongside the signature. Verifiers can check every on-disk file
  against the signed hashes without touching the manifest map, so
  verification is safe to run before eval.

## Build-input coverage (MOB-297)

Signatures made before MOB-297 listed only some of the files the build
reads (a `native_dir` was filtered to `.c`/`.h`/`.cpp`/`.zig`, so every
iOS `.m` NIF was unsigned, and `cpp_archive` sources were skipped), and
the verifier checked only listed files. A signature now lists every
`build_inputs/2` file **and** a coverage marker entry — a `file_hashes`
entry for a path that never exists, hashed as empty bytes. Because the
marker is inside the signed list it cannot be stripped, and a verifier
that sees it also requires every build input to be listed. mob_dev 0.7.2
rehashes the marker path, finds nothing, gets the empty-bytes hash and
accepts it, so hosts that have not upgraded still verify new signatures.
See decisions/2026-09-30-plugin-signature-coverage.md.

# `file_hash`

```elixir
@type file_hash() :: binary()
```

SHA-256 digest of a single file (raw 32-byte binary).

# `file_hashes`

```elixir
@type file_hashes() :: [{rel_path(), file_hash()}]
```

Sorted list of `{relative_path, sha256}` tuples.

# `rel_path`

```elixir
@type rel_path() :: String.t()
```

Relative path inside the plugin directory.

# `build_inputs`

```elixir
@spec build_inputs(Path.t(), map() | nil) :: [rel_path()]
```

Plugin-relative paths of every file the native build reads from the
plugin, sorted. The signer lists all of them; a verifier seeing the
coverage marker refuses a signature that omits any.

Derived from the same `MobDev.Plugin.Merge` gatherers the build uses,
called with an empty plugin dir so they return the declared relative
paths:

- `priv/mob_plugin.exs` itself (evaluated by every consumer).
- `ios.swift_files`, `android.bridge_kt`, `android.res_files`.
- Every compiled C-family source: each C / ObjC / Zig NIF's primary
  source (`<native_dir>/<module>.<ext>`, default `native_dir` applied),
  `android.jni_source`, and `lang: :cpp_archive` `sources:` — **and every
  file under each one's directory**, whatever its extension, because a
  quoted `#include`/`@import` resolves there first.
- `migrations.migrations_dir/*.exs` (copied into the host and run on
  device), `assets.fonts`, `assets.images`, `default_font.file`.

Not covered: `{:dep, app, path}` entries (another package's files) and
`cpp_archive` `includes:` roots. An include root can be provisioned on
the host at build time rather than shipped — mob_nx_eigen's
`eigen-3.4.0` is downloaded into the plugin's own tree by its Mix
compiler — so its contents cannot be signed, and requiring them listed
would refuse the plugin on every host that has compiled it. Headers a
plugin ships belong beside its sources, where they are covered.

Directory expansion skips dotfiles: they are editor/OS litter
(`.DS_Store` appears on hosts that browse `deps/` in Finder), and
requiring them listed would fail verification for a harmless file.
A symlink is listed as an entry and never followed. `priv/mob_plugin.sig`
is never an input.

# `build_payload`

```elixir
@spec build_payload(file_hashes()) :: map()
```

Builds the canonical payload term that gets signed.

Shape:

    %{
      file_hashes: [{rel_path, sha256}, ...],
      envelope_version: 2
    }

Authoritative for what's inside the signature — any new field added
here needs both author and host updates.

Note that the payload no longer includes the manifest term itself
(see MOB-74). The manifest is one of the files hashed in
`file_hashes`, so its bytes are covered — and dropping the map from
the payload lets `Verify` recompute the payload without eval'ing
the manifest first.

# `compute_file_hashes`

```elixir
@spec compute_file_hashes(Path.t(), map() | nil) :: file_hashes()
```

Returns the relative-path-sorted `{relative_path, sha256}` list a new
signature carries: every `build_inputs/2` file plus the coverage marker.

Missing files hash as empty bytes (see `sha256!/1`) —
`Validator.validate_plugin/3` refuses to publish a plugin with missing
declared paths, and a missing file that later appears no longer matches.

# `declares_build_input_coverage?`

```elixir
@spec declares_build_input_coverage?(file_hashes()) :: boolean()
```

True when a signed `file_hashes` list carries the build-input coverage
marker, i.e. the signer listed every `build_inputs/2` file.

# `envelope_version`

```elixir
@spec envelope_version() :: integer()
```

Current signing envelope version.

# `manifest_path`

```elixir
@spec manifest_path(Path.t()) :: Path.t()
```

Relative path inside a plugin dir where the manifest lives.

# `sign_plugin`

```elixir
@spec sign_plugin(Path.t(), MobDev.Plugin.Crypto.priv_key()) :: :ok | {:error, term()}
```

Signs `plugin_dir` and writes `priv/mob_plugin.sig`.

Orchestrates the full author workflow: loads the manifest (to know
which files it references), computes file hashes (including the
manifest bytes themselves), builds the v2 payload, signs it, wraps
the signature **and the file_hashes list** in the envelope binary,
and writes the file. Returns `:ok` on success or `{:error, reason}`
if the manifest is missing/invalid.

The envelope carries `file_hashes` on disk so `Verify.verify_plugin/1`
can check tampering without needing to `Code.eval_file` the manifest
first — see MOB-74.

# `signature_path`

```elixir
@spec signature_path(Path.t()) :: Path.t()
```

Relative path inside a plugin dir where the signature lives.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
