Skip to content

Commit 125db86

Browse files
committed
frontend: store secretKey in service worker.
This fixes an issue where a potentially malicious custom firmware could steal the secretKey
1 parent b02141b commit 125db86

File tree

7 files changed

+176
-24
lines changed

7 files changed

+176
-24
lines changed

frontend/src/components/Navbar.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useLocation } from "preact-iso";
22
import Nav from "react-bootstrap/Nav";
3-
import { AppState, bc, fetchClient, FRONTEND_URL, loggedIn, pub_key, resetSecret, secret } from "../utils";
3+
import { AppState, bc, fetchClient, FRONTEND_URL, loggedIn, pub_key, resetSecret, secret, clearSecretKeyFromServiceWorker } from "../utils";
44
import { useTranslation } from "react-i18next";
55
import { Navbar } from "react-bootstrap";
66
import Median from "median-js-bridge";
@@ -23,7 +23,7 @@ export async function logout(logout_all: boolean) {
2323

2424
resetSecret();
2525
localStorage.removeItem("loginSalt");
26-
localStorage.removeItem("secretKey");
26+
await clearSecretKeyFromServiceWorker();
2727

2828
loggedIn.value = AppState.LoggedOut;
2929
bc.postMessage("logout");

frontend/src/components/login.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Component } from "preact";
22
import Button from "react-bootstrap/Button"
33
import Form from "react-bootstrap/Form";
4-
import { AppState, bc, fetchClient, loggedIn } from "../utils";
4+
import { AppState, bc, fetchClient, loggedIn, storeSecretKeyInServiceWorker } from "../utils";
55
import { showAlert } from "./Alert";
66
import { generate_hash, get_salt_for_user } from "../utils";
77
import { Modal } from "react-bootstrap";
@@ -80,7 +80,7 @@ export class Login extends Component<{}, LoginState> {
8080
const secret_key = await generate_hash(this.state.password, new Uint8Array(secret_salt), sodium.crypto_secretbox_KEYBYTES);
8181
const encoded_key = Base64.fromUint8Array(secret_key);
8282

83-
localStorage.setItem("secretKey", encoded_key);
83+
await storeSecretKeyInServiceWorker(encoded_key);
8484
loggedIn.value = AppState.LoggedIn;
8585
bc.postMessage("login");
8686
}

frontend/src/index.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import Median from "median-js-bridge";
3939
import { Footer } from "./components/Footer";
4040
import favicon from "favicon";
4141
import logo from "logo";
42+
import { Message, MessageType } from './types';
4243

4344
import "./styles/main.scss";
4445
import { docs } from "links";
@@ -96,6 +97,19 @@ export function App() {
9697
}
9798
}, [loggedIn.value])
9899

100+
// Migrate secret from localStorage to service worker
101+
useEffect(() => {
102+
const secretFromLocalStorage = localStorage.getItem("secretKey");
103+
if (secretFromLocalStorage) {
104+
localStorage.removeItem("secretKey");
105+
const msg: Message = {
106+
type: MessageType.StoreSecret,
107+
data: secretFromLocalStorage
108+
};
109+
navigator.serviceWorker.controller?.postMessage(msg);
110+
}
111+
}, []);
112+
99113
if (!window.ServiceWorker) {
100114
return <Row fluid className="align-content-center justify-content-center vh-100">
101115
{t("no_service_worker")}

frontend/src/pages/Frame.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,12 @@ export class Frame extends Component<{}, FrameState> {
351351
}}>{t("abort")}</Button>
352352
</Row>
353353
<Row className="flex-grow-1 m-0">
354-
<iframe class="p-0" hidden={this.state.show_spinner} width="100%" height="100%" id="interface"></iframe>
354+
<iframe
355+
class="p-0"
356+
hidden={this.state.show_spinner}
357+
width="100%"
358+
height="100%"
359+
id="interface" />
355360
</Row>
356361
{downLoadButton}
357362
</Container>

frontend/src/sw.ts

Lines changed: 81 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -104,21 +104,89 @@ self.addEventListener("activate", () => {
104104
self.clients.claim();
105105
});
106106

