diff --git a/docs/deck/browser-results.json b/docs/deck/browser-results.json new file mode 100644 index 0000000..faff1c4 --- /dev/null +++ b/docs/deck/browser-results.json @@ -0,0 +1,61 @@ +{ + "scope": "Synthetic trusted-reader and signer adapters; actual DOM and IndexedDB; no service/authenticator/native receipt claim", + "results": [ + { + "scenario": "disabled", + "pass": true, + "nonlocal_requests": 0, + "page_errors": [] + }, + { + "scenario": "choose", + "pass": true, + "nonlocal_requests": 0, + "page_errors": [] + }, + { + "scenario": "later", + "pass": true, + "nonlocal_requests": 0, + "page_errors": [] + }, + { + "scenario": "comment", + "pass": true, + "nonlocal_requests": 0, + "page_errors": [] + }, + { + "scenario": "unknown-recover", + "pass": true, + "nonlocal_requests": 0, + "page_errors": [] + }, + { + "scenario": "ordinary-graph", + "pass": true, + "nonlocal_requests": 0, + "page_errors": [] + }, + { + "scenario": "stale-item", + "pass": true, + "nonlocal_requests": 0, + "page_errors": [] + }, + { + "scenario": "xss-title", + "pass": true, + "nonlocal_requests": 0, + "page_errors": [] + }, + { + "scenario": "storage-reload-cas", + "one_insert": true, + "reload_exact": true, + "unknown_preserved": true, + "scope_isolated": true, + "pass": true + } + ] +} \ No newline at end of file diff --git a/docs/deck/contract.md b/docs/deck/contract.md new file mode 100644 index 0000000..bf7520d --- /dev/null +++ b/docs/deck/contract.md @@ -0,0 +1,127 @@ +# Online request and attested answer source + +The `/deck/` preview implements Rikuy's request card and answer interpretation +on the generic Quipu attested-write primitive. It ships disabled, selects no +production endpoint or credential, and makes no request on page load. Its CSP +also disallows network connections. Deployment adapters and policy changes are +separate reviewed work. The offline decision protocol is unchanged and has a +different domain, database and retry contract. + +## Request and proposal + +A curator publishes a canonical request manifest in an ordinary request graph, +with a current revision and an immutable revision subject. `requestPublication` +constructs those facts; it does not publish them. A real curator job must use +normal governed conditional writes: advance the current revision atomically, +expect the old revision, and expect the new manifest slot to be absent. This +source does not deploy that job or grant it a credential. + +The admitted reader supplies the exact graph, request ID, current revision, +revision subject and complete manifest. Unknown fields, duplicate JSON keys, +foreign items, ambiguous revisions and incomplete results refuse. The card +renders text through DOM text nodes, including option labels and comments. + +Each intent has a stable 256-bit operation ID. The proposal carries five +expectations in three named graphs: + +| Graph | Required slot | +|---|---| +| Request | Request's current revision equals the displayed revision | +| Answer | That request revision's decided slot is empty | +| Item | Exact canonical item's revision equals the displayed item revision | +| Item | Exact canonical item's status equals the configured open status | +| Answer | This operation's type slot is absent | + +Typed integers, strings and IRIs stay distinct. JavaScript refuses integers +outside its exact safe range, oversized UTF-8 values, unpaired surrogates, +unknown fields and duplicate slots. The fixed native field order is retained +when serializing proposals and server-issued challenge bytes. + +Choose and Later fill the decided slot; comment does not. Comment text is +retained exactly, including newlines. A comment requires nonblank text. +The underlying item is neither closed nor revised by these answer facts. +The reader projects the answer and visible comment from the answer graph; +existing Brief/Seeds consumer integration is still owed. + +The client requires the server's signed deadline to be no later than the +curated request deadline before asking the authenticator. A request with less +than the server's challenge lifetime remaining therefore refuses; it does not +silently extend the request. The reader checks the retained deadline and +server recording time too. It does not use a browser timestamp as evidence. + +## Evidence and pending custody + +The reader reconstructs the entire permitted proposal, including every +expectation and datum, from the current curated request. It compares the exact +canonical manifest with a native point receipt from the pinned reader route. +A verified receipt must bind the configured store lineage, signer, actor, +origin/RP, nonce, message hash and positive transaction. Ordinary-graph copies, +other request revisions, missing expectations, unknown fields, unverified or +foreign receipts and extra facts never become answered. + +`readAnswer` admits an immutable answer object. `disposition` only folds those +admitted objects for the exact request: WAITING, ANSWERED or visible LATER. +Comment alone leaves WAITING. Competing decisions or incomplete evidence are +UNKNOWN, not an empty success result. A trusted adapter obtains receipt replies +from native `/attest/receipt`; a caller's own `verified: true` is not authority. + +`PendingStore` uses IndexedDB transactions, scoped by the exact operator, +store lineage and graph configuration. One insertion wins across tabs. It +stores the proposal, nonce, content hash and request deadline before signing. +No memory fallback is selected when durable storage fails. The WebAuthn caller +requests the enrolled credential, RP and server-derived challenge with user +verification required; it has no software-key fallback. + +Before the one commit call, custody changes to UNKNOWN. A timeout, refusal or +absent point receipt never authorizes a second commit or signature. Recovery +only reads the same nonce. ACCEPTED requires both the verified receipt and +complete matching facts/provenance read-back. The UI also re-reads its current +projection before acknowledging and clearing custody. A never-submitted +PENDING attempt can be cancelled with a state-and-nonce compare-and-delete; +an UNKNOWN attempt cannot be discarded that way. + +## Shapes and limits + +`shapes/deck.shapes.ttl` defines the Decision and Comment source vocabulary. +Its required-field and value constraints are not a claim that every native +transaction enforces a complete batch SHACL result. In the qualified candidate, +post-state admission deliberately excludes `MinCountConstraintComponent` from +its selected components. A missing-comment signed write can therefore remain +as a native attestation; Rikuy's full manifest reader rejects it. A native +invalid-action value does refuse with the loaded shape, providing an armed +positive control. Loading a shape does not authorize a writer or protect a graph. + +An invalid signed write can still occupy a decided slot. The curator advances +the request revision through its normal conditional lane; it never edits or +erases the invalid attestation. No source here repairs history automatically. + +## Qualification and remaining acceptance + +`just ci` exercises the source units and disabled build. The explicit native +fixture uses generated software keys only in memory, disposable local stores, +the actual candidate operator CLI and linked native library. It checks Rust +serialization and queryable provenance, successful choose/Later/comment, +replay, forgery, wrong origin, changed/closed item, request revision, loaded +shape values, and native-valid manifests that the Deck reader must reject. +It is not a production profile-union, HTTP/CORS, hardware or human test. + +Generate a fresh Cargo JSONL artifact inventory from the candidate Quipu +checkout using `cargo test --no-run --message-format=json`. Set `QUIPU_CLI` to +its clean candidate executable and `QUIPU_ARTIFACTS` to that inventory. Build +outside the test HOME, then run under the normal HOME guard: + +```sh +node scripts/qualify-deck-native.mjs --build-helper /tmp/rikuy-native-fixture +QUIPU_NATIVE_FIXTURE=/tmp/rikuy-native-fixture node scripts/qualify-deck-native.mjs +``` + +The helper receipt pins its source, library and executable hashes; changed or +ambiguous artifacts refuse. All stores are created below a new owned temporary +directory and removed afterward. No default/installed store is opened. + +Remaining acceptance: normal source CI/review/landing; admitted curator and +bounded stored-query adapters; production shape classification; generic signer +CLI adoption; dedicated trusted origin and live CORS/wrong-origin checks; +explicit human ceremony; served Brief safety and interleaved performance; then +separately reviewed old-domain removal. This preview is not activation, +production publication, a new enrollment route or completed migration. diff --git a/docs/deck/demo.md b/docs/deck/demo.md new file mode 100644 index 0000000..1cfd6ea --- /dev/null +++ b/docs/deck/demo.md @@ -0,0 +1,64 @@ +# Source demonstration and repeatable browser fixture + +Before, this repository had no online request card or interpretation of generic +attested-write receipts. The source preview now renders a synthetic current +request and exercises choose, Later, comment and pending receipt recovery. +It still ships disabled and selects no production adapter. + +[Download the real recovery WebM](recovery.webm) or inspect the +[final recovered screenshot](recovered.png). In the recording, the synthetic +commit response is unknown, the original intent stays pending, and a point +receipt later reconciles it. Exactly one commit and one signing callback occur. +The browser fixture uses synthetic trusted-reader and signer adapters; it is +not a real device, native receipt, human decision or service demonstration. +The separate [native fixture results](native-results.json) record 16 controls +against the clean candidate CLI/native library, with a different trust scope. + +The recording is from the working source over base de8c7fd. The exact browser +module/fixture hashes are retained in [source-assets.json](source-assets.json), +with nine controls in [browser-results.json](browser-results.json). Those +hashes were equal before and after the browser run. The default page is inert; +all synthetic HTTPS assets are intercepted and nonlocal requests are blocked. +A WebM-capable player is required; inline playback is not assumed. + +The browser controls include actual IndexedDB reload, competing insertions and +state/nonce deletion checks; they do not substitute a memory store. Comment +leaves the request waiting, Later stays visible, ordinary-graph evidence is +UNKNOWN, stale items never sign, and a markup-like title stays plain text. + +Use Node22, Playwright1.58.2, Chrome, an isolated display and its installed +video-recorder dependency. Copy this block to /tmp/playwright-test-deck.cjs +and run from the repository root under a private HOME and the normal HOME +guard. PLAYWRIGHT_MODULE and DECK_EVIDENCE select installed dependencies and +an owned output directory. No production endpoint is contacted. + +```javascript +const {chromium}=require(process.env.PLAYWRIGHT_MODULE || 'playwright'); +const fs=require('fs'),path=require('path'),assert=require('assert/strict'); +const ROOT=process.env.DECK_SOURCE||process.cwd(),OUT=process.env.DECK_EVIDENCE||fs.mkdtempSync(path.join(require('os').tmpdir(),'rikuy-deck-')),ORIGIN='https://sign.example.test'; +const crypto=require('crypto');const sourceFiles=fs.readdirSync(path.join(ROOT,'web/deck')).sort().map(n=>'web/deck/'+n).concat(['tests/deck-fixture.mjs']);const sourceHash=()=>Object.fromEntries(sourceFiles.map(n=>[n,crypto.createHash('sha256').update(fs.readFileSync(path.join(ROOT,n))).digest('hex')]));const beforeSource=sourceHash(); +(async()=>{const browser=await chromium.launch({headless:false,executablePath:'/usr/bin/google-chrome',args:['--no-sandbox','--disable-dev-shm-usage']});const results=[]; +try{for(const scenario of (process.env.DECK_SCENARIOS?.split(',')||['disabled','choose','later','comment','unknown-recover','ordinary-graph','stale-item','xss-title','storage-reload-cas'])){ + const context=await browser.newContext({viewport:{width:980,height:900},...(scenario==='unknown-recover'?{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 u=new URL(route.request().url());const p=u.pathname.replace(/^\//,'');if(u.origin!==ORIGIN||!((/^web\/deck\/[a-z-]+\.(mjs|css|html)$/).test(p)||p==='tests/deck-fixture.mjs')){blocked.push(u.href);await route.abort();return;}await route.fulfill({body:fs.readFileSync(path.join(ROOT,p)),contentType:p.endsWith('.mjs')?'text/javascript':p.endsWith('.css')?'text/css':'text/html'});}); + await page.goto(ORIGIN+'/web/deck/index.html');await page.waitForFunction(()=>document.querySelector('[role=status]').textContent.includes('Preview')); + if(scenario==='disabled'){assert.equal(await page.locator('button').count(),0);} + else if(scenario==='storage-reload-cas'){ + const proof=await page.evaluate(async()=>{const f=await import('/tests/deck-fixture.mjs');const {PendingStore}=await import('/web/deck/storage.mjs');const {admittedChallenge}=await import('/web/deck/evidence.mjs');let a=await PendingStore.open(f.who,f.boundary),b=await PendingStore.open(f.who,f.boundary);const p=f.make(),issued=await f.issued(p),c=await admittedChallenge(issued,p,f.who,1900000000);const record={proposal:p,nonce:c.challenge.nonce,contentHash:c.contentHash,notAfter:f.ask.expiresAt,state:'PENDING'};const initial=await Promise.allSettled([a.insert(record),b.insert(record)]);if(initial.filter(x=>x.status==='fulfilled').length!==1)throw Error('two-tab insertion was not atomic');a.close();b.close();a=await PendingStore.open(f.who,f.boundary);const retained=await a.get();if(JSON.stringify(retained.proposal)!==JSON.stringify(p))throw Error('reload changed proposal');await a.change(record.nonce,{state:'UNKNOWN'});const refused=[];for(const operation of [()=>a.remove(record.nonce,'PENDING'),()=>a.change(record.nonce,{state:'UNKNOWN',proposal:f.make()}),()=>a.remove('c'.repeat(64),'ACCEPTED')]){try{await operation();refused.push(false);}catch{refused.push(true);}}if(refused.some(x=>!x))throw Error('unknown custody was altered');const other=await PendingStore.open({...f.who,audience:'other-store'},f.boundary);if(await other.get())throw Error('foreign store shares custody');other.close();a.close();return{one_insert:true,reload_exact:true,unknown_preserved:true,scope_isolated:true};});results.push({scenario,...proof,pass:true}); + }else{ + await page.evaluate(async scenario=>{const f=await import('/tests/deck-fixture.mjs');const {mount}=await import('/web/deck/view.mjs');const {PendingWrite}=await import('/web/deck/pending.mjs');const {PendingStore}=await import('/web/deck/storage.mjs');const {requestPublication,revisionIri}=await import('/web/deck/model.mjs');const r=structuredClone(f.ask);if(scenario==='xss-title')r.title='';const publication=requestPublication(r,f.boundary);const requestRow={graph:publication.graph,requestId:r.id,currentRevision:r.revision,revisionIri:revisionIri(r),manifest:publication.datums[1].o.value,complete:true};let proposed,e=null,commits=0,signs=0;const store=await PendingStore.open(f.who,f.boundary);window.fixtureStore=store;const transport={challenge:async p=>{proposed=p;return f.issued(p);},commit:async()=>{commits++;if(scenario!=='unknown-recover')e=await f.evidence(proposed);if(scenario==='unknown-recover')throw Error('indeterminate');},receipt:async()=>e?.response||{found:false,verified:false,receipt:null,tx:null},readback:async()=>e.readback};if(scenario==='ordinary-graph'){e=await f.evidence();e.row.graph=f.boundary.requestGraph;}const writer=new PendingWrite({identity:f.who,store,transport,sign:async()=>{signs++;return{client_data_json:'a',authenticator_data:'b',signature:'c'};},now:()=>1900000000});window.completeFixture=async()=>{e=await f.evidence(proposed);};window.fixtureCounts=()=>({commits,signs});await mount(document.querySelector('main'),{enabled:true,boundary:f.boundary,identity:f.who,read:async()=>({complete:true,requestRow,freshItem:{...f.freshItem,...(scenario==='stale-item'?{revision:8}:{})},rows:e?[{row:e.row,receipt:e.response}]:[]}),writer,now:()=>1900000000});},scenario); + if(scenario==='ordinary-graph'){await page.waitForFunction(()=>document.querySelector('[role=status]').textContent.includes('UNKNOWN'));assert.equal(await page.locator('button').count(),0);} + else if(scenario==='xss-title'){assert.equal(await page.locator('img').count(),0);assert.equal(await page.locator('h1').innerText(),'');assert.equal(await page.evaluate(()=>window.injection||false),false);} + else{ + await page.getByRole('textbox',{name:'Comment'}).fill('Synthetic browser comment');const label=scenario==='later'?'Later':scenario==='comment'?'Add comment':'Yes';if(scenario==='unknown-recover')await page.waitForTimeout(1000);await page.getByRole('button',{name:label,exact:true}).click(); + if(scenario==='stale-item'){await page.waitForFunction(()=>document.querySelector('[role=status]').textContent.includes('UNKNOWN'));assert.deepEqual(await page.evaluate(()=>window.fixtureCounts()),{commits:0,signs:0});} + else if(scenario==='unknown-recover'){await page.waitForFunction(()=>document.querySelector('[role=status]').textContent.includes('Pending')&&document.querySelector('main').getAttribute('aria-busy')==='false');assert.equal(await page.getByRole('button',{name:'Yes',exact:true}).isDisabled(),true);assert.deepEqual(await page.evaluate(()=>window.fixtureCounts()),{commits:1,signs:1});await page.waitForTimeout(1500);await page.evaluate(()=>window.completeFixture());await page.getByRole('button',{name:'Recover receipt'}).click();await page.waitForFunction(()=>document.querySelector('[role=status]').textContent.startsWith('Answered')&&document.querySelector('main').getAttribute('aria-busy')==='false');assert.deepEqual(await page.evaluate(()=>window.fixtureCounts()),{commits:1,signs:1});await page.screenshot({path:path.join(OUT,'recovered.png'),fullPage:true});await page.waitForTimeout(2000);} + else{await page.waitForFunction(kind=>kind==='comment'?document.querySelector('section').textContent.includes('fixture-operator')&&document.querySelector('[role=status]').textContent.startsWith('Current'):kind==='later'?document.querySelector('[role=status]').textContent.startsWith('Later'):document.querySelector('[role=status]').textContent.startsWith('Answered'),scenario);await page.waitForFunction(()=>document.querySelector('main').getAttribute('aria-busy')==='false');if(scenario==='comment')assert.equal(await page.getByRole('button',{name:'Yes',exact:true}).isDisabled(),false);else assert.equal(await page.getByRole('button',{name:'Yes',exact:true}).isDisabled(),true);assert.deepEqual(await page.evaluate(()=>window.fixtureCounts()),{commits:1,signs:1});} + } + await page.evaluate(()=>window.fixtureStore.close()); + } + assert.deepEqual(blocked,[]);assert.deepEqual(errors,[]);if(scenario!=='storage-reload-cas')results.push({scenario,pass:true,nonlocal_requests:blocked.length,page_errors:errors}); + }finally{await context.close();} +}assert.deepEqual(sourceHash(),beforeSource,'Source changed during browser fixture');fs.writeFileSync(path.join(OUT,'browser-source-hashes.json'),JSON.stringify(beforeSource,null,2));fs.writeFileSync(path.join(OUT,'browser-results.json'),JSON.stringify({scope:'Synthetic trusted-reader and signer adapters; actual DOM and IndexedDB; no service/authenticator/native receipt claim',results},null,2));console.log(JSON.stringify(results));}finally{await browser.close();}})().catch(e=>{console.error(e.stack);process.exitCode=1;}); + +``` diff --git a/docs/deck/native-results.json b/docs/deck/native-results.json new file mode 100644 index 0000000..551a3d1 --- /dev/null +++ b/docs/deck/native-results.json @@ -0,0 +1,93 @@ +{ + "candidate": "quipu 0.16.3\ngit_sha: 771e710de7294b718ceaa1a4b825fd28e4b34c23\ngit_dirty: false", + "native_library_sha256": "2ac7b3096f54903dd18664016fc7d102632cb8b079a4fbb239b8818b9d3e1ef8", + "helper_source_sha256": "4e78bbc7eeae503b1477881dc9c8e8a93f804fa15f7f12e5e034df11e9dd1bbd", + "fixture": "Disposable stores; generated software keys kept only in memory; local native methods and actual operator CLI; no HTTP/service/real credential or production profile union", + "results": [ + { + "case": "choose", + "state": "ANSWERED", + "canonical_native_match": true, + "native_verified": true, + "replay_no_mutation": true + }, + { + "case": "later", + "state": "LATER", + "canonical_native_match": true, + "native_verified": true, + "replay_no_mutation": true + }, + { + "case": "comment", + "state": "WAITING", + "canonical_native_match": true, + "native_verified": true, + "replay_no_mutation": true + }, + { + "case": "forged", + "refused_no_mutation": true, + "nonce_and_counter_unspent": true + }, + { + "case": "wrong-origin", + "refused_no_mutation": true, + "nonce_and_counter_unspent": true + }, + { + "case": "item-revision", + "refused_no_mutation": true, + "nonce_and_counter_unspent": true + }, + { + "case": "closed-item", + "refused_no_mutation": true, + "nonce_and_counter_unspent": true + }, + { + "case": "request-revision", + "refused_no_mutation": true, + "nonce_and_counter_unspent": true + }, + { + "case": "missing-expectation-0", + "native_verified": true, + "deck_reader_refused": true + }, + { + "case": "missing-expectation-1", + "native_verified": true, + "deck_reader_refused": true + }, + { + "case": "missing-expectation-2", + "native_verified": true, + "deck_reader_refused": true + }, + { + "case": "missing-expectation-3", + "native_verified": true, + "deck_reader_refused": true + }, + { + "case": "missing-expectation-4", + "native_verified": true, + "deck_reader_refused": true + }, + { + "case": "missing-comment-floor", + "native_verified": true, + "deck_reader_refused": true, + "limitation": "Native post-state admission excludes MinCount floors; full shape validation and client/reader constraints remain distinct" + }, + { + "case": "loaded-action-value-constraint", + "refused_no_mutation": true + }, + { + "case": "ordinary-graph-and-other-request-revision", + "deck_reader_refused": true + } + ] +} diff --git a/docs/deck/recovered.png b/docs/deck/recovered.png new file mode 100644 index 0000000..9b33b68 Binary files /dev/null and b/docs/deck/recovered.png differ diff --git a/docs/deck/recovery.webm b/docs/deck/recovery.webm new file mode 100644 index 0000000..36c7bb8 Binary files /dev/null and b/docs/deck/recovery.webm differ diff --git a/docs/deck/source-assets.json b/docs/deck/source-assets.json new file mode 100644 index 0000000..b7c5635 --- /dev/null +++ b/docs/deck/source-assets.json @@ -0,0 +1,14 @@ +{ + "web/deck/app.mjs": "b6067fadf3a8ecc38d078fb8a5c9a2c12cece95d40ac0ded772d3437e0934571", + "web/deck/config.mjs": "a1260c00e253f2c36dd6e05f73b322ce3d0759180289c44788e55bd54420ef89", + "web/deck/evidence.mjs": "9802076b6ca3d18c8c56a47de206011ea767e51cbc652e8c9ccb503f0f39bfa7", + "web/deck/index.html": "893f9cb7a446e97d08ba1895c3930f6037db6cbac80d46733f5b4ffe167040e0", + "web/deck/model.mjs": "5cc4b0400d3795207feb9e664908f76363bf335b1329c774f6c1bfad480ea278", + "web/deck/pending.mjs": "d01971f6f2b8bbee768624e609037bf203c38cf55098a9d2b27b477331c9bf30", + "web/deck/sign.mjs": "4b3cbf761a2fc91f24ff9c69e382b5165f7aed15f6083fcaa9b34dbebdc1a201", + "web/deck/storage.mjs": "cb9bd06ae0a2d9d0b311bb41006e9d4420df00029cf804a8d21707aa36f345c4", + "web/deck/style.css": "6a8fe7c6fc5a99618bfd9e5d824ba25c92552bf6be689d98877f265ef972dbcf", + "web/deck/value.mjs": "f8ecf293993866e9c48b6d4cc79a1598acde120fb6fb237b133302043fda51c7", + "web/deck/view.mjs": "830997563eccfd5a3513df7ebb88c5ca48ed1f9ee82e8bc2a95a2f2972d33b65", + "tests/deck-fixture.mjs": "94e13282f9b8cbf6c8702f5da7cea428460ceabbc86e8a69f40da782b241048f" +} \ No newline at end of file diff --git a/scripts/build.mjs b/scripts/build.mjs index fea6a55..4157773 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','enroll'])await cp(resolve(root,'web',name),resolve(out,name),{recursive:true,filter}); +for(const name of ['knowledge','offline','enroll','deck'])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/scripts/qualify-deck-native.mjs b/scripts/qualify-deck-native.mjs new file mode 100644 index 0000000..cd034f4 --- /dev/null +++ b/scripts/qualify-deck-native.mjs @@ -0,0 +1,143 @@ +// # arming: agent-invoked explicit disposable qualification; no installed/default store. +import {mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join, dirname, resolve} from 'node:path'; +import {execFileSync} from 'node:child_process'; +import {createHash, generateKeyPairSync, sign} from 'node:crypto'; +import assert from 'node:assert/strict'; +import {NS} from '../web/deck/value.mjs'; +import {buildProposal, canonicalProposal, operationIri, requestPublication, readRequest, revisionIri} from '../web/deck/model.mjs'; +import {admittedChallenge, readAnswer, disposition, readbackProof} from '../web/deck/evidence.mjs'; +const cli = process.env.QUIPU_CLI, artifacts = process.env.QUIPU_ARTIFACTS; +if (!cli || !artifacts) throw Error('Explicit candidate QUIPU_CLI and fresh QUIPU_ARTIFACTS JSONL required'); +const root = mkdtempSync(join(tmpdir(), 'rikuy-native-')), source = resolve(import.meta.dirname, '..'); +const libraries = new Map(); +for (const line of readFileSync(artifacts, 'utf8').split('\n')) { + let record; try {record = JSON.parse(line);} catch {continue;} + if (record.reason !== 'compiler-artifact' || record.profile?.test || !record.target?.kind?.includes('lib') || !['quipu', 'serde_json'].includes(record.target.name)) continue; + const paths = record.filenames.filter(p => p.endsWith('.rlib')); + if (paths.length !== 1 || libraries.has(record.target.name) && libraries.get(record.target.name) !== paths[0]) throw Error('Ambiguous candidate library population'); + libraries.set(record.target.name, paths[0]); +} +if (libraries.size !== 2) throw Error('Candidate quipu and serde_json library artifacts missing'); +const buildAt = process.argv[2] === '--build-helper' ? process.argv[3] : null; +const native = buildAt || process.env.QUIPU_NATIVE_FIXTURE || join(root, 'native-fixture'); +const results = [], sha = bytes => createHash('sha256').update(bytes).digest('hex'); +const boundary = {requestGraph: 'https://example.test/requests', answerGraph: 'https://example.test/answers', itemGraph: 'https://example.test/items', itemPrefix: 'https://example.test/item/', revisionPredicate: 'https://example.test/revision', statusPredicate: 'https://schema.org/actionStatus', openStatus: 'https://schema.org/ActiveActionStatus'}; +const ask = () => ({id: 'https://example.test/request/1', revision: 3, seed: 'https://example.test/item/1', itemRevision: 7, title: 'Approve the synthetic change?', options: ['Yes', 'No'], actions: ['choose', 'later', 'comment'], expiresAt: Math.floor(Date.now() / 1000) + 3600}); +function command(db, verb, value) { + return JSON.parse(execFileSync(native, [db, verb], {input: JSON.stringify(value), encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe']})); +} +function cliRun(db, args) { + return execFileSync(cli, [...args, '--db', db], {encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe']}); +} +function put(db, graph, datums, retract = false) {return command(db, 'seed', {graph, datums, retract});} +function replace(db, graph, s, p, old, value) {put(db, graph, [{s, p, o: old}], true); put(db, graph, [{s, p, o: value}]);} +function setup(index, kind = 'choose') { + const db = join(root, `case-${index}.db`), r = ask(); + const {privateKey, publicKey} = generateKeyPairSync('ed25519'); + const x = Buffer.from(publicKey.export({format: 'jwk'}).x, 'base64url'); + const cose = Buffer.concat([Buffer.from([0xa4, 1, 1, 3, 0x27, 0x20, 6, 0x21, 0x58, 0x20]), x]); + const publication = requestPublication(r, boundary); + put(db, publication.graph, publication.datums); + const requestFacts = command(db, 'facts', {graph: boundary.requestGraph}).datums; + const requestRow = {graph: boundary.requestGraph, requestId: r.id, currentRevision: requestFacts.find(d => d.s === r.id && d.p === NS + 'currentRevision').o.value, revisionIri: revisionIri(r), manifest: requestFacts.find(d => d.s === revisionIri(r) && d.p === NS + 'requestManifest').o.value, complete: true}; + assert.deepEqual(readRequest(requestRow, boundary), r); + put(db, boundary.itemGraph, [{s: r.seed, p: boundary.revisionPredicate, o: {kind: 'int', value: r.itemRevision}}, {s: r.seed, p: boundary.statusPredicate, o: {kind: 'iri', value: boundary.openStatus}}]); + cliRun(db, ['shapes', 'load', 'rikuy-deck', join(source, 'shapes/deck.shapes.ttl')]); + cliRun(db, ['signer', 'enroll', '--credential-id', 'dGVzdA', '--cose-key', cose.toString('base64url'), '--scheme', 'webauthn-eddsa', '--origin', 'https://sign.example.test', '--rp-id', 'sign.example.test', '--actor', 'fixture-operator', '--graph', boundary.answerGraph, '--expires-at', String(r.expiresAt)]); + assert.equal(JSON.parse(cliRun(db, ['signer', 'status'])).enabled, false); + cliRun(db, ['signer', 'protect-graph', boundary.answerGraph]); + cliRun(db, ['signer', 'enable']); // Synthetic temporary database only. + const who = {signer: 'dGVzdA', audience: command(db, 'store-id', {}).store_id, origin: 'https://sign.example.test', rpId: 'sign.example.test', actor: 'fixture-operator'}; + const action = {operationId: String(index + 1).padStart(64, '0'), action: kind, optionLabel: kind === 'choose' ? 'Yes' : null, comment: 'Synthetic native comment\non two lines'}; + const p = buildProposal({boundary, ask: r, freshItem: {graph: boundary.itemGraph, seed: r.seed, revision: r.itemRevision, status: boundary.openStatus}, signer: who.signer, action, now: Math.floor(Date.now() / 1000)}); + assert.equal(command(db, 'canonical', p).canonical, canonicalProposal(p)); + return {db, r, who, action, p, privateKey}; +} +function assertion(state, issued, origin = state.who.origin, forge = false) { + const client = Buffer.from(JSON.stringify({type: 'webauthn.get', challenge: issued.webauthn_challenge, origin, crossOrigin: false})); + const auth = Buffer.alloc(37); createHash('sha256').update(state.who.rpId).digest().copy(auth); auth[32] = 5; auth.writeUInt32BE(1, 33); + const signature = sign(null, Buffer.concat([auth, createHash('sha256').update(client).digest()]), state.privateKey); + if (forge) signature[0] ^= 1; + return {nonce: issued.challenge.nonce, client_data_json: client.toString('base64url'), authenticator_data: auth.toString('base64url'), signature: signature.toString('base64url')}; +} +function footprint(state, nonce) { + return {facts: command(state.db, 'facts', {graph: boundary.answerGraph}), receipt: command(state.db, 'receipt', {nonce}), counter: JSON.parse(cliRun(state.db, ['signer', 'status'])).signers[0].sign_count}; +} +async function admit(state, p = state.p) { + const issued = command(state.db, 'challenge', p); + await admittedChallenge(issued, p, state.who, Math.floor(Date.now() / 1000)); + const body = assertion(state, issued); + command(state.db, 'commit', body); + const receipt = command(state.db, 'receipt', {nonce: body.nonce}); + const facts = command(state.db, 'facts', {graph: boundary.answerGraph}).datums; + const manifests = facts.filter(d => d.s === receipt.receipt_iri && d.p.endsWith('attestProposal')); + assert.equal(manifests.length, 1); + const row = {graph: boundary.answerGraph, subject: operationIri(state.action.operationId), receiptIri: receipt.receipt_iri, manifest: manifests[0].o.value}; + return {issued, body, receipt, facts, row}; +} +try { + const compile = ['--edition=2024', join(source, 'tests/native/deck-fixture.rs'), '-L', 'dependency=' + dirname(libraries.get('quipu')), ...[...libraries].flatMap(([name, path]) => ['--extern', `${name}=${path}`]), '-o', native]; + const metadata = () => ({source_sha256: sha(readFileSync(join(source, 'tests/native/deck-fixture.rs'))), library_sha256: sha(readFileSync(libraries.get('quipu'))), executable_sha256: sha(readFileSync(native))}); + if (buildAt || !process.env.QUIPU_NATIVE_FIXTURE) execFileSync('rustc', compile, {stdio: ['ignore', 'pipe', 'pipe']}); + if (buildAt) { + writeFileSync(native + '.json', JSON.stringify(metadata(), null, 2) + '\n', {mode: 0o600}); + console.log(JSON.stringify(metadata())); + } else { + if (process.env.QUIPU_NATIVE_FIXTURE) assert.deepEqual(JSON.parse(readFileSync(native + '.json', 'utf8')), metadata(), 'Stale native fixture artifact'); + const candidate = execFileSync(cli, ['--version'], {encoding: 'utf8'}).trim(); + for (const [index, kind] of ['choose', 'later', 'comment'].entries()) { + const state = setup(index, kind), e = await admit(state); + const answer = await readAnswer(e.row, e.receipt, state.r, boundary, state.who); + assert.equal(disposition([answer], state.r, boundary).state, {choose: 'ANSWERED', later: 'LATER', comment: 'WAITING'}[kind]); + const subjects = new Set(state.p.datums.map(d => d.s)); + readbackProof({graph: boundary.answerGraph, complete: true, datums: e.facts.filter(d => state.p.datums.some(p => p.s === d.s && p.p === d.p && p.o.kind === d.o.kind && p.o.value === d.o.value)), attestedBy: e.facts.filter(d => subjects.has(d.s) && d.p.endsWith('attestedBy')).map(d => ({s: d.s, receiptIri: d.o.value}))}, answer); + const before = footprint(state, e.body.nonce); + assert.throws(() => command(state.db, 'commit', e.body)); + assert.deepEqual(footprint(state, e.body.nonce), before); + assert.throws(() => command(state.db, 'challenge', state.p)); // Decided or operation slot is spent. + results.push({case: kind, state: disposition([answer], state.r, boundary).state, canonical_native_match: true, native_verified: e.receipt.verified, replay_no_mutation: true}); + } + for (const [index, kind] of ['forged', 'wrong-origin', 'item-revision', 'closed-item', 'request-revision'].entries()) { + const state = setup(index + 10), issued = command(state.db, 'challenge', state.p); + let restore = () => {}; + if (kind === 'item-revision') {replace(state.db, boundary.itemGraph, state.r.seed, boundary.revisionPredicate, {kind: 'int', value: 7}, {kind: 'int', value: 8}); restore = () => replace(state.db, boundary.itemGraph, state.r.seed, boundary.revisionPredicate, {kind: 'int', value: 8}, {kind: 'int', value: 7});} + if (kind === 'closed-item') {const closed = 'https://schema.org/CompletedActionStatus'; replace(state.db, boundary.itemGraph, state.r.seed, boundary.statusPredicate, {kind: 'iri', value: boundary.openStatus}, {kind: 'iri', value: closed}); restore = () => replace(state.db, boundary.itemGraph, state.r.seed, boundary.statusPredicate, {kind: 'iri', value: closed}, {kind: 'iri', value: boundary.openStatus});} + if (kind === 'request-revision') {replace(state.db, boundary.requestGraph, state.r.id, NS + 'currentRevision', {kind: 'int', value: 3}, {kind: 'int', value: 4}); restore = () => replace(state.db, boundary.requestGraph, state.r.id, NS + 'currentRevision', {kind: 'int', value: 4}, {kind: 'int', value: 3});} + const before = footprint(state, issued.challenge.nonce), body = assertion(state, issued, kind === 'wrong-origin' ? 'https://other.example.test' : state.who.origin, kind === 'forged'); + assert.throws(() => command(state.db, 'commit', body)); + assert.deepEqual(footprint(state, issued.challenge.nonce), before); + restore(); command(state.db, 'commit', assertion(state, issued)); + assert.equal(command(state.db, 'receipt', {nonce: issued.challenge.nonce}).verified, true); // Refusal did not consume nonce/counter. + results.push({case: kind, refused_no_mutation: true, nonce_and_counter_unspent: true}); + } + for (const [index, slot] of [0, 1, 2, 3, 4].entries()) { + const state = setup(index + 20), p = structuredClone(state.p); p.expect.splice(slot, 1); + const e = await admit(state, p); assert.equal(e.receipt.verified, true); // Domain-free native primitive permits it. + await assert.rejects(readAnswer(e.row, e.receipt, state.r, boundary, state.who)); + results.push({case: `missing-expectation-${slot}`, native_verified: true, deck_reader_refused: true}); + } + { + const state = setup(35, 'comment'), p = structuredClone(state.p); + p.datums = p.datums.filter(d => d.p !== NS + 'comment'); + const e = await admit(state, p); + await assert.rejects(readAnswer(e.row, e.receipt, state.r, boundary, state.who)); + results.push({case: 'missing-comment-floor', native_verified: true, deck_reader_refused: true, limitation: 'Native post-state admission excludes MinCount floors; full shape validation and client/reader constraints remain distinct'}); + } + { + const state = setup(36, 'comment'), p = structuredClone(state.p); + p.datums.find(d => d.p === NS + 'action').o.value = 'undeclared'; + const issued = command(state.db, 'challenge', p), before = footprint(state, issued.challenge.nonce); + assert.throws(() => command(state.db, 'commit', assertion(state, issued))); + assert.deepEqual(footprint(state, issued.challenge.nonce), before); + results.push({case: 'loaded-action-value-constraint', refused_no_mutation: true}); + } + const state = setup(40), e = await admit(state), ordinary = 'https://example.test/ordinary'; + put(state.db, ordinary, state.p.datums); + await assert.rejects(readAnswer({...e.row, graph: ordinary}, e.receipt, state.r, boundary, state.who)); + await assert.rejects(readAnswer(e.row, e.receipt, {...state.r, revision: 4}, boundary, state.who)); + results.push({case: 'ordinary-graph-and-other-request-revision', deck_reader_refused: true}); + console.log(JSON.stringify({candidate, native_library_sha256: sha(readFileSync(libraries.get('quipu'))), helper_source_sha256: sha(readFileSync(join(source, 'tests/native/deck-fixture.rs'))), fixture: 'Disposable stores; generated software keys kept only in memory; local native methods and actual operator CLI; no HTTP/service/real credential or production profile union', results}, null, 2)); + } +} finally {rmSync(root, {recursive: true, force: true});} diff --git a/shapes/deck.shapes.ttl b/shapes/deck.shapes.ttl new file mode 100644 index 0000000..6cea759 --- /dev/null +++ b/shapes/deck.shapes.ttl @@ -0,0 +1,27 @@ +@prefix r: . +@prefix sh: . +@prefix xsd: . +@prefix rdfs: . + +r:Decision a rdfs:Class ; rdfs:label "Rikuy decision" . +r:Comment a rdfs:Class ; rdfs:label "Rikuy comment" . + +# Membership in the declared options, cross-graph current revision and +# attestation provenance are checked by the reader and signed expectations. +# Loading these shapes does not authorize a writer or protect a graph. +r:DecisionShape a sh:NodeShape ; sh:targetClass r:Decision ; + sh:property [ sh:path r:request ; sh:minCount 1 ; sh:maxCount 1 ; sh:nodeKind sh:IRI ] ; + sh:property [ sh:path r:revision ; sh:minCount 1 ; sh:maxCount 1 ; sh:datatype xsd:integer ; sh:minInclusive 1 ] ; + sh:property [ sh:path r:seed ; sh:minCount 1 ; sh:maxCount 1 ; sh:nodeKind sh:IRI ] ; + sh:property [ sh:path r:action ; sh:minCount 1 ; sh:maxCount 1 ; sh:in ( "choose" "later" ) ] ; + sh:property [ sh:path r:operationId ; sh:minCount 1 ; sh:maxCount 1 ; sh:datatype xsd:string ; sh:pattern "^[0-9a-f]{64}$" ] ; + sh:property [ sh:path r:optionLabel ; sh:maxCount 1 ; sh:datatype xsd:string ; sh:maxLength 256 ] ; + sh:property [ sh:path r:comment ; sh:maxCount 1 ; sh:datatype xsd:string ; sh:maxLength 4096 ] . + +r:CommentShape a sh:NodeShape ; sh:targetClass r:Comment ; + sh:property [ sh:path r:request ; sh:minCount 1 ; sh:maxCount 1 ; sh:nodeKind sh:IRI ] ; + sh:property [ sh:path r:revision ; sh:minCount 1 ; sh:maxCount 1 ; sh:datatype xsd:integer ; sh:minInclusive 1 ] ; + sh:property [ sh:path r:seed ; sh:minCount 1 ; sh:maxCount 1 ; sh:nodeKind sh:IRI ] ; + sh:property [ sh:path r:action ; sh:minCount 1 ; sh:maxCount 1 ; sh:in ( "comment" ) ] ; + sh:property [ sh:path r:operationId ; sh:minCount 1 ; sh:maxCount 1 ; sh:datatype xsd:string ; sh:pattern "^[0-9a-f]{64}$" ] ; + sh:property [ sh:path r:comment ; sh:minCount 1 ; sh:maxCount 1 ; sh:datatype xsd:string ; sh:minLength 1 ; sh:maxLength 4096 ] . diff --git a/tests/build.test.mjs b/tests/build.test.mjs index d3f88c1..dc06fbc 100644 --- a/tests/build.test.mjs +++ b/tests/build.test.mjs @@ -11,6 +11,13 @@ test('deployment assets omit the synthetic demo entry point and fixture payload' await assert.rejects(access('dist/demo-rows.json')); }); +test('online Deck modules publish together with disabled configuration and no test fixtures',async()=>{ + execFileSync(process.execPath,['scripts/build.mjs']); + for(const name of ['index.html','style.css','app.mjs','config.mjs','value.mjs','model.mjs','evidence.mjs','pending.mjs','sign.mjs','storage.mjs','view.mjs']) assert.equal(await readFile('dist/deck/'+name,'utf8'),await readFile('web/deck/'+name,'utf8')); + assert.match(await readFile('dist/deck/config.mjs','utf8'),/enabled: false/); + await assert.rejects(access('dist/deck/fixtures')); + await assert.rejects(access('dist/deck/deck-fixture.mjs')); +}); test('enrollment assets are published together and retain the disabled configuration',async()=>{ execFileSync(process.execPath,['scripts/build.mjs']); diff --git a/tests/deck-fixture.mjs b/tests/deck-fixture.mjs new file mode 100644 index 0000000..dc86397 --- /dev/null +++ b/tests/deck-fixture.mjs @@ -0,0 +1,27 @@ +// Synthetic reader/challenge fixtures, not signature or installed-service proof. +import {NS, base64url, sha256, utf8} from '../web/deck/value.mjs'; +import {buildProposal, canonicalProposal, operationIri} from '../web/deck/model.mjs'; +export const boundary = Object.freeze({requestGraph: 'https://example.test/requests', answerGraph: 'https://example.test/answers', itemGraph: 'https://example.test/items', itemPrefix: 'https://example.test/item/', revisionPredicate: 'https://example.test/revision', statusPredicate: 'https://schema.org/actionStatus', openStatus: 'https://schema.org/ActiveActionStatus'}); +export const who = Object.freeze({signer: 'dGVzdA', audience: 'synthetic-store', origin: 'https://sign.example.test', rpId: 'sign.example.test', actor: 'fixture-operator'}); +export const ask = Object.freeze({id: 'https://example.test/request/1', revision: 3, seed: 'https://example.test/item/1', itemRevision: 7, title: 'Approve the synthetic change?', options: ['Yes', 'No'], actions: ['choose', 'later', 'comment'], expiresAt: 2000000000}); +export const freshItem = Object.freeze({graph: boundary.itemGraph, seed: ask.seed, revision: ask.itemRevision, status: boundary.openStatus}); +export const action = Object.freeze({operationId: 'a'.repeat(64), action: 'choose', optionLabel: 'Yes', comment: 'A note\non two lines'}); +export const make = (changes = {}) => buildProposal({boundary, ask, freshItem, signer: who.signer, action, now: 1900000000, ...changes}); +export async function issued(p = make(), changes = {}) { + const challenge = {domain: 'quipu-attested-write-v1', audience: who.audience, nonce: 'b'.repeat(64), origin: who.origin, rp_id: who.rpId, actor: who.actor, proposal: p, expires_at: 1900000300, ...changes}; + const message = utf8(JSON.stringify(challenge)), prefix = utf8('quipu-verdict-webauthn-v1\0'), all = new Uint8Array(prefix.length + message.length); + all.set(prefix); all.set(message, prefix.length); + return {challenge, message: base64url(message), webauthn_challenge: base64url(await crypto.subtle.digest('SHA-256', all))}; +} +export async function evidence(p = make()) { + const value = await issued(p); + const receipt = {challenge: value.challenge, content_hash: 'sha256:' + await sha256(utf8(JSON.stringify(value.challenge))), recorded_at: 1900000001}; + const receiptIri = 'urn:quipu:attest:' + value.challenge.nonce; + const operationId = p.datums.find(d => d.p === NS + 'operationId').o.value; + return {row: {graph: boundary.answerGraph, subject: operationIri(operationId), receiptIri, manifest: canonicalProposal(p)}, response: {found: true, verified: true, tx: 9, receipt_iri: receiptIri, receipt}, readback: {graph: boundary.answerGraph, datums: p.datums, attestedBy: [...new Set(p.datums.map(d => d.s))].map(s => ({s, receiptIri})), complete: true}}; +} +export function memory() { + let value = null; + return {get: async () => structuredClone(value), insert: async record => {if (value) throw Error('CAS conflict'); value = structuredClone(record);}, change: async (nonce, patch) => {if (value?.nonce !== nonce) throw Error('CAS conflict'); Object.assign(value, structuredClone(patch));}, remove: async (nonce, state) => {if (value?.nonce !== nonce || value?.state !== state) throw Error('CAS conflict'); value = null;}}; +} +export const currentRevisionPredicate = NS + 'currentRevision'; diff --git a/tests/deck.test.mjs b/tests/deck.test.mjs new file mode 100644 index 0000000..74c2b39 --- /dev/null +++ b/tests/deck.test.mjs @@ -0,0 +1,145 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {NS, RDF_TYPE, base64url, unbase64url} from '../web/deck/value.mjs'; +import {request, requestPublication, readRequest, buildProposal, canonicalProposal, decodeProposal, revisionIri} from '../web/deck/model.mjs'; +import {admittedChallenge, readAnswer, disposition, receiptProof, readbackProof} from '../web/deck/evidence.mjs'; +import {PendingWrite} from '../web/deck/pending.mjs'; +import {webauthnSigner} from '../web/deck/sign.mjs'; +import {boundary, who, ask, freshItem, action, make, issued, evidence, memory} from './deck-fixture.mjs'; +const copy = value => structuredClone(value); +test('curated request manifests bind the complete declared card to one current graph and revision', () => { + const publication = requestPublication(ask, boundary); + const row = {graph: publication.graph, requestId: ask.id, currentRevision: ask.revision, revisionIri: revisionIri(ask), manifest: publication.datums[1].o.value, complete: true}; + assert.deepEqual(readRequest(row, boundary), request(ask, boundary)); + for (const patch of [{graph: boundary.answerGraph}, {currentRevision: 4}, {revisionIri: ask.id + '#revision-2'}, {complete: false}, {manifest: row.manifest.replace('"revision":3', '"revision":2,"revision":3')}]) assert.throws(() => readRequest({...row, ...patch}, boundary)); +}); +test('online proposals retain all five slots across three graphs with exact native value kinds', () => { + const p = make(); + assert.equal(p.expect.length, 5); + assert.equal(new Set(p.expect.map(e => e.graph)).size, 3); + assert.equal(p.expect[0].o.kind, 'int'); + assert.equal(p.expect[1].o, null); + assert.equal(p.expect[3].o.kind, 'iri'); + assert.deepEqual(decodeProposal(canonicalProposal(p)), p); + assert.ok(p.datums.some(d => d.p === NS + 'decided' && d.s === revisionIri(ask))); + assert.equal(p.datums.find(d => d.p === NS + 'comment').o.value, action.comment); +}); +test('changed or closed item, foreign graph, stale revision, expiry and undeclared choices refuse', () => { + for (const changed of [{revision: 8}, {status: 'https://schema.org/CompletedActionStatus'}, {graph: boundary.answerGraph}, {seed: 'https://example.test/item/2'}]) assert.throws(() => make({freshItem: {...freshItem, ...changed}})); + assert.throws(() => make({now: ask.expiresAt})); + for (const changed of [{optionLabel: 'Maybe'}, {action: 'unknown'}, {operationId: 'bad'}, {comment: 'x'.repeat(4097)}]) assert.throws(() => make({action: {...action, ...changed}})); + assert.throws(() => request({...ask, seed: 'https://other.test/item/1'}, boundary)); + assert.throws(() => request({...ask, options: ['Yes', 'Yes']}, boundary)); + assert.throws(() => make({boundary: {...boundary, answerGraph: boundary.requestGraph}})); +}); +test('canonical manifest rejects duplicate JSON fields, extra data fields, inexact and untyped values', () => { + const raw = canonicalProposal(make()); + for (const bad of [raw.replace('"signer":', '"signer":"wrong","signer":'), ' ' + raw, raw.replace('"kind":"int","value":3', '"kind":"str","value":3')]) assert.throws(() => decodeProposal(bad)); + for (const mutate of [p => p.expect.push(p.expect[0]), p => p.datums.push(p.datums[0]), p => p.datums[0].o.extra = true, p => p.expect[0].o.value = 9007199254740992]) {const p = make(); mutate(p); assert.throws(() => canonicalProposal(p));} +}); +test('bounded Unicode and multiline manifests round-trip without changing bytes, labels or typed slots', () => { + const samples = ['Yes', 'quoted "choice"', 'back\\slash', 'café', '東京', '🙂', 'A\u2028B', 'e\u0301']; + for (const label of samples) for (const note of ['', ...samples, 'line 1\nline 2\tindented']) { + const p = make({ask: {...ask, options: [label]}, action: {...action, optionLabel: label, comment: note}}); + const bytes = canonicalProposal(p), decoded = decodeProposal(bytes); + assert.equal(canonicalProposal(decoded), bytes); + assert.deepEqual(decoded, p); + assert.equal(decoded.datums.find(d => d.p === NS + 'optionLabel').o.value, label); + assert.equal(decoded.datums.find(d => d.p === NS + 'comment')?.o.value ?? '', note); + } + assert.throws(() => make({action: {...action, comment: '\ud800'}})); + assert.throws(() => make({action: {...action, comment: '🙂'.repeat(1025)}})); +}); +test('choose, Later and comment fold from verified point receipts; comments do not decide', async () => { + for (const kind of ['choose', 'later', 'comment']) { + const p = make({action: {...action, action: kind, optionLabel: kind === 'choose' ? 'Yes' : null}}), e = await evidence(p); + const admitted = await readAnswer(e.row, e.response, ask, boundary, who); + assert.equal(disposition([admitted], ask, boundary).state, {choose: 'ANSWERED', later: 'LATER', comment: 'WAITING'}[kind]); + assert.equal(disposition([admitted], ask, boundary).comments[0].text, action.comment); + assert.equal(disposition([admitted], ask, boundary).comments[0].actor, who.actor); + assert.equal(p.datums.some(d => d.p === NS + 'decided'), kind !== 'comment'); + } + assert.throws(() => make({action: {...action, action: 'comment', optionLabel: null, comment: ' \n '}})); + assert.throws(() => disposition(Array(101).fill({}), ask, boundary)); + assert.throws(() => disposition([{action: 'choose'}], ask, boundary)); +}); +for (const slot of [0, 1, 2, 3, 4]) test(`reader refuses a receipt manifest missing required precondition ${slot}`, async () => { + const p = make(); p.expect.splice(slot, 1); + const e = await evidence(p); + await assert.rejects(readAnswer(e.row, e.response, ask, boundary, who)); +}); +test('ordinary graph, wrong revision, forged provenance, foreign receipt and extra facts never count', async () => { + const positive = await evidence(); + await readAnswer(positive.row, positive.response, ask, boundary, who); + const cases = [e => e.row.graph = boundary.requestGraph, e => e.row.receiptIri = 'urn:quipu:attest:' + 'c'.repeat(64), e => e.response.verified = false, e => e.response.found = false, e => e.response.tx = 0, e => e.response.receipt.challenge.actor = 'browser-asserted-human', e => e.response.receipt.content_hash = 'sha256:' + '0'.repeat(64)]; + for (const mutate of cases) {const e = copy(positive); mutate(e); await assert.rejects(readAnswer(e.row, e.response, ask, boundary, who));} + await assert.rejects(readAnswer(positive.row, positive.response, {...ask, revision: 4}, boundary, who)); + for (const mutate of [p => p.expect[0].graph = boundary.answerGraph, p => p.datums.push({s: ask.seed, p: NS + 'unrelated', o: {kind: 'str', value: 'extra'}})]) { + const p = make(); mutate(p); + const e = await evidence(p); await assert.rejects(readAnswer(e.row, e.response, ask, boundary, who)); + } + const malformed = make(); malformed.expect[2].o.kind = 'str'; + await assert.rejects(evidence(malformed)); +}); +test('returned challenge bytes, domain, derived WebAuthn challenge, scope and deadline are bound', async () => { + const p = make(), positive = await issued(p); + assert.equal((await admittedChallenge(positive, p, who, 1900000000)).challenge.nonce, 'b'.repeat(64)); + for (const changes of [{domain: 'quipu-decision-deck-v1'}, {audience: 'foreign-store'}, {expires_at: 1900000301}, {expires_at: 1900000000}, {origin: 'https://other.test'}]) await assert.rejects(admittedChallenge(await issued(p, changes), p, who, 1900000000)); + await assert.rejects(admittedChallenge({...positive, message: base64url(new Uint8Array([1, 2]))}, p, who, 1900000000)); + await assert.rejects(admittedChallenge({...positive, webauthn_challenge: 'a'.repeat(43)}, p, who, 1900000000)); + await assert.rejects(admittedChallenge(positive, make({action: {...action, optionLabel: 'No'}}), who, 1900000000)); +}); +test('receipt acceptance requires complete fact and provenance read-back', async () => { + const p = make(), e = await evidence(p), proof = await receiptProof(e.response, p, who, 'b'.repeat(64)); + assert.equal(readbackProof(e.readback, proof), proof); + for (const mutate of [r => r.complete = false, r => r.datums.pop(), r => r.attestedBy.pop(), r => r.attestedBy[0].receiptIri = 'urn:wrong', r => r.graph = boundary.requestGraph]) {const r = copy(e.readback); mutate(r); assert.throws(() => readbackProof(r, proof));} +}); +test('lost commit response is reconciled once by nonce without retry or resign; read-back gates clearing', async () => { + const p = make(), e = await evidence(p), store = memory(); let commits = 0, signs = 0, visible = false; + const transport = {challenge: () => issued(p), commit: async () => {commits++; throw Error('lost response');}, receipt: async () => e.response, readback: async () => ({...e.readback, complete: visible})}; + const writer = new PendingWrite({identity: who, store, transport, sign: async () => {signs++; return {client_data_json: 'a', authenticator_data: 'b', signature: 'c'};}, now: () => 1900000000}); + assert.equal((await writer.submit(p, ask.expiresAt)).state, 'UNKNOWN'); + await assert.rejects(writer.acknowledge()); + await assert.rejects(writer.submit(p, ask.expiresAt)); + visible = true; + assert.equal((await writer.reconcile()).state, 'ACCEPTED'); + assert.deepEqual([commits, signs], [1, 1]); + await writer.acknowledge(); assert.equal(await store.get(), null); +}); +test('absent, timed out and mismatched receipts retain stable custody and never permit commit retry', async () => { + const p = make(), store = memory(); let commits = 0; + const transport = {challenge: () => issued(p), commit: async () => {commits++;}, receipt: async () => ({found: false, verified: false, receipt: null, tx: null})}; + const writer = new PendingWrite({identity: who, store, transport, sign: async () => ({client_data_json: 'a', authenticator_data: 'b', signature: 'c'}), now: () => 1900000000}); + await writer.submit(p, ask.expiresAt); + const before = await store.get(); + for (let i = 0; i < 3; i++) assert.equal((await writer.reconcile()).state, 'UNKNOWN'); + assert.deepEqual((await store.get()).proposal, before.proposal); assert.equal(commits, 1); + const e = await evidence(make({action: {...action, optionLabel: 'No'}})); transport.receipt = async () => e.response; + assert.equal((await writer.reconcile()).state, 'UNKNOWN'); +}); +test('store custody precedes signing and commit, cancellation and two-tab CAS do not submit', async () => { + const p = make(), store = memory(); let commits = 0, signs = 0; + const transport = {challenge: () => issued(p), commit: async () => {commits++;}}; + const writer = new PendingWrite({identity: who, store, transport, sign: async () => {signs++; assert.ok(await store.get()); throw Error('cancel');}, now: () => 1900000000}); + assert.equal((await writer.submit(p, ask.expiresAt)).state, 'PENDING'); assert.deepEqual([signs, commits], [1, 0]); + const peer = new PendingWrite({identity: who, store, transport, sign: async () => {signs++;}, now: () => 1900000000}); + await assert.rejects(peer.submit(p, ask.expiresAt)); assert.deepEqual([signs, commits], [1, 0]); +}); +test('real WebAuthn caller uses only enrolled credential and exact server-derived challenge', async () => { + const p = make(), admitted = await admittedChallenge(await issued(p), p, who, 1900000000); let calls = 0; + const environment = {secure: true, origin: who.origin, credentials: {get: async options => { + calls++; assert.equal(base64url(options.publicKey.challenge), admitted.webauthnChallenge); assert.equal(options.publicKey.userVerification, 'required'); + assert.equal(base64url(options.publicKey.allowCredentials[0].id), who.signer); + return {type: 'public-key', rawId: unbase64url(who.signer).buffer, response: {authenticatorData: new Uint8Array([1]).buffer, clientDataJSON: new Uint8Array([2]).buffer, signature: new Uint8Array([3]).buffer}}; + }}}; + assert.deepEqual(await webauthnSigner(who, environment)(admitted), {authenticator_data: 'AQ', client_data_json: 'Ag', signature: 'Aw'}); + await assert.rejects(webauthnSigner(who, {...environment, origin: 'https://wrong.test'})(admitted)); assert.equal(calls, 1); +}); + +test('a server deadline beyond the request refuses before signing or retaining a submission', async () => { + const p = make(), store = memory(); let signs = 0; + const writer = new PendingWrite({identity: who, store, transport: {challenge: () => issued(p)}, sign: async () => {signs++;}, now: () => 1900000000}); + await assert.rejects(writer.submit(p, 1900000100)); + assert.equal(signs, 0); assert.equal(await store.get(), null); + const e = await evidence(); await assert.rejects(readAnswer(e.row, e.response, {...ask, expiresAt: 1900000100}, boundary, who)); +}); diff --git a/tests/native/deck-fixture.rs b/tests/native/deck-fixture.rs new file mode 100644 index 0000000..3268448 --- /dev/null +++ b/tests/native/deck-fixture.rs @@ -0,0 +1,67 @@ +// Disposable native qualification helper. It is not a production operator tool. +use quipu::store::attested_write::{AttestedDatum, Proposal, TypedValue}; +use quipu::types::{Op, Value}; +use quipu::{Datum, Store}; +use serde_json::{Value as J, json}; +use std::io::Read; +fn main() -> Result<(), Box> { + let a: Vec = std::env::args().collect(); + let mut raw = String::new(); + std::io::stdin().read_to_string(&mut raw)?; + let v: J = serde_json::from_str(&raw)?; + let mut s = Store::open(&a[1])?; + let out = match a[2].as_str() { + "seed" => { + let g = s.graph_create(v["graph"].as_str().unwrap())?; + let rows: Vec = serde_json::from_value(v["datums"].clone())?; + let ts = "2026-10-25T00:00:00Z"; + let mut ds = Vec::new(); + for d in rows { + let value = match d.o { + TypedValue::Int(n) => Value::Int(n), + TypedValue::Str(t) => Value::Str(t), + TypedValue::Iri(t) => Value::Ref(s.intern(&t)?), + }; + ds.push(Datum { + entity: s.intern(&d.s)?, + attribute: s.intern(&d.p)?, + value, + valid_from: ts.into(), + valid_to: None, + op: if v["retract"].as_bool().unwrap_or(false) { + Op::Retract + } else { + Op::Assert + }, + }); + } + s.transact_to_graph(&ds, ts, None, None, g)?; + json!({"seeded":true}) + } + "canonical" => { + let p: Proposal = serde_json::from_value(v)?; + json!({"canonical":serde_json::to_string(&p)?}) + } + "challenge" => quipu::tool_attest_challenge(&mut s, &v)?, + "commit" => quipu::tool_attest_commit(&mut s, &v)?, + "receipt" => quipu::tool_attest_receipt(&s, &v)?, + "store-id" => json!({"store_id":s.store_id()?}), + "facts" => { + let g = s.lookup(v["graph"].as_str().unwrap())?.unwrap(); + let mut rows = Vec::new(); + for f in s.current_facts_in_graph(g)? { + let o = match f.value { + Value::Int(n) => json!({"kind":"int","value":n}), + Value::Str(t) => json!({"kind":"str","value":t}), + Value::Ref(i) => json!({"kind":"iri","value":s.resolve(i)?}), + _ => continue, + }; + rows.push(json!({"s":s.resolve(f.entity)?,"p":s.resolve(f.attribute)?,"o":o})); + } + json!({"datums":rows}) + } + _ => panic!("fixture verb"), + }; + println!("{}", serde_json::to_string(&out)?); + Ok(()) +} diff --git a/web/deck/app.mjs b/web/deck/app.mjs new file mode 100644 index 0000000..c412417 --- /dev/null +++ b/web/deck/app.mjs @@ -0,0 +1,6 @@ +import {config} from './config.mjs'; +import {mount} from './view.mjs'; +// No production adapter is selected by this source preview. +mount(document.querySelector('main'), config).catch(() => { + document.querySelector('main').textContent = 'UNKNOWN · admitted adapters unavailable.'; +}); diff --git a/web/deck/config.mjs b/web/deck/config.mjs new file mode 100644 index 0000000..f1be197 --- /dev/null +++ b/web/deck/config.mjs @@ -0,0 +1,2 @@ +// Deployment and reader/write admission are separate from source delivery. +export const config = Object.freeze({enabled: false}); diff --git a/web/deck/evidence.mjs b/web/deck/evidence.mjs new file mode 100644 index 0000000..90961fd --- /dev/null +++ b/web/deck/evidence.mjs @@ -0,0 +1,95 @@ +import {Unknown, NS, RDF_TYPE, exact, text, integer, iri, hexId, utf8, sha256, base64url, unbase64url} from './value.mjs'; +import {scope, request, operationIri, proposal, canonicalProposal, decodeProposal, buildProposal} from './model.mjs'; +const admittedAnswers = new WeakSet(); +export function identity(value) { + exact(value, ['signer', 'audience', 'origin', 'rpId', 'actor']); + const result = {signer: text(value.signer, 1024), audience: text(value.audience, 256), origin: text(value.origin, 2048), rpId: text(value.rpId, 253), actor: text(value.actor, 256)}; + let url; try { url = new URL(result.origin); } catch { throw new Unknown('Invalid signing origin'); } + if (url.protocol !== 'https:' || url.origin !== result.origin || url.hostname !== result.rpId || url.username || url.password) throw new Unknown('Exact HTTPS signing origin/RP required'); + return result; +} +export function challenge(value, expectedIdentity) { + const who = identity(expectedIdentity); + exact(value, ['domain', 'audience', 'nonce', 'origin', 'rp_id', 'actor', 'proposal', 'expires_at']); + const result = {domain: value.domain, audience: value.audience, nonce: hexId(value.nonce), origin: value.origin, rp_id: value.rp_id, actor: value.actor, proposal: proposal(value.proposal), expires_at: integer(value.expires_at)}; + if (result.domain !== 'quipu-attested-write-v1' || result.audience !== who.audience || result.origin !== who.origin || result.rp_id !== who.rpId || result.actor !== who.actor || result.proposal.signer !== who.signer) throw new Unknown('Foreign signing scope'); + return result; +} +export async function admittedChallenge(issued, expectedProposal, who, now) { + exact(issued, ['message', 'webauthn_challenge', 'challenge']); + const c = challenge(issued.challenge, who), message = utf8(JSON.stringify(c)); + if (canonicalProposal(c.proposal) !== canonicalProposal(expectedProposal) || !Number.isSafeInteger(now) || c.expires_at <= now || c.expires_at > now + 300 || message.length > 32768 || base64url(message) !== issued.message) throw new Unknown('Changed or expired signed message'); + const prefix = utf8('quipu-verdict-webauthn-v1\0'), all = new Uint8Array(prefix.length + message.length); + all.set(prefix); all.set(message, prefix.length); + const derived = base64url(await crypto.subtle.digest('SHA-256', all)); + if (derived !== issued.webauthn_challenge) throw new Unknown('Wrong WebAuthn challenge'); + unbase64url(issued.message); + return {challenge: c, message: issued.message, webauthnChallenge: derived, contentHash: 'sha256:' + await sha256(message)}; +} +// The caller obtains this response from its pinned native /attest/receipt +// reader. A browser-provided row or its own `verified` flag is not authority. +export async function receiptProof(response, expectedProposal, who, nonce) { + if (!response || utf8(JSON.stringify(response)).length > 131072 || response.found !== true || response.verified !== true || response.receipt_iri !== 'urn:quipu:attest:' + hexId(nonce)) throw new Unknown('Verified native receipt unavailable'); + integer(response.tx); + const receipt = response.receipt, c = challenge(receipt?.challenge, who); + const contentHash = 'sha256:' + await sha256(utf8(JSON.stringify(c))); + if (c.nonce !== nonce || canonicalProposal(c.proposal) !== canonicalProposal(expectedProposal) || receipt.content_hash !== contentHash) throw new Unknown('Receipt is for another intent'); + const recordedAt = integer(receipt.recorded_at); + if (recordedAt >= c.expires_at) throw new Unknown('Receipt time outside signed deadline'); + return {receiptIri: response.receipt_iri, nonce, tx: response.tx, actor: c.actor, recordedAt, expiresAt: c.expires_at, contentHash, proposal: c.proposal}; +} +export function readbackProof(observed, proof) { + exact(observed, ['graph', 'datums', 'attestedBy', 'complete']); + if (observed.graph !== proof.proposal.graph || observed.complete !== true || !Array.isArray(observed.datums) || !Array.isArray(observed.attestedBy)) throw new Unknown('Incomplete answer read-back'); + const actual = proposal({...proof.proposal, datums: observed.datums}); + const keys = rows => rows.map(d => JSON.stringify(d)).sort(); + if (JSON.stringify(keys(actual.datums)) !== JSON.stringify(keys(proof.proposal.datums))) throw new Unknown('Answer facts not readable'); + const subjects = [...new Set(proof.proposal.datums.map(d => d.s))].sort(); + if (observed.attestedBy.length !== subjects.length) throw new Unknown('Missing answer provenance'); + for (const link of observed.attestedBy) { exact(link, ['s', 'receiptIri']); if (link.receiptIri !== proof.receiptIri) throw new Unknown('Foreign answer provenance'); } + if (JSON.stringify(observed.attestedBy.map(x => x.s).sort()) !== JSON.stringify(subjects)) throw new Unknown('Conflicting answer provenance'); + return proof; +} +export async function readAnswer(row, response, ask, boundary, who) { + const b = scope(boundary), r = request(ask, b); + exact(row, ['graph', 'subject', 'receiptIri', 'manifest']); + if (row.graph !== b.answerGraph) throw new Unknown('Answer from ordinary or foreign graph'); + const p = decodeProposal(row.manifest); + if (p.graph !== b.answerGraph || p.signer !== identity(who).signer) throw new Unknown('Foreign proposal'); + const values = predicate => p.datums.filter(d => d.s === row.subject && d.p === predicate); + const scalar = (predicate, kind, required = true) => { + const found = values(predicate); + if (!found.length && !required) return null; + if (found.length !== 1 || found[0].o.kind !== kind) throw new Unknown('Missing or conflicting answer field'); + return found[0].o.value; + }; + const operationId = scalar(NS + 'operationId', 'str'); + if (row.subject !== operationIri(operationId)) throw new Unknown('Foreign operation subject'); + const action = {operationId, action: scalar(NS + 'action', 'str'), optionLabel: scalar(NS + 'optionLabel', 'str', false), comment: scalar(NS + 'comment', 'str', false) ?? ''}; + // Reconstruct the entire permitted manifest, not just the field that the + // renderer was studying: no omitted precondition or extra datum can pass. + const expected = buildProposal({boundary: b, ask: r, signer: p.signer, action, + freshItem: {graph: b.itemGraph, seed: r.seed, revision: r.itemRevision, status: b.openStatus}, now: r.expiresAt - 1}); + if (canonicalProposal(expected) !== row.manifest || values(RDF_TYPE).length !== 1) throw new Unknown('Invalid Deck manifest'); + if (!/^urn:quipu:attest:[0-9a-f]{64}$/.test(row.receiptIri)) throw new Unknown('Missing server provenance'); + const proof = await receiptProof(response, expected, who, row.receiptIri.slice('urn:quipu:attest:'.length)); + if (proof.expiresAt > r.expiresAt || proof.recordedAt >= r.expiresAt) throw new Unknown('Signed deadline exceeds the curated request deadline'); + const answer = Object.freeze({...action, requestId: r.id, seed: r.seed, revision: r.revision, ...proof}); + admittedAnswers.add(answer); + return answer; +} +export function disposition(answers, ask, boundary) { + const r = request(ask, boundary); + if (!Array.isArray(answers) || answers.length > 100) throw new Unknown('Incomplete answer population'); + const operations = new Set(), comments = [], decisions = []; + for (const answer of answers) { + if (!admittedAnswers.has(answer) || answer.requestId !== r.id || answer.seed !== r.seed || answer.revision !== r.revision) throw new Unknown('Unadmitted or foreign answer'); + if (operations.has(answer.operationId)) throw new Unknown('Duplicate operation in answer population'); + operations.add(answer.operationId); + if (answer.comment !== '') comments.push({text: answer.comment, actor: answer.actor, recordedAt: answer.recordedAt, receiptIri: answer.receiptIri}); + if (answer.action !== 'comment') decisions.push(answer); + } + if (decisions.length > 1) throw new Unknown('Competing decisions'); + const answer = decisions[0]; + return {state: !answer ? 'WAITING' : answer.action === 'later' ? 'LATER' : 'ANSWERED', answer: answer ?? null, comments}; +} diff --git a/web/deck/index.html b/web/deck/index.html new file mode 100644 index 0000000..efd3fc2 --- /dev/null +++ b/web/deck/index.html @@ -0,0 +1,5 @@ + + + +Decision request · Rikuy +

