# Atrium at `id`: security assessment

Assessed 7 November 2026 (plan CL, slice CL9), against the code on `develop` after CL8. Plan IA §11 asks for this before
`id`'s first deployment: one host now signs people in to every product, so one breach of it reaches all of them. A dated
record: re-assess before the first deployment of `id`, and at each major release. The foundation's own assessment
(`SECURITY_ASSESSMENT.md`, 25 October 2026) still stands for what it covers; this one covers what Clavis added.

**Method.** The code was read, slice by slice: the sign-in at `id` (CL2), the products' sign-in (CL3), joining (CL4), web
addresses (CL5), the account's pages and claiming (CL6), passkeys, Google and Microsoft (CL7), and the products' API (CL8).
`php artisan route:list` for each route and its middleware, `composer audit` and `npm audit --omit=dev` (both clean on
7 November), and the full suite (954 passing before this slice's own fixes). A score is earned by code that was read, not by
a plan. Where a claim rests on a test, the test is named.

**Verdict.** **Not ready for `id`'s first deployment yet.** Sign-in itself is strong for what it is (codes, limits,
pause, bot check, OIDC with PKCE and signed tokens), but plan IA §6's rules for keeping `id` safe with everything on it
are only partly met: there is no Content Security Policy, uploads are served from `id`, and changing an organisation's
addresses, products or people asks for no fresh check. Those are Priority 1 below. One weakness was found and fixed in
this slice: Microsoft's e-mail claim was trusted.

**Rescored 8 November 2026 (plan SEC, slices SEC1 to SEC3).** Priority 1 is done: a Content Security Policy with nonces on
every page, files sandboxed and a files host ready to switch on, and a fresh proof before changing an organisation's
addresses, products or people. Web hardening goes from 4 to 7 and the account pages from 6 to 8; the overall from 6.6 to
7.6, past the 7 that production needs, with no critical absence left. **`id` is ready for its first deployment** on the
conditions in "Before the first deployment" below (the policy report-only for its first week, then enforced). The sections
below say what changed; Priorities 2 and 3 stay open.

---

## Scoring scale

As the foundation's: 1–3 unsafe for production with personal data; 4–6 staging or a friendly pilot; 7–8 production-ready
with documented caveats; 9–10 enterprise-grade with formal compliance.

---

## 1. Sign-in at `id`: **8 / 10**

**In place.**
- One field reads a phone, an e-mail or a username by shape alone, and never looks the account up, so the first step
  answers the same for an account that exists and one that doesn't (`SignInController::identify`,
  `SignInName`; `SignInTest` "reads the first step without looking the account up").
- Codes: six digits from `random_int`, an HMAC kept and never the code, 10 minutes, once, five tries; waits of 30 s, 60 s
  and 2 minutes; 5 an hour and 10 a day per number, 10 an hour per address, 3 to a range's other numbers in 10 minutes;
  ten wrong codes in an hour pause a number for 1, 2 then 4 hours, counted in the database under a row lock
  (`SignInCodes`, `sign_in_pause`; `SignInTest`). Campus's two faults (a pause that never tripped, a range rule that
  paused people resending) are fixed.
- A number with no account gets the same page and nothing is sent; texted codes are blanked in Nuntius's log
  (`SignInTest` "texts a code…", "gives a number with no account…").
- A second code from one address within the hour needs the bot check: a Turnstile token if sent must verify, else a
  signed, expiring, single-use proof-of-work puzzle (`BotCheck`; `SignInTest`).
- Wrong passwords lock the account whichever name was typed; a locked account can't sign in by code, passkey or provider
  either; an enrolled authenticator is always asked, after a code or a password; an account with a password quiet for
  90 days gives it after a code (`SignInTest`, `PasskeyTest`, `ProviderSignInTest`).
- The cost brake holds codes to new numbers by hand or in a burst, and never a number that already signs in
  (`SignInWatch`; `JoinTest`).

**Caveats.**
- A stolen SIM opens anything a code alone opens. Products are meant to ask for more (`acr_values=password`) for staff
  pages (CL-5); a product that doesn't, gives a SIM thief a staff session. There is no SIM-swap check (Campus's has no
  provider behind it either).
- Turnstile has no keys yet, so the bot check is the puzzle alone, which slows a script but doesn't stop a determined one.
- The first step is limited per name and address; a botnet spread over many addresses meets the per-number limits and the
  brake, not the address limit.

## 2. Products' sign-in (OIDC): **8 / 10**

**In place.**
- Passport with only the code grant and its refresh; client credentials, password, implicit and device grants are off
  (`OidcServiceProvider::registerAuthorizationServer`; `OidcTest` "offers no grant but the code").
