Files
privacy-tools/src/privacy/sanitize.ts
T
zemion 38fa45dbaa
Verify / verify (push) Canceled after 0s
Release Privacy Tools 0.2.0
2026-09-02 10:09:14 +02:00

461 lines
15 KiB
TypeScript

import {
digestHex,
sanitizeDownloadFilename,
} from "@add-ideas/toolbox-helpers";
import type {
ImageScanResult,
MetadataFinding,
PixelComparison,
SanitizedAsset,
SanitizationReport,
} from "./model";
import { scanImageBytes } from "./scanner";
export interface SanitizeOptions {
jpegQuality?: number;
webpQuality?: number;
signal?: AbortSignal;
/** A caller-selected, non-identifying output name. The detected extension is enforced. */
outputName?: string;
}
export async function sanitizeStaticImage(
file: File,
source: ImageScanResult,
options: SanitizeOptions = {},
): Promise<SanitizedAsset> {
if (!source.cleanable)
throw new TypeError(
`${source.name} is not a supported static clean-copy input.`,
);
if (source.coverage.projectScanner !== "complete")
throw new TypeError("The source project scan is not complete.");
if (file.size !== source.size || file.name !== source.name)
throw new TypeError(
"The selected source file no longer matches its scan result.",
);
throwIfAborted(options.signal);
await verifySourceHash(file, source);
throwIfAborted(options.signal);
const mime = outputMime(source.identity.detectedKind);
const decoded = await decodeDrawable(file, source, options.signal);
const canvas = document.createElement("canvas");
canvas.width = decoded.outputWidth;
canvas.height = decoded.outputHeight;
const context = canvas.getContext("2d", {
alpha: true,
colorSpace: "srgb",
willReadFrequently: false,
});
if (!context) throw new Error("The browser did not provide a 2D canvas.");
context.save();
applyOrientationTransform(
context,
decoded.orientationToApply,
decoded.rawWidth,
decoded.rawHeight,
);
context.drawImage(decoded.drawable, 0, 0);
context.restore();
decoded.close();
throwIfAborted(options.signal);
const sourceSample = await sampleDigest(canvas);
const quality =
mime === "image/jpeg"
? boundedQuality(options.jpegQuality ?? 0.92)
: mime === "image/webp"
? boundedQuality(options.webpQuality ?? 0.92)
: undefined;
const blob = await canvasToBlob(canvas, mime, quality);
if (blob.type !== mime)
throw new Error(
`This browser encoded ${blob.type || "an unknown format"} instead of ${mime}.`,
);
throwIfAborted(options.signal);
const outputBytes = await blob.arrayBuffer();
const outputName = options.outputName
? enforceOutputExtension(options.outputName, source.identity.detectedKind)
: cleanOutputName(source.safeName, source.identity.detectedKind);
const outputScan = await scanImageBytes({
id: `${source.id}-clean`,
name: outputName,
claimedType: mime,
size: outputBytes.byteLength,
lastModified: 0,
bytes: outputBytes,
});
const outputSample = await sampleBlobDigest(blob);
const pixelComparison: PixelComparison = {
method: "oriented-256px-sample",
sourceDigest: sourceSample,
outputDigest: outputSample,
identical: sourceSample === outputSample,
note:
mime === "image/png"
? "The decoded, orientation-normalized sample should normally be identical."
: "JPEG and lossy WebP encoding may change decoded sample pixels even when the visible image is preserved.",
};
const report = buildSanitizationReport(
source,
outputScan,
outputName,
mime,
blob.size,
pixelComparison,
);
return { blob, report };
}
export function buildSanitizationReport(
source: ImageScanResult,
output: ImageScanResult,
outputName: string,
outputType: string,
outputBytes: number,
pixelComparison: PixelComparison,
): SanitizationReport {
const outputKeys = new Set(output.findings.map(findingSignature));
const sourceKeys = new Set(source.findings.map(findingSignature));
const removed = source.findings.filter(
(finding) => !outputKeys.has(findingSignature(finding)),
);
const preserved = source.findings.filter((finding) =>
outputKeys.has(findingSignature(finding)),
);
const generated = output.findings.filter(
(finding) => !sourceKeys.has(findingSignature(finding)),
);
const unsupported: string[] = [];
const incomplete: string[] = [];
if (source.coverage.projectScanner !== "complete")
incomplete.push(
"The source project scanner did not reach complete coverage.",
);
if (source.coverage.secondaryScanner !== "complete")
incomplete.push(
`The source secondary scanner status was ${source.coverage.secondaryScanner}.`,
);
if (output.coverage.projectScanner !== "complete")
incomplete.push(
"The output project scanner did not reach complete coverage.",
);
if (output.coverage.secondaryScanner !== "complete")
incomplete.push(
`The output secondary scanner status was ${output.coverage.secondaryScanner}.`,
);
if (source.findings.some((finding) => finding.category === "provenance"))
unsupported.push(
"Source provenance/signature data was removed or invalidated; authenticity cannot be carried through pixel re-encoding.",
);
const dimensionsMatch =
source.width === undefined ||
source.height === undefined ||
(output.width === expectedWidth(source) &&
output.height === expectedHeight(source));
if (!dimensionsMatch)
incomplete.push(
"Output dimensions do not match the expected oriented dimensions.",
);
const orientationNormalized =
(output.orientation === undefined || output.orientation === 1) &&
dimensionsMatch;
if (!orientationNormalized)
incomplete.push("Output orientation was not normalized to pixel order.");
if (output.identity.typeMatch !== "match")
incomplete.push("Output type, filename, or detected bytes do not agree.");
if (outputType === "image/png" && !pixelComparison.identical)
incomplete.push(
"Lossless PNG output did not preserve the decoded pixel sample.",
);
const unsafeOutput = output.findings.filter(
(finding) =>
finding.risk === "sensitive" ||
finding.category === "comment" ||
finding.category === "provenance",
);
const unexpectedBlocks = findUnexpectedOutputBlocks(output);
if (unexpectedBlocks.length > 0)
incomplete.push(
`Output contains unexpected metadata blocks: ${unexpectedBlocks.join(", ")}.`,
);
const failed =
output.coverage.projectScanner !== "complete" ||
unsafeOutput.length > 0 ||
output.animated ||
output.multiImage ||
!orientationNormalized ||
output.identity.typeMatch !== "match" ||
(outputType === "image/png" && !pixelComparison.identical);
const warning =
incomplete.length > 0 ||
output.coverage.secondaryScanner !== "complete" ||
unsupported.length > 0;
const status = failed ? "failed" : warning ? "warning" : "verified";
return {
sourceId: source.id,
sourceName: source.name,
outputName,
outputType,
sourceSha256: source.sha256,
outputSha256: output.sha256,
sourceBytes: source.size,
outputBytes,
sourceDimensions:
source.width !== undefined && source.height !== undefined
? { width: source.width, height: source.height }
: undefined,
outputDimensions:
output.width !== undefined && output.height !== undefined
? { width: output.width, height: output.height }
: undefined,
orientationNormalized,
removed,
preserved,
generated,
unsupported,
incomplete,
outputScan: output,
pixelComparison,
status,
summary:
status === "verified"
? "The pixel re-encode completed and both bounded output scanners found no sensitive metadata."
: status === "warning"
? "The pixel re-encode completed, but one or more verification limits require review."
: "The output did not pass the mandatory metadata verification gate.",
disclaimer:
"This report is not an anonymity guarantee. Visible faces or text, steganography, invisible watermarks, reverse-image matching, sidecars, filesystem records, and cloud copies are outside this tool's checks.",
};
}
async function verifySourceHash(
file: File,
source: ImageScanResult,
): Promise<void> {
const bytes = new Uint8Array(await file.arrayBuffer());
const currentHash = await digestHex(bytes, "SHA-256", source.size);
if (currentHash !== source.sha256)
throw new TypeError(
"The selected source bytes no longer match their scan hash.",
);
}
function findUnexpectedOutputBlocks(output: ImageScanResult): string[] {
const allowed =
output.identity.detectedKind === "jpeg"
? new Set(["APP0", "APP2"])
: output.identity.detectedKind === "png"
? new Set(["iCCP", "pHYs"])
: output.identity.detectedKind === "webp"
? new Set(["ICCP"])
: new Set<string>();
return [...new Set(output.blocks.map((block) => block.kind))].filter(
(kind) => !allowed.has(kind),
);
}
interface DecodedDrawable {
drawable: CanvasImageSource;
rawWidth: number;
rawHeight: number;
outputWidth: number;
outputHeight: number;
orientationToApply: number;
close(): void;
}
async function decodeDrawable(
file: File,
source: ImageScanResult,
signal?: AbortSignal,
): Promise<DecodedDrawable> {
if ("createImageBitmap" in globalThis) {
const bitmap = await createImageBitmap(file, { imageOrientation: "none" });
throwIfAborted(signal);
const orientation = normalizeOrientation(source.orientation);
const scannerWidth = source.width ?? bitmap.width;
const scannerHeight = source.height ?? bitmap.height;
const browserAlreadyOriented =
swapsAxes(orientation) &&
bitmap.width === scannerHeight &&
bitmap.height === scannerWidth;
const orientationToApply = browserAlreadyOriented ? 1 : orientation;
return {
drawable: bitmap,
rawWidth: bitmap.width,
rawHeight: bitmap.height,
outputWidth: swapsAxes(orientationToApply) ? bitmap.height : bitmap.width,
outputHeight: swapsAxes(orientationToApply)
? bitmap.width
: bitmap.height,
orientationToApply,
close: () => bitmap.close(),
};
}
const url = URL.createObjectURL(file);
try {
const image = new Image();
image.decoding = "async";
image.src = url;
await image.decode();
throwIfAborted(signal);
return {
drawable: image,
rawWidth: image.naturalWidth,
rawHeight: image.naturalHeight,
outputWidth: image.naturalWidth,
outputHeight: image.naturalHeight,
orientationToApply: 1,
close: () => undefined,
};
} finally {
URL.revokeObjectURL(url);
}
}
function applyOrientationTransform(
context: CanvasRenderingContext2D,
orientation: number,
width: number,
height: number,
): void {
if (orientation === 2) context.transform(-1, 0, 0, 1, width, 0);
else if (orientation === 3) context.transform(-1, 0, 0, -1, width, height);
else if (orientation === 4) context.transform(1, 0, 0, -1, 0, height);
else if (orientation === 5) context.transform(0, 1, 1, 0, 0, 0);
else if (orientation === 6) context.transform(0, 1, -1, 0, height, 0);
else if (orientation === 7) context.transform(0, -1, -1, 0, height, width);
else if (orientation === 8) context.transform(0, -1, 1, 0, 0, width);
}
async function sampleDigest(canvas: HTMLCanvasElement): Promise<string> {
const maximum = 256;
const scale = Math.min(1, maximum / canvas.width, maximum / canvas.height);
const width = Math.max(1, Math.round(canvas.width * scale));
const height = Math.max(1, Math.round(canvas.height * scale));
const sample = document.createElement("canvas");
sample.width = width;
sample.height = height;
const context = sample.getContext("2d", { willReadFrequently: true });
if (!context)
throw new Error("The browser could not create a verification canvas.");
context.drawImage(canvas, 0, 0, width, height);
const pixels = context.getImageData(0, 0, width, height).data;
return digestHex(pixels, "SHA-256", maximum * maximum * 4);
}
async function sampleBlobDigest(blob: Blob): Promise<string> {
const bitmap = await createImageBitmap(blob, { imageOrientation: "none" });
try {
const canvas = document.createElement("canvas");
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const context = canvas.getContext("2d");
if (!context)
throw new Error("The browser could not decode output pixels.");
context.drawImage(bitmap, 0, 0);
return sampleDigest(canvas);
} finally {
bitmap.close();
}
}
function canvasToBlob(
canvas: HTMLCanvasElement,
type: string,
quality?: number,
): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (blob) resolve(blob);
else reject(new Error(`The browser could not encode ${type}.`));
},
type,
quality,
);
});
}
function outputMime(kind: ImageScanResult["identity"]["detectedKind"]): string {
if (kind === "jpeg") return "image/jpeg";
if (kind === "png") return "image/png";
if (kind === "webp") return "image/webp";
throw new TypeError(`No clean-copy encoder is available for ${kind}.`);
}
function cleanOutputName(
input: string,
kind: ImageScanResult["identity"]["detectedKind"],
): string {
const extension = kind === "jpeg" ? "jpg" : kind;
const withoutExtension = input.replace(/\.[^.]*$/u, "") || "image";
return sanitizeDownloadFilename(`${withoutExtension}.clean.${extension}`);
}
export function genericOutputName(
index: number,
kind: ImageScanResult["identity"]["detectedKind"],
): string {
if (!Number.isSafeInteger(index) || index < 0)
throw new RangeError(
"Generic output index must be a non-negative integer.",
);
const extension = kind === "jpeg" ? "jpg" : kind;
if (!new Set(["jpg", "png", "webp"]).has(extension))
throw new TypeError(`No generic clean-copy name is available for ${kind}.`);
return `image-${String(index + 1).padStart(3, "0")}.clean.${extension}`;
}
function enforceOutputExtension(
input: string,
kind: ImageScanResult["identity"]["detectedKind"],
): string {
const extension = kind === "jpeg" ? "jpg" : kind;
const safe = sanitizeDownloadFilename(input, `image.clean.${extension}`);
const stem = safe.replace(/\.[^.]*$/u, "") || "image.clean";
return `${stem}.${extension}`;
}
function findingSignature(finding: MetadataFinding): string {
const label = finding.label
.toLowerCase()
.replace(/[^a-z0-9]+/gu, " ")
.trim();
return `${finding.category}\u0000${label}\u0000${finding.value}`;
}
function normalizeOrientation(value: number | undefined): number {
return value !== undefined && value >= 1 && value <= 8 ? value : 1;
}
function swapsAxes(orientation: number): boolean {
return orientation >= 5 && orientation <= 8;
}
function expectedWidth(scan: ImageScanResult): number | undefined {
if (scan.width === undefined || scan.height === undefined) return undefined;
return swapsAxes(normalizeOrientation(scan.orientation))
? scan.height
: scan.width;
}
function expectedHeight(scan: ImageScanResult): number | undefined {
if (scan.width === undefined || scan.height === undefined) return undefined;
return swapsAxes(normalizeOrientation(scan.orientation))
? scan.width
: scan.height;
}
function boundedQuality(value: number): number {
if (!Number.isFinite(value) || value < 0.5 || value > 1)
throw new RangeError("Image quality must be between 0.5 and 1.");
return value;
}
function throwIfAborted(signal?: AbortSignal): void {
if (signal?.aborted)
throw new DOMException("Operation cancelled", "AbortError");
}