1/** 2 * new/js/services/course-crypto.js 3 * 4 * â ï¸ SENSITIVE â DO NOT MODIFY CRYPTO PARAMETERS â ï¸ 5 * 6 * Encrypts and decrypts course payloads for cloud storage only. 7 * This file and shared/workspace-crypto.js are the only files that may 8 * call crypto.subtle.*. No other file in the codebase may do so. 9 * 10 * Workspace-level key operations (key generation, wrapping, handoff) live 11 * in shared/workspace-crypto.js. This file only handles AES-GCM encrypt/decrypt 12 * of individual course JSON payloads using an already-unlocked workspace key. 13 * 14 * Source: 15 * online/index.html lines 5560â5566 (toTimestamp â inlined as private helper) 16 * online/index.html lines 6304â6316 (isEncryptedCourseEnvelope, getCoursePayloadMetaForCloud) 17 * online/index.html lines 6375â6433 (buildCourseEnvelopeAdditionalData, 18 * encryptCoursePayloadForCloud, 19 * decryptCoursePayloadFromCloud) 20 * 21 * Note: resolveRemoteCoursePayload (line 6435) lives in sync-service.js â it 22 * requires ensureCloudSession + ensureWorkspaceKeyAvailable and does not belong 23 * in the pure crypto layer. 24 * 25 * Step 17 â Assigned to: Claude (crypto â not delegated) 26 */ 27 28import { bytesToBase64, base64ToBytes, randomBytes } from '../../../shared/crypto-utils.js'; 29import { WORKSPACE_CRYPTO_CONFIG } from '../../../shared/constants.js'; 30 31// --------------------------------------------------------------------------- 32// Module-level state 33// --------------------------------------------------------------------------- 34 35// Reuse one encoder/decoder instance per module. 36// Source: online/index.html line 6094â6095 37const _textEncoder = new TextEncoder(); 38const _textDecoder = new TextDecoder(); 39const COURSE_TITLE_ENVELOPE_KIND = `${WORKSPACE_CRYPTO_CONFIG.envelopeKind}_title`; 40 41// --------------------------------------------------------------------------- 42// Private helpers 43// --------------------------------------------------------------------------- 44 45/** 46 * Coerces a value to a numeric timestamp. 47 * Accepts a number, a date string, or falls back to `fallback`. 48 * Inlined here to avoid a dependency on storage-service.js. 49 * Source: online/index.html line 5560 50 * @param {*} value 51 * @param {number} [fallback] 52 * @returns {number} 53 */ 54function toTimestamp(value, fallback = Date.now()) { 55 const numeric = Number(value); 56 if (Number.isFinite(numeric)) return numeric; 57 const parsed = Date.parse(value); 58 return Number.isFinite(parsed) ? parsed : fallback; 59} 60 61// --------------------------------------------------------------------------- 62// Envelope identification 63// --------------------------------------------------------------------------- 64 65/** 66 * Returns true if a cloud payload object is an AES-GCM encrypted course envelope. 67 * False means the payload is plaintext and can be used directly. 68 * 69 * Source: online/index.html line 6304 70 * @param {*} data 71 * @returns {boolean} 72 */ 73export function isEncryptedCourseEnvelope(data) { 74 return !!( 75 data && 76 typeof data === 'object' && 77 data.kind === WORKSPACE_CRYPTO_CONFIG.envelopeKind && 78 Number(data.encryption_version) === WORKSPACE_CRYPTO_CONFIG.encryptionVersion && 79 typeof data.iv_b64 === 'string' && 80 typeof data.ciphertext_b64 === 'string' 81 ); 82} 83 84// --------------------------------------------------------------------------- 85// Metadata helpers 86// --------------------------------------------------------------------------- 87 88/** 89 * Extracts presentation metadata (created, updatedAt, lastAccessed timestamps) 90 * from a course payload for cloud display and sync purposes. 91 * Works on both plaintext payloads and the `.meta` field of encrypted envelopes. 92 * 93 * Source: online/index.html line 6308 94 * @param {object} [data] 95 * @param {number} [fallbackTimestamp] 96 * @returns {{ created: number, updatedAt: number, lastAccessed: number }} 97 */ 98export function getCoursePayloadMetaForCloud(data = {}, fallbackTimestamp = Date.now()) { 99 const meta = data && typeof data.meta === 'object' ? data.meta : {}; 100 return { 101 created: toTimestamp(meta.created ?? data?.created, fallbackTimestamp), 102 updatedAt: toTimestamp(meta.updatedAt ?? data?.updatedAt, fallbackTimestamp), 103 lastAccessed: toTimestamp(meta.lastAccessed ?? data?.lastAccessed, fallbackTimestamp), 104 }; 105} 106 107// --------------------------------------------------------------------------- 108// AES-GCM additional authenticated data 109// --------------------------------------------------------------------------- 110 111/**
112 * Builds the Additional Authenticated Data (AAD) for AES-GCM course encryption. 113 * 114 * The AAD binds the ciphertext cryptographically to a specific courseId and userId. 115 * This prevents cross-user and cross-course ciphertext replay attacks: 116 * decryption will fail if either value doesn't match what was present at encrypt time. 117 * 118 * â ï¸ The JSON structure and field names are frozen â changing them makes all 119 * existing cloud ciphertexts undecryptable. 120 * 121 * Source: online/index.html line 6375 122 * @param {string} courseId 123 * @param {string} userId 124 * @returns {Uint8Array} 125 */ 126export function buildCourseEnvelopeAdditionalData(courseId, userId) { 127 return _textEncoder.encode(JSON.stringify({ 128 kind: WORKSPACE_CRYPTO_CONFIG.envelopeKind, 129 courseId: String(courseId || ''), 130 userId: String(userId || ''), 131 })); 132} 133 134export function buildCourseTitleEnvelopeAdditionalData(courseId, userId) { 135 return _textEncoder.encode(JSON.stringify({ 136 kind: COURSE_TITLE_ENVELOPE_KIND, 137 courseId: String(courseId || ''), 138 userId: String(userId || ''), 139 })); 140} 141 142export function isEncryptedCourseTitleEnvelope(data) { 143 return !!( 144 data && 145 typeof data === 'object' && 146 data.kind === COURSE_TITLE_ENVELOPE_KIND && 147 Number(data.encryption_version) === WORKSPACE_CRYPTO_CONFIG.encryptionVersion && 148 typeof data.iv_b64 === 'string' && 149 typeof data.ciphertext_b64 === 'string' 150 ); 151} 152 153// --------------------------------------------------------------------------- 154// Encryption 155// --------------------------------------------------------------------------- 156 157/** 158 * Encrypts a course payload object for cloud storage using the workspace key. 159 * 160 * Returns an envelope object suitable for saving to the Supabase `courses` table. 161 * The envelope carries the IV, ciphertext, and plaintext metadata (timestamps) 162 * so the course list can be shown without decrypting. 163 * 164 * Source: online/index.html line 6383 165 * @param {object} payload - raw course data object 166 * @param {CryptoKey} workspaceKey - unlocked AES-GCM workspace key 167 * @param {string} sessionUserId - current user's ID (used in AAD) 168 * @returns {Promise<object>} - encrypted envelope for Supabase 169 */ 170export async function encryptCoursePayloadForCloud(payload, workspaceKey, sessionUserId) { 171 const ivBytes = randomBytes(WORKSPACE_CRYPTO_CONFIG.ivBytes); 172 const ciphertext = await crypto.subtle.encrypt( 173 { 174 name: 'AES-GCM', 175 iv: ivBytes, 176 additionalData: buildCourseEnvelopeAdditionalData(payload?.courseId, sessionUserId), 177 }, 178 workspaceKey, 179 _textEncoder.encode(JSON.stringify(payload)) 180 ); 181 182 return { 183 kind: WORKSPACE_CRYPTO_CONFIG.envelopeKind, 184 encryption_version: WORKSPACE_CRYPTO_CONFIG.encryptionVersion, 185 alg: 'AES-GCM', 186 iv_b64: bytesToBase64(ivBytes), 187 ciphertext_b64: bytesToBase64(new Uint8Array(ciphertext)), 188 meta: { 189 created: toTimestamp(payload?.created, Date.now()), 190 updatedAt: toTimestamp(payload?.updatedAt, Date.now()), 191 lastAccessed: toTimestamp(payload?.lastAccessed, Date.now()), 192 payloadFormatVersion: Number(payload?.formatVersion || 0) || 0, 193 }, 194 }; 195} 196 197export async function encryptCourseTitleForCloud(courseName, workspaceKey, courseId, sessionUserId) { 198 const normalizedName = String(courseName || '').trim(); 199 if (!normalizedName) return null; 200 201 const ivBytes = randomBytes(WORKSPACE_CRYPTO_CONFIG.ivBytes); 202 const ciphertext = await crypto.subtle.encrypt( 203 { 204 name: 'AES-GCM', 205 iv: ivBytes, 206 additionalData: buildCourseTitleEnvelopeAdditionalData(courseId, sessionUserId), 207 }, 208 workspaceKey, 209 _textEncoder.encode(JSON.stringify({ courseName: normalizedName })) 210 ); 211 212 return { 213 kind: COURSE_TITLE_ENVELOPE_KIND, 214 encryption_version: WORKSPACE_CRYPTO_CONFIG.encryptionVersion, 215 alg: 'AES-GCM', 216 iv_b64: bytesToBase64(ivBytes), 217 ciphertext_b64: bytesToBase64(new Uint8Array(ciphertext)), 218 }; 219} 220 221// --------------------------------------------------------------------------- 222// Decryption 223// --------------------------------------------------------------------------- 224 225/** 226 * Decrypts a course envelope from Supabase using the workspace key. 227 * Returns the original course data object. 228 * 229 * Throws a user-readable error if: 230 * - the AES-GCM tag check fails (wrong key, wrong courseId/userId, tampered data) 231 * - the decrypted bytes are not valid JSON 232 * 233 * Source: online/index.html line 6410 234 * @param {object} envelope - encrypted envelope from Supabase 235 * @param {CryptoKey} workspaceKey - unlocked AES-GCM workspace key 236 * @param {string} courseId - must match what was encrypted (used in AAD) 237 * @param {string} sessionUserId - must match what was encrypted (used in AAD) 238 * @returns {Promise<object>} - decrypted course data 239 */ 240export async function decryptCoursePayloadFromCloud(envelope, workspaceKey, courseId, sessionUserId) { 241 if (!isEncryptedCourseEnvelope(envelope)) return envelope; 242 243 let plaintext; 244 try { 245 plaintext = await crypto.subtle.decrypt( 246 { 247 name: 'AES-GCM', 248 iv: base64ToBytes(envelope.iv_b64), 249 additionalData: buildCourseEnvelopeAdditionalData(courseId, sessionUserId), 250 }, 251 workspaceKey, 252 base64ToBytes(envelope.ciphertext_b64) 253 ); 254 } catch (_) { 255 throw new Error('This browser could not decrypt the hosted course data. Check your account password or recovery key and try again.'); 256 } 257 258 try { 259 return JSON.parse(_textDecoder.decode(plaintext)); 260 } catch (_) { 261 throw new Error('The decrypted hosted course payload was invali
261d.'); 262 } 263} 264 265export async function decryptCourseTitleFromCloud(envelope, workspaceKey, courseId, sessionUserId) { 266 if (!isEncryptedCourseTitleEnvelope(envelope)) return ''; 267 268 let plaintext; 269 try { 270 plaintext = await crypto.subtle.decrypt( 271 { 272 name: 'AES-GCM', 273 iv: base64ToBytes(envelope.iv_b64), 274 additionalData: buildCourseTitleEnvelopeAdditionalData(courseId, sessionUserId), 275 }, 276 workspaceKey, 277 base64ToBytes(envelope.ciphertext_b64) 278 ); 279 } catch (_) { 280 throw new Error('This browser could not decrypt the hosted course title. Check your account password or recovery key and try again.'); 281 } 282 283 try { 284 const payload = JSON.parse(_textDecoder.decode(plaintext)); 285 return String(payload?.courseName || '').trim(); 286 } catch (_) { 287 throw new Error('The decrypted hosted course title was invalid.'); 288 } 289} 290 291// resolveRemoteCoursePayload lives in sync-service.js â it needs ensureCloudSession 292// and ensureWorkspaceKeyAvailable, which would create an upward dependency if imported 293// here. Callers should use sync-service.resolveRemoteCoursePayload instead.
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.