No description
  • Python 98%
  • Makefile 2%
Find a file
Mark Hahl 1079029740
Some checks failed
ci / lint-test (3.11) (push) Has been cancelled
ci / lint-test (3.13) (push) Has been cancelled
ci / lint-test (3.9) (push) Has been cancelled
Bump to 1.3.0: CLI, config, and security hardening
Prepare 1.3.0: add CLI features (completion, check-config, status --json/-o, --dry-run, register --force), improve config parsing (TOML lists, token_file, strict validation for accounts/key_prefix/webhook), safer password generation and idempotent PUTs, SSH private-key backup, atomic escrow/state writes with TPM support, backoff jitter, and SELinux/systemd hardening. Update docs, tests (new tests/test_hardening.py), Makefile, pyproject, spec, and bump __version__. Several internal refactors (status rendering, dry-run flows, webhook severity/auth) to support these features and improve robustness.
2026-09-03 10:23:55 +10:00
.github/workflows Support TPM-sealed escrow; hostname and CI updates 2026-09-02 10:18:38 +10:00
bin Initial commit: corvus-agent 2026-08-31 16:14:00 +10:00
docs Bump to 1.3.0: CLI, config, and security hardening 2026-09-03 10:23:55 +10:00
rpm Bump to 1.3.0: CLI, config, and security hardening 2026-09-03 10:23:55 +10:00
selinux Bump to 1.3.0: CLI, config, and security hardening 2026-09-03 10:23:55 +10:00
src/corvus_agent Bump to 1.3.0: CLI, config, and security hardening 2026-09-03 10:23:55 +10:00
systemd Bump to 1.3.0: CLI, config, and security hardening 2026-09-03 10:23:55 +10:00
tests Bump to 1.3.0: CLI, config, and security hardening 2026-09-03 10:23:55 +10:00
.gitignore Support TPM-sealed escrow; hostname and CI updates 2026-09-02 10:18:38 +10:00
AGENTS.md Bump to 1.3.0: CLI, config, and security hardening 2026-09-03 10:23:55 +10:00
CHANGELOG.md Bump to 1.3.0: CLI, config, and security hardening 2026-09-03 10:23:55 +10:00
LICENSE Initial commit: corvus-agent 2026-08-31 16:14:00 +10:00
Makefile Bump to 1.3.0: CLI, config, and security hardening 2026-09-03 10:23:55 +10:00
PLAN.md Initial commit: corvus-agent 2026-08-31 16:14:00 +10:00
pyproject.toml Bump to 1.3.0: CLI, config, and security hardening 2026-09-03 10:23:55 +10:00
README.md Bump to 1.3.0: CLI, config, and security hardening 2026-09-03 10:23:55 +10:00

corvus-agent

Rotates local passwords on a schedule and stores them in Corvus. LAPS for Linux.

Quick start

Install the RPM, write a config, register the host, enable the timer.

sudo dnf install dist/corvus-agent-*.el9.noarch.rpm

Copy the example config and lock it down.

sudo cp /etc/corvus/agent.toml.example /etc/corvus/agent.toml
sudo chmod 0600 /etc/corvus/agent.toml
sudo chown root:root /etc/corvus/agent.toml

Edit /etc/corvus/agent.toml. You need a server URL, a project UUID, and a machine token from Corvus.

url = "https://corvus.example.com"
project = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
token = "__VG_CORVUS_MACHINE_TOKEN_018b371d4466__"

Generate a password, push it to Corvus, and rotate the local account.

sudo corvus-agent register

Enable the daily timer.

sudo systemctl enable --now corvus-agent.timer

Corvus setup

