Skip to content

add CRL-based client certificate revocation check#219

Open
Jan-Schuppik wants to merge 2 commits into
mainfrom
add-client-cert-revoke-check
Open

add CRL-based client certificate revocation check#219
Jan-Schuppik wants to merge 2 commits into
mainfrom
add-client-cert-revoke-check

Conversation

@Jan-Schuppik

@Jan-Schuppik Jan-Schuppik commented Jul 8, 2026

Copy link
Copy Markdown

Add a crlChecker that loads a PEM or DER Certificate Revocation List, verifies its signature against the CA, and watches the file for live updates via fsnotify. Atomic file replacements (mv) are handled by re-adding the watch path on Create/Rename events.

Expose ApplyRevocationCheck on TLSCommon, which wires the checker into tls.Config.VerifyConnection so revoked client certificates are rejected at handshake time.

resolves #218

refs Icinga/icinga-notifications#454

@Jan-Schuppik Jan-Schuppik self-assigned this Jul 8, 2026
@cla-bot cla-bot Bot added the cla/signed CLA is signed by all contributors of a PR label Jul 8, 2026
@Jan-Schuppik Jan-Schuppik force-pushed the add-client-cert-revoke-check branch 3 times, most recently from e517715 to 4f6220d Compare July 8, 2026 14:03
@Jan-Schuppik Jan-Schuppik requested a review from yhabteab July 10, 2026 08:13
@Jan-Schuppik Jan-Schuppik marked this pull request as ready for review July 10, 2026 08:13
@Jan-Schuppik Jan-Schuppik force-pushed the add-client-cert-revoke-check branch from 8206a66 to a26e156 Compare July 10, 2026 08:18
Comment thread config/tls.go
Comment thread config/tls_test.go Outdated
Comment thread config/tls_test.go Outdated
Comment thread config/tls_test.go Outdated
Comment thread config/tls_test.go Outdated
Comment thread config/tls_test.go Outdated
Comment thread config/tls_test.go Outdated
Comment thread config/tls_test.go Outdated
Comment thread utils/crl.go Outdated
Comment thread utils/crl.go Outdated
Introduce utils.CrlChecker, which loads a PEM- or DER-encoded CRL
from disk, verifies its signature against the configured CA certificate,
and exposes IsRevoked for serial-number lookups. The in-memory CRL is
protected by a sync.RWMutex so background reloads are safe to run
concurrently with ongoing TLS handshakes.

IsRevoked treats an expired CRL (NextUpdate in the past) as an error
rather than silently allowing or denying connections, so a stale CRL
is always surfaced as a configuration problem rather than a silent
security regression.

WatchAndReload starts a background goroutine backed by fsnotify that
reloads the CRL whenever the file changes. Atomic file replacements
(mv tmp.crl ca.crl) are handled correctly on both Linux (inotify fires
Remove/IN_DELETE_SELF on the old inode) and macOS (kqueue fires Rename)
by re-adding the watch path after every event. Reload failures are
logged as warnings and do not stop the watcher; the last successfully
loaded CRL remains active until the next successful reload.

TLSCommon gains a CrlFile field (yaml: crl_file, env: CRL_FILE) and a
new InitRevocationChecking method that wires CRL checking into an
existing *tls.Config. The method is a no-op when the config is nil or
either CrlFile or Ca is unset, which keeps backward compatibility for
callers that do not configure a CRL. When active, it installs a
VerifyConnection hook that rejects any client certificate whose serial
number appears in the CRL. If a VerifyConnection hook is already set on
the config, it is called first and its error takes precedence, so
existing custom verification logic is preserved.

InitRevocationChecking returns the CrlChecker so callers can keep it
alive and call WatchAndReload for live CRL rotation without restarting
the server.
@Jan-Schuppik Jan-Schuppik force-pushed the add-client-cert-revoke-check branch from b534d16 to 25ac15c Compare July 10, 2026 15:15

@yhabteab yhabteab left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've nothing else to complain about here apart from the below inline comment but since this was requested by Alvar and Johannes, I'll let them review and approve it.

/cc @oxzi

