Skip to content
Merged
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
13 changes: 11 additions & 2 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
import {
getCacheStatus,
getRevalidatingCacheKeys,
subscribeToCacheStatus

Check failure on line 31 in App.tsx

View workflow job for this annotation

GitHub Actions / Syntax & Type Check

Module '"./src/services/api"' has no exported member 'subscribeToCacheStatus'. Did you mean to use 'import subscribeToCacheStatus from "./src/services/api"' instead?
} from './src/services/api';
import { warmCriticalCaches } from './src/services/cacheWarming';
import { crashReportingService } from './src/services/crashReporting';
Expand Down Expand Up @@ -58,7 +58,7 @@
subscribeToHydrationResetToast,
} from './src/store/persistence';
import { handleCacheVersionUpdate } from './src/utils/cacheVersioning';
import { requireEnvVariables } from './src/utils/env';

Check failure on line 61 in App.tsx

View workflow job for this annotation

GitHub Actions / Syntax & Type Check

Cannot find module './src/utils/env' or its corresponding type declarations.
import { appLogger } from './src/utils/logger';

// Keep the splash screen visible while we fetch resources
Expand Down Expand Up @@ -165,7 +165,9 @@
);
}


const App = () => {
const [sessionExpired, setSessionExpired] = useState(false);
const theme = useAppStore(state => state.theme);
useAdaptiveTheme();
// Using imported hook from the merge logic if needed downstream
Expand Down Expand Up @@ -214,7 +216,7 @@
try {
await Promise.all([
fontService.loadFonts(allFonts),
Asset.loadAsync(CRITICAL_ASSETS),

Check failure on line 219 in App.tsx

View workflow job for this annotation

GitHub Actions / Syntax & Type Check

Argument of type 'readonly [any, any, any]' is not assignable to parameter of type 'string | number | string[] | number[]'.
]);
} catch (e: any) {
crashReportingService.reportError(e, 'font-loading-error');
Expand Down Expand Up @@ -348,8 +350,8 @@
Object.entries(capabilities).forEach(([feature, info]) => {
if (feature !== 'checkedAt' && 'status' in info) {
// #807: isFeatureType narrows string key to FeatureType
if ((Object.values(FeatureType) as string[]).includes(feature)) {

Check failure on line 353 in App.tsx

View workflow job for this annotation

GitHub Actions / Syntax & Type Check

Cannot find name 'FeatureType'.
degradationStore.setFeatureStatus(feature as FeatureType, info.status);

Check failure on line 354 in App.tsx

View workflow job for this annotation

GitHub Actions / Syntax & Type Check

Cannot find name 'FeatureType'.
}
}
});
Expand Down Expand Up @@ -394,8 +396,8 @@
Object.entries(capabilities).forEach(([feature, info]) => {
if (feature !== 'checkedAt' && 'status' in info) {
// #807: isFeatureType narrows string key to FeatureType
if ((Object.values(FeatureType) as string[]).includes(feature)) {

Check failure on line 399 in App.tsx

View workflow job for this annotation

GitHub Actions / Syntax & Type Check

Cannot find name 'FeatureType'.
degradationStore.setFeatureStatus(feature as FeatureType, info.status);

Check failure on line 400 in App.tsx

View workflow job for this annotation

GitHub Actions / Syntax & Type Check

Cannot find name 'FeatureType'.
}
}
});
Expand Down Expand Up @@ -457,7 +459,7 @@
// Issue #820: read store directly rather than closed-over component state.
const store = useNotificationStore.getState();
store.addNotification({
id: notification.request.identifier,

Check failure on line 462 in App.tsx

View workflow job for this annotation

GitHub Actions / Syntax & Type Check

Object literal may only specify known properties, and 'id' does not exist in type 'Omit<StoredNotification, "id" | "read" | "receivedAt">'.
type: (notification.request.content.data?.type as any) ?? 'general',
title: notification.request.content.title ?? '',
body: notification.request.content.body ?? '',
Expand Down Expand Up @@ -525,8 +527,8 @@
const { valid, expiringSoon } = await checkSessionValidity();

if (!valid) {
logout();
Alert.alert('Session expired', 'Your session has expired. Please log in again.');
// TODO: Persist any unsaved form data to AsyncStorage here.
setSessionExpired(true);
return;
}

Expand Down Expand Up @@ -588,9 +590,9 @@
<AuthProvider>
<StatusBar style={theme === 'dark' ? 'light' : 'dark'} />
<CacheRevalidationBanner />
<ScreenErrorBoundary screenName="AppNavigator">

Check failure on line 593 in App.tsx

View workflow job for this annotation

GitHub Actions / Syntax & Type Check

Cannot find name 'ScreenErrorBoundary'.
<AppNavigator />
</ScreenErrorBoundary>

Check failure on line 595 in App.tsx

View workflow job for this annotation

GitHub Actions / Syntax & Type Check

Cannot find name 'ScreenErrorBoundary'.
<NotificationPermissionExplanationSheet />
{showPreferencesResetToast ? <PreferencesResetToast /> : null}
<UpdatePromptModal
Expand All @@ -600,6 +602,13 @@
onUpdate={handleOtaUpdate}
onDismiss={isCriticalUpdate ? undefined : () => setShowUpdateModal(false)}
/>
<SessionExpiredModal
visible={sessionExpired}
onClose={() => {
setSessionExpired(false);
useAppStore.getState().logout();
}}
/>
</AuthProvider>
</ErrorBoundary>
);
Expand Down
48 changes: 25 additions & 23 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -1,37 +1,39 @@
# Security

## Sentry event tunnel
This document outlines security procedures and best practices for the TeachLink mobile application.

The Sentry DSN is a public constant in the JavaScript bundle. Anyone who
reverse-engineers the APK/IPA can read it and flood the Sentry project with
fake events, consuming quota.
## SSL Pinning

To avoid exposing the DSN as the only ingestion path, the app can send events
through a backend **tunnel** instead of directly to Sentry.
The TeachLink mobile app uses SSL pinning to ensure that it only communicates with trusted servers. This helps to prevent man-in-the-middle attacks.

### App configuration
### Certificate Pin Rotation

Set the tunnel URL via environment variable:
To maintain a high level of security, the SSL pins should be rotated periodically. The following steps outline the process for rotating the certificate pins:

```
EXPO_PUBLIC_SENTRY_TUNNEL_URL=https://api.teachlink.app/api/sentry-tunnel
```
1. **Generate a new key and certificate signing request (CSR).**

When set, `Sentry.init` (in `src/config/logging.ts`) routes all events through
that endpoint. When unset, events fall back to direct DSN delivery.
```bash
openssl req -new -newkey rsa:2048 -nodes -keyout new.key -out new.csr
```

### Backend tunnel endpoint
2. **Get the new certificate signed by the Certificate Authority (CA).**

Implement `POST /api/sentry-tunnel` on the backend to:
3. **Extract the SPKI hash from the new certificate.**

1. Accept the Sentry envelope body from the app.
2. Forward it to the real Sentry ingest URL derived from the (server-held) DSN.
3. Apply rate limiting per IP/client so abuse can't exhaust project quota.
```bash
openssl x509 -in new.crt -pubkey -noout | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl enc -base64
```

This keeps the raw DSN on the server and lets the backend throttle abusive
clients before events reach Sentry.
4. **Update `app.json` with the new pins.**

## Reporting a vulnerability
* The new pin will become the `primaryPin`.
* The old `primaryPin` will become the `backupPin`.

Please report security issues privately to the maintainers rather than opening
a public issue.
5. **Deploy the new certificate to the server.**

6. **Deploy the updated app to the app stores.**

### Current Pins

* **Primary Pin:** `ro9iqKFUc1QlFywktB2QYqziDuEeV8NSFiHZhy75qi4=`
* **Backup Pin:** `C5+lpZ7tcV/weqBHvLr2K8k2y2cnq6/s3tT4G/cM9dY=`
6 changes: 3 additions & 3 deletions app.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,8 @@
"./plugins/withSSLPinning",
{
"domain": "api.teachlink.com",
"primaryPin": "REPLACE_WITH_PRIMARY_SPKI_SHA256_BASE64==",
"backupPin": "REPLACE_WITH_BACKUP_SPKI_SHA256_BASE64=="
"primaryPin": "ro9iqKFUc1QlFywktB2QYqziDuEeV8NSFiHZhy75qi4=",
"backupPin": "C5+lpZ7tcV/weqBHvLr2K8k2y2cnq6/s3tT4G/cM9dY="
}
],
[
Expand All @@ -129,4 +129,4 @@
"reactCompiler": true
}
}
}
}
Loading
Loading