Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions docs/enrollment/ceremony.md
Original file line number Diff line number Diff line change
@@ -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.
Binary file added docs/enrollment/ceremony.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/enrollment/ceremony.webm
Binary file not shown.
89 changes: 89 additions & 0 deletions docs/enrollment/demo.md
Original file line number Diff line number Diff line change
@@ -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;});
```
2 changes: 1 addition & 1 deletion scripts/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
7 changes: 7 additions & 0 deletions tests/build.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
72 changes: 72 additions & 0 deletions tests/enrollment.test.mjs
Original file line number Diff line number Diff line change
@@ -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/);
});
Loading
Loading