Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
f58ef8d
Stop sending the Chrome extension's logs to Axiom
cdeutsch Oct 27, 2026
66631b3
Say in the privacy policy that pushes are end-to-end encrypted
cdeutsch Oct 27, 2026
c0467ac
Say in the terms what end-to-end encryption means for content and a l…
cdeutsch Oct 27, 2026
03d6e00
Stop promising that the password never leaves the device
cdeutsch Oct 27, 2026
6a5fcaa
Put the Security page's "It also means" back after the key it refers to
cdeutsch Oct 27, 2026
21fd437
Except pushes from before encryption where the legal pages say conten…
cdeutsch Oct 27, 2026
206c17a
Name Google Analytics for Firebase in the privacy policy
cdeutsch Oct 27, 2026
3f5019b
Say in the terms that a file keeps the retention of the plan it was p…
cdeutsch Oct 27, 2026
3763858
Say the password unlocks the key pushes are encrypted with, not that …
cdeutsch Oct 27, 2026
f2b01aa
Say in the terms when an account made before encryption got its recov…
cdeutsch Oct 27, 2026
7606e6f
Say a link push is kept until the account's deletion is asked for
cdeutsch Oct 27, 2026
f81c799
List the stored key wraps in the privacy policy's account information
cdeutsch Oct 27, 2026
9939f63
Name the key version among what a notification carries
cdeutsch Oct 27, 2026
82a5367
Give "Push Content" the privacy policy's definition in the terms
cdeutsch Oct 27, 2026
291c6c3
Make the extension's log helpers synchronous
cdeutsch Oct 27, 2026
32cbd70
Say on the Security page when an account made before encryption got i…
cdeutsch Oct 27, 2026
301a647
Say the sign-in fallback tries the password itself, not as typed
cdeutsch Oct 27, 2026
26bf42f
Say which key replaces the password Google holds after a reset
cdeutsch Oct 27, 2026
7644955
Drop "We never see it" from the recovery-key screen
cdeutsch Oct 27, 2026
78098e7
Say at each sign-in fallback that password resets depend on it
cdeutsch Oct 27, 2026
b1a92f4
Record in the design doc why the raw-password fallback is permanent
cdeutsch Oct 27, 2026
51bae1c
Stop promising a one-step deletion of locked pushes after a reset
cdeutsch Oct 27, 2026
41bf369
Name apps from before encryption among the ways the password reaches …
cdeutsch Oct 27, 2026
02b5b09
Say the password is replaced after a reset once the recovery step is …
cdeutsch Oct 27, 2026
0c76d9b
Name the device model names an older account may still hold
cdeutsch Oct 27, 2026
b74d1bd
Use curly apostrophes in the privacy policy's new sentences
cdeutsch Oct 27, 2026
0a0a5df
Drop the extension worker's unused `vite/client` type reference
cdeutsch Oct 27, 2026
5c85229
Say what the old device names actually held
cdeutsch Oct 27, 2026
b597244
Stop counting the cases in which the password reaches Google
cdeutsch Oct 27, 2026
98545bf
Merge remote-tracking branch 'origin/main' into claude/e2e-encryption…
cdeutsch Oct 27, 2026
4e1ed53
Stop saying on the front page that the password never leaves the device
cdeutsch Oct 27, 2026
a28d5d2
Say the password unlocks the push key on the front page and in the RE…
cdeutsch Oct 27, 2026
80dd749
Rewrap the README's encryption paragraph
cdeutsch Oct 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion apps/android/app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@
<!-- %1$d is MIN_NEW_PASSWORD_LENGTH (e2ee/AccountKeys.kt), so the number is written once. -->
<string name="password_rule">Use at least %1$d characters. Your password is the key to your pushes, so it has to be hard to guess.</string>
<string name="recovery_key_title">Your recovery key</string>
<string name="recovery_key_body">Your password is the key to your pushes. We never see it and cannot reset it for you. Save your recovery key.</string>
<string name="recovery_key_body">Your password is the key to your pushes, and we cannot reset it for you. Save your recovery key.</string>
<string name="recovery_key_shown_once">It is shown only once. If you forget your password, it is the only way to keep your pushes.</string>
<string name="copy">Copy</string>
<string name="copied">Copied</string>
Expand Down
6 changes: 6 additions & 0 deletions apps/ios/PushThat/ViewModels/AuthViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion apps/ios/PushThat/Views/RecoveryKeyView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand Down
6 changes: 6 additions & 0 deletions apps/macos/PushThat/ViewModels/AuthViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion apps/macos/PushThat/Views/RecoveryKeyView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand Down
5 changes: 0 additions & 5 deletions apps/web/.env.example

This file was deleted.

2 changes: 1 addition & 1 deletion apps/web/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,5 @@ chrome-extension
# Firebase
.firebase

# Local environment (see .env.example)
# Local environment
.env
77 changes: 24 additions & 53 deletions apps/web/chrome/background.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/// <reference types="chrome"/>
/// <reference lib="webworker" />
/// <reference types="vite/client" />
import { Axiom } from '@axiomhq/js';
import { User } from 'firebase/auth';
import {
arrayUnion,
Expand All @@ -28,12 +26,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;
Expand All @@ -45,7 +37,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);
Expand All @@ -59,13 +51,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, {
Expand All @@ -79,7 +71,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;
}
}
Expand All @@ -102,21 +94,21 @@ const requestFcmToken = async (userId: string): Promise<string | undefined> => {

return token;
} catch (error) {
await logError('Error getting FCM token:', { error });
logError('Error getting FCM token:', { error });
}

return undefined;
};

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.');
}
}

Expand Down Expand Up @@ -155,14 +147,14 @@ async function readReceivedPush(pushId: string): Promise<Push | undefined> {
*/
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') {
Expand All @@ -186,15 +178,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
Expand All @@ -213,7 +200,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
Expand All @@ -233,11 +220,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 });
}
});

Expand All @@ -255,36 +242,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();
});

async function debug(message: string, optionalParams?: any) {
// 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.
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) {
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 } : {}),
},
]);
}
33 changes: 0 additions & 33 deletions apps/web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/KeyGate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ function NewRecoveryKey({ recoveryKey }: { recoveryKey: string }) {
</h2>
{/* docs/e2ee.md, Saying so: the sign-up's words, word for word. */}
<p className={styles.message}>
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.
</p>
<p className={styles.message}>
It is shown only once. If you forget your password, it is the only way to keep your pushes.
Expand Down
Loading
Loading