diff --git a/docs/enrollment/ceremony.md b/docs/enrollment/ceremony.md new file mode 100644 index 0000000..9153ad6 --- /dev/null +++ b/docs/enrollment/ceremony.md @@ -0,0 +1,43 @@ +# Public credential ceremony + +The `/enroll/` source page creates a WebAuthn credential and exports only its +credential ID, COSE public key, scheme, origin/RP and fingerprint. It has no +registration transport, bearer, actor authority, graph allowlist or enable +operation. The fingerprint is the first eight SHA-256 bytes of the exact COSE +encoding, rendered as four groups of four hex digits. Compare it with the +operator's native signer status after registration. + +Source configuration ships disabled. An approved deployment must provide one +exact dedicated HTTPS origin and its identical RP hostname. URL parameters, +local storage and browser-entered values cannot enable or change that scope. +The default button is disabled and no credential is created on page load. A +configured supported browser asks only after an explicit click. Cancellation, +unsupported exports, mismatched context and malformed data produce no packet; +creation may still have left a credential on the authenticator. + +The page checks the returned RP hash, credential identifier, creation challenge, +origin, user presence/verification and COSE structure. It declines credentials +reporting backup eligibility or backup state, as required by the single-device +scope, and declines unrequested authenticator extensions. These checks interpret +returned authenticator metadata; they are not hardware attestation or proof of +the person's identity. WebAuthn binds credentials to an RP/origin, not a URL path. +The dedicated origin must remain isolated from unrelated applications. +See the [W3C authenticator-data definition](https://www.w3.org/TR/webauthn-3/#sctn-authenticator-data). + +The operator still establishes the human identity, chooses the approved graph +scope and expiry, enrolls through the generic local signer CLI, and compares the +fingerprint with the device. Enrollment does not enable writes or protect a +graph. Rotation enrolls a new ID; the native CLI revokes the old ID. The page +never performs those operations and does not claim a credential is enrolled. + +Before a real human ceremony, the generic signer CLI must be installed and +qualified, the dedicated TLS origin trusted on the device, and the existing +origin/CORS gate must show its live wrong-origin refusal. The decision path, +served-reader checks and signed-action activation remain separate acceptance. +Do not prompt the human or flip a production configuration on source delivery. + +Local source qualification uses unit negatives and Chromium's virtual +authenticator on intercepted synthetic HTTPS assets. Its public packet is +accepted by the candidate native signer CLI in a disposable store, with the +same fingerprint and the switch still off. This is not a real enrollment, +production-origin smoke, hardware test or proof of installed acceptance. diff --git a/docs/enrollment/ceremony.png b/docs/enrollment/ceremony.png new file mode 100644 index 0000000..c9c2d1c Binary files /dev/null and b/docs/enrollment/ceremony.png differ diff --git a/docs/enrollment/ceremony.webm b/docs/enrollment/ceremony.webm new file mode 100644 index 0000000..c9fecb8 Binary files /dev/null and b/docs/enrollment/ceremony.webm differ diff --git a/docs/enrollment/demo.md b/docs/enrollment/demo.md new file mode 100644 index 0000000..ddb2d71 --- /dev/null +++ b/docs/enrollment/demo.md @@ -0,0 +1,89 @@ +# Ceremony source demonstration + +The shipped page is disabled and creates no credential. In this synthetic +configured fixture, an explicit click creates one virtual WebAuthn credential +and displays its public packet and fingerprint, with **Not enrolled** visible. +The exported packet also passes the clean candidate Quipu signer CLI at commit +`2ad619f2df76b51753011583f0d4c2c7fb59d84c` in a disposable store; its fingerprint +matches and its switch remains off. + +[Download the real WebM recording](ceremony.webm), or view the +[static final-state screenshot](ceremony.png). Playback requires a WebM-capable +player; inline playback is not assumed. The small recording is retained in +this source folder, without an external artifact host. It shows the candidate +working tree over Rikuy base `de8c7fdc6fe65a91e9cbdf78f29891aaa025b578`; +no production configuration or real device was involved. Neither the video nor +the public packet proves human identity or installed acceptance. + +Six actual browser controls pass: source-disabled, wrong-origin, configured +success, backup-eligible refusal, cancellation and unsupported WebAuthn. The +first two and last two create zero credentials. The successful authenticator +reports flags 69; the backup-eligible control reports 77 and exports nothing. +All asset requests are intercepted locally, with zero nonlocal traffic. +Three source mutations (ignore enabled, ignore backup flag, shift fingerprint) +fail their unit controls; restored source passes the full suite. + +## Repeat the browser fixture + +Use Node22, Playwright1.58.2 with its video recorder dependency, Chrome and an +isolated display. Set `PLAYWRIGHT_MODULE` to the installed module if it is not +on Node's search path. Point `JJY_FIXTURE_CLI` at the candidate signer executable. +Copy the following block to `/tmp/playwright-test-ceremony.cjs`, then run it +from the repository root under a private HOME. `JJY_EVIDENCE` selects an owned +temporary output directory. The script never requests or exports private keys. + +```javascript +const {chromium}=require(process.env.PLAYWRIGHT_MODULE || 'playwright'); +const fs=require('fs'),path=require('path'),assert=require('assert/strict'),{execFileSync}=require('child_process'); +const ROOT=process.env.JJY_SOURCE||process.cwd(); +const ORIGIN=process.env.JJY_FIXTURE_ORIGIN||'https://enroll.example.test'; +const OUT=process.env.JJY_EVIDENCE||fs.mkdtempSync(path.join(require('os').tmpdir(),'rikuy-ceremony-')); +const CLI=process.env.JJY_FIXTURE_CLI; +(async()=>{ + fs.mkdirSync(OUT,{recursive:true,mode:0o700}); + const browser=await chromium.launch({headless:false,executablePath:process.env.CHROME_BIN||'/usr/bin/google-chrome',args:['--no-sandbox','--disable-dev-shm-usage']}); + const results=[]; + try{ + for(const scenario of ['disabled','wrong-origin','enabled','backup-eligible','cancelled','unsupported']){ + const context=await browser.newContext({viewport:{width:980,height:900},...(scenario==='enabled'?{recordVideo:{dir:path.join(OUT,'video'),size:{width:980,height:900}}}:{})}),blocked=[],errors=[]; + try{ + const page=await context.newPage();page.on('pageerror',e=>errors.push(e.message)); + await context.route('**/*',async route=>{ + const url=new URL(route.request().url()); + if(url.origin!==ORIGIN){blocked.push(url.origin);await route.abort();return;} + let file=decodeURIComponent(url.pathname).replace(/^\/enroll\//,'');if(file===''||file==='index.html')file='index.html'; + if(!['index.html','app.mjs','model.mjs','cose.mjs','config.mjs','style.css'].includes(file)){blocked.push(url.pathname);await route.abort();return;} + let body=fs.readFileSync(path.join(ROOT,'web/enroll',file),'utf8'); + if(file==='config.mjs'&&scenario!=='disabled')body=`export const config=Object.freeze({enabled:true,origin:${JSON.stringify(scenario==='wrong-origin'?'https://other.example.test':ORIGIN)},rpId:'enroll.example.test'});`; + await route.fulfill({status:200,body,contentType:file.endsWith('.mjs')?'text/javascript':file.endsWith('.css')?'text/css':'text/html'}); + }); + const cdp=await context.newCDPSession(page);let creations=0;cdp.on('WebAuthn.credentialAdded',()=>{creations++;});await cdp.send('WebAuthn.enable'); + const {authenticatorId}=await cdp.send('WebAuthn.addVirtualAuthenticator',{options:{protocol:'ctap2',transport:'internal',hasResidentKey:true,hasUserVerification:true,isUserVerified:true,automaticPresenceSimulation:true,defaultBackupEligibility:scenario==='backup-eligible',defaultBackupState:false}}); + await page.addInitScript(()=>{if(navigator.credentials?.create){const original=navigator.credentials.create.bind(navigator.credentials);Object.defineProperty(navigator.credentials,'create',{value:async options=>{const credential=await original(options);globalThis.__fixtureAuthFlags=new Uint8Array(credential.response.getAuthenticatorData())[32];return credential;}});}}); + if(scenario==='unsupported')await page.addInitScript(()=>Object.defineProperty(navigator,'credentials',{value:{}})); + if(scenario==='cancelled')await page.addInitScript(()=>Object.defineProperty(navigator,'credentials',{value:{create:async()=>{throw new DOMException('cancelled','NotAllowedError');}}})); + await page.goto(ORIGIN+'/enroll/index.html');await page.waitForFunction(()=>document.querySelector('[role=status]').textContent.includes('origin')||document.querySelector('[role=status]').textContent.includes('unavailable')||!document.querySelector('button').disabled); + const button=page.getByRole('button',{name:'Create signing credential'}); + if(['disabled','wrong-origin','unsupported'].includes(scenario)){ + assert.equal(await button.isDisabled(),true);assert.equal(await page.locator('textarea').inputValue(),''); + }else{ + if(scenario==='enabled')await page.waitForTimeout(1000); + await button.click();await page.waitForFunction(()=>!document.querySelector('button').disabled,{timeout:10000}); + if(scenario==='enabled'){ + const packet=JSON.parse(await page.locator('textarea').inputValue());assert.equal(packet.backup_eligible,false);assert.equal(packet.scheme,'webauthn-es256');assert.match(await page.locator('[role=status]').innerText(),/Not enrolled/); + const fixture=fs.mkdtempSync(path.join(OUT,'native-'));const db=path.join(fixture,'fixture.db');const env={...process.env,HOME:fixture}; + const response=execFileSync(CLI,['signer','enroll','--credential-id',packet.credential_id,'--cose-key',packet.cose_key,'--scheme',packet.scheme,'--origin',packet.origin,'--rp-id',packet.rp_id,'--actor','fixture-operator','--graph','https://example.test/answers','--expires-at',String(Math.floor(Date.now()/1000)+86400),'--db',db],{encoding:'utf8',env}); + assert.ok(response.includes(packet.fingerprint));const status=JSON.parse(execFileSync(CLI,['signer','status','--db',db],{encoding:'utf8',env}));assert.equal(status.enabled,false);assert.equal(status.signers.length,1);assert.equal(status.signers[0].fingerprint,packet.fingerprint);assert.equal(status.signers[0].id,packet.credential_id); + fs.writeFileSync(path.join(OUT,'browser-public-fixture.json'),JSON.stringify(packet,null,2));await page.bringToFront();await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));await page.screenshot({path:path.join(OUT,'enabled.png'),fullPage:true});fs.rmSync(fixture,{recursive:true});await page.waitForTimeout(3000); + }else{ + assert.equal(await page.locator('textarea').inputValue(),'');assert.match(await page.locator('[role=status]').innerText(),/not registered/); + } + } + const flags=await page.evaluate(()=>globalThis.__fixtureAuthFlags??null);if(scenario==='enabled')assert.equal(flags&0x18,0);if(scenario==='backup-eligible')assert.equal(flags&0x08,0x08);assert.equal(creations,['enabled','backup-eligible'].includes(scenario)?1:0);assert.deepEqual(errors,[]);assert.deepEqual(blocked,[]);results.push({scenario,pass:true,credential_creations:creations,authenticator_flags:flags,nonlocal_network_requests:blocked.length,page_errors:errors}); + await cdp.send('WebAuthn.removeVirtualAuthenticator',{authenticatorId}); + }finally{await context.close();} + } + fs.writeFileSync(path.join(OUT,'browser-results.json'),JSON.stringify({source_kind:'uncommitted local source over de8c7fd; synthetic secure-origin routes; virtual authenticator, no human/private key export or production request',results},null,2));console.log(JSON.stringify(results)); + }finally{await browser.close();} +})().catch(e=>{console.error(e.stack);process.exitCode=1;}); +``` diff --git a/scripts/build.mjs b/scripts/build.mjs index 5aafb7a..fea6a55 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -5,7 +5,7 @@ const root=resolve(import.meta.dirname,'..'),out=resolve(root,'dist'); await rm(out,{recursive:true,force:true});await mkdir(out,{recursive:true}); const filter=p=>!p.endsWith('.test.mjs')&&!p.includes('/fixtures')&&!p.endsWith('/demo.mjs'); await cp(resolve(root,'web/legacy'),out,{recursive:true,filter}); -for(const name of ['knowledge','offline'])await cp(resolve(root,'web',name),resolve(out,name),{recursive:true,filter}); +for(const name of ['knowledge','offline','enroll'])await cp(resolve(root,'web',name),resolve(out,name),{recursive:true,filter}); const revision=execFileSync('git',['rev-parse','HEAD'],{cwd:root,encoding:'utf8'}).trim(); await writeFile(resolve(out,'source.json'),JSON.stringify({project:'rikuy',revision,activation:'deployment-owned; experimental readers OFF'},null,2)+'\n'); console.log('Built static Rikuy assets; experimental readers remain disabled.'); diff --git a/tests/build.test.mjs b/tests/build.test.mjs index cf04cdb..d3f88c1 100644 --- a/tests/build.test.mjs +++ b/tests/build.test.mjs @@ -10,3 +10,10 @@ test('deployment assets omit the synthetic demo entry point and fixture payload' await assert.rejects(access('dist/demo.mjs')); await assert.rejects(access('dist/demo-rows.json')); }); + + +test('enrollment assets are published together and retain the disabled configuration',async()=>{ + execFileSync(process.execPath,['scripts/build.mjs']); + for(const name of ['index.html','app.mjs','model.mjs','cose.mjs','config.mjs','style.css'])assert.equal(await readFile('dist/enroll/'+name,'utf8'),await readFile('web/enroll/'+name,'utf8')); + assert.match(await readFile('dist/enroll/config.mjs','utf8'),/enabled:\s*false/); +}); diff --git a/tests/enrollment.test.mjs b/tests/enrollment.test.mjs new file mode 100644 index 0000000..b2f21e0 --- /dev/null +++ b/tests/enrollment.test.mjs @@ -0,0 +1,72 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {admitted, base64url, creationOptions, createPublicCredential, publicPacket, fingerprint} from '../web/enroll/model.mjs'; +import {config as shipped} from '../web/enroll/config.mjs'; +import {coseKey} from '../web/enroll/cose.mjs'; +const config = {enabled: true, origin: 'https://enroll.example.test', rpId: 'enroll.example.test'}; +const key = Uint8Array.from([0xa4,1,1,3,0x27,0x20,6,0x21,0x58,0x20,...Array.from({length: 32}, (_, i) => i + 1)]); +const buffer = bytes => Uint8Array.from(bytes).buffer; +async function fixture(challenge = new Uint8Array(32)) { + const id = Uint8Array.of(1,2,3,4); + const auth = new Uint8Array(55 + id.length + key.length); + auth.set(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(config.rpId)))); + auth[32] = 0x45; auth[54] = id.length; auth.set(id,55); auth.set(key,55+id.length); + const client = {type:'webauthn.create', challenge:base64url(challenge), origin:config.origin, crossOrigin:false}; + const credential = {type:'public-key', id:base64url(id), rawId:id.buffer, response:{getAuthenticatorData:()=>auth.buffer, clientDataJSON:buffer(new TextEncoder().encode(JSON.stringify(client)))}}; + return {credential, auth, client, challenge}; +} +test('shipped ceremony disabled; origin gates refuse before creating a credential', async () => { + assert.equal(shipped.enabled, false); + let calls = 0; + for (const [cfg, origin, secure] of [[shipped,config.origin,true],[{...config,enabled:false},config.origin,true],[config,'https://other.example.test',true],[config,config.origin,false],[{...config,origin:'http://enroll.example.test'},'http://enroll.example.test',true],[{...config,rpId:'other.example.test'},config.origin,true]]) { + await assert.rejects(createPublicCredential(cfg,{origin,secure,credentials:{create(){calls++;}},crypto})); + } + assert.equal(calls,0); + assert.doesNotThrow(()=>admitted(config,config.origin,true)); +}); +test('actual creation options require verification and fresh unpredictable challenges', () => { + const a=creationOptions(config), b=creationOptions(config); + assert.equal(a.publicKey.authenticatorSelection.userVerification,'required'); + assert.equal(a.publicKey.challenge.length,32);assert.equal(a.publicKey.user.id.length,32); + assert.notDeepEqual(a.publicKey.challenge,b.publicKey.challenge); + assert.deepEqual(a.publicKey.pubKeyCredParams.map(p=>p.alg),[-7,-8]); +}); +test('public COSE bytes and independent native fingerprint golden are exact', async () => { + const f=await fixture();const packet=await publicPacket(f.credential,config,f.challenge); + assert.equal(packet.cose_key,base64url(key));assert.equal(packet.fingerprint,'9bb1-7e67-b268-ca8b'); + assert.equal(await fingerprint(key),'9bb1-7e67-b268-ca8b'); + assert.equal(packet.scheme,'webauthn-eddsa');assert.equal(packet.backup_eligible,false); + assert.deepEqual(Object.keys(packet).sort(),['backup_eligible','cose_key','credential_id','fingerprint','origin','rp_id','scheme']); +}); +test('user presence, verification, attested data, backup flags and unexpected extensions are required/refused', async () => { + for(const flag of [0x44,0x41,0x05,0x4d,0x55,0xc5]) { + const f=await fixture();f.auth[32]=flag; + await assert.rejects(publicPacket(f.credential,config,f.challenge),/single-device/); + } +}); +test('RP hash, credential ID, truncated key and trailing bytes fail closed', async () => { + for(const change of [f=>f.auth[0]^=1,f=>f.auth[55]^=1,f=>f.auth[54]=5,f=>f.credential.id='foreign',f=>f.credential.response.getAuthenticatorData=()=>f.auth.slice(0,-1).buffer,f=>f.credential.response.getAuthenticatorData=()=>buffer([...f.auth,0])]) { + const f=await fixture();change(f);await assert.rejects(publicPacket(f.credential,config,f.challenge)); + } +}); +test('creation origin, challenge, type, cross-origin and duplicate JSON fields refuse export', async () => { + for(const change of [c=>c.origin='https://other.example.test',c=>c.challenge='different',c=>c.type='webauthn.get',c=>c.crossOrigin=true]) { + const f=await fixture();change(f.client);f.credential.response.clientDataJSON=buffer(new TextEncoder().encode(JSON.stringify(f.client))); + await assert.rejects(publicPacket(f.credential,config,f.challenge),/context mismatch/); + } + const f=await fixture();f.credential.response.clientDataJSON=buffer(new TextEncoder().encode('{"origin":"wrong",'+JSON.stringify(f.client).slice(1))); + await assert.rejects(publicPacket(f.credential,config,f.challenge),/context mismatch/); +}); +test('bounded COSE parser rejects duplicate fields, wrong algorithms, indefinite lengths and oversize coordinates', () => { + for(const bytes of [Uint8Array.from([0xbf]),Uint8Array.from([0xa6]),Uint8Array.from([...key.slice(0,3),1,...key.slice(4)]),Uint8Array.from([...key.slice(0,4),0x26,...key.slice(5)]),key.slice(0,-1)]) assert.throws(()=>coseKey(bytes,0)); + const p256=Uint8Array.from([0xa5,1,2,3,0x26,0x20,1,0x21,0x58,0x20,...new Uint8Array(32),0x22,0x58,0x20,...new Uint8Array(32)]); + assert.equal(coseKey(p256,0).scheme,'webauthn-es256'); +}); +test('create uses the returned authenticator data and cancellation never produces a packet', async () => { + let calls=0; + const environment={origin:config.origin,secure:true,crypto,credentials:{async create(options){calls++;return (await fixture(options.publicKey.challenge)).credential;}}}; + assert.equal((await createPublicCredential(config,environment)).fingerprint,'9bb1-7e67-b268-ca8b');assert.equal(calls,1); + await assert.rejects(createPublicCredential(config,{...environment,credentials:{}}),/unavailable/); + environment.credentials.create=async()=>null; + await assert.rejects(createPublicCredential(config,environment),/cancelled/); +}); diff --git a/web/enroll/app.mjs b/web/enroll/app.mjs new file mode 100644 index 0000000..cdcc1b9 --- /dev/null +++ b/web/enroll/app.mjs @@ -0,0 +1,28 @@ +import {config} from './config.mjs'; +import {admitted, createPublicCredential} from './model.mjs'; +const button = document.querySelector('button'); +const status = document.querySelector('[role="status"]'); +const output = document.querySelector('textarea'); +const fingerprint = document.querySelector('#fingerprint'); +const environment = {origin: location.origin, secure: isSecureContext, credentials: navigator.credentials, crypto}; +let enabled = false; +try { + admitted(config, environment.origin, environment.secure); + if (typeof environment.credentials?.create === 'function' && typeof PublicKeyCredential !== 'undefined') enabled = true; + else status.textContent = 'WebAuthn is unavailable in this browser.'; +} +catch { status.textContent = 'Enrollment is not enabled for this origin.'; } +button.disabled = !enabled; +button.addEventListener('click', async () => { + if (!enabled) return; + button.disabled = true; output.value = ''; fingerprint.textContent = ''; + status.textContent = 'Waiting for your authenticator…'; + try { + const packet = await createPublicCredential(config, environment); + output.value = JSON.stringify(packet, null, 2); + fingerprint.textContent = packet.fingerprint; + status.textContent = 'Public credential ready for operator registration. Not enrolled. Compare this fingerprint after registration.'; + } catch { + status.textContent = 'No public export prepared. This page has not registered a signer. Your device may retain a credential from the creation attempt.'; + } finally { button.disabled = !enabled; } +}); diff --git a/web/enroll/config.mjs b/web/enroll/config.mjs new file mode 100644 index 0000000..9893216 --- /dev/null +++ b/web/enroll/config.mjs @@ -0,0 +1,2 @@ +// Source delivery does not admit an origin or begin a human ceremony. +export const config = Object.freeze({enabled: false, origin: null, rpId: null}); diff --git a/web/enroll/cose.mjs b/web/enroll/cose.mjs new file mode 100644 index 0000000..aee30b7 --- /dev/null +++ b/web/enroll/cose.mjs @@ -0,0 +1,39 @@ +// A bounded flat COSE public-key reader, not a general CBOR interpreter. +export function coseKey(bytes, offset) { + let at = offset; + const head = () => { + if (at >= bytes.length) throw new Error('Truncated public key'); + const tag = bytes[at++], major = tag >> 5, info = tag & 31; + let length = info; + if (info >= 24) { + const count = {24: 1, 25: 2, 26: 4}[info]; + if (!count || at + count > bytes.length) throw new Error('Unsupported public key encoding'); + length = 0; + for (let i = 0; i < count; i++) length = length * 256 + bytes[at++]; + } + return {major, length}; + }; + const integer = item => { + if (item.major !== 0 && item.major !== 1) throw new Error('Invalid public key integer'); + return item.major === 0 ? item.length : -1 - item.length; + }; + const map = head(); + if (map.major !== 5 || map.length < 4 || map.length > 5) throw new Error('Unsupported public key map'); + const fields = new Map(); + for (let i = 0; i < map.length; i++) { + const key = integer(head()), item = head(); + if (fields.has(key)) throw new Error('Duplicate public key field'); + let value; + if (item.major === 2) { + if (item.length !== 32 || at + item.length > bytes.length) throw new Error('Invalid key coordinate'); + value = bytes.slice(at, at + item.length); at += item.length; + } else value = integer(item); + fields.set(key, value); + } + const coordinate = key => fields.get(key) instanceof Uint8Array && fields.get(key).length === 32; + let scheme; + if (fields.size === 4 && fields.get(1) === 1 && fields.get(3) === -8 && fields.get(-1) === 6 && coordinate(-2)) scheme = 'webauthn-eddsa'; + else if (fields.size === 5 && fields.get(1) === 2 && fields.get(3) === -7 && fields.get(-1) === 1 && coordinate(-2) && coordinate(-3)) scheme = 'webauthn-es256'; + else throw new Error('Unsupported public key scheme'); + return {bytes: bytes.slice(offset, at), end: at, scheme}; +} diff --git a/web/enroll/index.html b/web/enroll/index.html new file mode 100644 index 0000000..b8cf7b8 --- /dev/null +++ b/web/enroll/index.html @@ -0,0 +1,8 @@ + + + +Signing credential +

Create a signing credential

This page prepares a public credential for an operator to register. Your authenticator keeps the signing key.

+

Enrollment is not enabled.

+

Fingerprint

+

Creating a credential does not register it or enable signed actions.

diff --git a/web/enroll/model.mjs b/web/enroll/model.mjs new file mode 100644 index 0000000..13928a4 --- /dev/null +++ b/web/enroll/model.mjs @@ -0,0 +1,52 @@ +import {coseKey} from './cose.mjs'; + +const equal = (a, b) => a.length === b.length && a.every((v, i) => v === b[i]); +export const base64url = bytes => btoa(String.fromCharCode(...bytes)).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, ''); +export async function fingerprint(bytes, crypto = globalThis.crypto) { + const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)); + return Array.from(hash.slice(0, 8), b => b.toString(16).padStart(2, '0')).join('').match(/.{4}/g).join('-'); +} +export function admitted(config, origin, secure) { + if (config?.enabled !== true || !secure || typeof config.rpId !== 'string' || !config.rpId.includes('.')) throw new Error('Enrollment is not configured for this origin'); + if (!/^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(config.rpId) || config.origin !== `https://${config.rpId}` || origin !== config.origin) throw new Error('Enrollment is not configured for this origin'); +} +export function creationOptions(config, crypto = globalThis.crypto) { + return {publicKey: { + challenge: crypto.getRandomValues(new Uint8Array(32)), + rp: {id: config.rpId, name: 'Signing credential'}, + user: {id: crypto.getRandomValues(new Uint8Array(32)), name: 'signing-operator', displayName: 'Signing operator'}, + pubKeyCredParams: [{type: 'public-key', alg: -7}, {type: 'public-key', alg: -8}], + authenticatorSelection: {userVerification: 'required', residentKey: 'preferred'}, + attestation: 'none', timeout: 60000 + }}; +} +export async function publicPacket(credential, config, challenge, crypto = globalThis.crypto) { + if (credential?.type !== 'public-key' || !(credential.rawId instanceof ArrayBuffer) || typeof credential.response?.getAuthenticatorData !== 'function') throw new Error('Authenticator export is unsupported'); + const id = new Uint8Array(credential.rawId); + if (id.length < 1 || id.length > 1024 || credential.id !== base64url(id)) throw new Error('Invalid credential identifier'); + const raw = credential.response.getAuthenticatorData(); + if (!(raw instanceof ArrayBuffer)) throw new Error('Invalid authenticator data'); + const auth = new Uint8Array(raw); + if (auth.length < 55 || auth.length > 4096) throw new Error('Invalid authenticator data size'); + const flags = auth[32]; + if ((flags & 0x45) !== 0x45 || (flags & 0x18) !== 0 || (flags & 0x80) !== 0) throw new Error('A verified single-device credential without extensions is required'); + const rpHash = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(config.rpId))); + if (!equal(auth.slice(0, 32), rpHash)) throw new Error('Relying party mismatch'); + const length = auth[53] * 256 + auth[54], end = 55 + length; + if (length !== id.length || end >= auth.length || !equal(auth.slice(55, end), id)) throw new Error('Authenticator credential mismatch'); + const key = coseKey(auth, end); + if (key.end !== auth.length) throw new Error('Trailing authenticator data'); + if (!(credential.response.clientDataJSON instanceof ArrayBuffer) || credential.response.clientDataJSON.byteLength > 4096) throw new Error('Invalid client data'); + const text = new TextDecoder('utf-8', {fatal: true}).decode(credential.response.clientDataJSON); + const client = JSON.parse(text); + if (JSON.stringify(client) !== text || client.type !== 'webauthn.create' || client.origin !== config.origin || client.challenge !== base64url(challenge) || client.crossOrigin !== false) throw new Error('Creation context mismatch'); + return Object.freeze({credential_id: base64url(id), cose_key: base64url(key.bytes), scheme: key.scheme, origin: config.origin, rp_id: config.rpId, fingerprint: await fingerprint(key.bytes, crypto), backup_eligible: false}); +} +export async function createPublicCredential(config, environment) { + admitted(config, environment.origin, environment.secure); + if (typeof environment.credentials?.create !== 'function') throw new Error('WebAuthn is unavailable'); + const options = creationOptions(config, environment.crypto); + const credential = await environment.credentials.create(options); + if (!credential) throw new Error('Credential creation was cancelled'); + return publicPacket(credential, config, options.publicKey.challenge, environment.crypto); +} diff --git a/web/enroll/style.css b/web/enroll/style.css new file mode 100644 index 0000000..4e73a05 --- /dev/null +++ b/web/enroll/style.css @@ -0,0 +1,9 @@ +:root { color-scheme: light dark; font-family: system-ui, sans-serif; } +body { margin: 0; padding: 2rem 1rem; } +main { max-width: 42rem; margin: auto; } +p { line-height: 1.6; } +button { font: inherit; padding: .7rem 1rem; cursor: pointer; } +button:disabled { cursor: default; } +output { display: block; font: 1.4rem ui-monospace, monospace; min-height: 2rem; } +label { display: block; margin: 1rem 0 .5rem; } +textarea { box-sizing: border-box; width: 100%; font: .9rem ui-monospace, monospace; }