oxedyne/fe2o3/fe2o3_net/tools/presentation_fixture.cjs
4.3 KiB, 1 run
created by r1870400018:61162, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | // Captures a named presentation signed by a real WebCrypto Ed25519 key in |
| 2 | // headless Chromium, for fe2o3_net's tests/presentation.rs. The browser makes |
| 3 | // both keys (an issuer's and a member's) non-extractable, signs the head and the |
| 4 | // presentation over its own canonical JSON (keys sorted, JSON.stringify, no |
| 5 | // whitespace), and hands back only public material. The Rust side must accept |
| 6 | // the lot, which it can only do if it rebuilds the browser's bytes exactly and |
| 7 | // verifies the browser's signatures. |
| 8 | // |
| 9 | // Run from fe2o3_net, with NODE_PATH naming a node_modules that holds playwright, |
| 10 | // and CHROMIUM_PATH naming a Chromium binary when playwright's own is absent: |
| 11 | // |
| 12 | // NODE_PATH=<dir>/node_modules node tools/presentation_fixture.cjs \ |
| 13 | // > tests/data/presentation_webcrypto.json |
| 14 | |
| 15 | 'use strict'; |
| 16 | |
| 17 | const { chromium } = require('playwright'); |
| 18 | |
| 19 | (async () => { |
| 20 | const browser = await chromium.launch({ |
| 21 | headless: true, |
| 22 | executablePath: process.env.CHROMIUM_PATH || undefined, |
| 23 | }); |
| 24 | const page = await browser.newPage(); |
| 25 | // WebCrypto lives only in a secure context, so serve a blank page on https. |
| 26 | await page.route('https://fixture.test/**', route => route.fulfill({ |
| 27 | status: 200, |
| 28 | contentType: 'text/html', |
| 29 | body: '<!doctype html><title>fixture</title>', |
| 30 | })); |
| 31 | await page.goto('https://fixture.test/'); |
| 32 | |
| 33 | const out = await page.evaluate(async () => { |
| 34 | const enc = new TextEncoder(); |
| 35 | const sorted = v => Array.isArray(v) ? v.map(sorted) |
| 36 | : (v && typeof v === 'object') |
| 37 | ? Object.fromEntries(Object.keys(v).sort().map(k => [k, sorted(v[k])])) |
| 38 | : v; |
| 39 | const jcs = v => JSON.stringify(sorted(v)); |
| 40 | const b64u = bytes => btoa(String.fromCharCode(...bytes)) |
| 41 | .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); |
| 42 | const hex = bytes => [...bytes].map(b => b.toString(16).padStart(2, '0')).join(''); |
| 43 | const sha256 = async bytes => new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)); |
| 44 | const keyPair = () => crypto.subtle.generateKey({ name: 'Ed25519' }, false, ['sign', 'verify']); |
| 45 | const raw = async pair => new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey)); |
| 46 | const sign = async (pair, text) => new Uint8Array( |
| 47 | await crypto.subtle.sign({ name: 'Ed25519' }, pair.privateKey, enc.encode(text))); |
| 48 | |
| 49 | const issuer = await keyPair(); |
| 50 | const member = await keyPair(); |
| 51 | const issuerPub = await raw(issuer); |
| 52 | const memberPub = await raw(member); |
| 53 | const now = Math.floor(Date.now() / 1000); |
| 54 | const rp_id = 'https://app.example'; |
| 55 | const nonce = crypto.getRandomValues(new Uint8Array(32)); |
| 56 | |
| 57 | // A head with an empty ring list, which is what a named presentation |
| 58 | // needs and what a network with no ring keys yet serves. |
| 59 | const headBody = { |
| 60 | v: 'head/1', epoch: 1, prev: null, ts: now - 30, |
| 61 | salt: b64u(crypto.getRandomValues(new Uint8Array(16))), |
| 62 | members: 1, ring_n: 0, ring_digest: b64u(await sha256(new Uint8Array(0))), |
| 63 | signer: b64u(issuerPub), |
| 64 | }; |
| 65 | const headText = jcs(headBody); |
| 66 | const headId = await sha256(enc.encode(headText)); |
| 67 | const head = { ...headBody, head: b64u(headId), sig: b64u(await sign(issuer, headText)) }; |
| 68 | |
| 69 | const body = { |
| 70 | v: 'present/1', mode: 'named', rp_id, nonce: b64u(nonce), |
| 71 | sub: hex((await sha256(memberPub)).slice(0, 5)), pub: b64u(memberPub), |
| 72 | predicates: ['adult'], head: b64u(headId), ts: now, |
| 73 | }; |
| 74 | const text = jcs(body); |
| 75 | const presentation = JSON.stringify({ ...body, sig: b64u(await sign(member, text)) }); |
| 76 | |
| 77 | return { |
| 78 | browser: navigator.userAgent, |
| 79 | now, |
| 80 | rp_id, |
| 81 | request: { |
| 82 | v: 'present-req/1', rp_id, nonce: b64u(nonce), |
| 83 | modes: ['pairwise', 'named'], predicates: ['adult'], exp: now + 300, |
| 84 | }, |
| 85 | head, |
| 86 | presentation, |
| 87 | status: { pub: b64u(memberPub), live: true }, |
| 88 | }; |
| 89 | }); |
| 90 | |
| 91 | process.stdout.write(JSON.stringify(out, null, 1) + '\n'); |
| 92 | await browser.close(); |
| 93 | })().catch(e => { |
| 94 | process.stderr.write(String(e && e.stack || e) + '\n'); |
| 95 | process.exit(1); |
| 96 | }); |