- PKCE with S256 required of every client, confidential ones included (`OidcAuthorise`; `OidcTest` Q5).
- Return addresses are worked out at each request from the product's live instances and their verified addresses, at
  the client's callback path, matched exactly; an ended instance or an unverified address stops at once (`ProductClient`;
  `OidcTest` Q4, `AddressesTest`).
- Tokens are RS256 with a key ID from the key itself, name the account by its `acc_…` ID never the row, and are short:
  access 15 minutes, `id_token` 5 minutes, code 10 minutes (`IdTokenResponse`, `AccountAccessToken`; `OidcTest` Q1–Q3
  verify the signature against the published key).
- The token says how the sign-in was proved (`acr`, `amr`, `auth_time`) and carries the `nonce`; a product asking for the
  password gets that alone, and the step-up page names the product (`OidcTest` Q7).
- Sign-out returns only to the product's own hosts and tells every product the session signed into, and no other
  (`OidcSignOut`, `ProductSessions`; `OidcTest` Q9). A 15-minute recheck catches a missed webhook (`ended_session`;
  `AccountApiTest`).
- The Service can't sign in to a product through `id` (CL-14; `AccountApiTest`).

**Caveats.**
- Missed on 7 November, found and fixed by the platform assessment (`SECURITY_ASSESSMENT_ATRIUM.md` F7, 8 November): a
  cross-site GET to the sign-out signed anyone out of `id` and every product. It now asks first unless the product shows
  that account's ID token.
- The JWKS publishes one key. Rotating it invalidates every token signed with the old one at once, and products caching
  the old key fail until they refetch. Rotation needs both keys published for a while.
- Refresh tokens last 30 days; ending a membership or signing out everywhere doesn't revoke them, so a product must act
  on the webhooks or the recheck. Revoking a removed person's refresh tokens at Atrium would close it.

## 3. Account and organisation pages: **8 / 10** (6 on 7 November)

**In place.**
- One verified identifier on one account, enforced by the database (`account_identifier_verified_once`). A number on
  another account is said so and sent nothing; removing never takes the last way in (`SignInMethodsTest`).
- Claiming a school-made account needs a code to a number of the holder's own; folding it into an existing account needs
  a code that proves that account theirs, and the holder acts as the account they keep (`ClaimTest`). An organisation may
  reset a school-made account only while unclaimed, and now leaves an audit line when it does.
- A first password needs a sign-in within 15 minutes; adding a passkey needs the same or a confirmed password
  (`RequireFreshSignIn`; `PasskeyTest`).
- Products learn only what an account shares, and only about their own organisation's people (`AccountApiTest`).

- **Fixed 8 November (SEC3).** Adding or removing a web address, starting a product, and adding, removing, deactivating or
  inviting people (one at a time or by import) ask for a sign-in within 15 minutes or the step-up window
  (`RequireFreshSignIn`, `fresh`), in the dialog over the page as the password's step-up is. An account with no password
  confirms by a code to one of its own verified numbers or addresses, counted by the sign-in codes' rules and logged as
  `confirmed_by_code` (`StepUpCodeController`; `Auth/FreshCheckTest`).

**Gaps.**
- Signing out everywhere asks for no confirmation of identity; it only takes access away, so the risk is nuisance, not
  takeover.

## 4. Federated sign-in (Google, Microsoft, passkeys): **8 / 10**

**In place.**
- Google signs in only by an address Google marks verified that an account already holds verified, and links it; nothing
  ever makes an account; an identity links one account, and a second identity of the same provider is refused, never
  swapped (`ProviderSignIn`; `ProviderSignInTest`).
- **Fixed in this slice:** Microsoft's e-mail claim can be set, unverified, by any organisation's own directory admin
  ("nOAuth"), so matching on it let a stranger's tenant take over an account by its e-mail. Microsoft now signs in only
  through a link the account made itself while signed in (`ProviderSignIn::verified`; `ProviderSignInTest`).
- Passkeys never sign in the Service or a locked account, and count as more than a code (`PasskeyTest`).

**Caveats.** Google and Microsoft have no keys yet, so they aren't offered. A linked Google account that its owner loses
is a way in until unlinked.

## 5. Web hardening on `id`: **7 / 10** (4 on 7 November)

**In place.** `nosniff`, `X-Frame-Options: SAMEORIGIN`, `Referrer-Policy`, HSTS in production; a host-only, `__Host-`
prefixed, secure session cookie in the production environment (`deploy/production.env.example`); CSRF; the staff
console refused on `id` (`EnforceHost`); the OIDC and discovery routes refused anywhere but `id`.

