oxedyne/daimond/dev/verify_passkey_adopt.mjs
9.3 KiB, 1 run
created by r2519314175:573, 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 | // verify_passkey_adopt.mjs — a passkey brings the whole account to a device |
| 2 | // that holds nothing. |
| 3 | // |
| 4 | // This is the capability a passkey did NOT have before. The credential syncs |
| 5 | // through iCloud Keychain or Google Password Manager, but the sealed copy of the |
| 6 | // identity lived in one browser's localStorage, so every new device still needed |
| 7 | // a pairing code typed from a device that was already open. The sealed bundle |
| 8 | // now lives on the gateway too, keyed by a hash of the credential id, so: |
| 9 | // |
| 10 | // one discoverable assertion → credential id + its PRF secret |
| 11 | // → fetch the sealed bundle → open it → import the identity → unlocked |
| 12 | // |
| 13 | // with nothing typed and no pairing code. |
| 14 | // |
| 15 | // node dev/verify_passkey_adopt.mjs |
| 16 | // |
| 17 | // Needs dev/serve.mjs (DAIMOND_PORT, default 8777) AND the gateway on :9002 — the whole |
| 18 | // point is that the bundle round-trips through the gateway. |
| 19 | // |
| 20 | // HARNESS LIMIT: a virtual authenticator's hmac-secret cannot be exported, so a |
| 21 | // genuinely second browser cannot be given the same PRF secret. The "new device" |
| 22 | // here is therefore the same authenticator with the browser's stored state |
| 23 | // wiped — which exercises every step above except the credential sync itself, |
| 24 | // the one part the platform does rather than us. |
| 25 | |
| 26 | import { open, errors, PASS } from './harness.mjs'; |
| 27 | import { GW_PORT } from './ports.mjs'; |
| 28 | |
| 29 | const sleep = ms => new Promise(r => setTimeout(r, ms)); |
| 30 | let failures = 0; |
| 31 | const check = (cond, msg, detail) => { |
| 32 | console.log((cond ? ' ok ' : ' FAIL ') + msg + (detail != null ? ' — ' + detail : '')); |
| 33 | if (!cond) failures++; |
| 34 | }; |
| 35 | |
| 36 | const s = await open({ name: 'adopt-' + Date.now(), connect: false }); |
| 37 | const { page } = s; |
| 38 | |
| 39 | // ── A virtual platform authenticator with PRF ── |
| 40 | const cdp = await s.browser.newCDPSession(page); |
| 41 | await cdp.send('WebAuthn.enable'); |
| 42 | let authId = null; |
| 43 | for (const extra of [{ ctap2Version: 'ctap2_1', hasPrf: true }, { hasPrf: true }, {}]) { |
| 44 | try { |
| 45 | const r = await cdp.send('WebAuthn.addVirtualAuthenticator', { |
| 46 | options: Object.assign({ |
| 47 | protocol: 'ctap2', transport: 'internal', hasResidentKey: true, |
| 48 | hasUserVerification: true, isUserVerified: true, |
| 49 | automaticPresenceSimulation: true, |
| 50 | }, extra), |
| 51 | }); |
| 52 | authId = r.authenticatorId; |
| 53 | break; |
| 54 | } catch (e) { /* try the next option shape */ } |
| 55 | } |
| 56 | if (!authId) { console.log('FAIL could not create a virtual authenticator'); await s.close(); process.exit(1); } |
| 57 | |
| 58 | // ── The account must have a gateway session, or the bundle cannot be stored ── |
| 59 | await page.waitForFunction(() => window.DaimondGateway |
| 60 | && DaimondGateway.state().authed === true, { timeout: 20000 }) |
| 61 | .catch(() => {}); |
| 62 | const authed = await page.evaluate(() => !!(window.DaimondGateway && DaimondGateway.state().authed)); |
| 63 | check(authed, 'the signed-in account has a gateway session to store against'); |
| 64 | if (!authed) { |
| 65 | console.log(` (is the gateway running on :${GW_PORT}, this world's own? this test needs it)`); |
| 66 | await s.close(); |
| 67 | process.exit(1); |
| 68 | } |
| 69 | |
| 70 | const before = await page.evaluate(() => ({ |
| 71 | fp: DaimondIdentity.fingerprint(), |
| 72 | name: DaimondIdentity.displayName(), |
| 73 | })); |
| 74 | |
| 75 | // ── Enrol a passkey; the sealed bundle should reach the gateway ── |
| 76 | const enrolled = await page.evaluate(async (pass) => { |
| 77 | const r = await window.DaimondPasskey.enrol(pass).catch(e => ({ ok: false, error: String(e) })); |
| 78 | return r; |
| 79 | }, PASS); |
| 80 | check(enrolled && enrolled.ok, 'a passkey enrols', enrolled && enrolled.error); |
| 81 | if (!enrolled || !enrolled.ok) { |
| 82 | console.log(' (the virtual authenticator did not surface PRF; nothing further can be exercised)'); |
| 83 | await s.close(); |
| 84 | process.exit(1); |
| 85 | } |
| 86 | check(enrolled.synced === true, 'and its sealed bundle reaches the gateway'); |
| 87 | |
| 88 | // It really is on the gateway: ask for it the way a bare device would, with no |
| 89 | // session and no identity — just the handle. |
| 90 | const rec = await page.evaluate(() => JSON.parse(localStorage.getItem('daimond-passkey'))); |
| 91 | check(rec && rec.v === 2 && !rec.salt, 'the local record is v2 and carries no salt'); |
| 92 | |
| 93 | const served = await page.evaluate(async () => { |
| 94 | // Recompute the handle exactly as passkey.js does, then fetch it cold. |
| 95 | const r = JSON.parse(localStorage.getItem('daimond-passkey')); |
| 96 | const id = Uint8Array.from(atob(r.cred.replace(/-/g, '+').replace(/_/g, '/') |
| 97 | + '==='.slice((r.cred.length + 3) % 4)), c => c.charCodeAt(0)); |
| 98 | const lab = new TextEncoder().encode('daimond-passkey-handle-v1'); |
| 99 | const buf = new Uint8Array(lab.length + id.length); |
| 100 | buf.set(lab, 0); buf.set(id, lab.length); |
| 101 | const h = btoa(String.fromCharCode(...new Uint8Array(await crypto.subtle.digest('SHA-256', buf)))) |
| 102 | .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); |
| 103 | const res = await fetch('/api/passkey-blob?h=' + encodeURIComponent(h)); |
| 104 | const j = await res.json().catch(() => null); |
| 105 | return { status: res.status, hasBlob: !!(j && j.blob), sameBlob: !!(j && j.blob === r.blob) }; |
| 106 | }); |
| 107 | check(served.status === 200 && served.hasBlob, 'the gateway serves it back without a session', 'HTTP ' + served.status); |
| 108 | check(served.sameBlob, 'and it is byte-for-byte the blob that was sealed'); |
| 109 | |
| 110 | // A handle nobody has stored is a plain 404, not a hint. |
| 111 | const missing = await page.evaluate(async () => { |
| 112 | const h = 'A'.repeat(43); |
| 113 | const res = await fetch('/api/passkey-blob?h=' + h); |
| 114 | return res.status; |
| 115 | }); |
| 116 | check(missing === 404, 'an unknown handle is a flat 404', 'HTTP ' + missing); |
| 117 | const malformed = await page.evaluate(async () => |
| 118 | (await fetch('/api/passkey-blob?h=nonsense')).status); |
| 119 | check(malformed === 400, 'a malformed handle is refused before any lookup', 'HTTP ' + malformed); |
| 120 | |
| 121 | // ── Now become a device that holds nothing ── |
| 122 | // Everything this browser knows about the account goes, keeping only the |
| 123 | // authenticator — which is what a new phone with a synced passkey actually has. |
| 124 | await page.evaluate(async () => { |
| 125 | localStorage.clear(); |
| 126 | try { |
| 127 | const root = await navigator.storage.getDirectory(); |
| 128 | for await (const ent of root.entries()) { |
| 129 | await root.removeEntry(ent[0], { recursive: true }).catch(() => {}); |
| 130 | } |
| 131 | } catch (e) { /* OPFS may be unavailable */ } |
| 132 | }); |
| 133 | await page.reload({ waitUntil: 'domcontentloaded' }); |
| 134 | await page.waitForSelector('#id-primary', { timeout: 15000 }); |
| 135 | await page.waitForTimeout(600); |
| 136 | |
| 137 | const fresh = await page.evaluate(() => ({ |
| 138 | mode: document.getElementById('identity-modal').dataset.mode, |
| 139 | exists: DaimondIdentity.exists(), |
| 140 | enrolled: window.DaimondPasskey.isEnrolled(), |
| 141 | btnText: (document.querySelector('#id-passkey span') || {}).textContent, |
| 142 | btnShown: getComputedStyle(document.getElementById('id-passkey')).display !== 'none', |
| 143 | })); |
| 144 | check(fresh.mode === 'create', 'the wiped browser comes up as a new device', fresh.mode); |
| 145 | check(!fresh.exists && !fresh.enrolled, 'with no identity and no local passkey record'); |
| 146 | check(fresh.btnShown, 'and it OFFERS the passkey as a way in'); |
| 147 | check(/I have a passkey/.test(fresh.btnText || ''), 'worded as bringing an account across', fresh.btnText); |
| 148 | |
| 149 | // ── The one gesture ── |
| 150 | await page.click('#id-passkey'); |
| 151 | const adopted = await page.waitForSelector('#identity-modal', { state: 'hidden', timeout: 20000 }) |
| 152 | .then(() => true).catch(() => false); |
| 153 | check(adopted, 'the passkey alone brings the account to this device'); |
| 154 | |
| 155 | const after = await page.evaluate(() => ({ |
| 156 | fp: DaimondIdentity.fingerprint(), |
| 157 | name: DaimondIdentity.displayName(), |
| 158 | unlocked: DaimondIdentity.isUnlocked(), |
| 159 | locked: document.body.classList.contains('locked'), |
| 160 | rec: JSON.parse(localStorage.getItem('daimond-passkey') || 'null'), |
| 161 | })); |
| 162 | check(after.unlocked, 'the identity is unlocked, with nothing typed'); |
| 163 | check(!after.locked, 'and the app is open'); |
| 164 | check(after.fp === before.fp, 'it is the SAME identity, not a new one', after.fp + ' vs ' + before.fp); |
| 165 | check(after.name === before.name, 'carrying its name across', after.name); |
| 166 | check(after.rec && after.rec.v === 2, 'and the sealed blob is cached locally for next time'); |
| 167 | |
| 168 | // ── Removing it revokes the gateway copy too ── |
| 169 | await page.evaluate(async () => { await window.DaimondPasskey.remove(); }); |
| 170 | await sleep(400); |
| 171 | const revoked = await page.evaluate(async () => { |
| 172 | const lab = new TextEncoder().encode('daimond-passkey-handle-v1'); |
| 173 | return { local: localStorage.getItem('daimond-passkey') }; |
| 174 | }); |
| 175 | check(!revoked.local, 'remove() clears the local record'); |
| 176 | const goneFromGateway = await page.evaluate(async (h) => { |
| 177 | const res = await fetch('/api/passkey-blob?h=' + encodeURIComponent(h)); |
| 178 | return res.status; |
| 179 | }, await page.evaluate(async () => { |
| 180 | // The handle is recomputed from the credential the authenticator still holds. |
| 181 | const got = await navigator.credentials.get({ publicKey: { |
| 182 | rpId: location.hostname, |
| 183 | challenge: crypto.getRandomValues(new Uint8Array(32)), |
| 184 | userVerification: 'required', |
| 185 | }}).catch(() => null); |
| 186 | if (!got) return 'A'.repeat(43); |
| 187 | const id = new Uint8Array(got.rawId); |
| 188 | const lab = new TextEncoder().encode('daimond-passkey-handle-v1'); |
| 189 | const buf = new Uint8Array(lab.length + id.length); |
| 190 | buf.set(lab, 0); buf.set(id, lab.length); |
| 191 | return btoa(String.fromCharCode(...new Uint8Array(await crypto.subtle.digest('SHA-256', buf)))) |
| 192 | .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); |
| 193 | })); |
| 194 | check(goneFromGateway === 404, 'and the gateway no longer serves the bundle', 'HTTP ' + goneFromGateway); |
| 195 | |
| 196 | const errs = errors(s).filter(e => !/Failed to load resource/i.test(e)); |
| 197 | check(errs.length === 0, 'no unexpected console errors', errs.join(' | ') || 'none'); |
| 198 | |
| 199 | console.log(`\n${failures ? failures + ' FAILED' : 'all passed'}`); |
| 200 | await s.close(); |
| 201 | process.exit(failures ? 1 : 0); |