diff --git a/package-lock.json b/package-lock.json index b4abe8b9..7ef7f253 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 91ce5c3c..46afc0ed 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/lib/oauth.ts b/src/lib/oauth.ts index fea94ed2..cfc73137 100644 --- a/src/lib/oauth.ts +++ b/src/lib/oauth.ts @@ -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(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)}`; +} diff --git a/src/lib/totp.ts b/src/lib/totp.ts index 895d1af2..cd0d0257 100644 --- a/src/lib/totp.ts +++ b/src/lib/totp.ts @@ -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. diff --git a/src/lib/uuid.ts b/src/lib/uuid.ts index d0ce302d..c4aa4472 100644 --- a/src/lib/uuid.ts +++ b/src/lib/uuid.ts @@ -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); - }); } diff --git a/src/test-setup.ts b/src/test-setup.ts index 20840685..80e20dae 100644 --- a/src/test-setup.ts +++ b/src/test-setup.ts @@ -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: (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); + }), +}));