The served capitals-and-cities table is integrity-pinned too, and a forged one is not drawn

Following the generator's pin (studio's follow-up): the Natural Earth table the sheets fetch at print
time (boundaries/ne-places.json) is data, not code, but pinned the same way — its sha384 sits beside the
generator's in poster-render.integrity.js (bun run pin:poster-render writes both), and posterSheets hashes
the fetched text before parsing it. A table that is not the pinned one is not used: the sheet draws no
city dots and says so in the console, rather than plotting places it cannot vouch for.

check:poster-render-pinned, two-sided for the table as for the generator: the pin matches the source
and the served copy; a FORGED table (every name replaced) yields exactly the sheet drawn with no places;
the genuine table IS used (its sheet differs) — the probe's pins now span Chad, because a single pin
framed too small an area for any place to fall inside it and the check could see nothing.
check:poster-browser-equiv still byte-identical; gates:pre green.
This commit is contained in:
Doxa Maps 2026-09-29 06:39:52 +00:00
parent 155c774b22
commit 3268105643
4 changed files with 59 additions and 9 deletions

View file

@ -25,14 +25,21 @@ const ok = (c, m) => { console.log(` ${c ? 'ok ' : 'FAIL'} ${m}`); if (!c) fai
const source = readFileSync(join(REPO, 'library/packet/poster-render.js'), 'utf8')
const served = readFileSync(join(REPO, 'public/dx-maps/assets/lib/poster-render.js'), 'utf8')
const { POSTER_RENDER_SHA384 } = await import(join(REPO, 'library/packet/poster-render.integrity.js'))
const { POSTER_RENDER_SHA384, NE_PLACES_SHA384 } = await import(join(REPO, 'library/packet/poster-render.integrity.js'))
ok(POSTER_RENDER_SHA384 === pinOf(source),
`the pin matches library/packet/poster-render.js${POSTER_RENDER_SHA384 === pinOf(source) ? '' : ' — run: node library/scripts/posters/pin-poster-render.mjs'}`)
ok(pinOf(served) === pinOf(source), 'the served copy (assets/lib/poster-render.js) is the pinned file')
const placesSrc = readFileSync(join(REPO, 'library/scripts/posters/data/ne-places.json'), 'utf8')
const placesServed = readFileSync(join(REPO, 'public/dx-maps/assets/boundaries/ne-places.json'), 'utf8')
ok(NE_PLACES_SHA384 === pinOf(placesSrc) && pinOf(placesServed) === pinOf(placesSrc),
'the capitals-and-cities pin matches its source and the served copy (boundaries/ne-places.json)')
const { generateSheet, countryZone } = await import(join(REPO, 'library/packet/posterSheets.js'))
const zone = countryZone({ id: 'country-tcd', iso3: 'TCD', label: 'Chad', count: 1 })
const pins = [{ name: 'Probe', lat: 15, lng: 19, slug: 'probe', country_code: 'TCD', population: 1 }]
// Probe pins spread across Chad, so the sheet's view holds real capitals and cities (one pin frames too
// small an area for any place to fall inside it, and the places check below would see nothing).
const pins = [[12.1, 15.0], [13.8, 20.8], [9.1, 18.4], [17.9, 19.1], [8.6, 16.1]]
.map(([lat, lng], i) => ({ name: `Probe ${i + 1}`, lat, lng, slug: `probe-${i}`, country_code: 'TCD', population: 1000 }))
// TAMPERED: the pinned file plus one line that would run on import.
const tmp = mkdtempSync(join(tmpdir(), 'poster-pin-'))
@ -52,5 +59,22 @@ const html = await generateSheet({ zone, pins, format: '3x3', seriesId: 'country
assetsBase: pathToFileURL(join(REPO, 'public/dx-maps/assets')).href, date: '2026-09-29' })
ok(typeof html === 'string' && html.length > 10000 && /<svg\b/.test(html), `the pinned generator loads and draws a sheet (${html.length} bytes)`)
// THE PLACES TABLE, two-sided: a tampered table is NOT used (the sheet equals one drawn with no places),
// and the genuine one IS (its sheet differs) — so the check can see the places at all.
{
const base = { zone, pins, format: '3x3', seriesId: 'country', adm0, date: '2026-09-29' }
const none = await generateSheet({ ...base, assetsBase: pathToFileURL(join(REPO, 'public/dx-maps/assets')).href, nePlaces: [] })
const tmp2 = mkdtempSync(join(tmpdir(), 'places-pin-'))
try {
mkdirSync(join(tmp2, 'lib'), { recursive: true }); mkdirSync(join(tmp2, 'boundaries'), { recursive: true })
writeFileSync(join(tmp2, 'lib/poster-render.js'), source)
const doctored = JSON.parse(placesSrc); doctored.places = doctored.places.map((q) => ({ ...q, name: 'Forged' }))
writeFileSync(join(tmp2, 'boundaries/ne-places.json'), JSON.stringify(doctored))
const tampered = await generateSheet({ ...base, assetsBase: pathToFileURL(tmp2).href })
ok(tampered === none && !tampered.includes('Forged'), 'a tampered capitals-and-cities table is not used — the sheet draws no city dots')
} finally { rmSync(tmp2, { recursive: true, force: true }) }
ok(html !== none, 'the genuine table IS used (its sheet differs from one with no places)')
}
if (fails) { console.log(`POSTER-RENDER-PINNED: FAIL (${fails})`); process.exit(1) }
console.log('POSTER-RENDER-PINNED: PASS — the served generator is the pinned one, a tampered copy is refused unrun, and the genuine copy draws')
console.log('POSTER-RENDER-PINNED: PASS — the served generator and places table are the pinned ones, a tampered generator is refused unrun, a tampered table is not used, and the genuine copies draw')

View file

@ -3,3 +3,6 @@
// (<assets>/lib/poster-render.js), hashes it, and runs it only when it matches this pin: code loaded at
// print time is integrity-pinned like every embed, because it executes on partner pages.
export const POSTER_RENDER_SHA384 = 'sha384-8uzpgX1i2IEQNEh7WIxVxanacbBvX2r11/VeqSxYHzInWdV5XlLDoMCONlYI2pFA'
// The sha384 of library/scripts/posters/data/ne-places.json, served at <assets>/boundaries/ne-places.json.
// Data, not code, but pinned the same way: a sheet uses the places only when they are the pinned ones.
export const NE_PLACES_SHA384 = 'sha384-Qtz2q8mcnA9Ei8Kwa2MRMyX95hSbSnUXjXdtsy8rHTX++mgSCih4LO4Rdrb2MIQy'

View file

@ -15,7 +15,7 @@
* const html = await generateSheet({ zone, pins, zoneFC, format: 'packet', seriesId: 'upg100',
* ISO3_NAME, series: { totalGroups, totalLists, index } })
*/
import { POSTER_RENDER_SHA384 } from './poster-render.integrity.js'
import { POSTER_RENDER_SHA384, NE_PLACES_SHA384 } from './poster-render.integrity.js'
// ★ THE GENERATOR IS SERVED, NOT BUNDLED, AND IT IS INTEGRITY-PINNED. poster-render.js is served verbatim
// at <assets>/lib/poster-render.js (generate-tree) and loaded at the FIRST print: a single-file build
@ -68,9 +68,27 @@ function loadShared() {
// The Natural Earth capitals and cities, FETCHED at print time from the served tree (boundaries/
// ne-places.json) rather than bundled: 170 KB that only a reader who prints should pay for. Without an
// asset base, or on any failure, the sheet draws no city dots — the generator's own rule for absent data.
// PINNED like the generator (NE_PLACES_SHA384): the text is hashed before it is parsed, and a table that is
// not the pinned one is not used — the sheet then draws no city dots and says so in the console, rather
// than plotting capitals it cannot vouch for.
const placesCache = new Map()
function loadPlaces(assetsBase) {
if (!assetsBase) return Promise.resolve([])
return getJSON(`${assetsBase}/boundaries/ne-places.json`).then((j) => (j && j.places) || []).catch(() => [])
const url = abs(`${assetsBase}/boundaries/ne-places.json`)
if (!placesCache.has(url)) {
const p = (async () => {
const res = await fetch(url, { cache: 'force-cache' })
if (!res.ok) return []
const text = await res.text()
if ((await sha384(text)) !== NE_PLACES_SHA384) {
console.warn(`[posterSheets] ${url} failed its integrity check — drawing no city dots`)
return []
}
return JSON.parse(text).places || []
})().catch(() => [])
placesCache.set(url, p)
}
return placesCache.get(url)
}
/**

View file

@ -1,7 +1,8 @@
#!/usr/bin/env node
// pin-poster-render.mjs — write library/packet/poster-render.integrity.js, the sha384 the browser checks
// before it runs the served map-sheet generator. Run it whenever library/packet/poster-render.js changes;
// check:poster-render-pinned fails (and names this command) until the pin matches the file again.
// pin-poster-render.mjs — write library/packet/poster-render.integrity.js, the sha384s the browser checks
// before it runs the served map-sheet generator or uses the served capitals-and-cities table. Run it
// whenever library/packet/poster-render.js or library/scripts/posters/data/ne-places.json changes;
// check:poster-render-pinned fails (and names this command) until the pins match the files again.
//
// node library/scripts/posters/pin-poster-render.mjs
import { readFileSync, writeFileSync } from 'node:fs'
@ -15,11 +16,15 @@ export const pinOf = (text) => `sha384-${createHash('sha384').update(text, 'utf8
const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]
if (isMain) {
const pin = pinOf(readFileSync(join(LIB, 'poster-render.js'), 'utf8'))
const places = pinOf(readFileSync(join(LIB, '..', 'scripts', 'posters', 'data', 'ne-places.json'), 'utf8'))
writeFileSync(join(LIB, 'poster-render.integrity.js'), `// GENERATED by library/scripts/posters/pin-poster-render.mjs — do not edit by hand.
// The sha384 of library/packet/poster-render.js. The browser fetches the served copy of that file
// (<assets>/lib/poster-render.js), hashes it, and runs it only when it matches this pin: code loaded at
// print time is integrity-pinned like every embed, because it executes on partner pages.
export const POSTER_RENDER_SHA384 = '${pin}'
// The sha384 of library/scripts/posters/data/ne-places.json, served at <assets>/boundaries/ne-places.json.
// Data, not code, but pinned the same way: a sheet uses the places only when they are the pinned ones.
export const NE_PLACES_SHA384 = '${places}'
`)
console.log(`[pin-poster-render] ${pin}`)
console.log(`[pin-poster-render] ${pin}\n[pin-poster-render] ne-places ${places}`)
}