Help us shape the future of Go! Take the 2026 Go Developer Survey

revocation

package module
v0.6.6 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 26, 2026 License: BSD-3-Clause Imports: 23 Imported by: 2

README

go-authn/revocation

Distributing revocation lists, SSH KRLs and X.509 CRLs, from the authority that writes them to every server that must refuse what they list, without trusting anything in between and without ever mistaking a stale list for a current one.

  • PROTOCOL.md: what is verified, and why.
  • authn-revokd (called revokd before v0.6.0): an agent that fetches the lists, keeps only those that verify, and writes them where sshd and TLS servers read them. When a list lapses, it fails closed for sshd. With listen, it serves the verified lists to other agents as a mirror.
  • the Go package: verifies, orders and fetches lists, and signs KRLs for an issuer (go-authn/bridge).

Why

OpenSSH has no way to distribute a KRL. Practice is configuration management, rsync or cron with curl, and two things are missing whatever the transport:

  • A KRL is not signed. OpenSSH never verifies the KRL signature section. PROTOCOL.krl says 9.4 and later refuse a KRL that has one; measured, ssh-keygen 9.6p1 and 10.3p1 load it and skip the signature. Either way, a KRL is only as authentic as the last hop that carried it.
  • Nothing says a KRL is stale. sshd reads a file that stopped updating months ago as the current list. TUF calls serving such a file an indefinite freeze attack.

A CRL has neither problem: it is signed, and has a nextUpdate past which OpenSSL refuses it (measured below). This brings KRLs to the same place:

KRL CRL
authentic detached SSHSIG by the SSH CA key, namespace krl@go-authn.github.io, checked with ssh-keygen -Y verify too signed by its CA
current expires@go-authn.github.io, a non-critical extension, which sshd ignores (go-authn/krl v0.2.0) nextUpdate
never backwards krl_version, then generation date CRL Number, then thisUpdate
fail closed authn-revokd writes a KRL that revokes the CA key: every certificate of that CA is refused OpenSSL refuses an expired CRL by itself

Since every list verifies by itself, the transport can be HTTPS, HTTP inside a cluster, a CDN, a mirror, or a file. The transport decides how fast a change arrives, never whether a forged or stale list is accepted.

authn-revokd

docs/install.md installs it as a hardened systemd service (units in packaging/), wires sshd and a TLS server, and says how to upgrade, roll back and uninstall. Upgrading from revokd: see Upgrading to v0.6.0.

# /etc/authn-revokd/revokd.hcl
state_dir = "/var/lib/authn-revokd/state"   # the verified copies: rollback protection. Refused if a symlink,
                                            # group- or world-writable, or owned by neither its user nor root
refresh   = "1m"

source "univ-a-ssh" {
  url    = "https://idp.univ-a.example/ssh/krl"   # and .../ssh/krl.sig
  ssh_ca = "/etc/ssh/ca/univ-a.pub"          # what the list must be signed by
}
source "univ-b-ssh" {
  url    = "http://mirror.cluster.local:8080/univ-b-ssh"   # a mirror: plain http is fine
  ssh_ca = "/etc/ssh/ca/univ-b.pub"
  # revoke_keys = true                       # this source may revoke any key, of any CA's users
}
source "univ-a-x509" {
  url     = "https://idp.univ-a.example/x509/crl"
  x509_ca = "/etc/ssl/univ-a-ca.pem"
}

output "sshd" {                              # sshd_config: RevokedKeys /var/lib/authn-revokd/sshd.krl
  path    = "/var/lib/authn-revokd/sshd.krl"
  sources = ["univ-a-ssh", "univ-b-ssh"]     # merged into one file
}
output "nginx" {                             # nginx: ssl_crl /var/lib/authn-revokd/univ-a.crl
  path      = "/var/lib/authn-revokd/univ-a.crl"
  sources   = ["univ-a-x509"]
  format    = "pem"
  # on_change = ["/usr/local/bin/notify-something"]   # retried at each sync until it succeeds;
  #   under the systemd unit it runs unprivileged: reload nginx with a path unit (docs/install.md)
}

