import { Platform } from 'react-native' import AsyncStorage from '@react-native-async-storage/async-storage' import * as Keychain from 'react-native-keychain' import { sha256 } from '@noble/hashes/sha256' import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils' const AUTH_SERVICE_PREFIX = 'com.d3ro.voice.supabase-auth.v1.' const LEGACY_SUPABASE_KEY_PREFIX = 'sb-' export interface SecureAuthStorageAdapter { getItem(key: string): Promise setItem(key: string, value: string): Promise removeItem(key: string): Promise } function normalizedStorageKey(key: string): string { if (typeof key !== 'string' || key.length < 1 || key.length > 512 || key.includes('\u0000')) { throw new Error('secure_auth_storage_invalid_key') } return key } function serviceForKey(key: string): string { const digest = bytesToHex(sha256(utf8ToBytes(normalizedStorageKey(key)))) return `${AUTH_SERVICE_PREFIX}${digest}` } function setOptions(service: string): Keychain.SetOptions { return { service, accessible: Keychain.ACCESSIBLE.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY, ...(Platform.OS === 'android' ? { securityLevel: Keychain.SECURITY_LEVEL.SECURE_SOFTWARE, storage: Keychain.STORAGE_TYPE.AES_GCM_NO_AUTH, } : {}), } } async function removeLegacyValue(key: string): Promise { await AsyncStorage.removeItem(key) } function secureCredentials(value: unknown): false | { username: string; password: string } { if (value === false) return false if ( typeof value !== 'object' || value === null || typeof (value as { username?: unknown }).username !== 'string' || typeof (value as { password?: unknown }).password !== 'string' ) { throw new Error('secure_auth_storage_invalid_response') } return value as { username: string; password: string } } function assertStored(value: unknown): void { if (typeof value !== 'object' || value === null) { throw new Error('secure_auth_storage_write_failed') } } async function resetService(service: string): Promise { const reset = await Keychain.resetGenericPassword({ service }) if (reset !== true) throw new Error('secure_auth_storage_reset_failed') } export const secureAuthStorage: SecureAuthStorageAdapter = { async getItem(keyValue: string): Promise { const key = normalizedStorageKey(keyValue) const service = serviceForKey(key) const credentials = secureCredentials(await Keychain.getGenericPassword({ service })) if (credentials !== false) { if (credentials.username !== key) { await resetService(service) throw new Error('secure_auth_storage_identity_mismatch') } // Old releases persisted the same Supabase session in AsyncStorage. // Refuse to return a usable token until any plaintext duplicate is gone. await removeLegacyValue(key) return credentials.password } const legacyValue = await AsyncStorage.getItem(key) if (legacyValue === null) return null const stored = await Keychain.setGenericPassword( key, legacyValue, setOptions(service), ) assertStored(stored) // Migration is complete only after the plaintext copy is removed. A // removal failure is deliberately surfaced instead of silently falling // back to an insecure session. await removeLegacyValue(key) return legacyValue }, async setItem(keyValue: string, value: string): Promise { const key = normalizedStorageKey(keyValue) if (typeof value !== 'string') throw new Error('secure_auth_storage_invalid_value') const service = serviceForKey(key) const stored = await Keychain.setGenericPassword(key, value, setOptions(service)) assertStored(stored) await removeLegacyValue(key) }, async removeItem(keyValue: string): Promise { const key = normalizedStorageKey(keyValue) const service = serviceForKey(key) await resetService(service) await removeLegacyValue(key) }, } export async function clearAllSecureAuthStorage(): Promise { const [services, legacyKeys] = await Promise.all([ Keychain.getAllGenericPasswordServices(), AsyncStorage.getAllKeys(), ]) if (!Array.isArray(services) || services.some((service) => typeof service !== 'string')) { throw new Error('secure_auth_storage_invalid_response') } const secureServices = services.filter((service) => service.startsWith(AUTH_SERVICE_PREFIX)) const plaintextKeys = legacyKeys.filter((key) => key.startsWith(LEGACY_SUPABASE_KEY_PREFIX)) for (const service of secureServices) { await resetService(service) } if (plaintextKeys.length > 0) await AsyncStorage.multiRemove(plaintextKeys) } export const secureAuthStorageInternals = { AUTH_SERVICE_PREFIX, serviceForKey, }