Skip to content

Releases: PNWSoft/mfa-firewall-knocker

v0.4.0

Choose a tag to compare

@mrhayner mrhayner released this 25 Sep 18:33

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 in iptables. The sweeper and MFAAdmin's diag/reset read and
    clear both tables. Previously an IPv6 client on Linux was accepted but its grant failed. Windows
    has always handled IPv6 grants through NetSecurity, 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 /64 without
    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 /64 at 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. AdditionalOrigins lets passkey
    sign-in work on such a name. It takes exact origins only (https, under AppUrl'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 /64 rather 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 shared GlobalRateLimitPerWindow
    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.
  • RulePrefix is 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 add and 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. import warns 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 needed chmod +x after extracting. The 0.4.0 archive marks them executable.
  • Local SMTP relays on a non-loopback address. Smtp:UseSsl=false was accepted only for
    localhost or 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 when Smtp:Host is 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/delete revocation, 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 in ip6tables, and an iPhone on AT&T 5G signing in through an IPv6-only name
    listed in AdditionalOrigins.
  • 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, /64 widened grants created through NetSecurity,
    and MFAAdmin diag listing 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

  1. Check RulePrefix first. 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.
  2. Tighten the log directory as described in INSTALL.md's upgrade steps (Windows ACL change,
    Linux sticky bit).
  3. Linux: add AF_INET AF_INET6 to RestrictAddressFamilies in mfa-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.
  4. Upgrade MFAService before MFAWeb, as for 0.2.0 and later.
  5. Optional: set Ipv6GrantPrefixLength and/or AdditionalOrigins. Both default to
    today's behavior.

Full diff: v0.3.0...v0.4.0

v0.3.0

Choose a tag to compare

@mrhayner mrhayner released this 09 Sep 18:14

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:UseSsl could 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, and UseSsl=false is 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 report SUCCESS to 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: none proves 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.TryParse accepts an IPv6 zone ID (%...)
    containing arbitrary characters and silently drops it on ToString() — but the firewall
    request path validated and forwarded the original string, so a crafted zone ID like
    2001:db8::1%$(id) could reach a privileged bash -c call 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. export dumps 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 /56 or /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 /56 prefix; a
    small aggregate cap across every partition combined was added as a backstop.
  • Failed-login alert emails had no cap across accounts. /passkey/challenge discloses 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.
  • AddPasskey now 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 in reset — now shares that fix.
  • MFAAdmin import no longer trusts an imported file's PasskeyRegistrationReady flag; forced
    false unconditionally, since an untrusted file could otherwise claim that state directly against
    any token.
  • MFAAdmin delete no 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: none means 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...

Read more

v0.2.0

Choose a tag to compare

@mrhayner mrhayner released this 24 Aug 03:40

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          # Windows
sha256sum -c SHA256SUMS.txt --ignore-missing   # Linux

The 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_INSTANCE and 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 comment

iptables -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 --version started 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 --version and --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 to journalctl. 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.
  • MFAService exited 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-failure was 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...
Read more

v0.1.0 — first public release

Choose a tag to compare

@mrhayner mrhayner released this 18 Aug 22:44

⚠️ Superseded by v0.2.0

Use 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 --version started 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.dat is 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-missing

Passkey-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 modify users.dat can
    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:

  1. Copy each appsettings.example.json to appsettings.json and fill it in. DpapiEntropy must
    be identical across all three and is refused if left at the placeholder.
  2. Install MFAService (LocalSystem / root) and MFAWeb (unprivileged).
  3. 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.