feat(signing): sign agent binaries with age key + XEdDSA (VA-62) #66

Merged
nrupard merged 2 commits from feat/VA-62-age-xeddsa-signer into main 2026-06-19 16:42:14 +02:00
Owner

Implements the publisher half of the in-house age-key + XEdDSA agent-binary signing scheme (VA-61 decision, VS-36 epic), replacing minisign entirely. Companion verifier PR is in vervain-server (VS-41).

What changed

  • signing_keygen.rs: generates the keypair in-process with the age crate (drops the minisign -G -W shell-out), writes the secret 0600, refuses to clobber, prints the age1... recipient.
  • New signing_sign.rs + a sign subcommand: reads the secret from VERVAIN_AGENT_SIGNING_SECRET_KEY (or --secret-key-file), bech32-decodes it to the X25519 scalar, XEdDSA-signs the input's full bytes (pure Rust xeddsa, no external tool), and writes the two-line meshagent-<id>.sig (an untrusted comment: line + base64 of the raw 64-byte signature).
  • .forgejo/workflows/build-package.yml: drops the Alpine minisign container; the freshly built vervain-agent binary signs both published binaries via its own sign subcommand. Publishes .sig instead of .minisig; CI secret renamed to VERVAIN_AGENT_SIGNING_SECRET_KEY.
  • Docs (agent-signing-architecture.md, agent-package-distribution.md, agent-signing-rotation-runbook.md) rewritten for the age scheme; ROADMAP entry added (VA-62).
  • Dependencies: add age, xeddsa, bech32, secrecy, and rand 0.10 (aliased rand010 so the crate's existing rand 0.8 is untouched).

Why age + XEdDSA

age is encryption-only (X25519, no signature primitive). To sign with an age key, XEdDSA (Signal's scheme) derives an Ed25519 signature from the X25519 key. This is bespoke (non-standard) crypto by design, per the VA-61 decision: it is isolated behind the signing_sign module and leans entirely on the maintained xeddsa crate (no hand-rolled curve math).

Interop guarantee

The two repos share no crate, so a fixed cross-repo known-answer vector (age1... recipient + message + signature) is embedded as a unit test in BOTH this crate and vervain-server's meshcentral-agent-fetch. Any drift in signature format, bech32 handling, or curve handling fails the test in both repos.

Tests

cargo test -p vervain-agent --lib signing passes (8 tests): keygen produces a parseable age keypair (secret 0600, public half matches), sign then verify roundtrips, a tampered binary and a bad secret are rejected, and the shared known-answer vector verifies. cargo clippy -p vervain-agent --lib --bins -- -D warnings and cargo fmt --all --check are clean.

Multi-key rotation (a publickeys set) stays out of scope; the server holds a single publickey, rotated by a live value swap (documented in the rotation runbook).

Implements the publisher half of the in-house age-key + XEdDSA agent-binary signing scheme (VA-61 decision, VS-36 epic), replacing minisign entirely. Companion verifier PR is in vervain-server (VS-41). ## What changed - `signing_keygen.rs`: generates the keypair in-process with the `age` crate (drops the `minisign -G -W` shell-out), writes the secret `0600`, refuses to clobber, prints the `age1...` recipient. - New `signing_sign.rs` + a `sign` subcommand: reads the secret from `VERVAIN_AGENT_SIGNING_SECRET_KEY` (or `--secret-key-file`), bech32-decodes it to the X25519 scalar, XEdDSA-signs the input's full bytes (pure Rust `xeddsa`, no external tool), and writes the two-line `meshagent-<id>.sig` (an `untrusted comment:` line + base64 of the raw 64-byte signature). - `.forgejo/workflows/build-package.yml`: drops the Alpine minisign container; the freshly built `vervain-agent` binary signs both published binaries via its own `sign` subcommand. Publishes `.sig` instead of `.minisig`; CI secret renamed to `VERVAIN_AGENT_SIGNING_SECRET_KEY`. - Docs (`agent-signing-architecture.md`, `agent-package-distribution.md`, `agent-signing-rotation-runbook.md`) rewritten for the age scheme; ROADMAP entry added (VA-62). - Dependencies: add `age`, `xeddsa`, `bech32`, `secrecy`, and `rand` 0.10 (aliased `rand010` so the crate's existing `rand` 0.8 is untouched). ## Why age + XEdDSA `age` is encryption-only (X25519, no signature primitive). To sign *with an age key*, XEdDSA (Signal's scheme) derives an Ed25519 signature from the X25519 key. This is bespoke (non-standard) crypto by design, per the VA-61 decision: it is isolated behind the `signing_sign` module and leans entirely on the maintained `xeddsa` crate (no hand-rolled curve math). ## Interop guarantee The two repos share no crate, so a fixed cross-repo known-answer vector (`age1...` recipient + message + signature) is embedded as a unit test in BOTH this crate and vervain-server's `meshcentral-agent-fetch`. Any drift in signature format, bech32 handling, or curve handling fails the test in both repos. ## Tests `cargo test -p vervain-agent --lib signing` passes (8 tests): keygen produces a parseable age keypair (secret `0600`, public half matches), `sign` then verify roundtrips, a tampered binary and a bad secret are rejected, and the shared known-answer vector verifies. `cargo clippy -p vervain-agent --lib --bins -- -D warnings` and `cargo fmt --all --check` are clean. Multi-key rotation (a `publickeys` set) stays out of scope; the server holds a single `publickey`, rotated by a live value swap (documented in the rotation runbook).
feat(signing): sign agent binaries with age key + XEdDSA
All checks were successful
Check / clippy + fmt + tests (pull_request) Successful in 9m21s
b275e734fe
Replace minisign entirely with an in-house, pure-Rust signing scheme (no external binaries). The signing key is an age X25519 keypair; signatures are XEdDSA (Signal's scheme, deriving an Ed25519 signature from the age key) via the xeddsa crate, with bech32 recovering the raw key bytes age does not expose.

signing-keygen now generates the keypair in-process via the age crate (dropping the minisign -G -W shell-out) and prints the age1... recipient. A new sign subcommand signs a file's full bytes (vervain-agent sign --in ... --out ..., reading the secret from VERVAIN_AGENT_SIGNING_SECRET_KEY or --secret-key-file) and writes the two-line meshagent-<id>.sig the server verifier consumes. The build-package workflow self-signs both published binaries with the freshly built vervain-agent binary instead of an Alpine minisign container, publishing .sig instead of .minisig and renaming the CI secret to VERVAIN_AGENT_SIGNING_SECRET_KEY.

A shared cross-repo known-answer vector (identical to the one in vervain-server VS-41) guards signature-format interop. The signing/distribution/rotation docs are rewritten for the age scheme; multi-key rotation (a publickeys set) is deferred, so the server holds a single publickey. Adds age, xeddsa, bech32, secrecy, and rand 0.10 (aliased so the crate's rand 0.8 is untouched).

#VA-62

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fix(signing): harden secret-key handling from review
All checks were successful
Create release / Create release from merged PR (pull_request) Has been skipped
Check / clippy + fmt + tests (pull_request) Successful in 14m55s
f76467ba61
Two review findings on the signing path:

Close the keygen secret-file TOCTOU: signing-keygen wrote the secret with the default umask and only then chmod'd it to 0600, leaving a brief window where the secret key was world/group-readable. Create the file atomically with create_new + mode(0o600) instead, so it is owner-only from creation; create_new also upholds the no-clobber guarantee against a race past ensure_absent.

Wipe in-memory secret-key material after use: the resolved secret string, the bech32-decoded buffer, and the local copy of the X25519 scalar are now zeroized once signing is done (the xeddsa PrivateKey already zeroizes its own copy on drop). Adds the zeroize dependency.

#VA-62

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
nrupard deleted branch feat/VA-62-age-xeddsa-signer 2026-06-19 16:42:14 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/vervain-agent!66
No description provided.