Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/www/js/rekey.js

13.8 KiB, 1 run

created by r2519314175:1427, 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/* ============================================================
2 Daimond — every secret sealed under the passphrase says so (rekey.js)
3 ------------------------------------------------------------
4 Daimond seals a secret under a key derived from the user's
5 passphrase (`DaimondIdentity.wrap` / `.wrapBytes`). Change the
6 passphrase and the key is re-derived under a fresh salt, so
7 anything sealed under the old one is opaque from that moment —
8 unless the code reads it out first and puts it back.
9
10 ── THE ONE RULE THIS FILE EXISTS TO KEEP ───────────────────
11
12 A MODULE THAT SEALS A SECRET UNDER THE PASSPHRASE REGISTERS
13 HERE, BESIDE ITS OWN SEALING CODE — OR SAYS AT THAT SAME SPOT,
14 IN SO MANY WORDS, WHY IT NEED NOT.
15
16 Silence is the defect. Until 2026-08-14 the list of what gets
17 re-sealed was written out by hand in `doChangePassphrase`, and
18 seven modules called `wrap` while exactly one of them was on
19 that list. Three were found separately, hours apart, each by a
20 different lane:
21
22 - `mail.js` — every mailbox password, silently dead.
23 - `models.js` — every provider API key, while the notice on
24 screen said the key HAD been re-encrypted.
25 - `voice.js` — would have shipped with the same hole.
26 - `search.js` — the search API key, still broken that morning.
27
28 None of those is the fault. The fault is that forgetting was
29 the default and forgetting was silent, so the fix is not a
30 longer hand-written list: it is that the list is no longer
31 hand-written. `doChangePassphrase` iterates this registry and
32 names nobody, and `dev/verify_rekey.mjs` reads the source for
33 every caller of `wrap`/`wrapBytes` and fails on one that is
34 neither registered here nor exempted where it seals.
35
36 ── WHAT A PARTICIPANT LOOKS LIKE ───────────────────────────
37
38 DaimondRekey.register({
39 name: 'mail', // the module, one word
40 read: unsealForRekey, // OPTIONAL, see below
41 reseal: resealAfterRekey, // required
42 forget: forgetRekey, // OPTIONAL, see below
43 sentence: function (kind, list) { … }, // OPTIONAL
44 });
45
46 TWO PHASES, because the two shapes that already existed differ
47 and both have to be expressible without either being bent:
48
49 - `mail.js` holds its passwords ONLY sealed, so it must read
50 them out BEFORE `DaimondIdentity.changePassphrase` swaps
51 the key, hold them in its own module for the length of the
52 change, and put them back afterwards. That is `read`.
53 - `models.js` already holds its keys decrypted in memory
54 while unlocked, so there is nothing to read out and it has
55 no `read` at all. Only the wrapping has to be redone.
56
57 `read()` answers `{ held: <count>, failed: [<name>, …] }` —
58 how many plaintexts it is holding, and which secrets could not
59 be READ (already unreadable going in, which is not caused here
60 and is reported here because this is the one moment the app
61 holds both the fact and the user's attention).
62
63 `reseal()` answers `{ failed: [<name>, …], unread: [<name>, …] }`
64 — which could not be put back, and which were already
65 unreadable. Both NAMED, never counted: "Gmail needs its
66 password again" can be acted on, "a mailbox failed" cannot.
67
68 `forget()` drops anything held, for a change that did not
69 happen. A participant with a `read` needs one; a participant
70 without holds nothing, so it does not.
71
72 `sentence(kind, list)` composes the module's own words for
73 `kind` of 'unread' or 'failed'. The words stay in the module
74 that knows what they mean, so this file carries no vocabulary
75 and no locale keys for anybody else's secret.
76
77 ── WHAT THIS FILE NEVER LEARNS ─────────────────────────────
78
79 A SECRET. Every phase is called with NO ARGUMENTS and answers
80 with counts and names, so there is no parameter a plaintext
81 could arrive on and no field one is read out of. `mail.js`'s
82 own comment says nothing outside that module has any business
83 with a password; that stays true of the registry, which is
84 inside no module and would otherwise be the one place every
85 secret in the app passes through.
86
87 ── WHAT NO FAILURE MAY COST ────────────────────────────────
88
89 ANOTHER PARTICIPANT. Every registered participant runs, every
90 time, whatever the one before it did: each is called in its own
91 try/catch, failures are COLLECTED, and the caller says all of
92 them at the end. There is deliberately NO `return` anywhere in
93 either sequence below — the previous version of this code
94 returned on the first failure and silently abandoned every
95 secret beneath it, which is how one refused API key could cost
96 the push token and the passkey together.
97
98 Attaches one global, `window.DaimondRekey`.
99 ============================================================ */
100(function () {
101 'use strict';
102
103 /// What the app says. The table lives in i18n/en.js.
104 function t(k, v) { return window.DaimondI18n ? DaimondI18n.t(k, v) : k; }
105
106 /// A string from the table, or the English written here where the table has no
107 /// entry for it yet. The same device voice.js and search.js use.
108 function tOr(key, fallback, vars) {
109 var s = t(key, vars);
110 if (s !== key) return s;
111 if (!vars) return fallback;
112 return String(fallback).replace(/\{(\w+)\}/g, function (whole, k) {
113 return vars[k] != null ? String(vars[k]) : whole;
114 });
115 }
116
117 /// The participants, in registration order — which is script order in
118 /// index.html, and is therefore the order the notice names them in.
119 var parts = [];
120
121 /// Modules that seal something and say why they take no part. An exemption is
122 /// a REGISTRATION of a decision, not an absence of one: the verifier reads
123 /// these out of the source that seals, so a module can be silent about neither.
124 var exempted = [];
125
126 /// Registrations that were refused, so a malformed one is loud rather than
127 /// missing. A refused registration is a module that will not be re-sealed, and
128 /// that is exactly the failure this file exists to make impossible to have by
129 /// accident.
130 var refused = [];
131
132 /// The word used for a participant that threw, where nothing it holds can be
133 /// named individually because the whole of it went down together.
134 function allOfThem() { return tOr('changepass.all_of_them', 'all of them'); }
135
136 // ── Registration ────────────────────────────────────────────────
137
138 /// Take part in a passphrase change.
139 ///
140 /// Called at load, beside the sealing code it speaks for. A duplicate name is
141 /// refused rather than replacing the first: two modules answering to one name
142 /// would have one of them silently dropped, which is the defect again.
143 function register(p) {
144 var why = '';
145 if (!p || typeof p !== 'object') why = 'a participant must be an object';
146 else if (!p.name || typeof p.name !== 'string') why = 'a participant needs a name';
147 else if (typeof p.reseal !== 'function') why = 'a participant needs a reseal phase';
148 else if (p.read && typeof p.read !== 'function') why = 'read must be a function';
149 else if (p.forget && typeof p.forget !== 'function') why = 'forget must be a function';
150 else if (p.sentence && typeof p.sentence !== 'function') why = 'sentence must be a function';
151 else if (names().indexOf(p.name) >= 0) why = 'a participant of that name is already registered';
152 // A `read` with no `forget` cannot let go of what it holds on a change that
153 // failed, and a plaintext that outlives the change is the second half of
154 // this file's promise. Refused here rather than found later.
155 else if (p.read && typeof p.forget !== 'function') why = 'a participant that reads out must be able to forget';
156 if (why) {
157 refused.push({ name: (p && p.name) || '(unnamed)', why: why });
158 try { if (window.console) console.error('[rekey] registration refused: ' + why); } catch (e) { /* no console */ }
159 return false;
160 }
161 parts.push(p);
162 return true;
163 }
164
165 /// Seal something and take no part, for a stated reason.
166 ///
167 /// For a module whose seal is TRANSIENT — sealed at the moment of sending and
168 /// never read back — or whose ciphertext genuinely cannot be re-wrapped from
169 /// here. The reason is required and is kept, because "we thought about this
170 /// one" and "we forgot this one" look identical from outside and only one of
171 /// them is acceptable.
172 function exempt(name, why) {
173 if (!name || typeof name !== 'string' || !why || typeof why !== 'string') {
174 refused.push({ name: String(name || '(unnamed)'), why: 'an exemption needs a name and a reason' });
175 return false;
176 }
177 exempted.push({ name: name, why: why });
178 return true;
179 }
180
181 /// The registered participants themselves, in order. The live objects: a test
182 /// wraps one to prove the sequence really reached it, which a copy could not
183 /// show. They carry no secret — see the header.
184 function participants() { return parts.slice(); }
185
186 /// Their names, which is all the caller needs to report what took part.
187 function names() {
188 return parts.map(function (p) { return p.name; });
189 }
190
191 /// The stated exemptions, `{ name, why }`.
192 function exemptions() { return exempted.slice(); }
193
194 /// Registrations that were refused, `{ name, why }`. Empty in a sound build.
195 function refusals() { return refused.slice(); }
196
197 // ── The three phases of a change ────────────────────────────────
198
199 /// What a phase answered, reduced to counts and names.
200 ///
201 /// Whitelisted rather than passed through: a participant returning something
202 /// larger cannot widen what this file carries, and a name is coerced to a
203 /// string here so that the notice cannot be handed an object to interpolate.
204 function tidy(r) {
205 var out = { held: 0, failed: [], unread: [] };
206 if (!r || typeof r !== 'object') return out;
207 if (typeof r.held === 'number' && isFinite(r.held)) out.held = r.held | 0;
208 if (Array.isArray(r.failed)) out.failed = r.failed.map(String).filter(Boolean);
209 if (Array.isArray(r.unread)) out.unread = r.unread.map(String).filter(Boolean);
210 return out;
211 }
212
213 /// The module's own words for a failure, or a plain sentence naming it.
214 function say(p, kind, list) {
215 if (typeof p.sentence === 'function') {
216 var s = '';
217 try { s = String(p.sentence(kind, list.slice()) || ''); } catch (e) { s = ''; }
218 if (s) return s;
219 }
220 return tOr('changepass.rekey_generic',
221 '{who}: {list}.', { who: p.name, list: list.join(', ') });
222 }
223
224 /// Read out every secret that is held ONLY sealed, before the key changes.
225 ///
226 /// Must be called BEFORE `DaimondIdentity.changePassphrase`: afterwards nothing
227 /// can open them at all. Answers `{ held, ran, failed, sentences }` — `held` is
228 /// how many plaintexts the participants between them are now holding, `failed`
229 /// names what could not be read, and `sentences` is what to show for it.
230 async function readAll() {
231 var out = { held: 0, ran: [], failed: [], sentences: [] };
232 for (var i = 0; i < parts.length; i++) {
233 var p = parts[i];
234 if (typeof p.read !== 'function') continue;
235 out.ran.push(p.name);
236 var r;
237 // Each in its own try/catch, and NO return: a participant that throws
238 // costs its own secret and nothing else. `forgetAll` still reaches it,
239 // because a throw part-way through a read is exactly when something is
240 // left held.
241 try { r = tidy(await p.read()); }
242 catch (e) { r = { held: 0, failed: [allOfThem()], unread: [] }; }
243 out.held += r.held;
244 if (r.failed.length) {
245 out.failed.push({ name: p.name, list: r.failed });
246 out.sentences.push(say(p, 'unread', r.failed));
247 }
248 }
249 return out;
250 }
251
252 /// Put every secret back under the passphrase that has just replaced the old
253 /// one, and let go of whatever was held.
254 ///
255 /// Called AFTER `DaimondIdentity.changePassphrase` returned `{ ok: true }`.
256 /// Answers `{ ran, failed, unread, sentences }`.
257 async function resealAll() {
258 var out = { ran: [], failed: [], unread: [], sentences: [] };
259 for (var i = 0; i < parts.length; i++) {
260 var p = parts[i];
261 out.ran.push(p.name);
262 var r;
263 try { r = tidy(await p.reseal()); }
264 catch (e) { r = { held: 0, failed: [allOfThem()], unread: [] }; }
265 // Already unreadable going in, said first: it is the older fault, and a
266 // user reading the sentence about it should not think this change caused it.
267 if (r.unread.length) {
268 out.unread.push({ name: p.name, list: r.unread });
269 out.sentences.push(say(p, 'unread', r.unread));
270 }
271 if (r.failed.length) {
272 out.failed.push({ name: p.name, list: r.failed });
273 out.sentences.push(say(p, 'failed', r.failed));
274 }
275 }
276 return out;
277 }
278
279 /// Drop every plaintext held, for a change that did not happen — or that fell
280 /// over part-way through being prepared.
281 ///
282 /// Safe to call twice and safe to call when nothing was ever read out. A
283 /// participant that throws in here is caught and the rest still forget: the one
284 /// thing worse than a change that failed is a change that failed with the
285 /// passwords still in memory.
286 function forgetAll() {
287 var ran = [];
288 for (var i = 0; i < parts.length; i++) {
289 var p = parts[i];
290 if (typeof p.forget !== 'function') continue;
291 ran.push(p.name);
292 try { p.forget(); } catch (e) { /* it kept its hold; the others still let go */ }
293 }
294 return ran;
295 }
296
297 // ── Public surface ──────────────────────────────────────────────
298 window.DaimondRekey = {
299 register: register,
300 exempt: exempt,
301 participants: participants,
302 names: names,
303 exemptions: exemptions,
304 refusals: refusals,
305 readAll: readAll,
306 resealAll: resealAll,
307 forgetAll: forgetAll,
308 };
309})();