Releases: PNWSoft/mfa-firewall-knocker
Release list
v0.4.0
MFA Firewall Knocker v0.4.0
This release adds IPv6 support on Linux, optional IPv6 grant widening on both platforms, and a way
to offer an IPv6-only sign-in address, alongside fixes from two rounds of AI (Claude Fable 5.1)
security audit and human review. All three components should be upgraded together; see
Upgrade notes before installing, in particular the RulePrefix check.
New features
- IPv6 grants on Linux. A client that signs in over public IPv6 now gets its rule in
ip6tables; IPv4 grants stay iniptables. The sweeper and MFAAdmin'sdiag/resetread and
clear both tables. Previously an IPv6 client on Linux was accepted but its grant failed. Windows
has always handled IPv6 grants throughNetSecurity, as the exact address. - Optional IPv6 grant widening (
BouncerConfig:Ipv6GrantPrefixLength), both platforms.
IPv6 privacy addresses and cellular carriers change a client's address within its/64without
any user action, which strands an open grant. This setting widens each IPv6 grant to the
client's network, from/128(the default, unchanged behavior) down to/64at most. IPv4 is
never widened. - IPv6-only sign-in address (
AdditionalOrigins). Some browsers and operating systems,
notably iOS, choose between IPv4 and IPv6 themselves, based on which connected faster recently,
and can prefer IPv4 even when IPv6 works. On a mobile carrier, IPv4 usually means carrier-grade
NAT (CGNAT): the grant then covers every subscriber sharing that address, and the tunnel may not
leave from the same address as the login. An IPv6-only name (a hostname with only an AAAA
record) removes that choice and gives a per-device IPv6 grant.AdditionalOriginslets passkey
sign-in work on such a name. It takes exact origins only (https, underAppUrl's host, same
port); MFAWeb refuses to start on anything else and logs the accepted list at startup. The name
must be included in MFAWeb's certificate. Verified on a test deployment with an iPhone on AT&T
5G: it chose IPv4 for the dual-stack name, and signed in with its passkey through the
IPv6-only name, getting a grant for its own/64rather than a shared CGNAT address. - IPv6 addresses fit on the login and Access Granted pages. A full IPv6 address now wraps at
its colons instead of overflowing the box.
Security hardening from AI audit and human review
Medium:
- One source could lock every user out through the shared rate limit. Static files, unknown
paths and requests with the wrong HTTP method drew from the sharedGlobalRateLimitPerWindow
bucket with no per-source limit, so about 200 requests in five minutes from one address
returned 429 to every login. Only the app's own routes now use the
shared bucket; everything else has a per-source limit of its own. - An unauthenticated request could forge a credential-clone alert. Clone detection tested the
Fido2 error text for the word "counter", and another Fido2 error echoes the request's clientData
type. It now matches Fido2's exact counter message and no longer logs the error text on that
line.
Lower-severity / defense-in-depth:
- Capped logging for failures anyone can trigger. Sign-in attempts for addresses with no
passkey, invalid setup or registration links, and failed passkey checks are logged as[PROBE]
lines, up to 5 per source and 50 per hour, with one summary line counting the rest. Previously
some of these were not logged at all and others were uncapped. Request text in these lines can
no longer imitate other log tags. RulePrefixis validated (letters, digits, space,_ . -, 1-64 characters). A quote in it
broke the firewall commands and, on Linux, created grants the sweeper could never remove.- Plain email addresses only.
MFAAdmin addand MFAService's firewall requests accept only
plain addresses; a quoted local part could carry text into the Windows rule description that
expired that user's grants early.importwarns about such accounts. - Passkey challenges are tagged with their ceremony, so a registration challenge cannot be
redeemed at sign-in or the reverse. Not exploitable before this change, but only by coincidence. - Log retention. Each component deletes its own log files after 90 days. INSTALL.md now sets
up the log directory so each account can manage only its own files (a creator-owner ACL on
Windows, a sticky directory on Linux), so MFAWeb cannot delete MFAService's logs. - URL tokens echoed into pages are HTML-encoded.
- Linux archive permissions. The 0.3.0 tarball stored the program files without the execute
bit, so they neededchmod +xafter extracting. The 0.4.0 archive marks them executable. - Local SMTP relays on a non-loopback address.
Smtp:UseSsl=falsewas accepted only for
localhostor a loopback address, so a relay on the same machine that listens only on its LAN
address could not be used, and every email was refused (including certificate-expiry alerts).
It is now also accepted whenSmtp:Hostis an IP address assigned to one of this machine's
interfaces that is up, checked on every send. Hostnames are still never resolved for this
check, and plaintext to any other host is still refused. The rule is now one shared file used
by all three components.
Documentation
- Security claims checked against the code and corrected where they overstated it, including
per-address grant scope (now optionally a network),reset/deleterevocation, the certificate
expiry banner, and the Linux log directory setup, which contradicted itself. - New notes on iCloud Private Relay (a grant for a relay address does not match the tunnel) and on
dual-stack phones choosing IPv4.
Verification
- Automated suites, all passing: MFAService regression (27 checks), replay protection (2), MFAWeb
alerts (12) and registration binding (9), including new tests for each fix above. CI builds
both platforms with and without TOTP. The forged-alert test was checked to fail against the
previous logic. - Field-verified on a Linux test deployment: real passkey sign-ins over IPv4 and IPv6,
/64
widened grants inip6tables, and an iPhone on AT&T 5G signing in through an IPv6-only name
listed inAdditionalOrigins. - Field-verified on a Windows Server deployment (signed builds): passkey sign-in over IPv6 through
an IPv6-only name from an iPhone on AT&T 5G,/64widened grants created throughNetSecurity,
andMFAAdmin diaglisting them by network. The data and log directory permissions from
INSTALL.md were applied there, with the database read and rewritten and logs written under
them.
Upgrade notes
- Check
RulePrefixfirst. If yours contains anything other than letters, digits, space,
_,.or-, change it in MFAService and MFAAdmin before upgrading, and remove any rules
still open under the old prefix by hand. MFAService refuses to start otherwise. The default
MFA_Temp_is unaffected. - Tighten the log directory as described in INSTALL.md's upgrade steps (Windows ACL change,
Linux sticky bit). - Linux: add
AF_INET AF_INET6toRestrictAddressFamiliesinmfa-service.service, then
systemctl daemon-reload. The previously documented unit blocked network connections, so
MFAService's certificate-expiry and IPC alert emails were never sent on Linux. Updating the
binaries does not change the unit. - Upgrade MFAService before MFAWeb, as for 0.2.0 and later.
- Optional: set
Ipv6GrantPrefixLengthand/orAdditionalOrigins. Both default to
today's behavior.
Full diff: v0.3.0...v0.4.0
v0.3.0
MFA Firewall Knocker v0.3.0
This release is almost entirely security and correctness fixes — eight from a community
contributor, the rest from two rounds of AI (Claude Fable 5) security audit and human review. No
new user-facing features.
Community contributions
Huge thanks to @git-geeky, who opened eight pull requests
covering real, independently-found issues across the codebase:
- #2 — Reject replayed TOTP codes. A valid TOTP code could be accepted more than once within
its ~90-second validity window. Now captures the matched timestep and atomically consumes a
strictly-increasing, persisted watermark before any firewall request goes out, with a secret
fingerprint so a reprovisioned account isn't blocked by its predecessor's history. - #3 — Require TLS for remote SMTP relays. A missing or misconfigured
Smtp:UseSslcould let
the enrollment password and provisioning link go out in cleartext to a non-local relay. TLS is
now the default across all three components, andUseSsl=falseis only accepted for
localhost/loopback/::1— bound to the same host actually used for the send, so a config
reload can't pair a remote destination with a loopback decision. - #6 — Harden Linux secret and IPC permissions. Three issues in the Linux install path: the
guide could expose unrelated Let's Encrypt private keys, an atomic certificate replacement could
lose MFAWeb's database read access, and — most notably — the peer-UID verification check would
silently disable itself rather than fail closed when the configured MFAWeb account couldn't be
resolved. Certificate renewal is now staged, key-pair-verified, and atomically published via a
symlink swap; the IPC socket has one explicit owner group; and an unresolved account now rejects
every client while the expiry sweeper keeps running, instead of quietly trusting everyone. - #8 — Bind passkey registration challenges to accounts. Registration completion checked the
challenge and the provisioning token independently, without cross-checking that they resolved to
the same account — a cross-account credential-binding gap. Now requires an exact canonical
match between the two before verifying or persisting a credential. - #1 — Verify firewall operations before reporting success. A failed firewall subprocess could
still reportSUCCESSto the caller, and a hung child process could stall the expiry sweep
indefinitely. Command exit status and postconditions are now authoritative, with a 30-second
timeout and process-tree termination. - #7 — Monitor passkey and enrollment authentication failures. Failed attempts were always
recorded in the[SECURITY]log — that trail was never missing. What was inadvertently left off
was the alert: the failed-login threshold counter (and therefore the email notification) was
wired only to the optional TOTP route, so the default (passkey-only) build never raised it, even
though every failure it should have counted was sitting in the log the whole time. Passkey
verification and enrollment-password failures now count toward that same per-account threshold. - #5 — Pin reproducible dependency inputs. The build depended on a floating SDK version,
mutable GitHub Action tags, and unlocked NuGet resolution. Now pins the SDK, commits lockfiles,
restores in locked mode, pins Actions to immutable commits, and verifies two clean builds
produce identical managed-assembly hashes. - #4 — Correct passkey and credential security claims. Documentation overstated what
attestation: noneproves about a passkey's hardware-bound/non-exportable status, and
understated exactly what a database compromise exposes. Docs-only, no behavior change.
If you're tracking this project's GitHub PRs: these were incorporated by hand (merged into an
integration branch with real conflict resolution, not a fast-forward). GitHub still recognized
the original commits once they reached main and marked all eight PRs merged automatically.
Security hardening from AI audit and human review
The rest of this release comes from two rounds of AI (Claude Fable 5) security audit of this
project's own code, reviewed and verified by the maintainer, plus the deployment work needed to
confirm the fixes for real. Roughly
in order of how much they mattered:
Higher-impact fixes:
- IPv6 zone-ID shell injection.
IPAddress.TryParseaccepts an IPv6 zone ID (%...)
containing arbitrary characters and silently drops it onToString()— but the firewall
request path validated and forwarded the original string, so a crafted zone ID like
2001:db8::1%$(id)could reach a privilegedbash -ccall as root (or break out of a
PowerShell single-quoted string as SYSTEM). Zone IDs have no legitimate use at this layer and
are now rejected outright, with every downstream use going through the parsed, canonical
address instead of the raw string. Requires already having write access to the privileged IPC
endpoint — not reachable through a normal login. - MFAAdmin export files created world-readable.
exportdumps password hashes, TOTP secrets,
and any live provisioning tokens to a plaintext JSON file — by design, with an on-screen warning
— but wrote it with default process permissions, which under a typical root umask is
world-readable. A live token plus its enrollment password is a full account takeover during that
window. The file is now created access-restricted from the instant it exists (chmod 600/
owner-only ACL), not tightened afterward, and refuses to follow an existing file or symlink at
the destination path. - Enrollment password never actually cleared. The one-time password used to complete passkey
registration had no real burn-after-use: it survived registration, and nothing ever cleared it
once its 60-minute window passed unused. The registration code never checked or reused it after
that point, so this wasn't an active security risk in the passkey-only build — but the value has
no further purpose there, so it's now cleared on successful registration and by a periodic sweep
for an expired-but-unused window, except for accounts that have confirmed TOTP, where that same
password remains a legitimate ongoing login credential. - IPv6 rate limiting bucketed per-address. A residential IPv6 allocation commonly spans a
whole/56or/64— partitioning the login rate limiter on the full address let an attacker
get an effectively fresh bucket on every request just by rotating host bits, which matters more
here than in most apps: SECURITY.md documents per-IP throttling as the compensating control for
this project's deliberate no-account-lockout design. IPv6 now buckets on the/56prefix; a
small aggregate cap across every partition combined was added as a backstop. - Failed-login alert emails had no cap across accounts.
/passkey/challengediscloses a
target's credential ID by design (required for non-resident WebAuthn login) — so an attacker
with a list of victim emails could drive the per-account alert threshold for each one and flood
the operator's inbox, one email per account. Detection can't be weakened without hiding real
attacks too, so the fix caps total alert emails sent per window instead; the per-account
[SECURITY]log line is never suppressed.
Lower-severity / defense-in-depth:
- Sign-count updates on a passkey credential are now compare-and-only-increase under lock, instead
of a blind overwrite that let a concurrent write silently clobber the clone-detection signal —
and only a nonzero counter that fails to advance raises a[SECURITY]warning; an authenticator
that has never implemented a counter at all (most platform passkeys — Windows Hello, iCloud
Keychain, Android) doesn't trip it on every single login. AddPasskeynow re-checks credential-ID uniqueness under its own authoritative lock, closing a
narrow (large-random-value) race that MFAWeb's own pre-check couldn't fully close.RulePrefix(from config, not user input) is now escaped before interpolation into PowerShell
command strings at three sites, closing the same class of issue an unescaped username once was
elsewhere in this codebase.MFAAdmin diag's Linux rule listing used an imprecise substring match against the whole
iptables line — the same bug already fixed inreset— now shares that fix.MFAAdmin importno longer trusts an imported file'sPasskeyRegistrationReadyflag; forced
false unconditionally, since an untrusted file could otherwise claim that state directly against
any token.MFAAdmin deleteno longer unconditionally claims "all access revoked" — it now actually
attempts per-user rule revocation on Windows (matching rule descriptions), says plainly when it
can't on Linux (rules there carry no username), and both platforms now correctly note that a
session already in progress isn't ended by this.- The one page that ever renders a raw TOTP secret is now marked
no-store, so a browser's
back-button/disk cache can't resurface it on a shared machine.
Documentation
Two README claims that were actually wrong in 0.2.0 are corrected:
- Roaming/hardware security keys aren't actually rejected by server-side enforcement at
registration, as 0.2.0's README claimed —attestation: nonemeans there's no attestation
statement to check attachment type against. It's the browser honoring a request, not something
verified independently. - Private Relay doesn't degrade per-IP gating the way CGNAT or a corporate VPN does, contrary to
0.2.0's README — it only proxies standard web traffic, not WireGuard's UDP port or other
non-web protocols.
SECURITY.md also gains a new threat model section, covering ground 0.2.0 didn't document at all:
what conntrack-based session-theft detection actually catches (a collision between two concurrent
sessions...
v0.2.0
Second release. The IPC channel between the two processes now verifies who is on the other end
before it sends anything, and a Linux logging defect that misreported every successful grant is
fixed.
MFA Firewall Knocker keeps admin ports closed by default and opens a firewall rule for a
single source IP after someone proves who they are with a passkey. The rule expires on its own.
It sits in front of SSH, WireGuard, or RDP without replacing them.
Downloads
| File | Contents |
|---|---|
mfa-firewall-knocker-v0.2.0-win-x64.zip |
MFAWeb, MFAService, MFAAdmin — code-signed |
mfa-firewall-knocker-v0.2.0-linux-x64.tar.gz |
same three components |
SHA256SUMS.txt |
checksums for both archives |
Both are self-contained — no .NET runtime install required. Each archive holds all three
components, which share the users.dat schema and must be deployed together. Both are the default
passkey-only build; TOTP still requires building from source with -p:AllowTotp=true.
signtool verify /pa MFAWeb.exe # Windowssha256sum -c SHA256SUMS.txt --ignore-missing # LinuxThe IPC endpoint now proves its identity
MFAWeb used to send its request to whatever answered on the local IPC endpoint. On Windows, a
local unprivileged user who created the pipe name before MFAService started would receive
those requests — which meant reading short-lived provisioning tokens, and also replying with
forged responses, such as SUCCESS to a token-burn that never happened.
- The client verifies before its first write, which is the ordering that matters, because the
first write is what carries the token. On Windows it reads the pipe's owner SID and refuses to
send unless it is LocalSystem or Administrators. On Linux it requires the peer to be uid 0 via
SO_PEERCRED. This is the same mechanism the .NET BCL uses internally for
PipeOptions.CurrentUserOnly, which is itself unusable here because the two processes run as
different principals. - The privileged service holds the pipe name. It claims it with
FILE_FLAG_FIRST_PIPE_INSTANCEand serves a pool of long-lived instances, so the name is never
released while it runs. A name that is already taken is now a loud, alerting startup failure
instead of silent coexistence with an impostor. - Linux also checks the other direction — MFAService verifies the connecting uid against
FirewallService:GmsaAccount. Leaving that unset keeps the previous behaviour and relies on the
socket mode, so upgrades do not break. The real control on Linux remains that the socket lives in
a root-owned directory.
What remains is availability: a local user who wins the pipe name during a restart window keeps
MFAService from binding. That is unavoidable with a fixed name, and it now fails closed, logs, and
emails Smtp:NotifyAddress.
Every Linux grant was being logged as failed
Present since the first release. Rule creation added an iptables comment, but the verification
step omitted it:
iptables -I INPUT ... -j ACCEPT -m comment --comment 'MFA_Temp_<ip>_<port> exp:<epoch>'
iptables -C INPUT ... -j ACCEPT # no -m commentiptables -C compares the fully parsed rule specification, so a check without the comment can
never match a rule that carries one. Every successful grant on Linux logged
[FAILED] iptables rule could not be verified while the rule was present and working.
Verification is log-only — no access decision was ever affected — but the audit trail said the
opposite of the truth, which is worse than saying nothing in the one situation those logs exist
for. Verification now scans the rule list, the way the sweeper already did.
Related: verification failures now log at Warn rather than Info on both platforms, so they
survive Logging:AppMinLevel=warning. Malformed IPC requests are logged too.
Bug fixes
Neither of these is a security issue — both need root, and root already owns the firewall and the
user store — but both produce confusing behaviour that is hard to diagnose.
MFAService --versionstarted the service instead of printing a version. Command-line
arguments were ignored entirely, so any argument ran the host. Checking the version of an
installed binary is an ordinary thing to do, and doing it launched a second privileged instance.
It now supports--versionand--help, rejects anything else with exit code 2, and starts
only when given no arguments.- A second instance on Linux would take over the IPC socket.
bind()refuses when the path
already exists, but the code deleted the socket first and threw that exclusivity away — so a
duplicate unlinked the running service's socket and claimed the path. Both instances then swept
firewall rules and could both write the user store, with clients reaching whichever bound last,
and only one of them visible tojournalctl. MFAService now probes an existing socket rather
than assuming it is stale, refuses to displace a live one, and does the probe and unlink under
an exclusive lock so two starting instances cannot unlink each other. - A duplicate now does nothing at all. Refusing the IPC endpoint was not enough on its own —
a second instance still started its sweeper, certificate monitor and database hardening, so it
went on removing firewall rules on its own schedule. Its output also goes to whatever console
launched it rather than to journald or the Event Log, so none of that activity appeared where
an operator would look. Everything that touches shared state now waits until this process owns
the endpoint. On Linux the duplicate exits with status 1 so systemd reports a failure rather
than a clean stop; on Windows it keeps retrying, because there any user can create a pipe name
and exiting for good would let an unprivileged account block all future grants. MFAServiceexited 0 after a fatal error. A background-service failure stops the host
gracefully, so a service that died because it could not do its job reported success — and under
Restart=on-failurewas never restarted. It now exits 1. Clean shutdowns are unaffected.- An environment failure was reported as a duplicate instance. If the socket could not be
opened for some other reason — a permissions problem, a directory sitting at the socket path, an
AppArmor or SELinux policy — the service claimed to be a duplicate and emailed that "the running
service is unaffected" when nothing was running. Those causes are now distinguished. - Published binaries embedded the build machine's directory paths, which surfaced in any stack
trace written to a deployment's logs. Builds are now deterministic and path-normalised, which
also means two people building the same commit should get matching binaries.
Also in this release
- Verify the gate is actually gating — a new INSTALL.md section. This tool only ever adds
allow rules. If the protected port is already reachable for some other reason, every grant is
redundant and the deployment protects nothing, while the logs, the UI and the rule list all look
exactly as they would if it were working. There is no symptom. The section gives the one test
that distinguishes the two states. - An upgrade procedure, including the advice to run it over a connection that does not depend
on this gate — if an upgrade breaks it, the port you need is the one you are trying to fix. - The Windows install step now scopes MFAWeb's inbound rule to its executable as well as its
port. - Screenshots in the README, and a threat model rewritten around what a stolen credential
actually buys an attacker.
Upgrading from 0.1.0
users.dat is unchanged, so accounts and passkeys carry over untouched, and rollback works.
Upgrade MFAService before MFAWeb. From this release the client verifies the privileged
service's identity, so a newer MFAWeb against an older MFAService is the combination most likely
to fail. See INSTALL.md
for the full procedure, including backup and rollback.
If MFAService stops
Firewall rules live in the firewall, not in this program, so an outage changes nothing about the
current rule set. The standing block you configured stays, and the protected port stays closed —
protection is not lost. What you lose is the ability to grant new access, and the timely removal
of grants already issued: expiry is enforced by the sweeper, not by the firewall, so an open rule
persists past its exp: time until the service returns.
If that matters during an outage, MFAAdmin reset clears every MFA-granted rule and does not
need MFAService — it runs elevated and issues the firewall commands itself, then re-reads the rule
list and reports what actually remains. That makes it the emergency-revocation tool as well.
Status and limitations
Points to weigh before deploying this:
- Two deployments, one operator — both of them mine. Windows Server and Ubuntu 24.04, both
running these 0.2.0 binaries. Verified on both: passkey login, the IPC identity check in both
directions, a rule created for a single source address and correctly scoped, an upsert replacing
rather than stacking, and the sweeper removing a rule at expiry while leaving unexpired ones
alone. On Windows, 300 sequential IPC requests returned intact responses, and the instance pool
survived 40 aborted connections. Nobody who didn't write this has installed it yet, which is the
gap that matters most. - No external security audit. What it has had is repeated adversarial review passes over the
source using Anthropic's Fable 5 model, this time including a pass over the production logs of
both hosts. That found the two defects fixed above and several smaller ones. An LLM review
pass is not an independent human audit and no such audit has been done. - **On L...
v0.1.0 — first public release
⚠️ Superseded by v0.2.0Use v0.2.0 instead. These binaries remain available so existing checksums stay verifiable,
but they contain defects that are fixed in the current release:
- Every successful grant on Linux was logged as
[FAILED]. The rule was created and worked;
the verification step could never match it. Anyone reading these logs during an incident would
conclude no access had been granted — the opposite of the truth.MFAService --versionstarted the service rather than printing a version, launching a
second privileged instance that swept firewall rules alongside the real one.- A second instance on Linux could take over the IPC socket, leaving two privileged services
both managing firewall rules and both able to write the user store.- The IPC endpoint was not authenticated. A local unprivileged user who created the Windows
pipe name before the service started could read provisioning tokens and forge responses.None of these is remotely exploitable — the IPC issue needs local access, the rest need root —
so there is no advisory and no reason to panic. But v0.2.0 is strictly better and upgrading is
straightforward:users.datis unchanged, so accounts and passkeys carry over untouched. See
Upgrading, and
upgrade MFAService before MFAWeb.
First public release.
MFA Firewall Knocker keeps admin ports closed by default and opens a firewall rule for a
single source IP after someone proves who they are with a passkey. The rule expires on its own.
It sits in front of SSH, WireGuard, or RDP without replacing them.
SSH and WireGuard authenticate a key. Neither can answer the question that matters — is the
authorized user the one using this key, right now? This answers it before the protocol ever
sees a packet.
Downloads
| File | Contents |
|---|---|
mfa-firewall-knocker-v0.1.0-win-x64.zip |
MFAWeb, MFAService, MFAAdmin — code-signed |
mfa-firewall-knocker-v0.1.0-linux-x64.tar.gz |
same three components |
SHA256SUMS.txt |
checksums for both archives |
Both are self-contained — no .NET runtime install required. Each archive holds all three
components, which share the users.dat schema and must be deployed together.
Windows binaries are signed by Pacific Northwest Software Inc. via Azure Trusted Signing,
and timestamped, so the signature stays valid after the (deliberately short-lived) signing
certificate rotates. Verify with signtool verify /pa MFAWeb.exe or by checking file properties.
Linux binaries are not signed. There is no OS-level ELF signature anything would check, so
verify with SHA256SUMS.txt instead:
sha256sum -c SHA256SUMS.txt --ignore-missingPasskey-only by default
These binaries are the default build: no TOTP. There is no /auth route and no TOTP
enrollment route — they return 404 because no handler exists, and no TOTP secret is ever stored.
TOTP is a compile-time opt-in (-p:AllowTotp=true) and must be built from source, deliberately.
Passkeys require a platform authenticator with user verification — Windows Hello, Touch ID /
Face ID, or Android biometric, with a biometric or PIN on every registration and every login.
Roaming security keys such as YubiKeys are rejected as configured. See the README for how to
change that if you need it.
Status and limitations
Points to weigh before deploying this:
- Two deployments, one operator — both of them mine. Windows Server and Ubuntu 24.04, both
running these 0.1.0 binaries, and both exercised end to end: passkey enrollment and login,
firewall rules created for a single source IP and swept on expiry, config bounds hit
deliberately, certificate renewal picked up without a restart. The Windows one has been in
service since March 2026, on earlier builds before this release. Nobody who didn't write this
has installed it yet, which is the gap that matters most. - No external security audit. What it has had is repeated adversarial review passes over the
source using Anthropic's Fable 5 model, looking specifically for exploitable weaknesses, and
separately end-to-end exercise on real hosts. Those two find different things and neither
substitutes for the other: review hunts vulnerabilities, while running it surfaces defects —
mostly at the seams with the init system, the platform certificate store and the firewall
backend. Everything found is fixed and what I still know about is in SECURITY.md, but an LLM
review pass is not an independent human audit and no such audit has been done. - The user store is only as safe as its file permissions. In this passkey-only build it holds
no directly usable credential — WebAuthn credentials are public keys, and no TOTP secret is ever
written — so the risk is write access rather than read: anyone who can modifyusers.datcan
enrol their own passkey. It is DPAPI-encrypted on Windows and plain JSON on Linux; follow
INSTALL.md's permission steps on either. - One passkey per account, enforced on the privileged side. For redundancy use a synced
passkey or a second admin account on a different device. See "Do not make this your only way
in" in the README. - Don't make this your only route into a network. An expired certificate means no passkey
ceremony, which means nobody gets in. Keep a second path.
Known issues and the deliberate out-of-scope decisions are in
SECURITY.md.
Getting started
INSTALL.md is the real
guide — gMSA setup, systemd units, certbot, file permissions, and the shared IPC group are all
covered. The short version:
- Copy each
appsettings.example.jsontoappsettings.jsonand fill it in.DpapiEntropymust
be identical across all three and is refused if left at the placeholder. - Install MFAService (LocalSystem / root) and MFAWeb (unprivileged).
MFAAdmin add you@your-domain.com, then follow the emailed passkey link.
Bug reports and security issues are welcome — see SECURITY.md for private reporting.