oxedyne/daimond/dev/verify_forgetkeys.mjs
12.1 KiB, 1 run
created by r2519314175:423, 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_forgetkeys.mjs — "Forget this identity" forgets the security settings too. |
| 2 | // |
| 3 | // THE DEFECT. The sweep in `forgetIdentity` (www/js/daimond.js) is a NAMED LIST, and |
| 4 | // the comment above it said the rest was caught by `remove()` below. It is not: |
| 5 | // `remove()` is guarded by `!acct.primary`, and accounts.js's own `remove()` refuses |
| 6 | // the primary outright and sweeps only `d~<id>~` keys anyway. There is no |
| 7 | // `localStorage.clear()` anywhere in the app. So on an ordinary single-user install |
| 8 | // — which is nearly all of them — a key missing from that list SURVIVES BEING |
| 9 | // FORGOTTEN, and the sentence saying otherwise is why nobody looked. |
| 10 | // |
| 11 | // WHAT A USER MEETS. A laptop is handed over. "Forget this identity" is used. A new |
| 12 | // identity is made — and it boots in BYPASS, where nothing is asked, having never |
| 13 | // been shown the one-off explanation of what bypass gives away, with commands |
| 14 | // granted the network in every chat. Nobody in that browser has answered one of |
| 15 | // those questions. Worse still sat beside them: the enrolled PASSKEY, whose record |
| 16 | // seals the identity bundle and the passphrase together (passkey.js, v2), so the |
| 17 | // browser kept a working door into the identity that had just been erased. |
| 18 | // |
| 19 | // THE TEST APPLIED, because "which keys" kept being answered case by case: a key |
| 20 | // belongs in that list when its ABSENCE is the careful default and its stale value |
| 21 | // would GRANT something the next person never chose, or SILENCE a warning they have |
| 22 | // never seen. Eight keys meet it. They are seeded here with permissive values, and |
| 23 | // the whole namespace was read against that test rather than only the three that |
| 24 | // were reported. |
| 25 | // |
| 26 | // FIVE PROPERTIES: |
| 27 | // |
| 28 | // 1. THE PREMISE, ASSERTED AND NOT ASSUMED. The account being forgotten is the |
| 29 | // PRIMARY, and `DaimondAccounts.remove()` refuses it — so the named list really |
| 30 | // is the whole sweep. Held first, because every check below is only about |
| 31 | // anything at all if this is true, and a future change that made `remove()` |
| 32 | // general would make them pass for a reason that had nothing to do with them. |
| 33 | // 2. THE THREE PERMISSION SETTINGS ARE GONE — the standing network answer, the |
| 34 | // rung, and the bypass acknowledgement. |
| 35 | // 3. AND SO IS THE PASSKEY, which is the same fault at its worst. |
| 36 | // 4. AND THE FOUR OTHERS THE SWEEP OF THE NAMESPACE FOUND: the terminal's folder |
| 37 | // ceiling, the trust log, the spend ceiling — and the agreement to be |
| 38 | // recorded, which turned out to be cleared already by the sign-out that runs |
| 39 | // first, and is asserted here because that call is wrapped in a `try/catch` |
| 40 | // that says "erase anyway". |
| 41 | // 5. AND THE APP COMES BACK IN THE CAREFUL STATE, read from the engine and not |
| 42 | // from the absent key: guarded, and asking about the network in each chat. |
| 43 | // Separate from 2 because a key removed and a default not taken are two |
| 44 | // claims, and a build that read the rung from somewhere else would satisfy |
| 45 | // the first alone. |
| 46 | // |
| 47 | // PROVED AGAINST BROKEN CODE FIRST: |
| 48 | // |
| 49 | // node dev/verify_forgetkeys.mjs --break three # 2, 5: the state before the fix |
| 50 | // node dev/verify_forgetkeys.mjs --break passkey # 3 |
| 51 | // node dev/verify_forgetkeys.mjs --break sweep # 2-4: the whole tail dropped |
| 52 | // node dev/verify_forgetkeys.mjs # and then, clean |
| 53 | // |
| 54 | // eval "$(bash dev/world.sh 4 --up)" |
| 55 | // node dev/verify_forgetkeys.mjs |
| 56 | // |
| 57 | // Needs dev/serve.mjs and the mock. No gateway, no wasm rebuild. |
| 58 | import fs from 'node:fs'; |
| 59 | import path from 'node:path'; |
| 60 | import { fileURLToPath } from 'node:url'; |
| 61 | import { open, scratch, shot } from './harness.mjs'; |
| 62 | |
| 63 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 64 | const WWW = path.join(HERE, '..', 'www'); |
| 65 | |
| 66 | const BREAK = (() => { |
| 67 | const i = process.argv.indexOf('--break'); |
| 68 | return i > 0 ? String(process.argv[i + 1] || '') : ''; |
| 69 | })(); |
| 70 | |
| 71 | // Each break is one real edit to the served file: the sweep as it stood, in pieces. |
| 72 | const BREAKS = { |
| 73 | three: { |
| 74 | file: 'js/daimond.js', |
| 75 | find: "\t\t\t 'daimond-net-standing', 'daimond-permission-mode', 'daimond-permission-bypass-ack',", |
| 76 | with: "", |
| 77 | }, |
| 78 | passkey: { |
| 79 | file: 'js/daimond.js', |
| 80 | find: "\t\t\t 'daimond-passkey', 'daimond-passkey-asked',", |
| 81 | with: "", |
| 82 | }, |
| 83 | // Everything after the trash swept into a list nothing runs: the sweep as it was |
| 84 | // the day before the three were reported, with the ordinary stores still going. |
| 85 | sweep: { |
| 86 | file: 'js/daimond.js', |
| 87 | find: "\t\t\t 'daimond-trash',\n", |
| 88 | with: "\t\t\t 'daimond-trash',\n\t\t\t].forEach(function (k) { localStorage.removeItem(k); });\n\t\t\tif (false) [\n", |
| 89 | }, |
| 90 | }; |
| 91 | if (BREAK && !BREAKS[BREAK]) { |
| 92 | console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`); |
| 93 | process.exit(2); |
| 94 | } |
| 95 | |
| 96 | let bad = 0; |
| 97 | const check = (pass, name, detail) => { |
| 98 | if (!pass) bad++; |
| 99 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 100 | }; |
| 101 | |
| 102 | const stub = async (page) => { |
| 103 | if (!BREAK) return; |
| 104 | const spec = BREAKS[BREAK]; |
| 105 | const src = fs.readFileSync(path.join(WWW, spec.file), 'utf8'); |
| 106 | // An anchor that is not there exactly once patches nothing and the run would |
| 107 | // pass quietly, which is worse than a red. |
| 108 | if (src.split(spec.find).length !== 2) { |
| 109 | console.error(`break '${BREAK}': its anchor is not in ${spec.file} exactly once`); |
| 110 | process.exit(2); |
| 111 | } |
| 112 | const body = src.replace(spec.find, spec.with); |
| 113 | await page.route('**/' + spec.file, (r) => r.fulfill({ |
| 114 | status: 200, contentType: 'application/javascript', body, |
| 115 | })); |
| 116 | }; |
| 117 | |
| 118 | /// Everything seeded before the forget, with the value that makes it a GRANT rather |
| 119 | /// than a preference. The shapes are the ones the owning module writes: a stored rung |
| 120 | /// this build does not recognise falls back to the careful one, so a nonsense value |
| 121 | /// would make check 5 pass for the wrong reason. |
| 122 | const SEED = { |
| 123 | 'daimond-net-standing': 'allow', |
| 124 | 'daimond-permission-mode': 'bypass', |
| 125 | 'daimond-permission-bypass-ack': '1', |
| 126 | 'daimond-passkey': JSON.stringify({ v: 2, cred: 'Y3JlZA==', blob: 'YmxvYg==' }), |
| 127 | 'daimond-telemetry': '__ACCOUNT__', |
| 128 | 'daimond-terminal-root': JSON.stringify({ 'ws-1': '/home' }), |
| 129 | 'daimond-trust-log': JSON.stringify([{ scope: 'identity', method: 'in_person_qr' }]), |
| 130 | 'daimond-governor': JSON.stringify({ budgetUsd: 500 }), |
| 131 | }; |
| 132 | |
| 133 | const s = await open({ |
| 134 | name: 'forgetkeys', |
| 135 | profile: scratch('pw', 'forgetkeys' + (BREAK ? '-' + BREAK : '')), |
| 136 | route: stub, |
| 137 | }); |
| 138 | const { page: p } = s; |
| 139 | if (BREAK) console.log(`\n*** RUNNING UNDER --break ${BREAK}: failures below are the point ***\n`); |
| 140 | |
| 141 | try { |
| 142 | // ── 1. The premise ─────────────────────────────────────────── |
| 143 | // |
| 144 | // THE ACCOUNT IS THE PRIMARY AND CANNOT BE REMOVED, which is what makes the |
| 145 | // named list the whole sweep. Asked of accounts.js itself rather than read off |
| 146 | // the registry: `remove()` returning false is the behaviour the sweep depends |
| 147 | // on, and a registry that merely says `primary: true` is a description of it. |
| 148 | const premise = await p.evaluate(() => { |
| 149 | const A = window.DaimondAccounts; |
| 150 | if (!A) return { has: false }; |
| 151 | const id = A.current(); |
| 152 | return { has: true, id: id, removed: A.remove(id), n: A.count() }; |
| 153 | }); |
| 154 | check(premise.has && premise.removed === false && premise.n === 1, |
| 155 | 'THE ACCOUNT BEING FORGOTTEN IS THE PRIMARY, and accounts.js refuses to remove it', |
| 156 | JSON.stringify(premise)); |
| 157 | |
| 158 | // Seeded with the account's own id where the key holds one: `daimond-telemetry` |
| 159 | // is compared against the CURRENT account, and the primary keeps its id through a |
| 160 | // forget — which is exactly why an agreement left behind is inherited. |
| 161 | await p.evaluate((seed) => { |
| 162 | const id = (window.DaimondAccounts && window.DaimondAccounts.current()) || ''; |
| 163 | Object.keys(seed).forEach(function (k) { |
| 164 | localStorage.setItem(k, seed[k] === '__ACCOUNT__' ? id : seed[k]); |
| 165 | }); |
| 166 | }, SEED); |
| 167 | const seeded = await p.evaluate((names) => |
| 168 | names.filter(k => localStorage.getItem(k) === null), Object.keys(SEED)); |
| 169 | check(seeded.length === 0, |
| 170 | 'and every setting under test is really present before the forget', |
| 171 | seeded.length ? `missing: ${JSON.stringify(seeded)}` : ''); |
| 172 | |
| 173 | // ── Forget, the way a person does it ───────────────────────── |
| 174 | await p.evaluate(() => document.getElementById('user-row').click()); |
| 175 | await p.waitForTimeout(400); |
| 176 | const label = await p.evaluate(() => DaimondI18n.t('identity.forget')); |
| 177 | const hit = await p.evaluate((want) => { |
| 178 | const b = [...document.querySelectorAll('#admin-home .admin-item')] |
| 179 | .find(x => (x.textContent || '').trim() === want.trim()); |
| 180 | if (!b) return false; |
| 181 | b.click(); |
| 182 | return true; |
| 183 | }, label); |
| 184 | check(hit, 'the account panel offers "Forget this identity"', JSON.stringify(label)); |
| 185 | await p.waitForSelector('.dlg-card', { timeout: 8000 }); |
| 186 | await shot(s, 'forgetkeys-confirm'); |
| 187 | await p.evaluate(() => { |
| 188 | const card = [...document.querySelectorAll('.dlg-card')].filter(c => c.getClientRects().length).pop(); |
| 189 | card.querySelector('.dlg-ok').click(); |
| 190 | }); |
| 191 | // It ends in a reload. Waited for by the STATE the reload produces — an identity |
| 192 | // gate over an app with no identity — rather than by a timer, which on a slow |
| 193 | // OPFS wipe reads localStorage while the sweep is still running. |
| 194 | await p.waitForFunction(() => { |
| 195 | try { return localStorage.getItem('daimond-id-pub') === null; } catch (e) { return false; } |
| 196 | }, null, { timeout: 30000 }).catch(() => {}); |
| 197 | await p.waitForTimeout(2500); |
| 198 | |
| 199 | const left = await p.evaluate((names) => { |
| 200 | const out = {}; |
| 201 | names.forEach(function (k) { |
| 202 | var v = null; |
| 203 | try { v = localStorage.getItem(k); } catch (e) { v = 'unreadable'; } |
| 204 | if (v !== null) out[k] = String(v).slice(0, 40); |
| 205 | }); |
| 206 | return out; |
| 207 | }, Object.keys(SEED)); |
| 208 | |
| 209 | // ── 2. The three that were reported ────────────────────────── |
| 210 | const three = ['daimond-net-standing', 'daimond-permission-mode', 'daimond-permission-bypass-ack']; |
| 211 | const threeLeft = three.filter(k => k in left); |
| 212 | check(threeLeft.length === 0, |
| 213 | 'THE THREE PERMISSION SETTINGS ARE GONE — the standing network answer, the rung, the bypass note', |
| 214 | threeLeft.length ? `still set: ${JSON.stringify(threeLeft.map(k => [k, left[k]]))}` : ''); |
| 215 | |
| 216 | // ── 3. The passkey ─────────────────────────────────────────── |
| 217 | check(!('daimond-passkey' in left), |
| 218 | 'AND THE PASSKEY IS GONE — a sealed record is a working door into the identity just erased', |
| 219 | left['daimond-passkey'] ? `still set: ${left['daimond-passkey']}` : ''); |
| 220 | |
| 221 | // ── 4. The four the namespace sweep found ──────────────────── |
| 222 | const rest = ['daimond-telemetry', 'daimond-terminal-root', 'daimond-trust-log', 'daimond-governor']; |
| 223 | const restLeft = rest.filter(k => k in left); |
| 224 | check(restLeft.length === 0, |
| 225 | 'AND SO ARE THE TERMINAL CEILING, THE TRUST LOG, THE SPEND CEILING AND THE AGREEMENT TO BE RECORDED', |
| 226 | restLeft.length ? `still set: ${JSON.stringify(restLeft.map(k => [k, left[k]]))}` : ''); |
| 227 | |
| 228 | // ── 5. And the app comes back careful ──────────────────────── |
| 229 | // |
| 230 | // FROM THE ENGINE, not from the absent key. A key removed and a default taken |
| 231 | // are two claims: `DaimondHandMode.get()` is what the chip and the wasm are |
| 232 | // driven from, and a build that read the rung from somewhere else would have |
| 233 | // satisfied check 2 while still booting in bypass. |
| 234 | await p.reload({ waitUntil: 'domcontentloaded' }); |
| 235 | await p.waitForTimeout(2500); |
| 236 | const after = await p.evaluate(() => ({ |
| 237 | rung: (window.DaimondHandMode && DaimondHandMode.get) ? DaimondHandMode.get() : '(none)', |
| 238 | standing: (window.DaimondHandMode && DaimondHandMode.standingNet) ? DaimondHandMode.standingNet() : '(none)', |
| 239 | })); |
| 240 | check(after.rung === 'guarded', |
| 241 | 'THE APP COMES BACK GUARDED, not in the bypass the last person chose', |
| 242 | `rung=${after.rung}`); |
| 243 | check(after.standing === '', |
| 244 | 'and the network is put to the user in each chat again', |
| 245 | `standing=${JSON.stringify(after.standing)}`); |
| 246 | } finally { |
| 247 | await s.close(); |
| 248 | } |
| 249 | |
| 250 | console.log(bad ? `\n${bad} check(s) FAILED` : '\nall checks passed'); |
| 251 | process.exit(bad ? 1 : 0); |