Skip to content

Add security keys, the certificate names they need, and finish the console kit - #47

Merged
d3vhex merged 1 commit into
mainfrom
feat/security-keys-and-console-kit
Sep 10, 2026
Merged

d3vhex merged 1 commit into
mainfrom
feat/security-keys-and-console-kit

Conversation

@d3vhex

@d3vhex d3vhex commented Sep 10, 2026

Copy link
Copy Markdown
Owner

Security keys, the certificate names they need, and the console around them

Three pieces that turned out to be one: a security key cannot be registered
against an IP address, this deployment's certificate only ever answered to
localhost, and the pages that would host the feature were the ones still
outside the shared UI kit.


1. Certificate names

The shipped certificate carried this:

subject=C=TR, O=Sentora Dev, CN=localhost
X509v3 Subject Alternative Name: DNS:localhost, DNS:localhost

The SAN list was [cn, "localhost"] and nothing else — hence the duplicate,
and hence no way to add a name at all. A console opened at
https://192.168.1.26:8000 failed hostname verification in every browser, on
top of the untrusted-CA warning: two errors that look like one.

TLS_SAN is comma separated and takes hostnames and addresses alike. An entry
that parses as an address becomes an IP SAN, because a bare address in the
DNS list matches nothing. localhost, 127.0.0.1 and ::1 are always
included, and names are deduplicated:

DNS:sentora.corp.local, DNS:sentora, DNS:localhost,
IP Address:192.168.1.26, IP Address:127.0.0.1, IP Address:0:0:0:0:0:0:0:1

The part that was actually costing time. ensure_certs is idempotent —
correctly, since a new identity on every restart breaks every agent that pinned
the CA. The consequence is that changing TLS_CN on a deployment that already
has a certificate does nothing, and nothing said so. The operator sets the
hostname they intend to use, restarts, gets the same localhost certificate,
and spends the afternoon on the browser warning. It now says so at startup,
with the command to fix it, verified against the live certificate:

[tls] The certificate at .../server.crt does not cover 192.168.1.26,
sentora.corp.local - it was generated before those names were configured, and
TLS material is never regenerated on top of itself. ... run
`python certs/generate_certs.py --force --cn sentora.corp.local --san 192.168.1.26`.

That check silently vanished on its first draft — except: return made "could
not read the certificate" and "the names match" produce identical output, which
is the failure the function exists to report. It has its own test now.


2. Security keys

A one-time code typed into a convincing copy of this login page works on the
real one, and the operator has no way to tell. A key signs over the origin it
is actually talking to, so it produces nothing usable on a copy.

There is no configuration, and that was the design question. The
relying-party ID is derived from the request. A laptop at
https://localhost:8000 and an estate at https://soc.example.com both work
without being told anything, and no setting can disagree with the URL people
type.

The cost is that some origins cannot host a key, and the console says which
rather than showing a button that fails. Verified in the running container:

  OK  https://localhost:8000      -> rp_id localhost
  no  https://192.168.1.26:8000   (an address cannot be a relying-party ID)
  OK  https://sentora.corp.local  -> rp_id sentora.corp.local

Plain HTTP, and a server installed without the webauthn package, are reported
the same way. One-time codes are unaffected in every case — the message
says so, because an operator who reads "unavailable" as "two-factor is off
here" turns the whole thing off.

Three things worth reading in review:

  • A credential records its RP ID. A key registered at localhost is one
    the browser will not offer at a hostname. Without the column the console
    lists a factor that cannot work and the login prompt times out silently.
  • _has_security_key is deliberately not scoped to the RP ID. Whether a
    login needs a second factor is a property of the account; whether this
    browser can offer a key is a property of the origin. Conflating them would
    let an operator with a key registered at a hostname skip the second factor
    entirely by opening the console at an address — turning the origin binding,
    the entire point of WebAuthn, into a bypass.
  • The counter is checked. A key presenting a lower signature counter than
    the last one seen is refused and raises WEBAUTHN_COUNTER_REGRESSED
    (CRITICAL). Zero means "not implemented" — true of most platform passkeys and
    everything that syncs — and is accepted, because refusing it would switch the
    control off for the hardware people actually have.

The library is imported lazily, so a deployment that pulls new code without
reinstalling loses the key option rather than the console.

Existing invariants caught three things on the way in: the new routes were not
declared in the auth-wiring list, WEBAUTHN_COUNTER_REGRESSED was raised
before it was defined, and webauthn was in requirements.txt but not the lock.
The lock was regenerated inside the image; cryptography moved 43 → 50 and the
whole suite was run against it before the pin was added.

base64url is not base64

frontend/src/lib/webauthn.ts has its own test file because this is where
WebAuthn fails intermittently. - and _ stand in for + and /, padding is
dropped, and atob accepts neither substitution. The two alphabets agree until
a byte lands on one of the four differing characters — so an implementation
that forgets to translate passes a demo, passes review, and then throws
InvalidCharacterError for one operator in three, naming nothing.


