diff --git a/packages/@magic-sdk/provider/src/core/view-controller.ts b/packages/@magic-sdk/provider/src/core/view-controller.ts index 84d63264b..f6310c2be 100644 --- a/packages/@magic-sdk/provider/src/core/view-controller.ts +++ b/packages/@magic-sdk/provider/src/core/view-controller.ts @@ -9,13 +9,15 @@ import { import { JsonRpcResponse } from './json-rpc'; import { createPromise } from '../util/promise-tools'; import { MagicSDKWarning, createModalNotReadyError } from './sdk-exceptions'; -import { clearDeviceShares, encryptAndPersistDeviceShare } from '../util/device-share-web-crypto'; import { - createMagicRequest, - persistMagicEventRefreshToken, - standardizeResponse, - debounce, -} from '../util/view-controller-utils'; + clearDeviceShares, + encryptAndPersistDeviceShare, + getDecryptedDeviceShare, +} from '../util/device-share-web-crypto'; +import { standardizeResponse, debounce, StandardizedMagicRequest } from '../util/view-controller-utils'; +import { setItem, getItem } from '../util/storage'; +import { SDKEnvironment } from './sdk-environment'; +import { createJwt } from '../util/web-crypto'; interface RemoveEventListenerFunction { (): void; @@ -102,7 +104,7 @@ export abstract class ViewController { const batchData: JsonRpcResponse[] = []; const batchIds = Array.isArray(payload) ? payload.map(p => p.id) : []; - const msg = await createMagicRequest(`${msgType}-${this.parameters}`, payload, this.networkHash); + const msg = await this.createMagicRequest(`${msgType}-${this.parameters}`, payload, this.networkHash); await this._post(msg); @@ -111,7 +113,7 @@ export abstract class ViewController { */ const acknowledgeResponse = (removeEventListener: RemoveEventListenerFunction) => (event: MagicMessageEvent) => { const { id, response } = standardizeResponse(payload, event); - persistMagicEventRefreshToken(event); + this.persistMagicEventRefreshToken(event); if (response?.payload.error?.message === 'User denied account access.') { clearDeviceShares(); } else if (event.data.deviceShare) { @@ -249,4 +251,65 @@ export abstract class ViewController { this.heartbeatIntervalTimer = null; } } + + async persistMagicEventRefreshToken(event: MagicMessageEvent) { + if (!event.data.rt) { + return; + } + + await setItem('rt', event.data.rt); + } + + async createMagicRequest( + msgType: string, + payload: JsonRpcRequestPayload | JsonRpcRequestPayload[], + networkHash: string, + ) { + const request: StandardizedMagicRequest = { msgType, payload }; + + const rt = await this.getRT(); + const jwt = await this.getJWT(); + const decryptedDeviceShare = await this.getDecryptedDeviceShare(networkHash); + + if (jwt) { + request.jwt = jwt; + } + + if (jwt && rt) { + request.rt = rt; + } + + // Grab the device share if it exists for the network + if (decryptedDeviceShare) { + request.deviceShare = decryptedDeviceShare; + } + + return request; + } + + async getJWT(): Promise { + // only for webcrypto platforms + if (SDKEnvironment.platform === 'web') { + try { + const jwtFromStorage = await getItem('jwt'); + if (jwtFromStorage) return jwtFromStorage; + + const newJwt = await createJwt(); + return newJwt; + } catch (e) { + console.error('webcrypto error', e); + return null; + } + } else { + return null; + } + } + + async getRT(): Promise { + return await getItem('rt'); + } + + async getDecryptedDeviceShare(networkHash: string) { + return await getDecryptedDeviceShare(networkHash); + } } diff --git a/packages/@magic-sdk/provider/src/util/view-controller-utils.ts b/packages/@magic-sdk/provider/src/util/view-controller-utils.ts index c7888af4e..b4c5cf5d0 100644 --- a/packages/@magic-sdk/provider/src/util/view-controller-utils.ts +++ b/packages/@magic-sdk/provider/src/util/view-controller-utils.ts @@ -10,7 +10,7 @@ interface StandardizedResponse { response?: JsonRpcResponse; } -interface StandardizedMagicRequest { +export interface StandardizedMagicRequest { msgType: string; payload: JsonRpcRequestPayload | JsonRpcRequestPayload[]; jwt?: string; @@ -53,49 +53,6 @@ export function standardizeResponse( return {}; } -export async function createMagicRequest( - msgType: string, - payload: JsonRpcRequestPayload | JsonRpcRequestPayload[], - networkHash: string, -) { - const rt = await getItem('rt'); - let jwt; - - // only for webcrypto platforms - if (SDKEnvironment.platform === 'web') { - try { - jwt = (await getItem('jwt')) ?? (await createJwt()); - } catch (e) { - console.error('webcrypto error', e); - } - } - - const request: StandardizedMagicRequest = { msgType, payload }; - - if (jwt) { - request.jwt = jwt; - } - if (jwt && rt) { - request.rt = rt; - } - - // Grab the device share if it exists for the network - const decryptedDeviceShare = await getDecryptedDeviceShare(networkHash); - if (decryptedDeviceShare) { - request.deviceShare = decryptedDeviceShare; - } - - return request; -} - -export async function persistMagicEventRefreshToken(event: MagicMessageEvent) { - if (!event.data.rt) { - return; - } - - await setItem('rt', event.data.rt); -} - export function debounce void>(func: T, delay: number) { let timeoutId: ReturnType | null = null; diff --git a/packages/@magic-sdk/react-native-expo/app.json b/packages/@magic-sdk/react-native-expo/app.json new file mode 100644 index 000000000..1dd87701a --- /dev/null +++ b/packages/@magic-sdk/react-native-expo/app.json @@ -0,0 +1,5 @@ +{ + "plugins": [ + "expo-secure-store" + ] +} diff --git a/packages/@magic-sdk/react-native-expo/package.json b/packages/@magic-sdk/react-native-expo/package.json index 361fab76b..5bf17bf31 100644 --- a/packages/@magic-sdk/react-native-expo/package.json +++ b/packages/@magic-sdk/react-native-expo/package.json @@ -28,6 +28,7 @@ "lodash": "^4.17.19", "process": "~0.11.10", "react-native-event-listeners": "^1.0.7", + "react-native-uuid": "^2.0.3", "tslib": "^2.0.3", "whatwg-url": "~8.1.0" }, @@ -39,9 +40,11 @@ "babel-preset-expo": "^12.0.11", "expo": "^52.0.44", "expo-modules-core": "^2.2.3", + "expo-secure-store": "~14.0.1", "jest-expo": "~52.0.6", "react": "^19.1.0", "react-native": "^0.78.2", + "react-native-device-crypto": "^0.1.7", "react-native-safe-area-context": "^5.3.0", "react-native-webview": "^13.13.5", "react-test-renderer": "^19.1.0", @@ -50,8 +53,10 @@ "peerDependencies": { "@react-native-community/netinfo": ">=9.0.0", "expo": "*", + "expo-secure-store": "~14.0.1", "react": ">=17", "react-native": ">=0.60", + "react-native-device-crypto": "^0.1.7", "react-native-safe-area-context": ">=4.4.1", "react-native-webview": ">=12.4.0" }, diff --git a/packages/@magic-sdk/react-native-expo/src/native-crypto/check-native-modules.ts b/packages/@magic-sdk/react-native-expo/src/native-crypto/check-native-modules.ts new file mode 100644 index 000000000..6de63f10b --- /dev/null +++ b/packages/@magic-sdk/react-native-expo/src/native-crypto/check-native-modules.ts @@ -0,0 +1,98 @@ +import { NativeModules, Platform } from 'react-native'; +import { requireOptionalNativeModule } from 'expo-modules-core'; + +interface NativeModuleCheck { + name: string; + nativeModuleName: string; + packageName: string; + isExpoModule: boolean; +} + +const REQUIRED_NATIVE_MODULES: NativeModuleCheck[] = [ + { + name: 'expo-secure-store', + nativeModuleName: 'ExpoSecureStore', + packageName: 'expo-secure-store', + isExpoModule: true, + }, + { + name: 'react-native-device-crypto', + nativeModuleName: 'DeviceCrypto', + packageName: 'react-native-device-crypto', + isExpoModule: false, + }, +]; + +// Track if warning has been shown to avoid spamming +let hasWarned = false; + +/** + * Checks if all required native modules are properly installed and linked. + * Logs a warning if any native module is missing. + * + * Note: Some native modules (like react-native-device-crypto) require hardware + * features (e.g., Secure Enclave) that are not available in simulators/emulators. + * The SDK will continue to work but certain security features may be degraded. + */ +export const checkNativeModules = (): void => { + if (hasWarned) return; + + const missingModules: NativeModuleCheck[] = []; + + for (const module of REQUIRED_NATIVE_MODULES) { + if (module.isExpoModule) { + if (!requireOptionalNativeModule(module.nativeModuleName)) { + missingModules.push(module); + } + } else { + if (!NativeModules[module.nativeModuleName]) { + missingModules.push(module); + } + } + } + + if (missingModules.length > 0) { + hasWarned = true; + + const platform = Platform.OS; + const moduleList = missingModules.map(m => ` - ${m.packageName}`).join('\n'); + const installCommands = missingModules.map(m => `npx expo install ${m.packageName}`).join('\n'); + + const iosInstructions = + platform === 'ios' + ? ` +For iOS, run: + npx expo run:ios +` + : ''; + + const androidInstructions = + platform === 'android' + ? ` +For Android, run: + npx expo run:android +` + : ''; + + console.warn( + `@magic-sdk/react-native-expo: Missing native modules detected. + +The following native modules are not linked: +${moduleList} + +The SDK will continue to work, but some security features may not function properly. + +Note: If you're running in a simulator/emulator, some native modules (like react-native-device-crypto) +require hardware features (Secure Enclave) that are only available on physical devices. + +If you're on a physical device and see this warning, please ensure the packages are installed and linked: + +1. Install the missing packages: +${installCommands} + +2. Rebuild your app: +${iosInstructions}${androidInstructions} +`, + ); + } +}; diff --git a/packages/@magic-sdk/react-native-expo/src/native-crypto/constants/index.ts b/packages/@magic-sdk/react-native-expo/src/native-crypto/constants/index.ts new file mode 100644 index 000000000..1380f90ef --- /dev/null +++ b/packages/@magic-sdk/react-native-expo/src/native-crypto/constants/index.ts @@ -0,0 +1,2 @@ +export const ALG = 'ES256'; +export const TYP = 'dpop+jwt'; diff --git a/packages/@magic-sdk/react-native-expo/src/native-crypto/dpop.ts b/packages/@magic-sdk/react-native-expo/src/native-crypto/dpop.ts new file mode 100644 index 000000000..8c863e368 --- /dev/null +++ b/packages/@magic-sdk/react-native-expo/src/native-crypto/dpop.ts @@ -0,0 +1,78 @@ +import uuid from 'react-native-uuid'; +import { toBase64Url } from './utils/uint8'; +import { spkiToJwk } from './utils/jwk'; +import { ALG, TYP } from './constants'; +import { derToRawSignature } from './utils/der'; +import { DpopClaims, DpopHeader } from './types'; +import DeviceCrypto, { AccessLevel } from 'react-native-device-crypto'; +import { getKeyAlias } from './utils/key-alias'; + +const KEY_ALIAS = getKeyAlias('dpop'); + +/** + * Generates the DPoP proof compatible with the Python backend. + * Handles key creation (if missing), JWK construction, and signing. + */ +export const getDpop = async (): Promise => { + try { + // 1. Get or Create Key in Secure Enclave + // We strictly disable authentication to avoid biometric prompts + const publicKey = await DeviceCrypto.getOrCreateAsymmetricKey(KEY_ALIAS, { + accessLevel: AccessLevel.ALWAYS, // Key is always accessible in this device + invalidateOnNewBiometry: false, + }); + + // 2. Prepare Public Key as JWK + // Toaster backend expects JWK in the header + const publicJwk = spkiToJwk(publicKey); + + // 3. Construct Payload + const now = Math.floor(Date.now() / 1000); + const claims: DpopClaims = { + iat: now, + jti: uuid.v4(), + }; + + const header: DpopHeader = { + typ: TYP, + alg: ALG, + jwk: publicJwk, + }; + + // 4. Prepare Signing Input + const headerB64 = toBase64Url(JSON.stringify(header)); + const payloadB64 = toBase64Url(JSON.stringify(claims)); + const signingInput = `${headerB64}.${payloadB64}`; + + // 5. Sign Data + // DeviceCrypto returns a Base64 signature. + const signatureBase64 = await DeviceCrypto.sign(KEY_ALIAS, signingInput, { + // Biometry prompts should not be fired since the key is always accessible in this device + biometryTitle: 'Sign DPoP', + biometrySubTitle: 'Sign DPoP', + biometryDescription: 'Sign DPoP', + }); + + // 6. Convert Signature (Toaster expects Raw R|S) + const signatureB64 = derToRawSignature(signatureBase64); + + return `${signingInput}.${signatureB64}`; + } catch (error) { + console.error('DPoP Generation Error:', error); + return null; + } +}; + +/** + * Removes the keys from the Secure Enclave + * Returns true if the key was deleted successfully, false otherwise. + * @returns {Promise} True if the key was deleted successfully, false otherwise. + */ +export const deleteDpop = async (): Promise => { + try { + return await DeviceCrypto.deleteKey(KEY_ALIAS); + } catch (error) { + console.error('DPoP Deletion Error:', error); + return false; + } +}; diff --git a/packages/@magic-sdk/react-native-expo/src/native-crypto/keychain.ts b/packages/@magic-sdk/react-native-expo/src/native-crypto/keychain.ts new file mode 100644 index 000000000..3f0ec88e7 --- /dev/null +++ b/packages/@magic-sdk/react-native-expo/src/native-crypto/keychain.ts @@ -0,0 +1,70 @@ +import * as SecureStore from 'expo-secure-store'; +import { getKeyAlias } from './utils/key-alias'; + +const SERVICE = getKeyAlias('refreshTokenService'); +const KEY = getKeyAlias('refreshToken'); + +let cachedRefreshToken: string | null = null; + +/** + * Stores the refresh token securely using Expo SecureStore. + * Uses 'WHEN_UNLOCKED_THIS_DEVICE_ONLY' for security similar to the original keychain accessible level. + */ +export const setRefreshTokenInSecureStore = async (rt: string): Promise => { + // Skip write if token hasn't changed + if (cachedRefreshToken === rt) { + return true; + } + + try { + await SecureStore.setItemAsync(KEY, rt, { + keychainService: SERVICE, // Used on iOS to scope the item + keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY, + }); + + cachedRefreshToken = rt; // Update cache on successful write + return true; + } catch (error) { + console.error('Failed to set refresh token in secure store', error); + return false; + } +}; + +/** + * Retrieves the refresh token from secure storage. + * Returns the cached value if available to improve performance. + */ +export const getRefreshTokenInSecureStore = async (): Promise => { + // Return cached value if available + if (cachedRefreshToken !== null) { + return cachedRefreshToken; + } + + try { + const token = await SecureStore.getItemAsync(KEY, { + keychainService: SERVICE, + }); + + if (!token) return null; + + cachedRefreshToken = token; + return cachedRefreshToken; + } catch (error) { + console.error('Failed to get refresh token in secure store', error); + return null; + } +}; + +/** + * Removes the refresh token from secure storage and clears the local cache. + */ +export const removeRefreshTokenInSecureStore = async (): Promise => { + try { + cachedRefreshToken = null; // Clear cache immediately + await SecureStore.deleteItemAsync(KEY, { + keychainService: SERVICE, + }); + } catch (error) { + console.error('Failed to remove refresh token in secure store', error); + } +}; diff --git a/packages/@magic-sdk/react-native-expo/src/native-crypto/types/index.ts b/packages/@magic-sdk/react-native-expo/src/native-crypto/types/index.ts new file mode 100644 index 000000000..4069afd27 --- /dev/null +++ b/packages/@magic-sdk/react-native-expo/src/native-crypto/types/index.ts @@ -0,0 +1,20 @@ +export interface DpopHeader { + typ: 'dpop+jwt'; + alg: 'ES256'; + jwk: JsonWebKey; +} + +export interface DpopClaims { + iat: number; + jti: string; + htm?: string; + htu?: string; +} + +export interface JsonWebKey { + kty: string; + crv: string; + x: string; + y: string; + ext?: boolean; +} diff --git a/packages/@magic-sdk/react-native-expo/src/native-crypto/utils/der.ts b/packages/@magic-sdk/react-native-expo/src/native-crypto/utils/der.ts new file mode 100644 index 000000000..98b296f12 --- /dev/null +++ b/packages/@magic-sdk/react-native-expo/src/native-crypto/utils/der.ts @@ -0,0 +1,46 @@ +import { base64ToUint8Array, toBase64Url } from './uint8'; + +/** + * Converts a DER encoded signature (ASN.1) to a Raw R|S signature (64 bytes). + * Device Crypto returns DER; Toaster backend expects Raw P1363. + */ +export const derToRawSignature = (derBase64: string): string => { + const der = base64ToUint8Array(derBase64); + + // DER Structure: 0x30 | total_len | 0x02 | r_len | r_bytes | 0x02 | s_len | s_bytes + let offset = 2; // Skip Sequence Tag (0x30) and Total Length + + // --- Read R --- + if (der[offset] !== 0x02) throw new Error('Invalid DER: R tag missing'); + offset++; // skip tag + const rLen = der[offset++]; // read length + let rBytes = der.subarray(offset, offset + rLen); + offset += rLen; + + // Handle ASN.1 Integer padding for R (remove leading 0x00 if present) + if (rLen === 33 && rBytes[0] === 0x00) { + rBytes = rBytes.subarray(1); + } + + // --- Read S --- + if (der[offset] !== 0x02) throw new Error('Invalid DER: S tag missing'); + offset++; // skip tag + const sLen = der[offset++]; // read length + let sBytes = der.subarray(offset, offset + sLen); + + // Handle ASN.1 Integer padding for S + if (sLen === 33 && sBytes[0] === 0x00) { + sBytes = sBytes.subarray(1); + } + + // --- Construct Raw Signature (64 bytes) --- + const rawSignature = new Uint8Array(64); + + // Copy R into the first 32 bytes (right-aligned/padded) + rawSignature.set(rBytes, 32 - rBytes.length); + + // Copy S into the last 32 bytes (right-aligned/padded) + rawSignature.set(sBytes, 64 - sBytes.length); + + return toBase64Url(rawSignature); +}; diff --git a/packages/@magic-sdk/react-native-expo/src/native-crypto/utils/jwk.ts b/packages/@magic-sdk/react-native-expo/src/native-crypto/utils/jwk.ts new file mode 100644 index 000000000..3cde8102c --- /dev/null +++ b/packages/@magic-sdk/react-native-expo/src/native-crypto/utils/jwk.ts @@ -0,0 +1,26 @@ +import { base64ToUint8Array, toBase64Url } from './uint8'; + +/** + * Converts SPKI Public Key (Base64) to JWK format. + * Extracts Raw X and Y coordinates from the uncompressed point. + */ +export const spkiToJwk = (spkiBase64: string) => { + const buf = base64ToUint8Array(spkiBase64); + // P-256 SPKI Header is 26 bytes. The key data (0x04 + X + Y) is at the end. + // We explicitly look for the last 65 bytes (1 header byte + 32 bytes X + 32 bytes Y) + const rawKey = buf.subarray(buf.length - 65); + + if (rawKey[0] !== 0x04) { + throw new Error('Invalid Public Key format: Expected uncompressed point'); + } + + const x = rawKey.subarray(1, 33); + const y = rawKey.subarray(33, 65); + + return { + kty: 'EC', + crv: 'P-256', + x: toBase64Url(x), + y: toBase64Url(y), + }; +}; diff --git a/packages/@magic-sdk/react-native-expo/src/native-crypto/utils/key-alias.ts b/packages/@magic-sdk/react-native-expo/src/native-crypto/utils/key-alias.ts new file mode 100644 index 000000000..4e1b78e4d --- /dev/null +++ b/packages/@magic-sdk/react-native-expo/src/native-crypto/utils/key-alias.ts @@ -0,0 +1,15 @@ +import * as Application from 'expo-application'; + +const KEY_SUFFIX_MAP = { + dpop: 'magic.sdk.dpop', + refreshToken: 'magic.sdk.rt', + refreshTokenService: 'magic.sdk.rt.service', +}; + +/** + * Returns the key alias for the given key. + * Let's us to safely store the keys in the keychain and avoid conflicts with other apps using magic sdk. + */ +export function getKeyAlias(key: keyof typeof KEY_SUFFIX_MAP): string { + return `${Application.applicationId}.${KEY_SUFFIX_MAP[key]}`; +} diff --git a/packages/@magic-sdk/react-native-expo/src/native-crypto/utils/uint8.ts b/packages/@magic-sdk/react-native-expo/src/native-crypto/utils/uint8.ts new file mode 100644 index 000000000..ac4edcc8d --- /dev/null +++ b/packages/@magic-sdk/react-native-expo/src/native-crypto/utils/uint8.ts @@ -0,0 +1,72 @@ +/** + * Encodes a Uint8Array to a Base64 string. + * Uses native 'btoa' by converting bytes to a binary string first. + */ +export const uint8ArrayToBase64 = (bytes: Uint8Array): string => { + let binary = ''; + const len = bytes.byteLength; + for (let i = 0; i < len; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +}; + +/** + * Decodes a Base64 string to a Uint8Array. + * Uses native 'atob'. + */ +export const base64ToUint8Array = (base64: string): Uint8Array => { + const binaryString = atob(base64); + const len = binaryString.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes; +}; + +/** + * Converts a string (UTF-8) to Uint8Array. + * Polyfill for simple ASCII/UTF-8 if TextEncoder is not available, + * but TextEncoder is standard in RN 0.68+. + */ +export const stringToUint8Array = (str: string): Uint8Array => { + // Use TextEncoder if available (standard in modern RN) + if (typeof TextEncoder !== 'undefined') { + return new TextEncoder().encode(str); + } + // Fallback for older environments + const arr = []; + for (let i = 0; i < str.length; i++) { + let code = str.charCodeAt(i); + if (code < 0x80) { + arr.push(code); + } else if (code < 0x800) { + arr.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f)); + } else if (code < 0xd800 || code >= 0xe000) { + arr.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f)); + } else { + i++; + code = 0x10000 + (((code & 0x3ff) << 10) | (str.charCodeAt(i) & 0x3ff)); + arr.push(0xf0 | (code >> 18), 0x80 | ((code >> 12) & 0x3f), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f)); + } + } + return new Uint8Array(arr); +}; + +/** + * Encodes input (String or Uint8Array) to Base64Url (RFC 4648). + * Removes padding '=', replaces '+' with '-', and '/' with '_' + */ +export const toBase64Url = (input: string | Uint8Array): string => { + let bytes: Uint8Array; + + if (typeof input === 'string') { + bytes = stringToUint8Array(input); + } else { + bytes = input; + } + + const base64 = uint8ArrayToBase64(bytes); + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +}; diff --git a/packages/@magic-sdk/react-native-expo/src/react-native-webview-controller.tsx b/packages/@magic-sdk/react-native-expo/src/react-native-webview-controller.tsx index 108394fee..8e05550b4 100644 --- a/packages/@magic-sdk/react-native-expo/src/react-native-webview-controller.tsx +++ b/packages/@magic-sdk/react-native-expo/src/react-native-webview-controller.tsx @@ -10,6 +10,9 @@ import { EventRegister } from 'react-native-event-listeners'; /* global NodeJS */ import Global = NodeJS.Global; import { useInternetConnection } from './hooks'; +import { getRefreshTokenInSecureStore, setRefreshTokenInSecureStore } from './native-crypto/keychain'; +import { getDpop } from './native-crypto/dpop'; +import { checkNativeModules } from './native-crypto/check-native-modules'; const MAGIC_PAYLOAD_FLAG_TYPED_ARRAY = 'MAGIC_PAYLOAD_FLAG_TYPED_ARRAY'; const OPEN_IN_DEVICE_BROWSER = 'open_in_device_browser'; @@ -87,6 +90,10 @@ export class ReactNativeWebViewController extends ViewController { const [mountOverlay, setMountOverlay] = useState(true); const isConnected = useInternetConnection(); + useEffect(() => { + checkNativeModules(); + }, []); + useEffect(() => { this.isConnectedToInternet = isConnected; }, [isConnected]); @@ -289,6 +296,27 @@ export class ReactNativeWebViewController extends ViewController { } } + async persistMagicEventRefreshToken(event: MagicMessageEvent) { + if (!event?.data?.rt) { + return; + } + + await setRefreshTokenInSecureStore(event.data.rt); + } + + // Overrides parent method to retrieve refresh token from keychain while creating a request + async getRT(): Promise { + return await getRefreshTokenInSecureStore(); + } + + async getJWT(): Promise { + try { + return await getDpop(); + } catch (e) { + return null; + } + } + // Todo - implement these methods /* istanbul ignore next */ protected checkRelayerExistsInDOM(): Promise { diff --git a/packages/@magic-sdk/react-native-expo/test/spec/native-crypto/check-native-modules.spec.ts b/packages/@magic-sdk/react-native-expo/test/spec/native-crypto/check-native-modules.spec.ts new file mode 100644 index 000000000..0b605c7d8 --- /dev/null +++ b/packages/@magic-sdk/react-native-expo/test/spec/native-crypto/check-native-modules.spec.ts @@ -0,0 +1,142 @@ +const mockNativeModules: Record = {}; +const mockPlatform: { OS: string } = { OS: 'ios' }; + +jest.mock('react-native', () => ({ + NativeModules: mockNativeModules, + Platform: mockPlatform, +})); + +jest.mock('expo-modules-core', () => ({ + requireOptionalNativeModule: jest.fn(() => false), +})); + +describe('checkNativeModules', () => { + let consoleWarnSpy: jest.SpyInstance; + + beforeEach(() => { + // Reset module to clear hasWarned state between tests + jest.resetModules(); + + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + // Clear native modules + delete mockNativeModules['DeviceCrypto']; + mockPlatform.OS = 'ios'; + }); + + afterEach(() => { + consoleWarnSpy.mockRestore(); + }); + + it('should not log any warning when all native modules are present', () => { + // Re-require after resetModules to get the fresh mock instance + const { requireOptionalNativeModule: mockRequire } = require('expo-modules-core'); + (mockRequire as jest.Mock).mockReturnValue(true); + mockNativeModules.DeviceCrypto = {}; + + const { checkNativeModules } = require('../../../src/native-crypto/check-native-modules'); + checkNativeModules(); + + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it('should log a warning when expo-secure-store is missing', () => { + mockNativeModules.DeviceCrypto = {}; + + const { checkNativeModules } = require('../../../src/native-crypto/check-native-modules'); + checkNativeModules(); + + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('expo-secure-store')); + }); + + it('should log a warning when react-native-device-crypto is missing', () => { + // Re-require after resetModules to get the fresh mock instance + const { requireOptionalNativeModule: mockRequire } = require('expo-modules-core'); + (mockRequire as jest.Mock).mockReturnValue(true); + mockNativeModules.DeviceCrypto = undefined; + + const { checkNativeModules } = require('../../../src/native-crypto/check-native-modules'); + checkNativeModules(); + + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('react-native-device-crypto')); + }); + + it('should log a warning when both native modules are missing', () => { + mockNativeModules.DeviceCrypto = undefined; + + const { checkNativeModules } = require('../../../src/native-crypto/check-native-modules'); + checkNativeModules(); + + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('expo-secure-store')); + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('react-native-device-crypto')); + }); + + it('should only log warning once even if called multiple times', () => { + mockNativeModules.DeviceCrypto = undefined; + + const { checkNativeModules } = require('../../../src/native-crypto/check-native-modules'); + checkNativeModules(); + checkNativeModules(); + checkNativeModules(); + + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + }); + + it('should include iOS instructions when platform is ios', () => { + mockPlatform.OS = 'ios'; + mockNativeModules.DeviceCrypto = {}; + + const { checkNativeModules } = require('../../../src/native-crypto/check-native-modules'); + checkNativeModules(); + + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('npx expo run:ios')); + }); + + it('should include Android instructions when platform is android', () => { + mockPlatform.OS = 'android'; + mockNativeModules.DeviceCrypto = {}; + + const { checkNativeModules } = require('../../../src/native-crypto/check-native-modules'); + checkNativeModules(); + + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('npx expo run:android')); + }); + + it('should not include iOS instructions when platform is android', () => { + mockPlatform.OS = 'android'; + mockNativeModules.DeviceCrypto = {}; + + const { checkNativeModules } = require('../../../src/native-crypto/check-native-modules'); + checkNativeModules(); + + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy).not.toHaveBeenCalledWith(expect.stringContaining('npx expo run:ios')); + }); + + it('should not include Android instructions when platform is ios', () => { + mockPlatform.OS = 'ios'; + mockNativeModules.ExpoSecureStore = undefined; + mockNativeModules.DeviceCrypto = {}; + + const { checkNativeModules } = require('../../../src/native-crypto/check-native-modules'); + checkNativeModules(); + + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy).not.toHaveBeenCalledWith(expect.stringContaining('npx expo run:android')); + }); + + it('should include install commands for missing packages', () => { + mockNativeModules.DeviceCrypto = undefined; + + const { checkNativeModules } = require('../../../src/native-crypto/check-native-modules'); + checkNativeModules(); + + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('npx expo install expo-secure-store')); + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('npx expo install react-native-device-crypto')); + }); +}); diff --git a/packages/@magic-sdk/react-native-expo/test/spec/native-crypto/dpop.spec.ts b/packages/@magic-sdk/react-native-expo/test/spec/native-crypto/dpop.spec.ts new file mode 100644 index 000000000..fe445f70a --- /dev/null +++ b/packages/@magic-sdk/react-native-expo/test/spec/native-crypto/dpop.spec.ts @@ -0,0 +1,181 @@ +// Mock dependencies before imports +const mockGetOrCreateAsymmetricKey = jest.fn(); +const mockSign = jest.fn(); +const mockDeleteKey = jest.fn(); + +jest.mock('react-native-device-crypto', () => ({ + __esModule: true, + default: { + getOrCreateAsymmetricKey: mockGetOrCreateAsymmetricKey, + sign: mockSign, + deleteKey: mockDeleteKey, + }, + AccessLevel: { + ALWAYS: 'ALWAYS', + }, +})); + +jest.mock('react-native-uuid', () => ({ + v4: () => 'test-uuid-1234', +})); + +// Mock expo-application +jest.mock('expo-application', () => ({ + applicationId: 'com.test.app', +})); + +import { getDpop, deleteDpop } from '../../../src/native-crypto/dpop'; +import { uint8ArrayToBase64 } from '../../../src/native-crypto/utils/uint8'; + +describe('dpop', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getDpop', () => { + it('should generate a valid DPoP token', async () => { + // Create a mock SPKI public key (P-256 format) + const xCoord = new Uint8Array(32).fill(0x11); + const yCoord = new Uint8Array(32).fill(0x22); + const spkiHeader = new Uint8Array([ + 0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, + 0xce, 0x3d, 0x03, 0x01, 0x07, 0x03, 0x42, 0x00, + ]); + const keyData = new Uint8Array([0x04, ...xCoord, ...yCoord]); + const mockPublicKey = uint8ArrayToBase64(new Uint8Array([...spkiHeader, ...keyData])); + + mockGetOrCreateAsymmetricKey.mockResolvedValue(mockPublicKey); + + // Create a mock DER signature + const r = new Uint8Array(32).fill(0x33); + const s = new Uint8Array(32).fill(0x44); + const derSignature = new Uint8Array([0x30, 68, 0x02, 32, ...r, 0x02, 32, ...s]); + mockSign.mockResolvedValue(uint8ArrayToBase64(derSignature)); + + const result = await getDpop(); + + expect(result).not.toBeNull(); + expect(typeof result).toBe('string'); + + // Verify the token structure (header.payload.signature) + const parts = result!.split('.'); + expect(parts.length).toBe(3); + + // Verify header + const header = JSON.parse(Buffer.from(parts[0], 'base64').toString()); + expect(header.typ).toBe('dpop+jwt'); + expect(header.alg).toBe('ES256'); + expect(header.jwk).toBeDefined(); + expect(header.jwk.kty).toBe('EC'); + expect(header.jwk.crv).toBe('P-256'); + + // Verify payload + const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString()); + expect(payload.jti).toBe('test-uuid-1234'); + expect(typeof payload.iat).toBe('number'); + + // Verify DeviceCrypto was called correctly + expect(mockGetOrCreateAsymmetricKey).toHaveBeenCalledWith('com.test.app.magic.sdk.dpop', { + accessLevel: 'ALWAYS', + invalidateOnNewBiometry: false, + }); + + expect(mockSign).toHaveBeenCalledWith( + 'com.test.app.magic.sdk.dpop', + expect.stringContaining('.'), + expect.objectContaining({ + biometryTitle: 'Sign DPoP', + biometrySubTitle: 'Sign DPoP', + biometryDescription: 'Sign DPoP', + }), + ); + }); + + it('should return null on key creation error', async () => { + mockGetOrCreateAsymmetricKey.mockRejectedValue(new Error('Key creation failed')); + + const result = await getDpop(); + + expect(result).toBeNull(); + }); + + it('should return null on signing error', async () => { + // Create a mock SPKI public key + const xCoord = new Uint8Array(32).fill(0x11); + const yCoord = new Uint8Array(32).fill(0x22); + const spkiHeader = new Uint8Array([ + 0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, + 0xce, 0x3d, 0x03, 0x01, 0x07, 0x03, 0x42, 0x00, + ]); + const keyData = new Uint8Array([0x04, ...xCoord, ...yCoord]); + const mockPublicKey = uint8ArrayToBase64(new Uint8Array([...spkiHeader, ...keyData])); + + mockGetOrCreateAsymmetricKey.mockResolvedValue(mockPublicKey); + mockSign.mockRejectedValue(new Error('Signing failed')); + + const result = await getDpop(); + + expect(result).toBeNull(); + }); + + it('should use correct timestamp in claims', async () => { + const mockDate = new Date('2024-01-15T12:00:00Z'); + jest.spyOn(global.Date, 'now').mockReturnValue(mockDate.getTime()); + + // Create a mock SPKI public key + const xCoord = new Uint8Array(32).fill(0x11); + const yCoord = new Uint8Array(32).fill(0x22); + const spkiHeader = new Uint8Array([ + 0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, + 0xce, 0x3d, 0x03, 0x01, 0x07, 0x03, 0x42, 0x00, + ]); + const keyData = new Uint8Array([0x04, ...xCoord, ...yCoord]); + const mockPublicKey = uint8ArrayToBase64(new Uint8Array([...spkiHeader, ...keyData])); + + mockGetOrCreateAsymmetricKey.mockResolvedValue(mockPublicKey); + + // Create a mock DER signature + const r = new Uint8Array(32).fill(0x33); + const s = new Uint8Array(32).fill(0x44); + const derSignature = new Uint8Array([0x30, 68, 0x02, 32, ...r, 0x02, 32, ...s]); + mockSign.mockResolvedValue(uint8ArrayToBase64(derSignature)); + + const result = await getDpop(); + + const parts = result!.split('.'); + const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString()); + + expect(payload.iat).toBe(Math.floor(mockDate.getTime() / 1000)); + + jest.restoreAllMocks(); + }); + }); + + describe('deleteDpop', () => { + it('should delete the DPoP key successfully', async () => { + mockDeleteKey.mockResolvedValue(true); + + const result = await deleteDpop(); + + expect(result).toBe(true); + expect(mockDeleteKey).toHaveBeenCalledWith('com.test.app.magic.sdk.dpop'); + }); + + it('should return false on deletion error', async () => { + mockDeleteKey.mockRejectedValue(new Error('Deletion failed')); + + const result = await deleteDpop(); + + expect(result).toBe(false); + }); + + it('should return false when deleteKey returns false', async () => { + mockDeleteKey.mockResolvedValue(false); + + const result = await deleteDpop(); + + expect(result).toBe(false); + }); + }); +}); + diff --git a/packages/@magic-sdk/react-native-expo/test/spec/native-crypto/keychain.spec.ts b/packages/@magic-sdk/react-native-expo/test/spec/native-crypto/keychain.spec.ts new file mode 100644 index 000000000..e8446667a --- /dev/null +++ b/packages/@magic-sdk/react-native-expo/test/spec/native-crypto/keychain.spec.ts @@ -0,0 +1,186 @@ +// Mock expo-secure-store before importing the module +const mockSetItemAsync = jest.fn(); +const mockGetItemAsync = jest.fn(); +const mockDeleteItemAsync = jest.fn(); + +jest.mock('expo-secure-store', () => ({ + setItemAsync: mockSetItemAsync, + getItemAsync: mockGetItemAsync, + deleteItemAsync: mockDeleteItemAsync, + AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY: 'AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY', +})); + +// Mock expo-application +jest.mock('expo-application', () => ({ + applicationId: 'com.test.app', +})); + +import { + setRefreshTokenInSecureStore, + getRefreshTokenInSecureStore, + removeRefreshTokenInSecureStore, +} from '../../../src/native-crypto/keychain'; + +describe('keychain', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Reset the module to clear cached token + jest.resetModules(); + }); + + describe('setRefreshTokenInSecureStore', () => { + it('should store refresh token successfully', async () => { + mockSetItemAsync.mockResolvedValue(undefined); + + // Need to re-import to get fresh module state + jest.isolateModules(async () => { + const { setRefreshTokenInSecureStore: setToken } = require('../../../src/native-crypto/keychain'); + const result = await setToken('test-token'); + + expect(result).toBe(true); + expect(mockSetItemAsync).toHaveBeenCalledWith('com.test.app.magic.sdk.rt', 'test-token', { + keychainService: 'com.test.app.magic.sdk.rt.service', + keychainAccessible: 'AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY', + }); + }); + }); + + it('should skip write if token has not changed (cached)', async () => { + mockSetItemAsync.mockResolvedValue(undefined); + + // Set the token first + await setRefreshTokenInSecureStore('same-token'); + mockSetItemAsync.mockClear(); + + // Try to set the same token again + const result = await setRefreshTokenInSecureStore('same-token'); + + expect(result).toBe(true); + expect(mockSetItemAsync).not.toHaveBeenCalled(); + }); + + it('should return false on error', async () => { + jest.isolateModules(async () => { + const mockSetItem = jest.fn().mockRejectedValue(new Error('Storage error')); + jest.doMock('expo-secure-store', () => ({ + setItemAsync: mockSetItem, + AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY: 'AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY', + })); + + const { setRefreshTokenInSecureStore: setToken } = require('../../../src/native-crypto/keychain'); + const result = await setToken('test-token'); + + expect(result).toBe(false); + }); + }); + }); + + describe('getRefreshTokenInSecureStore', () => { + it('should retrieve refresh token successfully', async () => { + jest.isolateModules(async () => { + const mockGetItem = jest.fn().mockResolvedValue('stored-token'); + jest.doMock('expo-secure-store', () => ({ + getItemAsync: mockGetItem, + })); + jest.doMock('expo-application', () => ({ + applicationId: 'com.test.app', + })); + + const { getRefreshTokenInSecureStore: getToken } = require('../../../src/native-crypto/keychain'); + const result = await getToken(); + + expect(result).toBe('stored-token'); + expect(mockGetItem).toHaveBeenCalledWith('com.test.app.magic.sdk.rt', { + keychainService: 'com.test.app.magic.sdk.rt.service', + }); + }); + }); + + it('should return cached token if available', async () => { + mockSetItemAsync.mockResolvedValue(undefined); + mockGetItemAsync.mockResolvedValue('stored-token'); + + // First set a token to populate the cache + await setRefreshTokenInSecureStore('cached-token'); + + // Now get should return cached value without calling getItemAsync + mockGetItemAsync.mockClear(); + const result = await getRefreshTokenInSecureStore(); + + expect(result).toBe('cached-token'); + expect(mockGetItemAsync).not.toHaveBeenCalled(); + }); + + it('should return null if no token exists', async () => { + jest.isolateModules(async () => { + const mockGetItem = jest.fn().mockResolvedValue(null); + jest.doMock('expo-secure-store', () => ({ + getItemAsync: mockGetItem, + })); + jest.doMock('expo-application', () => ({ + applicationId: 'com.test.app', + })); + + const { getRefreshTokenInSecureStore: getToken } = require('../../../src/native-crypto/keychain'); + const result = await getToken(); + + expect(result).toBeNull(); + }); + }); + + it('should return null on error', async () => { + jest.isolateModules(async () => { + const mockGetItem = jest.fn().mockRejectedValue(new Error('Storage error')); + jest.doMock('expo-secure-store', () => ({ + getItemAsync: mockGetItem, + })); + jest.doMock('expo-application', () => ({ + applicationId: 'com.test.app', + })); + + const { getRefreshTokenInSecureStore: getToken } = require('../../../src/native-crypto/keychain'); + const result = await getToken(); + + expect(result).toBeNull(); + }); + }); + }); + + describe('removeRefreshTokenInSecureStore', () => { + it('should remove refresh token successfully', async () => { + mockDeleteItemAsync.mockResolvedValue(undefined); + + await removeRefreshTokenInSecureStore(); + + expect(mockDeleteItemAsync).toHaveBeenCalledWith('com.test.app.magic.sdk.rt', { + keychainService: 'com.test.app.magic.sdk.rt.service', + }); + }); + + it('should clear cache when removing token', async () => { + mockSetItemAsync.mockResolvedValue(undefined); + mockDeleteItemAsync.mockResolvedValue(undefined); + mockGetItemAsync.mockResolvedValue('new-token'); + + // Set a token first + await setRefreshTokenInSecureStore('cached-token'); + + // Remove the token + await removeRefreshTokenInSecureStore(); + + // Now get should call getItemAsync since cache is cleared + const result = await getRefreshTokenInSecureStore(); + + expect(mockGetItemAsync).toHaveBeenCalled(); + expect(result).toBe('new-token'); + }); + + it('should handle error gracefully', async () => { + mockDeleteItemAsync.mockRejectedValue(new Error('Delete error')); + + // Should not throw + await expect(removeRefreshTokenInSecureStore()).resolves.not.toThrow(); + }); + }); +}); + diff --git a/packages/@magic-sdk/react-native-expo/test/spec/native-crypto/utils/der.spec.ts b/packages/@magic-sdk/react-native-expo/test/spec/native-crypto/utils/der.spec.ts new file mode 100644 index 000000000..def2cc840 --- /dev/null +++ b/packages/@magic-sdk/react-native-expo/test/spec/native-crypto/utils/der.spec.ts @@ -0,0 +1,228 @@ +import { derToRawSignature } from '../../../../src/native-crypto/utils/der'; +import { uint8ArrayToBase64 } from '../../../../src/native-crypto/utils/uint8'; + +describe('der utilities', () => { + describe('derToRawSignature', () => { + it('should convert DER signature to raw P1363 format', () => { + // Create a valid DER signature structure + // DER: 0x30 | total_len | 0x02 | r_len | r_bytes | 0x02 | s_len | s_bytes + const r = new Uint8Array(32).fill(0x11); + const s = new Uint8Array(32).fill(0x22); + + // Build DER encoded signature + const derSignature = new Uint8Array([ + 0x30, + 68, // total length: 2 + 32 + 2 + 32 = 68 + 0x02, + 32, // R length + ...r, + 0x02, + 32, // S length + ...s, + ]); + + const derBase64 = uint8ArrayToBase64(derSignature); + const result = derToRawSignature(derBase64); + + // Decode the result to verify + const decoded = Buffer.from(result, 'base64'); + expect(decoded.length).toBe(64); + expect(decoded.subarray(0, 32)).toEqual(Buffer.from(r)); + expect(decoded.subarray(32, 64)).toEqual(Buffer.from(s)); + }); + + it('should handle DER signature with padded R (33 bytes with leading 0x00)', () => { + // When R has high bit set, it gets padded with 0x00 in DER + const r = new Uint8Array(32); + r[0] = 0x80; // High bit set + r.fill(0x11, 1); + + const s = new Uint8Array(32).fill(0x22); + + // Build DER with padded R + const derSignature = new Uint8Array([ + 0x30, + 69, // total length: 2 + 33 + 2 + 32 = 69 + 0x02, + 33, // R length (padded) + 0x00, // padding byte + ...r, + 0x02, + 32, // S length + ...s, + ]); + + const derBase64 = uint8ArrayToBase64(derSignature); + const result = derToRawSignature(derBase64); + + const decoded = Buffer.from(result, 'base64'); + expect(decoded.length).toBe(64); + expect(decoded.subarray(0, 32)).toEqual(Buffer.from(r)); + expect(decoded.subarray(32, 64)).toEqual(Buffer.from(s)); + }); + + it('should handle DER signature with padded S (33 bytes with leading 0x00)', () => { + const r = new Uint8Array(32).fill(0x11); + + // When S has high bit set, it gets padded with 0x00 in DER + const s = new Uint8Array(32); + s[0] = 0x80; // High bit set + s.fill(0x22, 1); + + // Build DER with padded S + const derSignature = new Uint8Array([ + 0x30, + 69, // total length: 2 + 32 + 2 + 33 = 69 + 0x02, + 32, // R length + ...r, + 0x02, + 33, // S length (padded) + 0x00, // padding byte + ...s, + ]); + + const derBase64 = uint8ArrayToBase64(derSignature); + const result = derToRawSignature(derBase64); + + const decoded = Buffer.from(result, 'base64'); + expect(decoded.length).toBe(64); + expect(decoded.subarray(0, 32)).toEqual(Buffer.from(r)); + expect(decoded.subarray(32, 64)).toEqual(Buffer.from(s)); + }); + + it('should handle DER signature with both R and S padded', () => { + const r = new Uint8Array(32); + r[0] = 0x80; + r.fill(0x11, 1); + + const s = new Uint8Array(32); + s[0] = 0x80; + s.fill(0x22, 1); + + // Build DER with both padded + const derSignature = new Uint8Array([ + 0x30, + 70, // total length: 2 + 33 + 2 + 33 = 70 + 0x02, + 33, // R length (padded) + 0x00, + ...r, + 0x02, + 33, // S length (padded) + 0x00, + ...s, + ]); + + const derBase64 = uint8ArrayToBase64(derSignature); + const result = derToRawSignature(derBase64); + + const decoded = Buffer.from(result, 'base64'); + expect(decoded.length).toBe(64); + expect(decoded.subarray(0, 32)).toEqual(Buffer.from(r)); + expect(decoded.subarray(32, 64)).toEqual(Buffer.from(s)); + }); + + it('should throw error if R tag is missing', () => { + const invalidDer = new Uint8Array([ + 0x30, + 4, + 0x03, // Invalid tag (should be 0x02) + 2, + 0x11, + 0x22, + ]); + + const derBase64 = uint8ArrayToBase64(invalidDer); + expect(() => derToRawSignature(derBase64)).toThrow('Invalid DER: R tag missing'); + }); + + it('should throw error if S tag is missing', () => { + const r = new Uint8Array(32).fill(0x11); + const invalidDer = new Uint8Array([ + 0x30, + 38, + 0x02, + 32, + ...r, + 0x03, // Invalid tag (should be 0x02) + 2, + 0x22, + 0x33, + ]); + + const derBase64 = uint8ArrayToBase64(invalidDer); + expect(() => derToRawSignature(derBase64)).toThrow('Invalid DER: S tag missing'); + }); + + it('should produce base64url encoded output', () => { + const r = new Uint8Array(32).fill(0xff); + const s = new Uint8Array(32).fill(0xff); + + const derSignature = new Uint8Array([0x30, 68, 0x02, 32, ...r, 0x02, 32, ...s]); + + const derBase64 = uint8ArrayToBase64(derSignature); + const result = derToRawSignature(derBase64); + + // Should be base64url encoded (no +, /, or =) + expect(result).not.toContain('+'); + expect(result).not.toContain('/'); + expect(result).not.toContain('='); + }); + + it('should handle shorter R value with proper padding', () => { + // R value is only 31 bytes (needs left-padding in output) + const r = new Uint8Array(31).fill(0x11); + const s = new Uint8Array(32).fill(0x22); + + const derSignature = new Uint8Array([ + 0x30, + 67, // total length: 2 + 31 + 2 + 32 = 67 + 0x02, + 31, // R length (shorter) + ...r, + 0x02, + 32, // S length + ...s, + ]); + + const derBase64 = uint8ArrayToBase64(derSignature); + const result = derToRawSignature(derBase64); + + const decoded = Buffer.from(result, 'base64'); + expect(decoded.length).toBe(64); + // R should be left-padded with one zero byte + expect(decoded[0]).toBe(0); + expect(decoded.subarray(1, 32)).toEqual(Buffer.from(r)); + expect(decoded.subarray(32, 64)).toEqual(Buffer.from(s)); + }); + + it('should handle shorter S value with proper padding', () => { + const r = new Uint8Array(32).fill(0x11); + // S value is only 31 bytes (needs left-padding in output) + const s = new Uint8Array(31).fill(0x22); + + const derSignature = new Uint8Array([ + 0x30, + 67, // total length: 2 + 32 + 2 + 31 = 67 + 0x02, + 32, // R length + ...r, + 0x02, + 31, // S length (shorter) + ...s, + ]); + + const derBase64 = uint8ArrayToBase64(derSignature); + const result = derToRawSignature(derBase64); + + const decoded = Buffer.from(result, 'base64'); + expect(decoded.length).toBe(64); + expect(decoded.subarray(0, 32)).toEqual(Buffer.from(r)); + // S should be left-padded with one zero byte + expect(decoded[32]).toBe(0); + expect(decoded.subarray(33, 64)).toEqual(Buffer.from(s)); + }); + }); +}); + diff --git a/packages/@magic-sdk/react-native-expo/test/spec/native-crypto/utils/jwk.spec.ts b/packages/@magic-sdk/react-native-expo/test/spec/native-crypto/utils/jwk.spec.ts new file mode 100644 index 000000000..02465ac4b --- /dev/null +++ b/packages/@magic-sdk/react-native-expo/test/spec/native-crypto/utils/jwk.spec.ts @@ -0,0 +1,132 @@ +import { spkiToJwk } from '../../../../src/native-crypto/utils/jwk'; +import { uint8ArrayToBase64 } from '../../../../src/native-crypto/utils/uint8'; + +describe('jwk utilities', () => { + describe('spkiToJwk', () => { + it('should convert SPKI public key to JWK format', () => { + // Create a mock P-256 SPKI public key + // SPKI header for P-256 is 26 bytes, followed by 65 bytes of key data + const spkiHeader = new Uint8Array([ + 0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, + 0x3d, 0x03, 0x01, 0x07, 0x03, 0x42, 0x00, + ]); + + // 65 bytes: 0x04 (uncompressed point indicator) + 32 bytes X + 32 bytes Y + const xCoord = new Uint8Array(32); + xCoord.fill(0x11); + const yCoord = new Uint8Array(32); + yCoord.fill(0x22); + + const keyData = new Uint8Array([0x04, ...xCoord, ...yCoord]); + + const spkiKey = new Uint8Array([...spkiHeader, ...keyData]); + const spkiBase64 = uint8ArrayToBase64(spkiKey); + + const result = spkiToJwk(spkiBase64); + + expect(result).toEqual({ + kty: 'EC', + crv: 'P-256', + x: expect.any(String), + y: expect.any(String), + }); + + // Verify X and Y are base64url encoded + expect(result.x).not.toContain('+'); + expect(result.x).not.toContain('/'); + expect(result.x).not.toContain('='); + expect(result.y).not.toContain('+'); + expect(result.y).not.toContain('/'); + expect(result.y).not.toContain('='); + }); + + it('should extract correct X and Y coordinates', () => { + // Create predictable coordinates + const xCoord = new Uint8Array(32); + for (let i = 0; i < 32; i++) xCoord[i] = i; + + const yCoord = new Uint8Array(32); + for (let i = 0; i < 32; i++) yCoord[i] = 32 + i; + + const spkiHeader = new Uint8Array([ + 0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, + 0x3d, 0x03, 0x01, 0x07, 0x03, 0x42, 0x00, + ]); + + const keyData = new Uint8Array([0x04, ...xCoord, ...yCoord]); + const spkiKey = new Uint8Array([...spkiHeader, ...keyData]); + const spkiBase64 = uint8ArrayToBase64(spkiKey); + + const result = spkiToJwk(spkiBase64); + + // Decode and verify + const decodedX = Buffer.from(result.x, 'base64'); + const decodedY = Buffer.from(result.y, 'base64'); + + expect(decodedX).toEqual(Buffer.from(xCoord)); + expect(decodedY).toEqual(Buffer.from(yCoord)); + }); + + it('should throw error for compressed point format', () => { + const spkiHeader = new Uint8Array([ + 0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, + 0x3d, 0x03, 0x01, 0x07, 0x03, 0x42, 0x00, + ]); + + // Compressed point starts with 0x02 or 0x03 instead of 0x04 + const compressedKey = new Uint8Array([0x02, ...new Uint8Array(64).fill(0x11)]); + const spkiKey = new Uint8Array([...spkiHeader, ...compressedKey]); + const spkiBase64 = uint8ArrayToBase64(spkiKey); + + expect(() => spkiToJwk(spkiBase64)).toThrow('Invalid Public Key format: Expected uncompressed point'); + }); + + it('should handle minimal SPKI with just the required 65 bytes at the end', () => { + // The function extracts the last 65 bytes regardless of header + const xCoord = new Uint8Array(32).fill(0xaa); + const yCoord = new Uint8Array(32).fill(0xbb); + + // Just some arbitrary header bytes + const header = new Uint8Array(10).fill(0x00); + const keyData = new Uint8Array([0x04, ...xCoord, ...yCoord]); + + const spkiKey = new Uint8Array([...header, ...keyData]); + const spkiBase64 = uint8ArrayToBase64(spkiKey); + + const result = spkiToJwk(spkiBase64); + + expect(result.kty).toBe('EC'); + expect(result.crv).toBe('P-256'); + + const decodedX = Buffer.from(result.x, 'base64'); + const decodedY = Buffer.from(result.y, 'base64'); + + expect(decodedX).toEqual(Buffer.from(xCoord)); + expect(decodedY).toEqual(Buffer.from(yCoord)); + }); + + it('should handle real-world SPKI key format', () => { + // A realistic P-256 SPKI public key structure with proper uncompressed point (0x04 prefix) + // SPKI header (26 bytes) + 0x04 + X (32 bytes) + Y (32 bytes) + const xCoord = new Uint8Array(32).fill(0x11); + const yCoord = new Uint8Array(32).fill(0x22); + const spkiHeader = new Uint8Array([ + 0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, + 0x3d, 0x03, 0x01, 0x07, 0x03, 0x42, 0x00, + ]); + const keyData = new Uint8Array([0x04, ...xCoord, ...yCoord]); + const spkiKey = new Uint8Array([...spkiHeader, ...keyData]); + const realSpkiBase64 = uint8ArrayToBase64(spkiKey); + + const result = spkiToJwk(realSpkiBase64); + + expect(result).toEqual({ + kty: 'EC', + crv: 'P-256', + x: expect.any(String), + y: expect.any(String), + }); + }); + }); +}); + diff --git a/packages/@magic-sdk/react-native-expo/test/spec/native-crypto/utils/key-alias.spec.ts b/packages/@magic-sdk/react-native-expo/test/spec/native-crypto/utils/key-alias.spec.ts new file mode 100644 index 000000000..de0ed22bd --- /dev/null +++ b/packages/@magic-sdk/react-native-expo/test/spec/native-crypto/utils/key-alias.spec.ts @@ -0,0 +1,26 @@ +import { getKeyAlias } from '../../../../src/native-crypto/utils/key-alias'; + +// Mock expo-application +jest.mock('expo-application', () => ({ + applicationId: 'com.test.app', +})); + +describe('key-alias utilities', () => { + describe('getKeyAlias', () => { + it('should return correct alias for dpop key', () => { + const result = getKeyAlias('dpop'); + expect(result).toBe('com.test.app.magic.sdk.dpop'); + }); + + it('should return correct alias for refreshToken key', () => { + const result = getKeyAlias('refreshToken'); + expect(result).toBe('com.test.app.magic.sdk.rt'); + }); + + it('should return correct alias for refreshTokenService key', () => { + const result = getKeyAlias('refreshTokenService'); + expect(result).toBe('com.test.app.magic.sdk.rt.service'); + }); + }); +}); + diff --git a/packages/@magic-sdk/react-native-expo/test/spec/native-crypto/utils/uint8.spec.ts b/packages/@magic-sdk/react-native-expo/test/spec/native-crypto/utils/uint8.spec.ts new file mode 100644 index 000000000..9582e5486 --- /dev/null +++ b/packages/@magic-sdk/react-native-expo/test/spec/native-crypto/utils/uint8.spec.ts @@ -0,0 +1,163 @@ +import { + uint8ArrayToBase64, + base64ToUint8Array, + stringToUint8Array, + toBase64Url, +} from '../../../../src/native-crypto/utils/uint8'; + +describe('uint8 utilities', () => { + describe('uint8ArrayToBase64', () => { + it('should encode empty array to empty string', () => { + const result = uint8ArrayToBase64(new Uint8Array([])); + expect(result).toBe(''); + }); + + it('should encode simple byte array', () => { + // "Hello" in ASCII bytes + const bytes = new Uint8Array([72, 101, 108, 108, 111]); + const result = uint8ArrayToBase64(bytes); + expect(result).toBe('SGVsbG8='); + }); + + it('should encode binary data correctly', () => { + const bytes = new Uint8Array([0x00, 0xff, 0x80, 0x7f]); + const result = uint8ArrayToBase64(bytes); + expect(result).toBe('AP+Afw=='); + }); + }); + + describe('base64ToUint8Array', () => { + it('should decode empty string to empty array', () => { + const result = base64ToUint8Array(''); + expect(result).toEqual(new Uint8Array([])); + }); + + it('should decode base64 string to byte array', () => { + const result = base64ToUint8Array('SGVsbG8='); + expect(result).toEqual(new Uint8Array([72, 101, 108, 108, 111])); + }); + + it('should decode binary data correctly', () => { + const result = base64ToUint8Array('AP+Afw=='); + expect(result).toEqual(new Uint8Array([0x00, 0xff, 0x80, 0x7f])); + }); + + it('should roundtrip with uint8ArrayToBase64', () => { + const original = new Uint8Array([1, 2, 3, 4, 5, 255, 128, 0]); + const encoded = uint8ArrayToBase64(original); + const decoded = base64ToUint8Array(encoded); + expect(decoded).toEqual(original); + }); + }); + + describe('stringToUint8Array', () => { + const originalTextEncoder = global.TextEncoder; + + afterEach(() => { + // Restore original TextEncoder after each test + global.TextEncoder = originalTextEncoder; + }); + + it('uses TextEncoder when it exists', () => { + const mockEncode = jest.fn().mockReturnValue(new Uint8Array([97, 98, 99])); // "abc" + const mockConstructor = jest.fn(() => ({ encode: mockEncode })); + + // Set up a mock TextEncoder on the global object + global.TextEncoder = mockConstructor as any; + + const result = stringToUint8Array('abc'); + + expect(mockConstructor).toHaveBeenCalledTimes(1); + expect(mockEncode).toHaveBeenCalledWith('abc'); + expect(result).toEqual(new Uint8Array([97, 98, 99])); + }); + + it('should encode ASCII string', () => { + const result = stringToUint8Array('Hello'); + expect(result).toEqual(new Uint8Array([72, 101, 108, 108, 111])); + }); + + it('should encode empty string', () => { + const result = stringToUint8Array(''); + expect(result).toEqual(new Uint8Array([])); + }); + + it('should encode UTF-8 string with 2-byte characters', () => { + // "é" = 0xC3 0xA9 in UTF-8 + const result = stringToUint8Array('é'); + expect(result).toEqual(new Uint8Array([0xc3, 0xa9])); + }); + + it('should encode UTF-8 string with 3-byte characters', () => { + // "€" = 0xE2 0x82 0xAC in UTF-8 + const result = stringToUint8Array('€'); + expect(result).toEqual(new Uint8Array([0xe2, 0x82, 0xac])); + }); + + it('should encode UTF-8 string with 4-byte characters (surrogate pairs)', () => { + // "𝄞" (musical G clef) = 0xF0 0x9D 0x84 0x9E in UTF-8 + const result = stringToUint8Array('𝄞'); + expect(result).toEqual(new Uint8Array([0xf0, 0x9d, 0x84, 0x9e])); + }); + + it('should use fallback when TextEncoder is unavailable', () => { + const originalTextEncoder = global.TextEncoder; + // @ts-ignore - Testing fallback behavior + delete global.TextEncoder; + + try { + // ASCII + expect(stringToUint8Array('abc')).toEqual(new Uint8Array([97, 98, 99])); + + // 2-byte UTF-8 + expect(stringToUint8Array('é')).toEqual(new Uint8Array([0xc3, 0xa9])); + + // 3-byte UTF-8 + expect(stringToUint8Array('€')).toEqual(new Uint8Array([0xe2, 0x82, 0xac])); + + // 4-byte UTF-8 (surrogate pair) + expect(stringToUint8Array('𝄞')).toEqual(new Uint8Array([0xf0, 0x9d, 0x84, 0x9e])); + } finally { + global.TextEncoder = originalTextEncoder; + } + }); + }); + + describe('toBase64Url', () => { + it('should encode string to base64url', () => { + const result = toBase64Url('Hello'); + expect(result).toBe('SGVsbG8'); + }); + + it('should encode Uint8Array to base64url', () => { + const bytes = new Uint8Array([72, 101, 108, 108, 111]); + const result = toBase64Url(bytes); + expect(result).toBe('SGVsbG8'); + }); + + it('should replace + with - and / with _', () => { + // Create bytes that result in + and / in standard base64 + // 0xFB, 0xFF, 0xFE produces "+//+" in standard base64 + const bytes = new Uint8Array([0xfb, 0xff, 0xfe]); + const result = toBase64Url(bytes); + expect(result).not.toContain('+'); + expect(result).not.toContain('/'); + expect(result).toBe('-__-'); + }); + + it('should remove padding characters', () => { + // "Hi" produces "SGk=" in standard base64 + const result = toBase64Url('Hi'); + expect(result).not.toContain('='); + expect(result).toBe('SGk'); + }); + + it('should handle JSON object strings', () => { + const obj = { typ: 'dpop+jwt', alg: 'ES256' }; + const result = toBase64Url(JSON.stringify(obj)); + expect(result).not.toContain('+'); + expect(result).not.toContain('/'); + expect(result).not.toContain('='); + }); + }); +}); diff --git a/packages/@magic-sdk/react-native-expo/test/spec/react-native-webview-controller/getJWT.spec.ts b/packages/@magic-sdk/react-native-expo/test/spec/react-native-webview-controller/getJWT.spec.ts new file mode 100644 index 000000000..1ac805f1a --- /dev/null +++ b/packages/@magic-sdk/react-native-expo/test/spec/react-native-webview-controller/getJWT.spec.ts @@ -0,0 +1,22 @@ +import { getDpop } from '../../../src/native-crypto/dpop'; +import { createReactNativeWebViewController } from '../../factories'; + +jest.mock('../../../src/native-crypto/dpop', () => ({ + getDpop: jest.fn(), +})); + +describe('getJWT', () => { + it('should call getDpop', async () => { + const viewController = createReactNativeWebViewController('asdf'); + await viewController.getJWT(); + + expect(getDpop).toHaveBeenCalled(); + }); + + it('should return null if getDpop throws an error', async () => { + const viewController = createReactNativeWebViewController('asdf'); + (getDpop as jest.Mock).mockRejectedValueOnce(new Error('test-error')); + const result = await viewController.getJWT(); + expect(result).toBeNull(); + }); +}); diff --git a/packages/@magic-sdk/react-native-expo/test/spec/react-native-webview-controller/getRT.spec.ts b/packages/@magic-sdk/react-native-expo/test/spec/react-native-webview-controller/getRT.spec.ts new file mode 100644 index 000000000..92befe4d9 --- /dev/null +++ b/packages/@magic-sdk/react-native-expo/test/spec/react-native-webview-controller/getRT.spec.ts @@ -0,0 +1,15 @@ +import { getRefreshTokenInSecureStore } from '../../../src/native-crypto/keychain'; +import { createReactNativeWebViewController } from '../../factories'; + +jest.mock('../../../src/native-crypto/keychain', () => ({ + getRefreshTokenInSecureStore: jest.fn(), +})); + +describe('getRT', () => { + it('should call getRefreshTokenInSecureStore', async () => { + const viewController = createReactNativeWebViewController('asdf'); + await viewController.getRT(); + + expect(getRefreshTokenInSecureStore).toHaveBeenCalled(); + }); +}); diff --git a/packages/@magic-sdk/react-native-expo/test/spec/react-native-webview-controller/persistMagicEventRefreshToken.spec.ts b/packages/@magic-sdk/react-native-expo/test/spec/react-native-webview-controller/persistMagicEventRefreshToken.spec.ts new file mode 100644 index 000000000..fce8466a3 --- /dev/null +++ b/packages/@magic-sdk/react-native-expo/test/spec/react-native-webview-controller/persistMagicEventRefreshToken.spec.ts @@ -0,0 +1,27 @@ +import { setRefreshTokenInSecureStore } from '../../../src/native-crypto/keychain'; +import { createReactNativeWebViewController } from '../../factories'; + +jest.mock('../../../src/native-crypto/keychain', () => ({ + setRefreshTokenInSecureStore: jest.fn(), +})); + +describe('persistMagicEventRefreshToken', () => { + it('should not call setRefreshTokenInSecureStore if no data.rt', async () => { + const viewController = createReactNativeWebViewController('asdf'); + const result = await viewController.persistMagicEventRefreshToken({}); + + expect(result).toBeUndefined(); + expect(setRefreshTokenInSecureStore).not.toHaveBeenCalled(); + }); + + it('should call setRefreshTokenInSecureStore', async () => { + const viewController = createReactNativeWebViewController('asdf'); + await viewController.persistMagicEventRefreshToken({ + data: { + rt: 'test-token', + }, + }); + + expect(setRefreshTokenInSecureStore).toHaveBeenCalledWith('test-token'); + }); +}); diff --git a/yarn.lock b/yarn.lock index 4d799461b..fbe237582 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3398,14 +3398,17 @@ __metadata: expo: ^52.0.44 expo-application: ^5.0.1 expo-modules-core: ^2.2.3 + expo-secure-store: ~14.0.1 jest-expo: ~52.0.6 localforage: ^1.7.4 lodash: ^4.17.19 process: ~0.11.10 react: ^19.1.0 react-native: ^0.78.2 + react-native-device-crypto: ^0.1.7 react-native-event-listeners: ^1.0.7 react-native-safe-area-context: ^5.3.0 + react-native-uuid: ^2.0.3 react-native-webview: ^13.13.5 react-test-renderer: ^19.1.0 regenerator-runtime: 0.13.9 @@ -3414,8 +3417,10 @@ __metadata: peerDependencies: "@react-native-community/netinfo": ">=9.0.0" expo: "*" + expo-secure-store: ~14.0.1 react: ">=17" react-native: ">=0.60" + react-native-device-crypto: ^0.1.7 react-native-safe-area-context: ">=4.4.1" react-native-webview: ">=12.4.0" languageName: unknown @@ -10368,6 +10373,15 @@ __metadata: languageName: node linkType: hard +"expo-secure-store@npm:~14.0.1": + version: 14.0.1 + resolution: "expo-secure-store@npm:14.0.1" + peerDependencies: + expo: "*" + checksum: a38f4fc06ba1ff334c930a433fbc12c49e3af63e32687bd4bcfa0130da2aefa55ab721a5ce248387962399f2d7a13b6979ae3c81363d076376dbfa6bc4584dad + languageName: node + linkType: hard + "expo-web-browser@npm:14.0.2": version: 14.0.2 resolution: "expo-web-browser@npm:14.0.2" @@ -17270,6 +17284,16 @@ __metadata: languageName: node linkType: hard +"react-native-device-crypto@npm:^0.1.7": + version: 0.1.7 + resolution: "react-native-device-crypto@npm:0.1.7" + peerDependencies: + react: "*" + react-native: "*" + checksum: dc1fae91223074b8a4ddc4206f9b00490696389d3f40c1e2c071e7592119890951cd13bf7a9b4c302e8caa72a0a5fcbb0efdedf752ba87d35a24cfc09b507522 + languageName: node + linkType: hard + "react-native-device-info@npm:^10.3.0": version: 10.4.0 resolution: "react-native-device-info@npm:10.4.0" @@ -17310,6 +17334,13 @@ __metadata: languageName: node linkType: hard +"react-native-uuid@npm:^2.0.3": + version: 2.0.3 + resolution: "react-native-uuid@npm:2.0.3" + checksum: e47774481feaed5d38f75fb4b03f5189c03e7452038f1b0fa56677add8a8bdef64ec7ad5e83f9fa761d8788cac5c8e554ade07e82a2818145337df7c883e2124 + languageName: node + linkType: hard + "react-native-webview@npm:^13.13.5, react-native-webview@npm:^13.3.0": version: 13.13.5 resolution: "react-native-webview@npm:13.13.5"