Comment thread utils/crl.go Outdated
Comment on lines +94 to +100
if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) || event.Has(fsnotify.Rename) || event.Has(fsnotify.Remove) {
// Re-add path on every event: on Linux (inotify), atomic file
// replacement (mv tmp.crl ca.crl) fires IN_DELETE_SELF (Remove) on
// the old inode, not Rename — the watcher must re-add the path to
// pick up the new inode. On macOS (kqueue) the same operation fires
// Rename instead. Handling all four events keeps both platforms working.
_ = watcher.Add(c.path)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since I didn't understand what this comment is all about at first, I've exprimented a bit in my own branch but still don't get what the fsnotify.Create event is needed for. If the configured file doesn't exist then you shouldn't even be able to add it to the watch list and would already crash on the first watcher.Add() call on line 78, so I don't know what this event is good for. Please see the changes I made in the other branch and verify if that should cover all use cases we need.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess the Create event might be relevant in the following scenario:

  1. Status quo: A CRL lies on the disk.
  2. The sysadmin scps a new CRL to the machine, stored somewhere in its home directory or in the temp.
  3. The sysadmin removes the old CRL - the Remove event triggers
  4. The sysadmin moves the CRL to the location - the Create should trigger again

However, this requires the changes from https://github.com/Icinga/icinga-go-library/pull/219/changes#r3569471676 to work.

@yhabteab yhabteab requested a review from oxzi July 13, 2026 07:57
Comment thread config/tls.go
Comment on lines +42 to +48
// CrlFile is the path to the Certificate Revocation List (CRL) file.
//
// If specified, the CRL is used to check for revoked certificates in TLS.
// The CRL must be signed by the CA specified in the Ca option. If the CRL file is not found or cannot be loaded,
// an error is returned during TLS configuration.
// This option is ignored if TLS is not enabled or if the Ca option is not set.
CrlFile string `yaml:"crl_file" env:"CRL_FILE"`

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this in TLSCommon and not in ServerTLS?

Furthermore, there should be a check in the Validate method to ensure that iff CrlFile is set, Ca must be set as well. Also, it must be ensured that either tls.VerifyClientCertIfGiven or tls.RequireAndVerifyClientCert are the tls.ClientAuthType, as otherwise this code would be a noop - but without any visible failure to the client.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's placed in TLSCommon rather than ServerTLS since CRL checking isn't inherently server-side, clients could use it as well. It hasn't been configured for client TLS yet, but keeping it common avoids duplicating the field/config plumbing if it's needed there.

Comment thread config/tls.go Outdated
}

if len(cs.VerifiedChains) == 0 {
return nil // no client cert presented — skip

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return nil // no client cert presented skip
return nil // no client cert presented - skip

Comment thread utils/crl.go Outdated
Comment on lines +134 to +136
c.mu.RLock()
defer c.mu.RUnlock()
return c.crl

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This read lock only ensures that there are no races between acquiring the crl and setting it in the reload method. However, the caller might proceed with an outdated CRL.

As this method is only used in IsRevoked, why not remove it, and create a read lock in IsRevoked while iterating over the entries?

Comment thread config/tls.go
return nil, err
}

block, _ := pem.Decode(caPem)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to Decode's API doc, block might be nil. Passing nil to x509.ParseCertificate might panic. Please add a nil check against invalid CA certificates. Btw, this check is present in CrlChecker.reload.

Comment thread utils/crl.go Outdated
Comment on lines +114 to +115
// IsRevoked reports whether serial appears in the CRL. If the CRL's NextUpdate time has
// passed, the file is reloaded before the lookup so the check always operates on fresh data.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the CRL's NextUpdate time has passed, the file is reloaded before the lookup so the check always operates on fresh data.

This does not happen. Your code returns an error.

Comment thread utils/crl.go Outdated
Comment on lines +76 to +84
if err != nil {
return fmt.Errorf("cannot create watcher: %w", err)
}
if err := watcher.Add(c.path); err != nil {
_ = watcher.Close()
return fmt.Errorf("cannot watch CRL file: %w", err)
}

defer func() { _ = watcher.Close() }()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if err != nil {
return fmt.Errorf("cannot create watcher: %w", err)
}
if err := watcher.Add(c.path); err != nil {
_ = watcher.Close()
return fmt.Errorf("cannot watch CRL file: %w", err)
}
defer func() { _ = watcher.Close() }()
if err != nil {
return fmt.Errorf("cannot create watcher: %w", err)
}
defer func() { _ = watcher.Close() }()
if err := watcher.Add(c.path); err != nil {
return fmt.Errorf("cannot watch CRL file: %w", err)
}

