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-bare/MagicSdkReactNativeBare.podspec b/packages/@magic-sdk/react-native-bare/MagicSdkReactNativeBare.podspec new file mode 100644 index 000000000..d7256c050 --- /dev/null +++ b/packages/@magic-sdk/react-native-bare/MagicSdkReactNativeBare.podspec @@ -0,0 +1,23 @@ +require "json" + +package = JSON.parse(File.read(File.join(__dir__, "package.json"))) + +Pod::Spec.new do |s| + s.name = "MagicSdkReactNativeBare" + s.version = package["version"] + s.summary = package["description"] + s.description = <<-DESC + #{package["description"]} + DESC + s.homepage = package["homepage"] + s.license = package["license"] + s.author = package["author"] + s.platforms = { :ios => "13.4" } + s.source = { :git => package["repository"]["url"].gsub(/.git$/, ''), :tag => "#{s.version}" } + + # This is a pure JavaScript package + # Native dependencies (react-native-keychain, react-native-device-crypto) will be + # automatically linked via React Native autolinking when this package is installed + + s.dependency "React-Core" +end diff --git a/packages/@magic-sdk/react-native-bare/package.json b/packages/@magic-sdk/react-native-bare/package.json index 8b3d23299..42c7fca74 100644 --- a/packages/@magic-sdk/react-native-bare/package.json +++ b/packages/@magic-sdk/react-native-bare/package.json @@ -10,7 +10,8 @@ }, "homepage": "https://magic.link", "files": [ - "dist" + "dist", + "*.podspec" ], "target": "node", "main": "./dist/cjs/index.js", @@ -28,6 +29,7 @@ "process": "~0.11.10", "react-native-device-info": "^10.3.0", "react-native-event-listeners": "^1.0.7", + "react-native-uuid": "^2.0.3", "regenerator-runtime": "0.13.9", "tslib": "^2.0.3", "whatwg-url": "~8.1.0" @@ -38,7 +40,9 @@ "@testing-library/react-native": "^13.2.0", "react": "~19.1.0", "react-native": "~0.78.1", + "react-native-device-crypto": "^0.1.7", "react-native-device-info": "^10.3.0", + "react-native-keychain": "^10.0.0", "react-native-safe-area-context": "5.3.0", "react-native-webview": "^13.3.0", "react-test-renderer": "^19.1.0" @@ -48,7 +52,9 @@ "@react-native-community/netinfo": ">=9.0.0", "react": ">=16", "react-native": ">=0.60", + "react-native-device-crypto": "^0.1.7", "react-native-device-info": ">=10.3.0", + "react-native-keychain": "^10.0.0", "react-native-safe-area-context": ">=4.4.1", "react-native-webview": ">=12.4.0" }, diff --git a/packages/@magic-sdk/react-native-bare/src/native-crypto/check-native-modules.ts b/packages/@magic-sdk/react-native-bare/src/native-crypto/check-native-modules.ts new file mode 100644 index 000000000..b4afcfd28 --- /dev/null +++ b/packages/@magic-sdk/react-native-bare/src/native-crypto/check-native-modules.ts @@ -0,0 +1,89 @@ +import { NativeModules, Platform } from 'react-native'; + +interface NativeModuleCheck { + name: string; + nativeModuleName: string; + packageName: string; +} + +const REQUIRED_NATIVE_MODULES: NativeModuleCheck[] = [ + { + name: 'react-native-keychain', + nativeModuleName: 'RNKeychainManager', + packageName: 'react-native-keychain', + }, + { + name: 'react-native-device-crypto', + nativeModuleName: 'DeviceCrypto', + packageName: 'react-native-device-crypto', + }, +]; + +// 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 (!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 => `npm install ${m.packageName}`).join('\n'); + + const iosInstructions = + platform === 'ios' + ? ` +For iOS, run: + cd ios && pod install && cd .. +` + : ''; + + const androidInstructions = + platform === 'android' + ? ` +For Android, rebuild your app: + npx react-native run-android +` + : ''; + + console.warn( + `@magic-sdk/react-native-bare: 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. Link the native modules: +${iosInstructions}${androidInstructions} +3. Rebuild your app completely. +`, + ); + } +}; diff --git a/packages/@magic-sdk/react-native-bare/src/native-crypto/constants/index.ts b/packages/@magic-sdk/react-native-bare/src/native-crypto/constants/index.ts new file mode 100644 index 000000000..1380f90ef --- /dev/null +++ b/packages/@magic-sdk/react-native-bare/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-bare/src/native-crypto/dpop.ts b/packages/@magic-sdk/react-native-bare/src/native-crypto/dpop.ts new file mode 100644 index 000000000..8c863e368 --- /dev/null +++ b/packages/@magic-sdk/react-native-bare/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-bare/src/native-crypto/keychain.ts b/packages/@magic-sdk/react-native-bare/src/native-crypto/keychain.ts new file mode 100644 index 000000000..daa5bebb6 --- /dev/null +++ b/packages/@magic-sdk/react-native-bare/src/native-crypto/keychain.ts @@ -0,0 +1,54 @@ +import * as Keychain from 'react-native-keychain'; +import { getKeyAlias } from './utils/key-alias'; + +const SERVICE = getKeyAlias('refreshTokenService'); +const KEY = getKeyAlias('refreshToken'); + +let cachedRefreshToken: string | null = null; + +export const setRefreshTokenInKeychain = async (rt: string) => { + // Skip write if token hasn't changed + if (cachedRefreshToken === rt) { + return true; + } + + try { + const result = await Keychain.setGenericPassword(KEY, rt, { + service: SERVICE, + accessible: Keychain.ACCESSIBLE.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY, + }); + cachedRefreshToken = rt; // Update cache on successful write + return result; + } catch (error) { + console.error('Failed to set refresh token in keychain', error); + return false; + } +}; + +export const getRefreshTokenInKeychain = async (): Promise => { + // Return cached value if available + if (cachedRefreshToken !== null) { + return cachedRefreshToken; + } + + try { + const keychainEntry = await Keychain.getGenericPassword({ service: SERVICE }); + if (!keychainEntry) return null; + + cachedRefreshToken = keychainEntry.password; + return cachedRefreshToken; + } catch (error) { + console.error('Failed to get refresh token in keychain', error); + return null; + } +}; + +export const removeRefreshTokenInKeychain = async () => { + try { + cachedRefreshToken = null; // Clear cache + return await Keychain.resetGenericPassword({ service: SERVICE }); + } catch (error) { + console.error('Failed to remove refresh token in keychain', error); + return null; + } +}; diff --git a/packages/@magic-sdk/react-native-bare/src/native-crypto/types/index.ts b/packages/@magic-sdk/react-native-bare/src/native-crypto/types/index.ts new file mode 100644 index 000000000..4069afd27 --- /dev/null +++ b/packages/@magic-sdk/react-native-bare/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-bare/src/native-crypto/utils/der.ts b/packages/@magic-sdk/react-native-bare/src/native-crypto/utils/der.ts new file mode 100644 index 000000000..98b296f12 --- /dev/null +++ b/packages/@magic-sdk/react-native-bare/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-bare/src/native-crypto/utils/jwk.ts b/packages/@magic-sdk/react-native-bare/src/native-crypto/utils/jwk.ts new file mode 100644 index 000000000..3cde8102c --- /dev/null +++ b/packages/@magic-sdk/react-native-bare/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-bare/src/native-crypto/utils/key-alias.ts b/packages/@magic-sdk/react-native-bare/src/native-crypto/utils/key-alias.ts new file mode 100644 index 000000000..6147d4380 --- /dev/null +++ b/packages/@magic-sdk/react-native-bare/src/native-crypto/utils/key-alias.ts @@ -0,0 +1,16 @@ +import DeviceInfo from 'react-native-device-info'; + +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 { + const appId = DeviceInfo.getBundleId(); + return `${appId}.${KEY_SUFFIX_MAP[key]}`; +} diff --git a/packages/@magic-sdk/react-native-bare/src/native-crypto/utils/uint8.ts b/packages/@magic-sdk/react-native-bare/src/native-crypto/utils/uint8.ts new file mode 100644 index 000000000..ac4edcc8d --- /dev/null +++ b/packages/@magic-sdk/react-native-bare/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-bare/src/react-native-webview-controller.tsx b/packages/@magic-sdk/react-native-bare/src/react-native-webview-controller.tsx index 7a252f576..59ff62517 100644 --- a/packages/@magic-sdk/react-native-bare/src/react-native-webview-controller.tsx +++ b/packages/@magic-sdk/react-native-bare/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 { getRefreshTokenInKeychain, setRefreshTokenInKeychain } 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'; @@ -22,10 +25,7 @@ const LAST_MESSAGE_TIME = 'lastMessageTime'; */ function createWebViewStyles() { return StyleSheet.create({ - 'magic-webview': { - flex: 1, - backgroundColor: 'transparent', - }, + 'magic-webview': { flex: 1, backgroundColor: 'transparent' }, 'webview-container': { flex: 1, @@ -38,15 +38,9 @@ function createWebViewStyles() { bottom: 0, }, - show: { - zIndex: 10000, - elevation: 10000, - }, + show: { zIndex: 10000, elevation: 10000 }, - hide: { - zIndex: -10000, - elevation: 0, - }, + hide: { zIndex: -10000, elevation: 0 }, }); } @@ -87,6 +81,10 @@ export class ReactNativeWebViewController extends ViewController { const [mountOverlay, setMountOverlay] = useState(true); const isConnected = useInternetConnection(); + useEffect(() => { + checkNativeModules(); + }, []); + useEffect(() => { this.isConnectedToInternet = isConnected; }, [isConnected]); @@ -132,11 +130,7 @@ export class ReactNativeWebViewController extends ViewController { * display styles. */ const containerRef = useCallback((view: any): void => { - this.container = { - ...view, - showOverlay, - hideOverlay, - }; + this.container = { ...view, showOverlay, hideOverlay }; }, []); /** @@ -156,12 +150,7 @@ export class ReactNativeWebViewController extends ViewController { const containerStyles = useMemo(() => { return [ this.styles['webview-container'], - show - ? { - ...this.styles.show, - backgroundColor: backgroundColor ?? DEFAULT_BACKGROUND_COLOR, - } - : this.styles.hide, + show ? { ...this.styles.show, backgroundColor: backgroundColor ?? DEFAULT_BACKGROUND_COLOR } : this.styles.hide, ]; }, [show]); @@ -289,6 +278,28 @@ export class ReactNativeWebViewController extends ViewController { } } + // Overrides parent method to keep refresh token in keychain + async persistMagicEventRefreshToken(event: MagicMessageEvent) { + if (!event?.data?.rt) { + return; + } + + await setRefreshTokenInKeychain(event.data.rt); + } + + // Overrides parent method to retrieve refresh token from keychain while creating a request + async getRT(): Promise { + return await getRefreshTokenInKeychain(); + } + + 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-bare/test/spec/native-crypto/check-native-modules.spec.ts b/packages/@magic-sdk/react-native-bare/test/spec/native-crypto/check-native-modules.spec.ts new file mode 100644 index 000000000..be4a3c25f --- /dev/null +++ b/packages/@magic-sdk/react-native-bare/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, +})); + +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['RNKeychainManager']; + delete mockNativeModules['DeviceCrypto']; + mockPlatform.OS = 'ios'; + }); + + afterEach(() => { + consoleWarnSpy.mockRestore(); + }); + + it('should not log any warning when all native modules are present', () => { + mockNativeModules.RNKeychainManager = {}; + mockNativeModules.DeviceCrypto = {}; + + const { checkNativeModules } = require('../../../src/native-crypto/check-native-modules'); + checkNativeModules(); + + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it('should log a warning when react-native-keychain is missing', () => { + mockNativeModules.RNKeychainManager = undefined; + mockNativeModules.DeviceCrypto = {}; + + const { checkNativeModules } = require('../../../src/native-crypto/check-native-modules'); + checkNativeModules(); + + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('react-native-keychain')); + }); + + it('should log a warning when react-native-device-crypto is missing', () => { + mockNativeModules.RNKeychainManager = {}; + 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.RNKeychainManager = undefined; + mockNativeModules.DeviceCrypto = undefined; + + const { checkNativeModules } = require('../../../src/native-crypto/check-native-modules'); + checkNativeModules(); + + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('react-native-keychain')); + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('react-native-device-crypto')); + }); + + it('should only log warning once even if called multiple times', () => { + mockNativeModules.RNKeychainManager = undefined; + 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.RNKeychainManager = undefined; + mockNativeModules.DeviceCrypto = {}; + + const { checkNativeModules } = require('../../../src/native-crypto/check-native-modules'); + checkNativeModules(); + + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('pod install')); + }); + + it('should include Android instructions when platform is android', () => { + mockPlatform.OS = 'android'; + mockNativeModules.RNKeychainManager = undefined; + mockNativeModules.DeviceCrypto = {}; + + const { checkNativeModules } = require('../../../src/native-crypto/check-native-modules'); + checkNativeModules(); + + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('npx react-native run-android')); + }); + + it('should not include iOS instructions when platform is android', () => { + mockPlatform.OS = 'android'; + mockNativeModules.RNKeychainManager = undefined; + mockNativeModules.DeviceCrypto = {}; + + const { checkNativeModules } = require('../../../src/native-crypto/check-native-modules'); + checkNativeModules(); + + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy).not.toHaveBeenCalledWith(expect.stringContaining('pod install')); + }); + + it('should not include Android instructions when platform is ios', () => { + mockPlatform.OS = 'ios'; + mockNativeModules.RNKeychainManager = undefined; + mockNativeModules.DeviceCrypto = {}; + + const { checkNativeModules } = require('../../../src/native-crypto/check-native-modules'); + checkNativeModules(); + + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy).not.toHaveBeenCalledWith(expect.stringContaining('npx react-native run-android')); + }); + + it('should include install commands for missing packages', () => { + mockNativeModules.RNKeychainManager = undefined; + mockNativeModules.DeviceCrypto = undefined; + + const { checkNativeModules } = require('../../../src/native-crypto/check-native-modules'); + checkNativeModules(); + + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('npm install react-native-keychain')); + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('npm install react-native-device-crypto')); + }); +}); \ No newline at end of file diff --git a/packages/@magic-sdk/react-native-bare/test/spec/native-crypto/dpop.spec.ts b/packages/@magic-sdk/react-native-bare/test/spec/native-crypto/dpop.spec.ts new file mode 100644 index 000000000..daff90f34 --- /dev/null +++ b/packages/@magic-sdk/react-native-bare/test/spec/native-crypto/dpop.spec.ts @@ -0,0 +1,170 @@ +// Mock react-native-device-crypto before any imports +jest.mock('react-native-device-crypto', () => ({ + __esModule: true, + default: { + getOrCreateAsymmetricKey: jest.fn(), + sign: jest.fn(), + deleteKey: jest.fn(), + }, + AccessLevel: { + ALWAYS: 'ALWAYS', + }, +})); + +jest.mock('react-native-uuid', () => ({ + v4: () => 'test-uuid-1234', +})); + +// react-native-device-info is already mocked in test/mocks.ts + +import DeviceCrypto from 'react-native-device-crypto'; +import { getDpop, deleteDpop } from '../../../src/native-crypto/dpop'; +import { uint8ArrayToBase64 } from '../../../src/native-crypto/utils/uint8'; + +// Helper to create a mock SPKI public key +const createMockPublicKey = () => { + 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]); + return uint8ArrayToBase64(new Uint8Array([...spkiHeader, ...keyData])); +}; + +// Helper to create a mock DER signature +const createMockDerSignature = () => { + const r = new Uint8Array(32).fill(0x33); + const s = new Uint8Array(32).fill(0x44); + return uint8ArrayToBase64(new Uint8Array([0x30, 68, 0x02, 32, ...r, 0x02, 32, ...s])); +}; + +describe('dpop', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getDpop', () => { + it('should generate a valid DPoP token', async () => { + const mockPublicKey = createMockPublicKey(); + const mockSignature = createMockDerSignature(); + + (DeviceCrypto.getOrCreateAsymmetricKey as jest.Mock).mockResolvedValue(mockPublicKey); + (DeviceCrypto.sign as jest.Mock).mockResolvedValue(mockSignature); + + 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'); + }); + + it('should call DeviceCrypto with correct key alias and options', async () => { + const mockPublicKey = createMockPublicKey(); + const mockSignature = createMockDerSignature(); + + (DeviceCrypto.getOrCreateAsymmetricKey as jest.Mock).mockResolvedValue(mockPublicKey); + (DeviceCrypto.sign as jest.Mock).mockResolvedValue(mockSignature); + + await getDpop(); + + expect(DeviceCrypto.getOrCreateAsymmetricKey).toHaveBeenCalledWith('com.apple.mockApp.magic.sdk.dpop', { + accessLevel: 'ALWAYS', + invalidateOnNewBiometry: false, + }); + + expect(DeviceCrypto.sign).toHaveBeenCalledWith( + 'com.apple.mockApp.magic.sdk.dpop', + expect.stringContaining('.'), + expect.objectContaining({ + biometryTitle: 'Sign DPoP', + biometrySubTitle: 'Sign DPoP', + biometryDescription: 'Sign DPoP', + }), + ); + }); + + it('should return null when key creation fails', async () => { + (DeviceCrypto.getOrCreateAsymmetricKey as jest.Mock).mockRejectedValue(new Error('Key creation failed')); + + const result = await getDpop(); + + expect(result).toBeNull(); + }); + + it('should return null when signing fails', async () => { + const mockPublicKey = createMockPublicKey(); + + (DeviceCrypto.getOrCreateAsymmetricKey as jest.Mock).mockResolvedValue(mockPublicKey); + (DeviceCrypto.sign as jest.Mock).mockRejectedValue(new Error('Signing failed')); + + const result = await getDpop(); + + expect(result).toBeNull(); + }); + + it('should include correct timestamp in claims', async () => { + const mockDate = new Date('2024-01-15T12:00:00Z'); + jest.spyOn(global.Date, 'now').mockReturnValue(mockDate.getTime()); + + const mockPublicKey = createMockPublicKey(); + const mockSignature = createMockDerSignature(); + + (DeviceCrypto.getOrCreateAsymmetricKey as jest.Mock).mockResolvedValue(mockPublicKey); + (DeviceCrypto.sign as jest.Mock).mockResolvedValue(mockSignature); + + 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 return true when key is deleted successfully', async () => { + (DeviceCrypto.deleteKey as jest.Mock).mockResolvedValue(true); + + const result = await deleteDpop(); + + expect(result).toBe(true); + expect(DeviceCrypto.deleteKey).toHaveBeenCalledWith('com.apple.mockApp.magic.sdk.dpop'); + }); + + it('should return false when deletion throws an error', async () => { + (DeviceCrypto.deleteKey as jest.Mock).mockRejectedValue(new Error('Deletion failed')); + + const result = await deleteDpop(); + + expect(result).toBe(false); + }); + + it('should return false when deleteKey returns false', async () => { + (DeviceCrypto.deleteKey as jest.Mock).mockResolvedValue(false); + + const result = await deleteDpop(); + + expect(result).toBe(false); + }); + }); +}); diff --git a/packages/@magic-sdk/react-native-bare/test/spec/native-crypto/keychain.spec.ts b/packages/@magic-sdk/react-native-bare/test/spec/native-crypto/keychain.spec.ts new file mode 100644 index 000000000..7eade850f --- /dev/null +++ b/packages/@magic-sdk/react-native-bare/test/spec/native-crypto/keychain.spec.ts @@ -0,0 +1,111 @@ +import { + getRefreshTokenInKeychain, + removeRefreshTokenInKeychain, + setRefreshTokenInKeychain, +} from '../../../src/native-crypto/keychain'; +import { setGenericPassword, getGenericPassword, resetGenericPassword, ACCESSIBLE } from 'react-native-keychain'; + +jest.mock('react-native-device-info', () => ({ + getBundleId: () => 'com.apple.mockApp', +})); + +jest.mock('react-native-keychain', () => ({ + setGenericPassword: jest.fn(() => Promise.resolve('mock-result')), + getGenericPassword: jest.fn(() => Promise.resolve({ password: 'test-token' })), + resetGenericPassword: jest.fn(() => Promise.resolve(true)), + ACCESSIBLE: { + AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY: 'AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY', + }, +})); + +describe('setRefreshTokenInKeychain', () => { + it('should store refresh token successfully', async () => { + await removeRefreshTokenInKeychain(); + const result = await setRefreshTokenInKeychain('test-token'); + + expect(setGenericPassword).toHaveBeenCalledWith('com.apple.mockApp.magic.sdk.rt', 'test-token', { + service: 'com.apple.mockApp.magic.sdk.rt.service', + accessible: ACCESSIBLE.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY, + }); + expect(result).toBe('mock-result'); + }); + it('should skip write if token has not changed (cached)', async () => { + await setRefreshTokenInKeychain('test-token'); + await setRefreshTokenInKeychain('test-token'); + + expect(setGenericPassword).toHaveBeenCalledTimes(1); + }); + it('should return false on error', async () => { + (setGenericPassword as jest.Mock).mockRejectedValueOnce(new Error('test-error')); + + await removeRefreshTokenInKeychain(); + const result = await setRefreshTokenInKeychain('test-token-2'); + + expect(result).toBe(false); + expect(setGenericPassword).toHaveBeenCalledWith('com.apple.mockApp.magic.sdk.rt', 'test-token', { + service: 'com.apple.mockApp.magic.sdk.rt.service', + accessible: ACCESSIBLE.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY, + }); + }); +}); + +describe('getRefreshTokenInKeychain', () => { + it('should retrieve refresh token successfully', async () => { + await removeRefreshTokenInKeychain(); // make sure to reset cache; + + const result = await getRefreshTokenInKeychain(); + + expect(result).toBe('test-token'); + expect(getGenericPassword).toHaveBeenCalledWith({ service: 'com.apple.mockApp.magic.sdk.rt.service' }); + }); + it('should return cached token if available', async () => { + jest.clearAllMocks(); + await setRefreshTokenInKeychain('cached-token'); + + const result = await getRefreshTokenInKeychain(); + + expect(result).toBe('cached-token'); + expect(getGenericPassword).not.toHaveBeenCalled(); + }); + it('should return null on error', async () => { + (getGenericPassword as jest.Mock).mockRejectedValueOnce(new Error('test-error')); + + await removeRefreshTokenInKeychain(); + const result = await getRefreshTokenInKeychain(); + + expect(result).toBeNull(); + }); + it('should return null if no refresh token found in keychain', async () => { + (getGenericPassword as jest.Mock).mockResolvedValueOnce(undefined); + + await removeRefreshTokenInKeychain(); + const result = await getRefreshTokenInKeychain(); + + expect(result).toBeNull(); + }); +}); + +describe('removeRefreshTokenInKeychain', () => { + it('should remove refresh token successfully', async () => { + const result = await removeRefreshTokenInKeychain(); + expect(result).toBe(true); + expect(resetGenericPassword).toHaveBeenCalledWith({ service: 'com.apple.mockApp.magic.sdk.rt.service' }); + }); + it('should clear cache when removing token', async () => { + await setRefreshTokenInKeychain('cached-token'); // cache the token + + const success = await removeRefreshTokenInKeychain(); // remove both from cache & keychain + const refreshToken = await getRefreshTokenInKeychain(); // try to get it from keychain and get mock value + + expect(success).toBe(true); + expect(refreshToken).toBe('test-token'); + expect(resetGenericPassword).toHaveBeenCalledWith({ service: 'com.apple.mockApp.magic.sdk.rt.service' }); + expect(getGenericPassword).toHaveBeenCalledWith({ service: 'com.apple.mockApp.magic.sdk.rt.service' }); + }); + it('should handle error gracefully', async () => { + (resetGenericPassword as jest.Mock).mockRejectedValue(new Error('test-error')); + const result = await removeRefreshTokenInKeychain(); + expect(result).toBeNull(); + expect(resetGenericPassword).toHaveBeenCalledWith({ service: 'com.apple.mockApp.magic.sdk.rt.service' }); + }); +}); diff --git a/packages/@magic-sdk/react-native-bare/test/spec/native-crypto/utils/der.spec.ts b/packages/@magic-sdk/react-native-bare/test/spec/native-crypto/utils/der.spec.ts new file mode 100644 index 000000000..def2cc840 --- /dev/null +++ b/packages/@magic-sdk/react-native-bare/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-bare/test/spec/native-crypto/utils/jwk.spec.ts b/packages/@magic-sdk/react-native-bare/test/spec/native-crypto/utils/jwk.spec.ts new file mode 100644 index 000000000..02465ac4b --- /dev/null +++ b/packages/@magic-sdk/react-native-bare/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-bare/test/spec/native-crypto/utils/key-alias.spec.ts b/packages/@magic-sdk/react-native-bare/test/spec/native-crypto/utils/key-alias.spec.ts new file mode 100644 index 000000000..be87be54b --- /dev/null +++ b/packages/@magic-sdk/react-native-bare/test/spec/native-crypto/utils/key-alias.spec.ts @@ -0,0 +1,23 @@ +import { getKeyAlias } from '../../../../src/native-crypto/utils/key-alias'; + +// react-native-device-info is already mocked in test/mocks.ts + +describe('key-alias utilities', () => { + describe('getKeyAlias', () => { + it('should return correct alias for dpop key', () => { + const result = getKeyAlias('dpop'); + expect(result).toBe('com.apple.mockApp.magic.sdk.dpop'); + }); + + it('should return correct alias for refreshToken key', () => { + const result = getKeyAlias('refreshToken'); + expect(result).toBe('com.apple.mockApp.magic.sdk.rt'); + }); + + it('should return correct alias for refreshTokenService key', () => { + const result = getKeyAlias('refreshTokenService'); + expect(result).toBe('com.apple.mockApp.magic.sdk.rt.service'); + }); + }); +}); + diff --git a/packages/@magic-sdk/react-native-bare/test/spec/native-crypto/utils/uint8.spec.ts b/packages/@magic-sdk/react-native-bare/test/spec/native-crypto/utils/uint8.spec.ts new file mode 100644 index 000000000..9582e5486 --- /dev/null +++ b/packages/@magic-sdk/react-native-bare/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-bare/test/spec/react-native-webview-controller/getJWT.spec.ts b/packages/@magic-sdk/react-native-bare/test/spec/react-native-webview-controller/getJWT.spec.ts new file mode 100644 index 000000000..1ac805f1a --- /dev/null +++ b/packages/@magic-sdk/react-native-bare/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-bare/test/spec/react-native-webview-controller/getRT.spec.ts b/packages/@magic-sdk/react-native-bare/test/spec/react-native-webview-controller/getRT.spec.ts new file mode 100644 index 000000000..da6528d31 --- /dev/null +++ b/packages/@magic-sdk/react-native-bare/test/spec/react-native-webview-controller/getRT.spec.ts @@ -0,0 +1,15 @@ +import { getRefreshTokenInKeychain } from '../../../src/native-crypto/keychain'; +import { createReactNativeWebViewController } from '../../factories'; + +jest.mock('../../../src/native-crypto/keychain', () => ({ + getRefreshTokenInKeychain: jest.fn(), +})); + +describe('getRT', () => { + it('should call getRefreshTokenInKeychain', async () => { + const viewController = createReactNativeWebViewController('asdf'); + await viewController.getRT(); + + expect(getRefreshTokenInKeychain).toHaveBeenCalled(); + }); +}); diff --git a/packages/@magic-sdk/react-native-bare/test/spec/react-native-webview-controller/persistMagicEventRefreshToken.spec.ts b/packages/@magic-sdk/react-native-bare/test/spec/react-native-webview-controller/persistMagicEventRefreshToken.spec.ts new file mode 100644 index 000000000..a213c6d1e --- /dev/null +++ b/packages/@magic-sdk/react-native-bare/test/spec/react-native-webview-controller/persistMagicEventRefreshToken.spec.ts @@ -0,0 +1,27 @@ +import { setRefreshTokenInKeychain } from '../../../src/native-crypto/keychain'; +import { createReactNativeWebViewController } from '../../factories'; + +jest.mock('../../../src/native-crypto/keychain', () => ({ + setRefreshTokenInKeychain: jest.fn(), +})); + +describe('persistMagicEventRefreshToken', () => { + it('should not call setRefreshTokenInKeychain if no data.rt', async () => { + const viewController = createReactNativeWebViewController('asdf'); + const result = await viewController.persistMagicEventRefreshToken({}); + + expect(result).toBeUndefined(); + expect(setRefreshTokenInKeychain).not.toHaveBeenCalled(); + }); + + it('should call setRefreshTokenInKeychain', async () => { + const viewController = createReactNativeWebViewController('asdf'); + await viewController.persistMagicEventRefreshToken({ + data: { + rt: 'test-token', + }, + }); + + expect(setRefreshTokenInKeychain).toHaveBeenCalledWith('test-token'); + }); +}); diff --git a/yarn.lock b/yarn.lock index 4d799461b..fa3f34ac6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3362,9 +3362,12 @@ __metadata: process: ~0.11.10 react: ~19.1.0 react-native: ~0.78.1 + react-native-device-crypto: ^0.1.7 react-native-device-info: ^10.3.0 react-native-event-listeners: ^1.0.7 + react-native-keychain: ^10.0.0 react-native-safe-area-context: 5.3.0 + react-native-uuid: ^2.0.3 react-native-webview: ^13.3.0 react-test-renderer: ^19.1.0 regenerator-runtime: 0.13.9 @@ -3375,7 +3378,9 @@ __metadata: "@react-native-community/netinfo": ">=9.0.0" react: ">=16" react-native: ">=0.60" + react-native-device-crypto: ^0.1.7 react-native-device-info: ">=10.3.0" + react-native-keychain: ^10.0.0 react-native-safe-area-context: ">=4.4.1" react-native-webview: ">=12.4.0" languageName: unknown @@ -17270,6 +17275,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" @@ -17300,6 +17315,13 @@ __metadata: languageName: node linkType: hard +"react-native-keychain@npm:^10.0.0": + version: 10.0.0 + resolution: "react-native-keychain@npm:10.0.0" + checksum: 1055cc192df58cb110bd49df1264a401007a354ad28b93adefd740aeac0f746cdab86d7428887694a1b0949ef8d0dc85db8984e77db684d7b967180167b64d42 + languageName: node + linkType: hard + "react-native-safe-area-context@npm:5.3.0, react-native-safe-area-context@npm:^5.3.0": version: 5.3.0 resolution: "react-native-safe-area-context@npm:5.3.0" @@ -17310,6 +17332,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"