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);
* 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);
* 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);
* 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);
* 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.
* `--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.
*/
import { readFileSync, existsSync } from 'node:fs'
@ -88,6 +90,12 @@ async function auditFetchPinned(shaSrc) {
{ world('https://d.test', { missing: [P] }); let err = null
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'})`) }
// 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
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 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: /],
['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'
? 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 {
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 }
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
// 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.
// ★ 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) {
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 = ''
for (const b of d) bin += String.fromCharCode(b)
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"
},
"//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.",
"_": "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>.",