diff --git a/README.md b/README.md index d454d4a..a6ebfe6 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ A RADIUS server only logs the requests that *reach* it. A huge class of 802.1X f ```console $ export AUTHHOUND_SECRET='shared-secret' # kept out of shell history and `ps` -$ authhound-probe radius test --server radius.corp.com --peap alice +$ authhound-probe radius test --server radius.corp.com --peap alice --server-name radius.corp.com Enter password for alice: Testing RADIUS server radius.corp.com:1812 (as NAS "authhound-probe") @@ -21,12 +21,12 @@ PASS RADIUS server answered in 23ms PASS Shared secret is correct (reply signature verified) PASS Server signs its replies with Message-Authenticator (BlastRADIUS-hardened) PASS PEAP-MSCHAPv2 authentication succeeded for alice -PASS Server certificate valid for 214 more days, chain looks complete (TLS 1.2) +PASS Server certificate valid for 214 more days, name matches "radius.corp.com", chain looks complete (TLS 1.2) Verdict: 5 passed, 0 failed, 0 warnings ``` -Like `eapol_test` or `radtest`, but the output is readable — one command, no `wpa_supplicant` config file. Add `--json` for scripting, `--nas-port-type ethernet|wireless|virtual` to match how your real NAS presents itself, and `--server-name` to set the expected certificate name. +Like `eapol_test` or `radtest`, but the output is readable — one command, no `wpa_supplicant` config file ([honest comparison](docs/COMPARISON.md)). Add `--json` for scripting, `--nas-port-type ethernet|wireless|virtual` to match how your real NAS presents itself, and `--server-name` to validate the server certificate's name the way your clients will. Common questions: [FAQ](docs/FAQ.md). ## Step 0 — register the probe on your server (one time) @@ -66,7 +66,7 @@ Skipped this step? The probe notices: on a first-run timeout it prints this exac | **EAP-TTLS (PAP)** | A real login inside the TTLS tunnel using inner PAP. Because the password is checked in cleartext (safe inside the tunnel), TTLS-PAP works against *any* backend — including hashed stores that MSCHAPv2 can't use. If PEAP-MSCHAPv2 fails but this passes, the directory can't produce an NT hash. | | **EAP-TLS** | Certificate-based login (no password): presents a client certificate and reports whether the server accepts it — with a plain-English reason on failure (untrusted CA, expired cert, policy reject). See [EAP-TLS: preparing a client certificate](#eap-tls-preparing-a-client-certificate). | | **Authorization / VLAN** | On any successful login, decodes and prints the authorization the Access-Accept returned (VLAN, Filter-Id, Session-Timeout, vendor attributes) — and lets you **assert** on it with `--expect-vlan` / `--expect-attr` so "auth works, wrong VLAN" fails loudly. See [Verifying policy](#verifying-policy-not-just-connectivity). | -| **Server certificate** | Establishes the PEAP/TLS tunnel over RADIUS, captures the server's certificate, and flags **expiry**, an incomplete intermediate chain, and the negotiated TLS version. The "Wi-Fi died overnight" outage, caught early. | +| **Server certificate** | Establishes the PEAP/TLS tunnel over RADIUS, captures the server's certificate, and flags **expiry**, an incomplete intermediate chain, a **name mismatch** against `--server-name` (FAIL — clients validating that name would reject the handshake), and the negotiated TLS version. Without `--server-name`, name validation is **skipped and reported as a WARN** — the probe never claims "valid" for a name it didn't check. The "Wi-Fi died overnight" outage, caught early. | | **Path MTU / fragmentation** (`--mtu`) | Finds the largest RADIUS packet that survives the round trip. Pinpoints the invisible failure where a firewall or VPN drops large / IP-fragmented UDP, so the multi-kilobyte EAP-TLS certificate flight never arrives and 802.1X silently stalls — while every server-side log looks clean. | | **RadSec** (`radsec test`) | Checks a RADIUS/TLS endpoint on TCP/2083: reachability, TLS handshake, server certificate, and a RADIUS exchange over the tunnel. For modern deployments and UDP→RadSec migration readiness. | @@ -209,7 +209,7 @@ $ authhound-probe radsec test --server radius.corp.com \ | `--count N` | Run the checks `N` times (2–50) and report aggregate statistics — see [Chasing intermittent failures](#chasing-intermittent-failures). | | `--interval DURATION` | Pause between `--count` iterations (default `2s`; a hard-coded safety floor applies). | | `--nas-port-type wireless\|ethernet\|virtual` | How the probe presents itself, so server policies match (default `wireless`). | -| `--server-name NAME` | Expected server-certificate name (TLS SNI). | +| `--server-name NAME` | Name the server certificate must be valid for (also sent as TLS SNI). Mismatch = **FAIL**; omitted = name validation skipped, reported as a **WARN** — see [Certificate name validation](#certificate-name-validation---server-name). | | `--nas-id NAME` | NAS-Identifier to send (default `authhound-probe`). | | `--timeout DURATION` | Per-request timeout (default `5s`). | | `--bind IP[:port]` | Source IP to send from, for pinning the outgoing interface on a multi-homed host — see [Binding a source interface](#binding-a-source-interface---bind). | @@ -396,6 +396,39 @@ the probe prints on a timeout automatically reflects the bound IP, so you can paste it as-is. (If NAT sits between this host and the server, the server still sees the post-NAT address — register that instead.) +### Certificate name validation (`--server-name`) + +Your clients don't just check that the RADIUS server's certificate is unexpired +— a correctly configured 802.1X profile also validates the certificate's +**name**. A server presenting a healthy certificate with the *wrong* name still +breaks every client that validates it. So the probe treats the name as part of +the certificate verdict: + +- **`--server-name radius.corp.com` and the certificate matches** (SAN rules, + wildcards included — the same matching real clients do): PASS, and the + summary says the name matched. +- **`--server-name` given but the certificate doesn't match**: **FAIL**, with + the names the certificate is *actually* valid for, so you can tell "wrong + cert selected on the server" from "my expected name is stale". A certificate + with no SAN at all fails too — modern clients don't fall back to the CN. +- **`--server-name` omitted**: the probe cannot know what name your clients + expect, so name validation is **skipped — and reported as a WARN**, never + folded into a silent "valid". Expiry and chain are still checked; the WARN + tells you exactly what to add. In `--json`, `fields.name_validation` is + `"match"`, `"mismatch"`, or `"skipped"` on the `server-cert` / `radsec-cert` + results. + +Note the division of labour: in the authentication checks (PEAP/TTLS/EAP-TLS), +`--server-name` is only sent as TLS SNI — the probe deliberately completes +those handshakes even against a broken certificate, because its job is to +diagnose rather than refuse. The name *verdict* lives in the `server-cert` +(and `radsec-cert`) check. The full TLS posture, including why verification is +capture-and-report by design, is documented in +[SECURITY.md](SECURITY.md#tls-and-certificate-posture--an-honest-note). + +Under `--strict`, the skipped-name WARN exits `1` — deliberate: a scheduled +monitor should be told which name to pin, not silently skip the check forever. + ### Where credentials come from This tool is meant to run on shared jump boxes, so it never *requires* a secret or @@ -480,17 +513,10 @@ Expand-Archive -Path authhound-probe.zip -DestinationPath authhound-probe -Force any directory, move the `.exe` somewhere on your `PATH` — e.g. `C:\Windows\System32` for all users, or a folder you add to your user `Path`.) -Or via a package manager — see [`packaging/`](packaging/) for the manifests and -maintainer notes: - -```powershell -# Scoop (from the AuthHound bucket) -scoop bucket add authhound https://github.com/authhound/scoop-bucket -scoop install authhound-probe - -# winget -winget install authhound.probe -``` +Scoop and winget packages are **in progress** — the manifests are maintained in +[`packaging/`](packaging/), but the Scoop bucket and the winget submission +haven't been published yet. Until they land, use the PowerShell download above. +(This note gets replaced with the install one-liners once they're live.) **Go:** @@ -578,7 +604,7 @@ The probe is a single `.exe` with no runtime, so it drops straight into Task Sch # the command line (it would be visible in the task definition). $action = New-ScheduledTaskAction ` -Execute 'C:\Tools\authhound-probe.exe' ` - -Argument 'radius test --server nps.corp.local --peap svc-radius-probe --json --strict' ` + -Argument 'radius test --server nps.corp.local --server-name nps.corp.local --peap svc-radius-probe --json --strict' ` -WorkingDirectory 'C:\Tools' $trigger = New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Minutes 15) $principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest @@ -589,7 +615,7 @@ Or the classic one-liner with `schtasks`, redirecting output to a rolling log: ```powershell schtasks /Create /TN "AuthHound RADIUS probe" /SC MINUTE /MO 15 /RU SYSTEM /RL HIGHEST /TR ^ - "cmd /c C:\Tools\authhound-probe.exe radius test --server nps.corp.local --peap svc-radius-probe --json --strict >> C:\Tools\probe.log 2>&1" + "cmd /c C:\Tools\authhound-probe.exe radius test --server nps.corp.local --server-name nps.corp.local --peap svc-radius-probe --json --strict >> C:\Tools\probe.log 2>&1" ``` Supply credentials via the environment for the task's account (`AUTHHOUND_SECRET`, `AUTHHOUND_PASSWORD`) or `--secret-file`/`--password-file` pointing at a file only that account can read — see [Where credentials come from](#where-credentials-come-from). Because the probe exits `1` on failure (or on a warning under `--strict`), Task Scheduler's **Last Run Result** reflects health directly: `0x0` healthy, `0x1` something needs attention. Point your existing task-result monitoring at that, or tail `probe.log`. @@ -605,7 +631,10 @@ Output is colourised only when it's going to a real terminal that can render it, ## Safety -Built to be safe to run against production, and to pass an enterprise security review: +Built to be safe to run against production, and to pass an enterprise security +review. The one-page threat model — what the probe sends, what it stores +(nothing), what it never does, and how to report a vulnerability — is +[SECURITY.md](SECURITY.md); the short version: - **Read-only.** Sends Access-Requests and reads the replies — it never changes anything on the server, and never captures packets. - **Credentials stay local, and off the command line.** The shared secret and any password are used only to build the RADIUS packets. They are never written to output, `--json`, logs, or error messages, and nothing is ever sent anywhere (there is no telemetry). So they don't leak into shell history or `ps` on a shared box, they're read from a file (`--secret-file` / `--password-file`, refused if world-readable), an environment variable, standard input, or a no-echo prompt — see [Where credentials come from](#where-credentials-come-from). The plain `--secret`/`user:pass` forms still work but warn on a terminal. @@ -645,7 +674,10 @@ Interop reports are especially valuable: run the probe against your RADIUS — a ## See also -Already have a log to read? Paste FreeRADIUS debug output or a Windows NPS event into the free [RADIUS log analyzer](https://authhound.com/analyzer) for a plain-English diagnosis. +- **[FAQ](docs/FAQ.md)** — timeouts, NPS, credentials, scripting. +- **[authhound-probe vs eapol_test vs radtest](docs/COMPARISON.md)** — a factual comparison, including when the classics are the better tool. +- **[SECURITY.md](SECURITY.md)** — the one-page threat model and vulnerability reporting. +- Already have a log to read? Paste FreeRADIUS debug output or a Windows NPS event into the free [RADIUS log analyzer](https://authhound.com/analyzer) for a plain-English diagnosis. ## License diff --git a/SECURITY.md b/SECURITY.md index 5e50f99..6ad3eb3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,11 +1,123 @@ # Security -You run this tool inside your network with your RADIUS shared secret. That -only works if you can trust what's in the binary — this page documents how we -keep that trust checkable rather than asking you to take our word for it. +You run this tool inside your network with your RADIUS shared secret, often on +a shared jump box. That only works if you can answer "what does this thing do +to my network and my secrets" without reading the code. This page is that +answer — the threat model first, then how to report a problem, then how we +keep the supply chain checkable. - +## What the probe sends + +- **RADIUS traffic to the server you name, and nothing else.** Access-Request + and Status-Server packets out (UDP/1812 for `radius test`, TLS on TCP/2083 + for `radsec test`), replies in. Exactly what a switch or access point would + send — the probe *is* a NAS as far as the server can tell. +- **No telemetry, no phone-home, no update check.** The free tool never + contacts authhound.com or any other host. The only network peer of a run is + the RADIUS server on the command line. (`connect`, the paid tier, is + explicitly opt-in and prints what it would do; in this open-source tool it + does nothing else.) +- **Bounded rate.** A hard-coded rate ceiling in the runner caps how fast + packets leave, and `--count` is capped at 50 iterations with an enforced + interval floor. There is no flag, environment variable, or config file that + raises either — the probe cannot be turned into a load generator or used to + hammer someone else's server. + +## What it stores + +Nothing. The probe is a one-shot process: + +- no config files written, no cache, no history, no state directory; +- no daemon or watch mode — when the run ends, the process and everything it + knew are gone; +- secrets read from a file (`--secret-file` / `--password-file`) stay on the + file you own; the probe only ever reads them. + +## What it never does + +- **Never captures packets** or opens raw sockets — it sends its own requests + and reads its own replies, nothing else on the wire. +- **Never changes anything on the RADIUS server.** Authentication requests + are read-only from the server's point of view; the probe has no + provisioning, CoA, or accounting-write capability. +- **Never completes a second factor.** If the server issues an MFA challenge, + the probe reports that boundary and stops — completing a push/OTP from an + unattended tool would mean holding a live MFA secret, which it refuses to do. +- **Never proxies or forwards authentication** for anything else. +- **Never sends your secrets anywhere** except inside the RADIUS/EAP exchange + they are for (see below). + +## How credentials are handled + +- **Inputs stay off the command line by default.** The shared secret and any + password can come from a file (refused if group/world-readable on unix), an + environment variable, standard input, or a no-echo interactive prompt — the + inline `--secret`/`user:pass` forms work but print a warning on a terminal, + because they leak into shell history and `ps`. Precedence and details: + [README — Where credentials come from](README.md#where-credentials-come-from). +- **Secrets are used only to build protocol messages.** The shared secret + signs/validates RADIUS packets; passwords are hidden per RFC 2865 (PAP) or + used inside the TLS tunnel (PEAP/TTLS) or as MSCHAPv2 responses — never + transmitted in the clear outside the protocol that carries them. +- **Secrets never appear in output.** Not in text output, `--json`, hints, + error messages, or panics. This is enforced by tests (including a dedicated + leak test in `cmd/authhound-probe/leak_test.go`) and asserted again by the + Docker smoke test, which greps every output surface for the secret. +- **The EAP-TLS/RadSec private key** is read from disk only to complete the + TLS handshake; it is never transmitted (TLS never sends private keys) or + logged. +- Environment variables are convenient but readable by other processes running + as the same user — for hostile multi-user boxes, prefer a `chmod 600` file + or the prompt. + +## TLS and certificate posture — an honest note + +The probe's TLS handshakes (PEAP/TTLS/EAP-TLS tunnels and RadSec) deliberately +run with certificate verification disabled at the TLS layer, because the probe +is a *diagnostic*: its job is to capture the certificate the server presents +and report on it — expiry, chain completeness, name — even (especially) when +that certificate is broken. A normal TLS client would abort on the broken cert +and tell you nothing. + +What that means in practice: + +- The dedicated certificate checks (`server-cert`, `radsec-cert`) do the + verification *as reporting*: expiry and chain problems FAIL/WARN, and the + certificate name is validated against `--server-name` — a mismatch is a + FAIL. **If `--server-name` is omitted, name validation is skipped and the + probe says so with a WARN** (`name_validation: "skipped"` in `--json`); it + never reports an unqualified "valid" for a name it didn't check. +- The credential-carrying checks (PEAP/TTLS) therefore complete their exchange + even against a server presenting an untrusted certificate. The inner + credential is protected by the method itself (MSCHAPv2 challenge-response + never sends the password; TTLS-PAP sends it only inside the tunnel), but a + sufficiently positioned attacker who can intercept RADIUS *and* knows your + shared secret could present their own tunnel endpoint. Use a dedicated + least-privilege test account — the README says this everywhere credentials + come up — and treat `--server-name` plus the server-cert check as your + impersonation tripwire. + +## Verify what you downloaded + +Every release artifact is checksummed and keyless-signed with Sigstore cosign, +bound to this repo's tagged release workflow via OIDC and logged in the public +Rekor transparency log — you can prove a binary came from our CI and was not +tampered with, without trusting a long-lived key. Step-by-step commands +(including Windows): [README — Verify your download](README.md#verify-your-download). +Every archive also ships an SPDX SBOM, so when a CVE drops you can check your +exposure in seconds. + +## Reporting a vulnerability + +- Use GitHub private vulnerability reporting — [Security → + "Report a vulnerability"](https://github.com/authhound/probe/security/advisories/new) + on this repo. It reaches the maintainer privately and tracks the fix. +- Please don't open a public issue for anything you believe is exploitable. + +You'll get an acknowledgement within 72 hours. Confirmed vulnerabilities get a +GitHub Security Advisory and a patch release; the SBOMs let you determine your +exposure without waiting on us. We won't take legal action against good-faith +research done against your own infrastructure. ## Dependencies & supply chain diff --git a/cmd/authhound-probe/main.go b/cmd/authhound-probe/main.go index ba6779f..0dd3443 100644 --- a/cmd/authhound-probe/main.go +++ b/cmd/authhound-probe/main.go @@ -130,7 +130,7 @@ func cmdRadsecTest(args []string) int { server := fs.String("server", "", "RadSec server host or host:port (default port 2083)") clientCert := fs.String("client-cert", "", "client certificate (PEM) for mutual TLS; optional") clientKey := fs.String("client-key", "", "client private key (PEM); required with --client-cert") - serverName := fs.String("server-name", "", "expected server certificate name (TLS SNI); optional") + serverName := fs.String("server-name", "", "name the server certificate must be valid for (also sent as TLS SNI); omitted = name validation skipped and reported as a warning") timeout := fs.Duration("timeout", 5*time.Second, "connection/handshake timeout") jsonOut := fs.Bool("json", false, "emit results as JSON instead of text") noColor := fs.Bool("no-color", false, "disable ANSI colour") @@ -187,7 +187,7 @@ func cmdRadiusTest(args []string) int { clientKey := fs.String("client-key", "", "client private key (PEM) for the EAP-TLS test") nasID := fs.String("nas-id", "authhound-probe", "NAS-Identifier to send") nasPortType := fs.String("nas-port-type", "wireless", "NAS-Port-Type: wireless, ethernet, or virtual") - serverName := fs.String("server-name", "", "expected server certificate name (TLS SNI); optional") + serverName := fs.String("server-name", "", "name the server certificate must be valid for (also sent as TLS SNI); omitted = name validation skipped and reported as a warning") expectVLAN := fs.String("expect-vlan", "", "assert the Access-Accept assigns this VLAN (Tunnel-Private-Group-ID); a mismatch is a FAIL") var expectAttr stringSliceFlag fs.Var(&expectAttr, "expect-attr", "assert a returned authorization attribute as 'Name=Value' (repeatable); a mismatch is a FAIL") diff --git a/docs/COMPARISON.md b/docs/COMPARISON.md new file mode 100644 index 0000000..b385cad --- /dev/null +++ b/docs/COMPARISON.md @@ -0,0 +1,65 @@ +# authhound-probe vs eapol_test vs radtest + +`eapol_test` and `radtest` are the two tools RADIUS admins have reached for +over two decades, and both are excellent at what they were built for. +`eapol_test` (part of Jouni Malinen's wpa_supplicant/hostap project) is the +reference EAP test client — nothing else covers as many EAP methods. +`radtest` (from the FreeRADIUS utilities) is the fastest possible "does an +Access-Request come back" sanity check, installed anywhere FreeRADIUS is. + +`authhound-probe` sits in a different spot: it's built for the *diagnosis* +workflow — one binary, no config file, plain-English verdicts with next steps, +and machine-readable output for RMM/monitoring scripts. This page is a factual +comparison so you can pick the right tool for the job at hand. + +## At a glance + +| | **authhound-probe** | **eapol_test** | **radtest** | +|---|---|---|---| +| Ships as | Single static binary (Linux/macOS/Windows), signed releases | Part of wpa_supplicant; usually compiled from source with `CONFIG_EAPOL_TEST=y` (some distros/ports package it) | `freeradius-utils` package (a wrapper around `radclient`) | +| Configuration | Command-line flags only | wpa_supplicant-style config file per scenario | Command-line arguments | +| PAP | ✅ | — (EAP only) | ✅ (also CHAP, MSCHAP via radclient) | +| PEAP-MSCHAPv2 | ✅ | ✅ | — | +| EAP-TTLS | ✅ (inner PAP) | ✅ (many inner methods) | — | +| EAP-TLS | ✅ | ✅ | — | +| Other EAP methods (FAST, PWD, SIM/AKA, …) | — | ✅ widest coverage anywhere | — | +| Server certificate analysis (expiry, chain, name) | ✅ with PASS/WARN/FAIL verdicts | Cert is in the debug output; you interpret it | — | +| Path-MTU / EAP fragmentation probing | ✅ (`--mtu`) | fragment size is configurable, not probed | — | +| RadSec (RADIUS/TLS) testing | ✅ (`radsec test`) | — | — | +| Status-Server (RFC 5997) liveness | ✅ | — | ✅ (`radclient status`) | +| BlastRADIUS (CVE-2024-3596) posture check | ✅ | — | — | +| Multi-server comparison / flakiness stats | ✅ (`--server a,b`, `--count N`) | scriptable around it | scriptable around it | +| Output | Plain-English PASS/WARN/FAIL + what to check next | Full protocol debug trace (excellent for deep debugging) | Terse packet dump | +| Machine-readable output | ✅ `--json` (versioned schema) + stable exit codes | exit code | exit code | +| Windows | ✅ first-class (NPS-aware, `.exe`, Scheduled Task recipes) | not practical | via WSL/ports | +| Best at | Fast diagnosis, monitoring scripts, handing a check to a colleague | Exhaustive EAP method coverage, protocol-level debugging | Instant PAP/secret sanity check where FreeRADIUS is installed | + +## When to use which + +**Use `radtest`** when you're on a box that already has FreeRADIUS installed +and you want a two-second answer to "does the server accept this PAP login / +is the secret right". It's the ubiquitous quick check — no download needed. + +**Use `eapol_test`** when you need an EAP method the probe doesn't speak +(EAP-FAST, EAP-PWD, EAP-SIM/AKA, exotic inner methods), or when you want the +full protocol trace to debug a genuinely weird interop problem. It is the +reference implementation; its debug output is the ground truth. The cost is +getting a build of it and writing a config file per scenario, and reading raw +protocol output. + +**Use `authhound-probe`** when the question is "why is 802.1X broken and +which hop do I fix" or "script this check into my RMM": one command with +flags, a verdict per layer (reachability → secret → auth → certificate → +MTU), guidance on what to check next, `--json` with a versioned schema, and +the same binary on the Windows box next to your NPS server. It covers the +methods that dominate real networks (PAP, PEAP-MSCHAPv2, EAP-TTLS/PAP, +EAP-TLS) plus the checks the classics don't attempt: certificate expiry/name +verdicts, path-MTU probing, RadSec, BlastRADIUS posture, and multi-server +drift comparison. + +They compose, too: plenty of workflows start with `authhound-probe` to +localise the failure in seconds, then drop into `eapol_test` for a +protocol-level trace of the one broken method. + +*Something in this table outdated or unfair? Open an issue — factual +corrections are very welcome.* diff --git a/docs/FAQ.md b/docs/FAQ.md new file mode 100644 index 0000000..d6ec69e --- /dev/null +++ b/docs/FAQ.md @@ -0,0 +1,91 @@ +# FAQ + +Answers to questions that come up in the field. Sections marked as +placeholders get filled as forum/issue feedback arrives — if your question +isn't here, [open an issue](https://github.com/authhound/probe/issues). + + + +## Getting started / timeouts + +### Every check times out — is the server down? + +Probably not. A RADIUS server **silently drops** requests from IPs it doesn't +know, so an unregistered probe looks identical to a dead server. Register the +probe's IP and secret as a RADIUS client first — see +[Step 0 in the README](../README.md#step-0--register-the-probe-on-your-server-one-time). +On a first-run timeout the probe prints the exact registration snippet with +your detected source IP filled in. + +### The probe passes but my users still can't connect. How? + +The probe ran from where *it* sits. If that's not the same VLAN/firewall path +your clients use, it didn't test their path — placement matters (see +[Where to run it](../README.md#where-to-run-it)). Also compare `--nas-port-type` +with what your real NAS sends: policies frequently branch on it. + + + +## Windows / NPS + +### Does it work against Windows NPS? + +Yes — NPS is a first-class target: PEAP-MSCHAPv2 and machine (`host/…`) +authentication, `--nas-port-type ethernet` to hit wired policies, and a +PowerShell one-liner for client registration. See +[Machine auth (NPS)](../README.md#machine-auth-nps) and +[Running as a Windows Scheduled Task](../README.md#running-as-a-windows-scheduled-task). + + + +## Credentials & security + +### Is it safe to run against production? + +It's designed to be: read-only, rate-capped by a ceiling no flag can raise, +no packet capture, no state, no telemetry. The one-page threat model is +[SECURITY.md](../SECURITY.md). + +### Why does the probe warn that name validation was skipped? + +Because you didn't pass `--server-name`, so the probe couldn't check the +certificate is one your clients would accept — and it refuses to print +"valid" for a name it never checked. Add `--server-name radius.corp.com` +(whatever name your client profiles trust) to turn the warning into a real +verdict either way. + + + +## EAP methods & certificates + +### Which EAP methods are supported? + +PEAP-MSCHAPv2, EAP-TTLS (inner PAP), and EAP-TLS — plus non-EAP PAP. For +methods beyond that (EAP-FAST, EAP-PWD, SIM/AKA…), `eapol_test` is the right +tool; see the [comparison](COMPARISON.md). + + + +## Scripting / RMM / monitoring + +### How do I alarm on the output? + +Exit codes are a stable contract: `0` pass, `1` any FAIL (or any WARN under +`--strict`), `2` usage error. Pair with `--json` — the schema is versioned and +documented in [json-schema.md](json-schema.md). + +### Can it run on a schedule / as a daemon? + +There is deliberately no daemon or watch mode (see SECURITY.md). Use your +scheduler — cron, systemd timers, Task Scheduler — to invoke one-shot runs; +recipes are in the README. Continuous scheduled monitoring with history and +alerting is what the paid [AuthHound](https://authhound.com) service does. + + diff --git a/docs/json-schema.md b/docs/json-schema.md index c14272d..b3fa113 100644 --- a/docs/json-schema.md +++ b/docs/json-schema.md @@ -69,7 +69,7 @@ Each entry in `results`: | `summary` | string | always | One plain-English line describing the outcome. | | `detail` | string | when present | Extra context. Omitted when empty. | | `hint` | string | when present | Multi-line, paste-ready remediation. Newline formatting is significant. Omitted when empty. Never contains secrets. | -| `fields` | object (string→string) | when present | Structured extras such as `rtt_ms`, `tls_version`, `not_after`, `subject`, `san`, `chain_len`, `source_ip`. On the `status-server` check, `supported` is `"true"` (the server answered the RFC 5997 liveness query) or `"false"` (it didn't — which is fine; that check never fails). `blastradius_posture` (on the `blastradius-posture` check) is `"signed"` or `"unsigned"` — whether the server signed its reply with a Message-Authenticator (see [BlastRADIUS posture](../README.md#blastradius--message-authenticator-posture)). `timeout: "true"` marks a request that got no reply at all (a *lost* request, as opposed to a processed rejection). Aggregate verdicts under `--count` add `success_rate`, `attempts`, `successes`, `timeouts`, and `latency_{min,median,p95,max}_ms`. Keys vary by check; values are always strings. Omitted when there are none. | +| `fields` | object (string→string) | when present | Structured extras such as `rtt_ms`, `tls_version`, `not_after`, `subject`, `san`, `chain_len`, `source_ip`. On the `status-server` check, `supported` is `"true"` (the server answered the RFC 5997 liveness query) or `"false"` (it didn't — which is fine; that check never fails). `blastradius_posture` (on the `blastradius-posture` check) is `"signed"` or `"unsigned"` — whether the server signed its reply with a Message-Authenticator (see [BlastRADIUS posture](../README.md#blastradius--message-authenticator-posture)). `timeout: "true"` marks a request that got no reply at all (a *lost* request, as opposed to a processed rejection). On `server-cert` and `radsec-cert`, `name_validation` is `"match"`, `"mismatch"` (a `fail`), or `"skipped"` (no `--server-name` given — a `warn` when everything else is healthy, so a run that never validated the name is never a silent pass). Aggregate verdicts under `--count` add `success_rate`, `attempts`, `successes`, `timeouts`, and `latency_{min,median,p95,max}_ms`. Keys vary by check; values are always strings. Omitted when there are none. | | `duration_ns` | integer | when present | How long the check took, in nanoseconds. Omitted when zero. | | `authorization` | object | when present | On an auth check that reached an Access-Accept, the authorization attributes the server returned (VLAN/Filter-Id/…) and the outcome of any `--expect-vlan`/`--expect-attr` assertions. See below. Omitted otherwise. | diff --git a/internal/check/certanalysis.go b/internal/check/certanalysis.go index 4df0888..f34a39a 100644 --- a/internal/check/certanalysis.go +++ b/internal/check/certanalysis.go @@ -12,9 +12,14 @@ const certExpiryWarnDays = 21 // analyzeCert turns a captured server-certificate chain into a Result: it flags // expiry (the classic "Wi-Fi died overnight" outage), an incomplete intermediate -// chain, and reports the negotiated TLS version. Shared by the EAP server-cert -// check and RadSec. -func analyzeCert(checkName string, chain []*x509.Certificate, tlsVersion uint16) Result { +// chain, a name mismatch against serverName, and reports the negotiated TLS +// version. Shared by the EAP server-cert check and RadSec. +// +// serverName is the name the caller expects the certificate to be valid for +// (from --server-name). Empty means the operator asserted nothing, so name +// validation is skipped — and that is reported as a WARN, never as a silent +// PASS: "valid" without a name check is not what most readers would assume. +func analyzeCert(checkName string, chain []*x509.Certificate, tlsVersion uint16, serverName string) Result { if len(chain) == 0 { return Result{Check: checkName, Status: StatusSkip, Summary: "Server presented no certificate"} } @@ -29,6 +34,17 @@ func analyzeCert(checkName string, chain []*x509.Certificate, tlsVersion uint16) fields["san"] = strings.Join(sans, ", ") } + nameErr := error(nil) + switch { + case serverName == "": + fields["name_validation"] = "skipped" + case leaf.VerifyHostname(serverName) == nil: + fields["name_validation"] = "match" + default: + nameErr = leaf.VerifyHostname(serverName) + fields["name_validation"] = "mismatch" + } + now := time.Now() daysLeft := int(leaf.NotAfter.Sub(now).Hours() / 24) @@ -47,6 +63,15 @@ func analyzeCert(checkName string, chain []*x509.Certificate, tlsVersion uint16) Summary: fmt.Sprintf("Server certificate is not yet valid (starts %s)", leaf.NotBefore.UTC().Format("2006-01-02")), Detail: "The certificate's validity hasn't started — check the clock on the server and clients.", } + case nameErr != nil: + return Result{ + Check: checkName, Status: StatusFail, Fields: fields, + Summary: fmt.Sprintf("Server certificate does not match the expected name %q", serverName), + Detail: "Names the certificate is actually valid for: " + certNames(leaf) + ". " + + "Clients configured to validate this server name will reject the handshake. " + + "Either the wrong certificate is selected on the server, or the expected name " + + "(--server-name) is out of date.", + } case chainLooksIncomplete(chain): return Result{ Check: checkName, Status: StatusWarn, Fields: fields, @@ -62,15 +87,38 @@ func analyzeCert(checkName string, chain []*x509.Certificate, tlsVersion uint16) Detail: "Renew before it lapses. Continuous certificate-expiry alerting across every " + "server is part of AuthHound's monitoring tier.", } + case serverName == "": + return Result{ + Check: checkName, Status: StatusWarn, Fields: fields, + Summary: fmt.Sprintf("Certificate captured — expiry and chain OK, but name validation was SKIPPED (%s)", + tlsVersionName(tlsVersion)), + Detail: "No --server-name was given, so the probe could not check that this is the " + + "certificate your clients expect. Names it is valid for: " + certNames(leaf) + ". " + + "Re-run with --server-name to validate it — real clients do, and a name " + + "mismatch fails them even when expiry and chain are fine.", + } default: return Result{ Check: checkName, Status: StatusPass, Fields: fields, - Summary: fmt.Sprintf("Server certificate valid for %d more days, chain looks complete (%s)", - daysLeft, tlsVersionName(tlsVersion)), + Summary: fmt.Sprintf("Server certificate valid for %d more days, name matches %q, chain looks complete (%s)", + daysLeft, serverName, tlsVersionName(tlsVersion)), } } } +// certNames renders the names a certificate is actually valid for, for humans: +// SANs when present, else the legacy Common Name (marked as such, since modern +// clients ignore it). +func certNames(leaf *x509.Certificate) string { + if len(leaf.DNSNames) > 0 { + return strings.Join(leaf.DNSNames, ", ") + } + if leaf.Subject.CommonName != "" { + return leaf.Subject.CommonName + " (CN only — no SAN, which many clients reject outright)" + } + return "(none — the certificate carries no DNS name at all)" +} + func chainLooksIncomplete(chain []*x509.Certificate) bool { if len(chain) == 0 { return false diff --git a/internal/check/certanalysis_test.go b/internal/check/certanalysis_test.go new file mode 100644 index 0000000..fc123da --- /dev/null +++ b/internal/check/certanalysis_test.go @@ -0,0 +1,145 @@ +package check + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "math/big" + "strings" + "testing" + "time" +) + +// testCert builds a self-signed certificate with the given SANs and validity, +// enough for analyzeCert (which never verifies the chain to a root). +func testCert(t *testing.T, cn string, dnsNames []string, notBefore, notAfter time.Time) *x509.Certificate { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: cn}, + DNSNames: dnsNames, + NotBefore: notBefore, + NotAfter: notAfter, + // Self-signed: mark as CA so chainLooksIncomplete recognises the + // self-signature and doesn't flag a missing intermediate. + IsCA: true, + BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + t.Fatal(err) + } + return cert +} + +func healthyCert(t *testing.T, dnsNames ...string) *x509.Certificate { + t.Helper() + return testCert(t, "radius.corp.com", dnsNames, + time.Now().Add(-24*time.Hour), time.Now().Add(365*24*time.Hour)) +} + +func TestAnalyzeCertNameMatch(t *testing.T) { + chain := []*x509.Certificate{healthyCert(t, "radius.corp.com")} + r := analyzeCert("server-cert", chain, tls.VersionTLS12, "radius.corp.com") + if r.Status != StatusPass { + t.Fatalf("name match: got %s (%s), want pass", r.Status, r.Summary) + } + if r.Fields["name_validation"] != "match" { + t.Errorf("name_validation: got %q, want match", r.Fields["name_validation"]) + } + if !strings.Contains(r.Summary, `name matches "radius.corp.com"`) { + t.Errorf("summary should state the name matched: %q", r.Summary) + } +} + +func TestAnalyzeCertWildcardMatch(t *testing.T) { + chain := []*x509.Certificate{healthyCert(t, "*.corp.com")} + r := analyzeCert("server-cert", chain, tls.VersionTLS12, "radius.corp.com") + if r.Status != StatusPass { + t.Fatalf("wildcard match: got %s (%s), want pass", r.Status, r.Summary) + } +} + +func TestAnalyzeCertNameMismatch(t *testing.T) { + chain := []*x509.Certificate{healthyCert(t, "other.corp.com")} + r := analyzeCert("server-cert", chain, tls.VersionTLS12, "radius.corp.com") + if r.Status != StatusFail { + t.Fatalf("name mismatch: got %s (%s), want fail", r.Status, r.Summary) + } + if r.Fields["name_validation"] != "mismatch" { + t.Errorf("name_validation: got %q, want mismatch", r.Fields["name_validation"]) + } + if !strings.Contains(r.Detail, "other.corp.com") { + t.Errorf("detail should list the names the cert is valid for: %q", r.Detail) + } +} + +// A certificate with no SAN must mismatch: modern clients (and Go) do not fall +// back to the Common Name, and the detail should say so. +func TestAnalyzeCertCNOnlyMismatch(t *testing.T) { + chain := []*x509.Certificate{healthyCert(t /* no SANs */)} + r := analyzeCert("server-cert", chain, tls.VersionTLS12, "radius.corp.com") + if r.Status != StatusFail { + t.Fatalf("CN-only: got %s (%s), want fail", r.Status, r.Summary) + } + if !strings.Contains(r.Detail, "CN only") { + t.Errorf("detail should flag the missing SAN: %q", r.Detail) + } +} + +// No --server-name: everything else healthy must be a WARN that says name +// validation was skipped — never a silent "valid". +func TestAnalyzeCertNameValidationSkipped(t *testing.T) { + chain := []*x509.Certificate{healthyCert(t, "radius.corp.com")} + r := analyzeCert("server-cert", chain, tls.VersionTLS12, "") + if r.Status != StatusWarn { + t.Fatalf("skipped: got %s (%s), want warn", r.Status, r.Summary) + } + if r.Fields["name_validation"] != "skipped" { + t.Errorf("name_validation: got %q, want skipped", r.Fields["name_validation"]) + } + if !strings.Contains(r.Summary, "SKIPPED") { + t.Errorf("summary must say name validation was skipped: %q", r.Summary) + } + if !strings.Contains(r.Detail, "--server-name") { + t.Errorf("detail should tell the user which flag to add: %q", r.Detail) + } +} + +// Expiry outranks the name verdict: an expired cert is a FAIL about expiry even +// when a matching (or absent) name would otherwise decide, and the +// name_validation field still reports what the name check found. +func TestAnalyzeCertExpiredOutranksName(t *testing.T) { + chain := []*x509.Certificate{testCert(t, "radius.corp.com", []string{"radius.corp.com"}, + time.Now().Add(-48*time.Hour), time.Now().Add(-24*time.Hour))} + r := analyzeCert("server-cert", chain, tls.VersionTLS12, "radius.corp.com") + if r.Status != StatusFail || !strings.Contains(r.Summary, "EXPIRED") { + t.Fatalf("expired: got %s (%s), want expiry fail", r.Status, r.Summary) + } + if r.Fields["name_validation"] != "match" { + t.Errorf("name_validation: got %q, want match", r.Fields["name_validation"]) + } +} + +// Name mismatch outranks the expiring-soon warning: both are wrong, but the +// mismatch is the hard failure. +func TestAnalyzeCertMismatchOutranksExpiringSoon(t *testing.T) { + chain := []*x509.Certificate{testCert(t, "other.corp.com", []string{"other.corp.com"}, + time.Now().Add(-24*time.Hour), time.Now().Add(5*24*time.Hour))} + r := analyzeCert("server-cert", chain, tls.VersionTLS12, "radius.corp.com") + if r.Status != StatusFail || !strings.Contains(r.Summary, "does not match") { + t.Fatalf("mismatch+expiring: got %s (%s), want name-mismatch fail", r.Status, r.Summary) + } +} diff --git a/internal/check/eaptls.go b/internal/check/eaptls.go index e11a4f3..df7ef7b 100644 --- a/internal/check/eaptls.go +++ b/internal/check/eaptls.go @@ -8,10 +8,11 @@ import ( ) // ServerCert establishes the PEAP outer TLS tunnel and inspects the RADIUS -// server's certificate — expiry, chain completeness, and TLS version. Expired -// or soon-to-expire server certificates are the classic "Wi-Fi died overnight -// and nothing was changed" outage; a missing intermediate breaks clients that -// don't already cache it. +// server's certificate — expiry, chain completeness, name (when ServerName is +// given), and TLS version. Expired or soon-to-expire server certificates are +// the classic "Wi-Fi died overnight and nothing was changed" outage; a missing +// intermediate breaks clients that don't already cache it. With no ServerName +// the name check is skipped and the result is a WARN, never a silent "valid". // // This is read-only: the probe receives the certificate during the handshake // and stops before sending any credential or client certificate. @@ -45,5 +46,5 @@ func (c ServerCert) Run(ctx context.Context, t Target) Result { } return r } - return analyzeCert("server-cert", captured.Chain, captured.TLSVersion) + return analyzeCert("server-cert", captured.Chain, captured.TLSVersion, c.ServerName) } diff --git a/internal/check/radsec.go b/internal/check/radsec.go index 479ccb0..89722fd 100644 --- a/internal/check/radsec.go +++ b/internal/check/radsec.go @@ -54,7 +54,7 @@ func RadSecReport(ctx context.Context, addr, certFile, keyFile, serverName strin } if len(res.Cert) > 0 { - out = append(out, analyzeCert("radsec-cert", res.Cert, res.TLSVersion)) + out = append(out, analyzeCert("radsec-cert", res.Cert, res.TLSVersion, serverName)) } if res.TLSOK { diff --git a/test/freeradius-smoke.sh b/test/freeradius-smoke.sh index ed3e2fb..6e46a69 100755 --- a/test/freeradius-smoke.sh +++ b/test/freeradius-smoke.sh @@ -90,8 +90,11 @@ EOF # lone self-signed leaf is NOT treated as an incomplete chain, so the only # non-PASS result is the expiry WARN — a clean fixture for the --strict flip. short_dir="$work/short"; mkdir -p "$short_dir" +# The SAN makes this cert also the fixture for --server-name validation: a +# matching name, a mismatching one, and the flag omitted are all deterministic. openssl req -x509 -newkey rsa:2048 -keyout "$short_dir/key.pem" -out "$short_dir/cert.pem" \ - -days 10 -nodes -subj "/CN=radsec-short.corp.local" >/dev/null 2>&1 + -days 10 -nodes -subj "/CN=radsec-short.corp.local" \ + -addext "subjectAltName = DNS:radsec-short.corp.local" >/dev/null 2>&1 cat "$short_dir/cert.pem" "$short_dir/key.pem" > "$work/server-short.pem" echo "== starting FreeRADIUS (debug, threaded for RadSec) ==" @@ -289,6 +292,44 @@ echo "$warn_out" | grep -qi "certificate expires in" || { echo "FAIL: expected a [ "$strict_rc" -eq 1 ] || { echo "FAIL: --strict should exit 1 on a WARN, got $strict_rc"; exit 1; } echo "OK: exit 0 without --strict, exit 1 with --strict on the same cert-expiry WARN" +echo +echo "== --server-name: match, mismatch (FAIL, exit 1), and omitted (skipped) ==" +# Against the 2084 listener whose cert carries SAN radsec-short.corp.local. +# Matching name: the run still WARNs (10-day expiry outranks the name verdict) +# but --json must record name_validation=match. Wrong name: a hard FAIL, exit 1, +# listing the names the cert is actually valid for. Omitted: name_validation= +# skipped — the probe must never imply the name was checked when it wasn't. +set +e +nv_match="$("$work/authhound-probe" radsec test --server 127.0.0.1:2084 --server-name radsec-short.corp.local \ + --client-cert "$work/cert.pem" --client-key "$work/key.pem" --json)"; nv_match_rc=$? +nv_bad="$("$work/authhound-probe" radsec test --server 127.0.0.1:2084 --server-name wrong.corp.local \ + --client-cert "$work/cert.pem" --client-key "$work/key.pem" --no-color)"; nv_bad_rc=$? +nv_skip="$("$work/authhound-probe" radsec test --server 127.0.0.1:2084 \ + --client-cert "$work/cert.pem" --client-key "$work/key.pem" --json)"; nv_skip_rc=$? +set -e +echo "$nv_match" | grep -q '"name_validation": "match"' || { echo "FAIL: matching --server-name should record name_validation=match"; exit 1; } +[ "$nv_match_rc" -eq 0 ] || { echo "FAIL: matching name (WARN-only run) should exit 0, got $nv_match_rc"; exit 1; } +echo "$nv_bad" | grep -qi "does not match the expected name" || { echo "FAIL: wrong --server-name should FAIL with a mismatch summary"; exit 1; } +echo "$nv_bad" | grep -q "radsec-short.corp.local" || { echo "FAIL: mismatch detail should list the names the cert is valid for"; exit 1; } +[ "$nv_bad_rc" -eq 1 ] || { echo "FAIL: a name mismatch should exit 1, got $nv_bad_rc"; exit 1; } +echo "$nv_skip" | grep -q '"name_validation": "skipped"' || { echo "FAIL: omitted --server-name should record name_validation=skipped"; exit 1; } +[ "$nv_skip_rc" -eq 0 ] || { echo "FAIL: skipped-name run (WARNs only) should exit 0, got $nv_skip_rc"; exit 1; } +echo "OK: name match recorded; mismatch FAILs (exit 1) and names the SANs; omitted flag is reported as skipped, never as valid" + +echo +echo "== no --server-name on radius test: server-cert WARNs that the name check was skipped ==" +# The EAP server-cert check must say so out loud in text, and never print the +# old unqualified "valid" line for a run that skipped name validation. +set +e +nv_radius="$("$work/authhound-probe" radius test --server 127.0.0.1 --secret "$SECRET" --no-color)" +nv_radius_json="$("$work/authhound-probe" radius test --server 127.0.0.1 --secret "$SECRET" --json)" +set -e +echo "$nv_radius_json" | grep -q '"name_validation": "skipped"' || { echo "FAIL: radius-test server-cert should record name_validation=skipped"; exit 1; } +if echo "$nv_radius" | grep -q "Server certificate valid .* chain looks complete"; then + echo "$nv_radius" | grep -q "name matches" || { echo "FAIL: an unqualified 'valid' verdict without name validation"; exit 1; } +fi +echo "OK: server-cert without --server-name never claims an unqualified valid" + echo echo "== --json exposes schema_version + always-present per-status counts ==" sjson="$("$work/authhound-probe" radsec test --server 127.0.0.1:2084 \