141 lines
4.7 KiB
TypeScript
141 lines
4.7 KiB
TypeScript
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<string | null>
|
|
setItem(key: string, value: string): Promise<void>
|
|
removeItem(key: string): Promise<void>
|
|
}
|
|
|
|
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<void> {
|
|
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<void> {
|
|
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<string | null> {
|
|
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<void> {
|
|
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<void> {
|
|
const key = normalizedStorageKey(keyValue)
|
|
const service = serviceForKey(key)
|
|
await resetService(service)
|
|
await removeLegacyValue(key)
|
|
},
|
|
}
|
|
|
|
export async function clearAllSecureAuthStorage(): Promise<void> {
|
|
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,
|
|
}
|