PageSourceSearch

https://classaction.cv/app/js/services/course-crypto.js

js classaction.cv collected 2026-10-07 13:18:45 UTC 11,430 bytes, 293 lines download raw bytes

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.