Zero-trust SSH certificate authority and control plane.
Mayfly issues short-lived OpenSSH user certificates to engineers who authenticate with GitHub and are explicitly authorized. Instead of distributing long-lived SSH keys, hosts trust a single CA public key, and every certificate is minted on demand, scoped to a principal, and expires within minutes. Each issuance and denial is recorded in a tamper-evident, append-only audit log.
- Pluggable identity providers — GitHub and Keycloak/OIDC are first-class
providers behind one abstraction (
AuthenticationProvider/ProviderRegistry); clients select one with an optionalprovider(?provider=/ body field), and adding another provider is "implement the trait + register" (ADR-0018/ADR-0021). Seedocs/keycloak.mdanddocs/oidc.md. - Device Flow auth — no passwords or pre-provisioned keys; identity is established through the provider's device authorization grant (RFC 8628).
- Deny-by-default authorization — provider-neutral allowlists: GitHub user/org/ team and OIDC group/role/attribute. Empty config denies everyone.
- CA management platform — manages 1–64 Ed25519 CAs whose metadata lives in SQLite and
whose encrypted keys live on disk. CAs are generated, imported, enabled, disabled, and
renamed through an admin API; the CA manager picks an enabled CA per request and signs
entirely with the
ssh-keycrate (nossh-keygen, no shell-outs). - Short-lived certificates — TTLs are bounded (default 5 minutes, max 1 hour).
- Tamper-evident audit log — SHA-256 hash-chained entries persisted to SQLite, with fail-closed writes on every security-relevant action.
- HTTPS only — a single TLS listener negotiating HTTP/2 or HTTP/1.1 via ALPN, built
on
rustlswith theringprovider. No plaintext transport. - Fail-fast startup — configuration, TLS material, provider credentials (GitHub and, when configured, Keycloak), and every stored CA are validated before the server accepts connections.
#![forbid(unsafe_code)]crate-wide.
- Rust 1.85+ (edition 2021)
- A GitHub OAuth app configured for the Device Flow (client id + secret)
- A storage passphrase used to encrypt CA keys at rest
Mayfly is a CA management server: it manages 1–64 Ed25519 CAs whose
metadata lives in the database and whose encrypted private keys live on disk
under ca.storage_directory. You do not generate or list CA keys by hand —
the server creates them through the admin API (and bootstraps a first CA,
mayfly-ca, on an empty store).
Every CA key is encrypted at rest with a single storage passphrase, read at
startup from the environment variable named by ca.passphrase_env (default
CA_STORAGE_PASSPHRASE) and never written to config:
export CA_STORAGE_PASSPHRASE='a-strong-storage-passphrase'Startup fails fast (closed) if there are more than 64 CAs, if any key id / public key / fingerprint is duplicated, if a key is undecryptable or not Ed25519, or if CAs exist but none is enabled.
Manage CAs at runtime with the mayfly ca CLI — the primary (and only
required) operator interface for CA administration. No manual REST calls are
needed; see mayfly-cli/docs/ca.md and mayfly-cli/docs/rotation.md.
# Generate, list, inspect, rotate, and retire CAs — all from the CLI.
export MAYFLY_CA_PASSPHRASE='a-strong-storage-passphrase'
mayfly ca create ca-2026-q3
mayfly ca list # -o wide|json|yaml; --watch
mayfly ca rotate # guided rotation: new CA + fleet rollout + warnings
mayfly ca disable <id> && mayfly ca retire <id> --yesThe CLI is a thin client of the deny-by-default, audited admin API (same authorization as certificate issuance). The equivalent REST calls remain available for automation:
# Generate a new CA (passphrase must match the storage passphrase).
curl -k -X POST https://127.0.0.1:8443/api/v1/admin/ca/generate \
-H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"key_id":"ca-2026-q3","passphrase":"a-strong-storage-passphrase"}'
# List all CAs, fetch one, or enable/disable/rename it.
curl -k https://127.0.0.1:8443/api/v1/admin/ca -H "authorization: Bearer $TOKEN"
curl -k -X PATCH https://127.0.0.1:8443/api/v1/admin/ca/<id> \
-H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"enabled":false}'Copy the example configuration and fill in your provider credentials (GitHub OAuth,
and/or a keycloak section for Keycloak/OIDC — see docs/keycloak.md):
cp config.example.yaml config.yamlConfiguration is layered, lowest precedence first:
- Built-in defaults
config.yaml(override the path withMAYFLY_CONFIG)- Environment variables prefixed
MAYFLY_, nesting with__
Sensitive values are best supplied via the environment rather than the file:
export MAYFLY_GITHUB__CLIENT_ID=Iv1.xxxxxxxx
export MAYFLY_GITHUB__CLIENT_SECRET=xxxxxxxx
export MAYFLY_SERVER__PORT=9443Note:
ca.passphrase_envmust not start withMAYFLY_— that prefix is reserved for configuration variables and would be intercepted by the config loader.
In development mode (environment: development), a self-signed certificate is
generated automatically under .mayfly/dev-certs/ so HTTPS works locally without
supplying TLS material. In production (the default), explicit server.tls.cert_path
and server.tls.key_path are required.
cargo runThe server starts on https://127.0.0.1:8443 by default. Because development uses a
self-signed certificate, pass -k (or the equivalent) to curl:
curl -sk https://127.0.0.1:8443/api/v1/healthAll endpoints are served under the /api/v1 prefix.
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/health |
none | Liveness, version, and uptime. |
GET |
/ready |
none | Readiness checks. |
POST |
/auth/device/start |
none | Begin the device flow (optional ?provider=). |
POST |
/auth/device/poll |
none | Exchange a device code for an access token (body provider?). |
GET |
/auth/whoami |
Bearer | Resolve the identity behind a token (optional ?provider=). |
POST |
/certificates/issue |
Bearer | Authenticate, authorize, and sign an SSH certificate. |
GET |
/certificates/validate |
none | Validate a certificate against the CA. |
POST |
/admin/ca/generate |
Bearer | Generate a new encrypted Ed25519 CA (→ CaView). |
POST |
/admin/ca/import |
Bearer | Import an existing encrypted Ed25519 CA key (→ CaView). |
POST |
/admin/ca/rotate |
Bearer | Guided rotation: generate a new CA + report fleet rollout (→ RotationResult). |
GET |
/admin/ca |
Bearer | List metadata for all managed CAs (→ CaView[]). |
GET |
/admin/ca/stats |
Bearer | Aggregate signing statistics (issued counts per CA). |
GET |
/admin/ca/bundle |
Bearer | The current public trust bundle (active CAs). |
GET |
/admin/ca/{id} |
Bearer | Detailed metadata for one CA (never the private key). |
GET |
/admin/ca/{id}/public-key |
Bearer | Export one CA's public key + fingerprint. |
PATCH |
/admin/ca/{id} |
Bearer | Enable, disable, or rename a CA (→ CaView). |
POST |
/admin/ca/{id}/enable |
Bearer | Enable a CA (add to signing set + bundle). |
POST |
/admin/ca/{id}/disable |
Bearer | Disable a CA (remove from signing set + bundle). |
GET |
/admin/ca/{id}/retirement |
Bearer | Whether a (disabled) CA can be safely retired. |
POST |
/admin/ca/{id}/retire |
Bearer | Permanently retire a disabled CA ({"force": true} to override). |
DELETE |
/admin/ca/{id} |
Bearer | Delete an unused (disabled, safe) CA (?force=true to override). |
GET |
/admin/bundle/status |
Bearer | Fleet rollout metrics (liveness + machines per generation). |
POST |
/admin/machines/enrollment-tokens |
Bearer | Mint a single-use enrollment token (TTL 60s–24h). |
GET |
/admin/machines |
Bearer | List enrolled machines (filterable; derived liveness + up-to-date). |
GET |
/admin/machines/{id} |
Bearer | One machine's full detail. |
POST |
/admin/machines/{id}/approve |
Bearer | Approve a pending machine (pending → active). |
POST |
/admin/machines/{id}/disable |
Bearer | Disable a machine (blocked until re-enabled). |
POST |
/admin/machines/{id}/enable |
Bearer | Re-enable a disabled machine. |
POST |
/admin/machines/{id}/revoke |
Bearer | Revoke a machine (permanently blocked). |
DELETE |
/admin/machines/{id} |
Bearer | Permanently delete a machine record. |
POST |
/admin/machines/{id}/reenroll |
Bearer | Revoke + mint a fresh single-use enrollment token. |
POST |
/admin/machines/{id}/rotate-identity |
Bearer | Rotate identity (revoke + new enrollment token). |
GET |
/admin/audit |
Bearer | Search the audit log (filter by machine/operator/provider/serial/request-id/event-type/result/date; paginated). |
GET |
/admin/audit/stream |
Bearer | Incremental audit tail (ascending; ?after=<chain_position> cursor). |
GET |
/admin/health |
Bearer | Rolled-up fleet health (status, machines, rollout, certificate/auth activity, bundle, audit). |
GET |
/admin/status |
Bearer | Cluster/system status (version, uptime, CA inventory, bundle generation, API summary). |
GET |
/admin/metrics |
Bearer | In-memory API request statistics + per-route timings (ephemeral telemetry). |
GET |
/admin/rollout |
Bearer | Fleet rollout status: progress, completion %, ETA, breakdown, generations, health. |
GET |
/admin/rollout/generations |
Bearer | Machine population per CA generation. |
GET |
/admin/rollout/machines |
Bearer | Per-machine rollout state (?state=all|current|lagging|stuck, ?generation=). |
GET |
/admin/rollout/stuck |
Bearer | Stuck machines + per-machine retry recommendation. |
GET |
/admin/rollout/health |
Bearer | Rollout health score (Healthy|Degraded|Blocked|Failed) + reasons. |
GET |
/admin/rollout/explain |
Bearer | Categorized explanation of why the rollout is incomplete. |
GET |
/admin/rollout/timeline |
Bearer | Recent bundle rollout events (?limit=, max 500). |
GET |
/admin/rollout/history |
Bearer | Generation adoption history (population + apply timestamps). |
POST |
/machines/enroll |
Token | Enroll an agent; returns intervals + Bundle Signing Key to pin. |
POST |
/agent/heartbeat |
Ed25519 sig | Agent liveness heartbeat. |
GET |
/agent/ca-bundle |
Ed25519 sig | Fetch the current signed CA bundle (ETag / 304). |
POST |
/agent/ca-bundle/ack |
Ed25519 sig | Report apply outcome (applied / rollback / signature_failed). |
Authenticated endpoints expect an Authorization: Bearer <provider-access-token> header
(a GitHub token, or — with ?provider=keycloak / body provider — an OIDC access token).
Agent endpoints are authenticated by a per-request Ed25519 signature, not a bearer token.
Agents fetch the CA trust bundle from GET /api/v1/agent/ca-bundle. The response is a
versioned, signed artifact — not just a key list:
{
"bundle_version": 1,
"generation": 42,
"created_at": "2026-06-29T00:00:00Z",
"expires_at": "2026-06-29T01:00:00Z",
"fingerprint": "sha256:9f86d081...",
"keys": [
{ "key_id": "ca-2026-q3", "public_key": "ssh-ed25519 AAAA...", "fingerprint": "SHA256:..." }
],
"signature_algorithm": "ssh-ed25519",
"signature": "Base64(Ed25519 over the canonical representation)",
"bundle_signing_public_key": "ssh-ed25519 AAAA... (OpenSSH public key line)"
}The canonical representation that the signature covers is a single-line UTF-8
JSON document with members in fixed (alphabetical) order and keys sorted by
key_id (server and agent produce it byte-for-byte identically):
{"bundle_version":1,"created_at":"<rfc3339>","expires_at":"<rfc3339>","fingerprint":"sha256:...","generation":42,"keys":[{"key_id":"ca-2026-q3","public_key":"ssh-ed25519 AAAA..."}]}
- Authenticity: the signature is computed over a fixed, version-specific canonical byte layout (never the serialized JSON), using a dedicated Bundle Signing Key that is distinct from the SSH CA keys. Agents pin this key from the enrollment response and must verify the signature (and reject expired bundles) before trusting the bundle — failures fail closed.
- Caching: the response
ETagis the bundle fingerprint. An up-to-date agent sendsIf-None-Match: "<fingerprint>"and receives304 Not Modified(no body, no re-signing). - Polling: each agent receives a per-host jittered
sync_interval(±jitter_percent, CSPRNG) at enrollment so the fleet does not poll in lockstep. - Acknowledgement: after applying a bundle the agent calls
/agent/ca-bundle/ackwithstatus: applied(advances its synced generation), orrollback/signature_failed(audited only). Every outcome is recorded in the audit log.
The certificate principal is always derived from the authenticated identity (the
provider's username) — never from the request body — so a caller cannot request a
certificate for someone else. This holds for every provider. Add an optional
"provider" field to verify the bearer against a non-default provider (e.g.
"keycloak"); omit it to use the configured default_provider.
curl -sk -X POST https://127.0.0.1:8443/api/v1/certificates/issue \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"public_key": "ssh-ed25519 AAAA... user@host",
"hostname": "bastion.example.com",
"ttl_seconds": 300,
"provider": "keycloak"
}'The response contains the OpenSSH-formatted certificate, its serial, principal, fingerprint, effective TTL, and validity window.
The /admin/machines endpoints are the control plane for the enrolled fleet and
back the mayfly machine CLI (the primary operator interface — no manual REST
calls are required). All of them are Bearer-authenticated and authorized with
the same deny-by-default policy as the CA admin API.
- List / show project a rich, presentation-neutral view of each machine,
including derived
liveness(online/stale/offline) andup_to_date(synced generation vs. the latest CA generation).GET /admin/machinesfilters server-side bystatus,liveness,hostname(substring),generation,os,arch, andagent_version. - Lifecycle —
approve/enableset a machineactive;disableandrevokeblock it;deleteremoves the record. Because agents are pull-based, these take effect at the per-request authentication gate: a machine that is notactive(or no longer exists) has its next signed request rejected, so it stops converging immediately — without any change to the agent. - Re-enroll / rotate-identity delete the existing machine (freeing its hostname and key) and return a fresh single-use enrollment token; applying it on the host enrolls a brand-new keypair, which is exactly an identity rotation. The old identity is dead the moment the call returns.
Auditing: every mutation (machine.approved/disabled/enabled/revoked/
deleted/reenroll_requested/identity_rotation_requested) and every
authorization denial (machine.admin_denied) appends a fail-closed,
hash-chained audit entry recording the operator identity and
privacy-preserving client context. Read operations (list/get) are
authorized but intentionally not audited, so CLI --watch polling cannot flood
the audit log.
# List active machines that are offline (operator Bearer token required).
curl -sk "https://127.0.0.1:8443/api/v1/admin/machines?status=active&liveness=offline" \
-H "Authorization: Bearer $TOKEN"
# Disable, then rotate a machine's identity (returns a single-use token).
curl -sk -X POST https://127.0.0.1:8443/api/v1/admin/machines/<id>/disable \
-H "Authorization: Bearer $TOKEN"
curl -sk -X POST https://127.0.0.1:8443/api/v1/admin/machines/<id>/rotate-identity \
-H "Authorization: Bearer $TOKEN"The /admin/ca endpoints are the complete control plane for the SSH User CAs
and back the mayfly ca CLI (the only interface an operator needs — no manual
REST calls are required). All are Bearer-authenticated and authorized with the
same deny-by-default policy as certificate issuance.
- List / show / stats / bundle project a rich, presentation-neutral
CaView(an additive superset of the storedCaRecord: same field names plus derivedstatus,in_current_bundle,age_seconds,bundle_generation, and a synthesizedactivation_historybuilt from the record's own timestamps). Private key material is never returned;public-key/bundleemit public keys and fingerprints only. - Lifecycle —
generate/importadd CAs;enable/disable(andPATCH) move a CA in and out of the signing set + trust bundle (bumping the generation);retiredestroys a disabled CA's key material but keeps its audit row;deleteremoves a disabled CA's row and key file. - Rotation —
POST /admin/ca/rotateperforms the safe first step of a rotation: it generates a new enabled CA and returns aRotationResultwith the previous active CA(s), fleet rollout metrics, and warnings. It deliberately does not retire the predecessor — the old CA stays active during overlap until the pull-based fleet converges on the new generation. - Safety (fail-closed
409) — an active CA cannot be deleted; the last enabled CA cannot be disabled and retire/delete require a disabled CA (so the bundle is never emptied); duplicate fingerprints/keys are refused on import; non-Ed25519/unparseable keys can never enter the signing set.retire/deleteare dependency-gated (forceto override, which is loudly audited).
Auditing: every mutation (ca.generated/imported/renamed/enabled/
disabled/rotated/retired/deleted and the *.denied/*.forced variants)
and every authorization denial (ca.admin_denied) appends a fail-closed,
hash-chained audit entry recording the operator identity and
privacy-preserving client context. Reads (list/get/stats/bundle/
public-key/retirement/status) are authorized but intentionally not audited,
so CLI --watch polling cannot flood the audit log. Passphrases and private key
material are never logged, audited, or returned.
The /admin/audit, /admin/audit/stream, /admin/health, /admin/status, and
/admin/metrics endpoints make the platform observable from the mayfly CLI
(audit/events/history/health/status/metrics/doctor) — an operator
can investigate and troubleshoot the whole fleet without manual REST calls.
All are Bearer-authenticated and authorized deny-by-default.
- Audit search (
GET /admin/audit) is a read-only query layer over the existing append-onlyaudit_log(the append path, hash chain, and triggers are untouched). It filters byevent_type(exact orprefix.),actor,machine/hostname,provider,serial,request_id,result(success/failure, derived from the event type), andsince/until, withlimit+chain_positioncursor paging. Search performance is backed by the index-only migration 007 (event_type/actor/subject/recorded_at). - Audit stream (
GET /admin/audit/stream?after=<position>) returns new entries in ascending order for incremental tailing (mayfly audit --follow). - Health / status roll up existing services (bundle/machine/CA/audit) into a single fleet view; the CLI renders, the server composes.
- Metrics (
GET /admin/metrics) exposes in-memory per-route request counts, status-code breakdown, and latency (avg/p50/p95/max) collected by a transparent router middleware. Metrics are ephemeral operational telemetry (reset on restart), bounded against route-cardinality blowup — they are not audit.
Like the other admin reads, these endpoints are authorized but not audited
(so polling/streaming cannot flood the chain); only an authorization denial
(ops.admin_denied) is recorded. Audit metadata returned to the CLI never
contains secrets, tokens, private keys, or full certificates.
The /admin/rollout* endpoints back mayfly rollout — the operator console for
observing and managing CA-bundle rollouts (ADR-0025). The rollout module is a
read-only composition over existing subsystems: machine views
(MachineAdminService), the CA generation counter (CaManager), and the
append-only audit log's bundle.* events (bundle.downloaded/applied/
rollback/signature_failed). It introduces no persistence, no write path,
and no migration.
- Status (
GET /admin/rollout) reports completion over active machines, a healthy/pending/stale/offline/failed breakdown, per-generation population, a transparent ETA (recent apply rate ÷ remaining), and an embedded health verdict. - Health (
GET /admin/rollout/health) scores the rolloutHealthy/Degraded/Blocked/Failedwith reasons (signature failures →Failed; all-remaining-offline →Blocked). - Explain (
GET /admin/rollout/explain) and stuck (GET /admin/rollout/stuck) categorize each not-up-to-date machine (offline / heartbeat stale / helper failure / bundle verification failure / generation mismatch / disabled / revoked) and give a recommended action. - Generations / machines / timeline / history drill into the per-generation population, per-machine state (filterable), recent events, and adoption over time.
Same posture as the operational console: Bearer + deny-by-default, reads
un-audited (only ops.admin_denied on denial), no secrets in any response.
| Key | Description |
|---|---|
environment |
development or production (default). |
server.host / port |
Bind address and port. |
server.tls.enabled |
HTTPS toggle (Mayfly only serves HTTPS). |
server.tls.cert_path |
PEM certificate chain (required in production). |
server.tls.key_path |
PEM private key (required in production). |
database.url |
SQLx connection URL (e.g. sqlite://mayfly.db). |
database.max_connections |
Connection pool size. |
logging.format |
pretty (local) or json (aggregation). |
logging.level |
Tracing filter (e.g. info, mayfly_server=debug). |
github.client_id |
GitHub OAuth client id (required). |
github.client_secret |
GitHub OAuth client secret (required; prefer the environment). |
github.scopes |
OAuth scopes (default read:user user:email). |
github.device_base_url |
Device/authorization base URL (override for GitHub Enterprise). |
github.api_base_url |
REST API base URL. |
keycloak.issuer_url |
OIDC issuer URL (required when [keycloak] present). See docs/keycloak.md. |
keycloak.client_id |
OIDC client id (required when [keycloak] present). |
keycloak.client_secret |
OIDC client secret (confidential clients only; prefer the environment). |
keycloak.scopes |
OAuth scopes (default openid profile email). |
keycloak.audience |
Expected token aud; enforced only when set (recommended). |
keycloak.clock_skew_seconds |
exp/nbf leeway in seconds (default 60). |
default_provider |
Provider used when a request omits provider (github default). |
ca.storage_directory |
Directory holding the encrypted CA private key files (default ./ca). |
ca.selection_strategy |
Signing-CA selection strategy (random). |
ca.auto_load |
Load all stored CAs at startup; bootstrap a first CA if empty (default true). |
ca.passphrase_env |
Env var holding the storage passphrase (default CA_STORAGE_PASSPHRASE). |
bundle.sync_interval_seconds |
Base agent poll cadence in seconds (default 300). |
bundle.jitter_percent |
Per-host poll-interval jitter, 0–100 (default 10). |
bundle.ttl_seconds |
Signed bundle validity window in seconds (default 3600). |
bundle.signing_key_env |
Env var holding the base64 Bundle Signing Key seed (default BUNDLE_SIGNING_KEY; generated on disk if unset). |
access.allowed_users |
Usernames always allowed (GitHub login or OIDC preferred_username). |
access.allowed_orgs |
GitHub orgs whose members are allowed. |
access.allowed_teams |
GitHub teams (org-login/team-slug) whose members are allowed. |
access.allowed_groups |
OIDC groups whose members are allowed (e.g. Keycloak groups). |
access.allowed_roles |
OIDC roles allowed (client/role or bare realm role). |
access.allowed_attributes |
OIDC attributes allowed, each key=value. |
Access is deny-by-default: leaving every access list empty denies everyone.
Matching is provider-neutral and case-insensitive — a provider resolves only the
facts the policy references (GitHub queries org/team membership only when those
lists are non-empty, which may require the read:org scope). Allowlists are not
scoped by provider; for OIDC prefer groups/roles/attributes over allowed_users.
cargo build # compile
cargo test # unit + integration tests
cargo clippy # lints
cargo fmt # formattingIntegration tests live under tests/ and cover the server, auth, certificate, and
audit flows.
Apache-2.0.