Pinned files: a page that is not secure is told so in words

A browser gives crypto.subtle only to a secure page (https, or http://localhost). Opened any other way, for example a dev server reached over plain http by its LAN address from a phone, every pinned file was refused with a bare "reading 'digest'": the packet printed in English and page 1 fell back to its stored sheet. sha384() now refuses with a sentence that names the cause. check:pinned-fetch-fresh arm D2 removes crypto.subtle and requires that sentence; its self-test plants the unguarded call.
This commit is contained in:
Doxa Maps 2026-10-03 08:58:09 +00:00
parent e8a1a1f12c
commit 5d3b2cf95d
3 changed files with 19 additions and 4 deletions

View file

@ -16,13 +16,15 @@
* B. a cached copy that matches is used as is: ONE request, force-cache (the fast, offline-friendly path stays); * B. a cached copy that matches is used as is: ONE request, force-cache (the fast, offline-friendly path stays);
* C. a server that also serves the wrong bytes → rejected, err.integrity, after one fresh refetch; * C. a server that also serves the wrong bytes → rejected, err.integrity, after one fresh refetch;
* D. a file that is not served → rejected (not an integrity failure); * D. a file that is not served → rejected (not an integrity failure);
* D2. a page without crypto.subtle (not secure: plain http by LAN address) → refused saying "secure page";
* E. loadPacketStrings with a stale cached table → the full table in every language (not the English fallback); * E. loadPacketStrings with a stale cached table → the full table in every language (not the English fallback);
* F. loadIndiaStates with a stale cached table → India's 36 states (not a rejection); * F. loadIndiaStates with a stale cached table → India's 36 states (not a rejection);
* G. generateSheet with a stale cached generator → a drawn sheet (not an integrity refusal); * G. generateSheet with a stale cached generator → a drawn sheet (not an integrity refusal);
* H. wiring: every pinned loader fetches through fetchPinned with its own pin, and none fetches its pinned file * H. wiring: every pinned loader fetches through fetchPinned with its own pin, and none fetches its pinned file
* directly; the classic-script path refreshes the HTTP cache and retries a refused tag once. * directly; the classic-script path refreshes the HTTP cache and retries a refused tag once.
* `--self-test`: fetchPinned without its fresh refetch, one that never uses the cache, one that accepts a * `--self-test`: fetchPinned without its fresh refetch, one that never uses the cache, one that accepts a
* mismatch, and a loader that fetches its pinned file directly must each FAIL. * mismatch, a hash that reaches for crypto.subtle unguarded, and a loader that fetches its pinned file directly must
* each FAIL.
* Two-sided by hand at the change: the previous loaders (the pilot before this) fail E, F and G. * Two-sided by hand at the change: the previous loaders (the pilot before this) fail E, F and G.
*/ */
import { readFileSync, existsSync } from 'node:fs' import { readFileSync, existsSync } from 'node:fs'
@ -88,6 +90,12 @@ async function auditFetchPinned(shaSrc) {
{ world('https://d.test', { missing: [P] }); let err = null { world('https://d.test', { missing: [P] }); let err = null
try { await fetchPinned(`https://d.test/${P}`, pin) } catch (e) { err = e } try { await fetchPinned(`https://d.test/${P}`, pin) } catch (e) { err = e }
if (!err || err.integrity) fails.push(`D: an unserved file should reject as not served (${err ? err.message : 'it resolved'})`) } if (!err || err.integrity) fails.push(`D: an unserved file should reject as not served (${err ? err.message : 'it resolved'})`) }
// D2: a page that is not secure (plain http by LAN address) has no crypto.subtle: refused in WORDS, not a bare TypeError
{ world('https://e.test', { cached: { [P]: current } }); const realCrypto = globalThis.crypto; let err = null
Object.defineProperty(globalThis, 'crypto', { value: {}, configurable: true, writable: true })
try { await fetchPinned(`https://e.test/${P}`, pin) } catch (e) { err = e }
finally { Object.defineProperty(globalThis, 'crypto', { value: realCrypto, configurable: true, writable: true }) }
if (!err || err.integrity || !/secure page/.test(err.message)) fails.push(`D2: on a page without crypto.subtle the refusal should say "secure page", got ${err ? JSON.stringify(err.message) : 'a resolve'}`) }
globalThis.fetch = realFetch globalThis.fetch = realFetch
return fails return fails
} }
@ -146,6 +154,7 @@ if (process.argv.includes('--self-test')) {
['fetchPinned without its fresh refetch', () => auditFetchPinned(shaSrc.replace(FOR, "for (const cache of ['force-cache']) {")), /^A: /], ['fetchPinned without its fresh refetch', () => auditFetchPinned(shaSrc.replace(FOR, "for (const cache of ['force-cache']) {")), /^A: /],
['fetchPinned that never uses the cache', () => auditFetchPinned(shaSrc.replace(FOR, "for (const cache of ['reload', 'reload']) {")), /^B: /], ['fetchPinned that never uses the cache', () => auditFetchPinned(shaSrc.replace(FOR, "for (const cache of ['reload', 'reload']) {")), /^B: /],
['fetchPinned that accepts a mismatch', () => auditFetchPinned(shaSrc.replace(' if (got === pin) return text\n', ' return text\n')), /^C: /], ['fetchPinned that accepts a mismatch', () => auditFetchPinned(shaSrc.replace(' if (got === pin) return text\n', ' return text\n')), /^C: /],
['a hash that reaches for crypto.subtle unguarded', () => auditFetchPinned(shaSrc.replace(/ if \(!subtle\) throw [^\n]*\n/, '')), /^D2: /],
['a loader that fetches its pinned file directly', () => auditWiring((f) => (f === 'library/packet/packetStrings.js' ['a loader that fetches its pinned file directly', () => auditWiring((f) => (f === 'library/packet/packetStrings.js'
? read(f).replace('fetchPinned(url, PACKET_STRINGS_SHA384)', "(await fetch(url, { cache: 'force-cache' })).text()") : read(f))), /^H: library\/packet\/packetStrings\.js/], ? read(f).replace('fetchPinned(url, PACKET_STRINGS_SHA384)', "(await fetch(url, { cache: 'force-cache' })).text()") : read(f))), /^H: library\/packet\/packetStrings\.js/],
] ]
@ -159,5 +168,5 @@ if (process.argv.includes('--self-test')) {
} else { } else {
const f = [...await auditFetchPinned(shaSrc), ...await auditLoaders(), ...auditWiring(read)] const f = [...await auditFetchPinned(shaSrc), ...await auditLoaders(), ...auditWiring(read)]
if (f.length) { for (const x of f) console.log('✗ ' + x); console.log(`FAIL check:pinned-fetch-fresh (${f.length})`); process.exitCode = 1 } if (f.length) { for (const x of f) console.log('✗ ' + x); console.log(`FAIL check:pinned-fetch-fresh (${f.length})`); process.exitCode = 1 }
else console.log('PASS check:pinned-fetch-fresh — a stale cached copy of a pinned file is refetched once, fresh: page 1 still draws, the packet keeps its language, India keeps its states; a matching cached copy is used as is; wrong bytes are refused; every pinned loader goes through fetchPinned') else console.log('PASS check:pinned-fetch-fresh — a stale cached copy of a pinned file is refetched once, fresh: page 1 still draws, the packet keeps its language, India keeps its states; a matching cached copy is used as is; wrong bytes are refused; a page that is not secure is told so in words; every pinned loader goes through fetchPinned')
} }

