oxedyne/daimond/www/js/voice.js
24.3 KiB, 1 run
created by r2519314175:1475, 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 — the voice a proposal is written with (voice.js) |
| 3 | ------------------------------------------------------------ |
| 4 | To write on the Oregami forge a tester must present a VOICE: a |
| 5 | per-person secret the forge looks the writer up BY. There is no |
| 6 | name on the wire and there is no shared one — the forge holds a |
| 7 | digest of each person's secret and identifies the voice from the |
| 8 | secret alone, so the secret IS the identity. That is why this |
| 9 | file exists and why it is this careful. |
| 10 | |
| 11 | ── THE ONE RULE THIS FILE EXISTS TO KEEP ─────────────────── |
| 12 | |
| 13 | THE SECRET IS HELD ENCRYPTED AT REST, IS DECRYPTED ONLY FOR |
| 14 | THE MOMENT OF A REQUEST, AND IS NEVER WRITTEN TO A LOG, NEVER |
| 15 | PUT IN A URL OR A QUERY STRING, AND NEVER PUT IN ANY TELEMETRY |
| 16 | OR ERROR REPORT. |
| 17 | |
| 18 | Each clause is load-bearing: |
| 19 | |
| 20 | - ENCRYPTED AT REST, under the user's passphrase, by the SAME |
| 21 | mechanism that wraps their API key and their mail app |
| 22 | password: `DaimondIdentity.wrap` / `.unwrap`. Not a second |
| 23 | scheme. A second way of encrypting a secret at rest is how one |
| 24 | of the two stops being reviewed, and the reviewed one is the |
| 25 | one everything else already uses. |
| 26 | - ONLY FOR THE MOMENT OF A REQUEST: nothing here caches the |
| 27 | plaintext. `header()` unwraps, hands the value to one call and |
| 28 | lets it go. There is no module variable holding a decrypted |
| 29 | secret between requests, so locking the identity really does |
| 30 | take the voice away. |
| 31 | - NEVER IN A URL: a query string is written into every access |
| 32 | log it passes, kept in history, and handed on in a referrer. A |
| 33 | credential in one is a credential published. `send()` is the |
| 34 | one door a voiced request goes through, and it refuses a path |
| 35 | that carries the secret at all — including one a CALLER built. |
| 36 | - NEVER IN TELEMETRY: `telemetry.js` can only carry integers, by |
| 37 | shape, so it cannot carry this even by mistake. Nothing here |
| 38 | calls it, and nothing here puts the secret in the text of an |
| 39 | error either: the sentences below say what is wrong with a |
| 40 | secret and never echo it. |
| 41 | |
| 42 | ── WHAT DOES NOT NEED A VOICE ────────────────────────────── |
| 43 | Reading a public repository. `header()` answers `{}` when no |
| 44 | voice is held rather than throwing, so a read spreads nothing |
| 45 | into its headers and goes through unvoiced. Refusing reads to a |
| 46 | tester who has not been admitted would be a fence around a |
| 47 | public page. |
| 48 | |
| 49 | ── WHAT IS NOT HERE, DELIBERATELY ────────────────────────── |
| 50 | A name. The forge is handed the secret and nothing else, so a |
| 51 | name field would be a field that travels for no reason and a |
| 52 | second thing to keep in step. If the forge ever keys by name, |
| 53 | the contract's §2.1 says so first and this file changes second. |
| 54 | |
| 55 | The gateway forwards this and stores nothing: it re-sends the |
| 56 | value on `x-ore-voice` and keeps no copy. The header spelled |
| 57 | here is Daimond's own leg only. |
| 58 | |
| 59 | Attaches one global, `window.DaimondVoice`. |
| 60 | ============================================================ */ |
| 61 | (function () { |
| 62 | 'use strict'; |
| 63 | |
| 64 | // ── Saying things ────────────────────────────────────────── |
| 65 | |
| 66 | function t(k, v) { return window.DaimondI18n ? DaimondI18n.t(k, v) : k; } |
| 67 | |
| 68 | /// A string with the English written at the call site as its fallback. |
| 69 | /// |
| 70 | /// The same device improve.js and trash.js use: `t` answers with the KEY when |
| 71 | /// the table has no entry, so a panel built against keys the locale files have |
| 72 | /// not been given yet would read "voice.err.long" on screen. These strings are |
| 73 | /// routed to the catalogue separately from this file. |
| 74 | function tOr(k, fallback, v) { |
| 75 | var s = t(k, v); |
| 76 | if (s !== k) return s; |
| 77 | if (!v) return fallback; |
| 78 | return String(fallback).replace(/\{(\w+)\}/g, function (whole, name) { |
| 79 | return v[name] != null ? String(v[name]) : whole; |
| 80 | }); |
| 81 | } |
| 82 | |
| 83 | // ── What a voice is ──────────────────────────────────────── |
| 84 | |
| 85 | /// Where the wrapped secret sits. `daimond-` prefixed, so accounts.js |
| 86 | /// namespaces it per account without this file knowing: two people at one |
| 87 | /// browser have two voices and neither can see the other's. |
| 88 | var LS = 'daimond-voice'; |
| 89 | |
| 90 | /// The header the browser sends its voice on. Daimond's own namespace on |
| 91 | /// Daimond's own leg; `improve.rs`'s `HDR_VOICE` is the other end of it, and |
| 92 | /// the gateway translates it to the forge's `x-ore-voice`. One spelling, in |
| 93 | /// one place, because a header nobody can find is a 401 nobody can explain. |
| 94 | var HDR = 'x-daimond-voice'; |
| 95 | |
| 96 | /// The record's shape, so a later one can be told from this one. |
| 97 | var REC_V = 1; |
| 98 | |
| 99 | /// The longest secret that may be sent. Exactly the gateway's `MAX_SECRET`. |
| 100 | /// Not larger: a secret this refuses and the gateway would forward is a fault |
| 101 | /// the tester meets twice; a secret this forwards and the gateway refuses is a |
| 102 | /// round trip spent to be told what was knowable here. |
| 103 | var MAX = 256; |
| 104 | |
| 105 | /// The shortest. STRICTER THAN THE GATEWAY, which takes any non-empty value, |
| 106 | /// and the reason is worth stating because looser-than-the-gateway would be a |
| 107 | /// hole and stricter has to earn its place instead. |
| 108 | /// |
| 109 | /// The forge mints a voice from `SECRET_BYTES` = 32 bytes of operating-system |
| 110 | /// randomness and prints it in the Hematite64 alphabet, so a real secret is 45 |
| 111 | /// characters. Sixteen is far below any secret the forge has ever issued and |
| 112 | /// far above anything a half-completed copy would leave behind — and a |
| 113 | /// truncated paste is exactly the mistake this catches. Without it the tester |
| 114 | /// sends a fragment, the forge answers `unknown`, and nothing on either side |
| 115 | /// says the secret arrived short. |
| 116 | var MIN = 16; |
| 117 | |
| 118 | /// A secret's alphabet, matching the gateway's `check_secret`: every character |
| 119 | /// ASCII graphic, 0x21 to 0x7E. NOT a check against the forge's own alphabet — |
| 120 | /// whether the secret is the RIGHT secret is the forge's question, asked with a |
| 121 | /// digest. What this refuses is a value that could not be a credential at all, |
| 122 | /// and in particular one carrying a control character, a space or a newline, |
| 123 | /// which is how a header value ends early and a second one gets written. |
| 124 | var GRAPHIC = /^[\x21-\x7e]+$/; |
| 125 | |
| 126 | // ── At rest ──────────────────────────────────────────────── |
| 127 | |
| 128 | /// The stored record, or null where there is none or it is not one. |
| 129 | function rec() { |
| 130 | var raw = null; |
| 131 | try { raw = localStorage.getItem(LS); } catch (e) { return null; } |
| 132 | if (!raw) return null; |
| 133 | var r = null; |
| 134 | try { r = JSON.parse(raw); } catch (e) { return null; } |
| 135 | if (!r || r.v !== REC_V || typeof r.s !== 'string' || !r.s) return null; |
| 136 | return r; |
| 137 | } |
| 138 | |
| 139 | /// Is a voice held for this account? |
| 140 | /// |
| 141 | /// Presence only, and deliberately synchronous: a panel drawing a row needs to |
| 142 | /// know whether to offer "Set a voice" or "Replace it" without unlocking |
| 143 | /// anything. It says nothing about whether the secret can be READ right now, |
| 144 | /// which needs the passphrase — see `header()`. |
| 145 | function has() { |
| 146 | return !!rec(); |
| 147 | } |
| 148 | |
| 149 | /// When the voice held was last set, in milliseconds, or 0. |
| 150 | function at() { |
| 151 | var r = rec(); |
| 152 | return (r && typeof r.at === 'number') ? r.at : 0; |
| 153 | } |
| 154 | |
| 155 | /// What is wrong with this secret, or '' where nothing is. |
| 156 | /// |
| 157 | /// Separate from `set()` so a form can say what is wrong as it is typed |
| 158 | /// without a throw. The sentences NEVER quote the value: an error message |
| 159 | /// carrying a credential is a credential in a screenshot. |
| 160 | /// |
| 161 | /// Asked of `tidy()`'s answer and never of the raw field, because `set()` |
| 162 | /// stores `tidy()`'s answer: a check that judged something other than what is |
| 163 | /// kept would refuse a paste the store would have accepted, or the reverse. |
| 164 | function check(secret) { |
| 165 | var s = tidy(secret); |
| 166 | if (!s) return tOr('voice.err.empty', |
| 167 | 'A voice is needed to write on the forge.'); |
| 168 | // The alphabet first, so that `length` below is a count of BYTES: every |
| 169 | // character admitted here is one byte, which is what the gateway measures. |
| 170 | if (!GRAPHIC.test(s)) return tOr('voice.err.shape', |
| 171 | 'That does not look like a voice. Copy the whole line the forge printed.'); |
| 172 | if (s.length < MIN) return tOr('voice.err.short', |
| 173 | 'That is shorter than any voice the forge issues. Copy the whole line.'); |
| 174 | if (s.length > MAX) return tOr('voice.err.long', |
| 175 | 'That is longer than a voice can be.'); |
| 176 | return ''; |
| 177 | } |
| 178 | |
| 179 | /// Exactly how long a minted voice is, in characters. |
| 180 | /// |
| 181 | /// The forge mints `SECRET_BYTES` = 32 bytes of operating-system randomness and |
| 182 | /// prints them in the Hematite64 alphabet (`oregami/src/voice.rs:67`, through |
| 183 | /// `ore_store::keys::text_of`). Six bits a character, unpadded, so 32 bytes is |
| 184 | /// ceil(256 / 6) = 43 symbols, AND THEN THE PADDING, which is what this file got |
| 185 | /// wrong. HEMATITE64 is not standard Base64 -- `fe2o3_text/src/base2x.rs:39` says |
| 186 | /// so outright -- and its padding is `'='` followed by a marker digit counting the |
| 187 | /// leftover bits. 43 symbols carry 258 bits for 256 of secret, so every minted |
| 188 | /// voice ends `=2` and is 45 characters. |
| 189 | /// |
| 190 | /// THE OLD 43 DID NOT MERELY MISLABEL ANYTHING; IT DISARMED `tidy`. The label is |
| 191 | /// taken off only when the tail is exactly a voice long, so against a real paste |
| 192 | /// the test was 45 === 43, the `secret ` column stayed on the front, and `GRAPHIC` |
| 193 | /// then refused the space -- the very dead end this pair of functions was written |
| 194 | /// to end. The fix for tester note 17 was built against a length no voice has. |
| 195 | /// |
| 196 | /// Used ONLY by `tidy` below, to decide whether a paste carrying whitespace is a |
| 197 | /// labelled secret or a damaged one; every other bound stays the loose pair, |
| 198 | /// because whether the secret is the RIGHT secret is the forge's question and this |
| 199 | /// file does not answer it. |
| 200 | var LEN = 45; |
| 201 | |
| 202 | /// The secret as it will be stored and sent: what was pasted, trimmed, and with |
| 203 | /// a LABEL taken off the front where the paste plainly carried one. |
| 204 | /// |
| 205 | /// A trim was not enough, and the reason is that the forge prints the credential |
| 206 | /// in a two-column line: |
| 207 | /// |
| 208 | /// secret 6yYbW… (`oregami voice`, src/main.rs:529) |
| 209 | /// |
| 210 | /// A person told to "copy the whole line the forge printed" -- which is what |
| 211 | /// this app's own help text said -- copies both columns, and `GRAPHIC` then |
| 212 | /// fails on the space in the middle. The instruction and the validator |
| 213 | /// disagreed, and the app took the validator's side with a sentence that |
| 214 | /// repeated the instruction. That is the dead end the owner hit. |
| 215 | /// |
| 216 | /// THE TAIL IS TAKEN ONLY WHEN IT IS EXACTLY A VOICE LONG, and that condition is |
| 217 | /// the whole safety of this. The looser rule -- always take the last field -- |
| 218 | /// rescues the labelled paste and also swallows a DAMAGED one: a real secret |
| 219 | /// with a space knocked into the middle of it would store its second half, be |
| 220 | /// refused by the forge as `unknown`, and tell the tester nothing about which |
| 221 | /// of the two things went wrong. Splitting a 45-character secret cannot leave a |
| 222 | /// 45-character tail, so the two cases are separated exactly rather than by |
| 223 | /// judgement, and a damaged paste is still refused by `GRAPHIC` below. |
| 224 | /// |
| 225 | /// The alternative -- keep refusing and word the refusal better -- was rejected |
| 226 | /// as the WHOLE fix, because the field is `type=password`: it asks a person to |
| 227 | /// make an exact edit to characters they cannot see, which is the worst place in |
| 228 | /// the app to demand precision. It is kept as HALF the fix, for every paste this |
| 229 | /// cannot rescue: the sentences in `check` now say what to copy -- one run of 45 |
| 230 | /// characters with no spaces -- rather than repeating the instruction that |
| 231 | /// caused the mistake. |
| 232 | function tidy(secret) { |
| 233 | var s = String(secret == null ? '' : secret).trim(); |
| 234 | if (!s) return s; |
| 235 | var parts = s.split(/\s+/); |
| 236 | if (parts.length < 2) return s; |
| 237 | var tail = parts[parts.length - 1]; |
| 238 | return tail.length === LEN ? tail : s; |
| 239 | } |
| 240 | |
| 241 | /// Hold a voice for this account, wrapped under the user's passphrase. |
| 242 | /// |
| 243 | /// Throws with the sentence a person should read: the secret is invalid, or |
| 244 | /// the identity is locked and there is no key to wrap it with. Storing it |
| 245 | /// unwrapped "for now" is not an option this file offers. |
| 246 | async function set(secret) { |
| 247 | var why = check(secret); |
| 248 | if (why) throw new Error(why); |
| 249 | if (!window.DaimondIdentity || !DaimondIdentity.isUnlocked()) { |
| 250 | throw new Error(tOr('voice.err.locked', |
| 251 | 'Unlock Daimond first: your voice is kept encrypted under your passphrase.')); |
| 252 | } |
| 253 | var wrapped = await DaimondIdentity.wrap(tidy(secret)); |
| 254 | localStorage.setItem(LS, JSON.stringify({ v: REC_V, s: wrapped, at: Date.now() })); |
| 255 | } |
| 256 | |
| 257 | /// Forget the voice held, destructively. |
| 258 | /// |
| 259 | /// The RECORD GOES. Not a flag beside it, not an empty field with the |
| 260 | /// ciphertext kept "in case they come back": a secret still on disk that |
| 261 | /// `has()` reports as absent is the worst of both, because nothing in the |
| 262 | /// interface will ever offer to remove it again. Revoking at the forge is a |
| 263 | /// separate act and this cannot do it; what this can promise is that the copy |
| 264 | /// on this device is gone. |
| 265 | function clear() { |
| 266 | try { localStorage.removeItem(LS); } catch (e) { /* private mode: nothing was stored */ } |
| 267 | } |
| 268 | |
| 269 | // ── Surviving a passphrase change ────────────────────────── |
| 270 | // |
| 271 | // The voice is sealed under a key derived from the passphrase, so a change |
| 272 | // makes it unreadable unless it is read out under the old key and put back |
| 273 | // under the new one. This file was written after the same hole was found in |
| 274 | // `mail.js` and would have inherited it; it never shipped with it. |
| 275 | // |
| 276 | // The plaintext is held HERE for the length of the change and nowhere else. |
| 277 | // `doChangePassphrase` used to hold it in a local of its own, which put a |
| 278 | // secret in a module that has no business with one — the same rule mail.js |
| 279 | // keeps about a password, kept about this. |
| 280 | |
| 281 | /// The secret in the clear, for the length of a change. Null at every other |
| 282 | /// moment, which is what makes "nothing here caches the plaintext" true. |
| 283 | var held = null; |
| 284 | |
| 285 | /// Read the voice out from under the CURRENT passphrase. |
| 286 | /// |
| 287 | /// Must run BEFORE `DaimondIdentity.changePassphrase` swaps the key. A voice |
| 288 | /// that cannot be read is reported and not held: it is already lost, and the |
| 289 | /// only useful thing left to do about it is say so while the user is looking. |
| 290 | async function readForRekey() { |
| 291 | held = null; |
| 292 | if (!has()) return { held: 0, failed: [] }; |
| 293 | var secret = ''; |
| 294 | try { secret = await DaimondIdentity.unwrap(rec().s); } |
| 295 | catch (e) { return { held: 0, failed: [tOr('voice.the_voice', 'your forge voice')] }; } |
| 296 | if (!secret) return { held: 0, failed: [] }; |
| 297 | held = secret; |
| 298 | return { held: 1, failed: [] }; |
| 299 | } |
| 300 | |
| 301 | /// Put it back under the NEW passphrase, and forget it either way. |
| 302 | /// |
| 303 | /// Wrapped directly rather than through `set()`, which re-validates: a voice |
| 304 | /// stored by an older build that would not pass `check()` today must survive a |
| 305 | /// passphrase change rather than being dropped by it. The `at` stamp is kept |
| 306 | /// for the same reason — this is a re-wrapping, not a new voice. |
| 307 | async function resealAfterRekey() { |
| 308 | if (!held) return { failed: [] }; |
| 309 | var failed = []; |
| 310 | try { |
| 311 | var when = at() || Date.now(); |
| 312 | localStorage.setItem(LS, JSON.stringify({ |
| 313 | v: REC_V, s: await DaimondIdentity.wrap(held), at: when, |
| 314 | })); |
| 315 | } catch (e) { failed.push(tOr('voice.the_voice', 'your forge voice')); } |
| 316 | finally { held = null; } // in the clear; never held past here |
| 317 | return { failed: failed }; |
| 318 | } |
| 319 | |
| 320 | /// Drop the plaintext unused, for a change that did not happen. |
| 321 | function forgetRekey() { held = null; } |
| 322 | |
| 323 | if (window.DaimondRekey) { |
| 324 | DaimondRekey.register({ |
| 325 | name: 'voice', |
| 326 | read: readForRekey, |
| 327 | reseal: resealAfterRekey, |
| 328 | forget: forgetRekey, |
| 329 | /// One secret, so the list is not named: there is only ever one voice |
| 330 | /// and "your forge voice, your forge voice" would be the shape of a |
| 331 | /// list where a thing is meant. |
| 332 | sentence: function (kind) { |
| 333 | return kind === 'unread' |
| 334 | ? tOr('changepass.voice_not_unsealed', |
| 335 | 'Your forge voice could not be read under the old passphrase, so it ' |
| 336 | + 'still needs setting again from the line the forge printed for you.') |
| 337 | : tOr('changepass.voice_not_resealed', |
| 338 | 'Your forge voice could not be re-encrypted under the new passphrase. ' |
| 339 | + 'Set it again from the line the forge printed for you.'); |
| 340 | }, |
| 341 | }); |
| 342 | } |
| 343 | |
| 344 | // ── The sync parcel ──────────────────────────────────────── |
| 345 | // |
| 346 | // A voice is a fact about the ACCOUNT, not about the browser it was set in: |
| 347 | // paired devices share one passphrase-derived identity, so the wrapped |
| 348 | // secret is decryptable on every one of them. It was simply never |
| 349 | // transported, and the empty state on the other device told the user it |
| 350 | // "will sync here shortly" -- a promise nothing kept. sync.js now carries |
| 351 | // this record beside the pause tree and the trash, and voice.js answers for |
| 352 | // it as those modules answer for theirs. |
| 353 | // |
| 354 | // THE WRAPPED RECORD TRAVELS AS-IS. Nothing here unwraps it: the ciphertext |
| 355 | // is the same shape at both ends and the plaintext never enters the parcel, |
| 356 | // so the one rule at the top of this file holds across the wire too. |
| 357 | |
| 358 | /// The stored record, for the parcel -- or null where there is none. |
| 359 | /// |
| 360 | /// The whole `{ v, s, at }`, `s` still wrapped. `null` is omitted by the |
| 361 | /// collector, and a section left off is a section the other device keeps; |
| 362 | /// an empty record would read to the merge as a deletion. |
| 363 | function snapshot() { return rec(); } |
| 364 | |
| 365 | /// Merge a record from another device. True when this device took it. |
| 366 | /// |
| 367 | /// NEWER `at` WINS, and that is the whole merge. A re-issued voice is set |
| 368 | /// with a fresh `Date.now()`, so it is newer everywhere and propagates; an |
| 369 | /// older incoming record never buries a voice this device set more recently. |
| 370 | /// A tie keeps what is already here, since the two are the same wrapped |
| 371 | /// secret under the same identity. Nothing stamps on the way in -- a device |
| 372 | /// that restamped what it adopted would push it straight back for ever. |
| 373 | function adopt(incoming) { |
| 374 | if (!incoming || typeof incoming !== 'object') return false; |
| 375 | if (incoming.v !== REC_V || typeof incoming.s !== 'string' || !incoming.s) return false; |
| 376 | var inAt = (typeof incoming.at === 'number') ? incoming.at : 0; |
| 377 | var mine = rec(); |
| 378 | if (mine) { |
| 379 | var myAt = (typeof mine.at === 'number') ? mine.at : 0; |
| 380 | if (inAt <= myAt) return false; // ours is newer or the same; keep it |
| 381 | } |
| 382 | try { |
| 383 | localStorage.setItem(LS, JSON.stringify({ v: REC_V, s: incoming.s, at: inAt })); |
| 384 | } catch (e) { return false; } // private mode: nothing to store into |
| 385 | return true; |
| 386 | } |
| 387 | |
| 388 | // ── For the moment of a request ──────────────────────────── |
| 389 | |
| 390 | /// The header a request carries, or `{}` where no voice is held. |
| 391 | /// |
| 392 | /// `{}` rather than a throw, because reading a public repository needs no |
| 393 | /// voice at all and a caller spreading this into its headers must be able to |
| 394 | /// do so unconditionally: |
| 395 | /// |
| 396 | /// fetch(url, { headers: Object.assign({}, await DaimondVoice.header()) }) |
| 397 | /// |
| 398 | /// A voice that is HELD but cannot be read is a different case and DOES throw: |
| 399 | /// returning `{}` there would send the write unvoiced, the forge would answer |
| 400 | /// `unvoiced`, and the tester would be told they have no voice when what they |
| 401 | /// have is a locked one. |
| 402 | /// |
| 403 | /// Nothing is cached. Each call unwraps afresh, so the plaintext lives for the |
| 404 | /// length of one request and locking really does take it away. |
| 405 | async function header() { |
| 406 | if (!has()) return {}; |
| 407 | if (!window.DaimondIdentity || !DaimondIdentity.isUnlocked()) { |
| 408 | throw new Error(tOr('voice.err.locked_send', |
| 409 | 'Unlock Daimond to write on the forge: your voice is encrypted under your passphrase.')); |
| 410 | } |
| 411 | var secret; |
| 412 | try { |
| 413 | secret = await DaimondIdentity.unwrap(rec().s); |
| 414 | } catch (e) { |
| 415 | // The GCM tag did not check, which in practice means the passphrase this |
| 416 | // is wrapped under is not the one in force: a passphrase CHANGE re-derives |
| 417 | // the wrapping key. `doChangePassphrase` now re-wraps this along with the |
| 418 | // mailbox passwords, the provider keys, the API key and the push token — |
| 419 | // but a change that failed part-way can still land here, so the message |
| 420 | // has to stand on its own. Say what happened in words a person can act on |
| 421 | // rather than passing WebCrypto's `OperationError` up — and never quote |
| 422 | // the ciphertext, which is what the raw error carries. |
| 423 | throw new Error(tOr('voice.err.unreadable', |
| 424 | 'Your voice cannot be read with this passphrase. Set it again from the line ' |
| 425 | + 'the forge printed for you.')); |
| 426 | } |
| 427 | var out = {}; |
| 428 | out[HDR] = secret; |
| 429 | return out; |
| 430 | } |
| 431 | |
| 432 | /// THE ONE DOOR a voiced request goes through. |
| 433 | /// |
| 434 | /// Callers may spread `header()` themselves, and reads do. Writes come through |
| 435 | /// here, because the URL check below has to happen somewhere and a rule kept |
| 436 | /// at every call site is a rule kept at all but one of them. |
| 437 | /// |
| 438 | /// Through `DaimondGateway.gwFetch`, which is THE ONE COPY of the gateway's |
| 439 | /// session rule — renew once, retry once. This file adds one header and does |
| 440 | /// not reimplement any of that. |
| 441 | async function send(path, opts) { |
| 442 | var url = String(path == null ? '' : path); |
| 443 | var h = await header(); |
| 444 | // The secret must not be in the URL, whoever put it there. A caller that |
| 445 | // built `?voice=…` is refused rather than corrected, because a request that |
| 446 | // went out with the query string quietly stripped would still have been |
| 447 | // composed by code that thinks this is allowed. |
| 448 | // |
| 449 | // THE DECODED FORM IS TESTED AS WELL, AND WITHOUT IT THIS GUARD MISSED EVERY |
| 450 | // REAL VOICE. A minted secret ends `=2` (see `LEN`), and every ordinary way of |
| 451 | // building a query -- `encodeURIComponent`, `URLSearchParams`, a template with |
| 452 | // a caller's own escaping -- writes that `=` as `%3D`. A raw substring test |
| 453 | // therefore matched nothing, the request went out, and the credential reached |
| 454 | // the access log, the history and the referrer this file's opening rule is |
| 455 | // about. It read as sound for as long as `dev/verify_voice.mjs` drove it with a |
| 456 | // 43-character fixture carrying no `=`: a fixture that was not the shape of the |
| 457 | // thing hid a hole in the code that was. |
| 458 | // |
| 459 | // `decodeURIComponent` throws on a malformed escape, and a URL nobody can decode |
| 460 | // is not one this can clear -- so the throw is caught and the raw test stands |
| 461 | // alone rather than the whole guard falling open. |
| 462 | var seen = url; |
| 463 | try { seen = url + '\n' + decodeURIComponent(url); } catch (e) { /* raw only */ } |
| 464 | if (h[HDR] && seen.indexOf(h[HDR]) >= 0) { |
| 465 | throw new Error(tOr('voice.err.inurl', |
| 466 | 'A voice goes in a header, never in an address.')); |
| 467 | } |
| 468 | var o = Object.assign({}, opts || {}); |
| 469 | o.headers = Object.assign({}, (opts && opts.headers) || {}, h); |
| 470 | if (window.DaimondGateway && DaimondGateway.gwFetch) { |
| 471 | return await DaimondGateway.gwFetch(url, o); |
| 472 | } |
| 473 | return await fetch(url, o); |
| 474 | } |
| 475 | |
| 476 | // ── Public surface ───────────────────────────────────────── |
| 477 | window.DaimondVoice = { |
| 478 | /// The header name, so a caller and a test name the same string. |
| 479 | HEADER: HDR, |
| 480 | /// The bounds, for a form that wants to say them before it refuses. |
| 481 | MIN: MIN, |
| 482 | MAX: MAX, |
| 483 | /// How long a minted voice is, so a form and a test name one number. |
| 484 | LEN: LEN, |
| 485 | /// What would be stored for this paste, for a test that wants to see the |
| 486 | /// label come off without storing anything. |
| 487 | tidy: tidy, |
| 488 | has: has, |
| 489 | at: at, |
| 490 | /// The wrapped record for the sync parcel, or null; and the merge that |
| 491 | /// applies one arriving from another device -- newer `at` wins. |
| 492 | snapshot: snapshot, |
| 493 | adopt: adopt, |
| 494 | /// What is wrong with a secret, or '' — for validating as it is typed. |
| 495 | check: check, |
| 496 | set: set, |
| 497 | clear: clear, |
| 498 | /// `{ 'x-daimond-voice': secret }`, or `{}` where no voice is held. |
| 499 | header: header, |
| 500 | /// The one door a voiced request goes through. |
| 501 | send: send, |
| 502 | /// The two phases of a passphrase change. Public so a test can drive them; |
| 503 | /// the app itself reaches them only through `DaimondRekey`. |
| 504 | readForRekey: readForRekey, |
| 505 | resealAfterRekey: resealAfterRekey, |
| 506 | forgetRekey: forgetRekey, |
| 507 | }; |
| 508 | })(); |