// Text sealed with a passphrase in the browser, PBKDF2-HMAC-SHA-256 to AES-256-GCM; Node runs all above page() // envelope: MAGIC, iterations u32 big-endian, salt, nonce, ciphertext, GCM tag · p a passphrase · no throw a SealError // num en-US number · plural "3 bytes" · hex "U+XXXX" · utf8 encoder · BAD BIG LONE HIDDEN refusals and patterns // walk move a line and column over text · deny a count's refusal · b64 base64 of bytes · derive the key // vet what seal() refuses export {MAGIC, DEFAULT_ITERATIONS, MAX_ITERATIONS, SALT_BYTES, IV_BYTES, TAG_BYTES, HEADER_BYTES, MIN_BYTES, MAX_INPUT_CHARS, MAX_ENVELOPE_BYTES, MAX_TEXT_BYTES, PAGE, ALPHABET, SealError, wrap, decodeEnvelope, inspect, seal, unseal, open, toLink, sourceFrom, passphraseNotes, generatePassphrase, hiddenCharacters, revealHidden, hexGroups}; const MAGIC = "SEAL1", DEFAULT_ITERATIONS = 6e5; // guard: a link carries its own count, a number a stranger chose, and it is checked before any key work const MAX_ITERATIONS = 1e7; const SALT_BYTES = 16, IV_BYTES = 12, TAG_BYTES = 16, HEADER_BYTES = MAGIC.length + 4 + SALT_BYTES + IV_BYTES; const MIN_BYTES = HEADER_BYTES + TAG_BYTES, MAX_INPUT_CHARS = 2e6, MAX_ENVELOPE_BYTES = 2 ** 20; const MAX_TEXT_BYTES = 1e6; // inv: every envelope sealed here opens here const PAGE = "https://shxtrk.dev/objects/seal/"; // Crockford base32: the digits and the letters but i, l, o and u const ALPHABET = [..."0123456789abcdefghijklmnopqrstuvwxyz"].filter(c => !"ilou".includes(c)).join(""); class SealError extends Error { constructor(code, message) { super(message); this.name = "SealError"; this.code = code } } const no = (k, s) => { throw new SealError(k, s) }; const num = n => n.toLocaleString("en-US"), plural = (n, s) => `${num(n)} ${s}${n === 1 ? "" : "s"}`; const hex = c => "U+" + c.toString(16).toUpperCase().padStart(4, "0"), utf8 = new TextEncoder(); const BAD = "Not an envelope. It isn't valid base64."; const BIG = "This envelope is larger than the 1,048,576 bytes this page will read."; const LONE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? n ? `This envelope asks for ${num(n)} iterations; the limit here is 10,000,000.` : "This envelope asks for 0 iterations. PBKDF2 needs at least one."; function b64(b) { let s = ""; for (let i = 0; i < b.length; i += 2 ** 15) s += String.fromCharCode(...b.subarray(i, i + 2**15)); return btoa(s) } function wrap(base64, width = 72) { return base64.replace(new RegExp(`.{1,${width}}`, "g"), "$&\n").trim() } function decodeEnvelope(input) { input.length > MAX_INPUT_CHARS && no("too-large", BIG); let s = input.trim(); s = s.slice(s.indexOf("#") + 1); if (s.includes("%")) try { s = decodeURIComponent(s) } catch { no("not-base64", BAD) } s = s.replace(/\s+/g, ""); s || no("empty", "Nothing to open."); const c = s.match(/[^\w+/=-]/u)?.[0]; c && no("not-base64", `Not an envelope. ${/[\p{C}\p{Z}]/u.test(c) ? hex(c.codePointAt(0)) : `"${c}"`} isn't a base64 character.`); /[+/]/.test(s) && /[-_]/.test(s) && no("mixed-alphabet", "Not an envelope. It mixes the two base64 alphabets."); s = s.replace(/={1,2}$/, "").replace(/-/g, "+").replace(/_/g, "/"); (s.includes("=") || s.length % 4 === 1) && no("not-base64", BAD); Math.floor(s.length * 3 / 4) > MAX_ENVELOPE_BYTES && no("too-large", BIG); s = atob(s + "==".slice(0, (4 - s.length%4) % 4)); const b = new Uint8Array(s.length); for (let i = 0; i < s.length; i++) b[i] = s.charCodeAt(i); return b } function inspect(input) { const b = typeof input === "string" ? decodeEnvelope(input) : input; String.fromCharCode(...b.subarray(0, 5)) !== MAGIC && no("not-seal1", "Not an envelope. It doesn't start with SEAL1."); b.length < MIN_BYTES && no("too-short", `Not an envelope. It is ${num(b.length)} bytes long, and the shortest possible is 53.`); const iterations = new DataView(b.buffer, b.byteOffset).getUint32(5), textBytes = b.length - MIN_BYTES; let j = 0; const fields = [["format", 5], ["iterations", 4], ["salt", 16], ["nonce", 12], ["ciphertext", textBytes], ["tag", 16]] .map(([name, length]) => ({name, offset: (j += length) - length, length})); const f = i => b.subarray(fields[i].offset, fields[i].offset + fields[i].length); return {bytes: b, iterations, allowed: iterations >= 1 && iterations <= MAX_ITERATIONS, cost: iterations / DEFAULT_ITERATIONS, salt: f(2), iv: f(3), ciphertext: f(4), tag: f(5), textBytes, fields} } async function derive(p, salt, n) { const k = await crypto.subtle.importKey("raw", utf8.encode(p), "PBKDF2", false, ["deriveKey"]); return crypto.subtle.deriveKey({name: "PBKDF2", hash: "SHA-256", salt, iterations: n}, k, {name: "AES-GCM", length: 256}, false, ["encrypt", "decrypt"]) } function vet(s, p, n) { p || no("no-passphrase", "A passphrase is required."); p.isWellFormed() || no("ill-formed", "The passphrase has a broken character (a lone surrogate)."); if (!s.isWellFormed()) { const i = s.search(LONE), {line, column} = walk({line: 1, column: 1}, s.slice(0, i)); no("ill-formed", `The text has a broken character at line ${line}, column ${column} (a lone surrogate, ${hex(s.charCodeAt(i))}).`) } const b = utf8.encode(s); b.length > MAX_TEXT_BYTES && no("text-too-large", `The text is ${num(b.length)} bytes. This page seals up to 1,000,000.`); (Number.isInteger(n) && n >= 1 && n <= MAX_ITERATIONS) || no("iterations", "Iterations must be a whole number from 1 to 10,000,000."); return b } async function seal(text, passphrase, iterations = DEFAULT_ITERATIONS) { let b = vet(text, passphrase, iterations); const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES)), iv = crypto.getRandomValues(new Uint8Array(IV_BYTES)); b = new Uint8Array(await crypto.subtle.encrypt({name: "AES-GCM", iv}, await derive(passphrase, salt, iterations), b)); const o = new Uint8Array(HEADER_BYTES + b.length); o.set(utf8.encode(MAGIC)); new DataView(o.buffer).setUint32(5, iterations); o.set(salt, 9); o.set(iv, 25); o.set(b, HEADER_BYTES); return b64(o) } // Unicode: a keyboard may type another normal form; tried as typed, then NFC, then NFD, and seal() never changes it async function unseal(input, passphrase) { const envelope = inspect(input), a = new Set(); envelope.allowed || no("iterations", deny(envelope.iterations)); passphrase || no("no-passphrase", "A passphrase is required."); for (const form of ["typed", "NFC", "NFD"]) { const p = form === "typed" ? passphrase : passphrase.normalize(form); if (a.has(p)) continue; a.add(p); const t = performance.now(), k = await derive(p, envelope.salt, envelope.iterations), ms = performance.now() - t; let bytes, text = null; try { bytes = new Uint8Array(await crypto.subtle.decrypt({name: "AES-GCM", iv: envelope.iv}, k, envelope.bytes.subarray(HEADER_BYTES))) } catch { continue } try { text = new TextDecoder("utf-8", {fatal: true, ignoreBOM: true}).decode(bytes) } catch {} return {bytes, text, form, ms, envelope} } no("wrong-passphrase", "Wrong passphrase, or the envelope is damaged.") } async function open(input, passphrase) { const {text, bytes} = await unseal(input, passphrase); text === null && no("not-utf8", `It opened, but the ${num(bytes.length)} bytes inside aren't UTF-8 text.`); return text } function toLink(envelope, page = PAGE) { return page + "#" + b64(inspect(envelope).bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") } // URL: dot segments drop out and /.//host/x parses to //host/x, another host to fetch; checked before and after parsing function sourceFrom(hash, search, origin) { if (hash.length > 1) return {kind: "hash", value: hash.slice(1)}; const s = new URLSearchParams(search).get("from"); if (!s) return null; let u; if (s.startsWith("/") && !s.startsWith("//") && !/[\\\0-\x20\x7F]/.test(s)) try { u = new URL(s, origin) } catch {} return u?.origin === origin && !u.pathname.startsWith("//") ? {kind: "path", path: u.pathname + u.search} : {kind: "refused"} } function passphraseNotes(p) { const n = [...p].length; return [n && n < 12 && "short", /^\s/.test(p) && "leading-space", /\s$/.test(p) && "trailing-space"].filter(Boolean) } // inv: 16 characters of 5 bits; 256 is a multiple of 32, and & 31 has no bias function generatePassphrase(random = n => crypto.getRandomValues(new Uint8Array(n))) { return {text: Array.from(random(16), x => ALPHABET[x & 31]).join("").replace(/.{4}(?=.)/g, "$&-"), bits: 80} } function hiddenCharacters(text) { const a = [], r = {line: 1, column: 1}; let i = 0; for (const m of text.matchAll(HIDDEN)) { if (m[1]) continue; walk(r, text.slice(i, m.index)); i = m.index; a.push({index: m.index, line: r.line, column: r.column, codePoint: m[0].codePointAt(0)}) } return a } function revealHidden(text) { return text.replace(HIDDEN, (m, s) => s ? m : `⟨${hex(m.codePointAt(0))}⟩`) } function hexGroups(bytes, limit = Infinity) { const b = bytes.subarray(0, limit), s = Array.from(b, x => x.toString(16).padStart(2, "0")).join(""); return {text: s.replace(/.{4}(?=.)/g, "$& "), more: bytes.length - b.length} } // guard: text from outside reaches the page only as .value or .textContent function page($) { // mode busy owed caps made idle turn down the page's state · out what Seal and Open made · seen the envelope as read // MOST HINT MODE MOOT FLAG EDGE COPY ACT fixed tables · pair the mode inputs · cue the "open it" button // h make · note say show aria flag tell write the page · secs into pick hint draw scan moot drop work load copy // mark peek look · soon after send edit move swap pass lock keys roll wipe the handlers' words const pair = $("tool").querySelectorAll("[name=mode]"), HINT = $("opened").placeholder; // cost: "show them" writes 8 characters a mark; Chrome takes most of a second on 50,000, over 4 minutes on 333,333 const MOST = 1e3; // inv: all that replaces the envelope bumps turn, and a ?from= load that lands on a later turn is dropped let mode, busy, owed, seen, caps, made, idle, turn = 0, down = false; const out = {seal: null, open: null}; const MODE = { seal: {radio: "mode-seal", go: "Seal", field: "plain"}, open: {radio: "mode-open", go: "Open", field: "envelope"} }; const MOOT = { seal: ["sealed-stale", ["copy-envelope", "copy-link", "link-note", "sealed-clip"]], open: ["opened-stale", ["copy-text", "opened-clip"]] }; const FLAG = {plain: "plain-note", passphrase: "passphrase-error"}; const EDGE = {"leading-space": "The passphrase starts with a space.", "trailing-space": "The passphrase ends with a space."}; const h = (s, o) => Object.assign(document.createElement(s), o); const note = (id, s = "", q) => { $(id).textContent = s; $(id).classList.toggle("error", !!q) }; // a11y: a live region speaks only when its text changes const say = s => { note("status", $("status").textContent === s ? s + "\u00a0" : s) }; const aria = (id, k, on) => { if (on) $(id).setAttribute(k, "true"); else $(id).removeAttribute(k) }; const show = (on, ...ids) => { for (const id of ids) $(id).hidden = !on }; const secs = t => (t / 1e3).toFixed(t < 1e3 ? 2 : 1) + " s"; const into = id => { if ($(id).getBoundingClientRect().top > innerHeight) $(id).scrollIntoView({block: "nearest"}) }; const flag = (id, s) => { note(FLAG[id], s, 1); aria(id, "aria-invalid", s) }; const cue = h("button", {type: "button", id: "to-open", className: "quiet", textContent: "open it"}); function pick(m) { mode = $("tool").dataset.mode = m; $(MODE[m].radio).checked = true; show(m === "seal", "seal-source", "seal-result", "generate"); show(m === "open", "open-source", "open-result"); $("go").textContent = MODE[m].go; flag("passphrase", ""); hint(); draw(m === "seal" ? out.seal : seen) } function hint() { const s = [ made ? "Generated here, 80 bits. Send it by a different route than the envelope." : mode === "seal" && passphraseNotes($("passphrase").value)[0] === "short" && "Anyone with the envelope can guess at a passphrase this short offline.", caps && document.activeElement === $("passphrase") && "Caps Lock is on."].filter(Boolean).join(" "); if (s === $("passphrase-note").textContent) return; note("passphrase-note", s); // a11y: a description is read when focus arrives, and this one changes while focus stays in the field if (s) say(s) } function draw(r) { $("inspect").hidden = !r; if (!r) return; $("fields").replaceChildren(...r.fields.map((x, i) => { const {text, more} = hexGroups(r.bytes.subarray(x.offset, x.offset + x.length), 16), o = h("tr"); o.append(...[num(x.offset), num(x.length), x.name, [MAGIC, num(r.iterations)][i] ?? text] .map(s => h("td", {textContent: s}))); if (more) o.lastChild.append(h("span", {className: "more", textContent: ` … ${num(more)} more`})); if (i === 1) o.lastChild.replaceChildren(...num(r.iterations).split(/(?<=,)/).flatMap(s => [h("wbr"), s]).slice(1)); return o })); note("inspect-note", `Anyone who has the envelope can read this table; only the passphrase is missing from it. The ciphertext is exactly as long as the text, so they also know the text is ${plural(r.textBytes, "byte")}.`) } function tell(a, q = a.some(x => x[1])) { aria("envelope", "aria-invalid", q); if (a.map(x => x[0]).join("") === $("envelope-note").textContent) return; $("envelope-note").replaceChildren(...a.map(x => h("span", {textContent: x[0], className: x[1] ? "error" : ""}))) } function scan() { clearTimeout(idle); let a = []; seen = null; if ($("envelope").value.trim()) try { const r = seen = inspect($("envelope").value); a = [[`SEAL1 · ${plural(r.iterations, "iteration")} · holds ${plural(r.textBytes, "byte")}`], r.allowed ? r.cost > 4 && [`This envelope asks for ${num(r.iterations)} iterations, ${num(Math.round(r.cost))} times what this page uses. Opening it will take a while.`] : [deny(r.iterations), 1]].filter(Boolean) } catch (w) { a = [[w.message, 1]] } tell(a); if (mode === "open") draw(seen) } function moot(m) { if (!out[m]) return; const [s, a] = MOOT[m], q = $(s).hidden; show(1, s); show(0, ...a); if (q && m === mode) say($(s).textContent) } function drop() { out.open = null; $("opened").value = ""; $("opened").placeholder = HINT; note("opened-facts"); $("reveal").setAttribute("aria-pressed", false); show(0, "opened-stale", "hidden-note", "reveal", "copy-text", "opened-clip") } async function work(s, f) { const t = performance.now(), g = () => { note("elapsed", secs(performance.now() - t)) }; const ms = matchMedia("(prefers-reduced-motion: reduce)").matches ? 1e3 : 100; const hold = on => { // a11y: Enter on a mode radio submits too, and disabling the focused radio would drop focus to the page if (on && [...pair].includes(document.activeElement)) $("go").focus(); busy = on; aria("tool", "aria-busy", on); for (const id of ["go", "generate", "clear"]) aria(id, "aria-disabled", on); $("plain").readOnly = $("envelope").readOnly = $("passphrase").readOnly = on; for (const x of pair) x.disabled = on }; let ticker; const wait = setTimeout(() => { g(); ticker = setInterval(g, ms) }, 500); hold(true); $("go").textContent = s; say("Deriving a key."); try { await f() } finally { clearTimeout(wait); clearInterval(ticker); note("elapsed"); hold(false); $("go").textContent = MODE[mode].go; if (owed) { owed = false; load() } } } const ACT = { async seal() { const s = $("plain").value, p = $("passphrase").value; if (!s) flag("plain", "Nothing to seal."); if (!p) flag("passphrase", "A passphrase is required."); if (!s || !p) return $(s ? "passphrase" : "plain").focus(); try { vet(s, p, DEFAULT_ITERATIONS) } catch (w) { const id = p.isWellFormed() ? "plain" : "passphrase", i = s.search(LONE); flag(id, w.message); $(id).focus(); if (id === "plain" && i >= 0) $("plain").setSelectionRange(i, i + 1); return } await work("Sealing…", async () => { const t = performance.now(), r = await seal(s, p), ms = performance.now() - t, u = toLink(r); out.seal = inspect(r); $("sealed").value = r; note("sealed-facts", `${plural(out.seal.iterations, "iteration")} · key in ${secs(ms)} · ${num(r.length)} characters`); note("link-note", `The link is ${num(u.length)} characters. Some chat apps cut links that long; send the envelope itself instead.`); show(u.length > 2e3, "link-note"); show(0, "sealed-stale", "sealed-clip"); show(1, "copy-envelope", "copy-link"); if (mode === "seal") draw(out.seal); say(`Sealed. The envelope is ${num(r.length)} characters.`); into("seal-result") }) }, async open() { const p = $("passphrase").value; let r, why; try { r = inspect($("envelope").value) } catch (w) { why = w.message } if (!r?.allowed) { flag("passphrase", ""); scan(); if (why) tell([[why, 1]]); return $("envelope").focus() } if (!p) { flag("passphrase", "A passphrase is required."); return $("passphrase").focus() } turn++; await work("Opening…", async () => { let o; try { o = await unseal(r.bytes, p) } catch (w) { flag("passphrase", [w.message, ...passphraseNotes(p).map(k => EDGE[k]), caps && "Caps Lock is on."].filter(Boolean).join(" ")); $("passphrase").focus(); return $("passphrase").select() } drop(); flag("passphrase", ""); out.open = o; const s = o.text, a = s === null ? [] : hiddenCharacters(s), n = a.length; $("opened").value = s ?? ""; if (s === "") $("opened").placeholder = "The text is empty."; note("opened-facts", `${plural(o.envelope.iterations, "iteration")} · key in ${secs(o.ms)} · ${plural(o.bytes.length, "byte")}` + (o.form === "typed" ? "" : ` · matched once normalized to ${o.form}`)); note("hidden-note", s === null ? `It opened, but the ${plural(o.bytes.length, "byte")} inside aren't UTF-8 text, so there is nothing to show.` : `${n === 1 ? "1 character here is invisible or changes" : `${num(n)} characters here are invisible or change`} the direction of the text: ` + a.slice(0, 5).map(x => `${hex(x.codePoint)} at line ${num(x.line)}, column ${num(x.column)}`).join("; ") + (n > 5 ? `, and ${num(n - 5)} more.` : ".") + (n > MOST ? ` This page shows them in place only when there are ${num(MOST)} or fewer.` : "")); show(s === null || n, "hidden-note"); show(n && n <= MOST, "reveal"); show(!!s, "copy-text"); say(s === null ? $("hidden-note").textContent : n ? `Opened. It contains ${plural(n, "invisible character")}.` : s ? "Opened." : "Opened. The text is empty."); into("open-result") }) } }; // guard: the hash wins over ?from=, and a passphrase never comes from the address async function load() { const from = sourceFrom(location.hash, location.search, location.origin), me = ++turn; const f = s => tell([[s, 1]], false); const away = "The address names a file on another site, so nothing was loaded."; if (busy) { owed = true; return } if (!from) return; moot("open"); $("envelope").value = $("envelope-from").textContent = ""; scan(); pick("open"); let s = from.value, where = "the link"; if (from.kind === "refused") return f(away); if (from.kind === "path") { const could = "Couldn't load the envelope named in the address"; tell([["Loading…"]]); let r; try { r = await fetch(from.path, {credentials: "omit", redirect: "follow"}); if (me !== turn) return; if (new URL(r.url).origin !== location.origin) return f(away); if (!r.ok) return f(`${could} (${r.status}).`); if (+r.headers.get("content-length") > MAX_INPUT_CHARS) return f(BIG); s = await r.text() } catch { if (me === turn) f(`${could} (network error).`); return } if (me !== turn) return; if (s.length > MAX_INPUT_CHARS) return f(BIG); // guard: the path only; any file here loads whatever query follows it, and that query is the link author's text where = from.path.split("?")[0]; if (where.length > 60) where = where.slice(0, 59) + "…" } try { s = b64(inspect(s).bytes) } catch (w) { if (w.code === "too-large") return f(BIG) } $("envelope").value = s; $("envelope-from").textContent = ", from " + where; scan(); if (seen) say(`Envelope loaded from ${where}.`) } const COPY = { "copy-envelope": [() => b64(out.seal.bytes), "sealed", "sealed-clip", "envelope"], "copy-link": [() => toLink(out.seal.bytes), "sealed", "sealed-clip", "link"], "copy-text": [() => out.open.text, "opened", "opened-clip", "text"] }; async function copy(id) { const [f, field, clip, what] = COPY[id], s = f(); try { await navigator.clipboard.writeText(s) } catch { if (field === "opened") mark(false); else $(field).value = s; $(field).focus(); $(field).select(); note(clip, `The clipboard isn't available here. The ${what} is selected; Ctrl+C or ⌘C will copy it.`); show(1, clip); return say($(clip).textContent) } if (field === "sealed") $("sealed").value = b64(out.seal.bytes); show(0, clip); $(id).dataset.done = ""; clearTimeout($(id).timer); $(id).timer = setTimeout(() => { delete $(id).dataset.done }, 2e3); say("Copied.") } function mark(on) { $("reveal").setAttribute("aria-pressed", on); $("opened").value = on ? revealHidden(out.open.text) : out.open.text } function peek(on) { $("show").setAttribute("aria-pressed", on); $("passphrase").type = on ? "text" : "password" } function look() { const s = $("plain").value.trim(); let q = false; clearTimeout(idle); if (s && !s.includes(" ")) try { q = !!inspect(s) } catch {} if (q === $("plain-note").contains(cue)) return; note("plain-note", q ? "This looks like an envelope. " : ""); if (q) $("plain-note").append(cue) } function edit() { moot("seal"); if ($("plain").hasAttribute("aria-invalid")) { aria("plain", "aria-invalid", 0); note("plain-note") } clearTimeout(idle); idle = setTimeout(look, 400) } function move() { if (busy) return; turn++; $("envelope").value = $("plain").value.trim(); $("plain").value = $("envelope-from").textContent = ""; note("plain-note"); moot("seal"); moot("open"); scan(); pick("open"); $("passphrase").focus() } function swap() { turn++; $("envelope-from").textContent = ""; moot("open"); clearTimeout(idle); idle = setTimeout(scan, 400) } function pass() { made = false; moot("seal"); flag("passphrase", ""); hint() } function roll() { if (busy) return; $("passphrase").value = generatePassphrase().text; peek(true); pass(); made = true; hint(); say("Passphrase generated, 80 bits.") } function wipe() { if (busy) return; turn++; if (!$("envelope").value) tell([]); if (mode === "seal") { $("plain").value = $("sealed").value = ""; out.seal = null; aria("plain", "aria-invalid", 0); note("plain-note"); note("sealed-facts"); show(0, "sealed-stale", "link-note", "copy-envelope", "copy-link", "sealed-clip") } else { $("envelope").value = $("envelope-from").textContent = ""; seen = null; tell([]); drop() } $("passphrase").value = ""; made = false; moot("seal"); flag("passphrase", ""); hint(); draw(null); if (location.hash || location.search) history.replaceState(null, "", location.pathname); say("Cleared."); $(MODE[mode].field).focus() } const send = e => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); $("tool").requestSubmit() } }; const soon = f => () => { setTimeout(f) }; const lock = e => { caps = e.getModifierState("CapsLock"); hint() }; // Safari: focus moved by arrow keys between radios is not :focus-visible; data-keys stands in const keys = e => { $("mode").toggleAttribute("data-keys", e.type === "keydown") }; // Chrome, Safari: a press blurs the field first, and a note shown on blur moves the button out from under the click addEventListener("pointerdown", () => { down = true }, true); for (const k of ["pointerup", "pointercancel"]) addEventListener(k, () => { down = false }, true); const after = f => () => { if (!down) return f(); addEventListener("pointerup", () => setTimeout(f), {once: true, capture: true}) }; Object.assign($("tool"), {onsubmit: e => { e.preventDefault(); if (!busy) ACT[mode]() }}); for (const x of pair) x.onchange = () => { pick(x.value) }; Object.assign($("mode"), {onkeydown: keys, onpointerdown: keys}); Object.assign($("plain"), {onkeydown: send, oninput: edit, onpaste: soon(look)}); Object.assign(cue, {onclick: move}); Object.assign($("envelope"), {onkeydown: send, oninput: swap, onpaste: soon(scan), onblur: after(scan)}); Object.assign($("passphrase"), {oninput: pass, onkeydown: lock, onkeyup: lock, onfocus: hint, onblur: after(hint)}); Object.assign($("show"), {onclick: () => { peek($("passphrase").type === "password") }}); Object.assign($("generate"), {onclick: roll}); for (const id in COPY) $(id).onclick = () => { copy(id) }; Object.assign($("reveal"), {onclick: () => { mark($("reveal").getAttribute("aria-pressed") === "false") }}); Object.assign($("clear"), {onclick: wipe}); // guard: Back on a shared computer shows no passphrase and no text, typed or opened addEventListener("pagehide", () => { $("plain").value = $("passphrase").value = ""; aria("plain", "aria-invalid", 0); note("plain-note"); made = false; moot("seal"); hint(); drop() }); addEventListener("hashchange", load); pick("seal"); load() } if (typeof document !== "undefined") { const $ = id => document.getElementById(id); if (globalThis.crypto?.subtle) page($); else { $("tool").hidden = true; $("unsupported").hidden = false } }