What the Sonar Quiz System (BrowserTest) actually does today, with the file that does it.
This page replaces the five generic SECURITY_*.md files from November 2025, which are
archived unchanged under docs/history/ for reference only; they describe proposals, not
the code.
Everything below was checked against the source on the date of writing. Where the code falls short of what a name or comment promises, that is stated.
The bundle runs from file:// URLs on air-gapped classroom machines. There is no server, no
network I/O, and no telemetry. All state lives in the student's browser (IndexedDB +
sessionStorage) on a machine the student physically controls.
Consequences:
- Nothing in this system is secret from a determined user of the same machine. Every control is client-side and can be bypassed by anyone willing to open DevTools, edit the HTML, or read the bundle. The controls exist to stop casual cheating and accidental disclosure.
- The assets to protect, in order: correct quiz answers (visible to instructors only), other students' answers and names, and the instructor role (which unlocks all of the above, CSV export, PIN reset, and erase-all).
- Realistic attackers: a curious student on a shared machine; a student who has seen the instructor password typed; a student who reads the published HTML from disk. Nation-state, supply-chain, and network attackers are out of scope by construction.
Runtime configuration is read from hidden <span> elements that the Oxygen WebHelp transform
emits into every page (dita/template/xslt/inc/customHeader.xsl, lines 22–40). The reader is
src/config/dom-config-reader.ts (CONFIG_IDS):
| Span id | Purpose | Security relevance |
|---|---|---|
#qd-instructor-hash |
Instructor password hash | The only credential in the system; visible in page source |
#qd-db-name |
IndexedDB database name (required) | Namespaces all stored data |
#qd-title-selector |
Selector for the publication title | The title is the Release ID, which keys storage and derives the obfuscation key |
#qd-status-container |
Where to mount the UI | none |
Live path: src/components/qd-instructor-login.ts → src/services/auth/instructor-auth.ts.
- The plaintext is hashed with
crypto.subtle.digest('SHA-256'), hex-encoded, and truncated to the first 12 hex characters (48 bits) so authors can paste it into an Oxygen dialog (hashPassword). Since SRD 013 (SEC-11) a publication may instead configure the full 64-hex digest (hashPasswordFull), whichverifyInstructorPasswordthen compares in full. - The expected value is the text of
#qd-instructor-hash, read at login time (getExpectedInstructorHash). If the span is missing or empty, instructor login is refused. - Comparison uses the HMAC-based
constantTimeComparefromsrc/utils/security.ts(verifyInstructorPassword), so verification time does not leak where the hashes differ. - Failed attempts are rate limited. Both instructor paths use a
RateLimiterwith the same allowance as the student PIN (PIN_CONSTANTS.MAX_ATTEMPTS): two wrong passwords are free, the third starts an exponential lockout of 2/4/8/16/30 s. A correct password resets the counter. - On success it creates a session with
serviceId: 'INSTRUCTOR'and stores the password the instructor typed insessionStorage['qd/instructor'](startInstructorSessioninsrc/services/auth/instructor-session.ts). On every page load, bootstrap callsrestoreInstructorSession(), which re-hashes the stored value and compares it with#qd-instructor-hashbefore any component asksisInstructor(). A value that does not verify is removed, along with any instructorqd/session(SRD 013 SEC-01, SEC-02). - Until October 2026 the stored value was the string
'true', and a logged-out student could set it from DevTools, get the toolbar and answer key, and erase every student record. The review reproduced this;tests/unit/services/auth/instructor-session.test.tsandtests/unit/init/bootstrap.test.tsnow start from that reproduction. - Trade-off: while an instructor is logged in, their password sits in that tab's sessionStorage, readable in DevTools by anyone at the keyboard. It is cleared on logout, on the 10-minute idle timeout and when the tab closes. Instructors should log out before leaving the machine. Forging the value without the password needs a preimage of the configured hash.
- Destructive actions ask again (SEC-03). "Erase All Data" and "Reset PIN" each need the
instructor password typed at that moment, checked against
#qd-instructor-hash(not against the stored value) and rate limited with the same policy (confirmInstructorPassword). Export CSV deliberately does not ask again (owner decision, SRD 013 Q3).
A second entry point exists: src/components/qd-instructor/qd-instructor-unlock.ts, the
toolbar unlock form. It now verifies through the same instructor-auth.ts helpers and the same
rate-limiting policy, so the two paths cannot disagree. It is in practice unreachable in DITA
output (<qd-instructor> renders the unlock form only while unlocked is false, but the element
is shown only when isInstructor() is already true), so the login modal is the path that matters.
Two defects here were fixed during the September 2026 consolidation: the component read a
different span, #instructor.password.hash, that the XSL never emitted (its reader,
src/config/instructor-password.ts, has been deleted), and it never recorded failed attempts,
so its rate limiter never engaged.
Spec: specs/004-student-pin-auth/spec.md. Code: src/services/auth/auth-service.ts,
src/services/auth/pin-service.ts, src/services/auth/rate-limiter.ts,
src/types/contracts.ts (PIN_CONSTANTS).
- PIN is exactly 4 digits (
PIN_LENGTH: 4,validatePinFormat). - Stored (since SRD 013 SEC-06) as PBKDF2-SHA-256 with a random 16-byte salt per student and
600,000 iterations (
createPinCredential), inStudentRecord.pinHashwithpinSalt,pinIterationsandpinKdf: 'pbkdf2-sha256'. One derivation takes roughly 130 ms in Chromium on a fast machine, so trying all 10,000 PINs for one student takes on the order of 20 minutes rather than milliseconds, and the salt prevents one table covering every student. This raises the effort; it does not make a 4-digit PIN secret from someone holding the database. - Records written before SRD 013 hold an unsalted SHA-256 digest. They still verify
(
verifyStoredPin), and the first successful login replaces the digest with a salted one and stamps the record schema 3 (SEC-07).needsMigration()compares withPIN_SCHEMA_VERSION(2), so the schema bump never sends PIN-bearing records back through PIN setup. - Verification uses a constant-time XOR compare over the two hex strings
(
pin-service.tsconstantTimeCompare). - Lockout (
rate-limiter.ts):MAX_ATTEMPTS: 3, thenLOCKOUT_MS: 30 * 1000(30 s). Attempt state is stored insessionStorageunderqd:pin-attempts:{serviceId}, so it is per tab and disappears when the tab closes or sessionStorage is cleared. Successful login clears it. - Identity is claimed, not verified: the first login for an unknown
serviceIdcreates the record and sets the PIN (auth-service.ts, "New student" branch). Anyone who knows a service ID before its owner logs in owns that record. - Instructor PIN reset (
src/services/pin-reset-service.ts,resetPininsrc/services/storage/migration.ts) setspinHash: ''and appends aPinResetEventto theauditLogobject store. The next login for that ID, by whoever presents it, sets a new PIN (!hasPinSet(existingStudent)branch). Records from schema versions before PINs behave the same way on first login after upgrade.
Constitution VIII in CLAUDE.md; spec specs/010-css-answer-hiding/spec.md. Three layers, of
unequal strength:
- CSS, before JavaScript runs (
dita/template/f13ldman.csslines 583–600): columns 2 and 3 of.qd-quiz(tdandth) getvisibility: hidden, with.qd-quiz-interactiveand.qd-quiz-instructoroverrides. This is purely cosmetic: the text is in the page and in view-source. - DOM blanking of the Answer column (column index 1) —
hideAnswerColumninsrc/enhancers/quiz-table-columns.ts. Order insrc/enhancers/quiz-table.tsenhanceQuizTable:parseQuizTable(src/services/quiz-parser.tsreadscells[1].textContentintocorrectAnswer) → store in a module-levelWeakMap→ set eachtbody td[1].textContent = ''and addqd-hidden(display:none !important, defined insrc/init/global-styles.ts). After this the correct answer exists only in JS memory. - DOM blanking of the Detail column (index 2) —
hideDetailColumnin the same module. Since September 2026 it also removes the cell content (MCQ option list or numeric tolerance), holding the original markup in aWeakMapkeyed by cell sorestoreDetailColumncan put it back on the instructor reveal path. Before that it only added a CSS class, so the tolerance was readable in DevTools.
Instructor reveal (src/enhancers/instructor-answer-reveal.ts) is the single place that writes
correctAnswer back into the DOM; it runs on qd:login with role: 'instructor' and on page
load when qd/instructor is 'true'.
- Encoding at publish time (SRD 013 WP3, October 2026). Until then the published
.htmlfiles held every answer in plain text, and WebHelp's full-text search showed them: searching "beam width" returned the snippet "…sonar? 15 2", the answer and its tolerance.dita/template/xslt/inc/quizAnswerEncoding.xslnow writes each Answer cell, and each Detail cell holding only a numeric tolerance, asqd1:plus a letter substitution keyed by theqd-answer-keypublishing parameter (13 capital letters; a default applies if unset). The build stops on an answer it cannot encode.customHeader.xslpublishes the key in#qd-answer-key;src/services/answer-encoding.tsdecodes, and a table with encoded values but no usable key is left un-enhanced. Plain tables from older publications still work. MCQ option lists stay plain, since students see them anyway. - Search removed. The template no longer renders a search box or search results. WebHelp
still builds
oxygen-webhelp/app/search/index/, but from the published pages, so it holds only encoded answers.npm run check:answer-leak -- <output>(a developer check; authors cannot run commands) fails on any plaintext answer and notes a leftover index.
What this does not do: the key is in every page, so a student who reads the bundle or the
XSL can decode answers by hand, and a debugger on parseQuizTable sees them decoded. The
encoding stops a student reading answers at a glance in view-source or a text editor.
Spec: specs/009-encrypt-stored-data/spec.md. Code: src/services/storage/obfuscation.ts,
src/services/storage/idb-codec.ts, src/config/feature-flags.ts,
src/services/storage/obfuscation-migration.ts.
This is obfuscation, not encryption. The names ENCRYPT_STORAGE, build:encrypted, and
isEncryptionEnabled() are misleading and should be read as "obfuscate".
- Scheme (
encode):JSON.stringify→ UTF-8 bytes → XOR with a repeating key → base64 → prefixOBF:.decodereverses it and throws on bad base64/UTF-8/JSON. - Key (
deriveKey): the Release ID (the publication title text from the page) with each character replaced by its decimal char code, concatenated. The key is therefore derived from public text on every page and from nothing else. Anyone with the page and the bundle can decode every record in one line of JavaScript. - Switch:
ENCRYPT_STORAGEis a build-time constant injected by Vite (vite.config.tsdefine: { __ENCRYPT_STORAGE__ }from theENCRYPT_STORAGE=trueenv var;npm run build:encrypted). The source default isfalse, but since SRD 013 (SEC-08) the builds that ship are obfuscated:npm run build:dita(the bundle copied into the Oxygen template) and therelease.ymlasset.npm run build:dita:plainis for development and the plain-storage E2E job. It cannot be changed per deployment without rebuilding. - Scope: only values in the
studentsobject store go throughencodeForStore/decodeStoredValue.auditLogentries (service ID, timestamps) are always stored plain. Thebackupsstore would also be plain, butStorageService.backup()has no callers, so it is empty in practice. sessionStorage (session, R/A/G cache including per-page answers, instructor flag) is never obfuscated. - Format mismatch is fatal by design (FR-009): reading a plain record with the flag on, or
vice versa, throws
StorageFormatError; the login flow surfaces this asneeds-migrationand offersqd-migration-dialog, which runsmigrateObfuscationin place.
What it buys: a student opening DevTools → Application → IndexedDB sees OBF:... strings
instead of names and answers. What it does not buy: confidentiality against anyone who reads
this page.
- Keys:
qd/{release}/u{serviceId}(getStorageKeyinsrc/services/storage/adapter-utils.ts). Stores:students,backups,auditLog(src/services/storage/idb-connection.ts,DB_VERSION = 3). The database name comes from#qd-db-nameand is required. Isolation between releases is by key prefix inside one database, not by browser origin; any script that can open the database sees all releases. - Session:
SESSION_TIMEOUT_MS = 10 * 60 * 1000(src/types/contracts.ts; 30 minutes until SRD 013 SEC-09, shortened because students share one machine), extended on activity bySessionService.updateActivity(src/services/session.ts). The timer now also starts for a login made on the current page. Expiry writesqd:session-expired, fires aqd:session-expiredevent and logs out; the login form then says the session ended (SEC-10). Logout removesqd/session,qd/state,qd/instructor, andqd/instructor/showAnswersfrom sessionStorage. - Erase all:
src/components/qd-instructor/qd-instructor-manage.tsrequires the instructor to typeDELETE ALL DATAand to re-enter the instructor password (SEC-03), then callsStorageService.clearAll()→IndexedDBStorageAdapter.clearAll(src/services/storage/indexeddb.tsline 215), which clears all three object stores in one read-write transaction, and thenclearQuizData()(src/utils/storage-helpers.ts), which removes every sessionStorage key beginning withqd/orqd:— the latter covers PIN lockout entries (qd:pin-attempts:*), which were previously left behind. It does not delete the database itself or touch other open tabs. - Logging:
src/utils/logger.tsmasks service IDs (maskServiceId:RN2344→RN****) in log output. PINs and passwords are never logged.
Lit templates auto-escape bindings, and src/utils/dom-helpers.ts plus
src/enhancers/quiz-instructor-overlay.ts use textContent for student-supplied text. The
exceptions, verified by grep:
src/components/qd-help-popup.tsline 158 assignsinnerHTMLfromsrc/config/help-content.ts, which is static author-controlled markup — acceptable.src/components/qd-confirm-dialog.tsrenders.messagewithunsafeHTML, andsrc/components/qd-pin-reset-dialog.tsbuilds that message from the student's own name and service ID. Name validation (src/utils/validation-helpers.ts) checks only that the name is non-empty, so a student could register with markup and have it execute in the instructor's browser — stored XSS, same origin, able to read the database and setqd/instructor. Fixed in September 2026: both values now pass throughescapeHtml(src/utils/dom-helpers.ts, unit tested intests/unit/utils/dom-helpers.test.ts).unsafeHTMLremains in the dialog, so any new caller must escape its own interpolations.- CSV export (
src/components/qd-instructor/qd-instructor-export.tsescapeCSVField) quotes fields containing,,", or newline, and since September 2026 also prefixes a single quote to any field starting with=,+,-,@, tab or CR so a student name or free-text answer cannot become a live formula when the instructor opens the CSV in Excel or Sheets.
The login form asks storage whether a service ID already has an account, so it
can offer "Create" rather than a "Login" that cannot succeed
(AuthService.isRegistered). That answer is visible to anyone at the keyboard:
typing a service ID reveals whether it is registered for the current release.
This is accepted. The tool is an offline, single-machine classroom aid with no accounts worth enumerating remotely, and the alternative — leaving a first-time user staring at a disabled button — was a real usability failure. Anyone who can type a service ID into the form can already open DevTools and read the whole IndexedDB database, so the lookup discloses nothing that was otherwise protected.
- No network, no server, no accounts: nothing here authenticates a person, only a tab.
- No real cryptography at rest: SHA-256 is used for the instructor hash and PBKDF2 for PIN verifiers; there is no encryption and no key management.
- No integrity protection: IndexedDB and sessionStorage values can be edited freely; the only check is that obfuscated JSON still parses.
- No cross-tab messaging, no BroadcastChannel, no
.envfiles, no build-time secrets. The instructor hash is deliberately in the published HTML so authors can change it per publication without a rebuild (Constitution VII).
- The instructor session stores the instructor's password in that tab's sessionStorage while they are logged in (§3). Anyone at the keyboard of an unattended instructor tab can read it.
- Instructor password: by default a 48-bit truncated hash, printed in page source. Comparison is constant-time and attempts are rate limited, but a 48-bit hash in public HTML is brute-forcible offline; treat the password as a speed bump, not a secret. A publication can configure the full 64-hex hash instead.
- Student PINs are 4 digits. PBKDF2 with a per-student salt slows an offline guess to minutes per student, but cannot prevent it. Legacy unsalted hashes remain until each student next logs in. Lockout state is per tab and resets on reload of sessionStorage.
- Service IDs are self-asserted; the first person to log in with an ID owns it, including after an instructor reset.
- Answers in the published HTML are encoded with a key that is also in the page (§5). This stops reading at a glance, not a student who decodes by hand. Output published before the October 2026 template still holds plain answers and a search index until it is rebuilt.
- Storage "encryption" is XOR with a key derived from the visible page title (§6). It is on in shipped builds since SRD 013 but off in plain development builds. Audit-log entries are never obfuscated.
qd-confirm-dialogstill renders its message withunsafeHTML; the two known callers now escape their inputs, but the component itself is unsafe by default for future callers.ENCRYPT_STORAGEis fixed at build time; a deployment cannot switch it without a rebuild and a migration of existing data.
tests/unit/instructor-auth.test.ts, tests/unit/services/auth/instructor-session.test.ts,
tests/unit/components/qd-pin-reset-dialog.test.ts,
tests/unit/components/qd-instructor/qd-instructor-manage.test.ts,
tests/unit/services/auth/pin-service.test.ts, tests/unit/services/storage/migration.test.ts,
tests/unit/services/auth/rate-limiter.test.ts, tests/unit/auth-service.test.ts,
tests/unit/services/storage/obfuscation.test.ts,
tests/unit/services/storage/obfuscation-migration.test.ts,
tests/integration/storage/encrypted-storage.test.ts,
tests/integration/instructor-answer-reveal.test.ts, tests/unit/enhancers/ (quiz table
column hiding), and the E2E flows tests/e2e/workflows/pin-authentication.spec.ts (four cases
skipped unless ENCRYPT_STORAGE=true) and tests/e2e/encrypted-storage.spec.ts (all skipped
unless ENCRYPT_STORAGE=true). tests/unit/utils/security.test.ts tests the unreachable path
in §3.
Archived originals (Nov 2025, generic guidance, not project-specific):
docs/history/SECURITY_BEST_PRACTICES.md, SECURITY_IMPLEMENTATION_GUIDE.md,
SECURITY_TEST_EXAMPLES.md, SECURITY_README.md, SECURITY_QUICK_REFERENCE.md.