Oregami
Repositories/oxedyne/daimond

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
26import { open, errors, PASS } from './harness.mjs';
27import { GW_PORT } from './ports.mjs';
28
29const sleep = ms => new Promise(r => setTimeout(r, ms));
30let failures = 0;
31const check = (cond, msg, detail) => {
32 console.log((cond ? ' ok ' : ' FAIL ') + msg + (detail != null ? ' — ' + detail : ''));
33 if (!cond) failures++;
34};
35
36const s = await open({ name: 'adopt-' + Date.now(), connect: false });
37const { page } = s;
38
39// ── A virtual platform authenticator with PRF ──
40const cdp = await s.browser.newCDPSession(page);
41await cdp.send('WebAuthn.enable');
42let authId = null;
43for (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}
56if (!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 ──
59await page.waitForFunction(() => window.DaimondGateway
60 && DaimondGateway.state().authed === true, { timeout: 20000 })
61 .catch(() => {});
62const authed = await page.evaluate(() => !!(window.DaimondGateway && DaimondGateway.state().authed));
63check(authed, 'the signed-in account has a gateway session to store against');
64if (!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
70const 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 ──
76const 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);
80check(enrolled && enrolled.ok, 'a passkey enrols', enrolled && enrolled.error);
81if (!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}
86check(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.
90const rec = await page.evaluate(() => JSON.parse(localStorage.getItem('daimond-passkey')));
91check(rec && rec.v === 2 && !rec.salt, 'the local record is v2 and carries no salt');
92
93const 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});
107check(served.status === 200 && served.hasBlob, 'the gateway serves it back without a session', 'HTTP ' + served.status);
108check(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.
111const 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});
116check(missing === 404, 'an unknown handle is a flat 404', 'HTTP ' + missing);
117const malformed = await page.evaluate(async () =>
118 (await fetch('/api/passkey-blob?h=nonsense')).status);
119check(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.
124await 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});
133await page.reload({ waitUntil: 'domcontentloaded' });
134await page.waitForSelector('#id-primary', { timeout: 15000 });
135await page.waitForTimeout(600);
136
137const 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}));
144check(fresh.mode === 'create', 'the wiped browser comes up as a new device', fresh.mode);
145check(!fresh.exists && !fresh.enrolled, 'with no identity and no local passkey record');
146check(fresh.btnShown, 'and it OFFERS the passkey as a way in');
147check(/I have a passkey/.test(fresh.btnText || ''), 'worded as bringing an account across', fresh.btnText);
148
149// ── The one gesture ──
150await page.click('#id-passkey');
151const adopted = await page.waitForSelector('#identity-modal', { state: 'hidden', timeout: 20000 })
152 .then(() => true).catch(() => false);
153check(adopted, 'the passkey alone brings the account to this device');
154
155const 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}));
162check(after.unlocked, 'the identity is unlocked, with nothing typed');
163check(!after.locked, 'and the app is open');
164check(after.fp === before.fp, 'it is the SAME identity, not a new one', after.fp + ' vs ' + before.fp);
165check(after.name === before.name, 'carrying its name across', after.name);
166check(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 ──
169await page.evaluate(async () => { await window.DaimondPasskey.remove(); });
170await sleep(400);
171const revoked = await page.evaluate(async () => {
172 const lab = new TextEncoder().encode('daimond-passkey-handle-v1');
173 return { local: localStorage.getItem('daimond-passkey') };
174});
175check(!revoked.local, 'remove() clears the local record');
176const 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}));
194check(goneFromGateway === 404, 'and the gateway no longer serves the bundle', 'HTTP ' + goneFromGateway);
195
196const errs = errors(s).filter(e => !/Failed to load resource/i.test(e));
197check(errs.length === 0, 'no unexpected console errors', errs.join(' | ') || 'none');
198
199console.log(`\n${failures ? failures + ' FAILED' : 'all passed'}`);
200await s.close();
201process.exit(failures ? 1 : 0);