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
8 changes: 8 additions & 0 deletions app.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,14 @@
"expo-speech-recognition",
"expo-video",
"./plugins/withProguard.js",
[
"./plugins/withSSLPinning",
{
"domain": "api.teachlink.com",
"primaryPin": "REPLACE_WITH_PRIMARY_SPKI_SHA256_BASE64==",
"backupPin": "REPLACE_WITH_BACKUP_SPKI_SHA256_BASE64=="
}
],
[
"expo-build-properties",
{
Expand Down
153 changes: 153 additions & 0 deletions docs/security/pin-rotation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
# SSL Certificate Pin Rotation Runbook

Certificate pinning is enforced in production builds for `api.teachlink.com`.
This document describes how to rotate keys with zero downtime and no forced app updates.

---

## How pinning works in this project

| Layer | Mechanism |
|---|---|
| iOS 14+ | `NSPinnedDomains` in `Info.plist` (SPKI SHA-256) |
| Android 7+ | `res/xml/network_security_config.xml` `<pin-set>` |
| JS detection | `isCertPinFailure()` in `src/services/api/axios.config.ts` |
| Config source | `src/config/security.ts` + `app.json` plugin options |

The backup pin is the key — it must always be pre-generated and deployed **before** the primary cert expires. This is what guarantees zero downtime.

---

## Prerequisites

- Access to the EAS build pipeline
- Authority to submit builds to the App Store / Play Store
- The current and next TLS certificate (or at minimum the next keypair/CSR)
- Sentry access to monitor `security.event: ssl_pin_failure` after rollout

---

## Step 1 — Generate the next keypair (do this now, not at expiry time)

```bash
# Generate a new RSA 2048 private key and CSR
openssl genrsa -out next-key.pem 2048
openssl req -new -key next-key.pem -out next-cert.csr \
-subj "/CN=api.teachlink.com/O=TeachLink/C=US"

# Compute the SPKI SHA-256 fingerprint for the new key
openssl pkey -in next-key.pem -pubout -outform der \
| openssl dgst -sha256 -binary \
| base64
# → copy this value; it becomes the new primaryPin after rotation
```

Submit `next-cert.csr` to your CA. The CA returns `next-cert.pem`.

---

## Step 2 — Compute the fingerprint of the current active cert (verify your baseline)

```bash
# From the live server
openssl s_client -connect api.teachlink.com:443 </dev/null 2>/dev/null \
| openssl x509 -pubkey -noout \
| openssl pkey -pubin -outform der \
| openssl dgst -sha256 -binary \
| base64

# Or from a cert file
openssl x509 -in current-cert.pem -pubkey -noout \
| openssl pkey -pubin -outform der \
| openssl dgst -sha256 -binary \
| base64
```

This must match `primaryPin` in `src/config/security.ts`. If it doesn't, investigate before proceeding.

---

## Step 3 — Deploy the new app build with the backup pin set to the NEXT cert

Update `src/config/security.ts`:

```typescript
export const SSL_PINNING = {
domain: 'api.teachlink.com',
primaryPin: '<CURRENT_CERT_SPKI_SHA256>', // unchanged
backupPin: '<NEXT_CERT_SPKI_SHA256>', // ← update this
bypassEnabled: process.env.EXPO_PUBLIC_APP_ENV !== 'production',
} as const;
```

Update `app.json` plugin options to match:

```json
{
"domain": "api.teachlink.com",
"primaryPin": "<CURRENT_CERT_SPKI_SHA256>",
"backupPin": "<NEXT_CERT_SPKI_SHA256>"
}
```

Also update the `expiration` date in the Android `<pin-set>` inside `plugins/withSSLPinning.js` to be at least 30 days beyond the new cert's expiry.

Build and release via EAS:

```bash
eas build --platform all --profile production
eas submit --platform all
```

Wait for the new build to reach **at least 80% of active users** before proceeding to Step 4. Monitor Sentry for any `ssl_pin_failure` events — these indicate users on old builds being rejected by a rotated cert prematurely.

---

## Step 4 — Rotate the certificate on the server

Deploy `next-cert.pem` to the API server. At this point:
- Users on the **new** build: accept both current and next cert (backup pin matches)
- Users on the **old** build: only pinned to current cert, which is still active → no disruption

---

## Step 5 — Deploy the final build removing the old primary pin

Once adoption of Step 3's build is sufficient (target: ≥95% of DAU), update pins again:

```typescript
export const SSL_PINNING = {
primaryPin: '<NEXT_CERT_SPKI_SHA256>', // promoted from backup
backupPin: '<FUTURE_CERT_SPKI_SHA256>', // generate another keypair now
...
} as const;
```

Build and release. The old cert can now be decommissioned.

---

## Emergency rollback

If a pin failure wave appears in Sentry (event `ssl_pin_failure`):

1. **Do not rotate the server cert further.**
2. Release an emergency build with `bypassEnabled: true` in `SSL_PINNING` (or remove `NSPinnedDomains` / `network_security_config.xml` pin-set).
3. Investigate — check if intermediate CA or CDN changed unexpectedly.
4. Re-pin once the certificate chain is stable.

---

## Monitoring

- Sentry query: `security.event:ssl_pin_failure`
- Alert threshold: > 5 events / hour → page on-call
- Events include `endpoint` and `method` only — no tokens, headers, or response bodies are captured

---

## Key storage

- Private keys are **never** committed to this repository.
- Store `next-key.pem` in the team password manager under `TeachLink / TLS Keys`.
- Fingerprints (public, non-secret) live in `src/config/security.ts` and `app.json`.
137 changes: 137 additions & 0 deletions plugins/withSSLPinning.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
const fs = require('fs');
const path = require('path');

const { withInfoPlist, withAndroidManifest, withDangerousMod } = require('@expo/config-plugins');

/**
* Expo config plugin: SSL public key pinning for iOS and Android.
*
* iOS — Injects NSPinnedDomains into Info.plist (iOS 14+, App Transport Security).
* Android — Writes res/xml/network_security_config.xml with a <pin-set> and
* sets android:networkSecurityConfig on the <application> element.
*
* Pinning is only applied when options.enabled is true (default: when
* EXPO_PUBLIC_APP_ENV === 'production'). Debug builds retain full proxy
* access via Android's <debug-overrides> and iOS's conditional skipping.
*
* Options (all required for production):
* domain — hostname that matches EXPO_PUBLIC_API_BASE_URL
* primaryPin — SHA-256 SPKI base64 hash of the primary leaf cert
* backupPin — SHA-256 SPKI base64 hash of the pre-rotated backup key
* enabled — override the production-env default (true/false)
*
* See docs/security/pin-rotation.md for the key rotation runbook.
*/
module.exports = function withSSLPinning(config, options = {}) {
const isProd = process.env.EXPO_PUBLIC_APP_ENV === 'production';
const enabled = options.enabled !== undefined ? options.enabled : isProd;
const domain = options.domain || 'api.teachlink.com';
const primaryPin = options.primaryPin || 'REPLACE_WITH_PRIMARY_SPKI_SHA256_BASE64==';
const backupPin = options.backupPin || 'REPLACE_WITH_BACKUP_SPKI_SHA256_BASE64==';

config = withIOSPinning(config, { enabled, domain, primaryPin, backupPin });
config = withAndroidPinning(config, { enabled, domain, primaryPin, backupPin });
return config;
};

// ── iOS: NSPinnedDomains in Info.plist ────────────────────────────────────────

function withIOSPinning(config, { enabled, domain, primaryPin, backupPin }) {
return withInfoPlist(config, plistConfig => {
if (!enabled) {
return plistConfig;
}

plistConfig.modResults.NSAppTransportSecurity = {
...(plistConfig.modResults.NSAppTransportSecurity || {}),
NSPinnedDomains: {
[domain]: {
// Pin the leaf certificate SPKI — more stable than pinning the full cert
NSPinnedLeafIdentities: [
{ 'SPKI-SHA256-BASE64': primaryPin },
{ 'SPKI-SHA256-BASE64': backupPin },
],
NSIncludesSubdomains: false,
},
},
};

return plistConfig;
});
}

// ── Android: network_security_config.xml + AndroidManifest reference ──────────

function withAndroidPinning(config, { enabled, domain, primaryPin, backupPin }) {
// Step 1 — write res/xml/network_security_config.xml
config = withDangerousMod(config, [
'android',
async modConfig => {
const resXmlDir = path.join(
modConfig.modRequest.platformProjectRoot,
'app',
'src',
'main',
'res',
'xml'
);
fs.mkdirSync(resXmlDir, { recursive: true });

const xml = enabled
? `<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!--
Production: enforce public key pinning on the API domain.
Update the expiration date and pin hashes when rotating keys.
See docs/security/pin-rotation.md.
-->
<domain-config cleartextTrafficPermitted="false">
<domain includeSubdomains="false">${domain}</domain>
<pin-set expiration="2027-12-31">
<pin digest="SHA-256">${primaryPin}</pin>
<pin digest="SHA-256">${backupPin}</pin>
</pin-set>
</domain-config>

<!--
Debug overrides: applied only when android:debuggable="true" (debug builds).
Allows proxy tools (Burp Suite, Charles) to intercept traffic during dev/QA
without installing a custom CA on device trust stores.
-->
<debug-overrides>
<trust-anchors>
<certificates src="system"/>
<certificates src="user"/>
</trust-anchors>
</debug-overrides>
</network-security-config>`
: `<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Pinning is disabled for this build profile (non-production). -->
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system"/>
</trust-anchors>
</base-config>
</network-security-config>`;

fs.writeFileSync(
path.join(resXmlDir, 'network_security_config.xml'),
xml
);

return modConfig;
},
]);

// Step 2 — add android:networkSecurityConfig to <application> in AndroidManifest.xml
config = withAndroidManifest(config, manifestConfig => {
const app = manifestConfig.modResults.manifest.application?.[0];
if (app) {
app.$['android:networkSecurityConfig'] = '@xml/network_security_config';
}
return manifestConfig;
});

return config;
}
Loading
Loading