mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
fix: use a CSPRNG for OAuth state, TOTP secrets and UUIDs and validate handoff endpoints
This commit is contained in:
Generated
+13
@@ -18,6 +18,7 @@
|
|||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"expo": "~54.0.33",
|
"expo": "~54.0.33",
|
||||||
"expo-camera": "~17.0.10",
|
"expo-camera": "~17.0.10",
|
||||||
|
"expo-crypto": "~15.0.9",
|
||||||
"expo-document-picker": "~14.0.8",
|
"expo-document-picker": "~14.0.8",
|
||||||
"expo-file-system": "~19.0.21",
|
"expo-file-system": "~19.0.21",
|
||||||
"expo-image-picker": "~17.0.10",
|
"expo-image-picker": "~17.0.10",
|
||||||
@@ -5246,6 +5247,18 @@
|
|||||||
"react-native": "*"
|
"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": {
|
"node_modules/expo-document-picker": {
|
||||||
"version": "14.0.8",
|
"version": "14.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-14.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-14.0.8.tgz",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"expo": "~54.0.33",
|
"expo": "~54.0.33",
|
||||||
"expo-camera": "~17.0.10",
|
"expo-camera": "~17.0.10",
|
||||||
|
"expo-crypto": "~15.0.9",
|
||||||
"expo-document-picker": "~14.0.8",
|
"expo-document-picker": "~14.0.8",
|
||||||
"expo-file-system": "~19.0.21",
|
"expo-file-system": "~19.0.21",
|
||||||
"expo-image-picker": "~17.0.10",
|
"expo-image-picker": "~17.0.10",
|
||||||
|
|||||||
+79
-14
@@ -1,5 +1,6 @@
|
|||||||
import * as WebBrowser from 'expo-web-browser';
|
import * as WebBrowser from 'expo-web-browser';
|
||||||
import { secureFetch } from './client-cert';
|
import { secureFetch } from './client-cert';
|
||||||
|
import { randomHex } from './random';
|
||||||
|
|
||||||
// Webmail-mediated login. The app opens the webmail's normal login page with
|
// Webmail-mediated login. The app opens the webmail's normal login page with
|
||||||
// extra `mobile_redirect_uri` and `mobile_state` query params. The webmail
|
// 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 {
|
function randomState(): string {
|
||||||
const bytes = new Uint8Array(16);
|
return randomHex(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');
|
// Anything from the redirect fragment is attacker-influenced: another app can
|
||||||
return s;
|
// 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 {
|
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 flow = params.get('flow');
|
||||||
const serverUrl = params.get('server_url');
|
const serverUrl = params.get('server_url');
|
||||||
if (!serverUrl) throw new HandoffError('Sign-in response missing 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') {
|
if (flow === 'password') {
|
||||||
const username = params.get('username');
|
const username = params.get('username');
|
||||||
@@ -105,6 +148,9 @@ export async function runWebmailHandoff(webmailUrl: string): Promise<HandoffResu
|
|||||||
if (!accessToken || !tokenEndpoint || !clientId) {
|
if (!accessToken || !tokenEndpoint || !clientId) {
|
||||||
throw new HandoffError('Sign-in response missing OAuth tokens');
|
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 refreshToken = params.get('refresh_token') ?? undefined;
|
||||||
const expiresIn = params.get('expires_in');
|
const expiresIn = params.get('expires_in');
|
||||||
return {
|
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) {
|
if (!data.server_url || !data.access_token || !data.token_endpoint || !data.client_id) {
|
||||||
throw new HandoffError('Pairing response missing token material');
|
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 {
|
return {
|
||||||
flow: 'oauth',
|
flow: 'oauth',
|
||||||
@@ -228,16 +280,29 @@ export async function refreshOAuthAccessToken(tokens: OAuthTokens): Promise<OAut
|
|||||||
refresh_token: tokens.refreshToken!,
|
refresh_token: tokens.refreshToken!,
|
||||||
client_id: tokens.clientId,
|
client_id: tokens.clientId,
|
||||||
});
|
});
|
||||||
const response = await secureFetch(tokens.tokenEndpoint, {
|
let response: Response;
|
||||||
method: 'POST',
|
try {
|
||||||
headers: {
|
response = await secureFetch(tokens.tokenEndpoint, {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
method: 'POST',
|
||||||
Accept: 'application/json',
|
headers: {
|
||||||
},
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
body: body.toString(),
|
Accept: 'application/json',
|
||||||
});
|
},
|
||||||
|
body: body.toString(),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
throw new TransientRefreshError(
|
||||||
|
`Token endpoint unreachable: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!response.ok) {
|
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 {
|
const data = (await response.json()) as {
|
||||||
access_token?: string;
|
access_token?: string;
|
||||||
|
|||||||
@@ -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
@@ -5,46 +5,16 @@
|
|||||||
// (x:AccountPassword/set with otpAuth.otpCode), so we never run HMAC-SHA1 on
|
// (x:AccountPassword/set with otpAuth.otpCode), so we never run HMAC-SHA1 on
|
||||||
// device — that keeps this file dependency-free.
|
// device — that keeps this file dependency-free.
|
||||||
|
|
||||||
|
import { randomBytes } from './random';
|
||||||
|
|
||||||
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||||
|
|
||||||
// 20 bytes = 160 bits, matching the webmail's `new OTPAuth.Secret({ size: 20 })`
|
// 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.
|
// and the SHA-1 HMAC block expectation of virtually every authenticator app.
|
||||||
const SECRET_BYTES = 20;
|
const SECRET_BYTES = 20;
|
||||||
|
|
||||||
function randomBytes(length: number): Uint8Array {
|
// Secure random bytes come from lib/random.ts (expo-crypto CSPRNG); a
|
||||||
const bytes = new Uint8Array(length);
|
// long-lived 2FA secret must never come from Math.random.
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// RFC 4648 base32, no padding — the form authenticator apps expect in the
|
// RFC 4648 base32, no padding — the form authenticator apps expect in the
|
||||||
// `secret` query parameter.
|
// `secret` query parameter.
|
||||||
|
|||||||
+15
-17
@@ -1,20 +1,18 @@
|
|||||||
// RFC 4122 v4 UUID generator. Prefers the platform crypto.randomUUID when
|
// RFC 4122 v4 UUID generator backed by the platform CSPRNG (expo-crypto, or
|
||||||
// available (Hermes/React Native 0.81 exposes it), falling back to a
|
// the web crypto global off-device). Used for contact UIDs (#644), outbox
|
||||||
// Math.random implementation. These IDs are only used as temporary client-side
|
// entry ids and client-generated Message-IDs, so the old Math.random fallback
|
||||||
// keys (e.g. parsed-vCard import rows) before the server assigns real ids, so
|
// only remains as a last resort for ephemeral client-side keys when no secure
|
||||||
// the Math.random fallback's weaker entropy is acceptable.
|
// source exists at all.
|
||||||
|
import { secureRandomUUID } from './random';
|
||||||
|
|
||||||
export function generateUUID(): string {
|
export function generateUUID(): string {
|
||||||
const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
|
try {
|
||||||
if (c?.randomUUID) {
|
return secureRandomUUID();
|
||||||
try {
|
} catch {
|
||||||
return c.randomUUID();
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (ch) => {
|
||||||
} catch {
|
const r = (Math.random() * 16) | 0;
|
||||||
// fall through to the manual implementation
|
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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,3 +83,18 @@ vi.mock('@react-native-community/netinfo', () => ({
|
|||||||
fetch: vi.fn(async () => ({ isConnected: true, isInternetReachable: true })),
|
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);
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user