feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
141
apps/mobile-rn/src/lib/secure-auth-storage.ts
Normal file
141
apps/mobile-rn/src/lib/secure-auth-storage.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
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,
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue