From f8a23ba643113e2b98ae73a78920e2382a2773c7 Mon Sep 17 00:00:00 2001 From: jensmohr Date: Tue, 9 Jun 2026 19:43:06 +0200 Subject: [PATCH] Add verifiable GVL revision evidence packages --- data/.gitkeep | 0 src/core/gvl-evidence-json.js | 462 ++++++++++++++++++++++++++++++++++ 2 files changed, 462 insertions(+) create mode 100644 data/.gitkeep create mode 100644 src/core/gvl-evidence-json.js diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/core/gvl-evidence-json.js b/src/core/gvl-evidence-json.js new file mode 100644 index 0000000..7fef3a2 --- /dev/null +++ b/src/core/gvl-evidence-json.js @@ -0,0 +1,462 @@ +"use strict"; + +const VENDORGET_GVL_EVIDENCE_EXPORT_FORMAT = + "vendorget-gvl-evidence-export"; +const VENDORGET_GVL_EVIDENCE_EXPORT_FORMAT_VERSION = 1; +const VENDORGET_GVL_REVISION_EVIDENCE_EXPORT_FORMAT = + "vendorget-gvl-revision-evidence"; +const VENDORGET_GVL_REVISION_EVIDENCE_EXPORT_FORMAT_VERSION = 1; +const VENDORGET_GVL_REVISION_EVIDENCE_CONTENT_KIND = "iab-gvl-revision"; + +const VENDORGET_GVL_EVIDENCE_STORE_NAMES = [ + VENDORGET_STORE_NAMES.gvlRawEvidence, + VENDORGET_STORE_NAMES.gvlSnapshots, + VENDORGET_STORE_NAMES.gvlVendors, + VENDORGET_STORE_NAMES.gvlPurposes, + VENDORGET_STORE_NAMES.gvlSpecialPurposes, + VENDORGET_STORE_NAMES.gvlFeatures, + VENDORGET_STORE_NAMES.gvlSpecialFeatures, + VENDORGET_STORE_NAMES.gvlDataCategories, + VENDORGET_STORE_NAMES.gvlVendorRelationships +]; + +const VENDORGET_GVL_EVIDENCE_ARRAY_NAMES = [ + "gvl_raw_evidence", + "gvl_snapshots", + "gvl_vendors", + "gvl_purposes", + "gvl_special_purposes", + "gvl_features", + "gvl_special_features", + "gvl_data_categories", + "gvl_vendor_relationships" +]; + +const VENDORGET_GVL_NORMALIZED_STORE_NAMES = [ + VENDORGET_STORE_NAMES.gvlVendors, + VENDORGET_STORE_NAMES.gvlPurposes, + VENDORGET_STORE_NAMES.gvlSpecialPurposes, + VENDORGET_STORE_NAMES.gvlFeatures, + VENDORGET_STORE_NAMES.gvlSpecialFeatures, + VENDORGET_STORE_NAMES.gvlDataCategories, + VENDORGET_STORE_NAMES.gvlVendorRelationships +]; + +async function exportVendorGetGvlEvidenceJson() { + const db = await openVendorGetDb(); + const exportedStores = await readGvlEvidenceStores(db); + + return { + metadata: { + exportFormat: VENDORGET_GVL_EVIDENCE_EXPORT_FORMAT, + exportFormatVersion: VENDORGET_GVL_EVIDENCE_EXPORT_FORMAT_VERSION, + exportedAt: new Date().toISOString(), + dbName: db.name, + dbVersion: db.version + }, + ...exportedStores + }; +} + +async function exportVendorGetGvlRevisionEvidenceJson(snapshotSha256) { + if (!snapshotSha256) { + throw new Error("missing_snapshot_sha256"); + } + + const db = await openVendorGetDb(); + const snapshot = await getGvlEvidenceRecordByKey( + db, + VENDORGET_STORE_NAMES.gvlSnapshots, + snapshotSha256 + ); + + if (!snapshot) { + throw new Error("gvl_snapshot_not_found"); + } + + const rawGvlSha256 = snapshot.rawGvlSha256 ?? null; + const rawEvidence = rawGvlSha256 + ? await getGvlEvidenceRecordByKey( + db, + VENDORGET_STORE_NAMES.gvlRawEvidence, + rawGvlSha256 + ) + : null; + const normalized = await readGvlRevisionNormalizedRecords(db, snapshotSha256); + const exportedAt = new Date(); + const exportedAtUtcCompact = formatGvlEvidenceUtcCompact(exportedAt); + const payload = { + metadata: { + exportFormat: VENDORGET_GVL_REVISION_EVIDENCE_EXPORT_FORMAT, + exportFormatVersion: VENDORGET_GVL_REVISION_EVIDENCE_EXPORT_FORMAT_VERSION, + exportedAt: exportedAt.toISOString(), + exportedAtUtcCompact, + dbName: db.name, + dbVersion: db.version, + vendorListVersion: snapshot.vendorListVersion ?? null, + snapshotSha256: snapshot.sha256 ?? snapshot.snapshotSha256 ?? null, + rawGvlSha256, + contentKind: VENDORGET_GVL_REVISION_EVIDENCE_CONTENT_KIND + }, + rawEvidence, + snapshot, + normalized + }; + const exportPayloadSha256 = await calculateGvlEvidencePayloadSha256(payload); + + return { + ...payload, + metadata: { + ...payload.metadata, + exportPayloadSha256 + } + }; +} + +async function verifyVendorGetGvlRevisionEvidenceJson(exportContainer) { + const errors = []; + const metadata = exportContainer?.metadata ?? {}; + const normalized = exportContainer?.normalized ?? {}; + const rawBody = exportContainer?.rawEvidence?.rawBody ?? null; + const snapshot = exportContainer?.snapshot ?? null; + const snapshotSha256 = metadata.snapshotSha256 ?? null; + const rawGvlSha256 = metadata.rawGvlSha256 ?? null; + + if (!exportContainer || typeof exportContainer !== "object") { + errors.push("invalid_export_container"); + } + + if (metadata.exportFormat !== VENDORGET_GVL_REVISION_EVIDENCE_EXPORT_FORMAT) { + errors.push("invalid_export_format"); + } + + if ( + metadata.exportFormatVersion !== + VENDORGET_GVL_REVISION_EVIDENCE_EXPORT_FORMAT_VERSION + ) { + errors.push("unsupported_export_format_version"); + } + + if (metadata.contentKind !== VENDORGET_GVL_REVISION_EVIDENCE_CONTENT_KIND) { + errors.push("invalid_content_kind"); + } + + if (metadata.vendorListVersion === null || metadata.vendorListVersion === undefined) { + errors.push("missing_vendor_list_version"); + } + + if (!snapshotSha256) { + errors.push("missing_snapshot_sha256"); + } + + if (!rawGvlSha256) { + errors.push("missing_raw_gvl_sha256"); + } + + if (typeof rawBody !== "string") { + errors.push("missing_raw_body"); + } else if ((await VendorGetGvlService.calculateRawGvlSha256(rawBody)) !== rawGvlSha256) { + errors.push("raw_body_sha256_mismatch"); + } + + const snapshotRecordSha256 = snapshot?.sha256 ?? snapshot?.snapshotSha256 ?? null; + + if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) { + errors.push("missing_snapshot"); + } else if (snapshotRecordSha256 !== snapshotSha256) { + errors.push("snapshot_sha256_mismatch"); + } else if ( + (await VendorGetGvlService.calculateGvlSnapshotSha256(snapshot.rawJson)) !== + snapshotSha256 + ) { + errors.push("snapshot_sha256_mismatch"); + } + + for (const storeName of VENDORGET_GVL_NORMALIZED_STORE_NAMES) { + if (!Array.isArray(normalized[storeName])) { + errors.push(`missing_normalized_store_${storeName}`); + continue; + } + + for (const record of normalized[storeName]) { + if (record?.snapshotSha256 !== snapshotSha256) { + errors.push(`normalized_record_snapshot_mismatch_${storeName}`); + break; + } + } + } + + if (!metadata.exportPayloadSha256) { + errors.push("missing_export_payload_sha256"); + } else { + const recalculatedPayloadSha256 = + await calculateGvlEvidencePayloadSha256WithoutEmbeddedHash(exportContainer); + + if (recalculatedPayloadSha256 !== metadata.exportPayloadSha256) { + errors.push("export_payload_sha256_mismatch"); + } + } + + return { + valid: errors.length === 0, + errors, + vendorListVersion: metadata.vendorListVersion ?? null, + snapshotSha256, + rawGvlSha256, + exportPayloadSha256: metadata.exportPayloadSha256 ?? null, + normalizedCounts: countGvlRevisionNormalizedRecords(normalized) + }; +} + +function getGvlEvidenceRecordByKey(db, storeName, key) { + return new Promise((resolve, reject) => { + const tx = db.transaction([storeName], "readonly"); + const getRequest = tx.objectStore(storeName).get(key); + let record = null; + + getRequest.onerror = () => reject(getRequest.error); + getRequest.onsuccess = () => { + record = getRequest.result ?? null; + }; + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + tx.oncomplete = () => resolve(record); + }); +} + +function readGvlRevisionNormalizedRecords(db, snapshotSha256) { + return new Promise((resolve, reject) => { + const normalized = {}; + const tx = db.transaction(VENDORGET_GVL_NORMALIZED_STORE_NAMES, "readonly"); + + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + tx.oncomplete = () => resolve(normalized); + + for (const storeName of VENDORGET_GVL_NORMALIZED_STORE_NAMES) { + const records = []; + const cursorRequest = tx + .objectStore(storeName) + .index("snapshotSha256") + .openCursor(IDBKeyRange.only(snapshotSha256)); + + normalized[storeName] = records; + cursorRequest.onerror = () => reject(cursorRequest.error); + cursorRequest.onsuccess = () => { + const cursor = cursorRequest.result; + + if (!cursor) { + return; + } + + records.push(cursor.value); + cursor.continue(); + }; + } + }); +} + +async function calculateGvlEvidencePayloadSha256(payload) { + return sha256Hex(stableStringify(payload)); +} + +async function calculateGvlEvidencePayloadSha256WithoutEmbeddedHash( + exportContainer +) { + const metadata = { + ...(exportContainer?.metadata ?? {}) + }; + + delete metadata.exportPayloadSha256; + + return calculateGvlEvidencePayloadSha256({ + ...exportContainer, + metadata + }); +} + +function countGvlRevisionNormalizedRecords(normalized) { + return Object.fromEntries( + VENDORGET_GVL_NORMALIZED_STORE_NAMES.map((storeName) => [ + storeName, + Array.isArray(normalized?.[storeName]) ? normalized[storeName].length : 0 + ]) + ); +} + +function formatGvlEvidenceUtcCompact(date) { + return [ + date.getUTCFullYear(), + padGvlEvidenceDatePart(date.getUTCMonth() + 1), + padGvlEvidenceDatePart(date.getUTCDate()), + "T", + padGvlEvidenceDatePart(date.getUTCHours()), + padGvlEvidenceDatePart(date.getUTCMinutes()), + padGvlEvidenceDatePart(date.getUTCSeconds()), + "Z" + ].join(""); +} + +function padGvlEvidenceDatePart(value) { + return String(value).padStart(2, "0"); +} + +function readGvlEvidenceStores(db) { + return new Promise((resolve, reject) => { + const exportContainer = {}; + const tx = db.transaction(VENDORGET_GVL_EVIDENCE_STORE_NAMES, "readonly"); + + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + tx.oncomplete = () => resolve(exportContainer); + + for (const storeName of VENDORGET_GVL_EVIDENCE_STORE_NAMES) { + const records = []; + const cursorRequest = tx.objectStore(storeName).openCursor(); + + exportContainer[storeName] = records; + + cursorRequest.onerror = () => reject(cursorRequest.error); + cursorRequest.onsuccess = () => { + const cursor = cursorRequest.result; + + if (!cursor) { + return; + } + + records.push(cursor.value); + cursor.continue(); + }; + } + }); +} + +async function importVendorGetGvlEvidenceJson(importContainer) { + validateVendorGetGvlEvidenceImport(importContainer); + + const db = await openVendorGetDb(); + + return importGvlEvidenceStores(db, importContainer); +} + +function validateVendorGetGvlEvidenceImport(importContainer) { + if (!importContainer || typeof importContainer !== "object") { + throw new Error("invalid_gvl_evidence_export"); + } + + const metadata = importContainer.metadata ?? {}; + + if (metadata.exportFormat !== VENDORGET_GVL_EVIDENCE_EXPORT_FORMAT) { + throw new Error("invalid_gvl_evidence_export_format"); + } + + if ( + metadata.exportFormatVersion !== + VENDORGET_GVL_EVIDENCE_EXPORT_FORMAT_VERSION + ) { + throw new Error("unsupported_gvl_evidence_export_version"); + } + + for (const arrayName of VENDORGET_GVL_EVIDENCE_ARRAY_NAMES) { + if (!Array.isArray(importContainer[arrayName])) { + throw new Error(`missing_gvl_evidence_store_${arrayName}`); + } + } +} + +function importGvlEvidenceStores(db, importContainer) { + return new Promise((resolve, reject) => { + const counts = buildEmptyGvlEvidenceImportCounts(); + const seenKeysByStore = new Map(); + const tx = db.transaction(VENDORGET_GVL_EVIDENCE_STORE_NAMES, "readwrite"); + + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + tx.oncomplete = () => { + resolve({ + importedAt: new Date().toISOString(), + counts + }); + }; + + for (const storeName of VENDORGET_GVL_EVIDENCE_STORE_NAMES) { + importGvlEvidenceStoreRecords( + tx.objectStore(storeName), + importContainer[storeName], + counts[storeName], + getSeenKeysForStore(seenKeysByStore, storeName) + ); + } + }); +} + +function buildEmptyGvlEvidenceImportCounts() { + return Object.fromEntries( + VENDORGET_GVL_EVIDENCE_STORE_NAMES.map((storeName) => [ + storeName, + { + read: 0, + inserted: 0, + skippedExisting: 0, + skippedInvalid: 0 + } + ]) + ); +} + +function getSeenKeysForStore(seenKeysByStore, storeName) { + if (!seenKeysByStore.has(storeName)) { + seenKeysByStore.set(storeName, new Set()); + } + + return seenKeysByStore.get(storeName); +} + +function importGvlEvidenceStoreRecords(objectStore, records, counts, seenKeys) { + for (const record of records) { + counts.read += 1; + + const key = getGvlEvidenceRecordKey(objectStore, record); + const keySignature = JSON.stringify(key); + + if (key === null || seenKeys.has(keySignature)) { + counts.skippedInvalid += 1; + continue; + } + + seenKeys.add(keySignature); + + const getRequest = objectStore.get(key); + + getRequest.onsuccess = () => { + if (getRequest.result !== undefined) { + counts.skippedExisting += 1; + return; + } + + const addRequest = objectStore.add(record); + + addRequest.onsuccess = () => { + counts.inserted += 1; + }; + }; + } +} + +function getGvlEvidenceRecordKey(objectStore, record) { + if (!record || typeof record !== "object" || Array.isArray(record)) { + return null; + } + + if (objectStore.name === VENDORGET_STORE_NAMES.gvlSnapshots) { + return record.sha256 ?? record.snapshotSha256 ?? null; + } + + const keyPath = objectStore.keyPath; + + if (typeof keyPath !== "string") { + return null; + } + + return record[keyPath] ?? null; +}