listen = ":8080"                             # optional: serve the verified lists (a mirror)
authn-revokd                    # run (-config /etc/authn-revokd/revokd.hcl by default); SIGHUP: sync now
authn-revokd -once              # one sync, for a timer: exit 1 when a list is not current or could not be fetched
authn-revokd -version
  • One file for sshd. RevokedKeys takes a single file before OpenSSH 10.3 (multiple files arrived with openssh-portable 135a622, 2026-02-11; Debian 13 ships 10.0, Ubuntu 24.04 9.6), so the KRL sources of an output are merged. Each list keeps its full effect on its own CA, and none on another (krl.Builder.MergeCAWith, krl v0.5.0):
    • Kept: its serials and key IDs, its any-CA sections (re-scoped to its CA), and its own CA key, which revokes all its certificates.
    • Left out, and logged: another CA's section, and another CA's key named by blob or fingerprint. So one CA cannot revoke another CA.
    • User keys (a plain public key, by blob or fingerprint) are left out, and logged, when the output has more than one source, unless the source has revoke_keys = true. A KRL cannot scope a key to one CA: sshd checks a certificate's own key against the file, whoever signed it, so a key one CA names locks its holder out under every CA of the file. Set revoke_keys only on a source trusted with any key, of any CA's users. Without it, a CA revokes its certificates by serial or key ID, which reach only its own. An output with one source keeps them.
    • A source whose list is not current contributes its CA key, revoked. The file's comment says FAIL CLOSED and names it (ssh-keygen -Q -l -f shows it).
    • Each list is first merged alone. A list that cannot be merged (past the serial ranges krl's Builder keeps, 4M) fails closed for its own CA only: its CA key is revoked, the other sources keep their lists, and /healthz names the source. If the lists merge alone but not together, every CA of the output is revoked until they do. Keeping the old file would let a later lapse go unseen.
    • The merged file expires with the first of its current inputs, so a reader that checks expiry sees authn-revokd stop.
  • max_age applies to KRL sources. It is refused on a source that feeds a CRL output: a TLS server judges a CRL by its nextUpdate alone and keeps one it has loaded in memory until then, so authn-revokd cannot shorten it.
  • The state is one file per source, holding the list and its signature, written by a single rename and the directory fsynced. A crash cannot leave a pair that fails to verify and thereby take the rollback guard with it. A v0.1 state (two files) is read once and replaced.
  • No reload for sshd. sshd reads RevokedKeys at each authentication, so the atomic rename is enough. An unreadable file refuses every login (sshd_config(5)).
  • The mirror serves GET /<source> (with ETag, If-None-Match), GET /<source>.sig (with If-Match, 412 when the list changed in between), and GET /healthz (503 while a source has no current list or an output fails, naming the source whose list cannot be merged). A source name ending in .sig, or healthz, is refused. A slow upstream never blocks the mirror's answers. It serves from memory the pair its fetcher holds, so a list and a signature always match. Mirrors chain: a mirror's source can be another mirror.

Measured

  • A real sshd judges what authn-revokd writes (TestSSHDJudgesWhatRevokdWrites: OpenSSH 10.3p1 on macOS, and 9.6p1 on the Ubuntu CI runner, a single-file RevokedKeys sshd). A certificate logs in with nothing revoked; once its serial is revoked it is refused; with a list revoking nothing that then lapses, it is refused (the fail-closed list); with a fresh list it logs in again. sshd is never restarted. Removing the fail-closed list from authn-revokd turns this red.
  • OpenSSL fails closed on its own: openssl verify -crl_check with a CRL past its nextUpdate answers error 12 ... CRL has expired (OpenSSL 3.6.4).
  • ssh-keygen judges the signatures both ways: ssh-keygen -Y verify accepts what SignKRL writes, and VerifyKRL accepts what ssh-keygen -Y sign writes. Each refuses the other namespace.
  • RSA with SHA-1 (ssh-rsa), which PROTOCOL.sshsig forbids, is accepted by x/crypto's and hiddeco/sshsig's Verify. It is refused because hiddeco/sshsig's Unarmor refuses that format. A test holds the dependency to it, with rsa-sha2-512 as the control.
  • A poll costs the mirror about 0.6 µs of handler time when nothing changed (304), whatever the size of the list. The first version hashed the list per request: 122 µs at 100 000 revocations. A full download of 100 000 sparse revocations is 256 KiB and costs 22 µs to serve and 2.6 ms to verify. So 10 000 servers polling every minute make 167 requests a second, which a mirror handles with a fraction of a core, TLS aside (go test -run '^$' -bench Mirror ./cmd/authn-revokd).

Reviewed

A security audit of v0.2.0 found that MergeCA (krl v0.3.0) also dropped a CA's revocations of user keys and of its own key. A CA revoking a compromised key revoked nothing once merged: a fail-open, fixed in krl v0.4.0 and here in v0.2.1 (TestFoundACAsOwnKeyRevocationsReachSSHD, which fails against krl v0.3.0). It also found that a CRL was parsed before its signature was checked: a 63 MB CRL signed by anybody cost 2.7 s and 3 GB allocated before being refused. The signature is now checked first, on the raw bytes: 29 ms, 36 allocations (TestACRLFromAnotherCAIsRefusedBeforeItIsParsed). govulncheck found six reachable standard-library advisories in Go 1.26.4, so the module requires Go 1.26.6, which an older Go fetches by itself.

A security audit of v0.2.1 found three more, each proved by a test that failed and is now its regression test (cmd/authn-revokd/audit_test.go), checked to fail without its fix:

  • one CA's list could lock out another CA's users by revoking their public keys (TestOneCAsListCannotRevokeAnotherCAsUserKeyUnlessTrustedWithKeys): now revoke_keys, above;
  • one CA's oversized list (~4.9M serial ranges, 1.2 MB signed) failed the whole output closed and locked out every CA's users (TestAListThatCannotBeMergedFailsClosedForItsOwnCAOnly);
  • the state directory was os.Stat-ed: a symbolic link, and a directory another user owns, were accepted (TestStateDirMustNotBeASymlinkNorAnotherUsersDirectory). It is now Lstat-ed, and its owner must be authn-revokd's user or root, as sshd's StrictModes requires.

An adversarial review of v0.1.1 found eight defects in this repository, each proved by a failing test. They are now regression tests (cmd/authn-revokd/review_test.go), each checked to fail without its fix:

  • a crash between the two state writes dropped the rollback guard;
  • a render error froze the merged output, so another source's lapse was never applied;
  • max_age was promised for CRL outputs and not enforced;
  • the mirror was blocked behind a slow upstream;
  • one CA's list could revoke another's certificates;
  • source names collided with the mirror's paths;
  • the state directory's permissions were not checked;
  • three RFC 5280 "quotations" in PROTOCOL.md were not in the RFC. They came from a summarising fetch tool and are now taken from the RFC's text.

What it does not do

A revocation reaches a server no faster than the server polls (refresh), plus the issuer's delay in re-issuing. When the issuer is down, it is refused from the list's expiry onward. Whoever holds a CA's key can sign any list, as they can sign any certificate.

Release binaries

Each release carries authn-revokd for linux, darwin and windows on amd64 and arm64 (pure Go, CGO_ENABLED=0), a SHA256SUMS manifest, and a build provenance attestation per binary, made by this repository's release workflow at the tag. Check a download before running it:

sha256sum -c SHA256SUMS --ignore-missing
gh attestation verify authn-revokd-linux-amd64 --repo go-authn/revocation

authn-revokd -version prints the tag it was built from. Before v0.6.0 the assets were revokd-<os>-<arch>.

License

BSD 3-Clause.

Documentation

Overview

Package revocation verifies, orders and fetches revocation lists -- SSH KRLs with a detached SSHSIG signature, and X.509 CRLs -- for a distribution that trusts nothing in between: every list is checked against the CA it revokes for, refused once it is no longer current, and never let replace a newer one.

VerifyKRL and VerifyCRL check a list; List.Current and List.Follows say whether it may be used now and whether it may replace the one held; Fetcher fetches one source with all three; SignKRL signs a KRL for an issuer; FailClosedKRL is what a distributor writes for sshd when it has no current list.

PROTOCOL.md says what is verified and why; the authn-revokd command distributes lists with this package.

Index

Constants

View Source
const Namespace = "krl@go-authn.github.io"

Namespace is the SSHSIG namespace a KRL's signature is made in (PROTOCOL.sshsig: it keeps a signature from being taken for another purpose). ssh-keygen -Y verify -n krl@go-authn.github.io checks it.

Variables

View Source
var (
	// ErrExpired is a list past its expiry, or older than the caller's
	// max_age: not current.
	ErrExpired = errors.New("revocation: the list is no longer current")
	// ErrRollback is a list older than the one held.
	ErrRollback = errors.New("revocation: the list is older than the one held")
)

Errors a caller tells apart.

Functions

func FailClosedKRL

func FailClosedKRL(ca ssh.PublicKey, now time.Time) ([]byte, error)

FailClosedKRL is the list a distributor writes for sshd when it has no current list from ca: ca's own key revoked, which sshd checks for every certificate ca signed (krl.c ssh_krl_check_key) -- so every one of them is refused, and nothing else. It is not signed: it is written locally, for the reader on this machine, never distributed.

func FilePath

func FilePath(u string) (string, error)

FilePath is the local path a file:// URL names: file:///etc/x is /etc/x, and on Windows file:///C:/x is C:\x (RFC 8089, appendix E.2).

func FileURL

func FileURL(path string) string

FileURL is the file:// URL of a local path, the inverse of FilePath.

func SignKRL

func SignKRL(raw []byte, ca ssh.Signer) ([]byte, error)

SignKRL signs a KRL for distribution: an armored SSHSIG signature by the CA key, in Namespace, hashed with SHA-512 (ssh-keygen -Y sign's default). The KRL must say when it expires (krl.Builder.SetExpires): a verifier refuses one that does not.

Types

type Fetcher

type Fetcher struct {
	// contains filtered or unexported fields
}

Fetcher fetches one source, keeping the last list that verified.

func NewFetcher

func NewFetcher(src Source, held *List) (*Fetcher, error)

NewFetcher returns a Fetcher for src that holds held, the copy kept from before (the file last written, read back): a fetched list must follow it.

func (*Fetcher) Fetch

func (f *Fetcher) Fetch(ctx context.Context) (*List, bool, error)

Fetch fetches the source once. It returns the list held afterwards, and whether it changed. A list that does not verify, is not current, or does not follow the one held is refused with an error, and the one held is kept: never the empty answer, which would revoke nothing.

func (*Fetcher) Held

func (f *Fetcher) Held() *List

Held is the last list that verified, or nil.

type Kind

type Kind int

Kind is what a list is.

const (
	// KRL is an OpenSSH key revocation list with its detached SSHSIG
	// signature.
	KRL Kind = iota + 1
	// CRL is an X.509 certificate revocation list, DER-encoded.
	CRL
)

func (Kind) String

func (k Kind) String() string

type List

type List struct {
	Kind Kind
	// Raw is the list as it was signed: what sshd, OpenSSL and every other
	// reader take.
	Raw []byte
	// Sig is a KRL's armored SSHSIG signature; nil for a CRL, which is
	// signed inside.
	Sig []byte
	// Version is the KRL's krl_version or the CRL Number: the order lists
	// of one issuer come in.
	Version *big.Int
	// Issued is the KRL's generation date or the CRL's thisUpdate.
	Issued time.Time
	// Expires is the KRL's expires@go-authn.github.io extension or the
	// CRL's nextUpdate: when the list stops being current.
	Expires time.Time

	// KRL is the parsed KRL, for a KRL.
	KRL *krl.KRL
	// CRL is the parsed CRL, for a CRL.
	CRL *x509.RevocationList
}

List is a revocation list that verified: its signature by the CA it was checked against, and its form. Whether it is still current is Current's question; whether it may replace another is Follows'.

func VerifyCRL

func VerifyCRL(raw []byte, ca *x509.Certificate) (*List, error)

VerifyCRL checks a CRL, DER or PEM, against the CA certificate ca. It refuses: a signature by anything else, or an issuer that is not ca's subject; a CRL with no number or no nextUpdate, which could be neither ordered nor aged; a delta CRL, which only means something with its base (RFC 5280 5.2.4); and a critical extension it does not understand, nor any critical entry extension.

func VerifyKRL

func VerifyKRL(raw, sig []byte, ca ssh.PublicKey) (*List, error)

VerifyKRL checks a KRL and its armored SSHSIG signature against the SSH CA key ca. It refuses: a signature by any other key, in another namespace, or in the RSA SHA-1 form PROTOCOL.sshsig forbids (hiddeco/sshsig refuses it when it reads the signature; x/crypto alone would verify it); a KRL sshd would not load (go-authn/krl refuses what OpenSSH refuses), or one with the old signature section, which OpenSSH never verifies and which PROTOCOL.krl says 9.4 and later refuse -- measured, 9.6p1 and 10.3p1 load it, so whether a given sshd does is not something to rely on; and a KRL that does not say when it expires, which a reader could never tell from a stale one.

func (*List) Current

func (l *List) Current(now time.Time, maxAge time.Duration) error

Current reports whether l may be used at now: not past its expiry, and, when maxAge is not zero, issued no more than maxAge before now. A list issued in the future -- an issuer's clock ahead by more than skew -- is not current either: its age cannot be known.

func (*List) Follows

func (l *List) Follows(held *List) error

Follows reports whether l may replace held, a list of the same issuer: a higher version, or the same version issued later (the same revocations, re-issued with a new expiry). Anything else is a rollback -- a replayed or restored older list, which would un-revoke what the newer one revoked. A nil held is followed by anything.

func (*List) Same

func (l *List) Same(o *List) bool

Same reports whether l and o are the same issue of a list.

type Source

type Source struct {
	// URL is http://, https:// or file://. The list verifies by itself, so
	// the transport decides how fast a change arrives, never whether a
	// forged or stale list is taken. A KRL's signature is at URL+".sig".
	URL  string
	Kind Kind
	// SSHCA is the CA key a KRL must be signed by; X509CA the certificate a
	// CRL must be signed by.
	SSHCA  ssh.PublicKey
	X509CA *x509.Certificate
	// MaxAge, when not zero, bounds a list's age from its issue, besides
	// its own expiry.
	MaxAge time.Duration
	// Client fetches http and https URLs; nil is a client with a one
	// minute timeout.
	Client *http.Client
	// Clock is the time a list's currency is judged at; nil is time.Now.
	// One clock for the fetcher and its caller, or they disagree on what
	// is current.
	Clock func() time.Time
}

Source is where a list comes from and what it must verify against.

Directories

Path Synopsis
cmd
authn-revokd command
Command authn-revokd distributes revocation lists: it fetches SSH KRLs and X.509 CRLs, keeps only those that verify (signed by the CA configured for them, current, never older than the copy held), writes them where sshd and TLS servers read them -- failing closed for sshd when a list lapses -- and, with listen, serves the verified copies to other agents as a mirror.
Command authn-revokd distributes revocation lists: it fetches SSH KRLs and X.509 CRLs, keeps only those that verify (signed by the CA configured for them, current, never older than the copy held), writes them where sshd and TLS servers read them -- failing closed for sshd when a list lapses -- and, with listen, serves the verified copies to other agents as a mirror.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL