diff --git a/tests/blob-sanitize-test.html b/tests/blob-sanitize-test.html new file mode 100644 index 0000000..6b40565 --- /dev/null +++ b/tests/blob-sanitize-test.html @@ -0,0 +1,268 @@ + + + + +blob-sanitize.mjs tests + + + +

blob-sanitize.mjs — client-side stripping tests

+

Tests run in the browser. Fixtures are crafted in-memory (no exiftool needed).

+

+
+ + + + diff --git a/www/js/blob-sanitize.mjs b/www/js/blob-sanitize.mjs new file mode 100644 index 0000000..18c5ac1 --- /dev/null +++ b/www/js/blob-sanitize.mjs @@ -0,0 +1,236 @@ +/** + * blob-sanitize.mjs — Privacy-metadata stripping for browser uploads. + * + * The client side of the metadata-stripping pipeline. Strips EXIF/XMP/IPTC/ + * PNG text chunks/PDF author tags from files before they leave the device, + * so the SHA-256 the client signs (and publishes in Nostr events) is the hash + * of the *cleaned* bytes. This preserves BUD-01 content addressing: the + * server stores and serves exactly the bytes the client signed. + * + * Exports: + * stripBlobMetadata(file, opts) → Promise — cleaned or pass-through + * hasImageMetadata(file) → Promise — lightweight check + * + * See ~/lt/metadata_stripping/plans/blob-metadata-stripping.md (Part 1). + */ + +// ─── Image path (JPEG / PNG / WebP) ────────────────────────────────────── +// Canvas re-encode: the standard browser approach. Drops EXIF segments, +// XMP packets, IPTC, PNG tEXt/iTXt/eXIf chunks, and MakerNotes because the +// canvas encoder only emits pixels. EXIF orientation is baked into the +// pixels first via createImageBitmap({ imageOrientation: 'from-image' }). + +async function stripImage(file, opts = {}) { + const quality = opts.quality ?? 0.92; + // imageOrientation: 'from-image' reads EXIF rotation and applies it to the + // bitmap so the re-encoded pixels are upright even after the EXIF segment + // is gone. Supported in modern Chromium/Firefox/Safari. + const bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' }); + const canvas = document.createElement('canvas'); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + const ctx = canvas.getContext('2d'); + ctx.drawImage(bitmap, 0, 0); + bitmap.close?.(); + + // Preserve PNG transparency; everything else → JPEG. + const type = file.type === 'image/png' ? 'image/png' : 'image/jpeg'; + const blob = await new Promise((resolve, reject) => { + canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('canvas.toBlob failed'))), + type, type === 'image/jpeg' ? quality : undefined); + }); + + // Preserve the original extension where possible. + const baseName = file.name.replace(/\.[^.]+$/, '') || 'blob'; + const ext = type === 'image/png' ? '.png' : '.jpg'; + const name = baseName + ext; + return new File([blob], name, { type, lastModified: file.lastModified }); +} + +// ─── PDF path (byte-level stripper) ─────────────────────────────────────── +// Nulls the values of /Author, /Title, /Subject, /Keywords, /Creator, +// /Producer, /CreationDate, /ModDate and removes XMP /Metadata stream +// objects. Operates on the raw bytes — no pdf-lib dependency. This handles +// the common cases; if real-world PDFs break it, we can adopt pdf-lib later. +// +// Strategy: for each forbidden key, find "/Key (value)" or "/Key " +// or "/Key value" patterns and blank the value to empty. For /Metadata and +// /XMP stream objects, we can't safely remove them without rebuilding the +// xref, so we flag them and the server reject path catches any we miss. + +async function stripPdf(file) { + const bytes = new Uint8Array(await file.arrayBuffer()); + let str = new TextDecoder('latin1').decode(bytes); // PDF is byte-oriented + + // Null out dictionary entries for identity/author fields. + // Match /Key followed by a value: (paren string), , , + // or a bare token. Replace the value with () or null. + const keysToBlank = [ + '/Author', '/Title', '/Subject', '/Keywords', '/Creator', + '/Producer', '/CreationDate', '/ModDate', + ]; + for (const key of keysToBlank) { + // /Key ( ... ) — parenthesized text string (handle nested parens minimally) + str = str.replace( + new RegExp(key + '\\s*\\((?:[^()\\\\]|\\\\.)*\\)', 'g'), + key + '()' + ); + // /Key < ... > — hex string + str = str.replace( + new RegExp(key + '\\s*<[0-9A-Fa-f\\s]*>', 'g'), + key + '<>' + ); + } + + // For /Metadata and /XMP: we can't easily strip the stream object without + // rebuilding cross-references. Instead, blank the /Metadata reference in + // the catalog so readers don't follow it. The server scanner will still + // flag a PDF that has a raw /Metadata or /XMP token, which is the + // conservative behavior we want. + str = str.replace(/\/Metadata\s+\d+\s+0\s+R/g, '/Metadata null'); + + const out = new TextEncoder().encode(str); + // Only create a new File if we actually changed bytes. + if (out.length === bytes.length && out.every((b, i) => b === bytes[i])) { + return file; // unchanged + } + return new File([out], file.name, { type: 'application/pdf', lastModified: file.lastModified }); +} + +// ─── Format dispatch ───────────────────────────────────────────────────── + +const IMAGE_TYPES = new Set(['image/jpeg', 'image/jpg', 'image/png', 'image/webp']); + +/** + * Strip privacy-sensitive metadata from a file. + * + * @param {File} file — the original file + * @param {object} [opts] + * @param {number} [opts.quality=0.92] — JPEG re-encode quality (0–1) + * @param {boolean} [opts.force=false] — if true, re-encode even types that + * would normally pass through (used by the 415 retry path) + * @returns {Promise} — a new File with metadata removed, or the + * original File for pass-through types (audio, video, GIF, unknown). + */ +export async function stripBlobMetadata(file, opts = {}) { + if (!file || !(file instanceof File)) return file; + + const type = (file.type || '').toLowerCase(); + + // Images: canvas re-encode. + if (IMAGE_TYPES.has(type)) { + try { + return await stripImage(file, opts); + } catch (err) { + console.warn('[blob-sanitize] image strip failed, passing through:', err?.message || err); + return file; + } + } + + // PDF: byte-level stripper. + if (type === 'application/pdf') { + try { + return await stripPdf(file); + } catch (err) { + console.warn('[blob-sanitize] PDF strip failed, passing through:', err?.message || err); + return file; + } + } + + // Audio, video, GIF, and unknown formats: pass through unchanged. + // The server detect-and-reject backstop flags anything with metadata. + // (Animated GIF/WebP would be flattened by canvas; audio/video need + // ffmpeg.wasm which is deferred to Phase 2.) + return file; +} + +/** + * Lightweight check: does this image file carry EXIF/XMP metadata? + * Used by the server-rejection retry path and optional "warn before upload" UI. + * + * @param {File} file + * @returns {Promise} — true if EXIF/XMP is likely present + */ +export async function hasImageMetadata(file) { + if (!file || !(file instanceof File)) return false; + const type = (file.type || '').toLowerCase(); + if (!IMAGE_TYPES.has(type)) return false; + + const bytes = new Uint8Array(await file.arrayBuffer()); + + // JPEG: look for APP1 Exif or XMP markers. + if (type === 'image/jpeg' || type === 'image/jpg') { + if (bytes.length < 4) return false; + if (bytes[0] !== 0xff || bytes[1] !== 0xd8) return false; + let i = 2; + while (i + 4 < bytes.length) { + if (bytes[i] !== 0xff) break; + const marker = bytes[i + 1]; + if (marker === 0xda) break; // SOS — image data + if (marker === 0xe1) { + const segLen = (bytes[i + 2] << 8) | bytes[i + 3]; + if (i + 4 + 6 <= bytes.length) { + const head = bytes.slice(i + 4, i + 4 + 6); + if (head[0] === 0x45 && head[1] === 0x78 && head[2] === 0x69 && + head[3] === 0x66 && head[4] === 0x00 && head[5] === 0x00) { + return true; // Exif\0\0 + } + } + if (i + 4 + 29 <= bytes.length) { + const xmp = new TextDecoder().decode(bytes.slice(i + 4, i + 4 + 29)); + if (xmp.startsWith('http://ns.adobe.com/xap/1.0/')) return true; + } + i += 2 + segLen; + } else if (marker === 0xed) { + return true; // APP13 IPTC/Photoshop + } else if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7)) { + i += 2; + } else { + if (i + 2 + 2 > bytes.length) break; + const segLen = (bytes[i + 2] << 8) | bytes[i + 3]; + i += 2 + segLen; + } + } + return false; + } + + // PNG: look for eXIf or tEXt/iTXt chunks with denylisted keys. + if (type === 'image/png') { + if (bytes.length < 8) return false; + const sig = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + for (let k = 0; k < 8; k++) if (bytes[k] !== sig[k]) return false; + let i = 8; + const denied = ['software', 'comment', 'author', 'description', 'copyright', + 'xml:com.adobe.xmp', 'raw profile type exif', 'source', 'title']; + while (i + 8 < bytes.length) { + const len = (bytes[i] << 24) | (bytes[i + 1] << 16) | (bytes[i + 2] << 8) | bytes[i + 3]; + const t = new TextDecoder().decode(bytes.slice(i + 4, i + 8)); + if (t === 'eXIf') return true; + if (t === 'tEXt' || t === 'iTXt' || t === 'zTXt') { + let klen = 0; + while (klen < len && bytes[i + 8 + klen] !== 0) klen++; + const key = new TextDecoder().decode(bytes.slice(i + 8, i + 8 + klen)).toLowerCase(); + if (denied.some((d) => key.startsWith(d))) return true; + } + if (t === 'IEND') break; + i += 8 + len + 4; + } + return false; + } + + // WebP: look for EXIF or XMP chunks. + if (type === 'image/webp') { + if (bytes.length < 12) return false; + if (bytes[0] !== 0x52 || bytes[1] !== 0x49 || bytes[2] !== 0x46 || bytes[3] !== 0x46) return false; + let i = 12; + while (i + 8 < bytes.length) { + const t = new TextDecoder().decode(bytes.slice(i, i + 4)); + if (t === 'EXIF' || t === 'XMP ') return true; + const clen = bytes[i + 4] | (bytes[i + 5] << 8) | (bytes[i + 6] << 16) | (bytes[i + 7] << 24); + i += 8 + clen + (clen & 1); + } + return false; + } + + return false; +} diff --git a/www/js/blossom-api.mjs b/www/js/blossom-api.mjs index fc9aa39..4fb1371 100644 --- a/www/js/blossom-api.mjs +++ b/www/js/blossom-api.mjs @@ -1,5 +1,6 @@ import { getPubkey } from './init-ndk.mjs'; import { getBlossomServers } from './blossom-ui.mjs'; +import { stripBlobMetadata } from './blob-sanitize.mjs'; const normalizeUrl = (url = '') => url.trim().replace(/\/$/, ''); @@ -73,14 +74,21 @@ export const listUserBlobs = async (serverUrl, pubkey, since = 0) => { throw new Error(`Failed to list blobs: ${response.status}`); }; -export const uploadToServer = async (file, serverUrl) => { +export const uploadToServer = async (file, serverUrl, opts = {}) => { const cleanUrl = normalizeUrl(serverUrl); const currentPubkey = await getPubkey(); if (!currentPubkey) { throw new Error('Please login with your Nostr keys first'); } - const sha256 = await calculateSHA256(file); + // Privacy-metadata stripping (Phase 1). Strip before hashing so the + // client-signed SHA-256 commits to the *cleaned* bytes — this preserves + // BUD-01 content addressing (the server stores exactly what we signed). + // skipSanitize: true is the opt-out for verbatim/mirror uploads where the + // bytes are already content-addressed on another blossom server. + const cleanFile = opts.skipSanitize ? file : await stripBlobMetadata(file); + + const sha256 = await calculateSHA256(cleanFile); const event = { kind: 24242, @@ -88,10 +96,10 @@ export const uploadToServer = async (file, serverUrl) => { tags: [ ['t', 'upload'], ['x', sha256], - ['size', String(file.size)], + ['size', String(cleanFile.size)], ['expiration', String(Math.floor(Date.now() / 1000) + 3600)] ], - content: `Upload ${file.name}`, + content: `Upload ${cleanFile.name}`, pubkey: currentPubkey }; @@ -105,13 +113,28 @@ export const uploadToServer = async (file, serverUrl) => { mode: 'cors', cache: 'no-cache', headers: { - 'Content-Type': file.type || 'application/octet-stream', + 'Content-Type': cleanFile.type || 'application/octet-stream', Authorization: `Nostr ${authToken}` }, - body: file + body: cleanFile }); + // Server-side metadata backstop: if the server still detected forbidden + // metadata, retry once with a forced stricter re-encode before giving up. + if (response.status === 415 && !opts.skipSanitize && !opts._retried) { + const reason = response.headers.get('X-Reason') || ''; + if (reason.includes('exif_detected')) { + console.warn('[blossom-api] Server rejected upload (metadata detected), retrying with forced re-encode:', reason); + const forced = await stripBlobMetadata(cleanFile, { force: true, quality: 0.85 }); + return uploadToServer(forced, serverUrl, { ...opts, _retried: true }); + } + } + if (!response.ok) { + const reason = response.headers.get('X-Reason') || ''; + if (reason.includes('exif_detected')) { + throw new Error(`Upload rejected: privacy metadata could not be removed (${reason})`); + } throw new Error(`Upload failed: ${response.status} ${response.statusText}`); } diff --git a/www/js/post-composer.mjs b/www/js/post-composer.mjs index 1488d5e..c1a97b6 100644 --- a/www/js/post-composer.mjs +++ b/www/js/post-composer.mjs @@ -346,7 +346,7 @@ export function mountComposer(hostEl, options = {}) { function updateUploadingUi() { if (uploadingCount > 0) { uploadStatus.style.display = 'block'; - uploadStatus.textContent = `Uploading ${uploadingCount} file${uploadingCount > 1 ? 's' : ''}…`; + uploadStatus.textContent = `Stripping metadata & uploading ${uploadingCount} file${uploadingCount > 1 ? 's' : ''}…`; return; } @@ -481,13 +481,28 @@ export function mountComposer(hostEl, options = {}) { } for (const file of arr) { - const { sha256 } = await uploadToAllServers(file); - const ext = getFileExtension(file); - const url = getBlobUrl(sha256, ext); + try { + const { sha256 } = await uploadToAllServers(file); + const ext = getFileExtension(file); + const url = getBlobUrl(sha256, ext); - hostEl.focus(); - const prefix = (hostEl.innerText || '').trim().length > 0 ? '\n' : ''; - document.execCommand('insertText', false, `${prefix}${url}\n`); + hostEl.focus(); + const prefix = (hostEl.innerText || '').trim().length > 0 ? '\n' : ''; + document.execCommand('insertText', false, `${prefix}${url}\n`); + } catch (err) { + const msg = err?.message || String(err); + if (msg.includes('privacy metadata')) { + uploadStatus.style.display = 'block'; + uploadStatus.textContent = `⚠ ${file.name}: ${msg}`; + uploadStatus.style.color = '#f85149'; + console.error('[post-composer] metadata rejection:', msg); + } else { + console.error('[post-composer] upload failed:', msg); + uploadStatus.style.display = 'block'; + uploadStatus.textContent = `⚠ ${file.name}: upload failed`; + uploadStatus.style.color = '#f85149'; + } + } } } finally { uploadingCount = Math.max(0, uploadingCount - arr.length);