**Fixed 8 November (SEC1, SEC2).**
- A Content Security Policy on every page, with a fresh nonce a response: no inline script runs without it, no `eval`,
  no plugin, no `<base>`, no framing by another site; Turnstile's origin allowed on the bot check's pages alone
  (`ContentSecurityPolicy`; `Operations/PagePolicyTest`). Every inline script and style the shell writes carries the nonce,
  and the whole app was walked in the browser with none refused. `CSP_MODE=report` lets the first deployment watch before
  it blocks; reports are logged by directive, origin and path.
- Every file is served sandboxed (an origin of its own, nothing allowed but itself) and only same-site may embed it; PDFs
  download on the app's hosts. `HOST_FILES` moves files to a host with no session by a signed, five-minute address
  (`FileResponse`, `SignedFileController`; `Files/FileRouteTest`).
- `Permissions-Policy`, `Cross-Origin-Opener-Policy: same-origin`, proxies trusted only from `TRUSTED_PROXIES`, and
  readiness failing without an encrypted session.

**Gaps.**
- `style-src-attr 'unsafe-inline'`: `style` attributes are allowed, since Vue's static trees write them. An injected
  attribute can't run script or load anything, but it can restyle the page (a phishing overlay needs an injection the
  script rules don't stop).
- The files host waits on DNS; until then files share the session's origin, sandboxed.
- No Subresource Integrity on the built assets (they come from the same origin, so a tampered asset means the server is
  already lost).

## 6. Personal data and children: **6 / 10**

**In place.** Codes, ended sessions, passkeys, links and product tokens are covered by the privacy erasure (`SignInPart`);
codes are purged after 30 days and ended sessions after a day; products get an account's shared details only; a second
phone or e-mail stays private unless shared (CL-17).

**Resolved, 7 November 2026.** The Architect decided Clavis holds no children's accounts (CL-13 revised): pupils stay in
the products, where children's data is cleared. Atrium's organisation-made accounts are for adults with no phone or
e-mail of their own. Atrium doesn't know ages, so this rests on the products not sending pupils to it.

## 7. Supply chain: **7 / 10**

`composer audit` and `npm audit --omit=dev` found nothing on 7 November. New this module: Passport 13.10 (with
`league/oauth2-server` and `lcobucci/jwt`), Socialite with the Microsoft provider, laravel/passkeys (through Fortify) and
`@laravel/passkeys` 0.2.0, each pinned in the lock files. No CI runs these audits yet (the foundation's Priority 5). Both
audits clean again on 8 November; SEC added no dependency.

---

## Weighted overall: **7.6 / 10** (6.6 on 7 November)

Weights follow what a breach of `id` would cost: sign-in 20%, products' sign-in 20%, web hardening 20%, account pages 15%,
personal data 10%, federated sign-in 10%, supply chain 5%. Production-ready needs 7 or more with no critical absence;
on 7 November web hardening at 4 was the critical absence, and SEC closed it.

---

## Before the first deployment of `id`

### Priority 1: IA §6's rules (web hardening 4 → 7, account pages 6 → 8): **done 8 November 2026 (plan SEC)**
For the first deployment: `CSP_MODE=report` and `SESSION_ENCRYPT=true` (the production example's), `csp.violation` lines read
for a week of real use, then `CSP_MODE=enforce`; `HOST_FILES` set once its DNS resolves.
1. A strict Content Security Policy on `id`: report-only first, with nonces for Vite and Inertia, then enforced; Turnstile's
   origin the only third-party one, on the sign-in and sign-up pages only.
2. Uploads off `id`: serve files from their own host (or as attachments with `Content-Security-Policy: sandbox` until it
   exists), logos included.
3. A fresh check for what matters: web addresses, products and people at `id` ask for a sign-in within 15 minutes or a
   confirmed password (`RequireFreshSignIn`, extended to answer Inertia pages).
4. ~~Legal advice on children's data~~: resolved, 7 November 2026; Clavis holds no children's accounts.

### Priority 2: tokens (products' sign-in 8 → 9)
5. Key rotation with both keys published for a period, and a command that does it.
6. Revoke a person's refresh tokens at Atrium when their membership ends or they sign out everywhere.

### Priority 3: keys and checks
7. Turnstile, Google and Microsoft keys from the Architect.
8. Audits in CI (the foundation's Priority 5).

## Tracking

| Item | State |
|---|---|
| Microsoft's e-mail claim trusted | Fixed 7 November 2026 (CL9) |
| An organisation's reset of a school-made account unaudited | Fixed 7 November 2026 (CL9) |
| Priority 1 above | Done 8 November 2026 (plan SEC, SEC1 to SEC3) |
| Priority 2–3 above | Open |
