Oregami
Repositories/oxedyne/daimond

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.
58import fs from 'node:fs';
59import path from 'node:path';
60import { fileURLToPath } from 'node:url';
61import { open, scratch, shot } from './harness.mjs';
62
63const HERE = path.dirname(fileURLToPath(import.meta.url));
64const WWW = path.join(HERE, '..', 'www');
65
66const 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.
72const 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};
91if (BREAK && !BREAKS[BREAK]) {
92 console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`);
93 process.exit(2);
94}
95
96let bad = 0;
97const check = (pass, name, detail) => {
98 if (!pass) bad++;
99 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
100};
101
102const 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.
122const 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
133const s = await open({
134 name: 'forgetkeys',
135 profile: scratch('pw', 'forgetkeys' + (BREAK ? '-' + BREAK : '')),
136 route: stub,
137});
138const { page: p } = s;
139if (BREAK) console.log(`\n*** RUNNING UNDER --break ${BREAK}: failures below are the point ***\n`);
140
141try {
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
250console.log(bad ? `\n${bad} check(s) FAILED` : '\nall checks passed');
251process.exit(bad ? 1 : 0);