Release Privacy Tools 0.1.0
This commit is contained in:
@@ -0,0 +1,435 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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 = 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}`);
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
Reference in New Issue
Block a user