From f58ef8d7333a234f56d2431830522aaf20fb59af Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Tue, 27 Oct 2026 14:26:45 -0500 Subject: [PATCH 01/32] Stop sending the Chrome extension's logs to Axiom The extension's service worker sent its debug and error logs to Axiom whenever it was built with an ingest token. Those logs carried user ids, push ids and error strings, and Axiom was a third party the privacy policy never named. With pushes now end-to-end encrypted, the extension should not be passing identifiers to anyone else, so the logging is removed rather than disclosed. `debug` and `logError` now write to the service worker's console only. The `@axiomhq/js` dependency, `VITE_AXIOM_TOKEN` and the `.env.example` that existed only for it are gone, along with the mentions in the docs, CI and AGENTS.md. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 3 +-- AGENTS.md | 6 +++--- apps/web/.env.example | 5 ----- apps/web/.gitignore | 2 +- apps/web/chrome/background.ts | 40 ++++++----------------------------- apps/web/package-lock.json | 33 ----------------------------- apps/web/package.json | 1 - cspell.json | 1 - docs/README.md | 2 +- docs/web.md | 9 ++++---- 10 files changed, 16 insertions(+), 86 deletions(-) delete mode 100644 apps/web/.env.example diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 88724bb..1fc218b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,8 +75,7 @@ jobs: # rules; plain Node. - run: npm run test:keys - run: npm run build - # The extension builds without VITE_AXIOM_TOKEN and then logs to the console only, - # which is what a fork gets. Its bundle is not uploaded from here. + # The extension's bundle is not uploaded from here. - run: npm run build:chrome android: diff --git a/AGENTS.md b/AGENTS.md index ff36495..4d65fb3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,9 +38,9 @@ original did. Prefer placeholders that are obviously made up (`example.com`, `someone@example.com`) over ones that merely sound plausible. The same goes for credentials. The service account key for `packages/push-test`, Stripe -keys and webhook secrets, the Axiom token, signing keystores: none of them is ever -committed, and each has a gitignored place to live (see the docs for each app). If you find -real content or a secret already in the repo, say so rather than quietly working around it. +keys and webhook secrets, signing keystores: none of them is ever committed, and each has a +gitignored place to live (see the docs for each app). If you find real content or a secret +already in the repo, say so rather than quietly working around it. ## Commits diff --git a/apps/web/.env.example b/apps/web/.env.example deleted file mode 100644 index dc0041a..0000000 --- a/apps/web/.env.example +++ /dev/null @@ -1,5 +0,0 @@ -# Copy to .env and fill in. .env is gitignored; never commit a real value. - -# Axiom ingest-only token for the Chrome extension's logging (chrome/background.ts). -# Leave it empty and the extension logs to the console only. -VITE_AXIOM_TOKEN= diff --git a/apps/web/.gitignore b/apps/web/.gitignore index 3826bbb..8aed52a 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -28,5 +28,5 @@ chrome-extension # Firebase .firebase -# Local environment (see .env.example) +# Local environment .env diff --git a/apps/web/chrome/background.ts b/apps/web/chrome/background.ts index 19c5192..65ec1f3 100644 --- a/apps/web/chrome/background.ts +++ b/apps/web/chrome/background.ts @@ -2,7 +2,6 @@ /// /// /// -import { Axiom } from '@axiomhq/js'; import { User } from 'firebase/auth'; import { arrayUnion, @@ -28,12 +27,6 @@ declare const self: ServiceWorkerGlobalScope & typeof globalThis; // Initialize Firebase Cloud Messaging const messaging = getMessaging(app); -// The ingest token comes from the build environment (apps/web/.env, see .env.example) so -// that it is never committed. It still ships inside the built extension, so it has to be an -// ingest-only token. A build without one logs to the console only. -const axiomToken = import.meta.env.VITE_AXIOM_TOKEN; -const axiom = axiomToken ? new Axiom({ token: axiomToken }) : undefined; - interface SignInMessage { type: 'ON_AUTH_STATE_CHANGED'; user: User; @@ -186,15 +179,10 @@ async function handlePush(event: PushEvent) { // The data says which push, never what is in it; the URL is not logged once decrypted. debug('Received push notification ', { pushId: data?.pushId, type: data?.type }); - try { - // Every push is announced by its id alone (functions/src/notifications.ts): the rules - // refuse a plaintext push, so the message that carried a URL and a device name to open - // from is never sent, and a URL is only ever opened once it has been decrypted here. - if (data?.pushId) await handleEncryptedPush(data); - } finally { - // Axiom batches in the background; make sure the logs land before the worker is allowed to sleep. - await axiom?.flush(); - } + // Every push is announced by its id alone (functions/src/notifications.ts): the rules + // refuse a plaintext push, so the message that carried a URL and a device name to open + // from is never sent, and a URL is only ever opened once it has been decrypted here. + if (data?.pushId) await handleEncryptedPush(data); } // Handle notification clicks @@ -263,28 +251,12 @@ chrome.runtime.onStartup.addListener(async () => { await start(); }); +// Logs stay in the extension's own console. They went to Axiom as well until it was removed: +// that sent user and push ids to a third party the privacy policy did not name. async function debug(message: string, optionalParams?: any) { console.debug(message, optionalParams); - return axiom?.ingest('push-that-chrome', [ - { - type: 'debug', - message, - source: 'chrome-extension', - timestamp: Date.now(), - ...(optionalParams ? { ...optionalParams } : {}), - }, - ]); } async function logError(message: string, optionalParams?: any) { console.error(message, optionalParams); - return axiom?.ingest('push-that-chrome', [ - { - type: 'error', - message, - source: 'chrome-extension', - timestamp: Date.now(), - ...(optionalParams ? { ...optionalParams } : {}), - }, - ]); } diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json index 7660854..be2dc71 100644 --- a/apps/web/package-lock.json +++ b/apps/web/package-lock.json @@ -8,7 +8,6 @@ "name": "pushthat-web", "version": "0.0.0", "dependencies": { - "@axiomhq/js": "^1.3.1", "@heroicons/react": "^2.2.0", "clsx": "^2.1.1", "firebase": "^11.2.0", @@ -54,19 +53,6 @@ "node": ">=6.0.0" } }, - "node_modules/@axiomhq/js": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@axiomhq/js/-/js-1.3.1.tgz", - "integrity": "sha512-Ytf5V3wKz8FKNiqJxnqZmUhjgJ7TItKUoyHVNE/H2V9dN1ozD6NNnsueenOjKdA48cm2sGRyP432nworst18aA==", - "license": "MIT", - "dependencies": { - "fetch-retry": "^6.0.0", - "uuid": "^11.0.2" - }, - "engines": { - "node": ">=16" - } - }, "node_modules/@babel/code-frame": { "version": "7.26.2", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", @@ -4367,12 +4353,6 @@ "node": ">=0.8.0" } }, - "node_modules/fetch-retry": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/fetch-retry/-/fetch-retry-6.0.0.tgz", - "integrity": "sha512-BUFj1aMubgib37I3v4q78fYo63Po7t4HUPTpQ6/QE6yK6cIQrP+W43FYToeTEyg5m2Y7eFUtijUuAv/PDlWuag==", - "license": "MIT" - }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -5915,19 +5895,6 @@ "punycode": "^2.1.0" } }, - "node_modules/uuid": { - "version": "11.0.5", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz", - "integrity": "sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/esm/bin/uuid" - } - }, "node_modules/vite": { "version": "6.0.11", "resolved": "https://registry.npmjs.org/vite/-/vite-6.0.11.tgz", diff --git a/apps/web/package.json b/apps/web/package.json index 758aa5d..78872d6 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -18,7 +18,6 @@ "spellcheck": "cspell -c ../../cspell.json --gitignore \"**/*.{js,json,jsx,md,ts,tsx}\"" }, "dependencies": { - "@axiomhq/js": "^1.3.1", "@heroicons/react": "^2.2.0", "clsx": "^2.1.1", "firebase": "^11.2.0", diff --git a/cspell.json b/cspell.json index 95e3ead..08a1021 100644 --- a/cspell.json +++ b/cspell.json @@ -12,7 +12,6 @@ ], "words": [ "Avenir", - "axiomhq", "CDEUTSCH", "crxjs", "Deutsch", diff --git a/docs/README.md b/docs/README.md index 31736e4..ae90c35 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,7 +5,7 @@ design record as much as a manual, with the decision and the reason for it, whic house style ([AGENTS.md](../AGENTS.md)). - [The web app and the Chrome extension](web.md): one codebase, two builds; why pushes are - received in the extension; the Axiom token; the rules and deploying. + received in the extension; the rules and deploying. - [Android app](android.md): building, running on an emulator, release signing. - [iOS app](ios.md): building, signing, notifications, and how it matches Android. - [macOS app](macos.md): the fork of the iOS app, and how it differs. diff --git a/docs/web.md b/docs/web.md index cca5f91..c3c19aa 100644 --- a/docs/web.md +++ b/docs/web.md @@ -34,11 +34,10 @@ What exists today: push handling is in the extension's background service worker background service worker is `chrome/background.ts`. Load `chrome-extension/` as an unpacked extension from `chrome://extensions` to try it. -The extension logs to Axiom when it is built with an ingest token. The token comes from -`VITE_AXIOM_TOKEN` in `apps/web/.env` (copy `.env.example`; `.env` is gitignored) or in the -build's environment. It ships inside the built extension whatever happens, so it must be an -ingest-only token, and keeping it out of the repository is about not making the repository -the place it is found. A build without one logs to the console only. +The extension logs to its own console only (the service worker's, under "Inspect views" on +`chrome://extensions`). It used to send its logs to Axiom too, which carried user and push +ids to a third party the privacy policy did not name, so that was removed rather than +disclosed. ## Rules, tests and deploying From 66631b35015929a4c23ee8eceb1e76ca33ce706c Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Tue, 27 Oct 2026 14:27:55 -0500 Subject: [PATCH 02/32] Say in the privacy policy that pushes are end-to-end encrypted The policy still described the app from before end-to-end encryption: it said the links and files people push "are stored in a database" and that Firebase stores them, without saying that both now hold only ciphertext. It also never listed the metadata that is still readable, since its definition of personal information covered only device and subscription information. The policy now opens with what is encrypted and links to the Security page. It defines "Account and Push Information" (email, device tokens, the random device id, each push's account, time, kind, sending device, encrypted content and blind search tokens, the rough file size and the monthly counts) and says what it is used for. It also says that a lawful request can only be answered with ciphertext, and how long pushes are kept, including what a reset without the recovery key and pushes from before encryption mean for that. It gains a "last updated" date. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/pages/Privacy.tsx | 65 ++++++++++++++++++++++++++++++---- 1 file changed, 58 insertions(+), 7 deletions(-) diff --git a/apps/web/src/pages/Privacy.tsx b/apps/web/src/pages/Privacy.tsx index a6e07dd..54c6ff6 100644 --- a/apps/web/src/pages/Privacy.tsx +++ b/apps/web/src/pages/Privacy.tsx @@ -1,6 +1,8 @@ import { Helmet } from 'react-helmet'; +import { Link } from 'react-router'; import LegalContainer from '@/components/LegalContainer'; +import { LIMITS } from '@/config/plan'; import styles from './Privacy.module.css'; @@ -13,11 +15,23 @@ export default function Privacy() {

