Trust
Signed plugins and signed agent binaries: one ed25519 trust list, two surfaces.
nanook ships one ed25519 trust system that covers two surfaces: plugins and the agent binary itself. The wire format, the storage path, and the operator commands are the same for both. The only thing that differs is which TOML table the trusted key sits in, which keeps the two roles separable by convention rather than by syntax.
| Surface | Trust list | Verifier |
|---|---|---|
| Plugins | [plugins.signature].signers | nanook plugins verify <cdylib> |
| Agent | [self.signature].signers | nanook self verify |
Both lists accept the same ssh-ed25519 <base64> [comment] line. An operator who wants one key for both roles can paste it into both lists; an operator who wants the publisher of plugins kept separate from the publisher of nanook keeps two lists.
Concepts
A signed artifact carries a small TOML manifest (name, kind, version, sha256) and an ed25519 signature over it. The manifest's sha256 is the artifact's hash, vouched for by the publisher. By default the manifest and signature ride along inside the artifact as an embedded trailer; operators see one file. Sidecar mode (separate .manifest.toml + .manifest.toml.sig) is available for when the artifact must stay byte-pristine (e.g. you're also platform-codesigning it).
The host's admission path is identical for the two surfaces:
- Locate the trailer (or sidecars) and parse the manifest.
- Verify the signature with one of the configured
signers. - Re-hash the artifact body and compare against the manifest's
sha256. - Reject if the hash sits in the operator's
revokedblocklist.
Verification happens before dlopen for plugins and before swap-on-update for the agent, so a malicious artifact never gets to run code on a rejection path.
Trust list format
= [
"ssh-ed25519 AAAAC3Nz... release@team",
"ssh-ed25519 AAAAC3Nz... ci@nanook",
]
Each entry is a single SSH-style line, the same format ssh-keygen writes and the same format [admin].authorized accepts. The trailing comment is free-form (operators usually use <who>@<where>). Reusing one ed25519 keypair across [admin], [plugins.signature], and [self.signature] is a deliberate operator choice; nanook neither encourages nor blocks it.
Operator config
Plugins
[]
= ["/usr/lib/nanook"]
[]
= true
= [
"ssh-ed25519 AAAA... release@team",
]
= [
# rotate-without-key-loss: refuse specific cdylib hashes even when
# the publisher key would otherwise vouch for them.
# "sha256:<hex>",
]
| Field | What |
|---|---|
signature.require | When true, every loaded plugin must carry a manifest signed by one of signers. Off by default. |
signature.signers | Trusted publisher keys, one per line, in ssh-ed25519 <base64> [comment] form. |
signature.revoked | Operator blocklist of cdylib hashes (sha256:<hex> or sha512:<hex>). A revoked hash is rejected even when carried by a trusted-signer manifest. |
[plugins].allowed and [plugins.signature] are not mutually exclusive. They compose as AND-gates, evaluated in this order on every load attempt:
- If the artifact's hash is in
[plugins.signature].revoked, reject. - If
signature.require = trueand the artifact has no valid manifest signed by a key insigners, reject. - If
[plugins].allowedis non-empty and the hash is not in it, reject. - Otherwise admit.
For most fleets, signing is enough on its own: the manifest's sha256 is already the artifact's hash, vouched for by the publisher, so an [plugins].allowed allowlist on top is redundant. Operators who want a second, locally-curated gate (so a compromised release key alone is not sufficient to ship a malicious build) can keep both lists. Pick what matches your threat model.
Agent
[]
= true
= [
"ssh-ed25519 AAAA... nanook-release@team",
]
= [
# "sha256:<hex>",
]
Same shape as [plugins.signature]. nanook self verify walks the same admission path against the agent binary at --path (or the running executable), with this list as the trust root.
The honest framing is: the check is meaningful only when the verifier is trusted independently of the artifact it verifies. The two places that holds:
- Before install or trust. A CI job, package builder, or fresh-from-source nanook on a known-clean machine verifies a candidate release before publishing or trusting it.
- During
nanook self update. The currently-running (presumed-trusted) nanook verifies the new binary before swap. Verifier and artifact are different bits, so the verifier's checks cannot be patched out by the candidate.
Running nanook self verify against the running binary on a host you don't already trust does not prove the running binary is genuine. A malicious binary can patch out the verification call or hardcode an Allowed return; the operator sees ok either way. See Threat model.
Identities on disk
nanook keeps admin and signing keys in separate directories so an operator's day-to-day login key is never accidentally used as a release key:
| Role | Default path | Trust list |
|---|---|---|
| Admin (HTTP auth) | ~/.nanook/admin/id_ed25519 | [admin].authorized |
| Plugin signing | ~/.nanook/signing/id_ed25519 | [plugins.signature].signers |
| Agent signing | ~/.nanook/signing/id_ed25519 | [self.signature].signers |
All three use the same ssh-ed25519 <base64> [comment] wire format. nanook keygen --kind signing mints a separate key under ~/.nanook/signing/ precisely so it can rotate independently of the admin key. Both files use mode 0600; the .pub companion is 0644.
Managing the trust list
nanook self trust and nanook plugins trust are format-preserving editors for the matching signers array. The commands round-trip the rest of nanook.toml through toml_edit, so comments, key order, and unrelated tables are preserved. Each save is atomic (staging + rename) and validated through the config loader before commit; an edit that would leave the file unparseable is rejected.
Same surface for both lists:
add accepts both forms because operators copy lines either way:
Duplicate add is idempotent (matched by fingerprint, not by the trailing comment). remove accepts either the full SHA256:<base64> form or any unique prefix; an ambiguous prefix errors and lists the matches.
# [self.signature].signers trust list
#
# ✓ SHA256:abcDEF... release@team
# ✓ SHA256:xyzWVU... ci@nanook
Signing artifacts
Publisher workflow
Mint a signing key once, share its .pub line with operators, and sign every release. Both surfaces use the same key (or distinct keys, your call).
# one-time: mint a release key
# share the .pub line with operators
# ssh-ed25519 AAAA... release@team
# sign a plugin (default: embedded trailer, one file out)
# sign the agent itself
nanook plugins sign flags:
| Flag | What |
|---|---|
-k, --key <path> | Identity file (defaults to ~/.nanook/signing/id_ed25519) |
--version <s> | Stamp a version into the manifest |
--kind <s> | Override the manifest's kind label |
--sidecar | Write .manifest.toml + .manifest.toml.sig next to the cdylib instead of embedding a trailer |
--force | Overwrite existing sidecars (sidecar mode only; trailer mode rewrites atomically) |
Both plugins sign and self sign are atomic at the trailer layer: a sibling <path>.nanook-sign.tmp is fsynced and renamed over the original. A crash mid-sign leaves the original intact.
Verifying
Both verify commands run the host's admission checks offline against the trust list in nanook.toml. Use either as a CI gate before publishing.
Packaging modes
By default, signed artifacts ship as one file: the manifest and signature are appended as a trailer. The trailer is invisible to dlopen (ELF, Mach-O, and PE all tolerate trailing bytes) but easy for nanook to scan back from EOF, validate, and verify.
libmyplugin.so # cdylib + trailer
nanook # binary + trailer
If you'd rather keep the artifact pristine (e.g. you also need to platform-codesign it), pass --sidecar to fall back to the three-file layout:
libmyplugin.so # the cdylib, untouched
libmyplugin.so.manifest.toml # name, kind, version, sha256
libmyplugin.so.manifest.toml.sig # raw base64 ed25519 signature, one line
Both layouts are admitted by the host. Trailer is preferred when you control the build.
Platform notes
The trailer survives dlopen on Linux, macOS, and Windows because all three loaders follow header offsets and ignore trailing bytes. There is one ordering rule when combining nanook's trailer with platform code-signing:
- Apply platform code-signing first, then
nanook plugins sign/nanook self sign. Authenticode (Windows) andcodesign(macOS) embed their own EOF blocks; if nanook's trailer is appended last, scanning from EOF still finds it. The platform signature does not cover nanook's trailer (each scheme has its own boundary), and that's fine: nanook verifies its own trailer independently. - If you must apply nanook's trailer first and then platform code-sign, ship sidecars instead. The platform signing tool will write past nanook's tail magic and the scan will fall through.
Threat model
The scheme has two surfaces, and they have different trust roots. The plugin surface assumes nanook itself is trusted: a trusted binary verifies untrusted plugins before dlopen. The agent surface is asymmetric. A running binary cannot meaningfully verify itself, because an attacker who can swap the binary can equally well patch out the verification. Self-verification is only useful when verifier and artifact are different bits.
What the scheme defends against
- Trojan plugins from a hostile source. The publisher's key vouches for the cdylib bytes; an unsigned plugin or one signed by an untrusted key never loads. Trust root: the nanook binary, assumed-trusted at load time.
- Trojan agent binaries during update.
nanook self updateruns as the currently-installed nanook and verifies the candidate binary before swap. Trust root: the running process. Artifact under test: the new file on disk. Different bits, so the candidate cannot patch out the verifier. - Trojan agent binaries before first trust. A CI gate or operator-side
nanook self verify --pathrun from a known-good nanook (or fresh-from-source build) on a clean machine catches a tampered candidate before it is ever trusted. Same shape as case 2: the trusted verifier is not the artifact under test. - Local tampering between sign and load. Any byte change in a cdylib, candidate agent binary, manifest, or signature file flips the verdict to a rejection variant (
ManifestMismatch,SignatureInvalid,SignatureRequired). - Confused deputy between admin auth and signing trust. Distinct config tables and default storage dirs keep the two roles separate by convention. The same ed25519 line format is used in both, so an operator can deliberately reuse one key across roles, but accidentally pasting into the wrong list is a hard-to-make mistake because the table headers are explicit.
- Bad releases from a still-trusted publisher. Add the offending sha256 to the matching
revokedlist to refuse it without rotating the publisher key.
What the scheme does not defend against
- A running binary that lies about itself.
nanook self verifyinvoked against the running executable on a host you don't already trust is circular: a malicious binary can hardcode anAllowedreturn, skip the signature check, or echo a plausible trust list. The operator seesokeither way. Treat post-installnanook self verify(no--path, or--pathpointing at the same bits doing the verifying) as a typo-and-corruption check, not an integrity attestation. For genuine post-compromise integrity, anchor outside nanook: OS code signing (Authenticode,codesign), secure boot, IMA, or remote attestation. - Root on the host. Root can rewrite
nanook.toml, replace the binary on disk, and swap signatures wholesale. Both trust roots live in operator-owned files, so root inside the box owns both surfaces. Defending against this is the OS's job, not nanook's. - Compromise of the publisher's build or signing machine. Out of scope, supply-chain concern. Mitigations live upstream: hardware-backed keys, sigstore-style transparency, build attestation.
- A malicious
install.shor the channel that delivered the binary in the first place. Bootstrapping trust in the first nanook binary is an out-of-band problem (HTTPS to a known publisher, a distro package signed by a distro key, a checked-out source tree you build yourself). nanook cannot reach back through that channel and prove it was honest.
Verification happens before dlopen for plugins and before swap-on-update for the agent, so a malicious artifact sitting in a search dir or staging path cannot execute code before its admission verdict is computed.
[self.signature] and [plugins.signature] are deliberately separate trust lists. An operator who trusts a third-party publisher to ship plugins is not implicitly trusting them to ship the nanook agent itself.