Preserve verified N-1 app boot after a cache entry is lost
This commit is contained in:
@@ -12,26 +12,46 @@
|
|||||||
// consumption can finish after that event; boot exactly once in that case.
|
// consumption can finish after that event; boot exactly once in that case.
|
||||||
if(domReady)root.boot();
|
if(domReady)root.boot();
|
||||||
}
|
}
|
||||||
|
async function external(url) {
|
||||||
|
await new Promise((resolve,reject)=>{
|
||||||
|
const node=doc.createElement('script');node.src=url;
|
||||||
|
node.onload=()=>{if(domReady&&!appSawDom)root.boot();resolve();};node.onerror=()=>reject(Error('Unable to load player'));
|
||||||
|
doc.head.append(node);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function verified(response, expected) {
|
||||||
|
const bytes=await response.clone().arrayBuffer();
|
||||||
|
const digest=new Uint8Array(await root.crypto.subtle.digest('SHA-256',bytes));
|
||||||
|
const hex=Array.from(digest,b=>b.toString(16).padStart(2,'0')).join('');
|
||||||
|
if(hex.slice(0,10)!==expected)throw Error('Player body hash mismatch');
|
||||||
|
return {bytes,digest};
|
||||||
|
}
|
||||||
async function start() {
|
async function start() {
|
||||||
if(!manifest.appCache || !root.crypto?.subtle) {
|
if(!manifest.appCache || !root.crypto?.subtle)return external(key);
|
||||||
await new Promise((resolve,reject)=>{
|
|
||||||
const node=doc.createElement('script');node.src=key;
|
|
||||||
node.onload=()=>{if(domReady&&!appSawDom)root.boot();resolve();};node.onerror=()=>reject(Error('Unable to load player'));
|
|
||||||
doc.head.append(node);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const expected=manifest.files['/app.js'].h;
|
const expected=manifest.files['/app.js'].h;
|
||||||
let cache;
|
let cache;
|
||||||
try { cache=await root.caches?.open('ytplayer-assets'); } catch {}
|
try { cache=await root.caches?.open('ytplayer-assets'); } catch {}
|
||||||
let response=await cache?.match(key);
|
let response=await cache?.match(key);
|
||||||
if(!response || response.headers.get('X-Asset-Hash')!==expected)response=await (root.Lazy.appResponse || root.fetch(key,{credentials:'same-origin'}));
|
if(!response || response.headers.get('X-Asset-Hash')!==expected) {
|
||||||
if(!response.ok || response.headers.get('X-Asset-Hash')!==expected)throw Error('Player asset hash mismatch');
|
try { response=await (root.Lazy.appResponse || root.fetch(key,{credentials:'same-origin'})); } catch {}
|
||||||
const bytes=await response.clone().arrayBuffer();
|
}
|
||||||
const digest=new Uint8Array(await root.crypto.subtle.digest('SHA-256',bytes));
|
if(!response?.ok || response.headers.get('X-Asset-Hash')!==expected) {
|
||||||
const hex=Array.from(digest,b=>b.toString(16).padStart(2,'0')).join('');
|
// Preserve staged N-1 boot after an individual cache eviction. Only the
|
||||||
|
// controller can serve this old URL safely; never fetch stale URLs bare.
|
||||||
|
const state=cache && await root.AssetSyncCore.state(cache);
|
||||||
|
const previousKey=state && root.AssetSyncCore.fallback(manifest,state.previous,'/app.js');
|
||||||
|
const previous=previousKey && await cache.match(previousKey);
|
||||||
|
const previousHash=state?.previous?.files['/app.js']?.h;
|
||||||
|
if(root.navigator?.serviceWorker?.controller && previous?.ok && previous.headers.get('X-Asset-Hash')===previousHash) {
|
||||||
|
await verified(previous,previousHash);
|
||||||
|
delete root.Lazy.appResponse;
|
||||||
|
return external(previousKey);
|
||||||
|
}
|
||||||
|
throw Error('Player asset hash mismatch');
|
||||||
|
}
|
||||||
|
const {bytes,digest}=await verified(response,expected);
|
||||||
const permission="'sha256-"+root.btoa(String.fromCharCode(...digest))+"'";
|
const permission="'sha256-"+root.btoa(String.fromCharCode(...digest))+"'";
|
||||||
if(hex.slice(0,10)!==expected || !doc.querySelector('meta[http-equiv="Content-Security-Policy"]').content.includes(permission))throw Error('Player body hash mismatch');
|
if(!doc.querySelector('meta[http-equiv="Content-Security-Policy"]').content.includes(permission))throw Error('Player body hash mismatch');
|
||||||
// CacheStorage may share a full quota with saved music. Never remove data
|
// CacheStorage may share a full quota with saved music. Never remove data
|
||||||
// to make room; boot from these verified bytes even if caching is refused.
|
// to make room; boot from these verified bytes even if caching is refused.
|
||||||
try { await cache?.put(key,response); } catch(error) { root.console.warn('[app-cache]',error.message); }
|
try { await cache?.put(key,response); } catch(error) { root.console.warn('[app-cache]',error.message); }
|
||||||
|
|||||||
@@ -3,16 +3,18 @@ const assert=require('node:assert/strict');
|
|||||||
const {webcrypto,createHash}=require('node:crypto');
|
const {webcrypto,createHash}=require('node:crypto');
|
||||||
const vm=require('node:vm');
|
const vm=require('node:vm');
|
||||||
const {readFileSync}=require('node:fs');
|
const {readFileSync}=require('node:fs');
|
||||||
function fixture({cached=false,tampered=false,enabled=true,loading=false,quota=false,insecure=false}={}){
|
function fixture({cached=false,tampered=false,enabled=true,loading=false,quota=false,insecure=false,previous=false,contract=1}={}){
|
||||||
const source='window.appRuns=(window.appRuns||0)+1; window.boot=()=>window.bootRuns=(window.bootRuns||0)+1; document.addEventListener("DOMContentLoaded",boot);',hash=createHash('sha256').update(source).digest('hex'), key='/app.js?v='+hash.slice(0,10);
|
const source='window.appRuns=(window.appRuns||0)+1; window.boot=()=>window.bootRuns=(window.bootRuns||0)+1; document.addEventListener("DOMContentLoaded",boot);',hash=createHash('sha256').update(source).digest('hex'), key='/app.js?v='+hash.slice(0,10);
|
||||||
|
const oldSource='window.oldApp=true;'+source, oldHash=createHash('sha256').update(oldSource).digest('hex').slice(0,10), oldKey='/app.js?v='+oldHash;
|
||||||
const held=new Map(), appended=[],listeners={};let calls=0;
|
const held=new Map(), appended=[],listeners={};let calls=0;
|
||||||
const response=()=>new Response(tampered?'bad':source,{headers:{'X-Asset-Hash':hash.slice(0,10)}});
|
const response=()=>new Response(tampered?'bad':source,{headers:{'X-Asset-Hash':hash.slice(0,10)}});
|
||||||
if(cached)held.set(key,response());
|
if(cached)held.set(key,response());
|
||||||
const root={crypto:insecure?undefined:webcrypto,TextDecoder,Uint8Array,btoa,console,Lazy:{manifest:{appCache:enabled,files:{'/app.js':{h:hash.slice(0,10)}}},url:()=>key},fetch:async()=>{calls++;return response();},caches:{open:async()=>({match:async k=>held.get(k)?.clone(),put:async(k,r)=>{if(quota)throw Error('quota');held.set(k,r.clone());}})}};
|
if(previous){held.set(oldKey,new Response(oldSource,{headers:{'X-Asset-Hash':oldHash}}));held.set('/__ytp_asset_state',Response.json({previous:{files:{'/app.js':{h:oldHash}},groups:{core:{contract,files:['/app.js']}}}}));}
|
||||||
const doc=root.document={readyState:loading?'loading':'complete',querySelector:()=>({content:"script-src 'self' 'sha256-"+Buffer.from(hash,'hex').toString('base64')+"'"}),addEventListener:(name,fn)=>(listeners[name] ||= []).push(fn),createElement:()=>({remove(){this.removed=true;}}),head:{append(node){appended.push(node);if(node.textContent)vm.runInContext(node.textContent,context);else {vm.runInContext(source,context);queueMicrotask(()=>node.onload());}}}};
|
const root={crypto:insecure?undefined:webcrypto,TextDecoder,Uint8Array,btoa,console,AssetSyncCore:require('./asset-sync-core'),navigator:{serviceWorker:{controller:{}}},Lazy:{manifest:{groups:{core:{contract:1,files:['/app.js']}},appCache:enabled,files:{'/app.js':{h:hash.slice(0,10)}}},url:()=>key},fetch:async()=>{calls++;return previous?new Response(oldSource,{headers:{'X-Asset-Hash':oldHash}}):response();},caches:{open:async()=>({match:async k=>held.get(k)?.clone(),put:async(k,r)=>{if(quota)throw Error('quota');held.set(k,r.clone());}})}};
|
||||||
|
const doc=root.document={readyState:loading?'loading':'complete',querySelector:()=>({content:"script-src 'self' 'sha256-"+Buffer.from(hash,'hex').toString('base64')+"'"}),addEventListener:(name,fn)=>(listeners[name] ||= []).push(fn),createElement:()=>({remove(){this.removed=true;}}),head:{append(node){appended.push(node);if(node.textContent)vm.runInContext(node.textContent,context);else {vm.runInContext(node.src===oldKey?oldSource:source,context);queueMicrotask(()=>node.onload());}}}};
|
||||||
root.window=root;const context=vm.createContext(root);
|
root.window=root;const context=vm.createContext(root);
|
||||||
vm.runInContext(readFileSync(require.resolve('./app-bootstrap.js'),'utf8'),context);
|
vm.runInContext(readFileSync(require.resolve('./app-bootstrap.js'),'utf8'),context);
|
||||||
return {root,held,appended,listeners,key,calls:()=>calls,doc,response};
|
return {root,held,appended,listeners,key,calls:()=>calls,doc,response,oldKey};
|
||||||
}
|
}
|
||||||
test('cold boot caches verified app before execution so worker skips its download',async()=>{
|
test('cold boot caches verified app before execution so worker skips its download',async()=>{
|
||||||
const f=fixture();await f.root.AppBootstrap.ready;assert.equal(f.calls(),1);assert.ok(f.held.has(f.key));assert.equal(f.root.appRuns,1);assert.equal(f.root.bootRuns,1);assert.equal(f.appended[0].src,undefined);assert.equal(f.appended[0].removed,true);
|
const f=fixture();await f.root.AppBootstrap.ready;assert.equal(f.calls(),1);assert.ok(f.held.has(f.key));assert.equal(f.root.appRuns,1);assert.equal(f.root.bootRuns,1);assert.equal(f.appended[0].src,undefined);assert.equal(f.appended[0].removed,true);
|
||||||
@@ -43,3 +45,12 @@ test('the early head response is consumed without a second page fetch',async()=>
|
|||||||
test('insecure local HTTP boot retains external execution without WebCrypto',async()=>{
|
test('insecure local HTTP boot retains external execution without WebCrypto',async()=>{
|
||||||
const f=fixture({insecure:true});await f.root.AppBootstrap.ready;assert.equal(f.calls(),0);assert.equal(f.appended[0].src,f.key);assert.equal(f.root.bootRuns,1);
|
const f=fixture({insecure:true});await f.root.AppBootstrap.ready;assert.equal(f.calls(),0);assert.equal(f.appended[0].src,f.key);assert.equal(f.root.bootRuns,1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('missing current app uses verified same-contract N-1 offline through the controlling worker',async()=>{
|
||||||
|
const f=fixture({previous:true});await f.root.AppBootstrap.ready;
|
||||||
|
assert.equal(f.appended[0].src,f.oldKey);assert.equal(f.root.oldApp,true);assert.equal(f.root.bootRuns,1);assert.equal(f.held.has(f.key),false);
|
||||||
|
});
|
||||||
|
test('an incompatible previous core never executes against the new shell',async()=>{
|
||||||
|
const f=fixture({previous:true,contract:2});await assert.rejects(f.root.AppBootstrap.ready,/hash/);assert.equal(f.root.appRuns,undefined);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user