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 | })(); |