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/README.md b/README.md
index 0b98306..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 made from the account password, 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
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/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 @@
- 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/apps/web/src/pages/Index.tsx b/apps/web/src/pages/Index.tsx
index c5110ba..775f256 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',
@@ -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,
diff --git a/apps/web/src/pages/Privacy.tsx b/apps/web/src/pages/Privacy.tsx
index a6e07dd..1d8379d 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,25 @@ export default function Privacy() {
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”).
+ 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 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 cases described below. The{' '}
+ Security page explains how this works and what it does not cover.
+
@@ -25,8 +41,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:
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 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 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
+ password itself whenever it signs in. After a reset, the App replaces the stored password with the derived key
+ once you have entered your recovery key or chosen to start without it.
- 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, 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 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.
+
+ When we talk about “Personal Information” in this Privacy Policy, we are talking about Device Information,
+ Subscription Information, and Account and Push Information.
PushThat Privacy Policy
+ YOUR PUSHES ARE END-TO-END ENCRYPTED
+
+ PERSONAL INFORMATION WE COLLECT
HOW DO WE USE YOUR PERSONAL INFORMATION?
@@ -73,6 +109,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 +130,19 @@ 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, 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.
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, apart from pushes made before end-to-end encryption that are still stored as they were sent.
+ 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). +
+ ++ 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, 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.
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. 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 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 + Google holds is replaced with the first key once you have entered your recovery key or chosen to start over.
@@ -94,9 +98,10 @@ export function Security() {
- 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.
@@ -113,9 +118,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.
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.
+ 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. +
+@@ -214,9 +231,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 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 +255,12 @@ export default function Terms() { +
+ Push Content can be accessed, preserved and/or disclosed only in its encrypted form, which MakeRocketGoNow LLC + 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. +
+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 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 { 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 6e7c211..b40f080 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/e2ee.md b/docs/e2ee.md index 4108a0d..f68dbc1 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,7 +204,9 @@ 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. -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. +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. 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. @@ -224,7 +226,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. 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