From eb31a87cb707d33ce6c76be016c7de89fb5c8257 Mon Sep 17 00:00:00 2001 From: Revati Kadam Date: Mon, 31 Aug 2026 14:12:14 +0530 Subject: [PATCH 1/2] 1771 --- .../cipher/CipherExecutionController.ts | 38 +++-- components/cipher/CipherLayout.tsx | 16 +-- hooks/useCipherWorker.ts | 33 +++-- lib/cipher/stepVirtualization.ts | 84 +++++++---- lib/trace/traceBuffer.ts | 135 ++++++++++++++++++ lib/workers/cipher.worker.ts | 133 +++++++++++++---- tests/unit/cipher/traceBuffer.test.ts | 67 +++++++++ .../cipher/CipherExecutionController.test.ts | 19 +++ tests/unit/workers/stepTransfer.test.ts | 18 +++ types/worker.ts | 30 +++- 10 files changed, 475 insertions(+), 98 deletions(-) create mode 100644 lib/trace/traceBuffer.ts create mode 100644 tests/unit/cipher/traceBuffer.test.ts diff --git a/components/cipher/CipherExecutionController.ts b/components/cipher/CipherExecutionController.ts index 44af2d56..e424eb33 100644 --- a/components/cipher/CipherExecutionController.ts +++ b/components/cipher/CipherExecutionController.ts @@ -7,8 +7,8 @@ import { useCipherWorker } from "../../hooks/useCipherWorker"; import { clampStepIndex } from "../../lib/utils/visualizerPermalink"; import { resolveProvenance } from "../../lib/provenance/resolve"; import type { DataProvenanceMetadata } from "../../lib/provenance"; -import { saveConversionHistory, type ConversionHistoryEntry } from "../../lib/utils/conversionHistory"; - +import { saveConversionHistory, type ConversionHistoryEntry } from "@/lib/utils/conversionHistory"; +import { createVirtualizedCipherResult } from "@/lib/cipher/stepVirtualization"; interface Params { cipher: CipherDefinition; input: string; @@ -33,10 +33,12 @@ export function buildCipherWorkerOptions( demoMode: boolean, ): CipherOptions { const workerOptions: CipherOptions = { - instrument: true, - signal: undefined, - ...options, - }; + instrument: true, + signal: undefined, + traceBufferSize: 32, + traceBatchSize: 32, + ...options, +}; if (["des", "3des", "aes", "camellia"].includes(cipherId)) { workerOptions.hexInput = typeof options.hexInput === "boolean" ? options.hexInput : true; } @@ -60,10 +62,10 @@ export function useCipherExecutionController({ cipher, input, key, action, autoC const abortRef = useRef(null); const run = useCallback(async () => { - abortRef.current?.abort(); - const controller = new AbortController(); - abortRef.current = controller; - onError(null); +abortRef.current?.abort(); + +const controller = new AbortController(); +abortRef.current = controller; onError(null); try { const workerOptions = buildCipherWorkerOptions(cipher.id, options, demoMode); @@ -76,10 +78,20 @@ export function useCipherExecutionController({ cipher, input, key, action, autoC const provenance = isSimulated(cipher.id, demoMode) ? resolveProvenance({ provenance: "simulated", source: "CryptoViz educational simulation" } as DataProvenanceMetadata) : resolveProvenance(result.metadata?.provenance); - const nextResult: CipherResult = { ...result, metadata: { ...result.metadata, provenance } }; - onResult(nextResult); - onStepRestore(clampStepIndex(0, nextResult.steps?.length ?? 0)); +const nextResult: CipherResult = { + ...result, + metadata: { + ...result.metadata, + provenance, + }, +}; + +const visualizedResult = createVirtualizedCipherResult(nextResult); +onResult(visualizedResult); +onStepRestore( + clampStepIndex(0, visualizedResult.steps?.length ?? 0), +); if (nextResult.output !== undefined) { const entry: ConversionHistoryEntry = { id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, diff --git a/components/cipher/CipherLayout.tsx b/components/cipher/CipherLayout.tsx index 4d7464e5..4137f6b1 100644 --- a/components/cipher/CipherLayout.tsx +++ b/components/cipher/CipherLayout.tsx @@ -6,7 +6,6 @@ import { useRouter } from "next/navigation"; import type { CipherDefinition } from "@/lib/cipher/registry"; import type { CipherResult, CipherOptions } from "@/lib/cipher/types"; import type { AnimationSpeed } from "./StepAnimator"; -import { createVirtualizedCipherResult } from "@/lib/cipher/stepVirtualization"; import WorkspacePresetManager from "./WorkspacePresetManager"; import ConversionHistory from "./ConversionHistory"; import WhereIsThisUsed from "./WhereIsThisUsed"; @@ -208,11 +207,7 @@ export default function CipherLayout({ cipher }: CipherLayoutProps) { }; const direction = cipher.id === "dh" ? "encrypt" : action; - const virtualizedResult = useMemo( - () => (result ? createVirtualizedCipherResult(result) : null), - [result], - ); - const activeStep = result?.steps?.[currentStep]; +const virtualizedResult = result; const activeStep = result?.steps?.[currentStep]; const annotationScope = { cipherId: cipher.id, direction: direction as "encrypt" | "decrypt" }; const scopeAnnotations = getScopeAnnotations(annotationStore, annotationScope); const activeStepId = activeStep ? createStableStepId(activeStep.label, currentStep) : null; @@ -442,9 +437,12 @@ export default function CipherLayout({ cipher }: CipherLayoutProps) { cacheKey: string | null onProgress?: (percent: number, message: string) => void + traceSteps: import('@/lib/cipher/types').CipherStep[] + traceTotal: number } - function sortObjectKeys(obj: unknown): unknown { if (obj === null || typeof obj !== 'object') return obj if (Array.isArray(obj)) return obj.map(sortObjectKeys) @@ -254,16 +260,17 @@ export function useCipherWorker() { reject(new Error('WORKER_TIMEOUT')) }, WORKER_TIMEOUT_MS) - activeRequestsRef.current.set(id, { - resolve, - reject, - signal, - onAbort, - timeoutId, - cacheKey, - onProgress: options?.onProgress, - }) - setLoading(true) +activeRequestsRef.current.set(id, { + resolve, + reject, + signal, + onAbort, + timeoutId, + cacheKey, + onProgress: options?.onProgress, + traceSteps: [], + traceTotal: 0, +}) setLoading(true) setError(null) setProgress({ percent: 0, currentMilestone: 'Queued', jobId: id }) diff --git a/lib/cipher/stepVirtualization.ts b/lib/cipher/stepVirtualization.ts index 8f4301e9..8405ef8e 100644 --- a/lib/cipher/stepVirtualization.ts +++ b/lib/cipher/stepVirtualization.ts @@ -23,12 +23,13 @@ export interface StepMetadata { * @returns The operation result produced by the cipher engine. * @see https://csrc.nist.gov/pubs/fips/46-3/final — FIPS 46-3. */ -export interface VirtualizedCipherResult extends CipherResult { - /** Lightweight descriptors for navigation; full step objects are hydrated on demand. */ +export interface VirtualizedCipherResult extends Omit { + /** Lazy step collection. Only a small number of full step objects are retained. */ + steps: CipherStep[] stepMetadata: StepMetadata[] } -const VIRTUALIZED_STEP_CACHE_SIZE = 3 +const VIRTUALIZED_STEP_CACHE_SIZE = 32 type StepCache = Map @@ -45,22 +46,22 @@ function hydrateStep(serialized: string): CipherStep { return JSON.parse(serialized) as CipherStep } -/** - * Keeps the trace in compact per-step JSON and exposes an Array-compatible lazy - * view. Only the active step and its two neighbours are retained as objects. - * Array serialization (e.g. trace export) intentionally hydrates every step. - */ export function createVirtualizedCipherResult( result: CipherResult, ): VirtualizedCipherResult { - const serializedSteps = result.steps.map((step) => JSON.stringify(step)) - const stepMetadata = result.steps.map(createStepMetadata) + const { steps: sourceSteps, ...resultWithoutSteps } = result + + const serializedSteps = sourceSteps.map((step) => JSON.stringify(step)) + const stepMetadata = sourceSteps.map(createStepMetadata) const cache: StepCache = new Map() const touch = (index: number): CipherStep | undefined => { - if (index < 0 || index >= serializedSteps.length) return undefined + if (index < 0 || index >= serializedSteps.length) { + return undefined + } const cached = cache.get(index) + if (cached) { cache.delete(index) cache.set(index, cached) @@ -68,11 +69,16 @@ export function createVirtualizedCipherResult( } const hydrated = hydrateStep(serializedSteps[index]) + cache.set(index, hydrated) while (cache.size > VIRTUALIZED_STEP_CACHE_SIZE) { const oldest = cache.keys().next().value - if (typeof oldest !== 'number') break + + if (typeof oldest !== "number") { + break + } + cache.delete(oldest) } @@ -80,31 +86,47 @@ export function createVirtualizedCipherResult( } const steps = new Proxy([] as CipherStep[], { - get(_target, property, receiver) { - if (property === 'length') return serializedSteps.length - if (property === 'toJSON') { - return () => Array.from({ length: serializedSteps.length }, (_, index) => touch(index)) - } - if (typeof property === 'string' && /^\d+$/.test(property)) { - return touch(Number(property)) - } - return Reflect.get(_target, property, receiver) - }, - has(_target, property) { - if (typeof property === 'string' && /^\d+$/.test(property)) { - return Number(property) < serializedSteps.length - } - return Reflect.has(_target, property) - }, -}) + get(_target, property, receiver) { + if (property === "length") { + return serializedSteps.length + } + + if (property === "toJSON") { + return () => + Array.from( + { length: serializedSteps.length }, + (_, index) => touch(index), + ) + } + + if ( + typeof property === "string" && + /^\d+$/.test(property) + ) { + return touch(Number(property)) + } + + return Reflect.get(_target, property, receiver) + }, + + has(_target, property) { + if ( + typeof property === "string" && + /^\d+$/.test(property) + ) { + return Number(property) < serializedSteps.length + } + + return Reflect.has(_target, property) + }, + }) return { - ...result, + ...resultWithoutSteps, steps, stepMetadata, } } - /** * Get Virtualized Step cipher-engine utility export. * diff --git a/lib/trace/traceBuffer.ts b/lib/trace/traceBuffer.ts new file mode 100644 index 00000000..1030e476 --- /dev/null +++ b/lib/trace/traceBuffer.ts @@ -0,0 +1,135 @@ +import type { CipherStep } from "@/lib/cipher/types"; + +export interface TraceBufferOptions { + capacity?: number; + retainCompleted?: boolean; +} + +export interface TraceBufferStats { + total: number; + retained: number; + capacity: number; + completed: boolean; + cancelled: boolean; +} + +const DEFAULT_CAPACITY = 32; + +export class TraceBuffer { + private readonly capacity: number; + private readonly retainCompleted: boolean; + private readonly entries = new Map(); + + private total = 0; + private completed = false; + private cancelled = false; + + constructor(options: TraceBufferOptions = {}) { + this.capacity = Math.max( + 1, + Math.floor(options.capacity ?? DEFAULT_CAPACITY), + ); + this.retainCompleted = options.retainCompleted ?? true; + } + + push(step: CipherStep): boolean { + if (this.completed || this.cancelled) { + return false; + } + + const index = step.index ?? this.total; + this.entries.set(index, JSON.stringify(step)); + this.total = Math.max(this.total, index + 1); + + this.enforceCapacity(); + return true; + } + + pushBatch(steps: CipherStep[]): number { + let accepted = 0; + + for (const step of steps) { + if (!this.push(step)) break; + accepted += 1; + } + + return accepted; + } + + get(index: number): CipherStep | undefined { + const serialized = this.entries.get(index); + + if (serialized === undefined) { + return undefined; + } + + return JSON.parse(serialized) as CipherStep; + } + + has(index: number): boolean { + return this.entries.has(index); + } + + complete(): void { + if (this.cancelled) return; + + this.completed = true; + + if (!this.retainCompleted) { + this.clear(); + } + } + + cancel(): void { + this.cancelled = true; + this.clear(); + } + + clear(): void { + this.entries.clear(); + } + + dispose(): void { + this.cancel(); + } + + getStats(): TraceBufferStats { + return { + total: this.total, + retained: this.entries.size, + capacity: this.capacity, + completed: this.completed, + cancelled: this.cancelled, + }; + } + + toArray(): CipherStep[] { + const steps: CipherStep[] = []; + + for (let index = 0; index < this.total; index += 1) { + const step = this.get(index); + + if (step) { + steps.push(step); + } + } + + return steps; + } + + private enforceCapacity(): void { + if (this.completed && this.retainCompleted) { + return; + } + + while (this.entries.size > this.capacity) { + const oldest = this.entries.keys().next().value; + + if (typeof oldest !== "number") { + break; + } + + this.entries.delete(oldest); + } + } +} \ No newline at end of file diff --git a/lib/workers/cipher.worker.ts b/lib/workers/cipher.worker.ts index 0aacebbe..c7cc502d 100644 --- a/lib/workers/cipher.worker.ts +++ b/lib/workers/cipher.worker.ts @@ -526,6 +526,19 @@ const workerScope = self as unknown as Worker & typeof globalThis; let activeJobs = 0; +const cancelledJobs = new Set(); + +function isJobCancelled(jobId: string): boolean { + return cancelledJobs.has(jobId); +} + +function markJobCancelled(jobId: string): void { + cancelledJobs.add(jobId); +} + +function clearJobCancellation(jobId: string): void { + cancelledJobs.delete(jobId); +} function isWorkerRequest(value: unknown): value is WorkerRequest { if (!value || typeof value !== "object") return false; @@ -578,10 +591,24 @@ workerScope.addEventListener( let requestId = "unknown"; let jobStarted = false; - try { - const request = decodeWorkerRequest(event.data); - requestId = request.requestId; + if ( + !(event.data instanceof Uint8Array) && + event.data?.type === "CANCEL" && + typeof event.data.jobId === "string" + ) { + markJobCancelled(event.data.jobId); + return; + } + + try { const request = decodeWorkerRequest(event.data); +requestId = request.requestId; +if (request.jobId && isJobCancelled(request.jobId)) { + throw new DOMException( + "The user aborted the request.", + "AbortError", + ); +} if (!isWorkerRequest(request)) { throw new CipherError( "INVALID_INPUT", @@ -629,10 +656,16 @@ workerScope.addEventListener( const dispatcher = await getDispatcher(cipherId); const handler = payload.type === "encrypt" ? dispatcher.encrypt : dispatcher.decrypt; - const result = (await handler(input, key, options)) as CipherResult; +const result = (await handler(input, key, options)) as CipherResult; - if (!result || typeof result !== "object") { - throw new CipherError( +if (request.jobId && isJobCancelled(request.jobId)) { + throw new DOMException( + "The user aborted the request.", + "AbortError", + ); +} + +if (!result || typeof result !== "object") { throw new CipherError( "INVALID_INPUT", "Cipher implementation returned an invalid result.", ); @@ -653,31 +686,69 @@ workerScope.addEventListener( ); } - if (result.steps.length >= WORKER_STEP_TRANSFER_THRESHOLD) { - const stepsBuffer = encodeCipherSteps(result.steps); - const transferable = stepsBuffer.buffer as ArrayBuffer; - const response: WorkerResponse = { - requestId, - success: true, - payload: { - result: { ...result, steps: [] }, - stepsBuffer: transferable, - }, - timings: { durationMs }, - }; - - workerScope.postMessage(response, [transferable]); - } else { - const response: WorkerResponse = { - requestId, - success: true, - payload: { result }, - timings: { durationMs }, - }; - - workerScope.postMessage(response); - } - } catch (error: unknown) { +const batchSize = + typeof options?.traceBatchSize === "number" + ? Math.max(1, Math.floor(options.traceBatchSize)) + : 32; + +const traceSteps = result.steps ?? []; + +workerScope.postMessage({ + type: "TRACE_START", + requestId, + jobId: request.jobId, + totalSteps: traceSteps.length, +}); + +for (let offset = 0; offset < traceSteps.length; offset += batchSize) { + if (request.jobId && isJobCancelled(request.jobId)) { + throw new DOMException( + "The user aborted the request.", + "AbortError", + ); + } + + const batch = traceSteps.slice(offset, offset + batchSize); + const stepsBuffer = encodeCipherSteps(batch); + const transferable = stepsBuffer.buffer as ArrayBuffer; + + workerScope.postMessage( + { + type: "TRACE_BATCH", + requestId, + jobId: request.jobId, + offset, + stepsBuffer: transferable, + }, + [transferable], + ); + + await new Promise((resolve) => { + const acknowledge = () => { + workerScope.removeEventListener("message", acknowledge); + resolve(); + }; + + workerScope.addEventListener("message", acknowledge); + }); +} + +workerScope.postMessage({ + type: "TRACE_COMPLETE", + requestId, + jobId: request.jobId, +}); + +const response: WorkerResponse = { + requestId, + success: true, + payload: { + result: { ...result, steps: [] }, + }, + timings: { durationMs }, +}; + +workerScope.postMessage(response); } catch (error: unknown) { const durationMs = performance.now() - startTime; const { code, message } = toErrorDetails(error); diff --git a/tests/unit/cipher/traceBuffer.test.ts b/tests/unit/cipher/traceBuffer.test.ts new file mode 100644 index 00000000..99349d93 --- /dev/null +++ b/tests/unit/cipher/traceBuffer.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import type { CipherStep } from "@/lib/cipher/types"; +import { TraceBuffer } from "@/lib/trace/traceBuffer"; + +function makeStep(index: number): CipherStep { + return { + index, + label: `Step ${index}`, + inputState: `in-${index}`, + outputState: `out-${index}`, + }; +} + +describe("TraceBuffer", () => { + it("keeps the active buffer bounded", () => { + const buffer = new TraceBuffer({ + capacity: 3, + retainCompleted: false, + }); + + buffer.pushBatch( + Array.from({ length: 10 }, (_, index) => makeStep(index)), + ); + + expect(buffer.getStats().retained).toBe(3); + }); + + it("retains a completed trace when retention is enabled", () => { + const buffer = new TraceBuffer({ + capacity: 3, + retainCompleted: true, + }); + + buffer.pushBatch( + Array.from({ length: 5 }, (_, index) => makeStep(index)), + ); + + buffer.complete(); + + expect(buffer.getStats().completed).toBe(true); + expect(buffer.toArray()).toHaveLength(5); + }); + + it("cleans up cancelled traces", () => { + const buffer = new TraceBuffer({ + capacity: 4, + }); + + buffer.push(makeStep(0)); + buffer.push(makeStep(1)); + + buffer.cancel(); + + expect(buffer.getStats().cancelled).toBe(true); + expect(buffer.getStats().retained).toBe(0); + expect(buffer.push(makeStep(2))).toBe(false); + }); + + it("supports explicit disposal", () => { + const buffer = new TraceBuffer(); + + buffer.push(makeStep(0)); + buffer.dispose(); + + expect(buffer.getStats().retained).toBe(0); + }); +}); \ No newline at end of file diff --git a/tests/unit/components/cipher/CipherExecutionController.test.ts b/tests/unit/components/cipher/CipherExecutionController.test.ts index 16b0084f..27aa5af9 100644 --- a/tests/unit/components/cipher/CipherExecutionController.test.ts +++ b/tests/unit/components/cipher/CipherExecutionController.test.ts @@ -30,3 +30,22 @@ describe("CipherExecutionController", () => { expect(buildCipherWorkerOptions("camellia", base, false)).toMatchObject({ padding: "PKCS7", mode: "CBC" }); }); }); +it("uses a fresh AbortController for each execution", () => { + const first = new AbortController(); + const second = new AbortController(); + + expect(first.signal).not.toBe(second.signal); + + first.abort(); + + expect(first.signal.aborted).toBe(true); + expect(second.signal.aborted).toBe(false); +}); + +it("does not allow an obsolete execution to become the active result", () => { + const controller = new AbortController(); + + controller.abort(); + + expect(controller.signal.aborted).toBe(true); +}); \ No newline at end of file diff --git a/tests/unit/workers/stepTransfer.test.ts b/tests/unit/workers/stepTransfer.test.ts index 256d7a01..f7c69353 100644 --- a/tests/unit/workers/stepTransfer.test.ts +++ b/tests/unit/workers/stepTransfer.test.ts @@ -42,3 +42,21 @@ describe('cipher step transfer protocol', () => { ) }) }) +it("processes trace data in bounded batches", () => { + const steps = Array.from({ length: 100 }, (_, index) => ({ + ...step, + index, + label: `Round ${index + 1}`, + })); + + const batchSize = 16; + const batches = []; + + for (let offset = 0; offset < steps.length; offset += batchSize) { + batches.push(steps.slice(offset, offset + batchSize)); + } + + expect(batches.length).toBe(7); + expect(Math.max(...batches.map((batch) => batch.length))).toBe(16); + expect(batches.flat()).toEqual(steps); +}); \ No newline at end of file diff --git a/types/worker.ts b/types/worker.ts index 036db0d8..02c5db5c 100644 --- a/types/worker.ts +++ b/types/worker.ts @@ -46,6 +46,31 @@ export interface WorkerProgressMessage { currentMilestone: string } +export interface WorkerTraceStartMessage { + type: 'TRACE_START' + requestId: string + jobId?: string + totalSteps: number +} + +export interface WorkerTraceBatchMessage { + type: 'TRACE_BATCH' + requestId: string + jobId?: string + offset: number + stepsBuffer: ArrayBuffer +} + +export interface WorkerTraceCompleteMessage { + type: 'TRACE_COMPLETE' + requestId: string + jobId?: string +} + +export interface WorkerTraceAckMessage { + type: 'TRACE_ACK' + requestId: string +} export interface WorkerResponsePayload { result?: CipherResult /** Serialized trace for large results, transferred as an ArrayBuffer. */ @@ -96,9 +121,12 @@ export type WorkerResponse = WorkerResponseSuccess | WorkerResponseFailure export type WorkerProtocolMessage = | WorkerMessage | WorkerProgressMessage + | WorkerTraceStartMessage + | WorkerTraceBatchMessage + | WorkerTraceCompleteMessage + | WorkerTraceAckMessage | WorkerErrorMessage | WorkerResponse - export interface WorkerResponseTimings { durationMs: number } From c485f5297a7ee8ae08d79fe0bc11ca4e248aad25 Mon Sep 17 00:00:00 2001 From: Revati Kadam Date: Mon, 31 Aug 2026 14:24:13 +0530 Subject: [PATCH 2/2] 1772 --- hooks/useCipherWorker.ts | 23 +- lib/cipher/parameterValidation.ts | 1024 ++++++++++++++++++ lib/cipher/registry.ts | 19 + lib/utils/errors.ts | 6 +- lib/workers/cipher.worker.ts | 65 +- tests/unit/cipherParameterValidation.test.ts | 169 +++ tests/unit/workers/cipherWorker.test.ts | 50 + types/worker.ts | 9 +- 8 files changed, 1334 insertions(+), 31 deletions(-) create mode 100644 lib/cipher/parameterValidation.ts create mode 100644 tests/unit/cipherParameterValidation.test.ts diff --git a/hooks/useCipherWorker.ts b/hooks/useCipherWorker.ts index 0ca17652..5f515295 100644 --- a/hooks/useCipherWorker.ts +++ b/hooks/useCipherWorker.ts @@ -10,8 +10,7 @@ import type { WorkerTraceCompleteMessage, } from '@/types/worker'import type { WorkerPriority } from '@/lib/workers/pool' import type { WorkerProgressMessage } from '@/lib/workers/cipher-worker-protocol' -import { CipherError } from '@/lib/utils/errors' -import { decodeCipherSteps } from '@/lib/workers/stepTransfer' +import { CipherError, type CipherErrorCode } from '@/lib/utils/errors'import { decodeCipherSteps } from '@/lib/workers/stepTransfer' const MAX_CACHE_SIZE = 200 const WORKER_TIMEOUT_MS = 10000 @@ -136,12 +135,20 @@ export function useCipherWorker() { reject(error) } } else { - const errorMsg = payload?.error ?? 'Operation failed in worker' - const code = payload?.errorCode - const cipherErr = code && code !== 'INVALID_WORKER_MESSAGE' - ? new CipherError(code, errorMsg) - : new Error(errorMsg) - setError(errorMsg) +const errorMsg = + payload?.error ?? + payload?.errorMessage ?? + 'Operation failed in worker' + +const code = payload?.errorCode + +const cipherErr = + code && code !== 'INVALID_WORKER_MESSAGE' + ? new CipherError(code as CipherErrorCode, errorMsg, { + details: payload?.errorDetails, + remediation: payload?.remediation, + }) + : new Error(errorMsg) setError(errorMsg) reject(cipherErr) } } diff --git a/lib/cipher/parameterValidation.ts b/lib/cipher/parameterValidation.ts new file mode 100644 index 00000000..091e25e4 --- /dev/null +++ b/lib/cipher/parameterValidation.ts @@ -0,0 +1,1024 @@ +import type { CipherDefinition } from "./registry"; +import type { CipherOptions } from "./types"; +import { CipherError, type CipherErrorCode } from "../utils/errors"; + +export type ParameterSource = "input" | "key" | "option"; + +export type ParameterType = + | "string" + | "number" + | "numberString" + | "boolean" + | "hex" + | "enum" + | "composite"; + +export interface ParameterPart { + id: string; + label: string; + type: ParameterType; + required?: boolean; + exactLengthBytes?: number; + allowedLengthsBytes?: number[]; + min?: number; + max?: number; + integer?: boolean; + pattern?: string; +} + +export interface ParameterRule { + id: string; + label: string; + source: ParameterSource; + type: ParameterType; + required?: boolean; + description?: string; + warning?: string; + min?: number; + max?: number; + integer?: boolean; + minLength?: number; + maxLength?: number; + exactLengthBytes?: number; + allowedLengthsBytes?: number[]; + choices?: unknown[]; + pattern?: string; + parts?: ParameterPart[]; + separator?: string; + distinctParts?: string[]; +} + +export interface ParameterDependency { + when: { + parameter: string; + equals?: unknown; + notEquals?: unknown; + }; + require?: string[]; + forbid?: string[]; +} + +export interface CipherParameterSchema { + cipherId: string; + parameters: ParameterRule[]; + dependencies?: ParameterDependency[]; + warnings?: string[]; +} + +export interface ParameterValidationIssue { + parameter: string; + code: CipherErrorCode; + message: string; + expected?: unknown; + actual?: unknown; +} + +export interface ParameterValidationResult { + valid: boolean; + issues: ParameterValidationIssue[]; + warnings: string[]; +} + +function getParameterValue( + rule: ParameterRule, + input: string, + key: string, + options: CipherOptions, +): unknown { + if (rule.source === "input") return input; + if (rule.source === "key") return key; + return options[rule.id]; +} + +function displayExpected(rule: ParameterRule): string { + if (rule.allowedLengthsBytes?.length) { + return rule.allowedLengthsBytes.join(", ") + " bytes"; + } + + if (rule.exactLengthBytes !== undefined) { + return `${rule.exactLengthBytes} bytes`; + } + + if (rule.min !== undefined && rule.max !== undefined) { + return `${rule.min}–${rule.max}`; + } + + if (rule.choices?.length) { + return rule.choices.join(", "); + } + + return rule.type; +} + +function byteLength(value: string): number { + return new TextEncoder().encode(value).length; +} + +function validateHex( + value: unknown, + rule: ParameterRule | ParameterPart, +): ParameterValidationIssue | undefined { + if (typeof value !== "string") { + return { + parameter: rule.id, + code: "INVALID_OPTION", + message: `${rule.label} must be a hexadecimal string.`, + expected: "hexadecimal string", + actual: typeof value, + }; + } + + const normalized = value.replace(/\s+/g, ""); + + if (normalized.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(normalized)) { + return { + parameter: rule.id, + code: "INVALID_OPTION", + message: `${rule.label} must contain only hexadecimal characters with an even length.`, + expected: "even-length hexadecimal string", + actual: value, + }; + } + + const bytes = normalized.length / 2; + + if ( + rule.exactLengthBytes !== undefined && + bytes !== rule.exactLengthBytes + ) { + return { + parameter: rule.id, + code: "INVALID_KEY_LENGTH", + message: `${rule.label} must be exactly ${rule.exactLengthBytes} bytes.`, + expected: `${rule.exactLengthBytes} bytes`, + actual: `${bytes} bytes`, + }; + } + + if ( + rule.allowedLengthsBytes && + !rule.allowedLengthsBytes.includes(bytes) + ) { + return { + parameter: rule.id, + code: "INVALID_KEY_LENGTH", + message: `${rule.label} must be ${displayExpected(rule)}.`, + expected: rule.allowedLengthsBytes, + actual: bytes, + }; + } + + return undefined; +} + +function validateScalar( + value: unknown, + rule: ParameterRule, +): ParameterValidationIssue | undefined { + if (value === undefined || value === null || value === "") { + if (rule.required) { + return { + parameter: rule.id, + code: + rule.source === "key" + ? "INVALID_KEY" + : rule.source === "input" + ? "INPUT_REQUIRED" + : "INVALID_OPTION", + message: `${rule.label} is required.`, + expected: displayExpected(rule), + }; + } + + return undefined; + } + + switch (rule.type) { + case "string": + if (typeof value !== "string") { + return { + parameter: rule.id, + code: "INVALID_OPTION", + message: `${rule.label} must be a string.`, + expected: "string", + actual: typeof value, + }; + } + + if ( + rule.minLength !== undefined && + value.length < rule.minLength + ) { + return { + parameter: rule.id, + code: "INVALID_OPTION", + message: `${rule.label} must contain at least ${rule.minLength} characters.`, + expected: rule.minLength, + actual: value.length, + }; + } + + if ( + rule.maxLength !== undefined && + value.length > rule.maxLength + ) { + return { + parameter: rule.id, + code: "INVALID_OPTION", + message: `${rule.label} must contain at most ${rule.maxLength} characters.`, + expected: rule.maxLength, + actual: value.length, + }; + } + break; + + case "number": + if (typeof value !== "number" || !Number.isFinite(value)) { + return { + parameter: rule.id, + code: "INVALID_OPTION", + message: `${rule.label} must be a finite number.`, + expected: "finite number", + actual: value, + }; + } + + if (rule.integer && !Number.isInteger(value)) { + return { + parameter: rule.id, + code: "INVALID_OPTION", + message: `${rule.label} must be an integer.`, + expected: "integer", + actual: value, + }; + } + + if (rule.min !== undefined && value < rule.min) { + return { + parameter: rule.id, + code: "INVALID_OPTION", + message: `${rule.label} must be at least ${rule.min}.`, + expected: rule.min, + actual: value, + }; + } + + if (rule.max !== undefined && value > rule.max) { + return { + parameter: rule.id, + code: "INVALID_OPTION", + message: `${rule.label} must be at most ${rule.max}.`, + expected: rule.max, + actual: value, + }; + } + break; + + case "numberString": { + if (typeof value !== "string" || !/^[+-]?\d+$/.test(value.trim())) { + return { + parameter: rule.id, + code: "INVALID_KEY", + message: `${rule.label} must be an integer value.`, + expected: "integer string", + actual: value, + }; + } + + const numericValue = Number(value); + + if ( + rule.min !== undefined && + numericValue < rule.min + ) { + return { + parameter: rule.id, + code: "INVALID_KEY", + message: `${rule.label} must be at least ${rule.min}.`, + expected: rule.min, + actual: numericValue, + }; + } + + if ( + rule.max !== undefined && + numericValue > rule.max + ) { + return { + parameter: rule.id, + code: "INVALID_KEY", + message: `${rule.label} must be at most ${rule.max}.`, + expected: rule.max, + actual: numericValue, + }; + } + break; + } + + case "boolean": + if (typeof value !== "boolean") { + return { + parameter: rule.id, + code: "INVALID_OPTION", + message: `${rule.label} must be true or false.`, + expected: "boolean", + actual: typeof value, + }; + } + break; + + case "hex": + return validateHex(value, rule); + + case "enum": + if (!rule.choices?.includes(value)) { + return { + parameter: rule.id, + code: "INVALID_OPTION", + message: `${rule.label} must be one of: ${rule.choices?.join(", ")}.`, + expected: rule.choices, + actual: value, + }; + } + break; + + case "composite": { + if (typeof value !== "string") { + return { + parameter: rule.id, + code: "INVALID_KEY", + message: `${rule.label} must be a string.`, + expected: "composite string", + actual: typeof value, + }; + } + + const parts = value.split(rule.separator ?? "|"); + + if (!rule.parts || parts.length !== rule.parts.length) { + return { + parameter: rule.id, + code: "INVALID_KEY", + message: `${rule.label} must contain exactly ${rule.parts?.length ?? 0} parts separated by "${rule.separator ?? "|"}".`, + expected: rule.parts?.map((part) => part.label), + actual: parts.length, + }; + } + + for (let index = 0; index < rule.parts.length; index += 1) { + const part = rule.parts[index]; + const issue = validateScalar(parts[index], { + id: part.id, + label: part.label, + source: "key", + type: part.type, + required: part.required ?? true, + min: part.min, + max: part.max, + integer: part.integer, + exactLengthBytes: part.exactLengthBytes, + allowedLengthsBytes: part.allowedLengthsBytes, + pattern: part.pattern, + }); + + if (issue) return issue; + } + + if (rule.distinctParts?.length) { + const indexes = rule.distinctParts.map((id) => + rule.parts!.findIndex((part) => part.id === id), + ); + + const values = indexes.map((index) => parts[index]); + + if ( + values.length > 1 && + new Set(values).size !== values.length + ) { + return { + parameter: rule.id, + code: "INVALID_KEY", + message: `${rule.label} requires the selected key parts to be different.`, + expected: "distinct key parts", + actual: values, + }; + } + } + + break; + } + } + + if (rule.pattern && typeof value === "string") { + if (!new RegExp(rule.pattern).test(value)) { + return { + parameter: rule.id, + code: "INVALID_OPTION", + message: `${rule.label} does not match the required format.`, + expected: rule.pattern, + actual: value, + }; + } + } + + return undefined; +} + +function mergeSchema( + base: CipherParameterSchema, + override?: Partial, +): CipherParameterSchema { + if (!override) return base; + + return { + ...base, + ...override, + parameters: override.parameters ?? base.parameters, + dependencies: override.dependencies ?? base.dependencies, + warnings: override.warnings ?? base.warnings, + }; +} + +const ALGORITHM_SCHEMAS: Record< + string, + Partial +> = { + aes: { + parameters: [ + { + id: "key", + label: "AES key", + source: "key", + type: "hex", + required: true, + allowedLengthsBytes: [16, 24, 32], + description: "AES accepts 128-, 192-, or 256-bit keys.", + }, + { + id: "mode", + label: "AES mode", + source: "option", + type: "enum", + choices: ["ECB", "CBC", "CTR", "CFB", "OFB"], + description: "Select the block-cipher mode.", + }, + { + id: "iv", + label: "Initialization vector", + source: "option", + type: "hex", + exactLengthBytes: 16, + description: "AES modes that use an IV require a 16-byte value when supplied.", + }, + { + id: "hexInput", + label: "Hex input mode", + source: "option", + type: "boolean", + }, + ], + dependencies: [ + { + when: { + parameter: "mode", + equals: "ECB", + }, + forbid: ["iv"], + }, + ], + warnings: [ + "ECB mode provides no semantic protection for repeated plaintext blocks.", + ], + }, + + "aes-xts": { + parameters: [ + { + id: "key", + label: "AES-XTS key pair", + source: "key", + type: "composite", + required: true, + parts: [ + { + id: "dataKey", + label: "Data key", + type: "hex", + allowedLengthsBytes: [16, 24, 32], + }, + { + id: "tweakKey", + label: "Tweak key", + type: "hex", + allowedLengthsBytes: [16, 24, 32], + }, + ], + distinctParts: ["dataKey", "tweakKey"], + separator: "|", + warning: "The XTS data and tweak keys must be different.", + }, + { + id: "input", + label: "XTS input", + source: "input", + type: "string", + required: true, + }, + ], + warnings: [ + "AES-XTS provides confidentiality but does not provide authentication.", + ], + }, + + "aes-ccm": { + parameters: [ + { + id: "key", + label: "AES-CCM parameters", + source: "key", + type: "composite", + required: true, + parts: [ + { + id: "keyHex", + label: "AES key", + type: "hex", + allowedLengthsBytes: [16, 24, 32], + }, + { + id: "nonceHex", + label: "CCM nonce", + type: "hex", + exactLengthBytes: 12, + }, + { + id: "aadHex", + label: "Associated data", + type: "hex", + required: false, + }, + ], + separator: "|", + }, + ], + }, + + "chacha20-poly1305": { + parameters: [ + { + id: "key", + label: "ChaCha20-Poly1305 parameters", + source: "key", + type: "composite", + required: true, + parts: [ + { + id: "keyHex", + label: "ChaCha20 key", + type: "hex", + exactLengthBytes: 32, + }, + { + id: "nonceHex", + label: "Poly1305 nonce", + type: "hex", + exactLengthBytes: 12, + }, + { + id: "aadHex", + label: "Associated data", + type: "hex", + required: false, + }, + ], + separator: "|", + }, + ], + }, + + xchacha20: { + parameters: [ + { + id: "key", + label: "XChaCha20 parameters", + source: "key", + type: "composite", + required: true, + parts: [ + { + id: "keyHex", + label: "XChaCha20 key", + type: "hex", + exactLengthBytes: 32, + }, + { + id: "nonceHex", + label: "XChaCha20 nonce", + type: "hex", + exactLengthBytes: 24, + }, + ], + separator: "|", + }, + ], + }, + + xsalsa20: { + parameters: [ + { + id: "key", + label: "XSalsa20 parameters", + source: "key", + type: "composite", + required: true, + parts: [ + { + id: "keyHex", + label: "XSalsa20 key", + type: "hex", + exactLengthBytes: 32, + }, + { + id: "nonceHex", + label: "XSalsa20 nonce", + type: "hex", + exactLengthBytes: 24, + }, + ], + separator: "|", + }, + ], + }, + + des: { + parameters: [ + { + id: "key", + label: "DES key", + source: "key", + type: "hex", + required: true, + exactLengthBytes: 8, + }, + ], + warnings: [ + "DES is obsolete and should only be used for educational or legacy compatibility purposes.", + ], + }, + + "3des": { + parameters: [ + { + id: "key", + label: "3DES key", + source: "key", + type: "hex", + required: true, + allowedLengthsBytes: [16, 24], + }, + ], + warnings: [ + "3DES is legacy cryptography and should not be selected for new systems.", + ], + }, + + rsa: { + parameters: [ + { + id: "key", + label: "RSA key parameters", + source: "key", + type: "string", + required: true, + minLength: 1, + }, + { + id: "inputEncoding", + label: "RSA input encoding", + source: "option", + type: "enum", + choices: ["integer", "text", "hex"], + }, + { + id: "demoMode", + label: "RSA demo mode", + source: "option", + type: "boolean", + }, + ], + warnings: [ + "The visualizer may use small educational RSA parameters in demo mode; these are not production-secure.", + ], + }, + + dh: { + parameters: [ + { + id: "key", + label: "Diffie-Hellman parameters", + source: "key", + type: "string", + required: true, + pattern: "^\\s*(?:p\\s*=\\s*)?\\d+\\s*[,\\s]+(?:g\\s*=\\s*)?\\d+\\s*$", + }, + { + id: "bobSecret", + label: "Bob secret", + source: "option", + type: "numberString", + min: 1, + }, + ], + warnings: [ + "Small DH parameters are suitable for visualization only, not real security.", + ], + }, + + dsa: { + parameters: [ + { + id: "key", + label: "DSA key parameters", + source: "key", + type: "string", + required: true, + pattern: "^\\s*\\d+\\s*[,\\s]+\\d+\\s*[,\\s]+\\d+\\s*[,\\s]+\\d+\\s*$", + }, + ], + warnings: [ + "The visualizer's small DSA parameters are educational and are not production-strength parameters.", + ], + }, + + ecdsa: { + parameters: [ + { + id: "key", + label: "ECDSA private/public key", + source: "key", + type: "hex", + required: true, + exactLengthBytes: 32, + }, + ], + }, + + pbkdf2: { + parameters: [ + { + id: "input", + label: "Password", + source: "input", + type: "string", + required: true, + }, + { + id: "key", + label: "Salt", + source: "key", + type: "string", + required: true, + minLength: 1, + }, + { + id: "iterations", + label: "PBKDF2 iterations", + source: "option", + type: "number", + required: true, + integer: true, + min: 1, + max: 10_000_000, + }, + { + id: "keyLength", + label: "Derived key length", + source: "option", + type: "number", + required: true, + integer: true, + min: 1, + max: 1024, + }, + ], + warnings: [ + "Higher iteration counts improve password-cracking resistance but increase execution cost.", + ], + }, + + argon2: { + parameters: [ + { + id: "memoryCost", + label: "Memory cost", + source: "option", + type: "number", + required: true, + integer: true, + min: 8, + max: 1_048_576, + }, + { + id: "timeCost", + label: "Time cost", + source: "option", + type: "number", + required: true, + integer: true, + min: 1, + max: 100, + }, + { + id: "parallelism", + label: "Parallelism", + source: "option", + type: "number", + required: true, + integer: true, + min: 1, + max: 64, + }, + { + id: "keyLength", + label: "Output length", + source: "option", + type: "number", + required: true, + integer: true, + min: 4, + max: 1024, + }, + ], + }, +}; + +function createGenericSchema( + definition: CipherDefinition, +): CipherParameterSchema { + const parameters: ParameterRule[] = [ + { + id: "input", + label: "Input", + source: "input", + type: "string", + required: true, + minLength: 1, + }, + ]; + + if (definition.defaultKey.trim()) { + parameters.push({ + id: "key", + label: "Key", + source: "key", + type: "string", + required: true, + minLength: 1, + }); + } + + for (const option of definition.options ?? []) { + parameters.push({ + id: option.id, + label: option.name, + source: "option", + type: + option.type === "select" + ? "enum" + : option.type, + choices: option.choices?.map((choice) => choice.value), + }); + } + + return { + cipherId: definition.id, + parameters, + }; +} + +export function buildCipherParameterSchema( + definition: CipherDefinition, +): CipherParameterSchema { + return mergeSchema( + createGenericSchema(definition), + ALGORITHM_SCHEMAS[definition.id], + ); +} + +export function validateCipherParameters( + definition: CipherDefinition, + input: string, + key: string, + options: CipherOptions = {}, +): ParameterValidationResult { + const schema = buildCipherParameterSchema(definition); + const issues: ParameterValidationIssue[] = []; + + for (const rule of schema.parameters) { + const value = getParameterValue(rule, input, key, options); + const issue = validateScalar(value, rule); + + if (issue) { + issues.push(issue); + } + } + + for (const dependency of schema.dependencies ?? []) { + const dependencyValue = + dependency.when.parameter === "input" + ? input + : dependency.when.parameter === "key" + ? key + : options[dependency.when.parameter]; + + const matches = + dependency.when.equals !== undefined + ? dependencyValue === dependency.when.equals + : dependency.when.notEquals !== undefined + ? dependencyValue !== dependency.when.notEquals + : true; + + if (!matches) continue; + + for (const requiredParameter of dependency.require ?? []) { + const value = + requiredParameter === "input" + ? input + : requiredParameter === "key" + ? key + : options[requiredParameter]; + + if ( + value === undefined || + value === null || + value === "" + ) { + issues.push({ + parameter: requiredParameter, + code: "INVALID_OPTION", + message: `${requiredParameter} is required for the selected configuration.`, + expected: "provided", + }); + } + } + + for (const forbiddenParameter of dependency.forbid ?? []) { + const value = + forbiddenParameter === "input" + ? input + : forbiddenParameter === "key" + ? key + : options[forbiddenParameter]; + + if (value !== undefined && value !== null && value !== "") { + issues.push({ + parameter: forbiddenParameter, + code: "INVALID_OPTION", + message: `${forbiddenParameter} cannot be used with the selected configuration.`, + expected: "not provided", + actual: value, + }); + } + } + } + + return { + valid: issues.length === 0, + issues, + warnings: schema.warnings ?? [], + }; +} + +export function assertValidCipherParameters( + definition: CipherDefinition, + input: string, + key: string, + options: CipherOptions = {}, +): void { + const result = validateCipherParameters( + definition, + input, + key, + options, + ); + + if (result.valid) return; + + const firstIssue = result.issues[0]; + + throw new CipherError( + firstIssue.code, + firstIssue.message, + { + details: { + type: "parameter-validation", + cipherId: definition.id, + parameter: firstIssue.parameter, + expected: firstIssue.expected, + actual: firstIssue.actual, + issues: result.issues, + warnings: result.warnings, + }, + }, + ); +} \ No newline at end of file diff --git a/lib/cipher/registry.ts b/lib/cipher/registry.ts index a450f6ef..d7fa1f51 100644 --- a/lib/cipher/registry.ts +++ b/lib/cipher/registry.ts @@ -6,6 +6,12 @@ * @returns The operation result produced by the cipher engine. * @see https://csrc.nist.gov/pubs/fips/197/final — FIPS 197. */ +import { KalynaEngine } from './kalyna/kalynaEngine'; +import { BaseCipher } from './baseCipher'; +import { + buildCipherParameterSchema, + type CipherParameterSchema, +} from './parameterValidation'; export type CipherOptionValue = string | number | boolean // Add to cipher registry definitions: // csidhDefinition, @@ -2276,4 +2282,17 @@ export const CIPHER_REGISTRY: CipherDefinition[] = [ securityStatus: 'recommended', options: [{ name: 'Disclosed Indices', id: 'disclosedIndices', type: 'text', default: '[0]' }] }, + export function getCipherParameterSchema( + cipherId: string, +): CipherParameterSchema | undefined { + const definition = CIPHER_REGISTRY.find( + (cipher) => cipher.id === cipherId, + ); + + if (!definition) { + return undefined; + } + + return buildCipherParameterSchema(definition); +} ]; diff --git a/lib/utils/errors.ts b/lib/utils/errors.ts index 14acc768..85b86a20 100644 --- a/lib/utils/errors.ts +++ b/lib/utils/errors.ts @@ -25,7 +25,7 @@ export type CipherErrorCode = | "AUTH_TAG_MISMATCH" | "INVALID_AAD" | "INVALID_OPTION" - | "WORKER_TIMEOUT" + | "PARAMETER_VALIDATION_FAILED" | "WORKER_TIMEOUT" | "WORKLOAD_INPUT_LIMIT" | "WORKLOAD_KEY_LIMIT" | "WORKLOAD_TRACE_LIMIT" @@ -53,11 +53,11 @@ export function categorizeErrorCode(code: string): ErrorCategory { if ( code.startsWith("OPTION") || code.endsWith("_OPTION") || + code === "PARAMETER_VALIDATION_FAILED" || code.includes("PADDING") || code.includes("IV") || code.includes("AAD") - ) { - return "OPTION"; + ) { return "OPTION"; } if ( code.startsWith("ALGORITHM") || diff --git a/lib/workers/cipher.worker.ts b/lib/workers/cipher.worker.ts index c7cc502d..3b313b8c 100644 --- a/lib/workers/cipher.worker.ts +++ b/lib/workers/cipher.worker.ts @@ -15,7 +15,8 @@ import { import { CipherError, validateInput } from "../utils/errors"; import type { WorkerRequest, WorkerResponse } from "../../types/worker"; import type { CipherResult } from "../cipher/types"; -import { +import { CIPHER_REGISTRY } from "../cipher/registry"; +import { assertValidCipherParameters } from "../cipher/parameterValidation";import { encodeCipherSteps, WORKER_STEP_TRANSFER_THRESHOLD, } from "./stepTransfer"; @@ -572,9 +573,16 @@ function decodeWorkerRequest(data: WorkerRequestMessage): WorkerRequest { function toErrorDetails(error: unknown): { code?: import("../utils/errors").CipherErrorCode | "INVALID_WORKER_MESSAGE"; message: string; + details?: unknown; + remediation?: string; } { if (error instanceof CipherError) { - return { code: error.code as any, message: error.message }; + return { + code: error.code, + message: error.message, + details: error.details, + remediation: error.remediation, + }; } if (error instanceof Error) { @@ -583,7 +591,6 @@ function toErrorDetails(error: unknown): { return { message: String(error) }; } - workerScope.addEventListener( "message", async (event: MessageEvent) => { @@ -617,8 +624,18 @@ if (request.jobId && isJobCancelled(request.jobId)) { } const { type, payload } = request; - const { cipherId, input, key, options } = payload; +const { cipherId, input, key, options } = payload; + +const cipherDefinition = CIPHER_REGISTRY.find( + (definition) => definition.id === cipherId, +); +if (!cipherDefinition) { + throw new CipherError( + "ALGORITHM_UNSUPPORTED", + `Unsupported cipher ID: ${cipherId}`, + ); +} // The worker is a trust boundary too. Never rely solely on the UI hook // to enforce resource limits because callers can post directly to it. const limits = resolveWorkloadLimits("cipher", cipherId); @@ -646,13 +663,26 @@ if (request.jobId && isJobCancelled(request.jobId)) { throw new CipherError("INVALID_KEY", "Key must be a string."); } - if (options !== undefined && - (typeof options !== "object" || options === null || Array.isArray(options))) { - throw new CipherError("INVALID_INPUT", "Cipher options must be an object."); + if ( + options !== undefined && + (typeof options !== "object" || + options === null || + Array.isArray(options)) + ) { + throw new CipherError( + "INVALID_INPUT", + "Cipher options must be an object.", + ); } - activeJobs += 1; - jobStarted = true; + assertValidCipherParameters( + cipherDefinition, + input, + key, + options ?? {}, + ); + + activeJobs += 1; jobStarted = true; const dispatcher = await getDispatcher(cipherId); const handler = payload.type === "encrypt" ? dispatcher.encrypt : dispatcher.decrypt; @@ -750,17 +780,18 @@ const response: WorkerResponse = { workerScope.postMessage(response); } catch (error: unknown) { const durationMs = performance.now() - startTime; - const { code, message } = toErrorDetails(error); - +const { code, message, details, remediation } = + toErrorDetails(error); const response: WorkerResponse = { requestId, success: false, - payload: { - error: message, - errorCode: code, - errorMessage: message, - }, - timings: { durationMs }, +payload: { + error: message, + errorCode: code, + errorMessage: message, + errorDetails: details, + remediation, +}, timings: { durationMs }, }; workerScope.postMessage(response); diff --git a/tests/unit/cipherParameterValidation.test.ts b/tests/unit/cipherParameterValidation.test.ts new file mode 100644 index 00000000..ca918f71 --- /dev/null +++ b/tests/unit/cipherParameterValidation.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from "vitest"; +import { + assertValidCipherParameters, + buildCipherParameterSchema, + validateCipherParameters, +} from "../../lib/cipher/parameterValidation"; +import { CIPHER_REGISTRY } from "../../lib/cipher/registry"; +import { CipherError } from "../../lib/utils/errors"; + +function getCipher(id: string) { + const definition = CIPHER_REGISTRY.find( + (cipher) => cipher.id === id, + ); + + if (!definition) { + throw new Error(`Missing test cipher: ${id}`); + } + + return definition; +} + +describe("cryptographic parameter validation framework", () => { + it("exposes a schema for every registered cipher", () => { + for (const definition of CIPHER_REGISTRY) { + const schema = buildCipherParameterSchema(definition); + + expect(schema.cipherId).toBe(definition.id); + expect(schema.parameters.length).toBeGreaterThan(0); + } + }); + + it("accepts a valid AES-128 configuration", () => { + const result = validateCipherParameters( + getCipher("aes"), + "00112233445566778899aabbccddeeff", + "000102030405060708090a0b0c0d0e0f", + { + mode: "CBC", + hexInput: true, + }, + ); + + expect(result.valid).toBe(true); + expect(result.issues).toHaveLength(0); + }); + + it("rejects an invalid AES key length", () => { + const result = validateCipherParameters( + getCipher("aes"), + "00112233445566778899aabbccddeeff", + "000102030405060708090a0b0c", + { + mode: "CBC", + hexInput: true, + }, + ); + + expect(result.valid).toBe(false); + expect(result.issues[0]).toMatchObject({ + parameter: "key", + code: "INVALID_KEY_LENGTH", + }); + }); + + it("rejects an IV when AES is configured for ECB", () => { + const result = validateCipherParameters( + getCipher("aes"), + "00112233445566778899aabbccddeeff", + "000102030405060708090a0b0c0d0e0f", + { + mode: "ECB", + iv: "00000000000000000000000000000000", + }, + ); + + expect(result.valid).toBe(false); + expect(result.issues[0]).toMatchObject({ + parameter: "iv", + code: "INVALID_OPTION", + }); + }); + + it("rejects an invalid AES-CCM nonce length", () => { + const result = validateCipherParameters( + getCipher("aes-ccm"), + "48656c6c6f", + "2b7e151628aed2a6abf7158809cf4f3c|0001020304050607", + ); + + expect(result.valid).toBe(false); + expect(result.issues[0]).toMatchObject({ + parameter: "nonceHex", + }); + }); + + it("rejects identical AES-XTS key parts", () => { + const key = + "000102030405060708090a0b0c0d0e0f|" + + "000102030405060708090a0b0c0d0e0f"; + + const result = validateCipherParameters( + getCipher("aes-xts"), + "0|00112233445566778899aabbccddeeff", + key, + ); + + expect(result.valid).toBe(false); + expect(result.issues[0]).toMatchObject({ + parameter: "key", + code: "INVALID_KEY", + }); + }); + + it("validates PBKDF2 numeric boundaries", () => { + const valid = validateCipherParameters( + getCipher("pbkdf2"), + "password", + "salt", + { + iterations: 1, + keyLength: 1, + }, + ); + + expect(valid.valid).toBe(true); + + const invalid = validateCipherParameters( + getCipher("pbkdf2"), + "password", + "salt", + { + iterations: 0, + keyLength: 0, + }, + ); + + expect(invalid.valid).toBe(false); + expect(invalid.issues.length).toBeGreaterThanOrEqual(2); + }); + + it("returns structured CipherError details", () => { + expect(() => + assertValidCipherParameters( + getCipher("aes"), + "00112233445566778899aabbccddeeff", + "000102030405060708090a0b0c", + { mode: "CBC" }, + ), + ).toThrowError(CipherError); + + try { + assertValidCipherParameters( + getCipher("aes"), + "00112233445566778899aabbccddeeff", + "000102030405060708090a0b0c", + { mode: "CBC" }, + ); + } catch (error) { + expect(error).toMatchObject({ + code: "INVALID_KEY_LENGTH", + details: { + type: "parameter-validation", + cipherId: "aes", + parameter: "key", + }, + }); + } + }); +}); \ No newline at end of file diff --git a/tests/unit/workers/cipherWorker.test.ts b/tests/unit/workers/cipherWorker.test.ts index c2bddcfa..4efcdd6c 100644 --- a/tests/unit/workers/cipherWorker.test.ts +++ b/tests/unit/workers/cipherWorker.test.ts @@ -143,3 +143,53 @@ describe("Worker Communication Suite", () => { }); }); }); + it("rejects invalid cryptographic parameters before execution", async () => { + const addEventListenerSpy = vi.spyOn(globalThis as any, "addEventListener"); + const postMessageSpy = vi + .spyOn(globalThis as any, "postMessage") + .mockImplementation((data) => { + structuredClone(data); + }); + + await import("@/lib/workers/cipher.worker"); + + const messageCall = addEventListenerSpy.mock.calls.find( + (call) => call[0] === "message", + ); + + expect(messageCall).toBeDefined(); + + const listener = messageCall![1] as any; + + await listener({ + data: { + type: "EXECUTE", + requestId: "req-invalid-aes-key", + payload: { + type: "encrypt", + cipherId: "aes", + input: "00112233445566778899aabbccddeeff", + key: "000102030405060708090a0b0c", + options: { + mode: "CBC", + }, + }, + }, + }); + + const response = postMessageSpy.mock.calls + .map((call) => call[0] as any) + .find( + (message) => + message?.requestId === "req-invalid-aes-key", + ); + + expect(response).toBeDefined(); + expect(response.success).toBe(false); + expect(response.payload.errorCode).toBe("INVALID_KEY_LENGTH"); + expect(response.payload.errorDetails).toMatchObject({ + type: "parameter-validation", + cipherId: "aes", + parameter: "key", + }); + }); \ No newline at end of file diff --git a/types/worker.ts b/types/worker.ts index 02c5db5c..16466628 100644 --- a/types/worker.ts +++ b/types/worker.ts @@ -78,8 +78,9 @@ export interface WorkerResponsePayload { error?: string errorCode?: import('@/lib/utils/errors').CipherErrorCode | 'INVALID_WORKER_MESSAGE' errorMessage?: string + errorDetails?: unknown + remediation?: string } - export interface WorkerErrorMessage { type: 'ERROR' jobId?: string @@ -99,7 +100,8 @@ export interface WorkerResponseSuccess { error?: never errorCode?: never errorMessage?: never - } + errorDetails?: never + remediation?: never } timings?: WorkerResponseTimings } @@ -112,7 +114,8 @@ export interface WorkerResponseFailure { error?: string errorCode?: import('@/lib/utils/errors').CipherErrorCode | 'INVALID_WORKER_MESSAGE' errorMessage?: string - } + errorDetails?: unknown + remediation?: string } timings?: WorkerResponseTimings }