107-
self.addEventListener("message", (e) => {
107+
const SECRET_CACHE_NAME = 'secret-cache-v1';
108+
109+
async function storeSecretKeyInCache(secretKey: string): Promise<void> {
110+
const cache = await caches.open(SECRET_CACHE_NAME);
111+
const response = new Response(secretKey);
112+
await cache.put('secret-key', response);
113+
}
114+
115+
async function getSecretKeyFromCache(): Promise<string | null> {
116+
try {
117+
const cache = await caches.open(SECRET_CACHE_NAME);
118+
const response = await cache.match('secret-key');
119+
if (response) {
120+
const secretKey = await response.text();
121+
return secretKey;
122+
}
123+
} catch (e) {
124+
console.error('Service Worker: Failed to get secretKey from cache:', e);
125+
}
126+
return null;
127+
}
128+
129+
async function clearSecretKeyFromCache(): Promise<void> {
130+
try {
131+
const cache = await caches.open(SECRET_CACHE_NAME);
132+
await cache.delete('secret-key');
133+
} catch (e) {
134+
console.error('Service Worker: Failed to clear secretKey from cache:', e);
135+
}
136+
}
137+
138+
function isIframeMessage(e: ExtendableMessageEvent): boolean {
139+
const source = e.source;
140+
if (source instanceof WindowClient && source.url.indexOf("wg-") !== -1) {
141+
return true;
142+
}
143+
return false;
144+
}
145+
146+
self.addEventListener("message", async (e: ExtendableMessageEvent) => {
147+
if (isIframeMessage(e)) {
148+
console.warn("Service Worker ignoring message from invalid origin or iframe:", e.source);
149+
return;
150+
}
151+
108152
const msg = e.data as Message;
109-
if (msg.type === MessageType.FetchResponse) {
110-
const resp_message = msg.data as ResponseMessage;
111-
const response = new Response(
112-
resp_message.body,
113-
{
114-
status: resp_message.status,
115-
statusText: resp_message.statusText,
116-
headers: new Headers(resp_message.headers)
153+
154+
switch (msg.type) {
155+
case MessageType.FetchResponse:
156+
const resp_message = msg.data as ResponseMessage;
157+
const response = new Response(
158+
resp_message.body,
159+
{
160+
status: resp_message.status,
161+
statusText: resp_message.statusText,
162+
headers: new Headers(resp_message.headers)
163+
}
164+
);
165+
166+
const event = new CustomEvent(msg.id as string, {detail: response});
167+
self.dispatchEvent(event);
168+
break;
169+
170+
case MessageType.StoreSecret:
171+
await storeSecretKeyInCache(msg.data);
172+
break;
173+
174+
case MessageType.RequestSecret:
175+
const secretKey = await getSecretKeyFromCache();
176+
if (secretKey) {
177+
const responseMsg: Message = {
178+
type: MessageType.StoreSecret,
179+
data: secretKey
180+
};
181+
e.source?.postMessage(responseMsg);
117182
}
118-
);
183+
break;
184+
185+
case MessageType.ClearSecret:
186+
await clearSecretKeyFromCache();
187+
break;
119188

120-
// msg.id is never undefined when type is FetchResponse
121-
const event = new CustomEvent(msg.id as string, {detail: response});
122-
self.dispatchEvent(event);
189+
default:
190+
break;
123191
}
124192
});

frontend/src/types.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ export enum MessageType {
44
Fetch,
55
FetchResponse,
66
Setup,
7-
Error
7+
Error,
8+
StoreSecret,
9+
RequestSecret,
10+
ClearSecret,
811
}
912

1013
export interface Message {

frontend/src/utils.tsx

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { logout } from "./components/Navbar";
77
import i18n from "./i18n";
88
import { showAlert } from "./components/Alert";
99
import { Base64 } from "js-base64";
10+
import { Message, MessageType } from "./types";
1011

1112
export async function get_salt() {
1213
const {data, response} = await fetchClient.GET("/auth/generate_salt");
@@ -101,21 +102,25 @@ export async function refresh_access_token() {
101102
});
102103

103104
if (!error || response.status === 502) {
104-
if (!localStorage.getItem("loginSalt") || !localStorage.getItem("secretKey")) {
105+
const hasLoginSalt = localStorage.getItem("loginSalt");
106+
const hasSecret = await getSecretKeyFromServiceWorker();
107+
if (!hasLoginSalt || !hasSecret) {
105108
logout(false);
106109
}
107110
loggedIn.value = AppState.LoggedIn;
108111
} else {
109112
auth_already_failed = true;
110113
resetSecret();
111114
localStorage.removeItem("loginSalt");
112-
localStorage.removeItem("secretKey");
115+
await clearSecretKeyFromServiceWorker();
113116
loggedIn.value = AppState.LoggedOut;
114117
}
115118
} catch (e) {
116119
resetSecret();
117120
//This means we are logged in but the refresh failed
118-
if (localStorage.getItem("loginSalt") && localStorage.getItem("secretKey")) {
121+
const hasLoginSalt = localStorage.getItem("loginSalt");
122+
const hasSecret = await getSecretKeyFromServiceWorker();
123+
if (hasLoginSalt && hasSecret) {
119124
loggedIn.value = AppState.LoggedIn;
120125
}
121126
console.error(e);
@@ -125,6 +130,61 @@ export async function refresh_access_token() {
125130
export let secret: Uint8Array | null = null;
126131
export let pub_key: Uint8Array | null = null;
127132

133+
// Service Worker communication functions
134+
export async function storeSecretKeyInServiceWorker(secretKey: string): Promise<void> {
135+
if (!navigator.serviceWorker.controller) {
136+
return;
137+
}
138+
139+
const msg: Message = {
140+
type: MessageType.StoreSecret,
141+
data: secretKey
142+
};
143+
navigator.serviceWorker.controller.postMessage(msg);
144+
}
145+
146+
export async function getSecretKeyFromServiceWorker(): Promise<string | null> {
147+
return new Promise((resolve) => {
148+
if (!navigator.serviceWorker.controller) {
149+
resolve(null);
150+
return;
151+
}
152+
153+
const timeout = setTimeout(() => {
154+
resolve(null);
155+
}, 5000); // 5 second timeout
156+
157+
const handleMessage = (event: MessageEvent) => {
158+
const msg = event.data as Message;
159+
if (msg.type === MessageType.StoreSecret) {
160+
clearTimeout(timeout);
161+
navigator.serviceWorker.removeEventListener('message', handleMessage);
162+
resolve(msg.data);
163+
}
164+
};
165+
166+
navigator.serviceWorker.addEventListener('message', handleMessage);
167+
168+
const requestMsg: Message = {
169+
type: MessageType.RequestSecret,
170+
data: null
171+
};
172+
navigator.serviceWorker.controller.postMessage(requestMsg);
173+
});
174+
}
175+
176+
export async function clearSecretKeyFromServiceWorker(): Promise<void> {
177+
if (!navigator.serviceWorker.controller) {
178+
return;
179+
}
180+
181+
const msg: Message = {
182+
type: MessageType.ClearSecret,
183+
data: null
184+
};
185+
navigator.serviceWorker.controller.postMessage(msg);
186+
}
187+
128188
export async function get_decrypted_secret() {
129189
await sodium.ready;
130190
const t = i18n.t;
@@ -134,9 +194,9 @@ export async function get_decrypted_secret() {
134194
showAlert(t("chargers.loading_secret_failed", {status, response: error}), "danger");
135195
return;
136196
}
137-
const encoded_key = localStorage.getItem("secretKey");
197+
const encoded_key = await getSecretKeyFromServiceWorker();
138198
if (!encoded_key) {
139-
showAlert(t("chargers.loading_secret_failed", {status: 'no_key', response: 'No secretKey in localStorage'}), "danger");
199+
showAlert(t("chargers.loading_secret_failed", {status: 'no_key', response: 'No secretKey in service worker cache'}), "danger");
140200
return;
141201
}
142202
const secret_key = Base64.toUint8Array(encoded_key);
@@ -147,6 +207,8 @@ export async function get_decrypted_secret() {
147207
export function resetSecret() {
148208
secret = null;
149209
pub_key = null;
210+
// Also clear the secret from service worker cache
211+
clearSecretKeyFromServiceWorker().catch((e: any) => console.warn("Failed to clear secret from service worker:", e));
150212
}
151213

152214
export const isDebugMode = signal(false);

0 commit comments

Comments
 (0)