fix: use a CSPRNG for OAuth state, TOTP secrets and UUIDs and validate handoff endpoints

This commit is contained in:
Linus Rath
2026-08-29 17:42:31 +02:00
parent 161baf1708
commit 2c0dbd15d1
7 changed files with 217 additions and 65 deletions
+13
View File
@@ -18,6 +18,7 @@
"date-fns": "^4.1.0",
"expo": "~54.0.33",
"expo-camera": "~17.0.10",
"expo-crypto": "~15.0.9",
"expo-document-picker": "~14.0.8",
"expo-file-system": "~19.0.21",
"expo-image-picker": "~17.0.10",
@@ -5246,6 +5247,18 @@
"react-native": "*"
}
},
"node_modules/expo-crypto": {
"version": "15.0.9",
"resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-15.0.9.tgz",
"integrity": "sha512-SNWKa2fXx7v9gkp1h/7nqXY5XN7qgNDn3yRc2aO0gWGbeMbvob/haMxxsPFe9f51aqH5NjNCqHf2kvLhvAd8KQ==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.0"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-document-picker": {
"version": "14.0.8",
"resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-14.0.8.tgz",
+1
View File
@@ -24,6 +24,7 @@
"date-fns": "^4.1.0",
"expo": "~54.0.33",
"expo-camera": "~17.0.10",
"expo-crypto": "~15.0.9",
"expo-document-picker": "~14.0.8",
"expo-file-system": "~19.0.21",
"expo-image-picker": "~17.0.10",
+79 -14
View File
@@ -1,5 +1,6 @@
import * as WebBrowser from 'expo-web-browser';
import { secureFetch } from './client-cert';
import { randomHex } from './random';
// Webmail-mediated login. The app opens the webmail's normal login page with
// extra `mobile_redirect_uri` and `mobile_state` query params. The webmail
@@ -38,12 +39,51 @@ export class HandoffCancelledError extends HandoffError {
}
}
// The token endpoint could not be reached, or answered 5xx/429: the refresh
// token is still good, the caller must keep the account and retry later
// (webmail 1.7.6 "keep the session when the auth server is briefly
// unreachable"). Only a definitive 400/401/403 is a `HandoffError`.
export class TransientRefreshError extends Error {
constructor(message: string) {
super(message);
this.name = 'TransientRefreshError';
}
}
// The state is the only guard against a forged `bulwarkmobile://` redirect
// delivering foreign credentials, so it comes from the platform CSPRNG.
function randomState(): string {
const bytes = new Uint8Array(16);
for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
let s = '';
for (const b of bytes) s += b.toString(16).padStart(2, '0');
return s;
return randomHex(16);
}
// Anything from the redirect fragment is attacker-influenced: another app can
// register the same custom scheme. Only accept https endpoints, and only a
// token endpoint that lives on the mail server's own host (or the webmail's).
function isHttpsUrl(value: string | null): value is string {
return !!value && /^https:\/\/[^/?#\s]+/i.test(value);
}
function hostOf(url: string): string {
const m = url.match(/^[a-z][a-z0-9+.-]*:\/\/([^/?#]+)/i);
return (m ? m[1] : '').toLowerCase().replace(/:\d+$/, '');
}
// Dev builds against a local Stalwart still need plain http on loopback.
export function isLoopbackHttp(value: string): boolean {
const host = hostOf(value);
return /^http:\/\//i.test(value) && (host === 'localhost' || host === '127.0.0.1' || host === '10.0.2.2');
}
export function isAcceptableTokenEndpoint(
tokenEndpoint: string | null,
serverUrl: string,
webmailUrl?: string,
): tokenEndpoint is string {
if (!isHttpsUrl(tokenEndpoint) && !(tokenEndpoint && isLoopbackHttp(tokenEndpoint))) return false;
const host = hostOf(tokenEndpoint as string);
if (!host) return false;
const allowed = [hostOf(serverUrl), webmailUrl ? hostOf(webmailUrl) : ''].filter(Boolean);
return allowed.some((h) => host === h || host.endsWith(`.${h}`) || h.endsWith(`.${host}`));
}
function buildHandoffUrl(webmailUrl: string, state: string): string {
@@ -88,6 +128,9 @@ export async function runWebmailHandoff(webmailUrl: string): Promise<HandoffResu
const flow = params.get('flow');
const serverUrl = params.get('server_url');
if (!serverUrl) throw new HandoffError('Sign-in response missing server URL');
if (!isHttpsUrl(serverUrl) && !isLoopbackHttp(serverUrl)) {
throw new HandoffError('Sign-in response server URL must use https');
}
if (flow === 'password') {
const username = params.get('username');
@@ -105,6 +148,9 @@ export async function runWebmailHandoff(webmailUrl: string): Promise<HandoffResu
if (!accessToken || !tokenEndpoint || !clientId) {
throw new HandoffError('Sign-in response missing OAuth tokens');
}
if (!isAcceptableTokenEndpoint(tokenEndpoint, serverUrl, webmailUrl)) {
throw new HandoffError('Sign-in response token endpoint is not trusted');
}
const refreshToken = params.get('refresh_token') ?? undefined;
const expiresIn = params.get('expires_in');
return {
@@ -193,6 +239,12 @@ export async function redeemPairingCode(webmailUrl: string, code: string): Promi
if (!data.server_url || !data.access_token || !data.token_endpoint || !data.client_id) {
throw new HandoffError('Pairing response missing token material');
}
if (!isHttpsUrl(data.server_url) && !isLoopbackHttp(data.server_url)) {
throw new HandoffError('Pairing response server URL must use https');
}
if (!isAcceptableTokenEndpoint(data.token_endpoint, data.server_url, base)) {
throw new HandoffError('Pairing response token endpoint is not trusted');
}
return {
flow: 'oauth',
@@ -228,16 +280,29 @@ export async function refreshOAuthAccessToken(tokens: OAuthTokens): Promise<OAut
refresh_token: tokens.refreshToken!,
client_id: tokens.clientId,
});
const response = await secureFetch(tokens.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
},
body: body.toString(),
});
let response: Response;
try {
response = await secureFetch(tokens.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
},
body: body.toString(),
});
} catch (err) {
throw new TransientRefreshError(
`Token endpoint unreachable: ${err instanceof Error ? err.message : String(err)}`,
);
}
if (!response.ok) {
throw new HandoffError(`Token refresh failed: ${response.status}`);
// 400/401/403 mean the refresh token is definitively dead. Anything
// else (429, 5xx, a proxy error page) is the auth server having a
// bad moment and must not evict the account.
if (response.status === 400 || response.status === 401 || response.status === 403) {
throw new HandoffError(`Token refresh failed: ${response.status}`);
}
throw new TransientRefreshError(`Token refresh failed: ${response.status}`);
}
const data = (await response.json()) as {
access_token?: string;
+90
View File
@@ -0,0 +1,90 @@
// Cryptographically strong random bytes for OAuth `state`, PKCE verifiers,
// TOTP secrets and UUIDs. Hermes exposes no `crypto` global by default, so
// the previous helpers quietly fell back to Math.random — fine for a cache
// key, not for a long-lived 2FA secret or the only guard against a forged
// `bulwarkmobile://` redirect. `expo-crypto` is the platform CSPRNG; the web
// / test fallbacks below keep the module usable off-device.
let expoCrypto: { getRandomValues?: <T extends ArrayBufferView>(a: T) => T; getRandomBytes?: (n: number) => Uint8Array; randomUUID?: () => string } | null = null;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
expoCrypto = require('expo-crypto');
} catch {
expoCrypto = null;
}
export function randomBytes(length: number): Uint8Array {
const bytes = new Uint8Array(length);
if (expoCrypto?.getRandomValues) {
try {
expoCrypto.getRandomValues(bytes);
return bytes;
} catch {
// fall through
}
}
if (expoCrypto?.getRandomBytes) {
try {
const out = expoCrypto.getRandomBytes(length);
if (out?.length === length) return out;
} catch {
// fall through
}
}
const c = (globalThis as {
crypto?: { getRandomValues?: (a: Uint8Array) => Uint8Array; randomUUID?: () => string };
}).crypto;
if (c?.getRandomValues) {
try {
c.getRandomValues(bytes);
return bytes;
} catch {
// fall through
}
}
if (c?.randomUUID) {
try {
let hex = '';
while (hex.length < length * 2) hex += c.randomUUID().replace(/-/g, '');
for (let i = 0; i < length; i++) bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
return bytes;
} catch {
// fall through
}
}
throw new Error('No secure random source available');
}
/** Lowercase hex of `length` random bytes. */
export function randomHex(length: number): string {
let s = '';
for (const b of randomBytes(length)) s += b.toString(16).padStart(2, '0');
return s;
}
/** RFC 4648 §5 base64url without padding. */
export function base64UrlEncode(bytes: Uint8Array): string {
let bin = '';
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
const b64 = typeof btoa === 'function' ? btoa(bin) : Buffer.from(bin, 'binary').toString('base64');
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
/** RFC 4122 v4 UUID from the secure source. */
export function secureRandomUUID(): string {
if (expoCrypto?.randomUUID) {
try {
return expoCrypto.randomUUID();
} catch {
// fall through
}
}
const b = randomBytes(16);
b[6] = (b[6] & 0x0f) | 0x40;
b[8] = (b[8] & 0x3f) | 0x80;
const hex = Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
+4 -34
View File
@@ -5,46 +5,16 @@
// (x:AccountPassword/set with otpAuth.otpCode), so we never run HMAC-SHA1 on
// device — that keeps this file dependency-free.
import { randomBytes } from './random';
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
// 20 bytes = 160 bits, matching the webmail's `new OTPAuth.Secret({ size: 20 })`
// and the SHA-1 HMAC block expectation of virtually every authenticator app.
const SECRET_BYTES = 20;
function randomBytes(length: number): Uint8Array {
const bytes = new Uint8Array(length);
const c = (globalThis as {
crypto?: { getRandomValues?: (a: Uint8Array) => Uint8Array; randomUUID?: () => string };
}).crypto;
// Best: a real CSPRNG (web / a polyfilled RN).
if (c?.getRandomValues) {
try {
c.getRandomValues(bytes);
return bytes;
} catch {
// fall through
}
}
// Good enough: derive bytes from crypto.randomUUID, which Hermes exposes
// even when getRandomValues isn't polyfilled (~122 bits of entropy each).
if (c?.randomUUID) {
try {
let hex = '';
while (hex.length < length * 2) hex += c.randomUUID().replace(/-/g, '');
for (let i = 0; i < length; i++) bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
return bytes;
} catch {
// fall through
}
}
// Last resort: Math.random. Same trade-off the rest of the app accepts in
// lib/uuid.ts and lib/oauth.ts; the secret is one-off and server-validated.
for (let i = 0; i < length; i++) bytes[i] = Math.floor(Math.random() * 256);
return bytes;
}
// Secure random bytes come from lib/random.ts (expo-crypto CSPRNG); a
// long-lived 2FA secret must never come from Math.random.
// RFC 4648 base32, no padding — the form authenticator apps expect in the
// `secret` query parameter.
+15 -17
View File
@@ -1,20 +1,18 @@
// RFC 4122 v4 UUID generator. Prefers the platform crypto.randomUUID when
// available (Hermes/React Native 0.81 exposes it), falling back to a
// Math.random implementation. These IDs are only used as temporary client-side
// keys (e.g. parsed-vCard import rows) before the server assigns real ids, so
// the Math.random fallback's weaker entropy is acceptable.
// RFC 4122 v4 UUID generator backed by the platform CSPRNG (expo-crypto, or
// the web crypto global off-device). Used for contact UIDs (#644), outbox
// entry ids and client-generated Message-IDs, so the old Math.random fallback
// only remains as a last resort for ephemeral client-side keys when no secure
// source exists at all.
import { secureRandomUUID } from './random';
export function generateUUID(): string {
const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
if (c?.randomUUID) {
try {
return c.randomUUID();
} catch {
// fall through to the manual implementation
}
try {
return secureRandomUUID();
} catch {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (ch) => {
const r = (Math.random() * 16) | 0;
const v = ch === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (ch) => {
const r = (Math.random() * 16) | 0;
const v = ch === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
+15
View File
@@ -83,3 +83,18 @@ vi.mock('@react-native-community/netinfo', () => ({
fetch: vi.fn(async () => ({ isConnected: true, isInternetReachable: true })),
},
}));
// expo-crypto is a native module; lib/random.ts falls back to node's
// webcrypto when the import fails, so just make the import resolvable.
vi.mock('expo-crypto', () => ({
getRandomValues: <T extends ArrayBufferView>(a: T): T => {
const bytes = new Uint8Array(a.buffer, a.byteOffset, a.byteLength);
for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
return a;
},
randomUUID: () => 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (ch) => {
const r = (Math.random() * 16) | 0;
const v = ch === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
}),
}));