mattermost-mobile-upstream/app/init/credentials.ts
Nick Misasi f663d0f520
perf: skip Android Keystore listing on cold start (#10056)
* perf: skip Android Keystore listing on cold start

Open the app DB first and decrypt credentials only for known server URLs so logged-in launches no longer enumerate every generic-password service.

Co-authored-by: Cursor <cursoragent@cursor.com>

* perf: load server credentials in one parallel pass

Drop the extra preauth Set and second keystore probe so cold start decrypts each known URL once, and read active servers from the existing query.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: retry failed startup and cache credentials only after Keychain writes

Reset base initialization so a rejected launch can retry, and keep the in-memory credential cache aligned with successful secure-storage writes.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test: drop mock-harness init tests and unused session cache

The initialize() retry test only asserted mock wiring. Remove leftover
hasCachedCredentials/session_cache APIs from closed stack floors, and
keep credential tests that lock listing-skip and cache behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore: drop leftover launch profiler instrumentation

Cold-start timing marks were debug logInfo breadcrumbs, not part of the Keystore skip.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test: assert empty known-URL fallback still returns credentials

Listing-called alone would miss a path that enumerates Keystore then drops the results.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 10:58:22 +08:00

197 lines
6.3 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Platform} from 'react-native';
import * as KeyChain from 'react-native-keychain';
import DatabaseManager from '@database/manager';
import {logWarning} from '@utils/log';
import {getIOSAppGroupDetails} from '@utils/mattermost_managed';
// After initialize(), this is the logged-in DB-active set — not a live Keystore listing.
let cachedServerCredentials: ServerCredential[] | undefined;
export const clearCachedServerCredentials = () => {
cachedServerCredentials = undefined;
};
const replaceCachedCredential = (serverUrl: string, credential: ServerCredential | null) => {
if (cachedServerCredentials === undefined) {
return;
}
const rest = cachedServerCredentials.filter((c) => c.serverUrl !== serverUrl);
cachedServerCredentials = credential ? [...rest, credential] : rest;
};
const getAllKeychainServerUrls = async (): Promise<string[]> => {
if (Platform.OS === 'ios') {
return KeyChain.getAllInternetPasswordServers();
}
return KeyChain.getAllGenericPasswordServices();
};
export const getAllServerCredentials = async (knownServerUrls?: string[]): Promise<ServerCredential[]> => {
if (cachedServerCredentials !== undefined) {
return cachedServerCredentials.map((c) => ({...c}));
}
let serverUrls: string[];
// Empty knownServerUrls intentionally lists (wiped/fresh DB).
if (knownServerUrls?.length) {
serverUrls = knownServerUrls;
} else {
serverUrls = await getAllKeychainServerUrls();
}
const serverCredentials = (await Promise.all(
serverUrls.map((serverUrl) => getServerCredentials(serverUrl)),
)).filter((credential): credential is ServerCredential => Boolean(credential));
cachedServerCredentials = serverCredentials;
return serverCredentials.map((c) => ({...c}));
};
export const getActiveServerUrl = async () => {
let serverUrl = await DatabaseManager.getActiveServerUrl();
if (!serverUrl) {
const serverUrls = await getAllKeychainServerUrls();
serverUrl = serverUrls[0];
}
return serverUrl || undefined;
};
export const setServerCredentials = async (serverUrl: string, token: string, preauthSecret?: string) => {
if (!(serverUrl && token)) {
return;
}
try {
let accessGroup;
if (Platform.OS === 'ios') {
const appGroup = getIOSAppGroupDetails();
accessGroup = appGroup.appGroupIdentifier;
}
const options: KeyChain.SetOptions = {
accessGroup,
securityLevel: KeyChain.SECURITY_LEVEL.SECURE_SOFTWARE,
};
const stored = await KeyChain.setInternetCredentials(serverUrl, token, token, options);
if (stored === false) {
throw new Error('failed to store credentials');
}
if (preauthSecret) {
const storedSecret = await KeyChain.setGenericPassword('preshared_secret', preauthSecret, {
server: serverUrl,
...options,
});
if (storedSecret === false) {
throw new Error('failed to store preauth secret');
}
} else {
const reset = await KeyChain.resetGenericPassword({
server: serverUrl,
...options,
});
if (reset === false) {
throw new Error('failed to reset preauth secret');
}
}
replaceCachedCredential(serverUrl, {serverUrl, userId: token, token, preauthSecret});
} catch (e) {
logWarning('could not set credentials', e);
}
};
export const removeServerCredentials = async (serverUrl: string) => {
await KeyChain.resetInternetCredentials({server: serverUrl});
replaceCachedCredential(serverUrl, null);
};
export const removePreauthSecret = async (serverUrl: string) => {
try {
const reset = await KeyChain.resetGenericPassword({server: serverUrl});
if (reset === false) {
return;
}
const existing = cachedServerCredentials?.find((c) => c.serverUrl === serverUrl);
if (existing) {
existing.preauthSecret = undefined;
}
} catch (e) {
// Preauth secret might not exist, ignore errors
}
};
export const getPreauthSecret = async (serverUrl: string): Promise<string | undefined> => {
try {
const preauthCredentials = await KeyChain.getGenericPassword({
server: serverUrl,
});
const secret = preauthCredentials ? preauthCredentials.password : undefined;
return secret;
} catch (e) {
return undefined;
}
};
export const removeActiveServerCredentials = async () => {
const serverUrl = await getActiveServerUrl();
if (serverUrl) {
await removeServerCredentials(serverUrl);
}
};
export const getServerCredentials = async (serverUrl: string): Promise<ServerCredential|null> => {
const cached = cachedServerCredentials?.find((c) => c.serverUrl === serverUrl);
if (cached) {
return {...cached};
}
try {
// Get main credentials
const credentials = await KeyChain.getInternetCredentials(serverUrl);
if (!credentials) {
return null;
}
// TODO: Pre-Gekidou we were concatenating the deviceToken and the userId in
// credentials.username so we need to check the length of credentials.username.split(',').
// This check should be removed at some point. https://mattermost.atlassian.net/browse/MM-43483
const parts = credentials.username.split(',');
const userId = parts[parts.length - 1];
const token = credentials.password;
if (!token || token === 'undefined') {
return null;
}
// Get preauth secret separately
let preauthSecret: string | undefined;
try {
const preauthCredentials = await KeyChain.getGenericPassword({
server: serverUrl,
});
preauthSecret = preauthCredentials ? preauthCredentials.password : undefined;
} catch (e) {
// Preauth secret is optional, so ignore errors
preauthSecret = undefined;
}
return {
serverUrl,
userId,
token,
preauthSecret,
};
} catch (e) {
return null;
}
};