Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 3 additions & 1 deletion packages/addon/public/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,9 @@
"content_scripts": [
{
"matches": [
"<all_urls>"
"https://send.tb.pro/*",
"https://send-stage.tb.pro/*",
"http://localhost/*"
],
"js": [
"token-bridge.js"
Expand Down
29 changes: 11 additions & 18 deletions packages/addon/public/token-bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,31 +22,24 @@ const PENDING_ADDON_TOKEN_RESPONSE = 'TB/PENDING_ADDON_TOKEN_RESPONSE';
window.postMessage({ type: BRIDGE_READY }, window.location.origin);
console.log(`[🌉 token-bridge] the token bridge has loaded.`);

// Visual cue, make sure to remove.
const tag = document.createElement('div');
tag.textContent = '✅ Content script injected';
Object.assign(tag.style, {
position: 'fixed',
zIndex: 999999,
inset: '8px auto auto 8px',
padding: '6px 10px',
background: 'lime',
color: 'black',
fontFamily: 'monospace',
boxShadow: '0 2px 8px rgba(0,0,0,.25)',
});
// document.documentElement.appendChild(tag);

// Initial message to the background
browser.runtime.sendMessage({
type: PING,
text: 'This got sent from the bridge to the background.',
});

window.addEventListener('message', (e) => {
// if (e.origin !== APP_ORIGIN) return; // security: only trust your app
// if (e.source !== window) return; // same-page messages only
// if (!e.data || e.data.type !== "TB_PING") return;
// Security: this bridge forwards messages straight to the privileged add-on
// background (OIDC tokens, the encryption passphrase, sign-in/out). Only trust
// messages the app page posted to itself:
// • e.source !== window → came from an embedded/cross-origin frame or
// another window, not the top app page. Reject.
// • e.origin !== window.location.origin → not same-origin. Reject.
// Combined with the scoped content_scripts `matches` in the manifest (the
// bridge is only injected on the Send app's own origins), this keeps a
// third-party page or frame from driving the add-on.
if (e.source !== window) return;
if (e.origin !== window.location.origin) return;

// ----- Web to add-on: Step 6b — refresh token → background (Thundermail) -----
// handleOIDCCallback() posts OIDC_TOKEN with the refresh_token so that
Expand Down
16 changes: 16 additions & 0 deletions packages/addon/scripts/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,22 @@ if [ "$ADDON_VARIANT" = "system" ]; then
bun run scripts/set-system-id.ts dist/manifest.json --allow-stage
fi

### Scope the token-bridge content script to remote app origins only for
### production. public/manifest.json lists http://localhost/* so local dev builds
### inject the bridge into the local Send app, but a released build must not trust
### any localhost page: the bridge forwards messages to the privileged background
### (OIDC tokens, encryption passphrase), and the same-origin guard in
### token-bridge.js does not stop a hostile page that is itself served from
### localhost. Strip it here for prod; non-prod builds keep localhost for dev.
if [ "$NODE_ENV" = "production" ]; then
echo "================================================================"
echo "=============== prod: drop localhost bridge match =============="
tmp_manifest="$(mktemp)"
jq '(.content_scripts[].matches) |= map(select(. != "http://localhost/*"))' \
dist/manifest.json > "$tmp_manifest"
mv "$tmp_manifest" dist/manifest.json
fi

cd dist
# Create xpi with version number
zip -r -FS ../tbpro-addon-${VERSION}.xpi *
Expand Down
42 changes: 8 additions & 34 deletions packages/send/frontend/src/composables/useSendConfig.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
import {
GET_LOGIN_STATE,
LOGIN_STATE_RESPONSE,
SEND_MESSAGE_TO_BRIDGE,
} from '@send-frontend/lib/const';
import { GET_LOGIN_STATE, LOGIN_STATE_RESPONSE } from '@send-frontend/lib/const';
import { pullBridgedPassphrase } from '@send-frontend/lib/bridgePassphrase';
import { dbUserSetup } from '@send-frontend/lib/helpers';
import init from '@send-frontend/lib/init';
import { validateToken } from '@send-frontend/lib/validations';
Expand Down Expand Up @@ -31,35 +28,12 @@ export function useSendConfig() {
const { isLoggedIn } = storeToRefs(authStore);

/**
* Checks browser extension storage for SEND_MESSAGE_TO_BRIDGE value
* and transfers it to localStorage under 'lb/passphrase' key.
* The value is stored as an object with passPhrase property.
* Pulls a passphrase shared from the web app via the token bridge into the
* keychain (delegates to pullBridgedPassphrase). Called before the login
* checks in loadLogin so the passphrase is present when keys are restored.
*/
const checkAndTransferBridgeMessage = async () => {
try {
const result = await browser.storage.local.get(SEND_MESSAGE_TO_BRIDGE);

if (result[SEND_MESSAGE_TO_BRIDGE]) {
const value = result[SEND_MESSAGE_TO_BRIDGE];
const passphraseObject = {
passPhrase: value,
};

localStorage.setItem('lb/passphrase', JSON.stringify(passphraseObject));
console.log('✅ Transferred bridge message to localStorage');

// Delete the value from extension storage after successful transfer
await browser.storage.local.remove(SEND_MESSAGE_TO_BRIDGE);
console.log('✅ Removed bridge message from extension storage');

return true;
}

return false;
} catch (error) {
console.error('Error checking bridge message:', error);
return false;
}
return pullBridgedPassphrase(keychain);
};
// Set up listener for bridge message transfer trigger
try {
Expand Down Expand Up @@ -209,8 +183,8 @@ export function useSendConfig() {
*/
useLoginQuery,
/**
* Checks browser extension storage for SEND_MESSAGE_TO_BRIDGE value
* and transfers it to localStorage under 'lb/passphrase' key.
* Pulls a passphrase shared from the web app via the token bridge into the
* keychain (delegates to pullBridgedPassphrase).
*/
checkAndTransferBridgeMessage,
/**
Expand Down
43 changes: 43 additions & 0 deletions packages/send/frontend/src/lib/bridgePassphrase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { SEND_MESSAGE_TO_BRIDGE } from '@send-frontend/lib/const';

/**
* Pull a passphrase shared from the web app via the token bridge into the
* keychain.
*
* The web app (running in a browser tab) posts SEND_MESSAGE_TO_BRIDGE; the
* add-on background stores its value in browser.storage.local under that key
* (see background.ts). This moves that staged value into the keychain — i.e.
* localStorage['lb/passphrase'], which every moz-extension page (background,
* popup, management) shares — and clears the staged copy so it is consumed once.
*
* Runs only in an extension context where browser.storage.local exists; it is a
* no-op in a plain web page (where `browser` is undefined). Safe to call from
* any context that is about to restore keys, so the popup and background don't
* depend on the management page having run the transfer first.
*
* @returns true if a bridged passphrase was found and stored, false otherwise.
*/
export async function pullBridgedPassphrase(keychain: {
storePassPhrase: (passphrase: string) => Promise<void>;
}): Promise<boolean> {
if (typeof browser === 'undefined' || !browser?.storage?.local) {
return false;
}

try {
const result = await browser.storage.local.get(SEND_MESSAGE_TO_BRIDGE);
const passphrase = result?.[SEND_MESSAGE_TO_BRIDGE];
if (!passphrase) {
return false;
}

await keychain.storePassPhrase(passphrase);
// Consume it once so a stale value can't linger in extension storage.
await browser.storage.local.remove(SEND_MESSAGE_TO_BRIDGE);
console.log('✅ Pulled bridged passphrase into the keychain');
return true;
} catch (error) {
console.error('Error pulling bridged passphrase:', error);
return false;
}
}
7 changes: 7 additions & 0 deletions packages/send/frontend/src/lib/keychain.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Storage } from '@send-frontend/lib/storage';
import { pullBridgedPassphrase } from '@send-frontend/lib/bridgePassphrase';
import { Backup as BackupUserStore } from '@send-frontend/stores/user-store.types';

export type JwkKeyPair = Record<'publicKey' | 'privateKey', string>;
Expand Down Expand Up @@ -720,6 +721,12 @@ export async function restoreKeysUsingLocalStorage(
keychain: Keychain,
api: ApiConnection
) {
// Pull any passphrase the web app shared via the token bridge into the
// keychain first. Both the popup and the background call this before
// restoring, so neither depends on the management page having already run the
// bridge→localStorage transfer (see pullBridgedPassphrase).
await pullBridgedPassphrase(keychain);

console.log('🔑 auto restoring keys');
if (!keychain.getPassphraseValue()) {
console.log('Keychain passphrase is not initialized');
Expand Down
67 changes: 67 additions & 0 deletions packages/send/frontend/src/test/lib/bridgePassphrase.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { pullBridgedPassphrase } from '@send-frontend/lib/bridgePassphrase';
import { SEND_MESSAGE_TO_BRIDGE } from '@send-frontend/lib/const';
import { afterEach, describe, expect, it, vi } from 'vitest';

// A minimal keychain stand-in: pullBridgedPassphrase only needs storePassPhrase.
function makeKeychain(storePassPhrase = vi.fn().mockResolvedValue(undefined)) {
return { storePassPhrase };
}

// Install a fake `browser.storage.local` whose get() returns `stored`.
function stubBrowser(stored: Record<string, unknown>) {
const get = vi.fn().mockResolvedValue(stored);
const remove = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal('browser', { storage: { local: { get, remove } } });
return { get, remove };
}

describe('pullBridgedPassphrase', () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

it('stores a staged passphrase in the keychain and consumes it once', async () => {
const { remove } = stubBrowser({ [SEND_MESSAGE_TO_BRIDGE]: 'word one two' });
const keychain = makeKeychain();

const result = await pullBridgedPassphrase(keychain);

expect(result).toBe(true);
expect(keychain.storePassPhrase).toHaveBeenCalledWith('word one two');
// The staged value is removed so it can only be consumed once.
expect(remove).toHaveBeenCalledWith(SEND_MESSAGE_TO_BRIDGE);
});

it('is a no-op when no passphrase is staged', async () => {
const { remove } = stubBrowser({});
const keychain = makeKeychain();

const result = await pullBridgedPassphrase(keychain);

expect(result).toBe(false);
expect(keychain.storePassPhrase).not.toHaveBeenCalled();
expect(remove).not.toHaveBeenCalled();
});

it('returns false outside an extension context (no browser global)', async () => {
vi.stubGlobal('browser', undefined);
const keychain = makeKeychain();

const result = await pullBridgedPassphrase(keychain);

expect(result).toBe(false);
expect(keychain.storePassPhrase).not.toHaveBeenCalled();
});

it('returns false and does not throw if storing the passphrase fails', async () => {
stubBrowser({ [SEND_MESSAGE_TO_BRIDGE]: 'word one two' });
const keychain = makeKeychain(
vi.fn().mockRejectedValue(new Error('storage full'))
);

const result = await pullBridgedPassphrase(keychain);

expect(result).toBe(false);
});
});
Loading