Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 139 additions & 0 deletions src/__tests__/config-key-web.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { describe, expect, it } from 'vitest';
import { removeURLFragment } from '../config-key-shared.js';
import { generateConfigKeyHash, verifyConfigKeyHash } from '../config-key-web.js';

// Mock Web Crypto API for Node.js test environment
const mockCrypto = {
subtle: {
digest: async (algorithm: string, data: Uint8Array) => {
const crypto = await import('node:crypto');
const hash = crypto.createHash('sha256');
hash.update(data);
return hash.digest();
},
},
};

// Setup global crypto mock
globalThis.window = {
crypto: mockCrypto,
} as any;

describe('config-key-web', () => {
describe('removeURLFragment', () => {
it('removes fragment from URL', () => {
expect(removeURLFragment('https://example.com/path#fragment')).toBe('https://example.com/path');
});

it('handles URL without fragment', () => {
expect(removeURLFragment('https://example.com/path')).toBe('https://example.com/path');
});

it('handles multiple # characters', () => {
expect(removeURLFragment('https://example.com/path#fragment#more')).toBe('https://example.com/path');
});
});

describe('generateConfigKeyHash', () => {
it('generates correct hash for URL and config key', async () => {
const url = 'https://exam.example.com/quiz/1';
const configKey = 'abc123def456';

const hash = await generateConfigKeyHash(url, configKey);

// Hash should be a 64-character hex string
expect(hash).toHaveLength(64);
expect(hash).toMatch(/^[a-f0-9]{64}$/);

// Verify it's deterministic (same input produces same output)
const hash2 = await generateConfigKeyHash(url, configKey);
expect(hash).toBe(hash2);
});

it('removes fragment before hashing', async () => {
const urlWithFragment = 'https://exam.example.com/quiz/1#section2';
const urlWithoutFragment = 'https://exam.example.com/quiz/1';
const configKey = 'abc123def456';

const hash1 = await generateConfigKeyHash(urlWithFragment, configKey);
const hash2 = await generateConfigKeyHash(urlWithoutFragment, configKey);

expect(hash1).toBe(hash2);
});

it('produces different hashes for different URLs', async () => {
const configKey = 'abc123def456';

const hash1 = await generateConfigKeyHash('https://example.com/page1', configKey);
const hash2 = await generateConfigKeyHash('https://example.com/page2', configKey);

expect(hash1).not.toBe(hash2);
});

it('produces different hashes for different config keys', async () => {
const url = 'https://exam.example.com/quiz/1';

const hash1 = await generateConfigKeyHash(url, 'key1');
const hash2 = await generateConfigKeyHash(url, 'key2');

expect(hash1).not.toBe(hash2);
});
});

describe('verifyConfigKeyHash', () => {
it('returns true for matching hash', async () => {
const url = 'https://exam.example.com/quiz/1';
const configKey = 'abc123def456';
const hash = await generateConfigKeyHash(url, configKey);

const isValid = await verifyConfigKeyHash(url, configKey, hash);

expect(isValid).toBe(true);
});

it('returns false for non-matching hash', async () => {
const url = 'https://exam.example.com/quiz/1';
const configKey = 'abc123def456';
const wrongHash = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';

const isValid = await verifyConfigKeyHash(url, configKey, wrongHash);

expect(isValid).toBe(false);
});

it('is case-insensitive', async () => {
const url = 'https://exam.example.com/quiz/1';
const configKey = 'abc123def456';
const hash = await generateConfigKeyHash(url, configKey);
const upperHash = hash.toUpperCase();

const isValid = await verifyConfigKeyHash(url, configKey, upperHash);

expect(isValid).toBe(true);
});

it('removes fragment before verification', async () => {
const urlWithFragment = 'https://exam.example.com/quiz/1#section2';
const urlWithoutFragment = 'https://exam.example.com/quiz/1';
const configKey = 'abc123def456';
const hash = await generateConfigKeyHash(urlWithoutFragment, configKey);

const isValid = await verifyConfigKeyHash(urlWithFragment, configKey, hash);

expect(isValid).toBe(true);
});
});

describe('web Crypto API availability', () => {
it('throws error when crypto is not available', async () => {
const originalWindow = globalThis.window;
globalThis.window = undefined as any;

await expect(generateConfigKeyHash('https://example.com', 'key')).rejects.toThrow(
'Web Crypto API is not available in this environment',
);

globalThis.window = originalWindow;
});
});
});
2 changes: 1 addition & 1 deletion src/__tests__/config-key.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { describe, expect, it } from 'vitest';
import { removeURLFragment } from '../config-key-shared.js';
import {
convertToSEBJSON,
generateConfigKey,
generateConfigKeyHash,
removeURLFragment,
verifyConfigKeyHash,
} from '../config-key.js';

