Files
privacy-tools/src/privacy/png.ts
T
2026-09-01 02:39:44 +02:00

360 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { crc32 } from "@add-ideas/toolbox-helpers";
import { FindingCollector } from "./findings";
import { inflateZlibBounded } from "./inflate";
import type { MetadataBlock, PrivacyLimits } from "./model";
import type { FormatScan } from "./jpeg";
import { scanTiff } from "./tiff";
import { decodeMetadataText, scanXmp } from "./xmp";
export function scanPng(
bytes: Uint8Array,
collector: FindingCollector,
limits: Readonly<PrivacyLimits>,
): FormatScan {
const blocks: MetadataBlock[] = [];
let offset = 8;
let width: number | undefined;
let height: number | undefined;
let orientation: number | undefined;
let animated = false;
let complete = true;
let chunks = 0;
let sawEnd = false;
let sawHeader = false;
let sawImageData = false;
while (offset + 12 <= bytes.byteLength) {
const length = readU32be(bytes, offset);
const type = ascii(bytes, offset + 4, 4);
const dataOffset = offset + 8;
const end = dataOffset + length;
if (!/^[A-Za-z]{4}$/u.test(type) || end + 4 > bytes.byteLength) {
collector.warn(`PNG chunk at byte ${offset} is malformed or truncated.`);
complete = false;
break;
}
chunks += 1;
if (chunks > limits.maxMetadataBlocks) {
collector.warn(`PNG chunk count exceeded ${limits.maxMetadataBlocks}.`);
complete = false;
break;
}
const data = bytes.subarray(dataOffset, end);
const expectedCrc = readU32be(bytes, end);
const crcInput = bytes.subarray(offset + 4, end);
if (crc32(crcInput, limits.maxFileBytes) !== expectedCrc) {
collector.warn(`PNG ${type} chunk at byte ${offset} has an invalid CRC.`);
complete = false;
}
if (chunks === 1 && type !== "IHDR") {
collector.warn("PNG IHDR is not the first chunk.");
complete = false;
}
if (type === "IHDR") {
if (sawHeader || chunks !== 1 || length !== 13) {
collector.warn("PNG IHDR is duplicated, misplaced, or malformed.");
complete = false;
} else {
width = readU32be(data, 0);
height = readU32be(data, 4);
}
sawHeader = true;
} else if (type === "IDAT") {
sawImageData = true;
} else if (type === "IEND") {
if (length !== 0) {
collector.warn("PNG IEND chunk is not empty.");
complete = false;
}
sawEnd = true;
offset = end + 4;
break;
} else if (type === "acTL" || type === "fcTL" || type === "fdAT") {
animated = true;
blocks.push({ kind: type, offset: dataOffset, length });
collector.add({
category: "technical",
risk: "context",
source: "PNG",
label: "APNG animation",
value: `${type} chunk (${length} bytes)`,
offset: dataOffset,
length,
});
} else if (isMetadataChunk(type)) {
if (length > limits.maxMetadataBlockBytes) {
collector.warn(`PNG ${type} metadata exceeds the block limit.`);
complete = false;
} else {
blocks.push({ kind: type, offset: dataOffset, length });
const result = scanPngMetadata(
type,
data,
dataOffset,
collector,
limits,
);
if (result.orientation !== undefined) orientation = result.orientation;
complete &&= result.complete;
}
} else if (type[1] === type[1]?.toLowerCase()) {
blocks.push({ kind: type, offset: dataOffset, length });
collector.add({
category: type === "caBX" ? "provenance" : "unknown",
risk: "context",
source: "PNG",
label:
type === "caBX" ? "C2PA provenance chunk" : `Private chunk ${type}`,
value: `${length} bytes`,
offset: dataOffset,
length,
});
}
offset = end + 4;
}
if (!sawEnd) {
collector.warn("PNG IEND chunk was not found.");
complete = false;
} else if (offset < bytes.byteLength) {
const trailing = bytes.byteLength - offset;
blocks.push({ kind: "trailing-data", offset, length: trailing });
collector.add({
category: "unknown",
risk: "sensitive",
source: "PNG",
label: "Trailing data",
value: `${trailing} bytes after IEND`,
offset,
length: trailing,
});
}
if (!sawHeader) {
collector.warn("PNG IHDR chunk was not found.");
complete = false;
}
if (!sawImageData) {
collector.warn("PNG IDAT image data was not found.");
complete = false;
}
return {
width,
height,
orientation,
animated,
multiImage: false,
complete,
blocks,
};
}
function scanPngMetadata(
type: string,
data: Uint8Array,
offset: number,
collector: FindingCollector,
limits: Readonly<PrivacyLimits>,
): { orientation?: number; complete: boolean } {
try {
if (type === "eXIf") {
const result = scanTiff(data, "PNG eXIf", offset, collector, limits);
return { orientation: result.orientation, complete: result.complete };
}
if (type === "tEXt") {
const separator = data.indexOf(0);
const keyword = decodeLatin(
data.subarray(0, separator < 0 ? data.length : separator),
);
const value =
separator < 0 ? "" : decodeLatin(data.subarray(separator + 1));
addPngText(keyword, value, type, offset, data.byteLength, collector);
} else if (type === "zTXt") {
const separator = data.indexOf(0);
if (separator < 0 || data[separator + 1] !== 0)
throw new SyntaxError("invalid zTXt header");
const keyword = decodeLatin(data.subarray(0, separator));
const inflated = inflateZlibBounded(
data.subarray(separator + 2),
limits.maxInflatedMetadataBytes,
);
addPngText(
keyword,
decodeLatin(inflated),
type,
offset,
data.byteLength,
collector,
);
} else if (type === "iTXt") {
const parsed = parseInternationalText(
data,
limits.maxInflatedMetadataBytes,
);
addPngText(
parsed.keyword,
parsed.text,
type,
offset,
data.byteLength,
collector,
);
} else if (type === "iCCP") {
const separator = data.indexOf(0);
if (separator < 0 || data[separator + 1] !== 0)
throw new SyntaxError("invalid iCCP header");
const profile = inflateZlibBounded(
data.subarray(separator + 2),
limits.maxInflatedMetadataBytes,
);
collector.add({
category: "colour-profile",
risk: "technical",
source: "PNG iCCP",
label: "ICC profile",
value: `${decodeLatin(data.subarray(0, separator)) || "unnamed"}; ${profile.byteLength} bytes inflated`,
offset,
length: data.byteLength,
});
} else if (type === "pHYs") {
collector.add({
category: "technical",
risk: "technical",
source: "PNG pHYs",
label: "Pixel density",
value: `${readU32be(data, 0)}×${readU32be(data, 4)} per ${data[8] === 1 ? "metre" : "unknown unit"}`,
offset,
length: data.byteLength,
});
} else if (type === "tIME" && data.byteLength === 7) {
collector.add({
category: "timestamp",
risk: "sensitive",
source: "PNG tIME",
label: "Last modification time",
value: `${readU16be(data, 0)}-${pad(data[2])}-${pad(data[3])} ${pad(data[4])}:${pad(data[5])}:${pad(data[6])} UTC-like fields`,
offset,
length: data.byteLength,
});
} else if (type === "caBX") {
collector.add({
category: "provenance",
risk: "context",
source: "PNG caBX",
label: "C2PA provenance data",
value: `${data.byteLength} bytes; re-encoding will remove or invalidate this provenance`,
offset,
length: data.byteLength,
});
}
return { complete: true };
} catch (error) {
collector.warn(
`PNG ${type} metadata could not be fully read: ${error instanceof Error ? error.message : "unknown error"}.`,
);
return { complete: false };
}
}
function addPngText(
keyword: string,
value: string,
type: string,
offset: number,
length: number,
collector: FindingCollector,
): void {
if (/xmp/iu.test(keyword) || /<\?xpacket|<rdf:/iu.test(value)) {
scanXmp(value, `PNG ${type} (${keyword || "XMP"})`, offset, collector);
return;
}
const normalized = keyword.toLowerCase();
const category = /author|copyright|creator/iu.test(keyword)
? "identity"
: /date|time/iu.test(keyword)
? "timestamp"
: /software/iu.test(keyword)
? "software"
: /description|comment|title|keyword/iu.test(keyword)
? "comment"
: normalized === "raw profile type exif"
? "device"
: "unknown";
collector.add({
category,
risk: category === "unknown" ? "context" : "sensitive",
source: `PNG ${type}`,
label: keyword || "Text entry",
value,
offset,
length,
});
}
function parseInternationalText(
data: Uint8Array,
maximumInflatedBytes: number,
): { keyword: string; text: string } {
const first = data.indexOf(0);
if (first < 0 || first + 2 >= data.byteLength)
throw new SyntaxError("invalid iTXt header");
const compressed = data[first + 1] === 1;
if ((data[first + 1] !== 0 && !compressed) || data[first + 2] !== 0)
throw new SyntaxError("unsupported iTXt compression");
const languageEnd = data.indexOf(0, first + 3);
if (languageEnd < 0) throw new SyntaxError("truncated iTXt language tag");
const translatedEnd = data.indexOf(0, languageEnd + 1);
if (translatedEnd < 0)
throw new SyntaxError("truncated iTXt translated keyword");
const payload = data.subarray(translatedEnd + 1);
return {
keyword: decodeLatin(data.subarray(0, first)),
text: decodeMetadataText(
compressed ? inflateZlibBounded(payload, maximumInflatedBytes) : payload,
),
};
}
function isMetadataChunk(type: string): boolean {
return [
"tEXt",
"zTXt",
"iTXt",
"eXIf",
"iCCP",
"pHYs",
"tIME",
"caBX",
].includes(type);
}
function readU32be(bytes: Uint8Array, offset: number): number {
return (
(bytes[offset] ?? 0) * 0x1000000 +
((bytes[offset + 1] ?? 0) << 16) +
((bytes[offset + 2] ?? 0) << 8) +
(bytes[offset + 3] ?? 0)
);
}
function readU16be(bytes: Uint8Array, offset: number): number {
return ((bytes[offset] ?? 0) << 8) | (bytes[offset + 1] ?? 0);
}
function ascii(bytes: Uint8Array, offset: number, length: number): string {
let value = "";
for (
let index = 0;
index < length && offset + index < bytes.length;
index += 1
)
value += String.fromCharCode(bytes[offset + index] ?? 0);
return value;
}
function decodeLatin(bytes: Uint8Array): string {
return new TextDecoder("latin1", { fatal: false }).decode(bytes);
}
function pad(value: number | undefined): string {
return String(value ?? 0).padStart(2, "0");
}