oxedyne/daimond/dev/verify_genpass.mjs
10.2 KiB, 1 run
created by r2519314175:433, 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_genpass.mjs — the generated passphrase, and the login form a password |
| 2 | // manager can actually see. |
| 3 | // |
| 4 | // Two changes are asserted here, and they belong together. The passphrase is now |
| 5 | // GENERATED (eight words from the EFF long list, ~103 bits) rather than chosen, |
| 6 | // which removes the two ways a chosen one fails: guessable, and reused from a |
| 7 | // site that has since been breached. That is what makes the second change safe — |
| 8 | // the login is a REAL form with REAL `type="password"` fields and the right |
| 9 | // autocomplete tokens, so the browser and the OS keychain offer to keep it |
| 10 | // instead of the user retyping a long phrase on a phone all day. |
| 11 | // |
| 12 | // node dev/verify_genpass.mjs |
| 13 | // |
| 14 | // Needs dev/serve.mjs (DAIMOND_PORT, default 8777). No gateway and no model: this is |
| 15 | // the gate only. |
| 16 | |
| 17 | import { open } from './harness.mjs'; |
| 18 | |
| 19 | let failures = 0; |
| 20 | const check = (cond, msg, detail) => { |
| 21 | console.log((cond ? ' ok ' : ' FAIL ') + msg + (detail != null ? ' — ' + detail : '')); |
| 22 | if (!cond) failures++; |
| 23 | }; |
| 24 | |
| 25 | const s = await open({ name: 'genpass-' + Date.now(), connect: false, signIn: false }); |
| 26 | const { page } = s; |
| 27 | await page.waitForSelector('#id-primary', { timeout: 15000 }); |
| 28 | await page.waitForTimeout(400); |
| 29 | |
| 30 | // ── The create screen offers a generated passphrase ── |
| 31 | const gen = await page.evaluate(() => ({ |
| 32 | words: document.getElementById('id-genwords').textContent, |
| 33 | field: document.getElementById('id-pass').value, |
| 34 | visible: getComputedStyle(document.getElementById('id-genbox')).display !== 'none', |
| 35 | confirm: getComputedStyle(document.getElementById('id-pass2-row')).display, |
| 36 | disabled: document.getElementById('id-primary').disabled, |
| 37 | fromList: window.DaimondWords.isFromList(document.getElementById('id-pass').value), |
| 38 | bits: Math.round(window.DaimondWords.bits(8)), |
| 39 | listLen: window.DaimondWords.count(), |
| 40 | })); |
| 41 | check(gen.visible, 'the create screen shows a generated passphrase'); |
| 42 | check(gen.words.trim().split(/\s+/).length === 8, 'it is eight words', gen.words.trim().split(/\s+/).length + ' words'); |
| 43 | check(gen.fromList, 'every word comes from the shipped wordlist'); |
| 44 | check(gen.listLen === 7776, 'the wordlist is the full EFF long list', gen.listLen + ' words'); |
| 45 | check(gen.bits === 103, 'which is ~103 bits, the figure Oxegen settles on', gen.bits + ' bits'); |
| 46 | check(gen.words === gen.field, 'the readout shows exactly what the field holds'); |
| 47 | check(gen.confirm === 'none', 'no confirm field: the words are on screen to be read'); |
| 48 | check(gen.disabled, 'the create button is disabled until it is acknowledged'); |
| 49 | |
| 50 | // ── The markup is one a password manager recognises ── |
| 51 | const form = await page.evaluate(() => { |
| 52 | const p = document.getElementById('id-pass'); |
| 53 | const n = document.getElementById('id-name'); |
| 54 | const k = document.getElementById('cfg-api-key'); |
| 55 | return { |
| 56 | inForm: !!p.closest('form'), |
| 57 | passType: p.type, |
| 58 | passAuto: p.getAttribute('autocomplete'), |
| 59 | nameAuto: n.getAttribute('autocomplete'), |
| 60 | submits: (document.getElementById('id-primary') || {}).type, |
| 61 | opts: p.hasAttribute('data-1p-ignore') || p.hasAttribute('data-lpignore') |
| 62 | || p.getAttribute('autocomplete') === 'off', |
| 63 | keyType: k ? k.type : '(absent)', |
| 64 | keyIgnored: k ? (k.hasAttribute('data-1p-ignore') && k.getAttribute('autocomplete') === 'off') : false, |
| 65 | }; |
| 66 | }); |
| 67 | check(form.inForm, 'the passphrase field is inside a real <form>'); |
| 68 | check(form.passType === 'password', 'it is a real type=password input', form.passType); |
| 69 | check(form.passAuto === 'new-password', 'creating tags it autocomplete=new-password', form.passAuto); |
| 70 | check(form.nameAuto === 'username', 'the name field is tagged autocomplete=username', form.nameAuto); |
| 71 | check(form.submits === 'submit', 'the button submits the form', form.submits); |
| 72 | check(!form.opts, 'none of the manager opt-outs are left on the passphrase'); |
| 73 | // The provider API key is a different kind of secret and stays out of keychains. |
| 74 | check(form.keyType === 'text' && form.keyIgnored, |
| 75 | 'the provider API key is still masked and opted out', form.keyType); |
| 76 | |
| 77 | // ── Choosing your own is still allowed, and still has a floor ── |
| 78 | await page.click('#id-choose'); |
| 79 | const ownUi = await page.evaluate(() => ({ |
| 80 | genbox: getComputedStyle(document.getElementById('id-genbox')).display, |
| 81 | confirm: getComputedStyle(document.getElementById('id-pass2-row')).display, |
| 82 | field: document.getElementById('id-pass').value, |
| 83 | })); |
| 84 | check(ownUi.genbox === 'none', 'choosing your own hides the generated phrase'); |
| 85 | check(ownUi.confirm !== 'none', 'and brings back the confirm field, since it is typed blind'); |
| 86 | check(ownUi.field === '', 'the generated phrase is cleared rather than left to be edited'); |
| 87 | |
| 88 | await page.fill('#id-name', 'Own Tester'); |
| 89 | await page.fill('#id-pass', 'short'); |
| 90 | await page.fill('#id-pass2', 'short'); |
| 91 | await page.evaluate(() => document.getElementById('id-primary').click()); |
| 92 | await page.waitForTimeout(400); |
| 93 | let ownErr = await page.evaluate(() => document.getElementById('id-error').textContent); |
| 94 | check(/at least 8/.test(ownErr), 'a short chosen passphrase is refused', ownErr); |
| 95 | |
| 96 | await page.fill('#id-pass', 'a long enough one'); |
| 97 | await page.fill('#id-pass2', 'a different one entirely'); |
| 98 | await page.evaluate(() => document.getElementById('id-primary').click()); |
| 99 | await page.waitForTimeout(400); |
| 100 | ownErr = await page.evaluate(() => document.getElementById('id-error').textContent); |
| 101 | check(/do not match/.test(ownErr), 'a mismatched confirmation is refused', ownErr); |
| 102 | |
| 103 | // Back to a fresh create screen, generated by default again. |
| 104 | await page.reload({ waitUntil: 'domcontentloaded' }); |
| 105 | await page.waitForSelector('#id-primary', { timeout: 15000 }); |
| 106 | await page.waitForTimeout(400); |
| 107 | const backToGen = await page.evaluate(() => |
| 108 | getComputedStyle(document.getElementById('id-genbox')).display !== 'none'); |
| 109 | check(backToGen, 'a fresh visit is back to a generated passphrase by default'); |
| 110 | |
| 111 | // ── Generating another really changes it ── |
| 112 | const first = await page.evaluate(() => document.getElementById('id-pass').value); |
| 113 | await page.click('#id-regen'); |
| 114 | await page.waitForTimeout(150); |
| 115 | const second = await page.evaluate(() => document.getElementById('id-pass').value); |
| 116 | check(second !== first && second.split(' ').length === 8, 'Generate another draws a fresh phrase'); |
| 117 | const readoutTracks = await page.evaluate(() => |
| 118 | document.getElementById('id-genwords').textContent === document.getElementById('id-pass').value); |
| 119 | check(readoutTracks, 'and the readout follows the field'); |
| 120 | |
| 121 | // ── Create the account with it ── |
| 122 | const phrase = second; |
| 123 | await page.fill('#id-name', 'Gen Tester'); |
| 124 | await page.check('#id-wrote', { force: true }); |
| 125 | const enabled = await page.evaluate(() => !document.getElementById('id-primary').disabled); |
| 126 | check(enabled, 'acknowledging it enables the create button'); |
| 127 | await page.evaluate(() => document.getElementById('id-primary').click()); |
| 128 | const made = await page.waitForSelector('#identity-modal', { state: 'hidden', timeout: 20000 }) |
| 129 | .then(() => true).catch(() => false); |
| 130 | check(made, 'the account is created from the generated passphrase'); |
| 131 | |
| 132 | // ── A reload locks it; the same phrase unlocks ── |
| 133 | await page.reload({ waitUntil: 'domcontentloaded' }); |
| 134 | await page.waitForSelector('#id-primary', { timeout: 15000 }); |
| 135 | await page.waitForTimeout(400); |
| 136 | const unlockUi = await page.evaluate(() => ({ |
| 137 | mode: document.getElementById('identity-modal').dataset.mode, |
| 138 | passAuto: document.getElementById('id-pass').getAttribute('autocomplete'), |
| 139 | nameVal: document.getElementById('id-name').value, |
| 140 | nameRO: document.getElementById('id-name').readOnly, |
| 141 | genbox: getComputedStyle(document.getElementById('id-genbox')).display, |
| 142 | confirm: getComputedStyle(document.getElementById('id-pass2-row')).display, |
| 143 | choose: getComputedStyle(document.getElementById('id-choose')).display, |
| 144 | })); |
| 145 | check(unlockUi.mode === 'unlock', 'a reload comes back locked', unlockUi.mode); |
| 146 | check(unlockUi.passAuto === 'current-password', 'unlocking tags it autocomplete=current-password', unlockUi.passAuto); |
| 147 | check(unlockUi.nameVal === 'Gen Tester', 'the username field carries the account name', unlockUi.nameVal); |
| 148 | check(unlockUi.nameRO, 'and is read-only there, naming the account rather than choosing one'); |
| 149 | check(unlockUi.genbox === 'none', 'no passphrase is generated on the unlock screen'); |
| 150 | // The confirm field is a new-password input; if it shows on unlock the browser |
| 151 | // offers to GENERATE a passphrase next to a box asking to confirm one, mid-unlock. |
| 152 | check(unlockUi.confirm === 'none', 'no confirm-passphrase box on the unlock screen', unlockUi.confirm); |
| 153 | check(unlockUi.choose === 'none', 'no "choose my own" on the unlock screen', unlockUi.choose); |
| 154 | |
| 155 | await page.fill('#id-pass', phrase); |
| 156 | await page.evaluate(() => document.getElementById('id-primary').click()); |
| 157 | const opened = await page.waitForSelector('#identity-modal', { state: 'hidden', timeout: 20000 }) |
| 158 | .then(() => true).catch(() => false); |
| 159 | check(opened, 'the generated passphrase unlocks it again'); |
| 160 | |
| 161 | // ── A trailing space, as a phone keyboard adds, still unlocks ── |
| 162 | await page.reload({ waitUntil: 'domcontentloaded' }); |
| 163 | await page.waitForSelector('#id-primary', { timeout: 15000 }); |
| 164 | await page.waitForTimeout(400); |
| 165 | await page.fill('#id-pass', phrase + ' '); |
| 166 | await page.evaluate(() => document.getElementById('id-primary').click()); |
| 167 | const padded = await page.waitForSelector('#identity-modal', { state: 'hidden', timeout: 25000 }) |
| 168 | .then(() => true).catch(() => false); |
| 169 | check(padded, 'a trailing space does not lock the user out'); |
| 170 | |
| 171 | // ── A wrong passphrase is still refused ── |
| 172 | await page.reload({ waitUntil: 'domcontentloaded' }); |
| 173 | await page.waitForSelector('#id-primary', { timeout: 15000 }); |
| 174 | await page.waitForTimeout(400); |
| 175 | await page.fill('#id-pass', 'not the passphrase at all'); |
| 176 | await page.evaluate(() => document.getElementById('id-primary').click()); |
| 177 | await page.waitForTimeout(3000); |
| 178 | const refused = await page.evaluate(() => ({ |
| 179 | shown: document.getElementById('identity-modal').style.display !== 'none', |
| 180 | err: document.getElementById('id-error').textContent, |
| 181 | })); |
| 182 | check(refused.shown && /did not match/.test(refused.err), 'a wrong passphrase is refused', refused.err); |
| 183 | |
| 184 | const hardErrs = s.errs.filter(e => !/502|Bad Gateway|Failed to load resource/.test(e)); |
| 185 | check(hardErrs.length === 0, 'no console errors', hardErrs.join(' | ') || 'none'); |
| 186 | |
| 187 | console.log(`\n${failures ? failures + ' FAILED' : 'all passed'}`); |
| 188 | await s.close(); |
| 189 | process.exit(failures ? 1 : 0); |