Skip to content

Add a second factor, and make the platform watch itself - #44

Merged
d3vhex merged 1 commit into
mainfrom
feat/two-factor-and-platform-self-defence
Sep 7, 2026
Merged

d3vhex merged 1 commit into
mainfrom
feat/two-factor-and-platform-self-defence

Conversation

@d3vhex

@d3vhex d3vhex commented Sep 7, 2026

Copy link
Copy Markdown
Owner

A second factor

The console was protected by a password and a lockout. That covers guessing
and nothing else — and a password that is correct and in the wrong hands is
enough to isolate a host, run a command as SYSTEM on every endpoint, and read
every log the fleet has produced.

TOTP, at My Security in the sidebar. RFC 4226 is written out rather than
depended on — eleven lines, checked against the published vectors, and the
padding rule is the kind of detail a comment can carry and a library cannot.

The mechanism was the easy part. What took the work was the five ways this
gets shipped looking like two-factor without being it:

  • No session exists until both factors pass. Issuing one on the password
    and upgrading it later leaves a window where a single factor is a logged-in
    operator, and every bug in that window is a bypass. The pending state is a
    five-minute, single-use, hash-stored token that can do exactly one thing.
  • A code works once. TOTP is a shared secret and a clock, so the same six
    digits stay valid for the whole step — without single use, watching one
    being typed buys the rest of that step. The last_step comparison is <,
    not =, so a code from earlier in the accepted window cannot be replayed
    either.
  • Recovery codes are credentials. Stored as hashes, burnt on use, and they
    leave the server exactly once. Keeping them readable so the console can show
    them again is the same mistake as storing a password, made for a friendlier
    reason. SHA-256 rather than bcrypt, deliberately: 80 bits of machine-chosen
    entropy has no dictionary to slow down.
  • The seed is Fernet-encrypted at rest, in a VARCHAR(512) sized for
    ciphertext — this codebase has already lost a day of telemetry to a column
    sized for the value a human would read.
  • Both login paths are gated. A factor protecting the local path and not
    the directory path protects nothing; an attacker holding a password picks
    the one without it.

Enrolment is not complete until a code is proved. A secret written when the QR
appears, with nothing confirming it, locks out everybody who scans it into an
app they then delete. Turning it off needs the password, so a hijacked session
cannot remove the control that would have stopped it.

Two bugs found on the way

hmac.compare_digest raises on non-ASCII strings, and "٠١٢٣٤٥".isdigit()
is True in Python — six Arabic-Indic digits passed the shape check and
turned a typo into a 500 on the authentication path.

The client threw on second_factor_required. authService.login rejected
anything that was not status === 'success', so the pending response surfaced
as "Login failed". The backend shipped working two-factor and enabling it
would have locked every operator out of the console, with a message telling
them their password was wrong.

And one the tests could not see. The routes were spliced into app.py
without their import. Thirty AST-based tests passed while the live server
answered name 'totp_store' is not defined with a 500 on the second half of
every login — caught by probing the running server, not by the suite. Two
tests now close that gap: the module is imported, and every name the handlers
use resolves.

The platform now watches itself

core/self_defence.py and userdb.platform_events, surfaced under Activity
Logs
. All of it was already detected and none of it was told to anyone: a
lockout went to a container log, a rejected agent key went to a container log,
a permission denial went to a table nobody opens unless they are already
investigating. The platform watched every host in the fleet and was the one
machine nobody watched.

Eight kinds, each raised and each explained in the guide — FLEET_KEY_ON_CHANNEL
(critical: that channel carries /self_destruct), LOGIN_LOCKOUT,
AGENT_KEY_REJECTED, ENROLMENT_TOKEN_REUSED, TOTP_CODE_REPLAYED,
TOTP_DISABLE_REFUSED, PERMISSION_DENIED, RECOVERY_CODE_USED.

Three rules hold it up: recording never breaks the thing it records (every
writer swallows its own failure — a server that will not reject a bad login
because it could not journal the rejection has turned a monitoring gap into an
outage); a severity is a claim, so the conclusion is stored rather than every
attempt; and no field ever holds a credential.

Corrections to the first version

Three of six kinds were defined and never raised, and the test passed
because it looked for the name somewhere in app.py — a mention in a comment
satisfies that. It now reads the call sites off the AST, follows variables, and
parametrises over the whole map, so an unwired kind cannot sit quietly.

TLS_DOWNGRADE_REFUSED is deliberately gone. INGEST_TLS_REQUIRED=1 does
not open the plaintext listener, so the server never sees the connection — the
kind could not be raised by design. Making it raisable would mean binding the
port and refusing politely, which is worse for exactly the agents it would
report: a build older than the receipt frame marks its rows sent once sendall
returns, and a clean close would let it do that and lose the batch.
ECONNREFUSED raises inside sendall, so the rows stay put. The visibility
lives on the agent side instead, as send failing in telemetry health. The
reasoning stays in the file — a deleted idea comes back if the reason is not
written down.

The panel is shown even when empty. Hiding it read well as an argument — a
permanently empty panel is furniture people learn to skip — and it recreated
the exact failure the rest of this work removes: an operator who never sees it
cannot tell "nothing has attacked us" from "this platform does not watch
itself". On a security view, a quiet twenty-four hours is a finding.

Documentation

§6.2 covers two-factor end to end, including the answer nobody wants: if the
recovery codes are lost and the authenticator is gone, the account cannot sign
in.
There is no support path, by design — any mechanism letting an
administrator bypass somebody's second factor is one an attacker can use. The
way back is a documented, deliberately awkward database edit.

§6.4 documents the platform events with their severities and what each one
means; a severity in a table nobody has a key for is a severity nobody acts on.

The renumbering that adding these sections required silently broke a forward
reference the first time, so a test now checks that every §x.y in the guide
points at a heading that exists.

This whole body of work began with a guide describing three settings the code
never read. A shipped feature the guide never mentions is the same drift with
the sign flipped, and it is the one an operator hits at three in the morning.

Tests

2,442 passing. New invariants: every kind in the vocabulary is actually raised;
every guard names a permission that exists; the pending token is never
persisted; the recovery codes leave the server exactly once; the QR is drawn on
a light ground (a code in theme colours on a dark card reads perfectly to a
human and not at all to a phone, and fails silently); and every section
reference in the guide resolves.

The console was one password away from isolating any host and running a command as SYSTEM on every endpoint; the client also threw on the pending-2FA response, so switching it on would have locked every operator out.
@d3vhex
d3vhex merged commit e6c80a6 into main Sep 7, 2026
11 checks passed
@d3vhex
d3vhex deleted the feat/two-factor-and-platform-self-defence branch September 7, 2026 18:16
d3vhex added a commit that referenced this pull request Sep 8, 2026
…defence

Add a second factor, and make the platform watch itself
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant