Security notes
What this service protects, what it does not, and the decisions that are easy to undo by
accident.
The thing being protected
A one-time code, and the records that say where it goes. EndpointTrust is not in the password
path: Windows validates the AD password. EndpointTrust decides whether a second factor is
required, delivers it, verifies it, and signs the answer.
So the attacks that matter are the ones that redirect a code, skip a code, or
forge an answer.
Redirecting a code
POST /v1/auth/sms has no destination field. The destination is read from
phone_numbers, where a partial unique index guarantees exactly one live row per person.
A compromised endpoint has no request it can make that names a number.
The one relaxation is agent pairing, which is off by default and is documented in full in
Administrators can change a number, which is the legitimate path, and the old number is
retired rather than deleted so the change is visible afterwards.
Skipping a code
Three ways a sign-in can legitimately proceed without one, and all three are counted and
shown prominently on the overview page:
- Grace — password-only authorisation when the endpoint cannot reach this service,
bounded by a signed grant. Never an offline code check.
- Local recovery — named local accounts, off by default.
- Tenant pass-through — the policy says not to enforce, with a mandatory deadline. The
configuration validator refuses pass_through with no deadline and refuses a deadline
under enforce, so a stale bypass cannot survive a mode change.
A fourth is not a bypass but looks like one in a report: an unprotected path. The
coverage field on every audit event says which it was.
Forging an answer
Every document the endpoint acts on is a compact JWS signed ES256 by the tenant's own key,
and the endpoint holds only that tenant's public key. Decisions are bound to the attempt,
the device, the session and the policy version — a signature alone is not enough, because
the same key legitimately signs other documents.
Secrets, and where they are
| Secret | Stored as | Recoverable? |
|---|---|---|
| Administrator password | scrypt, N=2^17 | No |
| Admin session token | SHA-256 of the cookie | No — a database dump does not hand over live sessions |
| Device bearer credential | SHA-256 | No — returned once, at activation |
| Enrollment credential | SHA-256 + a 6-character hint | No — shown once |
| One-time code | SHA-256 | No — which is why a test has to read it off the wire |
| Tenant signing key (private) | AES-256-GCM under MASTER_KEY | Yes, with the key |
| Tenant SMS credentials | AES-256-GCM under MASTER_KEY | Yes, with the key — they must be replayed to a third party |
MASTER_KEY is in the environment, never in the database. **A database dump alone cannot
sign a licence or read a tenant's SMS credentials.**
Things that are never written down
Per the endpoint's own rules, and enforced on this side too:
- No AD password, ever, anywhere.
- No OTP in a log, an audit record, a trace or an error message. It exists in its TLS
request and in transient memory.
- No full phone number outside
phone_numbers. Messages, logs and reports carry a mask,
applied at write time rather than at read time.
- No device credential, grant or Windows credential serialisation in any log.
The console's own surface
- Escaping is the default.
apps/dashboard/src/html.tshas no "trust me" path that
takes a string. Company names, hostnames and usernames are attacker-influenced text
rendered next to live controls, and two tests assert it holds.
- No script at all. The CSP is
default-src 'none'with noscript-src. The console
has no client-side JavaScript, so the strictest possible policy costs nothing.
- No inline style either.
style-src 'self'with no'unsafe-inline', which forbids
style="…" attributes outright. Every dynamic dimension in the UI — bar widths, column
heights — is SVG geometry instead, and a test walks every page asserting that not one
inline style attribute exists. The day somebody adds one it will silently fail to
render, so the test is what catches it.
- CSRF is a double-submit token checked in
readForm, for every form, so a route cannot
be added without it. The session cookie is SameSite=Lax as well.
- Roles are enforced on routes, not by hiding buttons. A
viewerposting a form gets a
403.
- Tenant scoping is on every query. A user id or a device id from another organisation
is a 404, and there is a test that uses a real id from a real other tenant.
- No open redirect on the login form: only a path beginning
/appis followed. - Account enumeration: a wrong password and an unknown address return the same body and
do the same work — the password is verified against a dummy hash when the address is
unknown.
SSRF
The http SMS provider posts to a URL the tenant chooses. sms/guards.ts resolves the
host, checks the resolved address, and connects to that address, so a DNS record that
changes between the check and the connection does not help. Loopback, link-local,
169.254.169.254 and private ranges are refused, at save time and at send time.
Entitlement
Only a signature-verified Stripe webhook writes subscriptions.seats. Events are applied
at most once (Stripe's event id is a primary key). A failure answers 500 so Stripe retries.
A seat reduction never releases anybody's seat — choosing which person loses their
second factor is not a decision a webhook handler makes silently, and doing it there would
let a billing event unprotect a machine.
A failed payment does not immediately revoke a licence. past_due still entitles,
because a card that bounced must not lock an organisation out of its own computers while
Stripe spends days on dunning.
Known limits
- Pre-NLA. Target-side MFA cannot block the RDP network-level authentication exchange.
Nothing in this product changes that.
- Session audit. A credential provider does not observe session ends. There is no
logoff data and no "signed in right now" figure, anywhere, on purpose.
- Administrator-registered numbers are trusted on sight. The tenant administrator is
the verification in this design. EndpointTrust has no HR record to check against.
- One administrator password protects a whole organisation's logon policy. There is no
second factor on the console itself yet. That is the most obvious next piece of work in
this repository.
- No email. No invitations, no password resets, no verification links. An owner creates
a colleague's account and hands over a generated password shown once. That is a deliberate
trade — an invitation flow needs a mail sender, a signed token, an expiry and a reset
path, and each is another way into an organisation's logon policy — but it does mean a
forgotten administrator password is an operator's problem, not a self-service one.