Comment thread utils/crl.go
// pick up the new inode. On macOS (kqueue) the same operation fires
// Rename instead. Handling all four events keeps both platforms working.
_ = watcher.Add(c.path)
if err := c.reload(); err != nil {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At least for a fsnotify.Remove event, the reload method is likely to fail - unless there is a race due to multiple similar events.

Comment thread utils/crl.go Outdated
Comment on lines +94 to +100
if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) || event.Has(fsnotify.Rename) || event.Has(fsnotify.Remove) {
// Re-add path on every event: on Linux (inotify), atomic file
// replacement (mv tmp.crl ca.crl) fires IN_DELETE_SELF (Remove) on
// the old inode, not Rename — the watcher must re-add the path to
// pick up the new inode. On macOS (kqueue) the same operation fires
// Rename instead. Handling all four events keeps both platforms working.
_ = watcher.Add(c.path)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess the Create event might be relevant in the following scenario:

  1. Status quo: A CRL lies on the disk.
  2. The sysadmin scps a new CRL to the machine, stored somewhere in its home directory or in the temp.
  3. The sysadmin removes the old CRL - the Remove event triggers
  4. The sysadmin moves the CRL to the location - the Create should trigger again

However, this requires the changes from https://github.com/Icinga/icinga-go-library/pull/219/changes#r3569471676 to work.

Comment thread utils/crl.go Outdated
// the old inode, not Rename — the watcher must re-add the path to
// pick up the new inode. On macOS (kqueue) the same operation fires
// Rename instead. Handling all four events keeps both platforms working.
_ = watcher.Add(c.path)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For a fsnotify.Remove event, this will error out. Afterwards there is nothing to be watched left!

Take the following demo as an example.

package main

import (
        "fmt"

        "github.com/fsnotify/fsnotify"
)

func errCheck(err error) {
        if err != nil {
                panic(err)
        }
}

func main() {
        const f = "test"

        watcher, err := fsnotify.NewWatcher()
        errCheck(err)
        defer func() { _ = watcher.Close() }()

        errCheck(watcher.Add(f))
        fmt.Printf("Starting wiht: %v\n", watcher.WatchList())

        for event := range watcher.Events {
                fmt.Printf("%+v\nAdd: %v\nAll :%v\n\n", event, watcher.Add(f), watcher.WatchList())
        }
}
$ go build
$ ./foo &
[1] 516727
Starting wiht: [test]
$ echo a > test
WRITE         "test"
Add: <nil>
All :[test]

mv test{,bkp}
RENAME        "test"
Add: no such file or directory
All :[]

$ touch test
$ # nope, nothing. we are watching nothing. the CRL is lost now

To fix this, watch the CRL's parent directory, but only act upon the CRL file. Take this updated demo as an example.

package main

import (
        "fmt"
        "path/filepath"

        "github.com/fsnotify/fsnotify"
)

func errCheck(err error) {
        if err != nil {
                panic(err)
        }
}

func main() {
        const f = "./test"

        watcher, err := fsnotify.NewWatcher()
        errCheck(err)
        defer func() { _ = watcher.Close() }()

        errCheck(watcher.Add(filepath.Dir(f)))
        fmt.Printf("Starting wiht: %v\n", watcher.WatchList())

        for event := range watcher.Events {
                if filepath.Base(event.Name) != filepath.Base(f) {
                        fmt.Printf("[SKIP] %+v\n", event)
                        continue
                }
                fmt.Printf("[ADD ] %+v\nAdd: %v\nAll :%v\n\n", event, watcher.Add(f), watcher.WatchList())
        }
}
$ go build
$ ./foo &
[2] 545169
Starting wiht: [.]

$ echo a > test
[ADD ] WRITE         "./test"
Add: <nil>
All :[. test]

$ mv test{,bkp}
[ADD ] RENAME        "./test"
Add: no such file or directory
All :[. test]

[SKIP] CREATE        "./testbkp" ← "./test"
[ADD ] RENAME        "test"
Add: no such file or directory
All :[.]

$ touch test
[ADD ] CREATE        "./test"
Add: <nil>
All :[. test]

[ADD ] CHMOD         "./test"
Add: <nil>
All :[. test]

Watch the parent directory instead of the CRL file itself so that atomic
file replacements (e.g. mv tmp.crl ca.crl) and symlink swaps are
detected reliably. Filter directory events by filename to avoid spurious
reloads.

Add upfront validation in makeConfig: reject a CRL file when no CA is
configured, and verify the file is readable before returning a TLS
config. In ServerTLS.Validate, reject a CRL file when the ClientAuth
mode does not verify client certificates.

Guard the PEM decode block against nil before passing it to
x509.ParseCertificate to prevent a panic on malformed CA files.

Treat a missing CRL file as a no-op in reload (so the watcher can start
before the file is first written) and inline the getCRL helper into
IsRevoked to simplify the locking logic.
@nilmerg nilmerg moved this from Todo to In progress in Icinga Notifications 1.0 Jul 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cla/signed CLA is signed by all contributors of a PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add client certificate revocation checking

4 participants