You need a project and a machine token before the agent can run.

  1. Create a project. Name it linux-root-passwords or similar.
  2. Create a machine token with role service-write and key scope hosts/*.
  3. Put the token and project UUID in /etc/corvus/agent.toml.

Commands

run

The agent checks each account against Corvus. When a secret expires within threshold_days (7 by default), the agent generates a new password, pushes it to Corvus, then calls chpasswd. The agent always pushes before it rotates. If the push fails, it writes the password to /var/lib/corvus-agent/escrow.json and tries again on the next run.

The systemd timer calls this.

sudo corvus-agent run

Exit code is 0 when every account succeeds or has no work to do, 1 on error.

status

Prints the expiry date and days left for each account. It never prints the password.

$ sudo corvus-agent status
hosts/web01/users/root      2026-10-15   45d left  due=no

If the host has no record:

$ sudo corvus-agent status
hosts/web01/users/root      never-rotated  (no record)

Exit code is 0 when Corvus has a record for every account, 1 otherwise.

rotate

Same as run but it ignores the threshold and rotates now.

sudo corvus-agent rotate
sudo corvus-agent rotate --dry-run   # plan only, no changes

register

Creates the first secret for each account. Generates a password, pushes it, then rotates locally. When a secret already exists and you run interactively without --force, the agent asks before overwriting. Scripts and the timer path are unaffected (non-interactive runs keep the old overwrite behavior unless --dry-run).

sudo corvus-agent register
sudo corvus-agent register --force    # skip the overwrite prompt
sudo corvus-agent register --dry-run  # plan only, no changes

status --json

status --json (or status -o json) prints the same data as JSON (key, account, state, expires_at, days_left, due) for monitoring scripts. It never prints secrets. The default table output renders as a formatted table when the optional rich extra is installed (pip install corvus-agent[rich]) and stdout is a terminal; otherwise it falls back to plain text, so scripts and minimal servers always get stable output.

completion

Generates a shell completion script (no config needed):

corvus-agent completion bash | sudo tee /etc/bash_completion.d/corvus-agent
corvus-agent completion zsh | sudo tee /usr/share/zsh/site-functions/_corvus-agent
corvus-agent completion fish | sudo tee /usr/share/fish/vendor_completions.d/corvus-agent.fish

check-config

Validates the config file and prints a non-secret summary (URL, project, accounts, thresholds, token source). It never prints the token. Run it after editing the config and before enabling the timer.

sudo corvus-agent check-config

Global flags: --config PATH (use a different config file), --version, -v/--verbose, -q/--quiet.

Config reference

Field Required Default Description
url yes Corvus server URL. Must start with https://.
token yes, unless token_file Machine token with service-write. Set only one of token / token_file.
token_file yes, unless token File holding only the token (same 0600 root-owned rules as the config). Useful with systemd-creds so the token is not inline.
project yes Corvus project UUID.
key_prefix no hosts/ Key is <prefix><hostname>/users/<account>. Letters, digits, /, _, - only; a trailing / is added if missing.
expires_days no 90 Lifetime of the secret in Corvus (1-3650).
threshold_days no 7 Agent rotates when days left fall to this value or below (0-3650).
password_length no 20 Length of the generated password (8-128). Full entropy per character from A-Za-z0-9-_.
webhook_url no Leave empty to disable alerts. Set a Slack, Mattermost, or generic JSON webhook URL to get failure and drift alerts. HTTPS required unless webhook_insecure_http = true.
webhook_insecure_http no false Allow an http:// webhook URL (testing only).
webhook_bearer no Optional Authorization: Bearer value for the webhook. Never logged.
accounts no root Local accounts to manage. POSIX names only (validated). Comma separated string or native list: accounts = ["root", "deploy"].
ssh_accounts no empty Must be a subset of accounts. Those get a server generated ed25519 keypair instead of a password. Agent sends kind=ssh with no value.
ssh_key no true Write authorized_keys when the PUT response includes ssh_public_key.
ssh_authorized_keys_file no /etc/ssh/authorized_keys.d/%u Root owned file for the public key. The agent creates the directory 0755 and the file 0644 root:root. Set to empty to use ~/.ssh/authorized_keys. %u becomes the account name. The RPM installs /etc/ssh/sshd_config.d/50-corvus-agent.conf for this path.
drift_heal no false true makes the agent auto rotate a password account whose /etc/shadow hash changed outside the agent. false logs a warning, sends a webhook, and exits 1 for that account. State uses TPM sealing when available.

Webhook alerts

webhook_url controls alerts. Empty means off. Set it to an incoming webhook URL for Slack, Mattermost, or any endpoint that accepts a JSON POST.

webhook_url = "https://hooks.slack.com/services/T.../B.../xxx"

What the agent sends

One POST per failure. Body is JSON with text and severity fields:

{"text": "web01: push failed, escrowed for root: PUT hosts/web01/users/root: HTTP 500 after retries", "severity": "error"}

text is always hostname: error message. The error is the same string the agent logs at CRIT, for example PUT rejected: token lacks write permission (HTTP 401) or drift detected for root. severity is error for push failures and warning for drift. Slack and Mattermost render text and ignore the extra field. The agent never includes the password or token.

Request headers and behavior:

  • Content-Type: application/json
  • POST with 10 second timeout
  • no retry; Authorization: Bearer <webhook_bearer> only when webhook_bearer is set
  • if the webhook returns an error or times out, the agent logs webhook POST failed: ... at WARNING and continues. The run still fails or succeeds based on the Corvus push, not the webhook.
  • HTTPS_PROXY, HTTP_PROXY, and NO_PROXY apply. Set them on the service if you need a proxy.

When it fires

  • Corvus PUT fails and the agent escrows the password (5xx, 429, network error), or the PUT is rejected (401, 403)
  • /etc/shadow hash for a password account changed outside the agent (drift). The agent records the hash with spwd after each successful chpasswd and compares it on the next run.

Test the URL without the agent:

curl -X POST -H "Content-Type: application/json" \
  -d '{"text":"corvus-agent test"}' "$WEBHOOK_URL"

Slack and Mattermost incoming webhooks accept this payload as is.

systemd

The RPM installs two units.

  • corvus-agent.service runs corvus-agent run as root. It uses ProtectSystem=full, ProtectHome=read-only, ReadWritePaths=/root /home /etc/ssh, NoNewPrivileges=true, plus kernel/host hardening (ProtectKernelTunables, ProtectKernelModules, ProtectControlGroups, ProtectHostname, ProtectClock, RestrictSUIDSGID, RestrictRealtime, LockPersonality). PrivateDevices is deliberately off so TPM sealing keeps working.
  • corvus-agent.timer runs the service once a day with RandomizedDelaySec=1h. It is persistent across reboots.
sudo systemctl enable --now corvus-agent.timer
sudo journalctl -u corvus-agent.service

Build from source

You need Fedora or RHEL 9+ with rpm-build.

make rpm
ls dist/

The RPM is noarch. It needs Python 3.9+ and shadow-utils. The build compiles the SELinux module and the RPM install loads it.

Security

  • The agent feeds chpasswd on stdin. It never puts the password in argv, logs, or status output.
  • The agent never logs or prints the token (or the webhook bearer).
  • The config must be 0600 and owned by root. The agent checks this and exits if it is wrong. token_file, when used, has the same rules.
  • The agent rejects a config with http:// for url. It only talks TLS. Webhooks must also be HTTPS unless webhook_insecure_http = true.
  • Account names are validated (letters, digits, _, -, .; must start with a letter or _). This blocks chpasswd line injection and %u path traversal.
  • A flock on /run/corvus-agent.lock stops two runs from overlapping.
  • The agent pushes to Corvus before it changes the local password. If the push fails, it saves the password to /var/lib/corvus-agent/escrow.json (0600) and retries on the next run.
  • An existing ~/.ssh/id_ed25519 that differs from the new key is backed up to id_ed25519.corvus-backup before replacement.
  • Retries use the 5s/15s/45s backoff with a small random jitter so fleets do not thundering-herd the server.
  • The SELinux module scopes network access to web ports and gives the agent its own type for /etc/ssh/authorized_keys.d instead of generic etc_t write access.

Troubleshooting

  • config: ... at startup: run sudo corvus-agent check-config. It validates permissions, HTTPS, account names, and thresholds without printing secrets.
  • Clock skew: status computes days-left from expires_at against local time. An NTP-skewed host rotates early or late; keep chronyd healthy.
  • No TPM: without systemd-creds plus /dev/tpmrm0 or /dev/tpm0, escrow and drift state are plaintext 0600 files. Permissions still guard them, but offline disk theft exposes them. The agent seals old plaintext files automatically once a TPM appears.
  • Proxy: both Corvus API calls and webhook posts use urllib, so HTTPS_PROXY/HTTP_PROXY/NO_PROXY apply to both. A token-bearing request through a proxy means the proxy sees the ciphertext, not the token, but it does see the destination.
  • Homes outside /root and /home: the unit only allows ReadWritePaths=/root /home /etc/ssh. Add a drop-in for other home roots: sudo systemctl edit corvus-agent.service and append ReadWritePaths=/srv/users.
  • drift detected: someone changed the password outside the agent. Fix by hand, then sudo corvus-agent rotate to re-seal the state (or set drift_heal = true for auto recovery).
  • SELinux denials: ausearch -m AVC -ts recent. The agent domain is corvus_agent_t; the key drop-in is labeled corvus_agent_ssh_keys_t via the shipped .fc file (relabel with restorecon -R /etc/ssh/authorized_keys.d after manual moves).

HTTP proxy

The agent uses urllib. It honors HTTPS_PROXY, HTTP_PROXY, and NO_PROXY. Set them in the environment or in a systemd drop in.

# sudo systemctl edit corvus-agent.service
[Service]
Environment=HTTPS_PROXY=http://proxy.example.com:3128
Environment=NO_PROXY=localhost,127.0.0.1

Escrow (offline queue)

If Corvus is unreachable when the agent needs to rotate, it saves the new password to /var/lib/corvus-agent/escrow.json (0600). On the next run it pushes the escrow first. If that push succeeds, it applies the password locally and removes the escrow. It keeps the file until the push and local apply both succeed.

If systemd-creds and a TPM2 device (/dev/tpmrm0 or /dev/tpm0) exist, the agent seals the file with systemd-creds encrypt --with-key=tpm2 --tpm2-device=auto --name=corvus-agent-escrow. The file then holds {"encrypted": "<blob>"}. The blob only decrypts on that machine. Without a TPM the agent writes {"escrows": {...}} in plaintext. Plaintext files from before you had a TPM get sealed automatically on the next write. Drift state in /var/lib/corvus-agent/state.json uses the same seal (corvus-agent-state).

The escrow file holds the account name and the password. Filesystem permissions guard it. TPM sealing adds offline theft protection; root on the live host can still decrypt it.