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:
parent
155c774b22
commit
3268105643
4 changed files with 59 additions and 9 deletions
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue