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; }