Oregami
Repositories/oxedyne/daimond

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