unknown

assessment

async function initializeAssignments() { addCss() // check to see if we have an id param, if so load that assessment const urlParams = new URLSearchParams(window.location.search); if(urlParams.has('id')){ // A "Begin Assessment" link can land here as the very first script on the // page, before system.js has defined `globals` - wait for it rather than // assuming it's already there (see waitForGlobals below). await waitForGlobals() globals.assessment.id = urlParams.get('id') getAssessmentJson(urlParams.get('id')) return } const {a, div, details, summary} = van.tags console.log("i'm initializing!") for(const el of document.querySelectorAll("div.assessment-sql")){ const data = JSON.parse(el.innerText) console.log("================================, data") console.log(data) el.replaceChildren( div({class:"assignment-pane", "data-assessment-id": data.assessment}, details({class:"assignment-details"}, summary({class:"assignment-header"}, data.name), div({class:"assignment-body"}, a({target: "_blank",class:"assignment-begin", href: assessmentHref(data.assessment)},"Begin Assessment")) ) ) ) el.classList.remove("hidden") } // get the assessment submission data for the current user console.log("about to get assessment submission for user") loadUserSubmissions() } // Depending on how this module got loaded (e.g. dev.js's local-dev // getSystemModule -> injectJs path), assessment.js's own script can start // running before system.js has finished defining `globals` - poll briefly // rather than assume it's already there. `typeof globals` is safe to read // even when `globals` was never declared at all (unlike `globals` itself, // which throws a ReferenceError). function waitForGlobals(timeoutMs = 3000){ return new Promise(resolve => { const start = Date.now() ;(function check(){ if(typeof globals !== "undefined"){ resolve(true); return } if(Date.now() - start >= timeoutMs){ console.warn("waitForGlobals - globals never became available, giving up") resolve(false) return } setTimeout(check, 50) })() }) } // globals.user is still its startup {} placeholder for a beat after page // scripts start running - system.js's getUserRecord() only fills it in once // its /api/auth/me fetch resolves. globals.userReady (see system.js) resolves // right after that first call settles, so await it here instead of checking // globals.user synchronously - otherwise this always sees the logged-out // placeholder no matter how fast the network actually responds. async function loadUserSubmissions(){ if(!(await waitForGlobals())){ return } if(globals.userReady){ await globals.userReady } if(!globals.user || !globals.user.id){ return } console.log("getting assessment submission for user") const options={ method: "GET", credentials: "include", headers: { Accept: "application/json", "X-Course-Id": sessionStorage.getItem("availabooks-course-id"), }, } fetch('https://app.availabooks.com/api/submissions', options) .then(response => { if (!response.ok) { throw new Error(`HTTP error! Status: ${response.status}`); } return response.json(); }) .then(data => { console.log('Success:', data); renderUserSubmissions(data); }) .catch(error => { console.error('Error fetching data:', error); }); } // GET /api/submissions returns {courseId, assessmentId, drafts: [...], completed: [...]}, // each completed record shaped {assessmentId, submittedAt, key, attempt, score} - score // as a 0-1 fraction, same as the {assessmentId, score, state} body sql.js's // submitGradeReport posts to /api/submissions/complete. Drafts aren't shown here yet. function renderUserSubmissions(data){ const submissions = Array.isArray(data?.completed) ? data.completed : [] if(!submissions.length){ return } const byAssessment = new Map() for(const sub of submissions){ if(!sub.assessmentId){ continue } if(!byAssessment.has(sub.assessmentId)){ byAssessment.set(sub.assessmentId, []) } byAssessment.get(sub.assessmentId).push(sub) } const {div} = van.tags for(const [assessmentId, subs] of byAssessment){ const pane = document.querySelector('div.assignment-pane[data-assessment-id="' + assessmentId + '"]') const body = pane && pane.querySelector('div.assignment-body') if(!body){ continue } // Most recent attempt first. subs.sort((a, b) => (parseSubmittedAt(b.submittedAt) ?? 0) - (parseSubmittedAt(a.submittedAt) ?? 0)) const beginButton = body.querySelector('a.assignment-begin') if(beginButton && subs.length){ beginButton.textContent = "Try Again" // sql.js reads this back off the URL and fetches /api/submissions/item // itself (same endpoint submissionRow's fetch below uses) to prefill the // student's prior SQL and feedback - no attempt-number lookup needed. beginButton.href = assessmentHref(assessmentId, subs[0].key) } body.appendChild(div({class:"assignment-submissions"}, ...subs.map(submissionRow))) } } // submittedAt looks like "2026-08-12T225153Z" - a date, then HHMMSS with no // colons, before the Z. new Date() won't parse that directly, so split it // apart and rebuild a normal ISO string. Returns null (not NaN) on anything // unrecognized, so callers can `??` a fallback. function parseSubmittedAt(value){ const match = typeof value === "string" && value.match(/^(\d{4}-\d{2}-\d{2})T(\d{2})(\d{2})(\d{2})Z$/) if(!match){ return null } const [, datePart, hh, mm, ss] = match const date = new Date(`${datePart}T${hh}:${mm}:${ss}Z`) return isNaN(date) ? null : date } // One submission as its own summary/details row - date and score in the // summary (always visible), detail fetched lazily. `loaded` makes sure that // fetch only happens the first time this particular row is opened, not on // every toggle. function submissionRow(sub){ const {details, summary, div} = van.tags const when = parseSubmittedAt(sub.submittedAt) const dateLabel = when ? when.toLocaleString() : "Unknown date" const scoreLabel = typeof sub.score === "number" ? `${Math.round(sub.score * 100)}%` : "Score unavailable" const body = div({class:"submission-body"}) const row = details({class:"submission-details", "data-key": sub.key}, summary({class:"submission-header"}, `${dateLabel} — ${scoreLabel}`), body ) let loaded = false row.addEventListener("toggle", () => { if(!row.open || loaded){ return } loaded = true body.textContent = "getting submission detail" const options={ credentials: "include", headers: { Accept: "application/json", "X-Course-Id": sessionStorage.getItem("availabooks-course-id"), }, } fetch(`https://app.availabooks.com/api/submissions/item?key=${encodeURIComponent(sub.key)}`, options) .then(response => { if (!response.ok) { throw new Error(`HTTP error! Status: ${response.status}`); } return response.json(); }) .then(data => { renderSubmissionDetail(body, data) }) .catch(error => { console.error('Error fetching submission detail:', error); body.textContent = "Error loading submission detail" }); }) return row } // Renders the /api/submissions/item payload for one attempt. `state` is // whatever shape the render engine that graded this attempt saved // (sql.js's sqlSubmissionState() or a-quiz.js's buildState()-equivalent) - // switch on state.type and fall back to raw JSON for anything else so a // new/unrecognized assessment type never shows a blank pane. function renderSubmissionDetail(body, data){ const {div} = van.tags body.replaceChildren() // /api/submissions/item nests the render engine's saved state under // `submission.state` ({kind, assessmentId, submittedAt, key, submission: // {score, state}}), not at the top level. const state = data && data.submission && data.submission.state const answers = Array.isArray(state?.answers) ? state.answers : null if(!answers){ body.appendChild(div({class:"submission-answer"}, JSON.stringify(data, null, 2))) return } const renderAnswer = state.type === "sql" ? renderSqlAnswer : state.type === "a-quiz" ? renderQuizAnswer : renderUnknownAnswer if(typeof state.correct === "number" && typeof state.total === "number"){ body.appendChild(div({class:"submission-detail-summary"}, `${state.correct} / ${state.total} correct`)) } body.appendChild(div({class:"submission-answers"}, ...answers.map(renderAnswer))) } function renderSqlAnswer(answer){ const {div, details, summary, pre, code} = van.tags const header = `Q${answer.index + 1}: ${answer.label} — ${answer.pointsEarned} / ${answer.points} points` + (answer.percent != null ? ` (${answer.percent}%)` : "") const children = [ div({class:"submission-answer-header"}, header), div({class:"submission-answer-prompt"}, answer.prompt), ] if(answer.feedback){ children.push(div({class:"submission-answer-feedback", innerHTML: answer.feedback})) } if(answer.studentSql){ children.push( details({class:"submission-answer-sql"}, summary({}, "Your SQL"), pre(code(answer.studentSql)) ) ) } return div({class:`submission-answer ${answer.credit || ""}`}, ...children) } function renderQuizAnswer(answer){ const {div} = van.tags const children = [ div({class:"submission-answer-header"}, `Q${answer.index + 1}: ${answer.correct ? "Correct" : "Incorrect"}`), div({class:"submission-answer-prompt"}, answer.prompt), div({class:"submission-answer-selection"}, `Your answer: ${answer.selectedKey ?? "(none)"}`), ] if(!answer.correct && answer.correctKey != null){ children.push(div({class:"submission-answer-selection"}, `Correct answer: ${answer.correctKey}`)) } return div({class:`submission-answer ${answer.correct ? "full-credit" : "no-credit"}`}, ...children) } function renderUnknownAnswer(answer){ const {div} = van.tags return div({class:"submission-answer"}, JSON.stringify(answer, null, 2)) } // Same collapsible summary/button pattern sql.js's sql-feedback-pane uses, // just a single medium-blue color instead of the credit-based palette - // the assignment title is the disclosure control and stays collapsed until // clicked, so "Begin Assessment" isn't visible on the page by default. function addCss(){ loadCss(` div.assignment-pane { margin-bottom: 1rem; border-radius: 6px; border: 1px solid #64b5f6; overflow: hidden; } summary.assignment-header { padding: 0.35rem 0.75rem; font-weight: 600; color: #fff; cursor: pointer; list-style: none; display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; background: #1976d2; } summary.assignment-header::marker { content: ""; } summary.assignment-header::-webkit-details-marker { display: none; } summary.assignment-header::after { content: ""; width: 0; height: 0; flex: none; border-left: 5px solid transparent; border-right: 5px solid transparent; border-top: 6px solid currentColor; transition: transform 0.15s ease; } details.assignment-details[open] > summary.assignment-header::after { transform: rotate(180deg); } div.assignment-body { padding: 0.5rem 0.75rem; display: flex; flex-direction: column; gap: 0.75rem; } a.assignment-begin { align-self: flex-end; display: inline-block; background: #001b88; color: #fff; border: none; border-radius: 4px; padding: 0.45rem 1rem; font-weight: 600; font-size: 0.9rem; text-decoration: none; cursor: pointer; transition: background 0.15s ease; } a.assignment-begin:hover { background: #125ea8; } div.assignment-submissions { margin-top: 0.75rem; display: flex; flex-direction: column; gap: 0.5rem; } details.submission-details { border: 1px solid #bbdefb; border-radius: 4px; overflow: hidden; } summary.submission-header { padding: 0.3rem 0.6rem; font-size: 0.9rem; font-weight: 500; color: #0d47a1; background: #e3f2fd; cursor: pointer; list-style: none; display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; } summary.submission-header::marker { content: ""; } summary.submission-header::-webkit-details-marker { display: none; } summary.submission-header::after { content: ""; width: 0; height: 0; flex: none; border-left: 4px solid transparent; border-right: 4px solid transparent; border-top: 5px solid currentColor; transition: transform 0.15s ease; } details.submission-details[open] > summary.submission-header::after { transform: rotate(180deg); } div.submission-body { padding: 0.4rem 0.6rem; font-size: 0.9rem; } div.submission-detail-summary { font-weight: 600; margin-bottom: 0.5rem; } div.submission-answers { display: flex; flex-direction: column; gap: 0.5rem; } div.submission-answer { border: 1px solid #ccc; border-radius: 4px; padding: 0.4rem 0.6rem; } div.submission-answer.full-credit { border-color: #a5d6a7; background: #e8f5e9; } div.submission-answer.partial-credit { border-color: #e0c04a; background: #fff8e1; } div.submission-answer.no-credit { border-color: #ef9a9a; background: #ffebee; } div.submission-answer-header { font-weight: 600; margin-bottom: 0.25rem; } div.submission-answer.full-credit div.submission-answer-header { color: #2e7d32; } div.submission-answer.partial-credit div.submission-answer-header { color: #b8860b; } div.submission-answer.no-credit div.submission-answer-header { color: #c62828; } div.submission-answer-prompt { margin-bottom: 0.25rem; } div.submission-answer-feedback { font-style: italic; } div.submission-answer-sql pre { margin: 0.25rem 0 0; padding: 0.4rem; background: #f5f5f5; border-radius: 4px; overflow-x: auto; } `) } // Builds the href for a "Begin Assessment"/"Try Again" link: the current // page's URL with its "id" query param set to this assignment's id, and (for // "Try Again") "priorKey" set to the most recent submission's key so the // render engine (e.g. sql.js) can fetch that submission's detail straight // from /api/submissions/item and prefill the student's prior work. A plain // link (rather than a JS-driven navigation) means the browser handles the // actual page load, and initializeAssignments' urlParams.has('id') branch // takes it from there. function assessmentHref(assessmentId, priorKey){ const url = new URL(location.href) url.searchParams.set("id", assessmentId) if(priorKey != null){ url.searchParams.set("priorKey", priorKey) }else{ url.searchParams.delete("priorKey") } return url.toString() } function getAssessmentJson(id){ console.log("at getAssessmentJson", id) // Assessment posts now live on the current book's own blog (same as the // "book"/"toc" posts initialize() reads), not the separate assessments blog. if(location.hostname.startsWith("local.availabooks.com")){ fetch("/books/" + location.pathname.split("/").slice(2,3).join("/") + "/assessments/" + id + ".json") .then(response => { return response.text(); }) .then(data => { gotAssessment(data) }) }else{ loadCrossOrigin(`${location.origin}/feeds/posts/default/-/${id}?alt=json-in-script&max-results=1&callback=gotAssessment`) } } async function gotAssessment(x){ console.log("at got assessment",x) if(typeof x === "string"){ globals.assessment.data = JSON.parse(x) }else{ const doc = JSON.parse(x.feed.entry[0].content.$t) globals.assessment.data = await decryptAssessmentDoc(doc, globals.assessment.id) } globals.assessment.name = globals.assessment.data.title console.log("assessment", globals.assessment) // clear the current body document.querySelector("div.post-body").replaceChildren() // get the engine to render this assessment - same local-dev vs. production // branch as getAssessmentJson above (and dev.js's getSystemModule): local // dev reads the render engine's own file straight off disk instead of // round-tripping through the blog. if(location.hostname.startsWith("local.availabooks.com")){ fetch(`${location.origin}/tools/api/${globals.assessment.data.assessmentType}.js`) .then(response => response.text()) .then(code => injectJs(code)) }else{ const codeUrl = `${globals.systemUrl}/feeds/posts/default/-/${globals.systemVersion}/${globals.assessment.data.assessmentType}?alt=json-in-script&max-results=1&callback=loadCode` console.log(codeUrl) loadCrossOrigin(codeUrl); } } // ############################################################################## // ##### Toolbar (Upload / Submit / Download), shown below whatever the ##### // ##### render engine module (e.g. sql.js) draws. It reads/writes the ##### // ##### current work via two globals the render engine module defines: ##### // ##### getDocument() - current answers, keyed however the ##### // ##### engine likes (sql.js uses studio ids) ##### // ##### putDocument(answers) - same shape back into the engine's UI ##### // ##### A render engine may optionally also define a third global: ##### // ##### gradeDocument() - grades the current answers in place ##### // ##### (feedback/scoring is the engine's own ##### // ##### business - see sql.js's gradeDocument ##### // ##### for the only implementation today). ##### // ##### submitAssessment() below calls it if present and awaits it before ##### // ##### continuing; render engines that have nothing to grade (e.g. a ##### // ##### future a-quiz-style engine) simply don't define it. ##### // ##### Render engine modules must call addAssessmentToolbar() themselves ##### // ##### once they're done building their DOM into post-body - calling it ##### // ##### any earlier means a later replaceChildren()/innerHTML in the ##### // ##### engine would wipe it out. ##### // ############################################################################## function addAssessmentToolbar(){ const {div, button, span} = van.tags const bodyTag = document.querySelector("div.post-body") if(!bodyTag){ return } const icon = (name) => span({class:"material-symbols-outlined"}, name) const toolbar = div({class:"assessment-toolbar"}, button({class:"assessment-upload", onclick: uploadAssessment}, icon("upload_file"), "Upload"), button({class:"assessment-download", onclick: downloadAssessment}, icon("download"), "Download"), button({class:"assessment-submit", onclick: submitAssessment}, icon("task_alt"), "Submit"), ) bodyTag.appendChild(toolbar) } async function submitAssessment(){ const submission = getDocument() console.log("submitAssessment - document from render engine:", submission) if(typeof gradeDocument === "function"){ await gradeDocument() } // TODO: send `submission` somewhere once a submission endpoint exists } function downloadAssessment(){ const submission = getDocument() console.log("downloadAssessment - document from render engine:", submission) const payload = { assessment: globals.assessment.id, name: globals.assessment.name, answers: submission, } // globals.user defaults to {} when signed out, so check for an actual record if(globals.user && globals.user.id){ const student = {} const fullName = [globals.user.firstName, globals.user.lastName].filter(Boolean).join(" ") if(fullName){ student.name = fullName } if(globals.user.email){ student.email = globals.user.email } payload.student = student } const blob = new Blob([JSON.stringify(payload, null, 2)], {type: "application/json"}) const url = URL.createObjectURL(blob) const link = document.createElement("a") link.href = url link.download = assessmentDownloadFilename() document.body.appendChild(link) link.click() link.remove() URL.revokeObjectURL(url) } function assessmentDownloadFilename(){ const base = (globals.assessment.name || globals.assessment.id || "assessment").trim() const safe = base.replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "") || "assessment" return `${safe}.json` } function uploadAssessment(){ // not attached to the DOM - creating it fresh each click means there's no // stale hanging around holding on to a previous file/listener const input = document.createElement("input") input.type = "file" input.accept = "application/json,.json" input.addEventListener("change", () => { const file = input.files && input.files[0] if(file){ restoreAssessmentUpload(file) } }) input.click() } async function restoreAssessmentUpload(file){ let payload try{ payload = JSON.parse(await file.text()) }catch(err){ console.error("uploadAssessment - could not parse file as JSON:", err) alert("That file isn't valid JSON.") return } if(!payload || typeof payload.answers !== "object" || payload.answers === null){ console.error("uploadAssessment - file has no answers to restore:", payload) alert("That file doesn't look like a downloaded assessment.") return } if(payload.assessment && payload.assessment !== globals.assessment.id){ console.warn("uploadAssessment - file is for assessment", payload.assessment, "but the current one is", globals.assessment.id, "- restoring anyway") } putDocument(payload.answers) } // ############################################################################## // ##### Decryption of assessment posts (published encrypted by publish-assessment.py) ##### // ############################################################################## // key is derived from the assessment's own id, matching generate_module_jwk() in tools/crypt.py. // Task answer keys are re-encrypted after the initial decrypt (see encryptTaskKeys) so the // plaintext answers never sit in globals.assessment.data - call decryptTaskKey() at the point // where a task's key is actually needed, and don't store the result anywhere longer-lived. async function decryptAssessmentDoc(doc, assessmentId){ const data = await decryptJwe(doc.data, assessmentId) await encryptTaskKeys(data, assessmentId) return data } async function encryptTaskKeys(data, assessmentId){ if(!Array.isArray(data.tasks)){ return } for(const task of data.tasks){ if(task.key !== undefined){ task.key = await encryptTaskSecret(task.key, assessmentId) } } } // task.key is only ever re-encrypted (see encryptTaskKeys) when the assessment doc // came through decryptAssessmentDoc. Local/dev testing loads assessment JSON straight // from disk (see gotAssessment's typeof x === "string" branch in this file) and never // calls encryptTaskKeys, so task.key there is still the plaintext answer key from the // JSON file - pass it through as-is rather than trying to decrypt it as a compact JWE. async function decryptTaskKey(task, assessmentId){ if(typeof task.key !== "string"){ return task.key } return decryptTaskSecret(task.key, assessmentId) } async function getKek(moduleId, usages){ const kekBytes = blake2b(new TextEncoder().encode(String(moduleId)), 32) return crypto.subtle.importKey("raw", kekBytes, {name:"AES-KW"}, false, usages) } async function decryptJwe(compact, moduleId){ const [protectedB64, encryptedKeyB64, ivB64, ciphertextB64, tagB64] = compact.split(".") const encryptedKey = base64UrlToBytes(encryptedKeyB64) const iv = base64UrlToBytes(ivB64) const ciphertext = base64UrlToBytes(ciphertextB64) const tag = base64UrlToBytes(tagB64) const kek = await getKek(moduleId, ["unwrapKey"]) // A256CBC-HS512's content encryption key is 64 bytes: a 32-byte HMAC key // followed by a 32-byte AES key. Unwrap it as one opaque blob, then split it. const cekKey = await crypto.subtle.unwrapKey( "raw", encryptedKey, kek, {name:"AES-KW"}, {name:"HMAC", hash:"SHA-512", length:512}, true, ["sign"] ) const rawCek = new Uint8Array(await crypto.subtle.exportKey("raw", cekKey)) const macKeyBytes = rawCek.slice(0, 32) const encKeyBytes = rawCek.slice(32, 64) const macKey = await crypto.subtle.importKey("raw", macKeyBytes, {name:"HMAC", hash:"SHA-512"}, false, ["sign"]) const aad = new TextEncoder().encode(protectedB64) const al = bigEndianBitLength(aad.length) const macInput = concatBytes(aad, iv, ciphertext, al) const computedMac = new Uint8Array(await crypto.subtle.sign("HMAC", macKey, macInput)) const computedTag = computedMac.slice(0, 32) if(computedTag.length !== tag.length || !computedTag.every((b,i) => b === tag[i])){ throw new Error("Authentication tag mismatch - assessment content may be corrupt or the key is wrong") } const encKey = await crypto.subtle.importKey("raw", encKeyBytes, {name:"AES-CBC"}, false, ["decrypt"]) const plaintext = await crypto.subtle.decrypt({name:"AES-CBC", iv}, encKey, ciphertext) return JSON.parse(new TextDecoder().decode(plaintext)) } // Simplified encrypted format for task keys: wrappedKey.iv.ciphertext.tag (no header // segment). Unlike the top-level post, this never needs to round-trip through Python // or be read by anything else - it exists only so a task's answer key doesn't sit as // plaintext in globals.assessment.data, so there's no public algorithm header to carry. async function encryptTaskSecret(data, moduleId){ const cekBytes = crypto.getRandomValues(new Uint8Array(64)) const macKeyBytes = cekBytes.slice(0, 32) const encKeyBytes = cekBytes.slice(32, 64) const iv = crypto.getRandomValues(new Uint8Array(16)) const encKey = await crypto.subtle.importKey("raw", encKeyBytes, {name:"AES-CBC"}, false, ["encrypt"]) const ciphertext = new Uint8Array(await crypto.subtle.encrypt({name:"AES-CBC", iv}, encKey, new TextEncoder().encode(JSON.stringify(data)))) const macKey = await crypto.subtle.importKey("raw", macKeyBytes, {name:"HMAC", hash:"SHA-512"}, false, ["sign"]) const tag = new Uint8Array(await crypto.subtle.sign("HMAC", macKey, concatBytes(iv, ciphertext))).slice(0, 32) const kek = await getKek(moduleId, ["wrapKey"]) const cekKey = await crypto.subtle.importKey("raw", cekBytes, {name:"HMAC", hash:"SHA-512", length:512}, true, ["sign"]) const wrappedKey = new Uint8Array(await crypto.subtle.wrapKey("raw", cekKey, kek, {name:"AES-KW"})) return [base64UrlEncode(wrappedKey), base64UrlEncode(iv), base64UrlEncode(ciphertext), base64UrlEncode(tag)].join(".") } async function decryptTaskSecret(compact, moduleId){ const [wrappedKeyB64, ivB64, ciphertextB64, tagB64] = compact.split(".") const encryptedKey = base64UrlToBytes(wrappedKeyB64) const iv = base64UrlToBytes(ivB64) const ciphertext = base64UrlToBytes(ciphertextB64) const tag = base64UrlToBytes(tagB64) const kek = await getKek(moduleId, ["unwrapKey"]) const cekKey = await crypto.subtle.unwrapKey( "raw", encryptedKey, kek, {name:"AES-KW"}, {name:"HMAC", hash:"SHA-512", length:512}, true, ["sign"] ) const rawCek = new Uint8Array(await crypto.subtle.exportKey("raw", cekKey)) const macKeyBytes = rawCek.slice(0, 32) const encKeyBytes = rawCek.slice(32, 64) const macKey = await crypto.subtle.importKey("raw", macKeyBytes, {name:"HMAC", hash:"SHA-512"}, false, ["sign"]) const computedMac = new Uint8Array(await crypto.subtle.sign("HMAC", macKey, concatBytes(iv, ciphertext))) const computedTag = computedMac.slice(0, 32) if(computedTag.length !== tag.length || !computedTag.every((b,i) => b === tag[i])){ throw new Error("Authentication tag mismatch - task key may be corrupt or the key is wrong") } const encKey = await crypto.subtle.importKey("raw", encKeyBytes, {name:"AES-CBC"}, false, ["decrypt"]) const plaintext = await crypto.subtle.decrypt({name:"AES-CBC", iv}, encKey, ciphertext) return JSON.parse(new TextDecoder().decode(plaintext)) } function base64UrlToBytes(b64url){ const b64 = b64url.replaceAll("-","+").replaceAll("_","/") const bin = atob(b64) const bytes = new Uint8Array(bin.length) for(let i=0; i n + a.length, 0) const out = new Uint8Array(total) let offset = 0 for(const a of arrays){ out.set(a, offset) offset += a.length } return out } function bigEndianBitLength(byteLength){ const bits = BigInt(byteLength) * 8n const out = new Uint8Array(8) let v = bits for(let i=7; i>=0; i--){ out[i] = Number(v & 0xffn) v >>= 8n } return out } // ---- BLAKE2b (RFC 7693), unkeyed, 32-byte digest - matches Python's // hashlib.blake2b(id, digest_size=32) used by generate_module_jwk() in tools/crypt.py. // Uses BigInt for the 64-bit words; assessment ids are short so this stays cheap. const BLAKE2B_MASK64 = (1n << 64n) - 1n const BLAKE2B_IV = [ 0x6a09e667f3bcc908n, 0xbb67ae8584caa73bn, 0x3c6ef372fe94f82bn, 0xa54ff53a5f1d36f1n, 0x510e527fade682d1n, 0x9b05688c2b3e6c1fn, 0x1f83d9abfb41bd6bn, 0x5be0cd19137e2179n, ] const BLAKE2B_SIGMA = [ [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15], [14,10,4,8,9,15,13,6,1,12,0,2,11,7,5,3], [11,8,12,0,5,2,15,13,10,14,3,6,7,1,9,4], [7,9,3,1,13,12,11,14,2,6,5,10,4,0,15,8], [9,0,5,7,2,4,10,15,14,1,11,12,6,8,3,13], [2,12,6,10,0,11,8,3,4,13,7,5,15,14,1,9], [12,5,1,15,14,13,4,10,0,7,6,3,9,2,8,11], [13,11,7,14,12,1,3,9,5,0,15,4,8,6,2,10], [6,15,14,9,11,3,0,8,12,2,13,7,1,4,10,5], [10,2,8,4,7,6,1,5,15,11,9,14,3,12,13,0], [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15], [14,10,4,8,9,15,13,6,1,12,0,2,11,7,5,3], ] function blake2bRotr64(x, n){ return ((x >> BigInt(n)) | (x << BigInt(64 - n))) & BLAKE2B_MASK64 } function blake2bReadWordsLE(block){ const words = new Array(16) for(let i=0; i<16; i++){ let w = 0n for(let j=7; j>=0; j--){ w = (w << 8n) | BigInt(block[i*8 + j]) } words[i] = w } return words } function blake2bCompress(h, block, t, isLast){ const m = blake2bReadWordsLE(block) const v = new Array(16) for(let i=0; i<8; i++) v[i] = h[i] for(let i=0; i<8; i++) v[8+i] = BLAKE2B_IV[i] v[12] ^= t & BLAKE2B_MASK64 v[13] ^= (t >> 64n) & BLAKE2B_MASK64 // always 0 for our short inputs if(isLast) v[14] = (~v[14]) & BLAKE2B_MASK64 function G(a,b,c,d,x,y){ v[a] = (v[a] + v[b] + x) & BLAKE2B_MASK64 v[d] = blake2bRotr64(v[d] ^ v[a], 32) v[c] = (v[c] + v[d]) & BLAKE2B_MASK64 v[b] = blake2bRotr64(v[b] ^ v[c], 24) v[a] = (v[a] + v[b] + y) & BLAKE2B_MASK64 v[d] = blake2bRotr64(v[d] ^ v[a], 16) v[c] = (v[c] + v[d]) & BLAKE2B_MASK64 v[b] = blake2bRotr64(v[b] ^ v[c], 63) } for(let round=0; round<12; round++){ const s = BLAKE2B_SIGMA[round] G(0,4,8,12, m[s[0]], m[s[1]]) G(1,5,9,13, m[s[2]], m[s[3]]) G(2,6,10,14, m[s[4]], m[s[5]]) G(3,7,11,15, m[s[6]], m[s[7]]) G(0,5,10,15, m[s[8]], m[s[9]]) G(1,6,11,12, m[s[10]], m[s[11]]) G(2,7,8,13, m[s[12]], m[s[13]]) G(3,4,9,14, m[s[14]], m[s[15]]) } for(let i=0; i<8; i++){ h[i] = (h[i] ^ v[i] ^ v[8+i]) & BLAKE2B_MASK64 } } function blake2b(data, outlenBytes=32){ const h = BLAKE2B_IV.slice() h[0] = (h[0] ^ 0x01010000n ^ BigInt(outlenBytes)) & BLAKE2B_MASK64 const blockSize = 128 let offset = 0 let remaining = data.length if(remaining === 0){ blake2bCompress(h, new Uint8Array(blockSize), 0n, true) }else{ while(remaining > blockSize){ const block = data.subarray(offset, offset + blockSize) offset += blockSize remaining -= blockSize blake2bCompress(h, block, BigInt(offset), false) } const lastBlock = new Uint8Array(blockSize) lastBlock.set(data.subarray(offset, offset + remaining)) offset += remaining blake2bCompress(h, lastBlock, BigInt(offset), true) } const out = new Uint8Array(outlenBytes) let pos = 0 for(let i=0; i>= 8n } } return out } function loadCss(cssString){ const style = document.createElement('style'); style.textContent = cssString; document.head.appendChild(style); } initializeAssignments()