View file

@ -1,8 +1,14 @@
// The Subresource-Integrity form of a text's SHA-384 ("sha384-<base64>"), via WebCrypto, so a file fetched at // The Subresource-Integrity form of a text's SHA-384 ("sha384-<base64>"), via WebCrypto, so a file fetched at
// print time can be compared with the pin baked into the bundle (poster-render.integrity.js) before it is used. // print time can be compared with the pin baked into the bundle (poster-render.integrity.js) before it is used.
// The same value pin-poster-render.mjs computes with node:crypto. // The same value pin-poster-render.mjs computes with node:crypto.
// ★ ONLY ON A SECURE PAGE. A browser gives crypto.subtle only to a secure page (https, or http://localhost). Opened any
// other way (a dev server reached over plain http by its LAN address, from a phone on the same Wi-Fi), every pinned
// file was refused with a bare "reading 'digest'": the packet printed in English and page 1 fell back to its stored
// sheet, with nothing saying why. Say it in words.
export async function sha384(text) { export async function sha384(text) {
const d = new Uint8Array(await crypto.subtle.digest('SHA-384', new TextEncoder().encode(text))) const subtle = globalThis.crypto && globalThis.crypto.subtle
if (!subtle) throw new Error('pinned files can only be checked on a secure page (https, or http://localhost); this page is not one')
const d = new Uint8Array(await subtle.digest('SHA-384', new TextEncoder().encode(text)))
let bin = '' let bin = ''
for (const b of d) bin += String.fromCharCode(b) for (const b of d) bin += String.fromCharCode(b)
return `sha384-${btoa(bin)}` return `sha384-${btoa(bin)}`

View file

@ -449,7 +449,7 @@
"check:pinned-fetch-fresh:self-test": "bun internal/check-pinned-fetch-fresh.mjs --self-test" "check:pinned-fetch-fresh:self-test": "bun internal/check-pinned-fetch-fresh.mjs --self-test"
}, },
"//scripts": { "//scripts": {
"check:pinned-fetch-fresh": "a STALE cached copy of a pinned print-time file never decides a print: library/packet/sha384.js fetchPinned fetches force-cache, and on a copy that is not served or fails its pin, ONCE more with cache:'reload'; every pinned loader (posterSheets poster-render.js + ne-places.json, packetStrings, indiaStates) calls it, and the classic-script path refreshes the cache and retries a refused tag once. With a modelled HTTP cache holding stale copies: the current file after one fresh refetch; a matching cached copy used with one force-cache request; wrong bytes refused; page 1 still draws (generateSheet), the packet keeps every language, India keeps its 36 states. Self-test poisons: no fresh refetch, never the cache, a mismatch accepted, a loader fetching its pinned file directly.", "check:pinned-fetch-fresh": "a STALE cached copy of a pinned print-time file never decides a print: library/packet/sha384.js fetchPinned fetches force-cache, and on a copy that is not served or fails its pin, ONCE more with cache:'reload'; every pinned loader (posterSheets poster-render.js + ne-places.json, packetStrings, indiaStates) calls it, and the classic-script path refreshes the cache and retries a refused tag once. With a modelled HTTP cache holding stale copies: the current file after one fresh refetch; a matching cached copy used with one force-cache request; wrong bytes refused; page 1 still draws (generateSheet), the packet keeps every language, India keeps its 36 states. Self-test poisons: no fresh refetch, never the cache, a mismatch accepted, an unguarded crypto.subtle, a loader fetching its pinned file directly. On a page that is not secure (crypto.subtle missing: plain http by LAN address) the refusal says so in words (arm D2).",
"check:phone-print-keep": "on a PHONE the in-page print document (#dxm-print-root and its print style, library/composables/printInPlace.js) and printImageBlob's object URL outlive afterprint, because Android Chrome fires afterprint once the PREVIEW is built and renders a saved PDF later, from the page as it is then (a phone's saved packet was the bare host page). A timeline on fake timers and a stub DOM with an Android user agent: still mounted 30 s after afterprint; released once the reader is back (shown again after hidden, focused again after blur, or touched after print() but not in its first TOUCH_SETTLE_MS) plus BACK_GRACE_MS (60 s), or at the ceiling; going away again during the grace cancels the pending drop; the Print tap of a late print does not count; a new print replaces it; desktop is the frame path, verbatim. --impl=<path> runs it against another copy (the pre-fix file fails). Self-test poisons: the old drop at afterprint, no release when back, listeners armed at mount, no grace, printImageBlob revoking at afterprint, no ceiling, no cancel when away again, no touch settle window.", "check:phone-print-keep": "on a PHONE the in-page print document (#dxm-print-root and its print style, library/composables/printInPlace.js) and printImageBlob's object URL outlive afterprint, because Android Chrome fires afterprint once the PREVIEW is built and renders a saved PDF later, from the page as it is then (a phone's saved packet was the bare host page). A timeline on fake timers and a stub DOM with an Android user agent: still mounted 30 s after afterprint; released once the reader is back (shown again after hidden, focused again after blur, or touched after print() but not in its first TOUCH_SETTLE_MS) plus BACK_GRACE_MS (60 s), or at the ceiling; going away again during the grace cancels the pending drop; the Print tap of a late print does not count; a new print replaces it; desktop is the frame path, verbatim. --impl=<path> runs it against another copy (the pre-fix file fails). Self-test poisons: the old drop at afterprint, no release when back, listeners armed at mount, no grace, printImageBlob revoking at afterprint, no ceiling, no cancel when away again, no touch settle window.",
"_": "What each script does. These notes used to sit inside the command strings as trailing \"# …\" comments, which made the shell discard any arguments passed after \"--\" (so a gate given --url silently measured its hardcoded default port — possibly another agent's server). Keep notes here, never in a command.", "_": "What each script does. These notes used to sit inside the command strings as trailing \"# …\" comments, which made the shell discard any arguments passed after \"--\" (so a gate given --url silently measured its hardcoded default port — possibly another agent's server). Keep notes here, never in a command.",
"browser-gates": "lists every package.json script that launches a browser (the same detection run-parallel uses to route them through internal/browser-slot.sh). Run each as internal/browser-slot.sh bun run <name>.", "browser-gates": "lists every package.json script that launches a browser (the same detection run-parallel uses to route them through internal/browser-slot.sh). Run each as internal/browser-slot.sh bun run <name>.",