Expand Down
6 changes: 6 additions & 0 deletions src/config-key-shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* Utility function, removes the fragment (part after #) from a URL
*/
export function removeURLFragment(url: string): string {
return url.split('#')[0];
}
69 changes: 69 additions & 0 deletions src/config-key-web.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* Browser-compatible Config Key verification for Safe Exam Browser
* Uses Web Crypto API instead of Node.js crypto
* @see https://safeexambrowser.org/developer/seb-config-key.html
*/

import { removeURLFragment } from './config-key-shared.js';

/**
* Creates SHA-256 hash using Web Crypto API
*/
async function sha256(message: string): Promise<string> {
// Check if we're in a browser environment
if (typeof window === 'undefined' || !window.crypto || !window.crypto.subtle) {
throw new Error('Web Crypto API is not available in this environment');
}

const encoder = new TextEncoder();
const data = encoder.encode(message);
const hashBuffer = await window.crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
return hashHex.toLowerCase();
}

/**
* Generates the Config Key hash for a URL request (async version for browser)
* This is what SEB sends in the X-SafeExamBrowser-ConfigKeyHash header
*
* @param url - The absolute URL (without fragment)
* @param configKey - The base Config Key (64-char hex string)
* @returns The URL-specific Config Key hash (64-char hex string)
*
* @example
* ```typescript
* const urlHash = await generateConfigKeyHash('https://exam.example.com/quiz/1', configKey);
* // Compare this with the X-SafeExamBrowser-ConfigKeyHash header value
* ```
*/
export async function generateConfigKeyHash(url: string, configKey: string): Promise<string> {
const urlWithoutFragment = removeURLFragment(url);
const combined = urlWithoutFragment + configKey;
return sha256(combined);
}

/**
* Verifies that a Config Key hash matches the expected value for a given URL (async version for browser)
*
* @param url - The absolute URL (without fragment)
* @param configKey - The base Config Key (64-char hex string)
* @param receivedHash - The hash received in X-SafeExamBrowser-ConfigKeyHash header
* @returns true if the hash matches, false otherwise
*
* @example
* ```typescript
* const isValid = await verifyConfigKeyHash(
* 'https://exam.example.com/quiz/1',
* configKey,
* request.headers['x-safeexambrowser-configkeyhash']
* );
* if (!isValid) {
* throw new Error('Invalid SEB configuration');
* }
* ```
*/
export async function verifyConfigKeyHash(url: string, configKey: string, receivedHash: string): Promise<boolean> {
const expectedHash = await generateConfigKeyHash(url, configKey);
return expectedHash.toLowerCase() === receivedHash.toLowerCase();
}
8 changes: 1 addition & 7 deletions src/config-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

import { createHash } from 'node:crypto';
import { removeURLFragment } from './config-key-shared.js';

export interface SEBConfigObject {
[key: string]: unknown;
Expand Down Expand Up @@ -158,10 +159,3 @@ export function verifyConfigKeyHash(url: string, configKey: string, receivedHash
const expectedHash = generateConfigKeyHash(url, configKey);
return expectedHash.toLowerCase() === receivedHash.toLowerCase();
}

/**
* Utility function, removes the fragment (part after #) from a URL
*/
export function removeURLFragment(url: string): string {
return url.split('#')[0];
}
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
export { removeURLFragment } from './config-key-shared.js';
export {
convertToSEBJSON,
generateConfigKey,
generateConfigKeyHash,
removeURLFragment,
verifyConfigKeyHash,
} from './config-key.js';

export type { SEBConfigObject } from './config-key.js';

export {
Expand Down
7 changes: 5 additions & 2 deletions src/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ export {
export type { SafeExamBrowser, SEBKeys } from './browser.js';

export {
generateConfigKeyHash,
removeURLFragment,
} from './config-key-shared.js';

export {
generateConfigKeyHash,
verifyConfigKeyHash,
} from './config-key.js';
} from './config-key-web.js';