diff --git a/.env.example b/.env.example index 0a16741..1748165 100644 --- a/.env.example +++ b/.env.example @@ -64,6 +64,5 @@ VITE_WALLETCONNECT_PROJECT_ID= VITE_PROOF_WORKER_URL=https://proofkey-relay.onrender.com VITE_SEPOLIA_EXPLORER_URL=https://sepolia.etherscan.io VITE_CREDITCOIN_EXPLORER_URL=https://creditcoin-testnet.blockscout.com -VITE_DEVICE_SIMULATOR_URL=http://localhost:4174 VITE_DEMO_MACHINE_NAME=Industrial Excavator VITE_DEMO_MACHINE_LOCATION=Lagos Demo Yard · Bay 04 diff --git a/README.md b/README.md index ea9d41c..60ebe47 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,9 @@ ProofKey makes the source-chain receipt the authority: 6. Creditcoin's Native Query Verifier at `0x0FD2` verifies the proof. 7. `ProofKeyASC` decodes the proven receipt, validates every payment invariant, rejects replay, and atomically issues an expiring `AccessPass`. 8. The machine reads `AccessPass.isAuthorized` directly from Creditcoin and fails closed on expiry, deactivation, or RPC failure. +9. The customer creates a two-minute, one-use QR handoff bound to the machine, payer, order, and a random nonce; it never contains a private key. +10. The public `/device/:machineId` terminal claims that nonce once, continuously rechecks Creditcoin, and enables output only after the registered controller signs a start receipt. +11. A controller-signed end receipt seals the measured duration. My Rentals verifies both signatures locally against the live controller address. The worker pays gas and provides liveness. It cannot forge a payment, choose a beneficiary, alter a tariff, extend access, or bypass Attestcoin verification. @@ -152,11 +155,11 @@ npm run serve --workspace @proofkey/worker # Customer payment and proof journey npm run dev --workspace @proofkey/web -# Fail-closed machine simulator +# Optional standalone fail-closed diagnostic client npm run dev --workspace @proofkey/device ``` -The customer UI connects a wallet, switches to Sepolia, calculates exact token units without floating-point arithmetic, approves the payment token, settles usage, queues the Attestcoin relay, displays every proof phase, and reveals access only after Creditcoin execution succeeds. +The customer UI connects a wallet, switches to Sepolia, calculates exact token units without floating-point arithmetic, approves the payment token, settles usage, queues the Attestcoin relay, displays every proof phase, and reveals access only after Creditcoin execution succeeds. The same Vercel deployment serves the customer session page and independent `/device/:machineId` terminal, so QR links never point at localhost. ## Verify the recorded result @@ -186,14 +189,14 @@ npm run check The gate runs formatting, TypeScript checks, all automated tests, Solidity compilation, and production builds. -| Suite | Tests | Coverage focus | -| ---------------- | -----: | -------------------------------------------------------------------------------------------------- | -| Solidity | 52 | Receipt semantics, proof tampering, replay, authorization, pricing, ownership, expiry, reentrancy | -| Relay worker | 16 | Leases, restart recovery, retries, idempotency, CORS, rate limits, readiness, secret-safe evidence | -| Customer web | 19 | Proof state, route helpers, relay URL safety, exact token math, wallet lifecycle and errors | -| Wallet browser | 1 | EIP-6963 production connect button and prompt-free persisted reconnect | -| Device simulator | 6 | Locked/unlocking/unlocked/expired states, tampered results, RPC failure | -| **Total** | **94** | | +| Suite | Tests | Coverage focus | +| ---------------- | ------: | ------------------------------------------------------------------------------------------------------ | +| Solidity | 52 | Receipt semantics, proof tampering, replay, authorization, pricing, ownership, expiry, reentrancy | +| Relay worker | 23 | Leases, restart recovery, one-time handoffs, receipt signatures, CORS, readiness, secret-safe evidence | +| Customer web | 48 | Proof state, exact token math, operator workflow, session state machine, receipt tampering and expiry | +| Product browser | 7 | Multi-page rental, operator, proof, two-browser QR handoff, signed usage, and replay rejection | +| Device simulator | 6 | Locked/unlocking/unlocked/expired states, tampered results, RPC failure | +| **Total** | **136** | | The Solidity suite uses explicit verifier doubles at `0x0FD2` to isolate adversarial proof cases. Those tests are distinct from the committed live CC3 transaction, which executed against Creditcoin's real Native Query Verifier. diff --git a/apps/device/README.md b/apps/device/README.md index 3ec2445..615d2d0 100644 --- a/apps/device/README.md +++ b/apps/device/README.md @@ -1,6 +1,6 @@ # ProofKey machine simulator -This standalone browser view visualizes one physical machine and reads its authorization directly from `AccessPass` and `MachineRegistry` on Creditcoin CC3 testnet. It does not accept an unlock command from the worker or a private backend. +This standalone browser view is retained as a low-level authorization diagnostic. The production product integrates the machine experience at the public `/device/:machineId?handoff=:nonce` route in `@proofkey/web`, including one-time QR claims, continuous Creditcoin checks, and controller-signed usage receipts. Neither client accepts an unlock command from the relay. Copy `.env.example` to the repository root, supply the five `VITE_*` values, then run: diff --git a/apps/web/README.md b/apps/web/README.md index c9b7ea8..2b92cc3 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -15,6 +15,8 @@ For mobile connections, create a project in [Reown Cloud](https://cloud.reown.co The user chooses an EIP-6963 browser wallet, Coinbase Wallet, or WalletConnect mobile wallet; switches to Sepolia when prompted; chooses a duration; approves the payment token if necessary; and confirms `payForUsage`. The approved session reconnects silently after refresh and never requests a signature until the user starts a transaction. The app automatically submits the confirmed transaction hash to the worker and follows every proof phase through Creditcoin execution. It never exposes the worker wallet key to the browser. +After access activates, `/sessions/:sourceTransactionHash` creates a one-time QR for the public `/device/:machineId` route. The device claims it once, reads `AccessPass` and `MachineRegistry` directly from CC3 every four seconds, and fails closed on any mismatch or RPC failure. The registered controller signs the start and end receipts; the customer session and My Rentals screens recover them from the relay and verify the signatures locally. + Run wallet state and browser-fixture tests with: ```bash @@ -22,4 +24,4 @@ npm test --workspace @proofkey/web npm run test:e2e --workspace @proofkey/web ``` -For a side-by-side recording, also run `npm run dev --workspace @proofkey/device`. +For a side-by-side flow, open the generated device link in a second browser or private window. The separate `@proofkey/device` application remains available only as a low-level authorization diagnostic. diff --git a/apps/web/e2e/marketplace-checkout.spec.ts b/apps/web/e2e/marketplace-checkout.spec.ts index a039336..b3119c6 100644 --- a/apps/web/e2e/marketplace-checkout.spec.ts +++ b/apps/web/e2e/marketplace-checkout.spec.ts @@ -1,9 +1,14 @@ import { expect, test, type Page } from '@playwright/test'; -import { Interface, keccak256, toUtf8Bytes } from 'ethers'; +import { Interface, Wallet, hexlify, keccak256, toUtf8Bytes } from 'ethers'; +import { + usageReceiptMessage, + type UsageReceiptPayload, +} from '../src/device-session.js'; const machineId = '0xc04beae61beb9471c4f24c8788a4624988d2948a5c3d3dd0b6ba1b7602875bcc'; -const owner = '0x1114eeafeb92b71babf860e64e4575433a734b6a'; +const controllerWallet = new Wallet(`0x${'01'.repeat(32)}`); +const owner = controllerWallet.address.toLowerCase(); const tokenAddress = '0x0000000000000000000000000000000000000003'; const metadataHash = '0x24f58d3fcaa80aa0cbe4c88b0ce7d4a23312fa95ca201300a7313894970e883e'; @@ -47,7 +52,7 @@ test('Explore, machine detail, and checkout form one verified journey', async ({ await expect(page.getByText('22-ton operating capacity')).toBeVisible(); const bookingLink = page.getByRole('link', { name: /Book machine time/ }); await expect(bookingLink).toHaveAttribute('href', `/rent/${machineId}`); - await page.goto(`/rent/${machineId}`); + await bookingLink.click(); await expect(page).toHaveURL(new RegExp(`/rent/${machineId}$`)); await expect( page.getByRole('heading', { name: 'Choose operating time' }), @@ -66,11 +71,13 @@ test('checkout fails closed when registry RPC is unavailable', async ({ await page.route('https://**.rpc.proofkey.invalid/**', (route) => route.abort(), ); - await page.goto(`/rent/${machineId}`); + await page.goto(`/rent/${machineId}`, { waitUntil: 'domcontentloaded' }); await expect( page.getByText('Checkout cannot verify the machine'), - ).toBeVisible(); - await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible(); + ).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible({ + timeout: 15_000, + }); }); test('a fresh connected browser recovers active access from chain and relay state', async ({ @@ -102,7 +109,7 @@ test('a fresh connected browser recovers active access from chain and relay stat await expect(page.getByText('Machine access active')).toBeVisible({ timeout: 15_000, }); - await expect(page.getByRole('link', { name: /Open access/ })).toBeVisible(); + await expect(page.getByRole('link', { name: /Start session/ })).toBeVisible(); await expect( page.getByRole('button', { name: 'Download receipt JSON' }), ).toBeVisible(); @@ -224,6 +231,154 @@ test('operator metadata upload fails closed then resumes the confirmed onboardin }); }); +test('customer and device browsers complete a one-time signed machine session', async ({ + browser, +}) => { + const nonce = 'a7'.repeat(32); + const sourceHash = `0x${'fa'.repeat(32)}`; + const startedAt = '1970-01-01T00:33:20.000Z'; + const sessionId = keccak256(toUtf8Bytes(`proofkey-device-session:${nonce}`)); + const startPayload: UsageReceiptPayload = { + schema: 'proofkey.usage-receipt.v1', + kind: 'start', + sessionId, + machineId, + payer: controllerWallet.address, + orderId, + nonce, + controller: controllerWallet.address, + startedAt, + endedAt: null, + measuredDurationSeconds: 0, + accessExpiresAt: '2500', + }; + const endPayload: UsageReceiptPayload = { + ...startPayload, + kind: 'end', + endedAt: startedAt, + }; + const signedMessages = { + [hexlify(toUtf8Bytes(usageReceiptMessage(startPayload)))]: + await controllerWallet.signMessage(usageReceiptMessage(startPayload)), + [hexlify(toUtf8Bytes(usageReceiptMessage(endPayload)))]: + await controllerWallet.signMessage(usageReceiptMessage(endPayload)), + }; + let handoff = { + schema: 'proofkey.device-handoff.v1' as const, + nonce, + machineId, + payer: controllerWallet.address, + orderId, + sourceTransactionHash: sourceHash, + accessExpiresAt: '2500', + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 120_000).toISOString(), + claimedAt: undefined as string | undefined, + startReceipt: undefined as + { payload: UsageReceiptPayload; signature: string } | undefined, + endReceipt: undefined as + { payload: UsageReceiptPayload; signature: string } | undefined, + }; + const claimToken = 'c8'.repeat(32); + + const customerContext = await browser.newContext(); + const deviceContext = await browser.newContext(); + const replayContext = await browser.newContext(); + const customer = await customerContext.newPage(); + const device = await deviceContext.newPage(); + const replay = await replayContext.newPage(); + for (const page of [customer, device, replay]) { + await installWallet(page, signedMessages); + await mockMarketplaceRpc(page, true); + await mockDeviceRelay( + page, + () => handoff, + (next) => (handoff = next), + claimToken, + ); + } + + await customer.goto(`/sessions/${sourceHash}`); + await customer.getByRole('button', { name: 'Connect renter wallet' }).click(); + await customer.getByRole('button', { name: /Playwright Wallet/ }).click(); + await customer.getByRole('button', { name: 'Close wallet dialog' }).click(); + await customer.getByRole('button', { name: 'Generate one-time QR' }).click(); + await expect( + customer.getByAltText('One-time device handoff QR code'), + ).toBeVisible({ timeout: 15_000 }); + + const deviceUrl = `/device/${machineId}?handoff=${nonce}&payer=${controllerWallet.address}`; + await replay.goto( + `/device/0x${'99'.repeat(32)}?handoff=${nonce}&payer=${controllerWallet.address}`, + ); + await expect( + replay.getByText( + 'QR is bound to a different machine. Device remains locked.', + ), + ).toBeVisible({ timeout: 15_000 }); + await replay.goto( + `/device/${machineId}?handoff=${nonce}&payer=0x2222222222222222222222222222222222222222`, + ); + await expect( + replay.getByText( + 'QR is bound to a different payer. Device remains locked.', + ), + ).toBeVisible({ timeout: 15_000 }); + + await device.goto(deviceUrl); + await expect(device.getByText('Verified · ready')).toBeVisible({ + timeout: 15_000, + }); + await device + .getByRole('button', { name: 'Connect controller wallet' }) + .click(); + await device.getByRole('button', { name: /Playwright Wallet/ }).click(); + await device.getByRole('button', { name: 'Close wallet dialog' }).click(); + await device + .getByRole('button', { name: /Sign start & enable machine/ }) + .click(); + await expect(device.getByText('Equipment enabled')).toBeVisible({ + timeout: 15_000, + }); + await device.reload(); + await expect(device.getByText('Equipment enabled')).toBeVisible({ + timeout: 15_000, + }); + await expect( + customer.getByRole('heading', { name: 'Machine session active' }), + ).toBeVisible({ timeout: 15_000 }); + + await device + .getByRole('button', { name: /Stop & sign usage receipt/ }) + .click(); + await expect(device.getByText('Session stopped')).toBeVisible({ + timeout: 15_000, + }); + await expect( + customer.getByRole('heading', { name: 'Usage stopped and sealed' }), + ).toBeVisible({ timeout: 15_000 }); + await expect( + customer.getByText('Controller signature verified locally.'), + ).toHaveCount(2); + + await customer.goto('/activity'); + await expect(customer.getByText('Signed usage receipt')).toBeVisible({ + timeout: 15_000, + }); + await expect(customer.getByText('Controller verified locally')).toBeVisible(); + + await replay.goto(deviceUrl); + await expect( + replay.getByText('This QR handoff was already claimed by a device.'), + ).toBeVisible({ timeout: 15_000 }); + + await Promise.all([ + customerContext.close(), + deviceContext.close(), + replayContext.close(), + ]); +}); + async function mockMarketplaceRpc(page: Page, includeUsage = false) { await page.route('https://**.rpc.proofkey.invalid/**', async (route) => { const request = route.request(); @@ -499,9 +654,12 @@ function creditcoinBlock() { }; } -async function installWallet(page: Page) { +async function installWallet( + page: Page, + signedMessages: Record = {}, +) { await page.addInitScript( - ({ approvedAccount }) => { + ({ approvedAccount, signatures }) => { type Listener = (...arguments_: unknown[]) => void; const listeners = new Map>(); let chainId = '0xaa36a7'; @@ -540,6 +698,15 @@ async function installWallet(page: Page) { chainId = (params?.[0] as { chainId: string }).chainId; return null; } + if (method === 'personal_sign') { + const message = String(params?.[0] ?? '').toLowerCase(); + const signature = signatures[message]; + if (!signature) + throw Object.assign(new Error('Unexpected message to sign'), { + code: 4001, + }); + return signature; + } if (method === 'eth_sendTransaction') { if (localStorage.getItem('reject-next-transaction')) { localStorage.removeItem('reject-next-transaction'); @@ -632,10 +799,96 @@ async function installWallet(page: Page) { window.addEventListener('eip6963:requestProvider', announce); queueMicrotask(announce); }, - { approvedAccount: owner }, + { approvedAccount: owner, signatures: signedMessages }, ); } +async function mockDeviceRelay( + page: Page, + read: () => { + schema: 'proofkey.device-handoff.v1'; + nonce: string; + machineId: string; + payer: string; + orderId: string; + sourceTransactionHash: string; + accessExpiresAt: string; + createdAt: string; + expiresAt: string; + claimedAt?: string; + startReceipt?: { payload: UsageReceiptPayload; signature: string }; + endReceipt?: { payload: UsageReceiptPayload; signature: string }; + }, + write: (handoff: ReturnType) => void, + claimToken: string, +) { + await page.route('https://relay.invalid/**', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const current = read(); + if (url.pathname.startsWith('/jobs/')) + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + sourceTransactionHash: current.sourceTransactionHash, + phase: 'completed', + orderId: current.orderId, + machineId: current.machineId, + payer: current.payer, + accessExpiresAt: current.accessExpiresAt, + creditcoinTransactionHash: `0x${'ab'.repeat(32)}`, + }), + }); + if (url.pathname === '/device-handoffs' && request.method() === 'POST') + return route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify(current), + }); + if (url.pathname.endsWith('/claim') && request.method() === 'POST') { + if (current.claimedAt) + return route.fulfill({ + status: 409, + contentType: 'application/json', + body: JSON.stringify({ + error: { + message: 'This QR handoff was already claimed by a device.', + }, + }), + }); + const claimed = { ...current, claimedAt: new Date().toISOString() }; + write(claimed); + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ handoff: claimed, claimToken }), + }); + } + if (url.pathname.endsWith('/receipts') && request.method() === 'POST') { + const receipt = request.postDataJSON() as { + payload: UsageReceiptPayload; + signature: string; + }; + const next = + receipt.payload.kind === 'start' + ? { ...current, startReceipt: receipt } + : { ...current, endReceipt: receipt }; + write(next); + return route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify(next), + }); + } + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(current), + }); + }); +} + function cc3Registration() { return { address: '0x0000000000000000000000000000000000000002', diff --git a/apps/web/package.json b/apps/web/package.json index 8573943..d97deb2 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "tsc -p tsconfig.json --noEmit && vite build", "dev": "vite --host 0.0.0.0", - "test": "tsc -p tsconfig.test.json && node --test dist-test/flow.test.js dist-test/product.test.js dist-test/wallet/state.test.js dist-test/worker.test.js dist-test/marketplace.test.js dist-test/rental.test.js dist-test/activity.test.js dist-test/proof.test.js dist-test/operator.test.js", + "test": "tsc -p tsconfig.test.json && node --test dist-test/flow.test.js dist-test/product.test.js dist-test/wallet/state.test.js dist-test/worker.test.js dist-test/marketplace.test.js dist-test/rental.test.js dist-test/activity.test.js dist-test/proof.test.js dist-test/operator.test.js dist-test/device-session.test.js", "test:e2e": "playwright test", "typecheck": "tsc -p tsconfig.json --noEmit" }, @@ -17,6 +17,7 @@ "@tanstack/react-query": "^5.102.8", "ethers": "6.17.0", "lucide-react": "^1.45.0", + "qrcode": "^1.5.4", "react": "^19.3.0", "react-dom": "^19.3.0", "react-router": "^8.3.1", diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index 51eb657..c28abd2 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -35,7 +35,6 @@ export default defineConfig({ VITE_DEMO_MACHINE_ID: '0xc04beae61beb9471c4f24c8788a4624988d2948a5c3d3dd0b6ba1b7602875bcc', VITE_PROOF_WORKER_URL: 'https://relay.invalid', - VITE_DEVICE_SIMULATOR_URL: 'http://127.0.0.1:1', VITE_WALLETCONNECT_PROJECT_ID: '', }, }, diff --git a/apps/web/src/app/router.tsx b/apps/web/src/app/router.tsx index e491864..c35c078 100644 --- a/apps/web/src/app/router.tsx +++ b/apps/web/src/app/router.tsx @@ -15,6 +15,10 @@ export const router = createBrowserRouter([ }, { path: 'rent/:machineId', lazy: () => import('../pages/RentPage.js') }, { path: 'activity', lazy: () => import('../pages/ActivityPage.js') }, + { + path: 'sessions/:sourceTransactionHash', + lazy: () => import('../pages/SessionPage.js'), + }, { path: 'proofs/:sourceTxHash', lazy: () => import('../pages/ProofPage.js'), diff --git a/apps/web/src/contracts.ts b/apps/web/src/contracts.ts index c1c1cef..5ca993a 100644 --- a/apps/web/src/contracts.ts +++ b/apps/web/src/contracts.ts @@ -42,7 +42,6 @@ export interface AppConfig { workerUrl: string; sepoliaExplorerUrl: string; creditcoinExplorerUrl: string; - deviceUrl: string; machineName: string; machineLocation: string; creditcoinRegistryDeploymentBlock: number; @@ -119,7 +118,6 @@ export function loadAppConfig(environment: ImportMetaEnv): AppConfig { environment.VITE_CREDITCOIN_EXPLORER_URL ?? 'https://creditcoin-testnet.blockscout.com' ).replace(/\/$/, ''), - deviceUrl: environment.VITE_DEVICE_SIMULATOR_URL ?? 'http://localhost:4174', machineName: environment.VITE_DEMO_MACHINE_NAME ?? 'Industrial Excavator', machineLocation: environment.VITE_DEMO_MACHINE_LOCATION ?? 'Lagos Demo Yard · Bay 04', diff --git a/apps/web/src/device-session.test.ts b/apps/web/src/device-session.test.ts new file mode 100644 index 0000000..c122622 --- /dev/null +++ b/apps/web/src/device-session.test.ts @@ -0,0 +1,150 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { Wallet, keccak256, toUtf8Bytes } from 'ethers'; +import { + deriveDeviceSession, + usageReceiptMessage, + verifyUsageReceipt, + type DeviceAuthorization, + type DeviceHandoff, + type SignedUsageReceipt, +} from './device-session.js'; + +const controller = Wallet.createRandom(); +const handoff: DeviceHandoff = { + schema: 'proofkey.device-handoff.v1', + nonce: 'ab'.repeat(32), + machineId: `0x${'12'.repeat(32)}`, + payer: '0x1111111111111111111111111111111111111111', + orderId: `0x${'34'.repeat(32)}`, + sourceTransactionHash: `0x${'56'.repeat(32)}`, + accessExpiresAt: '200', + createdAt: '1970-01-01T00:01:00.000Z', + expiresAt: '1970-01-01T00:02:00.000Z', + claimedAt: '1970-01-01T00:01:01.000Z', +}; +const authorization: DeviceAuthorization = { + authorized: true, + authorizationId: handoff.orderId, + expiresAt: 200n, + machineActive: true, + controller: controller.address, + blockNumber: 42, + blockTimestamp: 100, +}; + +test('session unlocks only for the exact live machine, payer order, and expiry', () => { + assert.equal( + deriveDeviceSession(handoff, undefined, 100).state, + 'awaiting_authorization', + ); + assert.equal( + deriveDeviceSession(handoff, authorization, 100).state, + 'unlocked', + ); + assert.equal( + deriveDeviceSession( + handoff, + { ...authorization, authorizationId: `0x${'ff'.repeat(32)}` }, + 100, + ).state, + 'fail_closed', + ); + assert.equal( + deriveDeviceSession( + handoff, + { ...authorization, machineActive: false }, + 100, + ).state, + 'fail_closed', + ); +}); + +test('active sessions warn before expiry and stop without extending access', () => { + const started: DeviceHandoff = { + ...handoff, + startReceipt: {} as SignedUsageReceipt, + }; + assert.equal( + deriveDeviceSession(started, authorization, 100).state, + 'active', + ); + assert.equal( + deriveDeviceSession(started, authorization, 150).state, + 'expiring', + ); + assert.equal( + deriveDeviceSession(started, authorization, 200).state, + 'expired', + ); + assert.equal( + deriveDeviceSession( + { ...started, endReceipt: {} as SignedUsageReceipt }, + authorization, + 110, + ).state, + 'stopped', + ); +}); + +test('RPC failure immediately fails closed even after a successful read', () => { + assert.equal( + deriveDeviceSession(handoff, authorization, 100, 'RPC unavailable').state, + 'fail_closed', + ); +}); + +test('an invalid stored receipt forces a reconstructed session closed', () => { + const stopped = { + ...handoff, + endReceipt: {} as SignedUsageReceipt, + }; + assert.equal( + deriveDeviceSession( + stopped, + authorization, + 100, + 'invalid receipt signature', + ).state, + 'fail_closed', + ); +}); + +test('controller receipt verifies locally and any bound field tampering fails', async () => { + const payload = { + schema: 'proofkey.usage-receipt.v1' as const, + kind: 'start' as const, + sessionId: keccak256( + toUtf8Bytes(`proofkey-device-session:${handoff.nonce}`), + ), + machineId: handoff.machineId, + payer: handoff.payer, + orderId: handoff.orderId, + nonce: handoff.nonce, + controller: controller.address, + startedAt: '1970-01-01T00:01:40.000Z', + endedAt: null, + measuredDurationSeconds: 0, + accessExpiresAt: handoff.accessExpiresAt, + }; + const receipt: SignedUsageReceipt = { + payload, + signature: await controller.signMessage(usageReceiptMessage(payload)), + }; + assert.equal( + verifyUsageReceipt(receipt, handoff, controller.address).valid, + true, + ); + assert.equal( + verifyUsageReceipt( + { ...receipt, payload: { ...payload, orderId: `0x${'99'.repeat(32)}` } }, + handoff, + controller.address, + ).valid, + false, + ); + assert.equal( + verifyUsageReceipt(receipt, handoff, Wallet.createRandom().address).valid, + false, + ); +}); diff --git a/apps/web/src/device-session.ts b/apps/web/src/device-session.ts new file mode 100644 index 0000000..fea27e4 --- /dev/null +++ b/apps/web/src/device-session.ts @@ -0,0 +1,430 @@ +import { + BrowserProvider, + Contract, + JsonRpcProvider, + getAddress, + isAddress, + isHexString, + keccak256, + toUtf8Bytes, + verifyMessage, + type Eip1193Provider, +} from 'ethers'; +import type { AppConfig } from './contracts.js'; + +const accessPassAbi = [ + 'function isAuthorized(bytes32 machineId,address beneficiary) view returns (bool)', + 'function accessCredentials(bytes32 machineId,address beneficiary) view returns (bytes32 authorizationId,uint64 expiresAt)', +] as const; +const machineRegistryAbi = [ + 'function machines(bytes32 machineId) view returns (address owner,address controller,bytes32 metadataHash,uint128 tariff,bool active)', +] as const; + +export type DeviceSessionState = + | 'awaiting_authorization' + | 'unlocked' + | 'active' + | 'expiring' + | 'expired' + | 'stopped' + | 'fail_closed'; + +export interface UsageReceiptPayload { + schema: 'proofkey.usage-receipt.v1'; + kind: 'start' | 'end'; + sessionId: string; + machineId: string; + payer: string; + orderId: string; + nonce: string; + controller: string; + startedAt: string; + endedAt: string | null; + measuredDurationSeconds: number; + accessExpiresAt: string; +} + +export interface SignedUsageReceipt { + payload: UsageReceiptPayload; + signature: string; +} + +export interface DeviceHandoff { + schema: 'proofkey.device-handoff.v1'; + nonce: string; + machineId: string; + payer: string; + orderId: string; + sourceTransactionHash: string; + accessExpiresAt: string; + createdAt: string; + expiresAt: string; + claimedAt?: string; + startReceipt?: SignedUsageReceipt; + endReceipt?: SignedUsageReceipt; +} + +export interface DeviceAuthorization { + authorized: boolean; + authorizationId: string; + expiresAt: bigint; + machineActive: boolean; + controller: string; + blockNumber: number; + blockTimestamp: number; +} + +export interface DerivedDeviceSession { + state: DeviceSessionState; + reason: string; +} + +export function deriveDeviceSession( + handoff: DeviceHandoff, + authorization: DeviceAuthorization | undefined, + nowSeconds: number, + rpcError?: string, +): DerivedDeviceSession { + if (rpcError) + return { + state: 'fail_closed', + reason: `Creditcoin check failed: ${rpcError}`, + }; + if (handoff.endReceipt) + return { + state: 'stopped', + reason: 'A signed end receipt closed the session.', + }; + if (!authorization) + return { + state: 'awaiting_authorization', + reason: 'Waiting for an independent Creditcoin authorization read.', + }; + const accessExpiry = Number(authorization.expiresAt); + if (accessExpiry <= nowSeconds) + return { + state: 'expired', + reason: 'The AccessPass reached its on-chain expiry.', + }; + if (!authorization.machineActive) + return { + state: 'fail_closed', + reason: 'The machine is inactive on Creditcoin.', + }; + if (!authorization.authorized) + return { + state: 'fail_closed', + reason: 'Creditcoin denied this payer access.', + }; + if ( + authorization.authorizationId.toLowerCase() !== + handoff.orderId.toLowerCase() + ) + return { + state: 'fail_closed', + reason: 'The live AccessPass belongs to a different order.', + }; + if (authorization.expiresAt.toString() !== handoff.accessExpiresAt) + return { + state: 'fail_closed', + reason: 'The handoff expiry does not match the live AccessPass.', + }; + if (!handoff.startReceipt) + return { + state: 'unlocked', + reason: 'Authorization verified. Ready to start.', + }; + if (accessExpiry - nowSeconds <= 60) + return { + state: 'expiring', + reason: 'Session is active but its AccessPass expires within one minute.', + }; + return { + state: 'active', + reason: 'Session active with continuous chain checks.', + }; +} + +export function usageReceiptMessage(payload: UsageReceiptPayload): string { + return [ + 'ProofKey Usage Receipt v1', + `kind=${payload.kind}`, + `sessionId=${payload.sessionId.toLowerCase()}`, + `machineId=${payload.machineId.toLowerCase()}`, + `payer=${payload.payer.toLowerCase()}`, + `orderId=${payload.orderId.toLowerCase()}`, + `nonce=${payload.nonce.toLowerCase()}`, + `controller=${payload.controller.toLowerCase()}`, + `startedAt=${payload.startedAt}`, + `endedAt=${payload.endedAt ?? ''}`, + `measuredDurationSeconds=${payload.measuredDurationSeconds}`, + `accessExpiresAt=${payload.accessExpiresAt}`, + ].join('\n'); +} + +export function verifyUsageReceipt( + receipt: SignedUsageReceipt, + handoff: DeviceHandoff, + registeredController: string, +): { valid: boolean; reason: string; signer?: string } { + try { + const payload = receipt.payload; + if ( + payload.schema !== 'proofkey.usage-receipt.v1' || + !isHexString(payload.sessionId, 32) || + !isAddress(payload.controller) || + payload.machineId.toLowerCase() !== handoff.machineId.toLowerCase() || + payload.payer.toLowerCase() !== handoff.payer.toLowerCase() || + payload.orderId.toLowerCase() !== handoff.orderId.toLowerCase() || + payload.nonce.toLowerCase() !== handoff.nonce.toLowerCase() || + payload.accessExpiresAt !== handoff.accessExpiresAt + ) + return { + valid: false, + reason: 'Receipt fields do not match the handoff.', + }; + const expectedSessionId = keccak256( + toUtf8Bytes(`proofkey-device-session:${handoff.nonce}`), + ); + if (payload.sessionId.toLowerCase() !== expectedSessionId.toLowerCase()) + return { + valid: false, + reason: 'Receipt session ID does not match the handoff nonce.', + }; + const startedAt = Date.parse(payload.startedAt); + const endedAt = payload.endedAt ? Date.parse(payload.endedAt) : undefined; + const accessExpiresAt = Number(payload.accessExpiresAt) * 1_000; + if (!Number.isFinite(startedAt) || startedAt > accessExpiresAt) + return { valid: false, reason: 'Receipt start time is invalid.' }; + if ( + (payload.kind === 'start' && + (payload.endedAt !== null || payload.measuredDurationSeconds !== 0)) || + (payload.kind === 'end' && + (!endedAt || + endedAt < startedAt || + endedAt > accessExpiresAt || + Math.floor((endedAt - startedAt) / 1_000) !== + payload.measuredDurationSeconds || + handoff.startReceipt?.payload.startedAt !== payload.startedAt || + handoff.startReceipt?.payload.sessionId.toLowerCase() !== + payload.sessionId.toLowerCase())) + ) + return { + valid: false, + reason: 'Receipt timing or session binding is invalid.', + }; + const signer = verifyMessage( + usageReceiptMessage(payload), + receipt.signature, + ); + if (getAddress(signer) !== getAddress(registeredController)) + return { + valid: false, + reason: 'Signature is not from the registered machine controller.', + signer, + }; + return { + valid: true, + reason: 'Controller signature verified locally.', + signer, + }; + } catch { + return { + valid: false, + reason: 'Receipt signature or encoding is invalid.', + }; + } +} + +export function deviceClaimStorageKey(nonce: string): string { + return `proofkey:device-claim:${nonce.toLowerCase()}`; +} + +export function customerHandoffStorageKey( + sourceTransactionHash: string, +): string { + return `proofkey:customer-handoff:${sourceTransactionHash.toLowerCase()}`; +} + +export class DeviceHandoffClient { + constructor(private readonly baseUrl: string) {} + + create(sourceTransactionHash: string): Promise { + return this.request('/device-handoffs', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sourceTransactionHash }), + }); + } + + get(nonce: string): Promise { + return this.request(`/device-handoffs/${nonce}`); + } + + claim( + nonce: string, + ): Promise<{ handoff: DeviceHandoff; claimToken: string }> { + return this.request(`/device-handoffs/${nonce}/claim`, { method: 'POST' }); + } + + submitReceipt( + nonce: string, + claimToken: string, + receipt: SignedUsageReceipt, + ): Promise { + return this.request(`/device-handoffs/${nonce}/receipts`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-ProofKey-Claim-Token': claimToken, + }, + body: JSON.stringify(receipt), + }); + } + + private async request(path: string, init?: RequestInit): Promise { + const response = await fetch(`${this.baseUrl}${path}`, init); + const body = (await response.json().catch(() => undefined)) as + T | { error?: { message?: string } } | undefined; + if (!response.ok) + throw new Error( + (body as { error?: { message?: string } } | undefined)?.error + ?.message ?? `Device relay returned HTTP ${response.status}.`, + ); + return body as T; + } +} + +export class CreditcoinDeviceReader { + private readonly provider: JsonRpcProvider; + private readonly accessPass: Contract; + private readonly machines: Contract; + + constructor(private readonly config: AppConfig) { + this.provider = new JsonRpcProvider(config.creditcoinRpcUrl, 102031, { + staticNetwork: true, + }); + this.accessPass = new Contract( + config.accessPassAddress, + accessPassAbi, + this.provider, + ); + this.machines = new Contract( + config.machineRegistryAddress, + machineRegistryAbi, + this.provider, + ); + } + + async read(machineId: string, payer: string): Promise { + const [chainId, authorized, credential, machine, blockNumber] = + await Promise.all([ + this.provider.send('eth_chainId', []), + this.accessPass.getFunction('isAuthorized')(machineId, payer), + this.accessPass.getFunction('accessCredentials')(machineId, payer), + this.machines.getFunction('machines')(machineId), + this.provider.getBlockNumber(), + ]); + if (BigInt(chainId as string) !== 102031n) + throw new Error('The device RPC is not Creditcoin CC3 testnet.'); + const block = await this.provider.getBlock(blockNumber); + if (!block) throw new Error('The latest Creditcoin block is unavailable.'); + return { + authorized: authorized as boolean, + authorizationId: credential.authorizationId as string, + expiresAt: credential.expiresAt as bigint, + machineActive: machine.active as boolean, + controller: getAddress(machine.controller as string), + blockNumber, + blockTimestamp: block.timestamp, + }; + } +} + +export async function signUsageReceipt( + walletProvider: Eip1193Provider, + connectedAccount: string, + handoff: DeviceHandoff, + controller: string, + kind: 'start' | 'end', + chainTimestamp: number, +): Promise { + if (getAddress(connectedAccount) !== getAddress(controller)) + throw new Error( + 'Connect the controller wallet registered for this machine.', + ); + const startPayload = handoff.startReceipt?.payload; + const startedAt = + kind === 'end' && startPayload + ? startPayload.startedAt + : new Date(chainTimestamp * 1_000).toISOString(); + const endedAt = + kind === 'end' + ? new Date( + Math.min(chainTimestamp, Number(handoff.accessExpiresAt)) * 1_000, + ).toISOString() + : null; + const measuredDurationSeconds = endedAt + ? Math.max( + 0, + Math.floor((Date.parse(endedAt) - Date.parse(startedAt)) / 1_000), + ) + : 0; + const payload: UsageReceiptPayload = { + schema: 'proofkey.usage-receipt.v1', + kind, + sessionId: + startPayload?.sessionId ?? + keccak256(toUtf8Bytes(`proofkey-device-session:${handoff.nonce}`)), + machineId: handoff.machineId, + payer: getAddress(handoff.payer), + orderId: handoff.orderId, + nonce: handoff.nonce, + controller: getAddress(controller), + startedAt, + endedAt, + measuredDurationSeconds, + accessExpiresAt: handoff.accessExpiresAt, + }; + const signer = await new BrowserProvider(walletProvider).getSigner( + connectedAccount, + ); + return { + payload, + signature: await signer.signMessage(usageReceiptMessage(payload)), + }; +} + +export async function switchDeviceWalletToCreditcoin( + provider: Eip1193Provider, +): Promise { + const chainId = '0x18e8f'; + try { + await provider.request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId }], + }); + } catch (error) { + const code = (error as { code?: number }).code; + if (code !== 4902) throw error; + await provider.request({ + method: 'wallet_addEthereumChain', + params: [ + { + chainId, + chainName: 'Creditcoin CC3 Testnet', + nativeCurrency: { name: 'Creditcoin', symbol: 'CTC', decimals: 18 }, + rpcUrls: ['https://rpc.cc3-testnet.creditcoin.network'], + blockExplorerUrls: ['https://creditcoin-testnet.blockscout.com'], + }, + ], + }); + await provider.request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId }], + }); + } + const current = await provider.request({ method: 'eth_chainId' }); + if (BigInt(current as string) !== 102031n) + throw new Error('Wallet did not switch to Creditcoin CC3 testnet.'); +} diff --git a/apps/web/src/pages/ActivityPage.tsx b/apps/web/src/pages/ActivityPage.tsx index 157aea0..e18df9a 100644 --- a/apps/web/src/pages/ActivityPage.tsx +++ b/apps/web/src/pages/ActivityPage.tsx @@ -17,6 +17,13 @@ import { type RentalRecord, } from '../activity.js'; import { DataState, PageHeading } from '../components/ProductUI.js'; +import type { AppConfig } from '../contracts.js'; +import { + CreditcoinDeviceReader, + DeviceHandoffClient, + customerHandoffStorageKey, + verifyUsageReceipt, +} from '../device-session.js'; import { compactHash, machinePath, proofPath, rentPath } from '../product.js'; const tabs: Array<{ value: 'all' | RentalLifecycle; label: string }> = [ @@ -287,6 +294,7 @@ function RentalRow({ )} {record.diagnostic && {record.diagnostic}} +
{record.status === 'active' ? ( - - Open access + + Start session ) : record.status === 'expired' ? ( @@ -340,6 +351,60 @@ function RentalRow({ ); } +function RentalReceiptEvidence({ + record, + config, +}: { + record: RentalRecord; + config: AppConfig; +}) { + const nonce = localStorage.getItem( + customerHandoffStorageKey(record.sourceTransactionHash), + ); + const evidence = useQuery({ + queryKey: ['signed-usage-receipt', nonce], + queryFn: async () => { + const handoff = await new DeviceHandoffClient(config.workerUrl).get( + nonce!, + ); + const authorization = await new CreditcoinDeviceReader(config).read( + handoff.machineId, + handoff.payer, + ); + const receipt = handoff.endReceipt ?? handoff.startReceipt; + return { + handoff, + receipt, + verification: receipt + ? verifyUsageReceipt(receipt, handoff, authorization.controller) + : undefined, + }; + }, + enabled: Boolean(nonce), + staleTime: 4_000, + refetchInterval: 5_000, + retry: false, + }); + if (!evidence.data?.receipt) return null; + return ( +
+ + + {evidence.data.handoff.endReceipt + ? 'Signed usage receipt' + : 'Signed start receipt'} + + + {evidence.data.verification?.valid + ? 'Controller verified locally' + : 'Signature verification failed'} + +
+ ); +} + function StatusIcon({ status }: { status: RentalLifecycle }) { return status === 'active' ? ( diff --git a/apps/web/src/pages/DevicePage.tsx b/apps/web/src/pages/DevicePage.tsx index da25591..08f6cc0 100644 --- a/apps/web/src/pages/DevicePage.tsx +++ b/apps/web/src/pages/DevicePage.tsx @@ -1,99 +1,428 @@ import { - ExternalLink, + Check, + Clock3, KeyRound, + LoaderCircle, LockKeyhole, - QrCode, RadioTower, + ShieldAlert, + ShieldCheck, + Square, } from 'lucide-react'; -import { Link, useParams } from 'react-router'; -import { useMachineOffer, useRuntime } from '../app/AppProviders.js'; +import { useEffect, useMemo, useState } from 'react'; +import { Link, useParams, useSearchParams } from 'react-router'; +import { useRuntime } from '../app/AppProviders.js'; import { DataState, PageHeading } from '../components/ProductUI.js'; +import { + CreditcoinDeviceReader, + DeviceHandoffClient, + deriveDeviceSession, + deviceClaimStorageKey, + signUsageReceipt, + switchDeviceWalletToCreditcoin, + verifyUsageReceipt, + type DeviceAuthorization, + type DeviceHandoff, +} from '../device-session.js'; import { compactHash } from '../product.js'; +const stateCopy = { + awaiting_authorization: 'Awaiting authorization', + unlocked: 'Verified · ready', + active: 'Session active', + expiring: 'Access expiring', + expired: 'Access expired', + stopped: 'Session stopped', + fail_closed: 'Fail-closed lock', +}; + export function Component() { const { machineId = '' } = useParams(); - const { config, account } = useRuntime(); - const offer = useMachineOffer(); - if (!config || machineId.toLowerCase() !== config.machineId.toLowerCase()) + const [params] = useSearchParams(); + const nonce = params.get('handoff') ?? ''; + const boundPayer = params.get('payer') ?? ''; + const runtime = useRuntime(); + const client = useMemo( + () => runtime.config && new DeviceHandoffClient(runtime.config.workerUrl), + [runtime.config], + ); + const reader = useMemo( + () => runtime.config && new CreditcoinDeviceReader(runtime.config), + [runtime.config], + ); + const [handoff, setHandoff] = useState(); + const [claimToken, setClaimToken] = useState(); + const [authorization, setAuthorization] = useState(); + const [rpcError, setRpcError] = useState(); + const [error, setError] = useState(); + const [busy, setBusy] = useState(false); + const [now, setNow] = useState(() => Math.floor(Date.now() / 1_000)); + + useEffect(() => { + if (!client || !/^[0-9a-fA-F]{64}$/.test(nonce)) return; + const key = deviceClaimStorageKey(nonce); + const storedToken = localStorage.getItem(key); + setBusy(true); + const request = client.get(nonce).then(async (record) => { + if (record.machineId.toLowerCase() !== machineId.toLowerCase()) + throw new Error( + 'QR is bound to a different machine. Device remains locked.', + ); + if (record.payer.toLowerCase() !== boundPayer.toLowerCase()) + throw new Error( + 'QR is bound to a different payer. Device remains locked.', + ); + return storedToken + ? { handoff: record, claimToken: storedToken } + : client.claim(nonce); + }); + void request + .then((claimed) => { + localStorage.setItem(key, claimed.claimToken); + setClaimToken(claimed.claimToken); + setHandoff(claimed.handoff); + }) + .catch((caught: unknown) => + setError(caught instanceof Error ? caught.message : String(caught)), + ) + .finally(() => setBusy(false)); + }, [boundPayer, client, machineId, nonce]); + + useEffect(() => { + if (!reader || !handoff) return; + let active = true; + const inspect = async () => { + try { + const next = await reader.read(handoff.machineId, handoff.payer); + if (!active) return; + setAuthorization(next); + setRpcError(undefined); + setNow(next.blockTimestamp); + } catch (caught) { + if (!active) return; + setRpcError(caught instanceof Error ? caught.message : String(caught)); + } + }; + void inspect(); + const poll = window.setInterval(() => void inspect(), 4_000); + return () => { + active = false; + window.clearInterval(poll); + }; + }, [handoff?.machineId, handoff?.payer, reader]); + + useEffect(() => { + const timer = window.setInterval(() => setNow((value) => value + 1), 1_000); + return () => window.clearInterval(timer); + }, []); + + async function sign(kind: 'start' | 'end') { + if ( + !runtime.walletProvider || + !runtime.account || + !handoff || + !authorization || + !claimToken || + !client + ) + return; + setBusy(true); + setError(undefined); + try { + await switchDeviceWalletToCreditcoin(runtime.walletProvider); + const receipt = await signUsageReceipt( + runtime.walletProvider, + runtime.account, + handoff, + authorization.controller, + kind, + authorization.blockTimestamp, + ); + if (!verifyUsageReceipt(receipt, handoff, authorization.controller).valid) + throw new Error('Local controller signature verification failed.'); + setHandoff( + await client.submitReceipt(handoff.nonce, claimToken, receipt), + ); + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)); + } finally { + setBusy(false); + } + } + + if ( + !nonce || + !/^[0-9a-fA-F]{64}$/.test(nonce) || + !/^0x[0-9a-fA-F]{40}$/.test(boundPayer) + ) return (
+
+ ); + if (error && !handoff) + return ( +
+ + Return to rentals + + } + /> +
+ ); + if (!handoff || (busy && !authorization)) + return ( +
+
); + const invalidReceipt = authorization + ? [handoff.startReceipt, handoff.endReceipt] + .filter((receipt) => Boolean(receipt)) + .map((receipt) => + verifyUsageReceipt(receipt!, handoff, authorization.controller), + ) + .find((result) => !result.valid) + : undefined; + const derived = deriveDeviceSession( + handoff, + authorization, + now, + invalidReceipt?.reason ?? rpcError, + ); + const controllerMatches = Boolean( + runtime.account && + authorization && + runtime.account.toLowerCase() === authorization.controller.toLowerCase(), + ); + const remaining = Math.max(0, Number(handoff.accessExpiresAt) - now); + return ( -
+
+ CC3 · block{' '} + {authorization?.blockNumber ?? '—'} + + } /> -
-
-
+
+
+
- + {['unlocked', 'active', 'expiring'].includes(derived.state) ? ( + + ) : ( + + )}
- AWAITING VERIFIED ACCESS -

{config.machineName}

-

- {account - ? `Checking access for ${compactHash(account, 8, 6)}` - : 'Connect the renter wallet to prepare a device handoff.'} -

-
-
-
- - Network - - Creditcoin CC3 -
-
- - Authorization - - AccessPass.isAuthorized + + {stateCopy[derived.state]} + +

+ {derived.state === 'active' + ? 'Equipment enabled' + : derived.state === 'unlocked' + ? 'Ready for controller start' + : 'Equipment output disabled'} +

+

{derived.reason}

+ {(derived.state === 'active' || derived.state === 'expiring') && ( +
+ + {formatClock(remaining)} + verified time remaining +
+ )} + {derived.state === 'unlocked' && controllerMatches && ( + + )} + {handoff.startReceipt && !handoff.endReceipt && controllerMatches && ( + + )} +
+ +
+
+
+

LIVE SAFETY INTERLOCKS

+

Every binding must pass

+
+
-
+ +
+
+ ); +} + +function AuditRow({ + ok, + label, + value, +}: { + ok: boolean; + label: string; + value: string; +}) { + return ( +
+ {ok ? : } +
+ {label} + {value} +
); } + +function ReceiptStatus({ + label, + present, +}: { + label: string; + present: boolean; +}) { + return ( +
+ {present ? : ''} + {label} + {present ? 'signed + persisted' : 'not issued'} +
+ ); +} + +function formatClock(total: number) { + const hours = Math.floor(total / 3_600); + const minutes = Math.floor((total % 3_600) / 60); + const seconds = total % 60; + return `${String(hours).padStart(2, '0')}:${String(minutes).padStart( + 2, + '0', + )}:${String(seconds).padStart(2, '0')}`; +} diff --git a/apps/web/src/pages/MachinePage.tsx b/apps/web/src/pages/MachinePage.tsx index 060fbd3..0935b0b 100644 --- a/apps/web/src/pages/MachinePage.tsx +++ b/apps/web/src/pages/MachinePage.tsx @@ -153,8 +153,8 @@ export function Component() { Rental unavailable )} - - Open device terminal + + Device handoff from My Rentals
diff --git a/apps/web/src/pages/RentPage.tsx b/apps/web/src/pages/RentPage.tsx index 3ad41e7..78288bb 100644 --- a/apps/web/src/pages/RentPage.tsx +++ b/apps/web/src/pages/RentPage.tsx @@ -609,19 +609,27 @@ export function Component() { ) : session.phase === 'access' ? ( - +
+ + Open secure device handoff + + + +
) : ( + {error && ( +
+ {error} +
+ )} + + + ) : ( +
+
+
+
+

DEVICE CLAIM TICKET

+

+ {handoff.claimedAt ? 'Claimed by device' : 'Ready to scan'} +

+
+ + {handoff.claimedAt ? ( + <> + CLAIMED + + ) : qrRemaining ? ( + <> + {qrRemaining}s + + ) : ( + 'EXPIRED' + )} + +
+ {qrDataUrl && !handoff.claimedAt ? ( + One-time device handoff QR code + ) : ( +
+ + Device owns this nonce +
+ )} +
+ + + + +
+
+ + + Open device tab + +
+
+ +
+
+
+

LIVE SESSION EVIDENCE

+

+ {handoff.endReceipt + ? 'Usage stopped and sealed' + : handoff.startReceipt + ? 'Machine session active' + : handoff.claimedAt + ? 'Device checking Creditcoin' + : 'Awaiting device claim'} +

+
+ +
+
    + + + + +
+
+ {handoff.startReceipt && ( + + )} + {handoff.endReceipt && ( + + )} +
+ + Inspect the Attestcoin proof + + {error && ( +
+ {error} +
+ )} +
+
+ )} + + ); +} + +function Binding({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {compactHash(value, 11, 8)} +
+ ); +} + +function TimelineStep({ + done, + title, + copy, +}: { + done: boolean; + title: string; + copy: string; +}) { + return ( +
  • + {done ? : ''} +
    + {title} + {copy} +
    +
  • + ); +} + +function ReceiptCard({ + label, + receipt, + verification, +}: { + label: string; + receipt: SignedUsageReceipt; + verification?: { valid: boolean; reason: string }; +}) { + return ( +
    +
    + {label} + {receipt.payload.measuredDurationSeconds}s +
    +

    + {' '} + {verification?.reason ?? 'Reading registered controller…'} +

    + +
    + ); +} + +function downloadReceipt(receipt: SignedUsageReceipt) { + const url = URL.createObjectURL( + new Blob([JSON.stringify(receipt, null, 2)], { type: 'application/json' }), + ); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = `proofkey-${receipt.payload.kind}-${receipt.payload.sessionId}.json`; + anchor.click(); + URL.revokeObjectURL(url); +} diff --git a/apps/web/src/product.test.ts b/apps/web/src/product.test.ts index 17acf68..76bc8af 100644 --- a/apps/web/src/product.test.ts +++ b/apps/web/src/product.test.ts @@ -17,6 +17,7 @@ test('Product V1 exposes every required route', () => { '/machines/:machineId', '/rent/:machineId', '/activity', + '/sessions/:sourceTransactionHash', '/proofs/:sourceTxHash', '/operator', '/device/:machineId', diff --git a/apps/web/src/product.ts b/apps/web/src/product.ts index 10e9c37..dc6a9e5 100644 --- a/apps/web/src/product.ts +++ b/apps/web/src/product.ts @@ -4,6 +4,7 @@ export const productRoutes = [ '/machines/:machineId', '/rent/:machineId', '/activity', + '/sessions/:sourceTransactionHash', '/proofs/:sourceTxHash', '/operator', '/device/:machineId', diff --git a/apps/web/src/qrcode.d.ts b/apps/web/src/qrcode.d.ts new file mode 100644 index 0000000..b248249 --- /dev/null +++ b/apps/web/src/qrcode.d.ts @@ -0,0 +1,13 @@ +declare module 'qrcode' { + function toDataURL( + text: string, + options?: { + errorCorrectionLevel?: 'L' | 'M' | 'Q' | 'H'; + margin?: number; + width?: number; + color?: { dark?: string; light?: string }; + }, + ): Promise; + const QRCode: { toDataURL: typeof toDataURL }; + export default QRCode; +} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 6a8a512..fc577d7 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -1692,6 +1692,39 @@ input:focus-visible { font-style: normal; line-height: 1.4; } +.rental-signed-proof { + display: grid; + grid-template-columns: 16px 1fr; + align-items: center; + gap: 2px 6px; + width: fit-content; + margin-top: 4px; + padding: 7px 9px; + border: 1px solid #d5ded6; + border-radius: 9px; + background: #f4f7f2; + color: #69746d; +} +.rental-signed-proof svg { + grid-row: span 2; +} +.rental-signed-proof span { + font-size: 8px; + font-weight: 800; +} +.rental-signed-proof small { + font-size: 7px; +} +.rental-signed-proof.verified { + color: #177245; + border-color: #bfe3cd; + background: #eaf8ef; +} +.rental-signed-proof.invalid { + color: #a13d31; + border-color: #f0c6bf; + background: #fff0ed; +} .rental-status { width: fit-content; padding: 4px 7px; @@ -3993,3 +4026,561 @@ input:focus-visible { transition-duration: 0.01ms !important; } } + +/* Secure customer-to-device session */ +.handoff-intro { + display: grid; + grid-template-columns: minmax(280px, 0.9fr) minmax(360px, 1.1fr); + align-items: center; + overflow: hidden; + min-height: 390px; + border: 1px solid var(--line); + border-radius: 26px; + background: var(--surface); + box-shadow: var(--shadow); +} +.handoff-intro > div:last-child { + max-width: 620px; + padding: 48px; +} +.handoff-intro h2, +.qr-ticket h2, +.session-evidence h2, +.terminal-lock-panel h2, +.terminal-audit-panel h2 { + margin: 8px 0 12px; + font-family: var(--display); + font-size: clamp(1.6rem, 3vw, 2.5rem); + line-height: 1; +} +.handoff-intro p:not(.eyebrow) { + max-width: 560px; + margin: 0 0 24px; + color: var(--muted); + line-height: 1.65; +} +.handoff-visual { + display: flex; + align-items: center; + justify-content: center; + align-self: stretch; + padding: 40px; + background: + radial-gradient(circle at 45% 50%, rgb(75 236 153 / 18%), transparent 42%), + linear-gradient(145deg, #07150f, #0c2419); +} +.handoff-visual span { + display: grid; + width: 76px; + height: 76px; + place-items: center; + border: 1px solid rgb(104 255 180 / 36%); + border-radius: 22px; + color: var(--lime); + background: rgb(255 255 255 / 5%); + box-shadow: inset 0 0 30px rgb(88 244 163 / 6%); +} +.handoff-visual i { + width: clamp(18px, 4vw, 52px); + height: 1px; + background: linear-gradient(90deg, var(--lime), rgb(105 255 183 / 12%)); +} +.session-grid { + display: grid; + grid-template-columns: minmax(330px, 0.85fr) minmax(420px, 1.15fr); + gap: 22px; +} +.qr-ticket, +.session-evidence { + padding: 28px; + border: 1px solid var(--line); + border-radius: 25px; + background: var(--surface); + box-shadow: var(--shadow-soft); +} +.qr-ticket { + position: relative; + background: + radial-gradient(circle at 50% 35%, rgb(91 224 153 / 9%), transparent 35%), + var(--surface); +} +.qr-ticket-head, +.session-live-head, +.terminal-audit-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; +} +.qr-ticket-head > span, +.terminal-network { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 10px; + border: 1px solid var(--line); + border-radius: 999px; + color: var(--muted); + font: 700 0.69rem/1 var(--mono); + letter-spacing: 0.07em; + white-space: nowrap; +} +.qr-ticket-head > span.live, +.qr-ticket-head > span.claimed { + color: #087342; + border-color: rgb(20 151 88 / 24%); + background: #e8f8ef; +} +.qr-ticket-head > span.expired { + color: #a84235; + background: #fff0ed; +} +.handoff-qr { + display: block; + width: min(100%, 320px); + margin: 18px auto 26px; + padding: 10px; + border: 1px solid #d9e5da; + border-radius: 18px; + background: #f4f7ee; +} +.claimed-device-mark { + display: grid; + min-height: 260px; + margin: 18px 0 26px; + place-items: center; + align-content: center; + gap: 14px; + border: 1px dashed rgb(27 151 91 / 35%); + border-radius: 18px; + color: #117b49; + background: #effaf3; +} +.ticket-binding { + overflow: hidden; + border: 1px solid var(--line); + border-radius: 15px; +} +.ticket-binding > div { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + padding: 11px 13px; + border-bottom: 1px solid var(--line); +} +.ticket-binding > div:last-child { + border: 0; +} +.ticket-binding span { + color: var(--muted); + font-size: 0.78rem; +} +.ticket-binding code { + font-size: 0.74rem; +} +.ticket-actions { + display: flex; + gap: 10px; + margin-top: 16px; +} +.ticket-actions button, +.ticket-actions a { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 1; + gap: 7px; + min-height: 42px; + border: 1px solid var(--line); + border-radius: 11px; + color: var(--ink); + background: transparent; + font-weight: 700; + text-decoration: none; +} +.session-evidence { + background: #07150f; + color: #f4f8f4; +} +.session-evidence .eyebrow { + color: #79efa9; +} +.session-live-head > svg { + color: #79efa9; +} +.spin-slow { + animation: spin 4s linear infinite; +} +.session-timeline { + position: relative; + display: grid; + gap: 0; + margin: 26px 0; + padding: 0; + list-style: none; +} +.session-timeline li { + display: grid; + grid-template-columns: 30px 1fr; + gap: 12px; + min-height: 64px; + color: #75887d; +} +.session-timeline li > span { + position: relative; + z-index: 1; + display: grid; + width: 24px; + height: 24px; + place-items: center; + border: 1px solid #30443a; + border-radius: 50%; + background: #07150f; +} +.session-timeline li:not(:last-child) > span::after { + position: absolute; + top: 23px; + left: 10px; + width: 1px; + height: 41px; + content: ''; + background: #30443a; +} +.session-timeline li.done { + color: #ecfff3; +} +.session-timeline li.done > span { + color: #041009; + border-color: #6ef2a5; + background: #6ef2a5; +} +.session-timeline li div { + display: grid; + gap: 5px; +} +.session-timeline small { + color: #85998e; + font-family: var(--mono); +} +.receipt-stack { + display: grid; + gap: 10px; + margin-bottom: 20px; +} +.signed-receipt { + padding: 15px; + border: 1px solid #254235; + border-radius: 13px; + background: #0c2016; +} +.signed-receipt > div { + display: flex; + justify-content: space-between; +} +.signed-receipt span { + color: #91a69a; + font: 700 0.68rem var(--mono); + letter-spacing: 0.08em; +} +.signed-receipt p { + display: flex; + align-items: center; + gap: 6px; + margin: 10px 0; + font-size: 0.78rem; +} +.signed-receipt p.verified { + color: #79efa9; +} +.signed-receipt button { + padding: 0; + border: 0; + color: #d6e8dc; + background: transparent; + font-weight: 700; +} +.session-evidence .text-link { + color: #8ef3b6; +} + +/* Independent device terminal */ +.terminal-grid { + display: grid; + grid-template-columns: minmax(360px, 1.05fr) minmax(380px, 0.95fr); + overflow: hidden; + border: 1px solid #203e30; + border-radius: 28px; + background: #06120c; + box-shadow: 0 30px 80px rgb(2 13 8 / 22%); +} +.terminal-lock-panel { + display: flex; + align-items: center; + flex-direction: column; + justify-content: center; + min-height: 620px; + padding: 50px; + color: white; + text-align: center; + background: + radial-gradient(circle at 50% 38%, rgb(94 244 159 / 13%), transparent 33%), + repeating-linear-gradient( + 0deg, + transparent, + transparent 44px, + rgb(255 255 255 / 2%) 45px + ), + #06120c; +} +.terminal-lock-orbit { + position: relative; + display: grid; + width: 190px; + height: 190px; + margin-bottom: 28px; + place-items: center; +} +.terminal-lock-orbit > span { + position: absolute; + inset: 0; + border: 1px solid rgb(121 240 171 / 22%); + border-radius: 50%; + animation: terminal-pulse 3s ease-in-out infinite; +} +.terminal-lock-orbit > span:nth-child(2) { + inset: 20px; + animation-delay: -1.5s; +} +.terminal-lock-orbit > div { + position: relative; + display: grid; + width: 104px; + height: 104px; + place-items: center; + border: 1px solid #315441; + border-radius: 32px; + color: #7bf0ac; + background: #0c2116; + box-shadow: 0 0 45px rgb(91 235 152 / 11%); +} +@keyframes terminal-pulse { + 50% { + transform: scale(0.95); + opacity: 0.45; + } +} +.terminal-state { + padding: 7px 11px; + border: 1px solid #365744; + border-radius: 999px; + color: #92ad9d; + font: 800 0.68rem var(--mono); + letter-spacing: 0.1em; + text-transform: uppercase; +} +.terminal-state.unlocked, +.terminal-state.active { + color: #75efa8; + border-color: #267a4b; + background: rgb(50 194 111 / 8%); +} +.terminal-state.expiring { + color: #ffd56d; + border-color: #7a6326; +} +.terminal-state.fail_closed, +.terminal-state.expired { + color: #ff988a; + border-color: #754137; +} +.terminal-lock-panel h2 { + margin-top: 17px; + font-size: clamp(2rem, 4vw, 3.5rem); +} +.terminal-lock-panel > p { + max-width: 470px; + margin: 0; + color: #8fa398; + line-height: 1.6; +} +.session-clock { + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + gap: 5px 10px; + margin: 25px 0 5px; + padding: 14px 18px; + border: 1px solid #294a38; + border-radius: 15px; +} +.session-clock strong { + font: 700 1.55rem var(--mono); + letter-spacing: 0.06em; +} +.session-clock span { + grid-column: 2; + color: #82978b; + font-size: 0.7rem; + text-align: left; + text-transform: uppercase; +} +.terminal-action { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 9px; + min-width: 275px; + min-height: 52px; + margin-top: 24px; + border: 0; + border-radius: 13px; + font-weight: 800; +} +.terminal-action.start { + color: #05140c; + background: #75efa8; +} +.terminal-action.stop { + color: #ffb1a5; + border: 1px solid #693c35; + background: #21110f; +} +.terminal-audit-panel { + padding: 38px; + color: #eaf3ed; + border-left: 1px solid #203e30; + background: #0b1d14; +} +.terminal-audit-panel h2 { + font-size: 2rem; +} +.terminal-audit-head > svg { + color: #72eca4; +} +.terminal-audit-row { + display: grid; + grid-template-columns: 32px 1fr; + align-items: center; + gap: 11px; + padding: 14px 0; + border-bottom: 1px solid #20372b; +} +.terminal-audit-row > span { + display: grid; + width: 27px; + height: 27px; + place-items: center; + border-radius: 50%; +} +.terminal-audit-row.pass > span { + color: #04140b; + background: #70efa5; +} +.terminal-audit-row.fail > span { + color: #ff9a8d; + background: #38201d; +} +.terminal-audit-row div { + display: flex; + justify-content: space-between; + gap: 14px; +} +.terminal-audit-row small { + color: #84988d; + font-family: var(--mono); +} +.controller-gate { + margin-top: 22px; +} +.controller-confirmed { + display: flex; + align-items: center; + gap: 8px; + padding: 13px; + border: 1px solid #2a6c47; + border-radius: 11px; + color: #76eea8; + background: #102d1e; +} +.terminal-receipts { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + margin-top: 18px; +} +.terminal-receipts > div { + display: grid; + grid-template-columns: 20px 1fr; + gap: 3px 7px; + padding: 12px; + border: 1px solid #2a3d33; + border-radius: 11px; + color: #71857a; +} +.terminal-receipts > div.present { + color: #bde8ce; + border-color: #2a6745; +} +.terminal-receipts span { + grid-row: span 2; + display: grid; + width: 18px; + height: 18px; + place-items: center; + border: 1px solid currentColor; + border-radius: 50%; +} +.terminal-receipts small { + color: #71857a; +} + +@media (max-width: 900px) { + .handoff-intro, + .session-grid, + .terminal-grid { + grid-template-columns: 1fr; + } + .handoff-visual { + min-height: 220px; + } + .terminal-audit-panel { + border-top: 1px solid #203e30; + border-left: 0; + } + .terminal-lock-panel { + min-height: 520px; + } +} +@media (max-width: 600px) { + .handoff-intro > div:last-child, + .qr-ticket, + .session-evidence, + .terminal-lock-panel, + .terminal-audit-panel { + padding: 24px; + } + .handoff-visual span { + width: 58px; + height: 58px; + border-radius: 17px; + } + .ticket-actions, + .terminal-audit-row div { + align-items: stretch; + flex-direction: column; + } + .terminal-lock-panel { + min-height: 470px; + } + .terminal-lock-orbit { + width: 150px; + height: 150px; + } + .terminal-action { + min-width: 100%; + } + .terminal-receipts { + grid-template-columns: 1fr; + } +} diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index ec7323a..5b3b53c 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -27,7 +27,6 @@ interface ImportMetaEnv { readonly VITE_PROOF_WORKER_URL?: string; readonly VITE_SEPOLIA_EXPLORER_URL?: string; readonly VITE_CREDITCOIN_EXPLORER_URL?: string; - readonly VITE_DEVICE_SIMULATOR_URL?: string; readonly VITE_DEMO_MACHINE_NAME?: string; readonly VITE_DEMO_MACHINE_LOCATION?: string; readonly VITE_MACHINE_REGISTRY_DEPLOYMENT_BLOCK?: string; diff --git a/apps/web/tsconfig.test.json b/apps/web/tsconfig.test.json index 25758d2..fea08fa 100644 --- a/apps/web/tsconfig.test.json +++ b/apps/web/tsconfig.test.json @@ -27,6 +27,8 @@ "src/proof.ts", "src/proof.test.ts", "src/operator.ts", - "src/operator.test.ts" + "src/operator.test.ts", + "src/device-session.ts", + "src/device-session.test.ts" ] } diff --git a/apps/worker/README.md b/apps/worker/README.md index a1fc19d..b99e21f 100644 --- a/apps/worker/README.md +++ b/apps/worker/README.md @@ -4,22 +4,34 @@ The relay exposes a small HTTPS ingestion/status API and runs proof execution in ## API -| Route | Purpose | -| --------------------------------- | --------------------------------------------------------------------------- | -| `GET /health` | Process liveness only; never includes configuration or secrets. | -| `GET /ready` | Separate database, Sepolia RPC, Creditcoin RPC, and relayer-balance checks. | -| `POST /jobs` | Idempotently enqueue `{ "transactionHash": "0x…" }`. | -| `GET /jobs/:transactionHash` | Read the public proof phase, result, or structured failure. | -| `GET /proofs/:identifier` | Search by source transaction, order, query, or CC3 transaction. | -| `POST /metadata` | Persist a canonical machine metadata document by content digest. | -| `GET /metadata/:digest` | Read immutable content-addressed machine metadata. | -| `GET /metadata/commitments/:hash` | Resolve metadata from its Creditcoin URI commitment. | +| Route | Purpose | +| --------------------------------------- | --------------------------------------------------------------------------- | +| `GET /health` | Process liveness only; never includes configuration or secrets. | +| `GET /ready` | Separate database, Sepolia RPC, Creditcoin RPC, and relayer-balance checks. | +| `POST /jobs` | Idempotently enqueue `{ "transactionHash": "0x…" }`. | +| `GET /jobs/:transactionHash` | Read the public proof phase, result, or structured failure. | +| `GET /proofs/:identifier` | Search by source transaction, order, query, or CC3 transaction. | +| `POST /metadata` | Persist a canonical machine metadata document by content digest. | +| `GET /metadata/:digest` | Read immutable content-addressed machine metadata. | +| `GET /metadata/commitments/:hash` | Resolve metadata from its Creditcoin URI commitment. | +| `POST /device-handoffs` | Create a two-minute handoff from a completed source transaction. | +| `GET /device-handoffs/:nonce` | Read public handoff state and signed usage receipts. | +| `POST /device-handoffs/:nonce/claim` | Atomically claim the QR once and return the device-only claim token. | +| `POST /device-handoffs/:nonce/receipts` | Persist a controller-signed start or end receipt in strict order. | Proof responses contain only public receipt, Attestcoin, relay-phase, and Creditcoin execution fields. Every response passes a recursive secret-field -guard before serialization; RPC URLs, private keys, and internal paths are +guard before serialization; RPC URLs, private keys, claim-token hashes, and internal paths are never part of the public schema. +Device handoffs are also durable PostgreSQL records. The QR carries only the +public nonce and route binding. Claiming creates a random device-only token, +stores only its SHA-256 hash, and atomically rejects every later claim. Receipt +payloads bind the machine ID, payer, order ID, nonce, access expiry, start/end +timestamps, and measured duration. The relay verifies the declared controller +signature before persisting either receipt; the customer additionally checks +that signer against `MachineRegistry.controller` directly from Creditcoin. + Only browser origins listed in `FRONTEND_ORIGINS` receive CORS access. Enqueue requests are bounded per client by `RELAY_RATE_LIMIT_REQUESTS` and `RELAY_RATE_LIMIT_WINDOW_MS`. ## Local development diff --git a/apps/worker/package.json b/apps/worker/package.json index d3d65d9..76b7509 100644 --- a/apps/worker/package.json +++ b/apps/worker/package.json @@ -9,7 +9,7 @@ "live:verify": "npm run build && node dist/verify-live.js", "relay": "npm run build && node dist/cli.js", "serve": "npm run build && node dist/server.js", - "test": "npm run build && node --test dist/evidence.test.js dist/executor.test.js dist/http.test.js dist/queue.test.js dist/relay.test.js dist/store.test.js", + "test": "npm run build && node --test dist/evidence.test.js dist/executor.test.js dist/http.test.js dist/queue.test.js dist/relay.test.js dist/store.test.js dist/usage-receipt.test.js", "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { diff --git a/apps/worker/src/http.test.ts b/apps/worker/src/http.test.ts index 7a9794e..10f4286 100644 --- a/apps/worker/src/http.test.ts +++ b/apps/worker/src/http.test.ts @@ -3,15 +3,23 @@ import { once } from 'node:events'; import type { Server } from 'node:http'; import type { AddressInfo } from 'node:net'; import { test } from 'node:test'; +import { Wallet, keccak256, toUtf8Bytes } from 'ethers'; import { createRelayHttpServer, type RelayHttpOptions } from './http.js'; import type { JobQueue } from './queue.js'; import type { + DeviceHandoff, RelayJob, RelayReadiness, + SignedUsageReceipt, StoredMachineMetadata, } from './types.js'; +import { usageReceiptMessage } from './usage-receipt.js'; const hash = `0x${'ab'.repeat(32)}`; +const paymentHash = `0x${'bc'.repeat(32)}`; +const orderId = `0x${'cd'.repeat(32)}`; +const machineId = `0x${'de'.repeat(32)}`; +const payer = '0x1111111111111111111111111111111111111111'; const origin = 'https://proofkey.vercel.app'; const job: RelayJob = { sourceTransactionHash: hash, @@ -37,6 +45,10 @@ const ready: RelayReadiness = { }; let storedMetadata: StoredMachineMetadata | undefined; +const handoffs = new Map< + string, + { handoff: DeviceHandoff; claimTokenHash?: string } +>(); const queue: JobQueue = { enqueue: async (transactionHash) => ({ ...job, @@ -47,9 +59,19 @@ const queue: JobQueue = { ? { ...job, phase: 'proof_generation' } : undefined, find: async (identifier) => - identifier.toLowerCase() === hash - ? { ...job, phase: 'proof_generation' } - : undefined, + identifier.toLowerCase() === paymentHash + ? { + ...job, + sourceTransactionHash: paymentHash, + phase: 'completed', + orderId, + machineId, + payer, + accessExpiresAt: '2000000000', + } + : identifier.toLowerCase() === hash + ? { ...job, phase: 'proof_generation' } + : undefined, putMetadata: async (metadata) => { storedMetadata = metadata; }, @@ -61,6 +83,31 @@ const queue: JobQueue = { storedMetadata?.commitment.toLowerCase() === commitment.toLowerCase() ? storedMetadata : undefined, + createDeviceHandoff: async (handoff) => { + handoffs.set(handoff.nonce, { handoff }); + }, + getDeviceHandoff: async (nonce) => handoffs.get(nonce)?.handoff, + claimDeviceHandoff: async (nonce, claimTokenHash, claimedAt) => { + const record = handoffs.get(nonce); + if (!record || record.claimTokenHash) return undefined; + record.claimTokenHash = claimTokenHash; + record.handoff = { ...record.handoff, claimedAt }; + return record.handoff; + }, + putDeviceReceipt: async (nonce, claimTokenHash, receipt) => { + const record = handoffs.get(nonce); + if (!record || record.claimTokenHash !== claimTokenHash) return undefined; + if (receipt.payload.kind === 'start' && !record.handoff.startReceipt) + record.handoff = { ...record.handoff, startReceipt: receipt }; + else if ( + receipt.payload.kind === 'end' && + record.handoff.startReceipt && + !record.handoff.endReceipt + ) + record.handoff = { ...record.handoff, endReceipt: receipt }; + else return undefined; + return record.handoff; + }, }; async function start( @@ -213,3 +260,98 @@ test('rejects secret-bearing metadata before durable storage', async (context) = ); assert.equal(storedMetadata, undefined); }); + +test('creates a short-lived one-time device handoff and stores a signed start receipt', async (context) => { + handoffs.clear(); + const now = Date.parse('2026-09-13T12:00:00.000Z'); + const { url } = await start(context, { now: () => now }); + const createdResponse = await fetch(`${url}/device-handoffs`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: origin }, + body: JSON.stringify({ sourceTransactionHash: paymentHash }), + }); + assert.equal(createdResponse.status, 201); + const handoff = (await createdResponse.json()) as DeviceHandoff; + assert.match(handoff.nonce, /^[0-9a-f]{64}$/); + assert.equal(handoff.machineId, machineId); + assert.equal(Date.parse(handoff.expiresAt) - now, 120_000); + + const claimResponse = await fetch( + `${url}/device-handoffs/${handoff.nonce}/claim`, + { method: 'POST' }, + ); + assert.equal(claimResponse.status, 200); + const claim = (await claimResponse.json()) as { + handoff: DeviceHandoff; + claimToken: string; + }; + assert.match(claim.claimToken, /^[0-9a-f]{64}$/); + assert.equal( + ( + await fetch(`${url}/device-handoffs/${handoff.nonce}/claim`, { + method: 'POST', + }) + ).status, + 409, + ); + + const controller = Wallet.createRandom(); + const payload = { + schema: 'proofkey.usage-receipt.v1' as const, + kind: 'start' as const, + sessionId: keccak256( + toUtf8Bytes(`proofkey-device-session:${handoff.nonce}`), + ), + machineId, + payer, + orderId, + nonce: handoff.nonce, + controller: controller.address, + startedAt: '2026-09-13T12:00:10.000Z', + endedAt: null, + measuredDurationSeconds: 0, + accessExpiresAt: handoff.accessExpiresAt, + }; + const receipt: SignedUsageReceipt = { + payload, + signature: await controller.signMessage(usageReceiptMessage(payload)), + }; + const stored = await fetch( + `${url}/device-handoffs/${handoff.nonce}/receipts`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-ProofKey-Claim-Token': claim.claimToken, + }, + body: JSON.stringify(receipt), + }, + ); + assert.equal(stored.status, 201); + assert.equal( + ((await stored.json()) as DeviceHandoff).startReceipt?.payload.controller, + controller.address, + ); +}); + +test('rejects an expired QR claim', async (context) => { + handoffs.clear(); + let now = Date.parse('2026-09-13T12:00:00.000Z'); + const { url } = await start(context, { now: () => now }); + const created = (await ( + await fetch(`${url}/device-handoffs`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sourceTransactionHash: paymentHash }), + }) + ).json()) as DeviceHandoff; + now += 120_001; + const expired = await fetch(`${url}/device-handoffs/${created.nonce}/claim`, { + method: 'POST', + }); + assert.equal(expired.status, 410); + assert.equal( + ((await expired.json()) as { error: { code: string } }).error.code, + 'HANDOFF_EXPIRED', + ); +}); diff --git a/apps/worker/src/http.ts b/apps/worker/src/http.ts index 388d65b..93c6bf6 100644 --- a/apps/worker/src/http.ts +++ b/apps/worker/src/http.ts @@ -4,11 +4,13 @@ import { type Server, type ServerResponse, } from 'node:http'; -import { keccak256, toUtf8Bytes } from 'ethers'; +import { createHash, randomBytes } from 'node:crypto'; +import { isHexString, keccak256, toUtf8Bytes } from 'ethers'; import { PermanentRelayError } from './retry.js'; import { assertPublicEvidence, publicEvidenceFromJob } from './evidence.js'; import type { JobQueue } from './queue.js'; import type { RelayReadiness } from './types.js'; +import { assertUsageReceipt } from './usage-receipt.js'; const maximumBodyBytes = 8_192; @@ -152,6 +154,172 @@ export function createRelayHttpServer( } return send(response, 202, await queue.enqueue(body.transactionHash)); } + if (request.method === 'POST' && url.pathname === '/device-handoffs') { + const rate = limiter.consume(clientAddress(request)); + setRateHeaders(response, options, rate); + if (!rate.allowed) + return sendError( + response, + 429, + 'RATE_LIMITED', + 'Too many device handoffs. Wait before creating another.', + true, + ); + requireJson(request); + const body = await readJson(request); + if ( + typeof body.sourceTransactionHash !== 'string' || + !isHexString(body.sourceTransactionHash, 32) + ) + throw new PermanentRelayError( + 'sourceTransactionHash must be a 32-byte hash.', + 'INVALID_TRANSACTION_HASH', + ); + const job = await queue.find(body.sourceTransactionHash); + if ( + !job || + !['completed', 'duplicate'].includes(job.phase) || + !job.machineId || + !job.payer || + !job.orderId || + !job.accessExpiresAt + ) + return sendError( + response, + 409, + 'ACCESS_NOT_READY', + 'A completed, fully correlated AccessPass is required.', + true, + ); + const now = nowMilliseconds(options); + const accessExpiry = Number(job.accessExpiresAt) * 1_000; + if (!Number.isSafeInteger(accessExpiry) || accessExpiry <= now + 5_000) + return sendError( + response, + 409, + 'ACCESS_EXPIRED', + 'The AccessPass has expired or is too close to expiry.', + false, + ); + const timestamp = new Date(now).toISOString(); + const handoff = { + schema: 'proofkey.device-handoff.v1' as const, + nonce: randomBytes(32).toString('hex'), + machineId: job.machineId.toLowerCase(), + payer: job.payer, + orderId: job.orderId.toLowerCase(), + sourceTransactionHash: job.sourceTransactionHash.toLowerCase(), + accessExpiresAt: job.accessExpiresAt, + createdAt: timestamp, + expiresAt: new Date( + Math.min(now + 120_000, accessExpiry), + ).toISOString(), + }; + await queue.createDeviceHandoff(handoff); + return send(response, 201, handoff); + } + const handoffMatch = /^\/device-handoffs\/([0-9a-fA-F]{64})$/.exec( + url.pathname, + ); + if (request.method === 'GET' && handoffMatch?.[1]) { + const handoff = await queue.getDeviceHandoff(handoffMatch[1]); + return handoff + ? send(response, 200, handoff) + : sendError( + response, + 404, + 'HANDOFF_NOT_FOUND', + 'Device handoff not found.', + false, + ); + } + const claimMatch = /^\/device-handoffs\/([0-9a-fA-F]{64})\/claim$/.exec( + url.pathname, + ); + if (request.method === 'POST' && claimMatch?.[1]) { + const existing = await queue.getDeviceHandoff(claimMatch[1]); + if (!existing) + return sendError( + response, + 404, + 'HANDOFF_NOT_FOUND', + 'Device handoff not found.', + false, + ); + const now = nowMilliseconds(options); + if (Date.parse(existing.expiresAt) <= now) + return sendError( + response, + 410, + 'HANDOFF_EXPIRED', + 'This QR handoff has expired.', + false, + ); + const claimToken = randomBytes(32).toString('hex'); + const claimed = await queue.claimDeviceHandoff( + claimMatch[1], + secretHash(claimToken), + new Date(now).toISOString(), + ); + return claimed + ? send(response, 200, { handoff: claimed, claimToken }) + : sendError( + response, + 409, + 'HANDOFF_ALREADY_CLAIMED', + 'This QR handoff was already claimed by a device.', + false, + ); + } + const receiptMatch = + /^\/device-handoffs\/([0-9a-fA-F]{64})\/receipts$/.exec(url.pathname); + if (request.method === 'POST' && receiptMatch?.[1]) { + requireJson(request); + const claimToken = request.headers['x-proofkey-claim-token']; + if ( + typeof claimToken !== 'string' || + !/^[0-9a-f]{64}$/.test(claimToken) + ) + return sendError( + response, + 401, + 'INVALID_CLAIM_TOKEN', + 'The device claim token is missing or invalid.', + false, + ); + const handoff = await queue.getDeviceHandoff(receiptMatch[1]); + if (!handoff) + return sendError( + response, + 404, + 'HANDOFF_NOT_FOUND', + 'Device handoff not found.', + false, + ); + const body = await readJson(request); + try { + assertUsageReceipt(handoff, body as never); + } catch (error) { + throw new PermanentRelayError( + error instanceof Error ? error.message : 'Receipt is invalid.', + 'INVALID_RECEIPT', + ); + } + const updated = await queue.putDeviceReceipt( + receiptMatch[1], + secretHash(claimToken), + body as never, + ); + return updated + ? send(response, 201, updated) + : sendError( + response, + 409, + 'RECEIPT_REJECTED', + 'The claim token, receipt order, or session state is invalid.', + false, + ); + } const match = /^\/jobs\/(0x[0-9a-fA-F]{64})$/.exec(url.pathname); if (request.method === 'GET' && match?.[1]) { const job = await queue.get(match[1]); @@ -246,6 +414,26 @@ export function createRelayHttpServer( }); } +function requireJson(request: IncomingMessage): void { + if ( + !request.headers['content-type'] + ?.toLowerCase() + .startsWith('application/json') + ) + throw new PermanentRelayError( + 'Content-Type must be application/json.', + 'UNSUPPORTED_CONTENT_TYPE', + ); +} + +function nowMilliseconds(options: RelayHttpOptions): number { + return options.now?.() ?? Date.now(); +} + +function secretHash(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + function setRateHeaders( response: ServerResponse, options: RelayHttpOptions, @@ -332,7 +520,10 @@ function setBaseHeaders(response: ServerResponse): void { function setCors(response: ServerResponse, origin: string): void { response.setHeader('Access-Control-Allow-Origin', origin); - response.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + response.setHeader( + 'Access-Control-Allow-Headers', + 'Content-Type, X-ProofKey-Claim-Token', + ); response.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS'); response.setHeader('Vary', 'Origin'); } diff --git a/apps/worker/src/queue.ts b/apps/worker/src/queue.ts index 97059dd..6d88cb3 100644 --- a/apps/worker/src/queue.ts +++ b/apps/worker/src/queue.ts @@ -1,7 +1,9 @@ import { PermanentRelayError } from './retry.js'; import type { + DeviceHandoff, DurableJobStore, RelayJob, + SignedUsageReceipt, StoredMachineMetadata, } from './types.js'; @@ -16,6 +18,18 @@ export interface JobQueue { getMetadataByCommitment( commitment: string, ): Promise; + createDeviceHandoff(handoff: DeviceHandoff): Promise; + getDeviceHandoff(nonce: string): Promise; + claimDeviceHandoff( + nonce: string, + claimTokenHash: string, + claimedAt: string, + ): Promise; + putDeviceReceipt( + nonce: string, + claimTokenHash: string, + receipt: SignedUsageReceipt, + ): Promise; } const transactionHashPattern = /^0x[0-9a-fA-F]{64}$/; @@ -64,4 +78,32 @@ export class RelayQueue implements JobQueue { getMetadataByCommitment(commitment: string) { return this.store.getMetadataByCommitment(commitment); } + + createDeviceHandoff(handoff: DeviceHandoff) { + return this.store.createDeviceHandoff(handoff); + } + + getDeviceHandoff(nonce: string) { + return this.store.getDeviceHandoff(nonce.toLowerCase()); + } + + claimDeviceHandoff(nonce: string, claimTokenHash: string, claimedAt: string) { + return this.store.claimDeviceHandoff( + nonce.toLowerCase(), + claimTokenHash, + claimedAt, + ); + } + + putDeviceReceipt( + nonce: string, + claimTokenHash: string, + receipt: SignedUsageReceipt, + ) { + return this.store.putDeviceReceipt( + nonce.toLowerCase(), + claimTokenHash, + receipt, + ); + } } diff --git a/apps/worker/src/store.test.ts b/apps/worker/src/store.test.ts index 6c85b7c..96d567a 100644 --- a/apps/worker/src/store.test.ts +++ b/apps/worker/src/store.test.ts @@ -60,6 +60,37 @@ test( ?.uri, metadata.uri, ); + const handoff = { + schema: 'proofkey.device-handoff.v1' as const, + nonce: '56'.repeat(32), + machineId: `0x${'67'.repeat(32)}`, + payer: '0x1111111111111111111111111111111111111111', + orderId: `0x${'78'.repeat(32)}`, + sourceTransactionHash: hash, + accessExpiresAt: '2000000000', + createdAt: '2026-09-13T12:00:00.000Z', + expiresAt: '2026-09-13T12:02:00.000Z', + }; + await restartedProcess.createDeviceHandoff(handoff); + assert.equal( + (await restartedProcess.getDeviceHandoff(handoff.nonce))?.orderId, + handoff.orderId, + ); + assert.ok( + await restartedProcess.claimDeviceHandoff( + handoff.nonce, + '89'.repeat(32), + '2026-09-13T12:00:10.000Z', + ), + ); + assert.equal( + await restartedProcess.claimDeviceHandoff( + handoff.nonce, + '90'.repeat(32), + '2026-09-13T12:00:11.000Z', + ), + undefined, + ); const recovered = await restartedProcess.claimNext( 'worker-after-restart', 1_000, diff --git a/apps/worker/src/store.ts b/apps/worker/src/store.ts index 1ea51e2..e13ee8f 100644 --- a/apps/worker/src/store.ts +++ b/apps/worker/src/store.ts @@ -1,7 +1,9 @@ import { Pool, type PoolConfig } from 'pg'; import type { + DeviceHandoff, DurableJobStore, RelayJob, + SignedUsageReceipt, StoredMachineMetadata, } from './types.js'; @@ -17,6 +19,7 @@ export class PostgresJobStore implements DurableJobStore { private readonly table: string; private readonly claimableIndex: string; private readonly metadataTable: string; + private readonly handoffTable: string; private initialized?: Promise; constructor(databaseUrl: string, options: PostgresJobStoreOptions = {}) { @@ -31,6 +34,7 @@ export class PostgresJobStore implements DurableJobStore { this.table = `${this.schema}."${tableName}"`; this.claimableIndex = `"${tableName}_claimable_idx"`; this.metadataTable = `${this.schema}."machine_metadata"`; + this.handoffTable = `${this.schema}."device_handoffs"`; const config: PoolConfig = { connectionString: databaseUrl, max: 5, @@ -140,6 +144,70 @@ export class PostgresJobStore implements DurableJobStore { return result.rows[0]; } + async createDeviceHandoff(handoff: DeviceHandoff): Promise { + await this.initialize(); + await this.pool.query( + `INSERT INTO ${this.handoffTable} + (nonce, handoff, created_at, expires_at) + VALUES ($1, $2::jsonb, $3, $4)`, + [ + handoff.nonce.toLowerCase(), + JSON.stringify(handoff), + handoff.createdAt, + handoff.expiresAt, + ], + ); + } + + async getDeviceHandoff(nonce: string): Promise { + await this.initialize(); + const result = await this.pool.query<{ handoff: DeviceHandoff }>( + `SELECT handoff FROM ${this.handoffTable} WHERE nonce = $1`, + [nonce.toLowerCase()], + ); + return result.rows[0]?.handoff; + } + + async claimDeviceHandoff( + nonce: string, + claimTokenHash: string, + claimedAt: string, + ): Promise { + await this.initialize(); + const result = await this.pool.query<{ handoff: DeviceHandoff }>( + `UPDATE ${this.handoffTable} + SET claim_token_hash = $2, + handoff = jsonb_set(handoff, '{claimedAt}', to_jsonb($3::text)) + WHERE nonce = $1 AND claim_token_hash IS NULL + RETURNING handoff`, + [nonce.toLowerCase(), claimTokenHash, claimedAt], + ); + return result.rows[0]?.handoff; + } + + async putDeviceReceipt( + nonce: string, + claimTokenHash: string, + receipt: SignedUsageReceipt, + ): Promise { + await this.initialize(); + const isStart = receipt.payload.kind === 'start'; + const path = isStart ? '{startReceipt}' : '{endReceipt}'; + const prerequisite = isStart + ? `handoff->'startReceipt' IS NULL` + : `handoff->'startReceipt' IS NOT NULL AND handoff->'endReceipt' IS NULL`; + const result = await this.pool.query<{ handoff: DeviceHandoff }>( + `UPDATE ${this.handoffTable} + SET handoff = jsonb_set(handoff, '${path}', $3::jsonb) + WHERE nonce = $1 + AND claim_token_hash = $2 + AND ${prerequisite} + RETURNING handoff`, + [nonce.toLowerCase(), claimTokenHash, JSON.stringify(receipt)], + ); + return result.rows[0]?.handoff; + } + async save(job: RelayJob): Promise { await this.initialize(); await this.pool.query( @@ -254,5 +322,22 @@ export class PostgresJobStore implements DurableJobStore { CONSTRAINT metadata_commitment_format CHECK (commitment ~ '^0x[0-9a-f]{64}$') )`, ); + await this.pool.query( + `CREATE TABLE IF NOT EXISTS ${this.handoffTable} ( + nonce VARCHAR(64) PRIMARY KEY, + handoff JSONB NOT NULL, + claim_token_hash VARCHAR(64), + created_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + CONSTRAINT device_handoff_nonce_format CHECK (nonce ~ '^[0-9a-f]{64}$'), + CONSTRAINT device_claim_hash_format CHECK ( + claim_token_hash IS NULL OR claim_token_hash ~ '^[0-9a-f]{64}$' + ) + )`, + ); + await this.pool.query( + `CREATE INDEX IF NOT EXISTS "device_handoffs_expiry_idx" + ON ${this.handoffTable} (expires_at)`, + ); } } diff --git a/apps/worker/src/test-store.ts b/apps/worker/src/test-store.ts index ba01175..9b01f0d 100644 --- a/apps/worker/src/test-store.ts +++ b/apps/worker/src/test-store.ts @@ -1,6 +1,8 @@ import type { + DeviceHandoff, DurableJobStore, RelayJob, + SignedUsageReceipt, StoredMachineMetadata, } from './types.js'; @@ -8,6 +10,10 @@ export class MemoryDurableStore implements DurableJobStore { readonly jobs = new Map(); private readonly leases = new Map(); readonly metadata = new Map(); + readonly handoffs = new Map< + string, + { handoff: DeviceHandoff; claimTokenHash?: string } + >(); async get(transactionHash: string): Promise { const job = this.jobs.get(transactionHash.toLowerCase()); @@ -61,6 +67,47 @@ export class MemoryDurableStore implements DurableJobStore { return undefined; } + async createDeviceHandoff(handoff: DeviceHandoff): Promise { + if (this.handoffs.has(handoff.nonce)) + throw new Error('Device handoff nonce already exists.'); + this.handoffs.set(handoff.nonce, { handoff: structuredClone(handoff) }); + } + + async getDeviceHandoff(nonce: string): Promise { + const record = this.handoffs.get(nonce.toLowerCase()); + return record && structuredClone(record.handoff); + } + + async claimDeviceHandoff( + nonce: string, + claimTokenHash: string, + claimedAt: string, + ): Promise { + const record = this.handoffs.get(nonce.toLowerCase()); + if (!record || record.claimTokenHash) return undefined; + record.claimTokenHash = claimTokenHash; + record.handoff.claimedAt = claimedAt; + return structuredClone(record.handoff); + } + + async putDeviceReceipt( + nonce: string, + claimTokenHash: string, + receipt: SignedUsageReceipt, + ): Promise { + const record = this.handoffs.get(nonce.toLowerCase()); + if (!record || record.claimTokenHash !== claimTokenHash) return undefined; + if (receipt.payload.kind === 'start') { + if (record.handoff.startReceipt) return undefined; + record.handoff.startReceipt = structuredClone(receipt); + } else { + if (!record.handoff.startReceipt || record.handoff.endReceipt) + return undefined; + record.handoff.endReceipt = structuredClone(receipt); + } + return structuredClone(record.handoff); + } + async save(job: RelayJob): Promise { this.jobs.set( job.sourceTransactionHash.toLowerCase(), diff --git a/apps/worker/src/types.ts b/apps/worker/src/types.ts index a01acae..a613560 100644 --- a/apps/worker/src/types.ts +++ b/apps/worker/src/types.ts @@ -89,6 +89,43 @@ export interface StoredMachineMetadata { createdAt: string; } +export type UsageReceiptKind = 'start' | 'end'; + +export interface UsageReceiptPayload { + schema: 'proofkey.usage-receipt.v1'; + kind: UsageReceiptKind; + sessionId: string; + machineId: string; + payer: string; + orderId: string; + nonce: string; + controller: string; + startedAt: string; + endedAt: string | null; + measuredDurationSeconds: number; + accessExpiresAt: string; +} + +export interface SignedUsageReceipt { + payload: UsageReceiptPayload; + signature: string; +} + +export interface DeviceHandoff { + schema: 'proofkey.device-handoff.v1'; + nonce: string; + machineId: string; + payer: string; + orderId: string; + sourceTransactionHash: string; + accessExpiresAt: string; + createdAt: string; + expiresAt: string; + claimedAt?: string; + startReceipt?: SignedUsageReceipt; + endReceipt?: SignedUsageReceipt; +} + export interface RelayFailure { code: string; message: string; @@ -119,6 +156,18 @@ export interface DurableJobStore extends JobStore { getMetadataByCommitment( commitment: string, ): Promise; + createDeviceHandoff(handoff: DeviceHandoff): Promise; + getDeviceHandoff(nonce: string): Promise; + claimDeviceHandoff( + nonce: string, + claimTokenHash: string, + claimedAt: string, + ): Promise; + putDeviceReceipt( + nonce: string, + claimTokenHash: string, + receipt: SignedUsageReceipt, + ): Promise; claimNext( ownerId: string, leaseDurationMs: number, diff --git a/apps/worker/src/usage-receipt.test.ts b/apps/worker/src/usage-receipt.test.ts new file mode 100644 index 0000000..b89e5b5 --- /dev/null +++ b/apps/worker/src/usage-receipt.test.ts @@ -0,0 +1,115 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { Wallet, keccak256, toUtf8Bytes } from 'ethers'; +import type { + DeviceHandoff, + SignedUsageReceipt, + UsageReceiptPayload, +} from './types.js'; +import { assertUsageReceipt, usageReceiptMessage } from './usage-receipt.js'; + +const controller = Wallet.createRandom(); +const handoff: DeviceHandoff = { + schema: 'proofkey.device-handoff.v1', + nonce: 'ab'.repeat(32), + machineId: `0x${'12'.repeat(32)}`, + payer: '0x1111111111111111111111111111111111111111', + orderId: `0x${'34'.repeat(32)}`, + sourceTransactionHash: `0x${'56'.repeat(32)}`, + accessExpiresAt: '2000000000', + createdAt: '2026-09-13T12:00:00.000Z', + expiresAt: '2026-09-13T12:02:00.000Z', + claimedAt: '2026-09-13T12:00:10.000Z', +}; + +async function signed( + payload: UsageReceiptPayload, +): Promise { + return { + payload, + signature: await controller.signMessage(usageReceiptMessage(payload)), + }; +} + +test('accepts controller-signed start and end receipts with exact measured duration', async () => { + const startPayload: UsageReceiptPayload = { + schema: 'proofkey.usage-receipt.v1', + kind: 'start', + sessionId: keccak256( + toUtf8Bytes(`proofkey-device-session:${handoff.nonce}`), + ), + machineId: handoff.machineId, + payer: handoff.payer, + orderId: handoff.orderId, + nonce: handoff.nonce, + controller: controller.address, + startedAt: '2026-09-13T12:00:20.000Z', + endedAt: null, + measuredDurationSeconds: 0, + accessExpiresAt: handoff.accessExpiresAt, + }; + const startReceipt = await signed(startPayload); + assert.doesNotThrow(() => assertUsageReceipt(handoff, startReceipt)); + + const endPayload: UsageReceiptPayload = { + ...startPayload, + kind: 'end', + endedAt: '2026-09-13T12:00:30.000Z', + measuredDurationSeconds: 10, + }; + const endReceipt = await signed(endPayload); + assert.doesNotThrow(() => + assertUsageReceipt({ ...handoff, startReceipt }, endReceipt), + ); +}); + +test('rejects field tampering, a foreign signer, and falsified duration', async () => { + const payload: UsageReceiptPayload = { + schema: 'proofkey.usage-receipt.v1', + kind: 'start', + sessionId: keccak256( + toUtf8Bytes(`proofkey-device-session:${handoff.nonce}`), + ), + machineId: handoff.machineId, + payer: handoff.payer, + orderId: handoff.orderId, + nonce: handoff.nonce, + controller: controller.address, + startedAt: '2026-09-13T12:00:20.000Z', + endedAt: null, + measuredDurationSeconds: 0, + accessExpiresAt: handoff.accessExpiresAt, + }; + const receipt = await signed(payload); + assert.throws(() => + assertUsageReceipt(handoff, { + ...receipt, + payload: { ...payload, orderId: `0x${'99'.repeat(32)}` }, + }), + ); + const foreign = Wallet.createRandom(); + assert.throws(() => + assertUsageReceipt(handoff, { + payload, + signature: foreign.signingKey.sign(keccak256(toUtf8Bytes('wrong'))) + .serialized, + }), + ); + const endPayload: UsageReceiptPayload = { + ...payload, + kind: 'end', + endedAt: '2026-09-13T12:00:30.000Z', + measuredDurationSeconds: 9, + }; + assert.throws(() => + assertUsageReceipt( + { ...handoff, startReceipt: receipt }, + { + payload: endPayload, + signature: controller.signingKey.sign( + keccak256(toUtf8Bytes(usageReceiptMessage(endPayload))), + ).serialized, + }, + ), + ); +}); diff --git a/apps/worker/src/usage-receipt.ts b/apps/worker/src/usage-receipt.ts new file mode 100644 index 0000000..91545a5 --- /dev/null +++ b/apps/worker/src/usage-receipt.ts @@ -0,0 +1,107 @@ +import { + getAddress, + isAddress, + isHexString, + keccak256, + toUtf8Bytes, + verifyMessage, +} from 'ethers'; +import type { + DeviceHandoff, + SignedUsageReceipt, + UsageReceiptPayload, +} from './types.js'; + +const sessionPattern = /^0x[0-9a-fA-F]{64}$/; +const noncePattern = /^[0-9a-fA-F]{64}$/; + +export function usageReceiptMessage(payload: UsageReceiptPayload): string { + return [ + 'ProofKey Usage Receipt v1', + `kind=${payload.kind}`, + `sessionId=${payload.sessionId.toLowerCase()}`, + `machineId=${payload.machineId.toLowerCase()}`, + `payer=${payload.payer.toLowerCase()}`, + `orderId=${payload.orderId.toLowerCase()}`, + `nonce=${payload.nonce.toLowerCase()}`, + `controller=${payload.controller.toLowerCase()}`, + `startedAt=${payload.startedAt}`, + `endedAt=${payload.endedAt ?? ''}`, + `measuredDurationSeconds=${payload.measuredDurationSeconds}`, + `accessExpiresAt=${payload.accessExpiresAt}`, + ].join('\n'); +} + +export function assertUsageReceipt( + handoff: DeviceHandoff, + receipt: SignedUsageReceipt, +): void { + const payload = receipt?.payload; + if ( + !payload || + payload.schema !== 'proofkey.usage-receipt.v1' || + !['start', 'end'].includes(payload.kind) || + !sessionPattern.test(payload.sessionId) || + !isHexString(payload.machineId, 32) || + !isHexString(payload.orderId, 32) || + !noncePattern.test(payload.nonce) || + !isAddress(payload.payer) || + !isAddress(payload.controller) || + !Number.isSafeInteger(payload.measuredDurationSeconds) || + payload.measuredDurationSeconds < 0 || + !/^\d+$/.test(payload.accessExpiresAt) || + !isHexString(receipt.signature, 65) + ) + throw new Error('Receipt shape or signature encoding is invalid.'); + + if ( + payload.machineId.toLowerCase() !== handoff.machineId.toLowerCase() || + payload.orderId.toLowerCase() !== handoff.orderId.toLowerCase() || + payload.payer.toLowerCase() !== handoff.payer.toLowerCase() || + payload.nonce.toLowerCase() !== handoff.nonce.toLowerCase() || + payload.accessExpiresAt !== handoff.accessExpiresAt + ) + throw new Error('Receipt does not match the claimed handoff.'); + const expectedSessionId = keccak256( + toUtf8Bytes(`proofkey-device-session:${handoff.nonce}`), + ); + if (payload.sessionId.toLowerCase() !== expectedSessionId.toLowerCase()) + throw new Error( + 'Receipt session ID is not derived from the handoff nonce.', + ); + + const startedAt = Date.parse(payload.startedAt); + const endedAt = payload.endedAt ? Date.parse(payload.endedAt) : undefined; + const accessExpiresAt = Number(payload.accessExpiresAt) * 1_000; + if (!Number.isFinite(startedAt) || startedAt > accessExpiresAt) + throw new Error('Receipt start time is invalid.'); + if (payload.kind === 'start') { + if (payload.endedAt !== null || payload.measuredDurationSeconds !== 0) + throw new Error('A start receipt cannot contain completed usage.'); + } else { + if (!endedAt || endedAt < startedAt || endedAt > accessExpiresAt) + throw new Error('Receipt end time is invalid.'); + const measured = Math.floor((endedAt - startedAt) / 1_000); + if (measured !== payload.measuredDurationSeconds) + throw new Error( + 'Measured duration does not match the receipt timestamps.', + ); + const started = handoff.startReceipt?.payload; + if (!started || started.startedAt !== payload.startedAt) + throw new Error('End receipt is not bound to the stored start receipt.'); + if ( + started.sessionId.toLowerCase() !== payload.sessionId.toLowerCase() || + started.controller.toLowerCase() !== payload.controller.toLowerCase() + ) + throw new Error('End receipt identity differs from the start receipt.'); + } + + let recovered: string; + try { + recovered = verifyMessage(usageReceiptMessage(payload), receipt.signature); + } catch { + throw new Error('Receipt signature recovery failed.'); + } + if (getAddress(recovered) !== getAddress(payload.controller)) + throw new Error('Receipt signature is not from the declared controller.'); +} diff --git a/package-lock.json b/package-lock.json index fcefc43..8e40f37 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,6 +53,7 @@ "@tanstack/react-query": "^5.102.8", "ethers": "6.17.0", "lucide-react": "^1.45.0", + "qrcode": "^1.5.4", "react": "^19.3.0", "react-dom": "^19.3.0", "react-router": "^8.3.1", @@ -183,6 +184,23 @@ } } }, + "apps/web/node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "apps/worker": { "name": "@proofkey/worker", "version": "0.1.0",