Implement scoped P06 authentication policy and guarded optional onboarding
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a09cbb-87c6-7900-a145-4ce53ba9f1a6
This commit is contained in:
parent
aa709fb854
commit
e0b3c25f06
12 changed files with 1085 additions and 2 deletions
68
scripts/provider_browser.mjs
Normal file
68
scripts/provider_browser.mjs
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import {createHmac} from 'node:crypto';
|
||||
// No production accounts, network providers or header overrides are used.
|
||||
const [debug, base] = process.argv.slice(2);
|
||||
const version=await (await fetch(debug+'/json/version')).json();
|
||||
const ws=new WebSocket(version.webSocketDebuggerUrl);
|
||||
await new Promise(resolve=>ws.addEventListener('open',resolve,{once:true}));
|
||||
let next=0;const pending=new Map();
|
||||
ws.addEventListener('message',event=>{const m=JSON.parse(event.data);if(pending.has(m.id)){const p=pending.get(m.id);pending.delete(m.id);m.error?p.reject(Error(m.error.message)):p.resolve(m.result);}});
|
||||
const call=(method,params={},sessionId)=>new Promise((resolve,reject)=>{const id=++next;pending.set(id,{resolve,reject});ws.send(JSON.stringify({id,method,params,...(sessionId?{sessionId}:{})}));});
|
||||
const {targetId}=await call('Target.createTarget',{url:'about:blank'});
|
||||
const {sessionId}=await call('Target.attachToTarget',{targetId,flatten:true});
|
||||
const cmd=(method,params)=>call(method,params,sessionId);
|
||||
await cmd('Page.enable');await cmd('Network.enable');
|
||||
const evaluate=async expression=>{const r=await cmd('Runtime.evaluate',{expression,returnByValue:true});if(r.exceptionDetails)throw Error(r.exceptionDetails.exception?.description?.split('\n')[0] || 'Browser evaluation failed');return r.result.value;};
|
||||
async function waitFor(expression){for(let n=0;n<300;n++){if(await evaluate(expression))return;await new Promise(r=>setTimeout(r,100));}throw Error('Browser condition not met: '+expression);}
|
||||
async function navigate(path){await cmd('Page.navigate',{url:base+path});await waitFor('location.origin === '+JSON.stringify(base)+' && document.readyState !== "loading"');}
|
||||
async function identity(who){await cmd('Network.clearBrowserCookies');if(who)await cmd('Network.setCookie',{name:'ue_session',value:who,url:base,path:'/',httpOnly:true,sameSite:'Lax'});}
|
||||
let checks=0;async function check(expression,name){if(!await evaluate(expression))throw Error(name);console.log('PASS '+name);checks++;}
|
||||
const captures=[];const responses=new Set();
|
||||
ws.addEventListener('message', async event=>{const message=JSON.parse(event.data);if(message.method==='Network.responseReceived' && message.params.response.url.split('?')[0].endsWith('/token/init'))responses.add(message.params.requestId);if(message.method==='Network.loadingFinished' && responses.has(message.params.requestId)){try{const body=await cmd('Network.getResponseBody',{requestId:message.params.requestId});captures.push(JSON.parse(body.body));}catch{}}});
|
||||
try {
|
||||
await navigate('/');
|
||||
await waitFor('!!document.querySelector("#username") && document.querySelector("#username").offsetParent!==null');
|
||||
await evaluate(`document.querySelector('#username').value='alice@fixture';document.querySelector('#username').dispatchEvent(new Event('input',{bubbles:true}));const password=Array.from(document.querySelectorAll('input[type=password]')).find(e=>e.offsetParent!==null);password.value='fixture-password';password.dispatchEvent(new Event('input',{bubbles:true}));Array.from(document.querySelectorAll('button')).find(b=>b.offsetParent!==null && b.textContent.trim()==='Log In').click()`);
|
||||
await waitFor('!!Array.from(document.links).find(a=>a.textContent.trim()==="Enroll Token")');
|
||||
await check(`document.body.innerText.includes('alice @fixture (user)')`,'provider password login identifies the correct account');
|
||||
async function enroll(){
|
||||
const count=captures.length;
|
||||
await evaluate(`location.hash='!/token/enroll///'`);
|
||||
await waitFor('!!document.querySelector("#tokentype") && document.querySelector("#tokentype").offsetParent!==null');
|
||||
await waitFor('document.querySelector("#tokentype").value === "string:totp"');
|
||||
await evaluate(`document.querySelector('#tokentype').dispatchEvent(new Event('change',{bubbles:true}));`);
|
||||
await evaluate(`Array.from(document.querySelectorAll('button')).find(b=>b.offsetParent!==null && b.textContent.trim()==='Enroll Token').click()`);
|
||||
for(let i=0;i<300 && captures.length===count;i++)await new Promise(r=>setTimeout(r,100));
|
||||
const response=captures[count];if(response?.detail?.rollout_state!=='verify')throw Error('Enrollment did not require possession confirmation');
|
||||
await waitFor('!!document.querySelector("#verifyResponse") && document.querySelector("#verifyResponse").offsetParent!==null');
|
||||
return response.detail;
|
||||
}
|
||||
const first=await enroll();
|
||||
await check(`!!document.querySelector('#verifyResponse')`,'provider pending setup requests possession proof');
|
||||
await evaluate(`location.hash='!/token/list'`);
|
||||
await waitFor('!!Array.from(document.links).find(a=>a.textContent.trim()==='+JSON.stringify(first.serial)+')');
|
||||
await evaluate(`Array.from(document.links).find(a=>a.textContent.trim()===${JSON.stringify(first.serial)}).click()`);
|
||||
await waitFor(`!!document.querySelector('[ng-click="deleteTokenAsk()"]')`);
|
||||
await waitFor(`!document.querySelector('#deleteButton').disabled && Array.from(document.querySelectorAll('td')).some(e=>e.textContent.trim()==='verify')`);
|
||||
await evaluate(`document.querySelector('[ng-click="deleteTokenAsk()"]').click()`);
|
||||
await waitFor('location.hash.includes("/token/list") && !Array.from(document.links).find(a=>a.textContent.trim()==='+JSON.stringify(first.serial)+')');
|
||||
await check(`!document.body.innerText.includes(${JSON.stringify(first.serial)})`,'provider unfinished setup can be cancelled');
|
||||
const second=await enroll();
|
||||
const uri=new URL(second.googleurl.value);const alphabet='ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';let bits=0,value=0;const bytes=[];
|
||||
for(const character of uri.searchParams.get('secret').replace(/=/g,'')){value=(value<<5)|alphabet.indexOf(character);bits+=5;if(bits>=8){bits-=8;bytes.push((value>>>bits)&255);}}
|
||||
const counter=Buffer.alloc(8);counter.writeBigUInt64BE(BigInt(Math.floor(Date.now()/30000)));
|
||||
const digest=createHmac('sha1',Buffer.from(bytes)).update(counter).digest();const offset=digest[digest.length-1]&15;const otp=String((digest.readUInt32BE(offset)&0x7fffffff)%1000000).padStart(6,'0');
|
||||
await evaluate(`const field=document.querySelector('#verifyResponse');field.value=${JSON.stringify(otp)};field.dispatchEvent(new Event('input',{bubbles:true}));Array.from(document.querySelectorAll('button')).find(b=>b.offsetParent!==null && b.textContent.trim()==='Verify Token').click()`);
|
||||
await waitFor('document.body.innerText.includes("Token successfully verified")');
|
||||
await check(`document.body.innerText.includes('Token successfully verified')`,'provider browser possession confirmation activates TOTP');
|
||||
// A password-only provider session must not enroll an attacker-controlled replacement.
|
||||
await evaluate(`location.hash='!/token/list'`);
|
||||
await waitFor('!!Array.from(document.links).find(a=>a.textContent.trim()==='+JSON.stringify(second.serial)+')');
|
||||
await evaluate(`location.hash='!/token/enroll///'`);
|
||||
await waitFor('!!document.querySelector("#tokentype") && document.querySelector("#tokentype").offsetParent!==null');
|
||||
await waitFor('document.querySelector("#tokentype").value === "string:totp"');
|
||||
await evaluate(`document.querySelector('#tokentype').dispatchEvent(new Event('change',{bubbles:true}));`);
|
||||
await evaluate(`Array.from(document.querySelectorAll('button')).find(b=>b.offsetParent!==null && b.textContent.trim()==='Enroll Token').click()`);
|
||||
await waitFor('document.body.innerText.includes("active authenticator already exists")');
|
||||
await check(`document.body.innerText.includes('account recovery process')`,'provider active-factor replacement has a recovery route');
|
||||
console.log(JSON.stringify({checks,result:'passed',scope:'installed provider browser, disposable database and fixture identity only'}));
|
||||
} finally {await call('Target.closeTarget',{targetId});ws.close();}
|
||||
Loading…
Add table
Add a link
Reference in a new issue