3. The console (C2)

All eight remaining pages moved onto the shared kit: 460 inline style objects
→ 171
, and eight hand-rolled modal overlays → the kit's Modal.

The styling was the smaller half.

  • Every one of the eight swallowed its load errors — console.error and an
    empty list, or no catch at all. The consequences were not equal. An empty
    Identity & Access reads as "nobody has access". Blank AdminConfig forms read
    as "nothing is configured" and those forms save, so a working SMTP or
    LDAP configuration could be overwritten with a guess. A freshly issued
    enrolment token showed as "No enrollment tokens yet", whose natural response
    is to issue another.
  • Playbooks reported a failed run to the browser console only. That page
    runs steps its own editor labels "cannot be undone from the console". A run
    that never reached the server and one that failed on the host looked
    identical — and the natural response to the first, press it again, is the
    worst response to the second.
  • AIAnalysis had a hardcoded green health light. "RabbitMQ Worker Active",
    rendered unconditionally with no request behind it, green while the worker
    was down. Removed.
  • Deployment lost tokens silently. The page says "copy the command now, it
    will not be shown again"; navigator.clipboard only exists in a secure
    context, so over plain http the copy did nothing and the button still said
    "Copied".
  • Four private copies of the severity-to-colour map collapsed into Badge.
  • The eight modals had three corner radii, two overlay opacities and
    zIndex 100/1000/9999 between them. The kit's Modal closes on Escape and
    on the backdrop — neither was true anywhere before, and the delete
    confirmations had no Cancel button. It portals to document.body, because
    position: fixed resolves against the nearest containing block and a dialog
    written inside a card comes out half off-screen; AgentDetail hit exactly that
    and carries a comment about it.
  • Pages were overriding the complete input, select, textarea rule in
    index.css with their own, at a different radius — two of them re-implementing
    :focus by hand in onFocus/onBlur.

4. Sentora/docs/MODULES.md

Nothing checked this file, and it had drifted badly.

  • Three table names that do not exist: installed_software,
    resource_log, disk_info. The real ones are software_inventory,
    resource_usage, disk_usage.
  • edr_enforcer's entry was wrong end to end. Documented as a hash-baseline
    scan writing security_audit; it is the agent's main periodic collector and
    writes fim_data, network_connections, process_events,
    hardware_inventory and registry_logs. Four of those appeared nowhere.
  • security_audit, in full. Three modules were documented as writing it.
    None ever has — nothing in the agent has. The document promised it, the
    schema created it, TABLES shipped it empty every cycle, and the console
    showed NOT COLLECTED for the life of every install: a sensor that reads as
    broken rather than as never built. The other two actually write events_alert
    through send_alert().

tests/test_module_reference.py now holds the line in both directions: every
documented output table must exist in the schema, and every table the agent
ships must be named somewhere.

While correcting the paragraph about scrub_nuls, a real NUL byte got into
the file from an escape that collapsed one layer too far — enough to make
grep treat it as binary and go quiet. Fixed, with a test.


5. windowsEvent.ts, found by running npm test

The frontend suite was already red.

parseEventMessage's field pattern is anchored at the start of a line, but the
agent builds its message as summary | inserts on one line, so the first field
arrives after the headline separated by a tab:

[PowerShell] EID=403, Cat=4 | Stopped | Available | \tNewEngineState=Stopped

Every event of that shape lost its first field — the one that says what
happened — from the detail view. The test for it existed and was failing.

The same function trimmed with (\s*\|\s*)+$: a quantifier inside a quantifier
with whitespace ambiguous between adjacent repetitions, which backtracks
exponentially on a line ending in a long run of spaces. This runs in the
operator's browser over text an attacker writes into a log. [\s|]+$ is still
super-linear — an anchored trailing match retries from every position on
failure, and JavaScript has no possessive quantifiers — so it is a one-pass
scan now.


Testing

2601 passed, 5 skipped (Python) · 42 pass, 0 fail (frontend) · typecheck and
build clean.

Verified against the running deployment, not only in tests:

before after
portscan_result 108 rows, newest 3 days old 164, newest today
rows with target_ip / state 0 / 0 56 / 56
software_inventory on the server 0 123,335
network_inventory on the server 0 52,794
A string literal cannot contain NUL 14 in the log 0

/api/2fa/webauthn/capability answers 401 unauthenticated rather than 500, the
agent is connected over wss://, and there are no tracebacks at startup.

…nsole kit

Derive the WebAuthn relying party from the request so there is nothing to configure, say why a key cannot be used where it cannot, add TLS_SAN and report a certificate that predates the configured names, move the last eight pages onto the shared kit with real loading and failure states, correct the agent module reference, and fix the event parser that dropped every PowerShell 403's first field.
@d3vhex d3vhex self-assigned this Sep 10, 2026
@d3vhex
d3vhex merged commit d46e926 into main Sep 10, 2026
11 checks passed
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