d3ro-voice/apps/mobile-rn/src/lib/secure-auth-storage.ts
2026-08-29 18:33:45 +09:00

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,
}