Loading source preview…

diff --git a/web/deck/model.mjs b/web/deck/model.mjs new file mode 100644 index 0000000..f1f98a2 --- /dev/null +++ b/web/deck/model.mjs @@ -0,0 +1,86 @@ +import {Unknown, NS, RDF_TYPE, exact, text, integer, iri, typed, str, ref, int, hexId, utf8} from './value.mjs'; +export function scope(value) { + exact(value, ['requestGraph', 'answerGraph', 'itemGraph', 'itemPrefix', 'revisionPredicate', 'statusPredicate', 'openStatus']); + const result = Object.fromEntries(Object.entries(value).map(([k, v]) => [k, iri(v)])); + if (!result.itemPrefix.endsWith('/') || new Set([result.requestGraph, result.answerGraph, result.itemGraph]).size !== 3 || result.revisionPredicate === result.statusPredicate) throw new Unknown('Distinct graph and item slots required'); + return result; +} +export function request(value, boundary) { + scope(boundary); + exact(value, ['id', 'revision', 'seed', 'itemRevision', 'title', 'options', 'actions', 'expiresAt']); + const result = {id: iri(value.id), revision: integer(value.revision), seed: iri(value.seed), itemRevision: integer(value.itemRevision), title: text(value.title, 4096, true)}; + if (result.id.includes('#') || !result.seed.startsWith(boundary.itemPrefix) || result.seed === boundary.itemPrefix) throw new Unknown('Foreign request or item'); + if (!Array.isArray(value.options) || value.options.length > 8 || new Set(value.options).size !== value.options.length) throw new Unknown('Invalid options'); + result.options = value.options.map(v => text(v, 256)); + if (!Array.isArray(value.actions) || !value.actions.length || value.actions.length > 3 || new Set(value.actions).size !== value.actions.length || value.actions.some(v => !['choose', 'later', 'comment'].includes(v)) || value.actions.includes('choose') && !result.options.length) throw new Unknown('Invalid declared actions'); + result.actions = [...value.actions]; result.expiresAt = integer(value.expiresAt); + return result; +} +export const revisionIri = value => `${value.id}#revision-${value.revision}`; +export const operationIri = id => `urn:rikuy:operation:${hexId(id)}`; +export function requestPublication(value, boundary) { + const r = request(value, boundary), manifest = JSON.stringify(r); + if (utf8(manifest).length > 16384) throw new Unknown('Curated request too large'); + return {graph: boundary.requestGraph, datums: [ + {s: r.id, p: NS + 'currentRevision', o: int(r.revision)}, + {s: revisionIri(r), p: NS + 'requestManifest', o: {kind: 'str', value: manifest}} + ]}; +} +export function readRequest(row, boundary) { + scope(boundary); + exact(row, ['graph', 'requestId', 'currentRevision', 'revisionIri', 'manifest', 'complete']); + if (row.graph !== boundary.requestGraph || row.complete !== true || typeof row.manifest !== 'string' || utf8(row.manifest).length > 16384) throw new Unknown('Incomplete curated request'); + let value; try {value = JSON.parse(row.manifest);} catch {throw new Unknown('Malformed curated request');} + const r = request(value, boundary); + if (JSON.stringify(r) !== row.manifest || r.id !== row.requestId || r.revision !== row.currentRevision || revisionIri(r) !== row.revisionIri) throw new Unknown('Foreign or ambiguous request revision'); + return r; +} +export function intent(value, ask) { + exact(value, ['operationId', 'action', 'optionLabel', 'comment']); + const result = {operationId: hexId(value.operationId), action: value.action, optionLabel: value.optionLabel, comment: value.comment}; + if (!ask.actions.includes(result.action) || typeof result.comment !== 'string') throw new Unknown('Undeclared action'); + if (result.comment !== '') text(result.comment, 4096, true); + if (result.action === 'choose' ? !ask.options.includes(result.optionLabel) : result.optionLabel !== null) throw new Unknown('Undeclared option'); + if (result.action === 'comment' && !result.comment.trim()) throw new Unknown('Comment required'); + return result; +} +export function proposal(value) { + exact(value, ['signer', 'graph', 'datums', 'expect']); + const result = {signer: text(value.signer, 1024), graph: iri(value.graph)}; + if (!Array.isArray(value.datums) || !value.datums.length || value.datums.length > 64 || !Array.isArray(value.expect) || value.expect.length > 16) throw new Unknown('Proposal bounds exceeded'); + result.datums = value.datums.map(d => { exact(d, ['s', 'p', 'o']); return {s: iri(d.s), p: iri(d.p), o: typed(d.o)}; }); + result.expect = value.expect.map(e => { exact(e, ['graph', 's', 'p', 'o']); return {graph: iri(e.graph), s: iri(e.s), p: iri(e.p), o: e.o === null ? null : typed(e.o)}; }); + for (const [rows, key] of [[result.datums, d => JSON.stringify(d)], [result.expect, e => JSON.stringify([e.graph, e.s, e.p])]]) if (new Set(rows.map(key)).size !== rows.length) throw new Unknown('Duplicate proposal slot or datum'); + if (utf8(JSON.stringify(result)).length > 32768) throw new Unknown('Proposal too large'); + return result; +} +export const canonicalProposal = value => JSON.stringify(proposal(value)); +export function decodeProposal(raw) { + if (typeof raw !== 'string' || utf8(raw).length > 32768) throw new Unknown('Invalid manifest size'); + let value; try { value = JSON.parse(raw); } catch { throw new Unknown('Invalid manifest JSON'); } + const normalized = proposal(value); + if (JSON.stringify(normalized) !== raw) throw new Unknown('Noncanonical or duplicate manifest field'); + return normalized; +} +export function buildProposal({boundary, ask, freshItem, signer, action, now}) { + const b = scope(boundary), r = request(ask, b), a = intent(action, r); + exact(freshItem, ['graph', 'seed', 'revision', 'status']); + if (freshItem.graph !== b.itemGraph || freshItem.seed !== r.seed || freshItem.revision !== r.itemRevision || freshItem.status !== b.openStatus || !Number.isSafeInteger(now) || now >= r.expiresAt) throw new Unknown('Changed, closed or expired request'); + const op = operationIri(a.operationId), rev = revisionIri(r); + const datums = [ + {s: op, p: RDF_TYPE, o: ref(NS + (a.action === 'comment' ? 'Comment' : 'Decision'))}, + {s: op, p: NS + 'request', o: ref(r.id)}, {s: op, p: NS + 'revision', o: int(r.revision)}, + {s: op, p: NS + 'seed', o: ref(r.seed)}, {s: op, p: NS + 'action', o: str(a.action)}, + {s: op, p: NS + 'operationId', o: str(a.operationId)} + ]; + if (a.optionLabel !== null) datums.push({s: op, p: NS + 'optionLabel', o: str(a.optionLabel)}); + if (a.comment !== '') datums.push({s: op, p: NS + 'comment', o: str(a.comment)}); + if (a.action !== 'comment') datums.push({s: rev, p: NS + 'decided', o: ref(op)}); + return proposal({signer, graph: b.answerGraph, datums, expect: [ + {graph: b.requestGraph, s: r.id, p: NS + 'currentRevision', o: int(r.revision)}, + {graph: b.answerGraph, s: rev, p: NS + 'decided', o: null}, + {graph: b.itemGraph, s: r.seed, p: b.revisionPredicate, o: int(r.itemRevision)}, + {graph: b.itemGraph, s: r.seed, p: b.statusPredicate, o: ref(b.openStatus)}, + {graph: b.answerGraph, s: op, p: RDF_TYPE, o: null} + ]}); +} diff --git a/web/deck/pending.mjs b/web/deck/pending.mjs new file mode 100644 index 0000000..3b204d6 --- /dev/null +++ b/web/deck/pending.mjs @@ -0,0 +1,62 @@ +import {Unknown, exact, integer} from './value.mjs'; +import {canonicalProposal, proposal} from './model.mjs'; +import {admittedChallenge, identity, receiptProof, readbackProof} from './evidence.mjs'; + +// An online nonce is committed at most once. Persistence and the trusted +// transport are injected; no endpoint, bearer or writer is selected here. +export class PendingWrite { + constructor({identity: who, store, transport, sign, now = () => Math.floor(Date.now() / 1000)}) { + Object.assign(this, {identity: identity(who), store, transport, sign, now}); + this.busy = false; + } + async submit(expected, notAfter) { + if (this.busy) throw new Unknown('An intent is already active'); + this.busy = true; + try { + const before = await this.store.get(); + if (before) throw new Unknown('Reconcile the retained intent before another submission'); + const p = proposal(expected); + integer(notAfter); + const issued = await this.transport.challenge(p); + const admitted = await admittedChallenge(issued, p, this.identity, this.now()); + if (admitted.challenge.expires_at > notAfter) throw new Unknown('Server challenge would outlive the request'); + // Durable custody precedes the authenticator and the one commit call. + const record = {proposal: p, nonce: admitted.challenge.nonce, contentHash: admitted.contentHash, notAfter, state: 'PENDING'}; + await this.store.insert(record); + let assertion; + try { assertion = await this.sign(admitted); } + catch { return record; } // No commit; explicit operator reconciliation required. + exact(assertion, ['client_data_json', 'authenticator_data', 'signature']); + await this.store.change(record.nonce, {state: 'UNKNOWN'}); + try { await this.transport.commit({nonce: record.nonce, ...assertion}); } + catch { /* A lost response proves neither acceptance nor refusal. */ } + return await this.reconcile(); + } finally { this.busy = false; } + } + async reconcile() { + const record = await this.store.get(); + if (!record) return null; + if (canonicalProposal(record.proposal) !== JSON.stringify(record.proposal)) throw new Unknown('Changed retained intent'); + let proof; + try { + proof = await receiptProof(await this.transport.receipt({nonce: record.nonce}), record.proposal, this.identity, record.nonce); + if (proof.contentHash !== record.contentHash) throw new Unknown('Changed retained content hash'); + if (proof.expiresAt > integer(record.notAfter)) throw new Unknown('Receipt exceeds retained request deadline'); + readbackProof(await this.transport.readback(proof), proof); + } catch { return {...record, state: 'UNKNOWN'}; } + // The persistence adapter must compare the nonce atomically. Unknown, + // absent, malformed and foreign receipts never clear pending custody. + await this.store.change(record.nonce, {state: 'ACCEPTED', proof}); + return {...record, state: 'ACCEPTED', proof}; + } + async acknowledge() { + const record = await this.store.get(); + if (record?.state !== 'ACCEPTED') throw new Unknown('Verified receipt and fact read-back required'); + await this.store.remove(record.nonce, 'ACCEPTED'); + } + async cancelUnsigned() { + const record = await this.store.get(); + if (record?.state !== 'PENDING') throw new Unknown('A possibly submitted intent cannot be discarded'); + await this.store.remove(record.nonce, 'PENDING'); + } +} diff --git a/web/deck/sign.mjs b/web/deck/sign.mjs new file mode 100644 index 0000000..506a531 --- /dev/null +++ b/web/deck/sign.mjs @@ -0,0 +1,18 @@ +import {Unknown, base64url, unbase64url} from './value.mjs'; +import {identity} from './evidence.mjs'; + +export function webauthnSigner(who, environment) { + const registered = identity(who); + return async admitted => { + if (environment.secure !== true || environment.origin !== registered.origin || typeof environment.credentials?.get !== 'function') throw new Unknown('Enrolled secure-origin authenticator unavailable'); + const credential = await environment.credentials.get({publicKey: { + challenge: unbase64url(admitted.webauthnChallenge), rpId: registered.rpId, + allowCredentials: [{id: unbase64url(registered.signer), type: 'public-key'}], + userVerification: 'required', timeout: 60000 + }}); + if (!credential || credential.type !== 'public-key' || base64url(credential.rawId) !== registered.signer) throw new Unknown('Enrolled credential not returned'); + const response = credential.response; + for (const [name, max] of [['authenticatorData', 16384], ['clientDataJSON', 16384], ['signature', 512]]) if (!(response?.[name] instanceof ArrayBuffer) || !response[name].byteLength || response[name].byteLength > max) throw new Unknown('Invalid authenticator export'); + return {client_data_json: base64url(response.clientDataJSON), authenticator_data: base64url(response.authenticatorData), signature: base64url(response.signature)}; + }; +} diff --git a/web/deck/storage.mjs b/web/deck/storage.mjs new file mode 100644 index 0000000..4d26864 --- /dev/null +++ b/web/deck/storage.mjs @@ -0,0 +1,60 @@ +import {Unknown, exact, integer, hexId, sha256, utf8} from './value.mjs'; +import {proposal, scope} from './model.mjs'; +import {identity} from './evidence.mjs'; + +// Separate database/domain from the offline outbox. One retained online +// intent per operator/store/graph scope; never fall back to memory storage. +export class PendingStore { + constructor(db) { this.db = db; } + static async open(who, boundary, indexedDB = globalThis.indexedDB) { + if (!indexedDB) throw new Unknown('Durable online intent storage unavailable'); + const name = 'rikuy-online-intent-v1-' + await sha256(utf8(JSON.stringify([identity(who), scope(boundary)]))); + const db = await new Promise((resolve, reject) => { + const request = indexedDB.open(name, 1); + request.onupgradeneeded = () => request.result.createObjectStore('pending'); + request.onerror = () => reject(request.error); + request.onblocked = () => reject(new Unknown('Online intent storage blocked')); + request.onsuccess = () => resolve(request.result); + }); + return new PendingStore(db); + } + operation(mode, fn) { + return new Promise((resolve, reject) => { + const tx = this.db.transaction('pending', mode), store = tx.objectStore('pending'); let result; + tx.oncomplete = () => resolve(structuredClone(result)); + tx.onabort = () => reject(tx.error || new Unknown('Concurrent online intent change')); + tx.onerror = () => {}; + const get = store.get('intent'); + get.onsuccess = () => { + try {result = fn(get.result ?? null, store, tx);} + catch (error) {tx.abort(); reject(error);} + }; + }); + } + get() {return this.operation('readonly', value => value);} + insert(record) { + exact(record, ['proposal', 'nonce', 'contentHash', 'notAfter', 'state']); + const p = proposal(record.proposal); hexId(record.nonce); integer(record.notAfter); + if (record.state !== 'PENDING' || !/^sha256:[0-9a-f]{64}$/.test(record.contentHash)) throw new Unknown('Invalid initial intent'); + return this.operation('readwrite', (old, store) => { + if (old) throw new Unknown('An intent already has durable custody'); + const value = {...record, proposal: p}; store.add(structuredClone(value), 'intent'); return value; + }); + } + change(nonce, patch) { + if (!patch || Object.keys(patch).some(k => !['state', 'proof'].includes(k)) || !['UNKNOWN', 'ACCEPTED'].includes(patch.state)) throw new Unknown('Immutable online intent'); + return this.operation('readwrite', (old, store) => { + if (!old || old.nonce !== nonce || old.state === 'ACCEPTED' && patch.state !== 'ACCEPTED') throw new Unknown('Concurrent online intent change'); + const value = {...old, ...structuredClone(patch)}; + if (utf8(JSON.stringify(value)).length > 131072) throw new Unknown('Online intent storage bound exceeded'); + store.put(value, 'intent'); return value; + }); + } + remove(nonce, state) { + return this.operation('readwrite', (old, store) => { + if (!old || old.nonce !== nonce || old.state !== state || !['PENDING', 'ACCEPTED'].includes(state)) throw new Unknown('A possibly submitted intent cannot be discarded'); + store.delete('intent'); return null; + }); + } + close() {this.db.close();} +} diff --git a/web/deck/style.css b/web/deck/style.css new file mode 100644 index 0000000..903b791 --- /dev/null +++ b/web/deck/style.css @@ -0,0 +1,7 @@ +:root { color-scheme: light dark; font-family: system-ui, sans-serif; } +body { margin: 2rem auto; max-width: 44rem; padding: 0 1rem; } +h1 { line-height: 1.3; font-size: 1.6rem; } +textarea { display: block; box-sizing: border-box; width: 100%; min-height: 6rem; margin: 1rem 0; } +button { padding: .6rem 1rem; margin: .3rem; } +[role="status"] { padding: 1rem; border: 1px solid currentColor; border-radius: .4rem; } +section > p { white-space: pre-wrap; overflow-wrap: anywhere; } diff --git a/web/deck/value.mjs b/web/deck/value.mjs new file mode 100644 index 0000000..efa07c6 --- /dev/null +++ b/web/deck/value.mjs @@ -0,0 +1,49 @@ +// Bounded online attested-write values. This is not the offline wire protocol. +export class Unknown extends Error {} +export const NS = 'https://intigraph.github.io/rikuy/ns#'; +export const RDF_TYPE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type'; +export const utf8 = value => new TextEncoder().encode(value); +export function exact(value, keys) { + if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).sort().join('|') !== [...keys].sort().join('|')) throw new Unknown('Unknown or missing field'); +} +export function text(value, max = 4096, multiline = false) { + if (typeof value !== 'string' || !value.length || utf8(value).length > max || /\p{Surrogate}/u.test(value) || (multiline ? /[\u0000-\u0008\u000b\u000c\u000e-\u001f]/u : /[\u0000-\u001f]/u).test(value)) throw new Unknown('Invalid bounded text'); + return value; +} +export function integer(value) { + if (!Number.isSafeInteger(value) || value < 1) throw new Unknown('Positive exact integer required'); + return value; +} +export function iri(value) { + text(value); + let url; + try { url = new URL(value); } catch { throw new Unknown('Absolute IRI required'); } + if (!['https:', 'http:', 'urn:'].includes(url.protocol) || url.username || url.password || /[<>"{}|^`\\\s]/u.test(value)) throw new Unknown('Unsupported IRI'); + return value; +} +export function typed(value) { + exact(value, ['kind', 'value']); + if (value.kind === 'int') { + if (!Number.isSafeInteger(value.value)) throw new Unknown('Inexact integer'); + } else if (value.kind === 'str') text(value.value, 4096, true); + else if (value.kind === 'iri') iri(value.value); + else throw new Unknown('Unknown value kind'); + return {kind: value.kind, value: value.value}; +} +export const str = value => typed({kind: 'str', value}); +export const ref = value => typed({kind: 'iri', value}); +export const int = value => typed({kind: 'int', value}); +export function base64url(bytes) { + return btoa(Array.from(new Uint8Array(bytes), n => String.fromCharCode(n)).join('')).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, ''); +} +export function unbase64url(value) { + if (typeof value !== 'string' || !value.length || value.length > 65536 || !/^[A-Za-z0-9_-]+$/.test(value)) throw new Unknown('Invalid base64url'); + const bytes = Uint8Array.from(atob(value.replaceAll('-', '+').replaceAll('_', '/')), c => c.charCodeAt(0)); + if (base64url(bytes) !== value) throw new Unknown('Noncanonical base64url'); + return bytes; +} +export const sha256 = async bytes => Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)), n => n.toString(16).padStart(2, '0')).join(''); +export function hexId(value) { + if (typeof value !== 'string' || !/^[0-9a-f]{64}$/.test(value)) throw new Unknown('Exact operation identifier required'); + return value; +} diff --git a/web/deck/view.mjs b/web/deck/view.mjs new file mode 100644 index 0000000..a557081 --- /dev/null +++ b/web/deck/view.mjs @@ -0,0 +1,79 @@ +import {Unknown, NS, sha256} from './value.mjs'; +import {scope, readRequest, buildProposal} from './model.mjs'; +import {identity, readAnswer, disposition} from './evidence.mjs'; + +// The deployment supplies bounded admitted readers and a durable PendingWrite. +// No endpoint, raw query, credential, enrollment or automatic retry is here. +export async function mount(root, {enabled = false, boundary, identity: who, read, writer, now = () => Math.floor(Date.now() / 1000)} = {}) { + const document = root.ownerDocument; + const node = (tag, text) => {const n = document.createElement(tag); if (text !== undefined) n.textContent = text; return n;}; + const status = node('p', 'Preview · signing is not configured.'), body = node('section'); + status.setAttribute('role', 'status'); status.setAttribute('aria-live', 'polite'); + root.replaceChildren(status, body); + if (enabled !== true) return; + const b = scope(boundary), operator = identity(who); + if (typeof read !== 'function' || !writer || JSON.stringify(identity(writer.identity)) !== JSON.stringify(operator)) throw new Unknown('Admitted reader and matching durable writer required'); + let bundle, r, admitted = [], busy = false; + const pending = () => writer.store.get(); + const disable = () => body.querySelectorAll('button').forEach(button => button.disabled = true); + const settle = async result => { + if (result?.state !== 'ACCEPTED') {status.textContent = 'Pending · receipt or read-back UNKNOWN. Use Recover receipt; do not sign again.'; return;} + await refresh(); + const op = result.proposal.datums.find(d => d.p === NS + 'operationId')?.o.value; + if (!admitted.some(a => a.operationId === op && a.receiptIri === result.proof.receiptIri)) throw new Unknown('Accepted intent is not in the current reader view'); + await writer.acknowledge(); await refresh(); + }; + const run = async operation => { + if (busy) return; + busy = true; root.setAttribute('aria-busy', 'true'); disable(); + try {await operation();} + catch {status.textContent = 'UNKNOWN · retained intent and source evidence need reconciliation.';} + finally { + busy = false; + try {await controls();} catch {disable(); status.textContent = 'UNKNOWN · durable intent storage unavailable.';} + root.setAttribute('aria-busy', 'false'); + } + }; + async function controls() { + const record = await pending(); + body.querySelectorAll('[data-action]').forEach(button => button.disabled = busy || !!record || body.dataset.state !== 'WAITING'); + const recover = body.querySelector('[data-recover]'); if (recover) recover.disabled = busy || !record; + const cancel = body.querySelector('[data-cancel]'); if (cancel) cancel.disabled = busy || record?.state !== 'PENDING'; + } + async function refresh() { + const next = await read(); + if (next?.complete !== true || !Array.isArray(next.rows) || next.rows.length > 100) throw new Unknown('Incomplete request/answer read'); + const nextRequest = readRequest(next.requestRow, b); + const answers = await Promise.all(next.rows.map(({row, receipt}) => readAnswer(row, receipt, nextRequest, b, operator))); + const view = disposition(answers, nextRequest, b); + bundle = next; r = nextRequest; admitted = answers; + body.replaceChildren(); body.dataset.state = view.state; + body.append(node('h1', r.title), node('p', `Revision ${r.revision} · ${view.state}`)); + const note = node('textarea'); note.maxLength = 4096; note.setAttribute('aria-label', 'Comment'); + body.append(note); + const addAction = (label, action, optionLabel = null) => { + const button = node('button', label); button.dataset.action = action; button.disabled = true; + button.addEventListener('click', () => run(async () => { + const bytes = new Uint8Array(32); crypto.getRandomValues(bytes); + const operationId = await sha256(bytes); + const p = buildProposal({boundary: b, ask: r, freshItem: bundle.freshItem, signer: operator.signer, action: {operationId, action, optionLabel, comment: note.value}, now: now()}); + status.textContent = 'Waiting for your authenticator. A lost response stays pending.'; + await settle(await writer.submit(p, r.expiresAt)); + })); + body.append(button); + }; + if (r.actions.includes('choose')) for (const choice of r.options) addAction(choice, 'choose', choice); + if (r.actions.includes('later')) addAction('Later', 'later'); + if (r.actions.includes('comment')) addAction('Add comment', 'comment'); + const recover = node('button', 'Recover receipt'); recover.dataset.recover = ''; recover.disabled = true; + recover.addEventListener('click', () => run(async () => settle(await writer.reconcile()))); + const cancel = node('button', 'Cancel unsigned attempt'); cancel.dataset.cancel = ''; cancel.disabled = true; + cancel.addEventListener('click', () => run(async () => {await writer.cancelUnsigned(); await refresh();})); + body.append(recover, cancel); + for (const comment of view.comments) body.append(node('p', `${comment.actor} · ${new Date(comment.recordedAt * 1000).toISOString()}: ${comment.text}`)); + status.textContent = view.state === 'WAITING' ? 'Current request · no admitted decision.' : view.state === 'LATER' ? 'Later · retained in the visible Later state.' : `Answered · ${view.answer.actor} · ${new Date(view.answer.recordedAt * 1000).toISOString()}`; + await controls(); + } + try {await refresh();} + catch {body.replaceChildren(); status.textContent = 'UNKNOWN · request or verified answer evidence unavailable.';} +}