add CRL-based client certificate revocation check#219
Conversation
e517715 to
4f6220d
Compare
8206a66 to
a26e156
Compare
23ec8dd to
b534d16
Compare
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.
b534d16 to
25ac15c
Compare
| 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) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
I guess the Create event might be relevant in the following scenario:
- Status quo: A CRL lies on the disk.
- The sysadmin
scps a new CRL to the machine, stored somewhere in its home directory or in the temp. - The sysadmin removes the old CRL - the
Removeevent triggers - The sysadmin moves the CRL to the location - the
Createshould trigger again
However, this requires the changes from https://github.com/Icinga/icinga-go-library/pull/219/changes#r3569471676 to work.
| // 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"` |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| } | ||
|
|
||
| if len(cs.VerifiedChains) == 0 { | ||
| return nil // no client cert presented — skip |
There was a problem hiding this comment.
| return nil // no client cert presented — skip | |
| return nil // no client cert presented - skip |
| c.mu.RLock() | ||
| defer c.mu.RUnlock() | ||
| return c.crl |
There was a problem hiding this comment.
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?
| return nil, err | ||
| } | ||
|
|
||
| block, _ := pem.Decode(caPem) |
There was a problem hiding this comment.
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.
| // 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. |
There was a problem hiding this comment.
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.
| 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() }() |
There was a problem hiding this comment.
| 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) | |
| } |
| // 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 { |
There was a problem hiding this comment.
At least for a fsnotify.Remove event, the reload method is likely to fail - unless there is a race due to multiple similar events.
| 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) |
There was a problem hiding this comment.
I guess the Create event might be relevant in the following scenario:
- Status quo: A CRL lies on the disk.
- The sysadmin
scps a new CRL to the machine, stored somewhere in its home directory or in the temp. - The sysadmin removes the old CRL - the
Removeevent triggers - The sysadmin moves the CRL to the location - the
Createshould trigger again
However, this requires the changes from https://github.com/Icinga/icinga-go-library/pull/219/changes#r3569471676 to work.
| // 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) |
There was a problem hiding this comment.
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.
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