PushThat Privacy Policy

+

Last updated: October 27, 2026

+

This Privacy Policy describes how your personal information is collected, used, and shared when you visit or make a purchase from https://pushthat.link (the “App”).

+
YOUR PUSHES ARE END-TO-END ENCRYPTED
+ +

+ The links you push, their page titles, and the names, types and contents of the files you push (together, “Push + Content”) are encrypted on your device before they leave it, with a key made from your password. They can be + decrypted only on the devices you sign in to. We, and Google, which runs the servers the App uses, hold Push + Content only in that encrypted form and cannot read it. Your password and your recovery key are never sent to + us. The Security page explains how this works and what it does not cover. +

+
PERSONAL INFORMATION WE COLLECT

@@ -25,8 +39,8 @@ export default function Privacy() { about your web browser, IP address, time zone, and some of the cookies that are installed on your device. Additionally, as you browse the App, we collect information about the individual web pages you view, what websites or search terms referred you to the App, and information about how you interact with the App. We refer - to this automatically-collected information as “Device Information.” The Urls and files you share via the App - are stored in a database. They are not collected as part of "Device Information". + to this automatically-collected information as “Device Information.” Push Content is not part of Device + Information.

We collect Device Information using the following technologies:

@@ -58,12 +72,23 @@ export default function Privacy() {

By registering or authenticating via our email and password login, Users allow the App to identify them and give - them access to the App's services. + them access to the App's services. The App does not send us your password: it sends a key derived from it, from + which neither your password nor the key that decrypts your Push Content can be worked out.

- When we talk about “Personal Information” in this Privacy Policy, we are talking both about Device Information - and Subscription Information. + To deliver your pushes we keep your email address, the notification tokens of the devices you sign in on, and a + random identifier that each installation of the App makes for itself. For each push we keep which account made + it, when, whether it is a link or a file, which of your devices sent it, the encrypted Push Content, and + scrambled search tokens made with your key, which let a search find a push without revealing the words searched + for. The size of a file you push can be seen roughly from its encrypted form. We also keep how many pushes your + account has made each month. We refer to this as “Account and Push Information.” It does not include Push + Content in any readable form. +

+ +

+ When we talk about “Personal Information” in this Privacy Policy, we are talking about Device Information, + Subscription Information, and Account and Push Information.

HOW DO WE USE YOUR PERSONAL INFORMATION?
@@ -73,6 +98,11 @@ export default function Privacy() { Additionally, we use this Subscription Information to communicate with you.

+

+ We use Account and Push Information to deliver your pushes to your devices, to apply your plan's monthly limits, + and to delete files when their retention period ends. +

+

We use the Device Information that we collect to help us screen for potential risk and fraud (in particular, your IP address), and more generally to improve and optimize our App (for example, by generating analytics about @@ -89,13 +119,16 @@ export default function Privacy() { Pro subscriptions, and share your email address with it so that it can issue receipts and renewal notices; you can read more about how Stripe uses your Personal Information at{' '} {'https://stripe.com/privacy'}. The App itself runs on Google’s - Firebase, which stores your account, the links and files you push, and the device tokens that deliver them. + Firebase, which stores your Account and Push Information, including your Push Content in its encrypted form. + Notifications are delivered through Google’s and Apple’s notification services and carry only an identifier for + the push, its kind and the device that sent it, never its Push Content.

Finally, we may also share your Personal Information to comply with applicable laws and regulations, to respond to a subpoena, search warrant or other lawful request for information we receive, or to otherwise protect our - rights. + rights. We can provide only what we hold: Push Content can be provided only in its encrypted form, which we + cannot decrypt.

DO NOT TRACK
@@ -125,6 +158,24 @@ export default function Privacy() { unless and until you ask us to delete this information.

+

+ A link push is kept until you delete it or your account is closed. A file push, with its record, is deleted when + the retention period of the plan your account was on when you pushed it ends ({LIMITS.free.keepDays} days on the + free plan, {LIMITS.pro.keepDays} days on Pro). +

+ +

+ If you reset your password without your recovery key, the pushes made before the reset can no longer be + decrypted by you or by us. They stay in your account as locked entries until you delete them, which the App + offers to do in one step, or, for files, until their retention period ends. +

+ +

+ Pushes made before your App was updated to end-to-end encryption were stored as they were sent. A link pushed + then is encrypted in place the next time an updated App signs in to your account. A file pushed then stays as it + was sent, under its own name, until its retention period ends. +

+

The App is not intended for individuals under the age of 18.

CHANGES
From c0467ac0603aebc1cde5c0417ddfc232f2855f8a Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Tue, 27 Oct 2026 14:28:29 -0500 Subject: [PATCH 03/32] Say in the terms what end-to-end encryption means for content and a lost password The terms still reserved rights the encrypted app cannot exercise: to "pre-screen" content, and to access and disclose it as if it were readable. They also said nothing about the one loss the design makes certain: a password reset without the recovery key leaves the earlier pushes unreadable by anyone. A new section under the account and password terms says what is encrypted, that the user alone holds the password and the recovery key, and that pushes lost with both are not recoverable or something we are liable for. The pre-screening clause keeps the right to refuse and delete content but says it is not reviewed, and the disclosure clause says content can only be handed over encrypted. The effective date moves to 10/27/2026. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/pages/Terms.tsx | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/apps/web/src/pages/Terms.tsx b/apps/web/src/pages/Terms.tsx index b531494..993ba99 100644 --- a/apps/web/src/pages/Terms.tsx +++ b/apps/web/src/pages/Terms.tsx @@ -20,7 +20,7 @@ export default function Terms() { SERVICES, YOU HEREBY AGREE TO BE BOUND BY THE TERMS AND ALL TERMS INCORPORATED HEREIN BY REFERENCE. IT IS THE RESPONSIBILITY OF YOU, THE USER, CUSTOMER, OR PROSPECTIVE CUSTOMER TO READ THE TERMS AND CONDITIONS BEFORE PROCEEDING TO USE THIS SITE. IF YOU DO NOT EXPRESSLY AGREE TO ALL OF THE TERMS AND CONDITIONS, THEN PLEASE DO - NOT ACCESS OR USE OUR SITES OR OUR SERVICES. THIS TERMS OF SERVICE AGREEMENT IS EFFECTIVE AS OF 03/16/2025 + NOT ACCESS OR USE OUR SITES OR OUR SERVICES. THIS TERMS OF SERVICE AGREEMENT IS EFFECTIVE AS OF 10/27/2026

ACCEPTANCE OF TERMS

@@ -137,6 +137,18 @@ export default function Terms() { damage arising from any failure to comply with this term and/or condition of the TOS.

+
END-TO-END ENCRYPTION AND YOUR RECOVERY KEY
+ +

+ The links and files you push, and their titles and names (“Push Content”), are end-to-end encrypted with a key + made from your password, as described in the Privacy Policy and at https://pushthat.link/security. When you + register, you are shown a recovery key, which is the only other way to unlock your Push Content. MakeRocketGoNow + LLC never receives your password or your recovery key and cannot decrypt your Push Content. You are solely + responsible for keeping your password and your recovery key. If you reset your password without your recovery + key, or lose both, the Push Content made before then cannot be recovered by you or by MakeRocketGoNow LLC, and + MakeRocketGoNow LLC shall not be held liable for that loss. +

+

CONDUCT

@@ -214,9 +226,10 @@ export default function Terms() {

- MakeRocketGoNow LLC herein reserves the right to pre-screen, refuse and/or delete any content currently - available through our Services. In addition, we reserve the right to remove and/or delete any such content that - would violate the TOS or which would otherwise be considered offensive to other visitors, users and/or members. + MakeRocketGoNow LLC herein reserves the right to refuse and/or delete any content currently available through + our Services. Because Push Content is end-to-end encrypted, MakeRocketGoNow LLC does not and cannot pre-screen + or review it. In addition, we reserve the right to remove and/or delete any such content that would violate the + TOS or which would otherwise be considered offensive to other visitors, users and/or members.

@@ -237,6 +250,11 @@ export default function Terms() { +

+ Push Content can be accessed, preserved and/or disclosed only in its encrypted form, which MakeRocketGoNow LLC + cannot decrypt. +

+

MakeRocketGoNow LLC herein reserves the right to include the use of security components that may permit digital information or material to be protected, and that such use of information and/or material is subject to usage From 03d6e00df1178561deeb518f0df7054487bbe613 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Tue, 27 Oct 2026 14:32:53 -0500 Subject: [PATCH 04/32] Stop promising that the password never leaves the device The privacy policy, the terms and the Security page all said the password is never sent. Two paths send it to Firebase Auth. A reset runs on Firebase's hosted form, which takes the new password as typed. And `signInEitherWay` retries with the raw password whenever `authKey` is refused, so that accounts made before encryption can still sign in; that covers every mistyped password and the first sign-in after a reset. The policy also said nothing could be worked out from the derived key, when a weak password can be found from it by guessing, as the Security page itself says. The three pages now say what is true: the recovery key is never sent, the app signs in with the derived key, the raw password reaches Firebase Authentication on a reset and on the fallback, and the next sign-in after a reset replaces it with the derived key. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/pages/Privacy.tsx | 14 ++++++++++---- apps/web/src/pages/Security.tsx | 9 ++++++--- apps/web/src/pages/Terms.tsx | 9 +++++---- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/apps/web/src/pages/Privacy.tsx b/apps/web/src/pages/Privacy.tsx index 54c6ff6..99943c1 100644 --- a/apps/web/src/pages/Privacy.tsx +++ b/apps/web/src/pages/Privacy.tsx @@ -28,8 +28,9 @@ export default function Privacy() { The links you push, their page titles, and the names, types and contents of the files you push (together, “Push Content”) are encrypted on your device before they leave it, with a key made from your password. They can be decrypted only on the devices you sign in to. We, and Google, which runs the servers the App uses, hold Push - Content only in that encrypted form and cannot read it. Your password and your recovery key are never sent to - us. The Security page explains how this works and what it does not cover. + Content only in that encrypted form and cannot read it. Your recovery key is never sent to us or to Google, and + your password only in the two cases described below. The Security page explains how + this works and what it does not cover.

PERSONAL INFORMATION WE COLLECT
@@ -72,8 +73,13 @@ export default function Privacy() {

By registering or authenticating via our email and password login, Users allow the App to identify them and give - them access to the App's services. The App does not send us your password: it sends a key derived from it, from - which neither your password nor the key that decrypts your Push Content can be worked out. + them access to the App's services. The App signs you in with a key derived from your password rather than the + password itself. Your password can be worked out from that key only by guessing it, which is made deliberately + slow. Your password itself reaches Google’s sign-in service, Firebase Authentication, in two cases: when you + reset it, because the reset is made on Google’s page, and when a sign-in with the derived key is refused (for + example, because the password was mistyped), because the App then tries the password as typed in case your + account was made before end-to-end encryption. After a reset, the App replaces the stored password with the + derived key the next time you sign in.

diff --git a/apps/web/src/pages/Security.tsx b/apps/web/src/pages/Security.tsx index 1dc771f..676c51e 100644 --- a/apps/web/src/pages/Security.tsx +++ b/apps/web/src/pages/Security.tsx @@ -35,9 +35,12 @@ export function Security() {

When you sign in, the app turns your password into two keys on the device: one signs you in, and the other - unlocks the key your pushes are encrypted with. Your password and that second key never leave the device, so we - never see them and cannot read your pushes. It also means we cannot reset your password for you and keep your - pushes: only the recovery key, below, can do that. + unlocks the key your pushes are encrypted with. That second key never leaves the device, so we cannot read your + pushes. Your password reaches Google’s sign-in service in two cases only: when you reset it, since the + reset is made on Google’s own page, and when a sign-in with the first key is refused (a mistyped password, + say), since the app then tries the password as typed in case the account was made before encryption. After a + reset, your next sign-in replaces it with the key. It also means we cannot reset your password for you and keep + your pushes: only the recovery key, below, can do that.

diff --git a/apps/web/src/pages/Terms.tsx b/apps/web/src/pages/Terms.tsx index 993ba99..9512b5f 100644 --- a/apps/web/src/pages/Terms.tsx +++ b/apps/web/src/pages/Terms.tsx @@ -143,10 +143,11 @@ export default function Terms() { The links and files you push, and their titles and names (“Push Content”), are end-to-end encrypted with a key made from your password, as described in the Privacy Policy and at https://pushthat.link/security. When you register, you are shown a recovery key, which is the only other way to unlock your Push Content. MakeRocketGoNow - LLC never receives your password or your recovery key and cannot decrypt your Push Content. You are solely - responsible for keeping your password and your recovery key. If you reset your password without your recovery - key, or lose both, the Push Content made before then cannot be recovered by you or by MakeRocketGoNow LLC, and - MakeRocketGoNow LLC shall not be held liable for that loss. + LLC never receives your recovery key, receives your password only in the cases described in the Privacy Policy, + and cannot decrypt your Push Content. You are solely responsible for keeping your password and your recovery + key. If you reset your password without your recovery key, or lose both, the Push Content made before then + cannot be recovered by you or by MakeRocketGoNow LLC, and MakeRocketGoNow LLC shall not be held liable for that + loss.

CONDUCT

From 6a5fcaa37c7453037922acec696e081ea330816d Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Tue, 27 Oct 2026 14:33:03 -0500 Subject: [PATCH 05/32] Put the Security page's "It also means" back after the key it refers to The previous commit inserted the sentences about when the password reaches Google between "That second key never leaves the device" and "It also means we cannot reset your password for you", so "It" read as referring to the reset. The reset sentences now come after it. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/pages/Security.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/web/src/pages/Security.tsx b/apps/web/src/pages/Security.tsx index 676c51e..d8cddd9 100644 --- a/apps/web/src/pages/Security.tsx +++ b/apps/web/src/pages/Security.tsx @@ -36,11 +36,11 @@ export function Security() {

When you sign in, the app turns your password into two keys on the device: one signs you in, and the other unlocks the key your pushes are encrypted with. That second key never leaves the device, so we cannot read your - pushes. Your password reaches Google’s sign-in service in two cases only: when you reset it, since the - reset is made on Google’s own page, and when a sign-in with the first key is refused (a mistyped password, - say), since the app then tries the password as typed in case the account was made before encryption. After a - reset, your next sign-in replaces it with the key. It also means we cannot reset your password for you and keep - your pushes: only the recovery key, below, can do that. + pushes. It also means we cannot reset your password for you and keep your pushes: only the recovery key, below, + can do that. Your password reaches Google’s sign-in service in two cases only: when you reset it, since + the reset is made on Google’s own page, and when a sign-in with the first key is refused (a mistyped + password, say), since the app then tries the password as typed in case the account was made before encryption. + After a reset, your next sign-in replaces it with the key.

From 21fd437a30a9f70c3e4f3993282fa5f4de12128f Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Tue, 27 Oct 2026 14:33:27 -0500 Subject: [PATCH 06/32] Except pushes from before encryption where the legal pages say content is unreadable The privacy policy and the terms said Push Content is held, and can be disclosed, only in encrypted form, and that it cannot be decrypted or reviewed. That is not true of pushes made before encryption: a link stays as it was sent until an updated app signs in to the account, which for an account that never updates is indefinitely, and a file keeps its plaintext bytes and name until it expires. The privacy policy's retention section already said so, so the documents contradicted themselves. Each of those statements now carries the exception. The terms' pre-screening clause drops "cannot" and keeps "does not", which is true either way. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/pages/Privacy.tsx | 9 +++++---- apps/web/src/pages/Terms.tsx | 17 +++++++++-------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/apps/web/src/pages/Privacy.tsx b/apps/web/src/pages/Privacy.tsx index 99943c1..04a16d1 100644 --- a/apps/web/src/pages/Privacy.tsx +++ b/apps/web/src/pages/Privacy.tsx @@ -28,9 +28,10 @@ export default function Privacy() { The links you push, their page titles, and the names, types and contents of the files you push (together, “Push Content”) are encrypted on your device before they leave it, with a key made from your password. They can be decrypted only on the devices you sign in to. We, and Google, which runs the servers the App uses, hold Push - Content only in that encrypted form and cannot read it. Your recovery key is never sent to us or to Google, and - your password only in the two cases described below. The Security page explains how - this works and what it does not cover. + Content only in that encrypted form and cannot read it, apart from pushes made before end-to-end encryption that + are still stored as they were sent (see Data Retention below). Your recovery key is never sent to us or to + Google, and your password only in the two cases described below. The Security page + explains how this works and what it does not cover.

PERSONAL INFORMATION WE COLLECT
@@ -134,7 +135,7 @@ export default function Privacy() { Finally, we may also share your Personal Information to comply with applicable laws and regulations, to respond to a subpoena, search warrant or other lawful request for information we receive, or to otherwise protect our rights. We can provide only what we hold: Push Content can be provided only in its encrypted form, which we - cannot decrypt. + cannot decrypt, apart from pushes made before end-to-end encryption that are still stored as they were sent.

DO NOT TRACK
diff --git a/apps/web/src/pages/Terms.tsx b/apps/web/src/pages/Terms.tsx index 9512b5f..993eaac 100644 --- a/apps/web/src/pages/Terms.tsx +++ b/apps/web/src/pages/Terms.tsx @@ -144,10 +144,10 @@ export default function Terms() { made from your password, as described in the Privacy Policy and at https://pushthat.link/security. When you register, you are shown a recovery key, which is the only other way to unlock your Push Content. MakeRocketGoNow LLC never receives your recovery key, receives your password only in the cases described in the Privacy Policy, - and cannot decrypt your Push Content. You are solely responsible for keeping your password and your recovery - key. If you reset your password without your recovery key, or lose both, the Push Content made before then - cannot be recovered by you or by MakeRocketGoNow LLC, and MakeRocketGoNow LLC shall not be held liable for that - loss. + and cannot decrypt your Push Content (apart from pushes made before end-to-end encryption that are still stored + as they were sent). You are solely responsible for keeping your password and your recovery key. If you reset + your password without your recovery key, or lose both, the Push Content made before then cannot be recovered by + you or by MakeRocketGoNow LLC, and MakeRocketGoNow LLC shall not be held liable for that loss.

CONDUCT

@@ -228,9 +228,9 @@ export default function Terms() {

MakeRocketGoNow LLC herein reserves the right to refuse and/or delete any content currently available through - our Services. Because Push Content is end-to-end encrypted, MakeRocketGoNow LLC does not and cannot pre-screen - or review it. In addition, we reserve the right to remove and/or delete any such content that would violate the - TOS or which would otherwise be considered offensive to other visitors, users and/or members. + our Services. Because Push Content is end-to-end encrypted, MakeRocketGoNow LLC does not pre-screen or review + it. In addition, we reserve the right to remove and/or delete any such content that would violate the TOS or + which would otherwise be considered offensive to other visitors, users and/or members.

@@ -253,7 +253,8 @@ export default function Terms() {

Push Content can be accessed, preserved and/or disclosed only in its encrypted form, which MakeRocketGoNow LLC - cannot decrypt. + cannot decrypt, apart from pushes made before end-to-end encryption that are still stored as they were sent, as + described in the Privacy Policy.

From 206c17a8f0f44cc57b538e25c772d3f1fbdc9a09 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Tue, 27 Oct 2026 14:33:52 -0500 Subject: [PATCH 07/32] Name Google Analytics for Firebase in the privacy policy The Android and iOS apps link Firebase Analytics and never turn its collection off, so they send its automatic usage events, with an identifier for the installation, to Google. The policy's sharing section described Google only as the App's storage and notification service. Axiom was just removed for being a third party the policy did not name, and this left the same gap open. The sharing section now names it and says no Push Content is in those events: neither app logs an event of its own. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/pages/Privacy.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/pages/Privacy.tsx b/apps/web/src/pages/Privacy.tsx index 04a16d1..ba89033 100644 --- a/apps/web/src/pages/Privacy.tsx +++ b/apps/web/src/pages/Privacy.tsx @@ -128,7 +128,9 @@ export default function Privacy() { {'https://stripe.com/privacy'}. The App itself runs on Google’s Firebase, which stores your Account and Push Information, including your Push Content in its encrypted form. Notifications are delivered through Google’s and Apple’s notification services and carry only an identifier for - the push, its kind and the device that sent it, never its Push Content. + the push, its kind and the device that sent it, never its Push Content. The Android and iOS apps also send + Google Analytics for Firebase the usage events it collects on its own, such as when the App is opened and on + what kind of device, with an identifier for the installation; they never include Push Content.

From 3f5019b43084d378209ee747330355457cd72ed7 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Tue, 27 Oct 2026 14:34:06 -0500 Subject: [PATCH 08/32] Say in the terms that a file keeps the retention of the plan it was pushed on The terms said a file is kept for "the file retention period of the plan the account is then on", which reads as the plan at the time of deletion. Retention is fixed when the file is pushed and nothing moves it afterwards (docs/billing.md), which is what the privacy policy now says, so the two documents disagreed. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/pages/Terms.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/pages/Terms.tsx b/apps/web/src/pages/Terms.tsx index 993eaac..e5e2ea2 100644 --- a/apps/web/src/pages/Terms.tsx +++ b/apps/web/src/pages/Terms.tsx @@ -87,7 +87,8 @@ export default function Terms() { Pro is billed yearly and renews automatically each year, at the price then current, until you cancel it. You can cancel at any time, on the website, from Plan, then Manage billing. Cancelling stops the next renewal; the account keeps Pro until the end of the year already paid for, and then returns to the free plan. Nothing you - have pushed is deleted because a plan ends, beyond the file retention period of the plan the account is then on. + have pushed is deleted because a plan ends: each file keeps the retention period of the plan the account was on + when it was pushed.

REFUNDS

From 3763858e3a7ff212b93a28c2bc6cdc9f958ec4db Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Tue, 27 Oct 2026 14:34:08 -0500 Subject: [PATCH 09/32] Say the password unlocks the key pushes are encrypted with, not that it makes it Both legal pages said Push Content is encrypted "with a key made from your password". Pushes are encrypted with a random account key; the key made from the password only wraps it, and the recovery key wraps it a second time (docs/e2ee.md, Keys). The old wording implied that a password change re-encrypts every push and that the recovery key is a different encryption key. They now say the key is one only the password or the recovery key can unlock, as the Security page does. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/pages/Privacy.tsx | 12 ++++++------ apps/web/src/pages/Terms.tsx | 15 ++++++++------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/apps/web/src/pages/Privacy.tsx b/apps/web/src/pages/Privacy.tsx index ba89033..219170b 100644 --- a/apps/web/src/pages/Privacy.tsx +++ b/apps/web/src/pages/Privacy.tsx @@ -26,12 +26,12 @@ export default function Privacy() {

The links you push, their page titles, and the names, types and contents of the files you push (together, “Push - Content”) are encrypted on your device before they leave it, with a key made from your password. They can be - decrypted only on the devices you sign in to. We, and Google, which runs the servers the App uses, hold Push - Content only in that encrypted form and cannot read it, apart from pushes made before end-to-end encryption that - are still stored as they were sent (see Data Retention below). Your recovery key is never sent to us or to - Google, and your password only in the two cases described below. The Security page - explains how this works and what it does not cover. + Content”) are encrypted on your device before they leave it, with a key that only your password, or your + recovery key, can unlock. They can be decrypted only on the devices you sign in to. We, and Google, which runs + the servers the App uses, hold Push Content only in that encrypted form and cannot read it, apart from pushes + made before end-to-end encryption that are still stored as they were sent (see Data Retention below). Your + recovery key is never sent to us or to Google, and your password only in the two cases described below. The{' '} + Security page explains how this works and what it does not cover.

PERSONAL INFORMATION WE COLLECT
diff --git a/apps/web/src/pages/Terms.tsx b/apps/web/src/pages/Terms.tsx index e5e2ea2..5d9b2a6 100644 --- a/apps/web/src/pages/Terms.tsx +++ b/apps/web/src/pages/Terms.tsx @@ -142,13 +142,14 @@ export default function Terms() {

The links and files you push, and their titles and names (“Push Content”), are end-to-end encrypted with a key - made from your password, as described in the Privacy Policy and at https://pushthat.link/security. When you - register, you are shown a recovery key, which is the only other way to unlock your Push Content. MakeRocketGoNow - LLC never receives your recovery key, receives your password only in the cases described in the Privacy Policy, - and cannot decrypt your Push Content (apart from pushes made before end-to-end encryption that are still stored - as they were sent). You are solely responsible for keeping your password and your recovery key. If you reset - your password without your recovery key, or lose both, the Push Content made before then cannot be recovered by - you or by MakeRocketGoNow LLC, and MakeRocketGoNow LLC shall not be held liable for that loss. + that only your password or your recovery key can unlock, as described in the Privacy Policy and at + https://pushthat.link/security. When you register, you are shown a recovery key, which is the only other way to + unlock your Push Content. MakeRocketGoNow LLC never receives your recovery key, receives your password only in + the cases described in the Privacy Policy, and cannot decrypt your Push Content (apart from pushes made before + end-to-end encryption that are still stored as they were sent). You are solely responsible for keeping your + password and your recovery key. If you reset your password without your recovery key, or lose both, the Push + Content made before then cannot be recovered by you or by MakeRocketGoNow LLC, and MakeRocketGoNow LLC shall not + be held liable for that loss.

CONDUCT

From f2b01aa837524f6795f2c9f1e9675f935a596564 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Tue, 27 Oct 2026 14:34:09 -0500 Subject: [PATCH 10/32] Say in the terms when an account made before encryption got its recovery key The terms said the recovery key is shown "when you register". An account made before encryption is shown it at its first sign-in with an updated app, when its keys are made (`openKeys` in `signIn.ts`), so for every existing account the sentence described something that never happened. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/pages/Terms.tsx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/apps/web/src/pages/Terms.tsx b/apps/web/src/pages/Terms.tsx index 5d9b2a6..803c1ca 100644 --- a/apps/web/src/pages/Terms.tsx +++ b/apps/web/src/pages/Terms.tsx @@ -143,13 +143,14 @@ export default function Terms() {

The links and files you push, and their titles and names (“Push Content”), are end-to-end encrypted with a key that only your password or your recovery key can unlock, as described in the Privacy Policy and at - https://pushthat.link/security. When you register, you are shown a recovery key, which is the only other way to - unlock your Push Content. MakeRocketGoNow LLC never receives your recovery key, receives your password only in - the cases described in the Privacy Policy, and cannot decrypt your Push Content (apart from pushes made before - end-to-end encryption that are still stored as they were sent). You are solely responsible for keeping your - password and your recovery key. If you reset your password without your recovery key, or lose both, the Push - Content made before then cannot be recovered by you or by MakeRocketGoNow LLC, and MakeRocketGoNow LLC shall not - be held liable for that loss. + https://pushthat.link/security. You are shown a recovery key when your account's encryption is set up (when you + register, or at your first sign-in with an updated App if your account was made before end-to-end encryption), + which is the only other way to unlock your Push Content. MakeRocketGoNow LLC never receives your recovery key, + receives your password only in the cases described in the Privacy Policy, and cannot decrypt your Push Content + (apart from pushes made before end-to-end encryption that are still stored as they were sent). You are solely + responsible for keeping your password and your recovery key. If you reset your password without your recovery + key, or lose both, the Push Content made before then cannot be recovered by you or by MakeRocketGoNow LLC, and + MakeRocketGoNow LLC shall not be held liable for that loss.

CONDUCT

From 7606e6ffa4fe094fea5f54504304cf376030a555 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Tue, 27 Oct 2026 14:34:24 -0500 Subject: [PATCH 11/32] Say a link push is kept until the account's deletion is asked for The privacy policy said a link push is kept until it is deleted "or your account is closed", which suggests a way to close an account that deletes its pushes. There is none: no app offers it and no function deletes an account's pushes, so closing one is a request to us, as the terms' termination section and the subscription sentence above already say. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/pages/Privacy.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/pages/Privacy.tsx b/apps/web/src/pages/Privacy.tsx index 219170b..0b4031b 100644 --- a/apps/web/src/pages/Privacy.tsx +++ b/apps/web/src/pages/Privacy.tsx @@ -168,9 +168,9 @@ export default function Privacy() {

- A link push is kept until you delete it or your account is closed. A file push, with its record, is deleted when - the retention period of the plan your account was on when you pushed it ends ({LIMITS.free.keepDays} days on the - free plan, {LIMITS.pro.keepDays} days on Pro). + A link push is kept until you delete it, or until you ask us to delete your account. A file push, with its + record, is deleted when the retention period of the plan your account was on when you pushed it ends ( + {LIMITS.free.keepDays} days on the free plan, {LIMITS.pro.keepDays} days on Pro).

From f81c7997fb7e10402fafeef8eb1e97f57a30569b Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Tue, 27 Oct 2026 14:34:25 -0500 Subject: [PATCH 12/32] List the stored key wraps in the privacy policy's account information The list of what is kept to deliver pushes left out `e2ee.wraps` on the user document: the account key encrypted under the password's key and under the recovery key. It is what lets someone with the database try guesses at a weak password offline, so a list that reads as complete should name it. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/pages/Privacy.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/pages/Privacy.tsx b/apps/web/src/pages/Privacy.tsx index 0b4031b..51e8364 100644 --- a/apps/web/src/pages/Privacy.tsx +++ b/apps/web/src/pages/Privacy.tsx @@ -89,7 +89,8 @@ export default function Privacy() { it, when, whether it is a link or a file, which of your devices sent it, the encrypted Push Content, and scrambled search tokens made with your key, which let a search find a push without revealing the words searched for. The size of a file you push can be seen roughly from its encrypted form. We also keep how many pushes your - account has made each month. We refer to this as “Account and Push Information.” It does not include Push + account has made each month, and your account's encryption key, itself encrypted so that only your password or + your recovery key can unlock it. We refer to this as “Account and Push Information.” It does not include Push Content in any readable form.

From 9939f63354ee9249b9ce6abd008f819bf2eb51d7 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Tue, 27 Oct 2026 14:34:27 -0500 Subject: [PATCH 13/32] Name the key version among what a notification carries `pushMessageOf` sends `keyVersion` with the push's id, type and sending device, and the policy's sentence read as a complete list of the three. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/pages/Privacy.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/web/src/pages/Privacy.tsx b/apps/web/src/pages/Privacy.tsx index 51e8364..341304c 100644 --- a/apps/web/src/pages/Privacy.tsx +++ b/apps/web/src/pages/Privacy.tsx @@ -129,9 +129,10 @@ export default function Privacy() { {'https://stripe.com/privacy'}. The App itself runs on Google’s Firebase, which stores your Account and Push Information, including your Push Content in its encrypted form. Notifications are delivered through Google’s and Apple’s notification services and carry only an identifier for - the push, its kind and the device that sent it, never its Push Content. The Android and iOS apps also send - Google Analytics for Firebase the usage events it collects on its own, such as when the App is opened and on - what kind of device, with an identifier for the installation; they never include Push Content. + the push, its kind, the device that sent it and which version of your key it was encrypted with, never its Push + Content. The Android and iOS apps also send Google Analytics for Firebase the usage events it collects on its + own, such as when the App is opened and on what kind of device, with an identifier for the installation; they + never include Push Content.

From 82a5367edf3b481c286c07d4e897c770409e7e95 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Tue, 27 Oct 2026 14:34:34 -0500 Subject: [PATCH 14/32] Give "Push Content" the privacy policy's definition in the terms The terms defined "Push Content" as the links and files pushed and their titles and names, while the privacy policy also counts a file's type. A defined term used across both documents should mean one thing, so the terms now point at the policy's definition and repeat it. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/pages/Terms.tsx | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/apps/web/src/pages/Terms.tsx b/apps/web/src/pages/Terms.tsx index 803c1ca..679349f 100644 --- a/apps/web/src/pages/Terms.tsx +++ b/apps/web/src/pages/Terms.tsx @@ -141,16 +141,17 @@ export default function Terms() {

END-TO-END ENCRYPTION AND YOUR RECOVERY KEY

- The links and files you push, and their titles and names (“Push Content”), are end-to-end encrypted with a key - that only your password or your recovery key can unlock, as described in the Privacy Policy and at - https://pushthat.link/security. You are shown a recovery key when your account's encryption is set up (when you - register, or at your first sign-in with an updated App if your account was made before end-to-end encryption), - which is the only other way to unlock your Push Content. MakeRocketGoNow LLC never receives your recovery key, - receives your password only in the cases described in the Privacy Policy, and cannot decrypt your Push Content - (apart from pushes made before end-to-end encryption that are still stored as they were sent). You are solely - responsible for keeping your password and your recovery key. If you reset your password without your recovery - key, or lose both, the Push Content made before then cannot be recovered by you or by MakeRocketGoNow LLC, and - MakeRocketGoNow LLC shall not be held liable for that loss. + Your “Push Content”, as the Privacy Policy defines it (the links you push, their page titles, and the names, + types and contents of the files you push), is end-to-end encrypted with a key that only your password or your + recovery key can unlock, as described in the Privacy Policy and at https://pushthat.link/security. You are shown + a recovery key when your account's encryption is set up (when you register, or at your first sign-in with an + updated App if your account was made before end-to-end encryption), which is the only other way to unlock your + Push Content. MakeRocketGoNow LLC never receives your recovery key, receives your password only in the cases + described in the Privacy Policy, and cannot decrypt your Push Content (apart from pushes made before end-to-end + encryption that are still stored as they were sent). You are solely responsible for keeping your password and + your recovery key. If you reset your password without your recovery key, or lose both, the Push Content made + before then cannot be recovered by you or by MakeRocketGoNow LLC, and MakeRocketGoNow LLC shall not be held + liable for that loss.

CONDUCT

From 291c6c3f92c82e1a6ea2f32f3ba63e55cd2e58be Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Tue, 27 Oct 2026 14:34:44 -0500 Subject: [PATCH 15/32] Make the extension's log helpers synchronous `debug` and `logError` were async so that callers could wait for the Axiom ingest. With that gone they only write to the console, and the `async` and the `await` at each call suggested I/O that no longer happens. Co-Authored-By: Claude Opus 5.5 --- apps/web/chrome/background.ts | 36 +++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/apps/web/chrome/background.ts b/apps/web/chrome/background.ts index 65ec1f3..241f6ed 100644 --- a/apps/web/chrome/background.ts +++ b/apps/web/chrome/background.ts @@ -38,7 +38,7 @@ function isAuthStateChangedInMessage(message: any): message is SignInMessage { } async function updateUserFcmToken(userId: string, token: string) { - await debug('Updating user FCM token', { userId }); + debug('Updating user FCM token', { userId }); const db = getFirestore(app); const userRef = doc(db, 'users', userId); @@ -52,13 +52,13 @@ async function updateUserFcmToken(userId: string, token: string) { // Only add the token if it doesn't already exist if (!existingTokens.includes(token)) { - await debug('Adding token to user', { userId }); + debug('Adding token to user', { userId }); await updateDoc(userRef, { fcmTokens: arrayUnion(token), }); } } else { - await debug('Creating new user document', { userId }); + debug('Creating new user document', { userId }); // Create new user document if it doesn't exist try { await setDoc(userRef, { @@ -72,7 +72,7 @@ async function updateUserFcmToken(userId: string, token: string) { } } } catch (error) { - await logError('Error updating user FCM token:', { error }); + logError('Error updating user FCM token:', { error }); throw error; } } @@ -95,7 +95,7 @@ const requestFcmToken = async (userId: string): Promise => { return token; } catch (error) { - await logError('Error getting FCM token:', { error }); + logError('Error getting FCM token:', { error }); } return undefined; @@ -103,13 +103,13 @@ const requestFcmToken = async (userId: string): Promise => { async function start() { const { userId } = await chrome.storage.local.get('userId'); - await debug('START', { userId }); + debug('START', { userId }); if (userId) { - await debug('Requesting FCM token'); + debug('Requesting FCM token'); const token = await requestFcmToken(userId); - await debug(`FCM token result: ${!!token}`); + debug(`FCM token result: ${!!token}`); } else { - await debug('No user id found, PushThat will not open new pushes.'); + debug('No user id found, PushThat will not open new pushes.'); } } @@ -148,14 +148,14 @@ async function readReceivedPush(pushId: string): Promise { */ async function handleEncryptedPush(data: { pushId?: string; deviceId?: string }) { if (data.deviceId && data.deviceId === (await deviceId())) { - await debug('Skipping a push this browser sent'); + debug('Skipping a push this browser sent'); return; } let push: Push | undefined; try { push = data.pushId ? await readReceivedPush(data.pushId) : undefined; } catch (error) { - await logError('Could not read a received push:', { error: String(error) }); + logError('Could not read a received push:', { error: String(error) }); } if (push?.url && push.type === 'url') { @@ -201,7 +201,7 @@ self.addEventListener('notificationclick', async (event) => { chrome.runtime.onMessage.addListener(async (message: any, sender, sendResponse) => { if (isAuthStateChangedInMessage(message)) { - await debug('ON_AUTH_STATE_CHANGED', { userId: message.user?.uid }); + debug('ON_AUTH_STATE_CHANGED', { userId: message.user?.uid }); if (message.user && message.user.uid) { // Save userId to storage @@ -221,11 +221,11 @@ chrome.runtime.onMessage.addListener(async (message: any, sender, sendResponse) self.addEventListener('activate', async (event) => { try { // Run code when service worker becomes active - await debug('Service worker activating'); + debug('Service worker activating'); start(); } catch (error) { - await logError('Activation error:', { error }); + logError('Activation error:', { error }); } }); @@ -243,20 +243,20 @@ chrome.runtime.onInstalled.addListener(async (details) => { // Add wake-up listeners // chrome.idle.onStateChanged.addListener(async (state) => { -// await debug('System state changed:', { state }); +// debug('System state changed:', { state }); // }); chrome.runtime.onStartup.addListener(async () => { - await debug('Extension starting up'); + debug('Extension starting up'); await start(); }); // Logs stay in the extension's own console. They went to Axiom as well until it was removed: // that sent user and push ids to a third party the privacy policy did not name. -async function debug(message: string, optionalParams?: any) { +function debug(message: string, optionalParams?: any) { console.debug(message, optionalParams); } -async function logError(message: string, optionalParams?: any) { +function logError(message: string, optionalParams?: any) { console.error(message, optionalParams); } From 32cbd70c9b99a82af9cb1f29401274bbeaf78dbe Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Tue, 27 Oct 2026 14:37:07 -0500 Subject: [PATCH 16/32] Say on the Security page when an account made before encryption got its recovery key The page said the recovery key is shown "when you create your account". An account made before encryption is shown it the first time an updated app signs in, when its keys are made. The terms were corrected for the same thing and now point readers here. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/pages/Security.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/web/src/pages/Security.tsx b/apps/web/src/pages/Security.tsx index d8cddd9..5ab364f 100644 --- a/apps/web/src/pages/Security.tsx +++ b/apps/web/src/pages/Security.tsx @@ -97,9 +97,10 @@ export function Security() {
THE RECOVERY KEY

- When you create your account, PushThat shows a recovery key once: 32 letters and numbers in groups of four. It - is a second way to unlock your pushes, and the only one besides your password. Keep it somewhere safe and apart - from your devices, such as a password manager or on paper. + When your account’s encryption is set up (when you create your account, or the first time an updated app + signs in to an account made before encryption), PushThat shows a recovery key once: 32 letters and numbers in + groups of four. It is a second way to unlock your pushes, and the only one besides your password. Keep it + somewhere safe and apart from your devices, such as a password manager or on paper.

From 301a6476bc8d5447da9ab3311be272366832ea31 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Tue, 27 Oct 2026 14:37:09 -0500 Subject: [PATCH 17/32] Say the sign-in fallback tries the password itself, not as typed The privacy policy and the Security page said a refused sign-in retries with the password "as typed". Android's `signInToFirebase` also tries it trimmed, since older Android builds trimmed it at registration, so "as typed" was not always the password that was sent. "The password itself" is true on every client. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/pages/Privacy.tsx | 6 +++--- apps/web/src/pages/Security.tsx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/src/pages/Privacy.tsx b/apps/web/src/pages/Privacy.tsx index 341304c..003903c 100644 --- a/apps/web/src/pages/Privacy.tsx +++ b/apps/web/src/pages/Privacy.tsx @@ -78,9 +78,9 @@ export default function Privacy() { password itself. Your password can be worked out from that key only by guessing it, which is made deliberately slow. Your password itself reaches Google’s sign-in service, Firebase Authentication, in two cases: when you reset it, because the reset is made on Google’s page, and when a sign-in with the derived key is refused (for - example, because the password was mistyped), because the App then tries the password as typed in case your - account was made before end-to-end encryption. After a reset, the App replaces the stored password with the - derived key the next time you sign in. + example, because the password was mistyped), because the App then tries the password itself in case your account + was made before end-to-end encryption. After a reset, the App replaces the stored password with the derived key + the next time you sign in.

diff --git a/apps/web/src/pages/Security.tsx b/apps/web/src/pages/Security.tsx index 5ab364f..867bfe5 100644 --- a/apps/web/src/pages/Security.tsx +++ b/apps/web/src/pages/Security.tsx @@ -39,7 +39,7 @@ export function Security() { pushes. It also means we cannot reset your password for you and keep your pushes: only the recovery key, below, can do that. Your password reaches Google’s sign-in service in two cases only: when you reset it, since the reset is made on Google’s own page, and when a sign-in with the first key is refused (a mistyped - password, say), since the app then tries the password as typed in case the account was made before encryption. + password, say), since the app then tries the password itself in case the account was made before encryption. After a reset, your next sign-in replaces it with the key.

From 26bf42f8e13a9840022921cbcf16b81b88d7226f Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Tue, 27 Oct 2026 14:37:10 -0500 Subject: [PATCH 18/32] Say which key replaces the password Google holds after a reset The Security page ended its reset sentence with "replaces it with the key", in a paragraph that has just introduced two keys, so neither "it" nor "the key" was clear. It now names the password Google holds and the first key, the one that signs in. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/pages/Security.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/pages/Security.tsx b/apps/web/src/pages/Security.tsx index 867bfe5..1225f2c 100644 --- a/apps/web/src/pages/Security.tsx +++ b/apps/web/src/pages/Security.tsx @@ -40,7 +40,7 @@ export function Security() { can do that. Your password reaches Google’s sign-in service in two cases only: when you reset it, since the reset is made on Google’s own page, and when a sign-in with the first key is refused (a mistyped password, say), since the app then tries the password itself in case the account was made before encryption. - After a reset, your next sign-in replaces it with the key. + After a reset, your next sign-in replaces the password Google holds with the first key.

From 764495588b16b9847764094862bb83b03a6556ab Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Tue, 27 Oct 2026 14:59:51 -0500 Subject: [PATCH 19/32] Drop "We never see it" from the recovery-key screen The screen said of the password "We never see it and cannot reset it for you". It is true that we never read it, but the raw password does reach Firebase Authentication on a reset and on the sign-in fallback, as the privacy policy now says, and the screen is not the place to argue that difference. What the user needs from it is that the password is the key and cannot be reset without losing the pushes. The sentence is now "Your password is the key to your pushes, and we cannot reset it for you." in docs/e2ee.md and in the web, Android, iOS and macOS apps, which copy it word for word. Co-Authored-By: Claude Opus 5.5 --- apps/android/app/src/main/res/values/strings.xml | 2 +- apps/ios/PushThat/Views/RecoveryKeyView.swift | 2 +- apps/macos/PushThat/Views/RecoveryKeyView.swift | 2 +- apps/web/src/components/KeyGate.tsx | 2 +- docs/e2ee.md | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index 3ce1c34..8f52efb 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -67,7 +67,7 @@ Use at least %1$d characters. Your password is the key to your pushes, so it has to be hard to guess. Your recovery key - Your password is the key to your pushes. We never see it and cannot reset it for you. Save your recovery key. + Your password is the key to your pushes, and we cannot reset it for you. Save your recovery key. It is shown only once. If you forget your password, it is the only way to keep your pushes. Copy Copied diff --git a/apps/ios/PushThat/Views/RecoveryKeyView.swift b/apps/ios/PushThat/Views/RecoveryKeyView.swift index 8950a0d..ee87796 100644 --- a/apps/ios/PushThat/Views/RecoveryKeyView.swift +++ b/apps/ios/PushThat/Views/RecoveryKeyView.swift @@ -20,7 +20,7 @@ struct RecoveryKeyView: View { .font(.title) .fontWeight(.bold) - Text("Your password is the key to your pushes. We never see it and cannot reset it for you. Save your recovery key.") + Text("Your password is the key to your pushes, and we cannot reset it for you. Save your recovery key.") .multilineTextAlignment(.center) Text("It is shown only once. If you forget your password, it is the only way to keep your pushes.") diff --git a/apps/macos/PushThat/Views/RecoveryKeyView.swift b/apps/macos/PushThat/Views/RecoveryKeyView.swift index 8950a0d..ee87796 100644 --- a/apps/macos/PushThat/Views/RecoveryKeyView.swift +++ b/apps/macos/PushThat/Views/RecoveryKeyView.swift @@ -20,7 +20,7 @@ struct RecoveryKeyView: View { .font(.title) .fontWeight(.bold) - Text("Your password is the key to your pushes. We never see it and cannot reset it for you. Save your recovery key.") + Text("Your password is the key to your pushes, and we cannot reset it for you. Save your recovery key.") .multilineTextAlignment(.center) Text("It is shown only once. If you forget your password, it is the only way to keep your pushes.") diff --git a/apps/web/src/components/KeyGate.tsx b/apps/web/src/components/KeyGate.tsx index 99b633d..0afeed2 100644 --- a/apps/web/src/components/KeyGate.tsx +++ b/apps/web/src/components/KeyGate.tsx @@ -197,7 +197,7 @@ function NewRecoveryKey({ recoveryKey }: { recoveryKey: string }) { {/* docs/e2ee.md, Saying so: the sign-up's words, word for word. */}

- Your password is the key to your pushes. We never see it and cannot reset it for you. Save your recovery key. + Your password is the key to your pushes, and we cannot reset it for you. Save your recovery key.

It is shown only once. If you forget your password, it is the only way to keep your pushes. diff --git a/docs/e2ee.md b/docs/e2ee.md index 4108a0d..372418d 100644 --- a/docs/e2ee.md +++ b/docs/e2ee.md @@ -224,7 +224,7 @@ Existing accounts have the raw password as their Firebase password and plaintext The reason for a password-derived key is that the user can see it. The surfaces: -- **Sign-up:** "Your password is the key to your pushes. We never see it and cannot reset it for you. Save your recovery key." The recovery key is shown there and then. +- **Sign-up:** "Your password is the key to your pushes, and we cannot reset it for you. Save your recovery key." The recovery key is shown there and then. - **Settings and the Plan page:** an "Encrypted" line with the device's `deviceId` and a link to the security page. - **Password reset:** the recovery key prompt and, without it, the sentence that the old pushes are gone, before anything is deleted. - **The site:** a `/security` page with the gist of this document: what is encrypted, what is not, what a lost password costs. From 78098e7bd874e5b63b801757144b259cee170882 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Tue, 27 Oct 2026 15:00:11 -0500 Subject: [PATCH 20/32] Say at each sign-in fallback that password resets depend on it The comments on the raw-password fallback said it was for accounts made before encryption, which reads as if it can be removed once they have all moved. It cannot: a reset runs on Firebase's hosted form, which sets the raw new password as the Firebase password, so the first sign-in after every reset gets in through the fallback and then moves the account back to `authKey`. Removing it was suggested on exactly that reading, and would have left every user who resets unable to sign in. The web, Android, iOS and macOS sign-in now say so beside the fallback, and when it can go: once a reset sets `authKey` itself, which needs the reset email's action URL unlocked. Co-Authored-By: Claude Opus 5.5 --- .../java/com/makerocketgonow/pushthat/e2ee/AccountKeys.kt | 7 +++++++ apps/ios/PushThat/ViewModels/AuthViewModel.swift | 6 ++++++ apps/macos/PushThat/ViewModels/AuthViewModel.swift | 6 ++++++ apps/web/src/services/signIn.ts | 6 ++++++ 4 files changed, 25 insertions(+) diff --git a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/e2ee/AccountKeys.kt b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/e2ee/AccountKeys.kt index 71915d6..7a9996c 100644 --- a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/e2ee/AccountKeys.kt +++ b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/e2ee/AccountKeys.kt @@ -102,6 +102,13 @@ class AccountKeys( * registration and at sign-in, so an account made here with a space at either end has * the trimmed password in Firebase. The keys are derived from it untrimmed, as the * spec says and the other apps do, so it signs in the same everywhere afterwards. + * + * The fallback stays after every old account has moved, because password resets + * depend on it. A reset runs on Firebase's hosted form (docs/e2ee.md, "Password + * reset"), which sets the raw new password as the Firebase password, so the first + * sign-in after any reset gets in this way and then moves the account back to + * `authKey`. It can go only once a reset sets `authKey` itself, which needs the reset + * email's action URL unlocked. */ private suspend fun signInToFirebase(email: String, password: String, firebasePassword: String): Boolean { try { diff --git a/apps/ios/PushThat/ViewModels/AuthViewModel.swift b/apps/ios/PushThat/ViewModels/AuthViewModel.swift index 641648c..50b7d80 100644 --- a/apps/ios/PushThat/ViewModels/AuthViewModel.swift +++ b/apps/ios/PushThat/ViewModels/AuthViewModel.swift @@ -130,6 +130,12 @@ class AuthViewModel: ObservableObject { /// Signs in with `base64url(authKey)`, falling back to the raw password for an account /// that has not moved yet, then finds the account key (docs/e2ee.md, "Migration"). + /// + /// The fallback stays after every old account has moved, because password resets depend + /// on it. A reset runs on Firebase's hosted form (docs/e2ee.md, "Password reset"), which + /// sets the raw new password as the Firebase password, so the first sign-in after any + /// reset gets in this way and then moves the account back to `authKey`. It can go only + /// once a reset sets `authKey` itself, which needs the reset email's action URL unlocked. private func signInWithKeys(email: String, password: String) async throws { let keys = try await Self.derive(email: email, password: password) diff --git a/apps/macos/PushThat/ViewModels/AuthViewModel.swift b/apps/macos/PushThat/ViewModels/AuthViewModel.swift index ec38192..9ecf3cc 100644 --- a/apps/macos/PushThat/ViewModels/AuthViewModel.swift +++ b/apps/macos/PushThat/ViewModels/AuthViewModel.swift @@ -129,6 +129,12 @@ class AuthViewModel: ObservableObject { /// Signs in with `base64url(authKey)`, falling back to the raw password for an account /// that has not moved yet, then finds the account key (docs/e2ee.md, "Migration"). + /// + /// The fallback stays after every old account has moved, because password resets depend + /// on it. A reset runs on Firebase's hosted form (docs/e2ee.md, "Password reset"), which + /// sets the raw new password as the Firebase password, so the first sign-in after any + /// reset gets in this way and then moves the account back to `authKey`. It can go only + /// once a reset sets `authKey` itself, which needs the reset email's action URL unlocked. private func signInWithKeys(email: String, password: String) async throws { let keys = try await Self.derive(email: email, password: password) diff --git a/apps/web/src/services/signIn.ts b/apps/web/src/services/signIn.ts index d170ca2..3887edc 100644 --- a/apps/web/src/services/signIn.ts +++ b/apps/web/src/services/signIn.ts @@ -63,6 +63,12 @@ const isWrongPassword = (error: unknown) => error instanceof FirebaseError && WR * is the sign-in that moves it over. Only a wrong password tries again; a network error * or Firebase's too-many-attempts stop here, since the second try would fail the same way * and count as one more attempt. + * + * The fallback stays after every old account has moved, because password resets depend on + * it. A reset runs on Firebase's hosted form (docs/e2ee.md, "Password reset"), which sets + * the raw new password as the Firebase password, so the first sign-in after any reset gets + * in this way and then moves the account back to `authKey`. It can go only once a reset + * sets `authKey` itself, which needs the reset email's action URL unlocked. */ async function signInEitherWay(email: string, password: string, keys: DerivedKeys) { try { From b1a92f4b122de9702f0f8ef6a7d37e7d686b7251 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Tue, 27 Oct 2026 15:17:04 -0500 Subject: [PATCH 21/32] Record in the design doc why the raw-password fallback is permanent docs/e2ee.md said the clients sign in with `authKey` and "never the password itself", and described the fallback only under Migration, as a path for accounts made before encryption. That reading is how removing it was suggested. Every reset runs on Firebase's hosted form, which sets the raw new password, so the first sign-in after any reset depends on the fallback. The comments added at each client's fallback cite the doc's Password reset section for this, and it did not say it. The Keys section now names the fallback. The Password reset section says the next sign-in gets in through it, that `updatePassword(authKey)` follows once the recovery step is done, that the fallback can go only when `/reset` sets `authKey` itself, and that this is why the raw password reaches Firebase Authentication on a reset and on a refused sign-in. Co-Authored-By: Claude Opus 5.5 --- docs/e2ee.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/e2ee.md b/docs/e2ee.md index 372418d..0e1b4b5 100644 --- a/docs/e2ee.md +++ b/docs/e2ee.md @@ -34,7 +34,7 @@ The last three lines are HKDF-SHA256 with an empty salt written out, since every Byte for byte: every string in a derivation is UTF-8, the labels are ASCII, and `|| 0x01` appends one byte. The email is NFC-normalized first, like the password, since two keyboards can spell one accented letter as different code points; then `trim` removes space, tab, CR and LF from both ends and `lowercase` maps A to Z onto a to z and changes nothing else. Both are deliberately ASCII-only so that no platform's locale or Unicode tables can enter the salt: an address with a capital letter outside ASCII keeps it. Every AES-GCM tag in this document is 16 bytes, every `base64` field (`n`, `ct`, `fk`, `np`) is the standard alphabet with padding, and every `base64url` string is unpadded. PBKDF2 was chosen over Argon2id because WebCrypto, Android and CommonCrypto all have it natively and the Chrome extension's service worker has no room for a WASM library; the `kdf` field on the user document names the construction so it can be raised later. 600,000 iterations is OWASP's 2023 figure and costs about a second on a phone, paid once at sign-in. -`authKey` is what Firebase Auth sees. The clients sign in and register with `base64url(authKey)` (43 characters, no padding) as the Firebase password, never the password itself. Firebase cannot derive `masterKey` from what it holds, and a stolen Firebase hash is behind 600,000 iterations per guess before Firebase's own scrypt. +`authKey` is what Firebase Auth sees. The clients sign in and register with `base64url(authKey)` (43 characters, no padding) as the Firebase password, and fall back to the password itself only on a wrong-password error (Migration, below), which a reset on Firebase's hosted form also relies on (Password reset). Firebase cannot derive `masterKey` from what it holds, and a stolen Firebase hash is behind 600,000 iterations per guess before Firebase's own scrypt. `masterKey` never leaves the device and wraps the account key: @@ -204,6 +204,8 @@ Firebase's hosted reset page sets the Firebase password to whatever the user typ So a reset runs on Firebase's hosted form, and the clients send the email with the web sign-in page as the continue URL, which gives that form a Continue button once the password is set. The sign-in that follows, on any client, derives the new keys, finds that the new `masterKey` opens none of the stored `password` wraps, and asks for the recovery key: with it, the account key is rewrapped and nothing is lost; without it, the client says the earlier pushes cannot be read and starts new keys, as below. The `/reset` page stays complete for the day the template is unlocked. What the detour costs is the order of one warning, which the recovery-key screen at sign-up has already given, and a client from before phase 3 is not affected, since the hosted form sets the raw password it signs in with. +That sign-in gets in through the raw-password fallback (Migration), since the hosted form set the raw new password, and `updatePassword(authKey)` follows once the recovery key is entered or the user starts over, as for a legacy account. So the fallback stays after every old account has moved: it can go only once the template's action URL is unlocked and `/reset` sets `authKey` itself. It is also why the raw password reaches Firebase Authentication on a reset and on any sign-in whose `authKey` is refused, such as a mistyped password, which the privacy policy and `/security` say. + Without the recovery key, the app says plainly that the pushes made before this reset cannot be read, generates a fresh account key with `keyVersion + 1`, and offers to delete the old pushes in one step; a user who declines sees them in the list as locked rows that can be deleted but never opened. Files expire on their own within 30 days either way. The email is the salt, so an email change would be a rewrap like a password change. The apps have no email change today and this does not add one. From 51bae1c729c88787fc05d17a35235db978e3b91e Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Tue, 27 Oct 2026 15:17:31 -0500 Subject: [PATCH 22/32] Stop promising a one-step deletion of locked pushes after a reset The privacy policy and the Security page said that after a reset without the recovery key, the app offers to delete the old pushes in one step. Only the `/reset` page makes that offer, and no reset reaches it while the email template's action URL is locked. Every reset goes through Firebase's hosted form and then the sign-in recovery prompt, whose "Start over" on web, Android, iOS and macOS makes new keys with no deletion offer. Both pages now say the old pushes stay as locked rows that can be deleted. The design doc's Password reset section says which page makes the offer and that it is not reached today. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/pages/Privacy.tsx | 4 ++-- apps/web/src/pages/Security.tsx | 6 +++--- docs/e2ee.md | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/web/src/pages/Privacy.tsx b/apps/web/src/pages/Privacy.tsx index 003903c..aa673b2 100644 --- a/apps/web/src/pages/Privacy.tsx +++ b/apps/web/src/pages/Privacy.tsx @@ -177,8 +177,8 @@ export default function Privacy() {

If you reset your password without your recovery key, the pushes made before the reset can no longer be - decrypted by you or by us. They stay in your account as locked entries until you delete them, which the App - offers to do in one step, or, for files, until their retention period ends. + decrypted by you or by us. They stay in your account as locked entries until you delete them, or, for files, + until their retention period ends.

diff --git a/apps/web/src/pages/Security.tsx b/apps/web/src/pages/Security.tsx index 1225f2c..551df1f 100644 --- a/apps/web/src/pages/Security.tsx +++ b/apps/web/src/pages/Security.tsx @@ -117,9 +117,9 @@ export function Security() {

Without it, the pushes you made before the reset cannot be read again, by you or by us: they were encrypted with - a key that only your old password or the recovery key could unlock. Your account gets new keys, and you can - delete the old pushes in one step or keep them in your list as locked rows. Files are deleted within{' '} - {LIMITS.pro.keepDays} days either way. + a key that only your old password or the recovery key could unlock. Your account gets new keys, and the old + pushes stay in your list as locked rows that you can delete. Files are deleted within {LIMITS.pro.keepDays} days + either way.

OLD VERSIONS OF THE APPS
diff --git a/docs/e2ee.md b/docs/e2ee.md index 0e1b4b5..f68dbc1 100644 --- a/docs/e2ee.md +++ b/docs/e2ee.md @@ -206,7 +206,7 @@ So a reset runs on Firebase's hosted form, and the clients send the email with t That sign-in gets in through the raw-password fallback (Migration), since the hosted form set the raw new password, and `updatePassword(authKey)` follows once the recovery key is entered or the user starts over, as for a legacy account. So the fallback stays after every old account has moved: it can go only once the template's action URL is unlocked and `/reset` sets `authKey` itself. It is also why the raw password reaches Firebase Authentication on a reset and on any sign-in whose `authKey` is refused, such as a mistyped password, which the privacy policy and `/security` say. -Without the recovery key, the app says plainly that the pushes made before this reset cannot be read, generates a fresh account key with `keyVersion + 1`, and offers to delete the old pushes in one step; a user who declines sees them in the list as locked rows that can be deleted but never opened. Files expire on their own within 30 days either way. +Without the recovery key, the app says plainly that the pushes made before this reset cannot be read, generates a fresh account key with `keyVersion + 1`, and offers to delete the old pushes in one step; a user who declines sees them in the list as locked rows that can be deleted but never opened. Files expire on their own within 30 days either way. Only `/reset` makes that offer, and it is not reached today; the sign-in prompt every reset reaches starts over without it, so the old pushes stay as locked rows until the user deletes them. The email is the salt, so an email change would be a rewrap like a password change. The apps have no email change today and this does not add one. From 41bf369650b9b8f1ae779530a6957245ca4cf094 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Tue, 27 Oct 2026 15:17:48 -0500 Subject: [PATCH 23/32] Name apps from before encryption among the ways the password reaches Google The privacy policy and the Security page listed two cases in which the raw password reaches Firebase Authentication, and the Security page said "only". A version of an app from before encryption, which the Security page covers under old versions, sends the raw password on every sign-in; Firebase refuses it once the account has moved, but it has still been sent. Both pages now name it. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/pages/Privacy.tsx | 5 +++-- apps/web/src/pages/Security.tsx | 9 +++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/web/src/pages/Privacy.tsx b/apps/web/src/pages/Privacy.tsx index aa673b2..b223036 100644 --- a/apps/web/src/pages/Privacy.tsx +++ b/apps/web/src/pages/Privacy.tsx @@ -30,7 +30,7 @@ export default function Privacy() { recovery key, can unlock. They can be decrypted only on the devices you sign in to. We, and Google, which runs the servers the App uses, hold Push Content only in that encrypted form and cannot read it, apart from pushes made before end-to-end encryption that are still stored as they were sent (see Data Retention below). Your - recovery key is never sent to us or to Google, and your password only in the two cases described below. The{' '} + recovery key is never sent to us or to Google, and your password only in the cases described below. The{' '} Security page explains how this works and what it does not cover.

@@ -79,7 +79,8 @@ export default function Privacy() { slow. Your password itself reaches Google’s sign-in service, Firebase Authentication, in two cases: when you reset it, because the reset is made on Google’s page, and when a sign-in with the derived key is refused (for example, because the password was mistyped), because the App then tries the password itself in case your account - was made before end-to-end encryption. After a reset, the App replaces the stored password with the derived key + was made before end-to-end encryption. A version of the App from before end-to-end encryption also sends your + password itself whenever it signs in. After a reset, the App replaces the stored password with the derived key the next time you sign in.

diff --git a/apps/web/src/pages/Security.tsx b/apps/web/src/pages/Security.tsx index 551df1f..591d75e 100644 --- a/apps/web/src/pages/Security.tsx +++ b/apps/web/src/pages/Security.tsx @@ -37,10 +37,11 @@ export function Security() { When you sign in, the app turns your password into two keys on the device: one signs you in, and the other unlocks the key your pushes are encrypted with. That second key never leaves the device, so we cannot read your pushes. It also means we cannot reset your password for you and keep your pushes: only the recovery key, below, - can do that. Your password reaches Google’s sign-in service in two cases only: when you reset it, since - the reset is made on Google’s own page, and when a sign-in with the first key is refused (a mistyped - password, say), since the app then tries the password itself in case the account was made before encryption. - After a reset, your next sign-in replaces the password Google holds with the first key. + can do that. Your password reaches Google’s sign-in service in two cases: when you reset it, since the + reset is made on Google’s own page, and when a sign-in with the first key is refused (a mistyped password, + say), since the app then tries the password itself in case the account was made before encryption. A version of + the app from before encryption also sends the password itself whenever it signs in. After a reset, your next + sign-in replaces the password Google holds with the first key.

From 02b5b09b1ee584a287ccc3ac905d741d0cd0d2bb Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Tue, 27 Oct 2026 15:17:49 -0500 Subject: [PATCH 24/32] Say the password is replaced after a reset once the recovery step is done Both pages said the next sign-in after a reset replaces the raw password with the derived key. `updatePassword(authKey)` runs only in `finish`, after the recovery key is entered or the user starts over, on every client. Someone who signs out at the recovery prompt keeps the raw password in Firebase, and the next sign-in sends it again. The pages now say when the replacement happens. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/pages/Privacy.tsx | 2 +- apps/web/src/pages/Security.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/pages/Privacy.tsx b/apps/web/src/pages/Privacy.tsx index b223036..6bf24b3 100644 --- a/apps/web/src/pages/Privacy.tsx +++ b/apps/web/src/pages/Privacy.tsx @@ -81,7 +81,7 @@ export default function Privacy() { example, because the password was mistyped), because the App then tries the password itself in case your account was made before end-to-end encryption. A version of the App from before end-to-end encryption also sends your password itself whenever it signs in. After a reset, the App replaces the stored password with the derived key - the next time you sign in. + once you have entered your recovery key or chosen to start without it.

diff --git a/apps/web/src/pages/Security.tsx b/apps/web/src/pages/Security.tsx index 591d75e..dcd3974 100644 --- a/apps/web/src/pages/Security.tsx +++ b/apps/web/src/pages/Security.tsx @@ -40,8 +40,8 @@ export function Security() { can do that. Your password reaches Google’s sign-in service in two cases: when you reset it, since the reset is made on Google’s own page, and when a sign-in with the first key is refused (a mistyped password, say), since the app then tries the password itself in case the account was made before encryption. A version of - the app from before encryption also sends the password itself whenever it signs in. After a reset, your next - sign-in replaces the password Google holds with the first key. + the app from before encryption also sends the password itself whenever it signs in. After a reset, the password + Google holds is replaced with the first key once you have entered your recovery key or chosen to start over.

From 0c76d9b42aff96ad83fa921d7afb19662cb03fd1 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Tue, 27 Oct 2026 15:18:11 -0500 Subject: [PATCH 25/32] Name the device model names an older account may still hold Before encryption, the push trigger kept a `deviceNames` array of device model names on the user document. The trigger stopped writing it, but nothing removes it from documents that already have one, so an account made before encryption may still hold it. The policy's list of Account and Push Information now says so. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/pages/Privacy.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/web/src/pages/Privacy.tsx b/apps/web/src/pages/Privacy.tsx index 6bf24b3..cb08e58 100644 --- a/apps/web/src/pages/Privacy.tsx +++ b/apps/web/src/pages/Privacy.tsx @@ -91,8 +91,9 @@ export default function Privacy() { scrambled search tokens made with your key, which let a search find a push without revealing the words searched for. The size of a file you push can be seen roughly from its encrypted form. We also keep how many pushes your account has made each month, and your account's encryption key, itself encrypted so that only your password or - your recovery key can unlock it. We refer to this as “Account and Push Information.” It does not include Push - Content in any readable form. + your recovery key can unlock it. An account made before end-to-end encryption may also still hold the model + names of the devices that pushed from it, which pushes used to carry. We refer to this as “Account and Push + Information.” It does not include Push Content in any readable form.

From b74d1bdc088cbe6f3f785f6eeb00c9737d024e0b Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Tue, 27 Oct 2026 15:18:12 -0500 Subject: [PATCH 26/32] Use curly apostrophes in the privacy policy's new sentences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sentences added for encryption wrote "account's" and "plan's" with straight apostrophes beside "Google’s" and "Apple’s" with curly ones in the same paragraphs. The terms use straight apostrophes throughout and keep them. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/pages/Privacy.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/pages/Privacy.tsx b/apps/web/src/pages/Privacy.tsx index cb08e58..bc7a836 100644 --- a/apps/web/src/pages/Privacy.tsx +++ b/apps/web/src/pages/Privacy.tsx @@ -90,7 +90,7 @@ export default function Privacy() { it, when, whether it is a link or a file, which of your devices sent it, the encrypted Push Content, and scrambled search tokens made with your key, which let a search find a push without revealing the words searched for. The size of a file you push can be seen roughly from its encrypted form. We also keep how many pushes your - account has made each month, and your account's encryption key, itself encrypted so that only your password or + account has made each month, and your account’s encryption key, itself encrypted so that only your password or your recovery key can unlock it. An account made before end-to-end encryption may also still hold the model names of the devices that pushed from it, which pushes used to carry. We refer to this as “Account and Push Information.” It does not include Push Content in any readable form. @@ -109,7 +109,7 @@ export default function Privacy() {

- We use Account and Push Information to deliver your pushes to your devices, to apply your plan's monthly limits, + We use Account and Push Information to deliver your pushes to your devices, to apply your plan’s monthly limits, and to delete files when their retention period ends.

From 0a0a5df46b55209dd1db3843fd182d2a9d71c6ee Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Tue, 27 Oct 2026 15:18:32 -0500 Subject: [PATCH 27/32] Drop the extension worker's unused `vite/client` type reference It was there for `import.meta.env.VITE_AXIOM_TOKEN`, and nothing in the worker reads `import.meta` since the Axiom logging was removed. Co-Authored-By: Claude Opus 5.5 --- apps/web/chrome/background.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/chrome/background.ts b/apps/web/chrome/background.ts index 241f6ed..7df0bef 100644 --- a/apps/web/chrome/background.ts +++ b/apps/web/chrome/background.ts @@ -1,7 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /// /// -/// import { User } from 'firebase/auth'; import { arrayUnion, From 5c852295d8a4daa4c8475cbfbc1968df10aff718 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Tue, 27 Oct 2026 15:20:04 -0500 Subject: [PATCH 28/32] Say what the old device names actually held The privacy policy and the Security page called the device name that pushes used to carry a model name. Before encryption, Android sent the name the owner gave the phone (`device_name`, falling back to `Build.MODEL`), and the web app sent `navigator.userAgent`. A name someone gave their phone often has their own name in it, so the pages described that data as narrower than it was. Both now say what it could be. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/pages/Privacy.tsx | 7 ++++--- apps/web/src/pages/Security.tsx | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/web/src/pages/Privacy.tsx b/apps/web/src/pages/Privacy.tsx index bc7a836..f7650b1 100644 --- a/apps/web/src/pages/Privacy.tsx +++ b/apps/web/src/pages/Privacy.tsx @@ -91,9 +91,10 @@ export default function Privacy() { scrambled search tokens made with your key, which let a search find a push without revealing the words searched for. The size of a file you push can be seen roughly from its encrypted form. We also keep how many pushes your account has made each month, and your account’s encryption key, itself encrypted so that only your password or - your recovery key can unlock it. An account made before end-to-end encryption may also still hold the model - names of the devices that pushed from it, which pushes used to carry. We refer to this as “Account and Push - Information.” It does not include Push Content in any readable form. + your recovery key can unlock it. An account made before end-to-end encryption may also still hold the names of + the devices that pushed from it (the name you gave the device, its model name, or its browser’s user agent), + which pushes used to carry. We refer to this as “Account and Push Information.” It does not include Push Content + in any readable form.

diff --git a/apps/web/src/pages/Security.tsx b/apps/web/src/pages/Security.tsx index dcd3974..03b2fa6 100644 --- a/apps/web/src/pages/Security.tsx +++ b/apps/web/src/pages/Security.tsx @@ -136,7 +136,8 @@ export function Security() {

Each install of PushThat makes a random device ID, which the web app shows on the Plan{' '} page. It goes with every push so that the device that sent one does not notify itself, and it says nothing about - the device: it replaced the device’s model name, which pushes used to carry. + the device: it replaced the device’s name (the one you gave it, its model name, or the browser’s + user agent), which pushes used to carry.

QUESTIONS
From b597244f36663e9097760465f886ce91b4db7cd9 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Tue, 27 Oct 2026 15:20:06 -0500 Subject: [PATCH 29/32] Stop counting the cases in which the password reaches Google Both pages announced "two cases" and then named a third, an app from before encryption. Without a count, the list cannot go stale if it changes again. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/pages/Privacy.tsx | 2 +- apps/web/src/pages/Security.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/pages/Privacy.tsx b/apps/web/src/pages/Privacy.tsx index f7650b1..1d8379d 100644 --- a/apps/web/src/pages/Privacy.tsx +++ b/apps/web/src/pages/Privacy.tsx @@ -76,7 +76,7 @@ export default function Privacy() { By registering or authenticating via our email and password login, Users allow the App to identify them and give them access to the App's services. The App signs you in with a key derived from your password rather than the password itself. Your password can be worked out from that key only by guessing it, which is made deliberately - slow. Your password itself reaches Google’s sign-in service, Firebase Authentication, in two cases: when you + slow. Your password itself reaches Google’s sign-in service, Firebase Authentication, in these cases: when you reset it, because the reset is made on Google’s page, and when a sign-in with the derived key is refused (for example, because the password was mistyped), because the App then tries the password itself in case your account was made before end-to-end encryption. A version of the App from before end-to-end encryption also sends your diff --git a/apps/web/src/pages/Security.tsx b/apps/web/src/pages/Security.tsx index 03b2fa6..5ae91dd 100644 --- a/apps/web/src/pages/Security.tsx +++ b/apps/web/src/pages/Security.tsx @@ -37,7 +37,7 @@ export function Security() { When you sign in, the app turns your password into two keys on the device: one signs you in, and the other unlocks the key your pushes are encrypted with. That second key never leaves the device, so we cannot read your pushes. It also means we cannot reset your password for you and keep your pushes: only the recovery key, below, - can do that. Your password reaches Google’s sign-in service in two cases: when you reset it, since the + can do that. Your password reaches Google’s sign-in service in these cases: when you reset it, since the reset is made on Google’s own page, and when a sign-in with the first key is refused (a mistyped password, say), since the app then tries the password itself in case the account was made before encryption. A version of the app from before encryption also sends the password itself whenever it signs in. After a reset, the password From 4e1ed53192710fd77640f51001df547d8f1b598d Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Tue, 27 Oct 2026 15:38:35 -0500 Subject: [PATCH 30/32] Stop saying on the front page that the password never leaves the device The front page's "Sign in everywhere" card said the password "never leaves" the devices. It reaches Firebase Authentication on a reset, on a sign-in whose derived key is refused and from an app from before encryption, as the privacy policy and the Security page now say. The card now says only that the password unlocks the key on each device. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/pages/Index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/pages/Index.tsx b/apps/web/src/pages/Index.tsx index c5110ba..31c5c87 100644 --- a/apps/web/src/pages/Index.tsx +++ b/apps/web/src/pages/Index.tsx @@ -49,7 +49,7 @@ function HowItWorks() { const steps = [ { title: 'Sign in everywhere', - body: 'One account on your phone, your computer and Chrome. Your password unlocks the key on each device, and it never leaves them.', + body: 'One account on your phone, your computer and Chrome. Your password unlocks the key on each device.', }, { title: 'Share it', From a28d5d2e1648dc788e4526608e8b88368ceb6df6 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Tue, 27 Oct 2026 15:38:43 -0500 Subject: [PATCH 31/32] Say the password unlocks the push key on the front page and in the README The front page said the app "turns your password into the key", and the README that pushes are encrypted "with a key made from the account password". Pushes are encrypted with a random account key that the password's key only unlocks, and the recovery key unlocks a second time (docs/e2ee.md, Keys). That is how a password change avoids re-encrypting every push. The legal pages and the Security page already say it this way, and these two now match them. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- apps/web/src/pages/Index.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0b98306..581361a 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ write, and they are all here: [apps/web/firestore.rules](apps/web/firestore.rule [apps/web/storage.rules](apps/web/storage.rules), with their tests beside them. The Cloud Functions are here too, but nothing proves that what runs on pushthat.link is this code, so that part you take on trust. Pushes are end-to-end encrypted: a push's link, title and file -are encrypted on the device with a key made from the account password, and Firebase holds +are encrypted on the device with a key that only the account password, or its recovery key, unlocks, and Firebase holds only ciphertext and the metadata it needs to count, deliver and expire it. [End-to-end encryption](docs/e2ee.md) is the specification, and `packages/push-test` is the reference the five clients are checked against. diff --git a/apps/web/src/pages/Index.tsx b/apps/web/src/pages/Index.tsx index 31c5c87..775f256 100644 --- a/apps/web/src/pages/Index.tsx +++ b/apps/web/src/pages/Index.tsx @@ -126,7 +126,7 @@ function Encryption() { { Icon: KeyIcon, title: 'Your password is the key', - body: 'The app turns your password into the key on the device. There is no second passphrase to set up and nothing to switch on: it is on for every push, from the first.', + body: 'Your password unlocks the key your pushes are encrypted with, on the device. There is no second passphrase to set up and nothing to switch on: it is on for every push, from the first.', }, { Icon: MagnifyingGlassIcon, From 80dd7494b639e4b21addde05df95203e83bdf081 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Tue, 27 Oct 2026 15:38:56 -0500 Subject: [PATCH 32/32] Rewrap the README's encryption paragraph The previous commit lengthened one line of it past the width the rest of the file keeps. Co-Authored-By: Claude Opus 5.5 --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 581361a..b98ef22 100644 --- a/README.md +++ b/README.md @@ -28,11 +28,11 @@ The apps and the Firestore and Storage rules are what decide what a client can r write, and they are all here: [apps/web/firestore.rules](apps/web/firestore.rules) and [apps/web/storage.rules](apps/web/storage.rules), with their tests beside them. The Cloud Functions are here too, but nothing proves that what runs on pushthat.link is this code, so -that part you take on trust. Pushes are end-to-end encrypted: a push's link, title and file -are encrypted on the device with a key that only the account password, or its recovery key, unlocks, and Firebase holds -only ciphertext and the metadata it needs to count, deliver and expire it. [End-to-end -encryption](docs/e2ee.md) is the specification, and `packages/push-test` is the reference -the five clients are checked against. +that part you take on trust. Pushes are end-to-end encrypted: a push's link, title and +file are encrypted on the device with a key that only the account password, or its +recovery key, unlocks, and Firebase holds only ciphertext and the metadata it needs to +count, deliver and expire it. [End-to-end encryption](docs/e2ee.md) is the specification, +and `packages/push-test` is the reference the five clients are checked against. ## Layout