From 10eaea58c0e31e36a703026bae1be74373f6f734 Mon Sep 17 00:00:00 2001 From: sherzod-bakhodirov Date: Fri, 21 Nov 2025 21:57:23 +0500 Subject: [PATCH 01/17] feat: implement keychain for rt --- .../provider/src/core/view-controller.ts | 16 ++++++- .../@magic-sdk/react-native-bare/package.json | 1 + .../react-native-bare/src/keychain.ts | 22 +++++++++ .../src/react-native-webview-controller.tsx | 46 +++++++++---------- yarn.lock | 8 ++++ 5 files changed, 68 insertions(+), 25 deletions(-) create mode 100644 packages/@magic-sdk/react-native-bare/src/keychain.ts diff --git a/packages/@magic-sdk/provider/src/core/view-controller.ts b/packages/@magic-sdk/provider/src/core/view-controller.ts index 84d63264b..48b8a8a64 100644 --- a/packages/@magic-sdk/provider/src/core/view-controller.ts +++ b/packages/@magic-sdk/provider/src/core/view-controller.ts @@ -12,10 +12,10 @@ 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'; +import { setItem } from '../util/storage'; interface RemoveEventListenerFunction { (): void; @@ -111,7 +111,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 +249,16 @@ export abstract class ViewController { this.heartbeatIntervalTimer = null; } } + + async persistMagicEventRefreshToken(event: MagicMessageEvent) { + if (!event.data.rt) { + return; + } + + await this.persistRefreshToken(event.data.rt) + } + + persistRefreshToken(rt: string) { + return setItem('rt', rt); + } } diff --git a/packages/@magic-sdk/react-native-bare/package.json b/packages/@magic-sdk/react-native-bare/package.json index 8b3d23299..fc6de9ffe 100644 --- a/packages/@magic-sdk/react-native-bare/package.json +++ b/packages/@magic-sdk/react-native-bare/package.json @@ -28,6 +28,7 @@ "process": "~0.11.10", "react-native-device-info": "^10.3.0", "react-native-event-listeners": "^1.0.7", + "react-native-keychain": "^10.0.0", "regenerator-runtime": "0.13.9", "tslib": "^2.0.3", "whatwg-url": "~8.1.0" diff --git a/packages/@magic-sdk/react-native-bare/src/keychain.ts b/packages/@magic-sdk/react-native-bare/src/keychain.ts new file mode 100644 index 000000000..ddb4ceaa9 --- /dev/null +++ b/packages/@magic-sdk/react-native-bare/src/keychain.ts @@ -0,0 +1,22 @@ +import * as Keychain from 'react-native-keychain'; + +const SERVICE = 'magic_sdk_rt'; +const KEY = 'magic_rt'; + +export const setRefreshTokenInKeychain = async (rt: string) => { + return await Keychain.setGenericPassword(KEY, rt, { + service: SERVICE, + accessible: Keychain.ACCESSIBLE.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY, + }); +}; + +export const getRefreshTokenInKeychain = async () => { + const keychainEntry = await Keychain.getGenericPassword({ service: SERVICE }); + if (!keychainEntry) return null; + + return keychainEntry.password; +}; + +export const removeRefreshTokenInKeychain = async () => { + return await Keychain.resetGenericPassword({ service: SERVICE }); +}; 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..820e0fdb0 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,7 @@ import { EventRegister } from 'react-native-event-listeners'; /* global NodeJS */ import Global = NodeJS.Global; import { useInternetConnection } from './hooks'; +import { getRefreshTokenInKeychain, setRefreshTokenInKeychain } from './keychain'; const MAGIC_PAYLOAD_FLAG_TYPED_ARRAY = 'MAGIC_PAYLOAD_FLAG_TYPED_ARRAY'; const OPEN_IN_DEVICE_BROWSER = 'open_in_device_browser'; @@ -22,10 +23,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 +36,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 }, }); } @@ -99,6 +91,14 @@ export class ReactNativeWebViewController extends ViewController { }; }, []); + useEffect(() => { + // try to retrieve the refresh token from secure storage and set it to the localforage + getRefreshTokenInKeychain().then(rt => { + if (!rt) return; + super.persistRefreshToken(rt); + }); + }, []); + useEffect(() => { EventRegister.addEventListener(MSG_POSTED_AFTER_INACTIVITY_EVENT, async message => { // If inactivity has been determined, the message is posted only after a brief @@ -132,11 +132,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 +152,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 +280,15 @@ export class ReactNativeWebViewController extends ViewController { } } + async persistMagicEventRefreshToken(event: MagicMessageEvent) { + if (!event.data.rt) { + return; + } + + super.persistMagicEventRefreshToken(event); + setRefreshTokenInKeychain(event.data.rt); + } + // Todo - implement these methods /* istanbul ignore next */ protected checkRelayerExistsInDOM(): Promise { diff --git a/yarn.lock b/yarn.lock index b757299ae..bda093154 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3364,6 +3364,7 @@ __metadata: react-native: ~0.78.1 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-webview: ^13.3.0 react-test-renderer: ^19.1.0 @@ -17300,6 +17301,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" From e67fa2dfb4287a26f75bd0c3206f79609e040a68 Mon Sep 17 00:00:00 2001 From: sherzod-bakhodirov Date: Thu, 27 Nov 2025 16:28:40 +0500 Subject: [PATCH 02/17] feat: move createMagicRequest to the methods of View Controller --- .../provider/src/core/view-controller.ts | 66 +++++++++++++++++-- .../src/util/view-controller-utils.ts | 45 +------------ .../src/react-native-webview-controller.tsx | 15 ++--- 3 files changed, 66 insertions(+), 60 deletions(-) diff --git a/packages/@magic-sdk/provider/src/core/view-controller.ts b/packages/@magic-sdk/provider/src/core/view-controller.ts index 48b8a8a64..a963560cb 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 { clearDeviceShares, encryptAndPersistDeviceShare, getDecryptedDeviceShare } from '../util/device-share-web-crypto'; import { - createMagicRequest, standardizeResponse, debounce, + StandardizedMagicRequest, } from '../util/view-controller-utils'; -import { setItem } from '../util/storage'; +import { setItem, getItem } from '../util/storage'; +import { SDKEnvironment } from './sdk-environment'; +import { createJwt } from 'magic-sdk'; 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); @@ -255,10 +257,60 @@ export abstract class ViewController { return; } - await this.persistRefreshToken(event.data.rt) + await setItem('rt', event.data.rt); } - persistRefreshToken(rt: string) { - return setItem('rt', 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/src/react-native-webview-controller.tsx b/packages/@magic-sdk/react-native-bare/src/react-native-webview-controller.tsx index 820e0fdb0..b2c9e1d10 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 @@ -91,14 +91,6 @@ export class ReactNativeWebViewController extends ViewController { }; }, []); - useEffect(() => { - // try to retrieve the refresh token from secure storage and set it to the localforage - getRefreshTokenInKeychain().then(rt => { - if (!rt) return; - super.persistRefreshToken(rt); - }); - }, []); - useEffect(() => { EventRegister.addEventListener(MSG_POSTED_AFTER_INACTIVITY_EVENT, async message => { // If inactivity has been determined, the message is posted only after a brief @@ -280,15 +272,20 @@ export class ReactNativeWebViewController extends ViewController { } } + // Overrides parent method to keep refresh token in keychain async persistMagicEventRefreshToken(event: MagicMessageEvent) { if (!event.data.rt) { return; } - super.persistMagicEventRefreshToken(event); setRefreshTokenInKeychain(event.data.rt); } + // Overrides parent method to retrieve refresh token from keychain while creating a request + async getRT() { + return getRefreshTokenInKeychain() + } + // Todo - implement these methods /* istanbul ignore next */ protected checkRelayerExistsInDOM(): Promise { From 427d0862d35b7248ba415b35f846abfb2cc944bd Mon Sep 17 00:00:00 2001 From: sherzod-bakhodirov Date: Mon, 1 Dec 2025 16:34:53 +0500 Subject: [PATCH 03/17] feat: implement dpop generation using react-native-biometrics --- .../provider/src/core/view-controller.ts | 37 ++++--- .../@magic-sdk/react-native-bare/package.json | 2 + .../src/dpop/constants/index.ts | 4 + .../react-native-bare/src/dpop/dpop.ts | 98 +++++++++++++++++++ .../react-native-bare/src/dpop/utils/der.ts | 46 +++++++++ .../src/dpop/utils/dpop-alias.ts | 11 +++ .../react-native-bare/src/dpop/utils/jwk.ts | 26 +++++ .../react-native-bare/src/dpop/utils/uint8.ts | 72 ++++++++++++++ .../src/react-native-webview-controller.tsx | 14 ++- .../src/types/react-native-biometrics.d.ts | 26 +++++ yarn.lock | 19 ++++ 11 files changed, 335 insertions(+), 20 deletions(-) create mode 100644 packages/@magic-sdk/react-native-bare/src/dpop/constants/index.ts create mode 100644 packages/@magic-sdk/react-native-bare/src/dpop/dpop.ts create mode 100644 packages/@magic-sdk/react-native-bare/src/dpop/utils/der.ts create mode 100644 packages/@magic-sdk/react-native-bare/src/dpop/utils/dpop-alias.ts create mode 100644 packages/@magic-sdk/react-native-bare/src/dpop/utils/jwk.ts create mode 100644 packages/@magic-sdk/react-native-bare/src/dpop/utils/uint8.ts create mode 100644 packages/@magic-sdk/react-native-bare/src/types/react-native-biometrics.d.ts diff --git a/packages/@magic-sdk/provider/src/core/view-controller.ts b/packages/@magic-sdk/provider/src/core/view-controller.ts index a963560cb..f6310c2be 100644 --- a/packages/@magic-sdk/provider/src/core/view-controller.ts +++ b/packages/@magic-sdk/provider/src/core/view-controller.ts @@ -9,15 +9,15 @@ import { import { JsonRpcResponse } from './json-rpc'; import { createPromise } from '../util/promise-tools'; import { MagicSDKWarning, createModalNotReadyError } from './sdk-exceptions'; -import { clearDeviceShares, encryptAndPersistDeviceShare, getDecryptedDeviceShare } from '../util/device-share-web-crypto'; import { - standardizeResponse, - debounce, - StandardizedMagicRequest, -} 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 'magic-sdk'; +import { createJwt } from '../util/web-crypto'; interface RemoveEventListenerFunction { (): void; @@ -256,7 +256,7 @@ export abstract class ViewController { if (!event.data.rt) { return; } - + await setItem('rt', event.data.rt); } @@ -268,10 +268,9 @@ export abstract class ViewController { const request: StandardizedMagicRequest = { msgType, payload }; const rt = await this.getRT(); - const jwt = await this.getJWT() - const decryptedDeviceShare = await this.getDecryptedDeviceShare(networkHash) - - + const jwt = await this.getJWT(); + const decryptedDeviceShare = await this.getDecryptedDeviceShare(networkHash); + if (jwt) { request.jwt = jwt; } @@ -279,23 +278,23 @@ export abstract class ViewController { 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 { + 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() + const newJwt = await createJwt(); return newJwt; } catch (e) { console.error('webcrypto error', e); @@ -306,11 +305,11 @@ export abstract class ViewController { } } - async getRT():Promise { - return await getItem('rt') + async getRT(): Promise { + return await getItem('rt'); } - async getDecryptedDeviceShare(networkHash: string){ - return await getDecryptedDeviceShare(networkHash) + async getDecryptedDeviceShare(networkHash: string) { + return await getDecryptedDeviceShare(networkHash); } } diff --git a/packages/@magic-sdk/react-native-bare/package.json b/packages/@magic-sdk/react-native-bare/package.json index fc6de9ffe..5a33f3154 100644 --- a/packages/@magic-sdk/react-native-bare/package.json +++ b/packages/@magic-sdk/react-native-bare/package.json @@ -21,6 +21,7 @@ "@magic-sdk/provider": "^31.2.0", "@magic-sdk/types": "^25.2.0", "@react-native-async-storage/async-storage": "^2.1.2", + "@sbaiahmed1/react-native-biometrics": "^0.9.1", "@types/lodash": "^4.14.158", "buffer": "~5.6.0", "localforage": "^1.7.4", @@ -29,6 +30,7 @@ "react-native-device-info": "^10.3.0", "react-native-event-listeners": "^1.0.7", "react-native-keychain": "^10.0.0", + "react-native-uuid": "^2.0.3", "regenerator-runtime": "0.13.9", "tslib": "^2.0.3", "whatwg-url": "~8.1.0" diff --git a/packages/@magic-sdk/react-native-bare/src/dpop/constants/index.ts b/packages/@magic-sdk/react-native-bare/src/dpop/constants/index.ts new file mode 100644 index 000000000..06a24acbe --- /dev/null +++ b/packages/@magic-sdk/react-native-bare/src/dpop/constants/index.ts @@ -0,0 +1,4 @@ +// Unique alias suffix to isolate SDK keys from the rest of the app +export const DPOP_KEY_ALIAS_SUFFIX = 'magiclink.dpop'; +export const ALG = 'ES256'; +export const TYP = 'dpop+jwt'; diff --git a/packages/@magic-sdk/react-native-bare/src/dpop/dpop.ts b/packages/@magic-sdk/react-native-bare/src/dpop/dpop.ts new file mode 100644 index 000000000..aeea1f89e --- /dev/null +++ b/packages/@magic-sdk/react-native-bare/src/dpop/dpop.ts @@ -0,0 +1,98 @@ +import * as Biometrics from '@sbaiahmed1/react-native-biometrics'; +import uuid from 'react-native-uuid'; // Ensure you have installed: npm install react-native-uuid +import { toBase64Url } from './utils/uint8'; +import { spkiToJwk } from './utils/jwk'; +import { ALG, TYP } from './constants'; +import { derToRawSignature } from './utils/der'; +import { getDpopAlias } from './utils/dpop-alias'; + +/** + * Generates the DPoP proof compatible with the Python backend. + * Handles key creation (if missing), JWK construction, and signing. + * @param httpMethod - The HTTP method (e.g., 'POST') + * @param httpUrl - The HTTP URL being accessed + */ +export const getDpop = async (httpMethod?: string, httpUrl?: string): Promise => { + try { + // 1. Configure Isolation + const DPOP_KEY_ALIAS = await getDpopAlias(); + await Biometrics.configureKeyAlias(DPOP_KEY_ALIAS); + + // 2. Retrieve Public Key (Check if exists) + const keyAttrs = await Biometrics.getKeyAttributes(DPOP_KEY_ALIAS); + let publicKeyBase64 = ''; + + if (!keyAttrs.exists) { + // Create new keys in Secure Enclave (ec256) + const keyResult = await Biometrics.createKeys(DPOP_KEY_ALIAS, 'ec256', Biometrics.BiometricStrength.Weak); + publicKeyBase64 = keyResult.publicKey; + } else { + // Retrieve existing key + const allKeys = await Biometrics.getAllKeys(DPOP_KEY_ALIAS); + if (allKeys.keys.length > 0) { + publicKeyBase64 = allKeys.keys[0].publicKey; + } else { + // Fallback: Re-create if lookup failed but attributes said it existed + const keyResult = await Biometrics.createKeys(DPOP_KEY_ALIAS, 'ec256', Biometrics.BiometricStrength.Weak); + publicKeyBase64 = keyResult.publicKey; + } + } + + // 3. Construct JWK + const publicJwk = spkiToJwk(publicKeyBase64); + + // 4. Construct Payload + const iat = Math.floor(Date.now() / 1000); + const jti = uuid.v4(); + + const payload: any = { + iat, + jti, + }; + + if (httpMethod) payload.htm = httpMethod.toUpperCase(); + if (httpUrl) payload.htu = httpUrl; + + // 5. Construct Header + const header = { + typ: TYP, + alg: ALG, + jwk: publicJwk, + }; + + // 6. Prepare Signing Input + const headerB64 = toBase64Url(JSON.stringify(header)); + const payloadB64 = toBase64Url(JSON.stringify(payload)); + const signingInput = `${headerB64}.${payloadB64}`; + + // 7. Sign + const signatureResult = await Biometrics.verifyKeySignature(DPOP_KEY_ALIAS, signingInput); + + if (!signatureResult.success || !signatureResult.signature) { + throw new Error(`Signing failed: ${signatureResult.error || 'No signature returned'}`); + } + + // 8. Convert Signature (Toaster expects Raw R|S) + const signatureB64 = derToRawSignature(signatureResult.signature); + + // 9. Return DPoP String + return `${signingInput}.${signatureB64}`; + } catch (error) { + console.error('DPoP Generation Error:', error); + throw error; + } +}; + +/** + * Removes existing keys and invalidates the DPoP + */ +export const deleteDpop = async (): Promise => { + try { + const DPOP_KEY_ALIAS = await getDpopAlias(); + const result = await Biometrics.deleteKeys(DPOP_KEY_ALIAS); + return result.success; + } catch (error) { + console.error('DPoP Deletion Error:', error); + return false; + } +}; diff --git a/packages/@magic-sdk/react-native-bare/src/dpop/utils/der.ts b/packages/@magic-sdk/react-native-bare/src/dpop/utils/der.ts new file mode 100644 index 000000000..e210535be --- /dev/null +++ b/packages/@magic-sdk/react-native-bare/src/dpop/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). + * React Native Biometrics returns DER; Python 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/dpop/utils/dpop-alias.ts b/packages/@magic-sdk/react-native-bare/src/dpop/utils/dpop-alias.ts new file mode 100644 index 000000000..ca26bd184 --- /dev/null +++ b/packages/@magic-sdk/react-native-bare/src/dpop/utils/dpop-alias.ts @@ -0,0 +1,11 @@ +import { getDefaultKeyAlias } from '@sbaiahmed1/react-native-biometrics'; +import { DPOP_KEY_ALIAS_SUFFIX } from '../constants'; + +export const getDpopAlias = async () => { + // Each client integrating our SDK gets unique defaultAlias based on their bundle id or package name + // We append our own suffix to the defaultAlias + const defaultAlias = await getDefaultKeyAlias(); + const DPOP_KEY_ALIAS = `${defaultAlias}.${DPOP_KEY_ALIAS_SUFFIX}`; + + return DPOP_KEY_ALIAS; +}; diff --git a/packages/@magic-sdk/react-native-bare/src/dpop/utils/jwk.ts b/packages/@magic-sdk/react-native-bare/src/dpop/utils/jwk.ts new file mode 100644 index 000000000..3cde8102c --- /dev/null +++ b/packages/@magic-sdk/react-native-bare/src/dpop/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/dpop/utils/uint8.ts b/packages/@magic-sdk/react-native-bare/src/dpop/utils/uint8.ts new file mode 100644 index 000000000..ac4edcc8d --- /dev/null +++ b/packages/@magic-sdk/react-native-bare/src/dpop/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 b2c9e1d10..c74e75607 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 @@ -11,6 +11,7 @@ import { EventRegister } from 'react-native-event-listeners'; import Global = NodeJS.Global; import { useInternetConnection } from './hooks'; import { getRefreshTokenInKeychain, setRefreshTokenInKeychain } from './keychain'; +import { getDpop } from './dpop/dpop'; const MAGIC_PAYLOAD_FLAG_TYPED_ARRAY = 'MAGIC_PAYLOAD_FLAG_TYPED_ARRAY'; const OPEN_IN_DEVICE_BROWSER = 'open_in_device_browser'; @@ -283,7 +284,18 @@ export class ReactNativeWebViewController extends ViewController { // Overrides parent method to retrieve refresh token from keychain while creating a request async getRT() { - return getRefreshTokenInKeychain() + return getRefreshTokenInKeychain(); + } + + async getJWT(): Promise { + try { + console.log('CREATING DPOP'); + const dpop = await getDpop(); + console.log({ dpop }); + return dpop; + } catch (e) { + return null; + } } // Todo - implement these methods diff --git a/packages/@magic-sdk/react-native-bare/src/types/react-native-biometrics.d.ts b/packages/@magic-sdk/react-native-bare/src/types/react-native-biometrics.d.ts new file mode 100644 index 000000000..6252c5e97 --- /dev/null +++ b/packages/@magic-sdk/react-native-bare/src/types/react-native-biometrics.d.ts @@ -0,0 +1,26 @@ +declare module '@sbaiahmed1/react-native-biometrics' { + export enum BiometricStrength { + Weak = 'WEAK', + Strong = 'STRONG', + } + + export function configureKeyAlias(keyAlias: string): Promise; + export function getDefaultKeyAlias(): Promise; + export function getKeyAttributes( + keyAlias?: string, + ): Promise<{ exists: boolean; attributes?: object; error?: string }>; + export function createKeys( + keyAlias?: string, + keyType?: 'rsa2048' | 'ec256', + biometricStrength?: BiometricStrength, + ): Promise<{ publicKey: string }>; + export function getAllKeys(customAlias?: string): Promise<{ keys: Array<{ alias: string; publicKey: string }> }>; + export function deleteKeys(keyAlias?: string): Promise<{ success: boolean }>; + export function verifyKeySignature( + keyAlias: string | undefined, + data: string, + promptTitle?: string, + promptSubtitle?: string, + cancelButtonText?: string, + ): Promise<{ success: boolean; signature?: string; error?: string }>; +} diff --git a/yarn.lock b/yarn.lock index bda093154..4e49d8155 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3354,6 +3354,7 @@ __metadata: "@react-native-async-storage/async-storage": ^2.1.2 "@react-native-community/netinfo": ">11.0.0" "@react-native/babel-preset": ^0.79.0 + "@sbaiahmed1/react-native-biometrics": ^0.9.1 "@testing-library/react-native": ^13.2.0 "@types/lodash": ^4.14.158 buffer: ~5.6.0 @@ -3366,6 +3367,7 @@ __metadata: 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 @@ -4892,6 +4894,16 @@ __metadata: languageName: node linkType: hard +"@sbaiahmed1/react-native-biometrics@npm:^0.9.1": + version: 0.9.1 + resolution: "@sbaiahmed1/react-native-biometrics@npm:0.9.1" + peerDependencies: + react: "*" + react-native: "*" + checksum: 81a20b4101f3e48701f93983a894b3bf13d4f48270058671db20556b3d313f62b075d60d809f8e8b25ae0a8cde193dfff21bcd2bb2067c0cef7310a47db54962 + languageName: node + linkType: hard + "@scure/base@npm:~1.1.0": version: 1.1.1 resolution: "@scure/base@npm:1.1.1" @@ -17318,6 +17330,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" From a8b9d6cf3d817642c1f1d7ce8c7aeb18fdf257f4 Mon Sep 17 00:00:00 2001 From: sherzod-bakhodirov Date: Mon, 1 Dec 2025 21:55:02 +0500 Subject: [PATCH 04/17] feat: implement dpop creation using device crypto --- .../@magic-sdk/react-native-bare/package.json | 1 + .../react-native-bare/src/dpop/dpop copy.ts | 98 +++++++++++++++++++ .../react-native-bare/src/dpop/dpop.ts | 84 +++++++--------- .../react-native-bare/src/dpop/types/index.ts | 20 ++++ .../react-native-bare/src/keychain.ts | 31 ++++-- 5 files changed, 175 insertions(+), 59 deletions(-) create mode 100644 packages/@magic-sdk/react-native-bare/src/dpop/dpop copy.ts create mode 100644 packages/@magic-sdk/react-native-bare/src/dpop/types/index.ts diff --git a/packages/@magic-sdk/react-native-bare/package.json b/packages/@magic-sdk/react-native-bare/package.json index 5a33f3154..a52a9e9b3 100644 --- a/packages/@magic-sdk/react-native-bare/package.json +++ b/packages/@magic-sdk/react-native-bare/package.json @@ -27,6 +27,7 @@ "localforage": "^1.7.4", "lodash": "^4.17.19", "process": "~0.11.10", + "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", diff --git a/packages/@magic-sdk/react-native-bare/src/dpop/dpop copy.ts b/packages/@magic-sdk/react-native-bare/src/dpop/dpop copy.ts new file mode 100644 index 000000000..aeea1f89e --- /dev/null +++ b/packages/@magic-sdk/react-native-bare/src/dpop/dpop copy.ts @@ -0,0 +1,98 @@ +import * as Biometrics from '@sbaiahmed1/react-native-biometrics'; +import uuid from 'react-native-uuid'; // Ensure you have installed: npm install react-native-uuid +import { toBase64Url } from './utils/uint8'; +import { spkiToJwk } from './utils/jwk'; +import { ALG, TYP } from './constants'; +import { derToRawSignature } from './utils/der'; +import { getDpopAlias } from './utils/dpop-alias'; + +/** + * Generates the DPoP proof compatible with the Python backend. + * Handles key creation (if missing), JWK construction, and signing. + * @param httpMethod - The HTTP method (e.g., 'POST') + * @param httpUrl - The HTTP URL being accessed + */ +export const getDpop = async (httpMethod?: string, httpUrl?: string): Promise => { + try { + // 1. Configure Isolation + const DPOP_KEY_ALIAS = await getDpopAlias(); + await Biometrics.configureKeyAlias(DPOP_KEY_ALIAS); + + // 2. Retrieve Public Key (Check if exists) + const keyAttrs = await Biometrics.getKeyAttributes(DPOP_KEY_ALIAS); + let publicKeyBase64 = ''; + + if (!keyAttrs.exists) { + // Create new keys in Secure Enclave (ec256) + const keyResult = await Biometrics.createKeys(DPOP_KEY_ALIAS, 'ec256', Biometrics.BiometricStrength.Weak); + publicKeyBase64 = keyResult.publicKey; + } else { + // Retrieve existing key + const allKeys = await Biometrics.getAllKeys(DPOP_KEY_ALIAS); + if (allKeys.keys.length > 0) { + publicKeyBase64 = allKeys.keys[0].publicKey; + } else { + // Fallback: Re-create if lookup failed but attributes said it existed + const keyResult = await Biometrics.createKeys(DPOP_KEY_ALIAS, 'ec256', Biometrics.BiometricStrength.Weak); + publicKeyBase64 = keyResult.publicKey; + } + } + + // 3. Construct JWK + const publicJwk = spkiToJwk(publicKeyBase64); + + // 4. Construct Payload + const iat = Math.floor(Date.now() / 1000); + const jti = uuid.v4(); + + const payload: any = { + iat, + jti, + }; + + if (httpMethod) payload.htm = httpMethod.toUpperCase(); + if (httpUrl) payload.htu = httpUrl; + + // 5. Construct Header + const header = { + typ: TYP, + alg: ALG, + jwk: publicJwk, + }; + + // 6. Prepare Signing Input + const headerB64 = toBase64Url(JSON.stringify(header)); + const payloadB64 = toBase64Url(JSON.stringify(payload)); + const signingInput = `${headerB64}.${payloadB64}`; + + // 7. Sign + const signatureResult = await Biometrics.verifyKeySignature(DPOP_KEY_ALIAS, signingInput); + + if (!signatureResult.success || !signatureResult.signature) { + throw new Error(`Signing failed: ${signatureResult.error || 'No signature returned'}`); + } + + // 8. Convert Signature (Toaster expects Raw R|S) + const signatureB64 = derToRawSignature(signatureResult.signature); + + // 9. Return DPoP String + return `${signingInput}.${signatureB64}`; + } catch (error) { + console.error('DPoP Generation Error:', error); + throw error; + } +}; + +/** + * Removes existing keys and invalidates the DPoP + */ +export const deleteDpop = async (): Promise => { + try { + const DPOP_KEY_ALIAS = await getDpopAlias(); + const result = await Biometrics.deleteKeys(DPOP_KEY_ALIAS); + return result.success; + } catch (error) { + console.error('DPoP Deletion Error:', error); + return false; + } +}; diff --git a/packages/@magic-sdk/react-native-bare/src/dpop/dpop.ts b/packages/@magic-sdk/react-native-bare/src/dpop/dpop.ts index aeea1f89e..73505bb80 100644 --- a/packages/@magic-sdk/react-native-bare/src/dpop/dpop.ts +++ b/packages/@magic-sdk/react-native-bare/src/dpop/dpop.ts @@ -5,77 +5,59 @@ import { spkiToJwk } from './utils/jwk'; import { ALG, TYP } from './constants'; import { derToRawSignature } from './utils/der'; import { getDpopAlias } from './utils/dpop-alias'; +import { DpopClaims, DpopHeader } from './types'; +import DeviceCrypto, { AccessLevel } from 'react-native-device-crypto'; + +// TODO: Make this dynamic based on the bundle id or package name +const KEY_ALIAS = 'dpop'; /** * Generates the DPoP proof compatible with the Python backend. * Handles key creation (if missing), JWK construction, and signing. - * @param httpMethod - The HTTP method (e.g., 'POST') - * @param httpUrl - The HTTP URL being accessed */ -export const getDpop = async (httpMethod?: string, httpUrl?: string): Promise => { +export const getDpop = async (): Promise => { try { - // 1. Configure Isolation - const DPOP_KEY_ALIAS = await getDpopAlias(); - await Biometrics.configureKeyAlias(DPOP_KEY_ALIAS); - - // 2. Retrieve Public Key (Check if exists) - const keyAttrs = await Biometrics.getKeyAttributes(DPOP_KEY_ALIAS); - let publicKeyBase64 = ''; - - if (!keyAttrs.exists) { - // Create new keys in Secure Enclave (ec256) - const keyResult = await Biometrics.createKeys(DPOP_KEY_ALIAS, 'ec256', Biometrics.BiometricStrength.Weak); - publicKeyBase64 = keyResult.publicKey; - } else { - // Retrieve existing key - const allKeys = await Biometrics.getAllKeys(DPOP_KEY_ALIAS); - if (allKeys.keys.length > 0) { - publicKeyBase64 = allKeys.keys[0].publicKey; - } else { - // Fallback: Re-create if lookup failed but attributes said it existed - const keyResult = await Biometrics.createKeys(DPOP_KEY_ALIAS, 'ec256', Biometrics.BiometricStrength.Weak); - publicKeyBase64 = keyResult.publicKey; - } - } + // 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 never leaves device + invalidateOnNewBiometry: false, + }); - // 3. Construct JWK - const publicJwk = spkiToJwk(publicKeyBase64); + // 2. Prepare Public Key as JWK + // Backend expects JWK in the header [cite: 27, 43] + const publicJwk = spkiToJwk(publicKey); - // 4. Construct Payload - const iat = Math.floor(Date.now() / 1000); - const jti = uuid.v4(); - - const payload: any = { - iat, - jti, + // 3. Construct Payload + const now = Math.floor(Date.now() / 1000); + const claims: DpopClaims = { + iat: now, + jti: uuid.v4(), }; - if (httpMethod) payload.htm = httpMethod.toUpperCase(); - if (httpUrl) payload.htu = httpUrl; - - // 5. Construct Header - const header = { + const header: DpopHeader = { typ: TYP, alg: ALG, jwk: publicJwk, }; - // 6. Prepare Signing Input + // 4. Prepare Signing Input const headerB64 = toBase64Url(JSON.stringify(header)); - const payloadB64 = toBase64Url(JSON.stringify(payload)); + const payloadB64 = toBase64Url(JSON.stringify(claims)); const signingInput = `${headerB64}.${payloadB64}`; - // 7. Sign - const signatureResult = await Biometrics.verifyKeySignature(DPOP_KEY_ALIAS, signingInput); - - if (!signatureResult.success || !signatureResult.signature) { - throw new Error(`Signing failed: ${signatureResult.error || 'No signature returned'}`); - } + // 5. Sign Data + // DeviceCrypto returns a Base64 signature. + // Note: Android often returns ASN.1 DER, iOS might return Raw. + const signatureBase64 = await DeviceCrypto.sign(KEY_ALIAS, signingInput, { + biometryTitle: 'Sign DPoP', + biometrySubTitle: 'Sign DPoP', + biometryDescription: 'Sign DPoP', + }); - // 8. Convert Signature (Toaster expects Raw R|S) - const signatureB64 = derToRawSignature(signatureResult.signature); + // 6. Convert Signature (Toaster expects Raw R|S) + const signatureB64 = derToRawSignature(signatureBase64); - // 9. Return DPoP String return `${signingInput}.${signatureB64}`; } catch (error) { console.error('DPoP Generation Error:', error); diff --git a/packages/@magic-sdk/react-native-bare/src/dpop/types/index.ts b/packages/@magic-sdk/react-native-bare/src/dpop/types/index.ts new file mode 100644 index 000000000..4069afd27 --- /dev/null +++ b/packages/@magic-sdk/react-native-bare/src/dpop/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/keychain.ts b/packages/@magic-sdk/react-native-bare/src/keychain.ts index ddb4ceaa9..ec3b2ec7f 100644 --- a/packages/@magic-sdk/react-native-bare/src/keychain.ts +++ b/packages/@magic-sdk/react-native-bare/src/keychain.ts @@ -4,19 +4,34 @@ const SERVICE = 'magic_sdk_rt'; const KEY = 'magic_rt'; export const setRefreshTokenInKeychain = async (rt: string) => { - return await Keychain.setGenericPassword(KEY, rt, { - service: SERVICE, - accessible: Keychain.ACCESSIBLE.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY, - }); + try { + return await Keychain.setGenericPassword(KEY, rt, { + service: SERVICE, + accessible: Keychain.ACCESSIBLE.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY, + }); + } catch (error) { + console.error('Failed to set refresh token in keychain', error); + return false; + } }; export const getRefreshTokenInKeychain = async () => { - const keychainEntry = await Keychain.getGenericPassword({ service: SERVICE }); - if (!keychainEntry) return null; + try { + const keychainEntry = await Keychain.getGenericPassword({ service: SERVICE }); + if (!keychainEntry) return null; - return keychainEntry.password; + return keychainEntry.password; + } catch (error) { + console.error('Failed to get refresh token in keychain', error); + return null; + } }; export const removeRefreshTokenInKeychain = async () => { - return await Keychain.resetGenericPassword({ service: SERVICE }); + try { + return await Keychain.resetGenericPassword({ service: SERVICE }); + } catch (error) { + console.error('Failed to remove refresh token in keychain', error); + return null; + } }; From 2ec46d8fcf10f04100dc716c46d52b1b37be86e1 Mon Sep 17 00:00:00 2001 From: sherzod-bakhodirov Date: Mon, 1 Dec 2025 21:57:34 +0500 Subject: [PATCH 05/17] chore: update yarn.lock --- yarn.lock | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/yarn.lock b/yarn.lock index 4e49d8155..fd4dffa5d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3363,6 +3363,7 @@ __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 @@ -17283,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" From 10d15f51377bc55f8a9c792b438512f39cdced06 Mon Sep 17 00:00:00 2001 From: sherzod-bakhodirov Date: Wed, 3 Dec 2025 16:40:27 +0500 Subject: [PATCH 06/17] feat: implement unique key alias --- .../react-native-bare/src/dpop/dpop copy.ts | 98 ------------------- .../src/dpop/utils/dpop-alias.ts | 11 --- .../constants/index.ts | 0 .../src/{dpop => native-crypto}/dpop.ts | 14 ++- .../src/{ => native-crypto}/keychain.ts | 0 .../{dpop => native-crypto}/types/index.ts | 0 .../src/{dpop => native-crypto}/utils/der.ts | 0 .../src/{dpop => native-crypto}/utils/jwk.ts | 0 .../src/native-crypto/utils/key-alias.ts | 12 +++ .../{dpop => native-crypto}/utils/uint8.ts | 0 .../src/react-native-webview-controller.tsx | 6 +- 11 files changed, 22 insertions(+), 119 deletions(-) delete mode 100644 packages/@magic-sdk/react-native-bare/src/dpop/dpop copy.ts delete mode 100644 packages/@magic-sdk/react-native-bare/src/dpop/utils/dpop-alias.ts rename packages/@magic-sdk/react-native-bare/src/{dpop => native-crypto}/constants/index.ts (100%) rename packages/@magic-sdk/react-native-bare/src/{dpop => native-crypto}/dpop.ts (85%) rename packages/@magic-sdk/react-native-bare/src/{ => native-crypto}/keychain.ts (100%) rename packages/@magic-sdk/react-native-bare/src/{dpop => native-crypto}/types/index.ts (100%) rename packages/@magic-sdk/react-native-bare/src/{dpop => native-crypto}/utils/der.ts (100%) rename packages/@magic-sdk/react-native-bare/src/{dpop => native-crypto}/utils/jwk.ts (100%) create mode 100644 packages/@magic-sdk/react-native-bare/src/native-crypto/utils/key-alias.ts rename packages/@magic-sdk/react-native-bare/src/{dpop => native-crypto}/utils/uint8.ts (100%) diff --git a/packages/@magic-sdk/react-native-bare/src/dpop/dpop copy.ts b/packages/@magic-sdk/react-native-bare/src/dpop/dpop copy.ts deleted file mode 100644 index aeea1f89e..000000000 --- a/packages/@magic-sdk/react-native-bare/src/dpop/dpop copy.ts +++ /dev/null @@ -1,98 +0,0 @@ -import * as Biometrics from '@sbaiahmed1/react-native-biometrics'; -import uuid from 'react-native-uuid'; // Ensure you have installed: npm install react-native-uuid -import { toBase64Url } from './utils/uint8'; -import { spkiToJwk } from './utils/jwk'; -import { ALG, TYP } from './constants'; -import { derToRawSignature } from './utils/der'; -import { getDpopAlias } from './utils/dpop-alias'; - -/** - * Generates the DPoP proof compatible with the Python backend. - * Handles key creation (if missing), JWK construction, and signing. - * @param httpMethod - The HTTP method (e.g., 'POST') - * @param httpUrl - The HTTP URL being accessed - */ -export const getDpop = async (httpMethod?: string, httpUrl?: string): Promise => { - try { - // 1. Configure Isolation - const DPOP_KEY_ALIAS = await getDpopAlias(); - await Biometrics.configureKeyAlias(DPOP_KEY_ALIAS); - - // 2. Retrieve Public Key (Check if exists) - const keyAttrs = await Biometrics.getKeyAttributes(DPOP_KEY_ALIAS); - let publicKeyBase64 = ''; - - if (!keyAttrs.exists) { - // Create new keys in Secure Enclave (ec256) - const keyResult = await Biometrics.createKeys(DPOP_KEY_ALIAS, 'ec256', Biometrics.BiometricStrength.Weak); - publicKeyBase64 = keyResult.publicKey; - } else { - // Retrieve existing key - const allKeys = await Biometrics.getAllKeys(DPOP_KEY_ALIAS); - if (allKeys.keys.length > 0) { - publicKeyBase64 = allKeys.keys[0].publicKey; - } else { - // Fallback: Re-create if lookup failed but attributes said it existed - const keyResult = await Biometrics.createKeys(DPOP_KEY_ALIAS, 'ec256', Biometrics.BiometricStrength.Weak); - publicKeyBase64 = keyResult.publicKey; - } - } - - // 3. Construct JWK - const publicJwk = spkiToJwk(publicKeyBase64); - - // 4. Construct Payload - const iat = Math.floor(Date.now() / 1000); - const jti = uuid.v4(); - - const payload: any = { - iat, - jti, - }; - - if (httpMethod) payload.htm = httpMethod.toUpperCase(); - if (httpUrl) payload.htu = httpUrl; - - // 5. Construct Header - const header = { - typ: TYP, - alg: ALG, - jwk: publicJwk, - }; - - // 6. Prepare Signing Input - const headerB64 = toBase64Url(JSON.stringify(header)); - const payloadB64 = toBase64Url(JSON.stringify(payload)); - const signingInput = `${headerB64}.${payloadB64}`; - - // 7. Sign - const signatureResult = await Biometrics.verifyKeySignature(DPOP_KEY_ALIAS, signingInput); - - if (!signatureResult.success || !signatureResult.signature) { - throw new Error(`Signing failed: ${signatureResult.error || 'No signature returned'}`); - } - - // 8. Convert Signature (Toaster expects Raw R|S) - const signatureB64 = derToRawSignature(signatureResult.signature); - - // 9. Return DPoP String - return `${signingInput}.${signatureB64}`; - } catch (error) { - console.error('DPoP Generation Error:', error); - throw error; - } -}; - -/** - * Removes existing keys and invalidates the DPoP - */ -export const deleteDpop = async (): Promise => { - try { - const DPOP_KEY_ALIAS = await getDpopAlias(); - const result = await Biometrics.deleteKeys(DPOP_KEY_ALIAS); - return result.success; - } catch (error) { - console.error('DPoP Deletion Error:', error); - return false; - } -}; diff --git a/packages/@magic-sdk/react-native-bare/src/dpop/utils/dpop-alias.ts b/packages/@magic-sdk/react-native-bare/src/dpop/utils/dpop-alias.ts deleted file mode 100644 index ca26bd184..000000000 --- a/packages/@magic-sdk/react-native-bare/src/dpop/utils/dpop-alias.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { getDefaultKeyAlias } from '@sbaiahmed1/react-native-biometrics'; -import { DPOP_KEY_ALIAS_SUFFIX } from '../constants'; - -export const getDpopAlias = async () => { - // Each client integrating our SDK gets unique defaultAlias based on their bundle id or package name - // We append our own suffix to the defaultAlias - const defaultAlias = await getDefaultKeyAlias(); - const DPOP_KEY_ALIAS = `${defaultAlias}.${DPOP_KEY_ALIAS_SUFFIX}`; - - return DPOP_KEY_ALIAS; -}; diff --git a/packages/@magic-sdk/react-native-bare/src/dpop/constants/index.ts b/packages/@magic-sdk/react-native-bare/src/native-crypto/constants/index.ts similarity index 100% rename from packages/@magic-sdk/react-native-bare/src/dpop/constants/index.ts rename to packages/@magic-sdk/react-native-bare/src/native-crypto/constants/index.ts diff --git a/packages/@magic-sdk/react-native-bare/src/dpop/dpop.ts b/packages/@magic-sdk/react-native-bare/src/native-crypto/dpop.ts similarity index 85% rename from packages/@magic-sdk/react-native-bare/src/dpop/dpop.ts rename to packages/@magic-sdk/react-native-bare/src/native-crypto/dpop.ts index 73505bb80..c8b8b9350 100644 --- a/packages/@magic-sdk/react-native-bare/src/dpop/dpop.ts +++ b/packages/@magic-sdk/react-native-bare/src/native-crypto/dpop.ts @@ -1,10 +1,8 @@ -import * as Biometrics from '@sbaiahmed1/react-native-biometrics'; import uuid from 'react-native-uuid'; // Ensure you have installed: npm install react-native-uuid import { toBase64Url } from './utils/uint8'; import { spkiToJwk } from './utils/jwk'; import { ALG, TYP } from './constants'; import { derToRawSignature } from './utils/der'; -import { getDpopAlias } from './utils/dpop-alias'; import { DpopClaims, DpopHeader } from './types'; import DeviceCrypto, { AccessLevel } from 'react-native-device-crypto'; @@ -15,7 +13,7 @@ const KEY_ALIAS = 'dpop'; * Generates the DPoP proof compatible with the Python backend. * Handles key creation (if missing), JWK construction, and signing. */ -export const getDpop = async (): Promise => { +export const getDpop = async (): Promise => { try { // 1. Get or Create Key in Secure Enclave // We strictly disable authentication to avoid biometric prompts @@ -61,18 +59,18 @@ export const getDpop = async (): Promise => { return `${signingInput}.${signatureB64}`; } catch (error) { console.error('DPoP Generation Error:', error); - throw error; + return null; } }; /** - * Removes existing keys and invalidates the DPoP + * 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 { - const DPOP_KEY_ALIAS = await getDpopAlias(); - const result = await Biometrics.deleteKeys(DPOP_KEY_ALIAS); - return result.success; + 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/keychain.ts b/packages/@magic-sdk/react-native-bare/src/native-crypto/keychain.ts similarity index 100% rename from packages/@magic-sdk/react-native-bare/src/keychain.ts rename to packages/@magic-sdk/react-native-bare/src/native-crypto/keychain.ts diff --git a/packages/@magic-sdk/react-native-bare/src/dpop/types/index.ts b/packages/@magic-sdk/react-native-bare/src/native-crypto/types/index.ts similarity index 100% rename from packages/@magic-sdk/react-native-bare/src/dpop/types/index.ts rename to packages/@magic-sdk/react-native-bare/src/native-crypto/types/index.ts diff --git a/packages/@magic-sdk/react-native-bare/src/dpop/utils/der.ts b/packages/@magic-sdk/react-native-bare/src/native-crypto/utils/der.ts similarity index 100% rename from packages/@magic-sdk/react-native-bare/src/dpop/utils/der.ts rename to packages/@magic-sdk/react-native-bare/src/native-crypto/utils/der.ts diff --git a/packages/@magic-sdk/react-native-bare/src/dpop/utils/jwk.ts b/packages/@magic-sdk/react-native-bare/src/native-crypto/utils/jwk.ts similarity index 100% rename from packages/@magic-sdk/react-native-bare/src/dpop/utils/jwk.ts rename to packages/@magic-sdk/react-native-bare/src/native-crypto/utils/jwk.ts 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..3841cf5eb --- /dev/null +++ b/packages/@magic-sdk/react-native-bare/src/native-crypto/utils/key-alias.ts @@ -0,0 +1,12 @@ +import DeviceInfo from 'react-native-device-info'; + +const KEY_SUFFIX_MAP = { + dpop: 'magic.sdk.dpop', + refreshToken: 'magic.sdk.rt', + refreshTokenService: 'magic.sdk.rt.service', +}; + +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/dpop/utils/uint8.ts b/packages/@magic-sdk/react-native-bare/src/native-crypto/utils/uint8.ts similarity index 100% rename from packages/@magic-sdk/react-native-bare/src/dpop/utils/uint8.ts rename to packages/@magic-sdk/react-native-bare/src/native-crypto/utils/uint8.ts 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 c74e75607..b2f1baeb0 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,8 +10,9 @@ import { EventRegister } from 'react-native-event-listeners'; /* global NodeJS */ import Global = NodeJS.Global; import { useInternetConnection } from './hooks'; -import { getRefreshTokenInKeychain, setRefreshTokenInKeychain } from './keychain'; -import { getDpop } from './dpop/dpop'; +import { getRefreshTokenInKeychain, setRefreshTokenInKeychain } from './native-crypto/keychain'; +import { getDpop } from './native-crypto/dpop'; +import { getKeyAlias } from './native-crypto/utils/key-alias'; const MAGIC_PAYLOAD_FLAG_TYPED_ARRAY = 'MAGIC_PAYLOAD_FLAG_TYPED_ARRAY'; const OPEN_IN_DEVICE_BROWSER = 'open_in_device_browser'; @@ -290,6 +291,7 @@ export class ReactNativeWebViewController extends ViewController { async getJWT(): Promise { try { console.log('CREATING DPOP'); + console.log({ keyAlias: getKeyAlias('dpop') }); const dpop = await getDpop(); console.log({ dpop }); return dpop; From 51717f0a0d386ad1b1c222085b2ea11998b60f37 Mon Sep 17 00:00:00 2001 From: sherzod-bakhodirov Date: Wed, 3 Dec 2025 18:29:09 +0500 Subject: [PATCH 07/17] feat: use key alias for rt and jwt --- .../@magic-sdk/react-native-bare/src/native-crypto/dpop.ts | 4 ++-- .../react-native-bare/src/native-crypto/keychain.ts | 5 +++-- .../react-native-bare/src/native-crypto/utils/key-alias.ts | 4 ++++ .../src/react-native-webview-controller.tsx | 6 +----- 4 files changed, 10 insertions(+), 9 deletions(-) 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 index c8b8b9350..685dc6a2f 100644 --- a/packages/@magic-sdk/react-native-bare/src/native-crypto/dpop.ts +++ b/packages/@magic-sdk/react-native-bare/src/native-crypto/dpop.ts @@ -5,9 +5,9 @@ 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'; -// TODO: Make this dynamic based on the bundle id or package name -const KEY_ALIAS = 'dpop'; +const KEY_ALIAS = getKeyAlias('dpop'); /** * Generates the DPoP proof compatible with the Python backend. 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 index ec3b2ec7f..e7c9a9f8a 100644 --- a/packages/@magic-sdk/react-native-bare/src/native-crypto/keychain.ts +++ b/packages/@magic-sdk/react-native-bare/src/native-crypto/keychain.ts @@ -1,7 +1,8 @@ import * as Keychain from 'react-native-keychain'; +import { getKeyAlias } from './utils/key-alias'; -const SERVICE = 'magic_sdk_rt'; -const KEY = 'magic_rt'; +const SERVICE = getKeyAlias('refreshTokenService'); +const KEY = getKeyAlias('refreshToken'); export const setRefreshTokenInKeychain = async (rt: string) => { try { 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 index 3841cf5eb..6147d4380 100644 --- 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 @@ -6,6 +6,10 @@ const KEY_SUFFIX_MAP = { 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/react-native-webview-controller.tsx b/packages/@magic-sdk/react-native-bare/src/react-native-webview-controller.tsx index b2f1baeb0..6c9fba112 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 @@ -290,11 +290,7 @@ export class ReactNativeWebViewController extends ViewController { async getJWT(): Promise { try { - console.log('CREATING DPOP'); - console.log({ keyAlias: getKeyAlias('dpop') }); - const dpop = await getDpop(); - console.log({ dpop }); - return dpop; + return await getDpop(); } catch (e) { return null; } From f2ac87759d234f030f6028ad2dc6625f8f7cd648 Mon Sep 17 00:00:00 2001 From: sherzod-bakhodirov Date: Wed, 3 Dec 2025 18:31:36 +0500 Subject: [PATCH 08/17] chore: remove unused var --- .../react-native-bare/src/native-crypto/constants/index.ts | 2 -- 1 file changed, 2 deletions(-) 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 index 06a24acbe..1380f90ef 100644 --- 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 @@ -1,4 +1,2 @@ -// Unique alias suffix to isolate SDK keys from the rest of the app -export const DPOP_KEY_ALIAS_SUFFIX = 'magiclink.dpop'; export const ALG = 'ES256'; export const TYP = 'dpop+jwt'; From 260ebf725d1897f96987554a563a38e23ba5187f Mon Sep 17 00:00:00 2001 From: sherzod-bakhodirov Date: Wed, 3 Dec 2025 18:50:27 +0500 Subject: [PATCH 09/17] feat: implement caching for refresh token --- .../@magic-sdk/react-native-bare/package.json | 1 - .../src/native-crypto/dpop.ts | 6 ++--- .../src/native-crypto/keychain.ts | 20 ++++++++++++-- .../src/native-crypto/utils/der.ts | 2 +- .../src/react-native-webview-controller.tsx | 1 - .../src/types/react-native-biometrics.d.ts | 26 ------------------- yarn.lock | 11 -------- 7 files changed, 22 insertions(+), 45 deletions(-) delete mode 100644 packages/@magic-sdk/react-native-bare/src/types/react-native-biometrics.d.ts diff --git a/packages/@magic-sdk/react-native-bare/package.json b/packages/@magic-sdk/react-native-bare/package.json index a52a9e9b3..9ff2116fc 100644 --- a/packages/@magic-sdk/react-native-bare/package.json +++ b/packages/@magic-sdk/react-native-bare/package.json @@ -21,7 +21,6 @@ "@magic-sdk/provider": "^31.2.0", "@magic-sdk/types": "^25.2.0", "@react-native-async-storage/async-storage": "^2.1.2", - "@sbaiahmed1/react-native-biometrics": "^0.9.1", "@types/lodash": "^4.14.158", "buffer": "~5.6.0", "localforage": "^1.7.4", 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 index 685dc6a2f..1ef6c572f 100644 --- a/packages/@magic-sdk/react-native-bare/src/native-crypto/dpop.ts +++ b/packages/@magic-sdk/react-native-bare/src/native-crypto/dpop.ts @@ -18,12 +18,12 @@ export const getDpop = async (): Promise => { // 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 never leaves device + accessLevel: AccessLevel.ALWAYS, // Key is always accessible in this device invalidateOnNewBiometry: false, }); // 2. Prepare Public Key as JWK - // Backend expects JWK in the header [cite: 27, 43] + // Toaster backend expects JWK in the header const publicJwk = spkiToJwk(publicKey); // 3. Construct Payload @@ -46,8 +46,8 @@ export const getDpop = async (): Promise => { // 5. Sign Data // DeviceCrypto returns a Base64 signature. - // Note: Android often returns ASN.1 DER, iOS might return Raw. 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', 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 index e7c9a9f8a..5ff2999ac 100644 --- a/packages/@magic-sdk/react-native-bare/src/native-crypto/keychain.ts +++ b/packages/@magic-sdk/react-native-bare/src/native-crypto/keychain.ts @@ -4,12 +4,21 @@ 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 { - return await Keychain.setGenericPassword(KEY, rt, { + 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; @@ -17,11 +26,17 @@ export const setRefreshTokenInKeychain = async (rt: string) => { }; export const getRefreshTokenInKeychain = async () => { + // Return cached value if available + if (cachedRefreshToken !== null) { + return cachedRefreshToken; + } + try { const keychainEntry = await Keychain.getGenericPassword({ service: SERVICE }); if (!keychainEntry) return null; - return keychainEntry.password; + cachedRefreshToken = keychainEntry.password; + return cachedRefreshToken; } catch (error) { console.error('Failed to get refresh token in keychain', error); return null; @@ -30,6 +45,7 @@ export const getRefreshTokenInKeychain = async () => { 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); 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 index e210535be..98b296f12 100644 --- 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 @@ -2,7 +2,7 @@ import { base64ToUint8Array, toBase64Url } from './uint8'; /** * Converts a DER encoded signature (ASN.1) to a Raw R|S signature (64 bytes). - * React Native Biometrics returns DER; Python Backend expects Raw P1363. + * Device Crypto returns DER; Toaster backend expects Raw P1363. */ export const derToRawSignature = (derBase64: string): string => { const der = base64ToUint8Array(derBase64); 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 6c9fba112..54ac940b3 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 @@ -12,7 +12,6 @@ import Global = NodeJS.Global; import { useInternetConnection } from './hooks'; import { getRefreshTokenInKeychain, setRefreshTokenInKeychain } from './native-crypto/keychain'; import { getDpop } from './native-crypto/dpop'; -import { getKeyAlias } from './native-crypto/utils/key-alias'; const MAGIC_PAYLOAD_FLAG_TYPED_ARRAY = 'MAGIC_PAYLOAD_FLAG_TYPED_ARRAY'; const OPEN_IN_DEVICE_BROWSER = 'open_in_device_browser'; diff --git a/packages/@magic-sdk/react-native-bare/src/types/react-native-biometrics.d.ts b/packages/@magic-sdk/react-native-bare/src/types/react-native-biometrics.d.ts deleted file mode 100644 index 6252c5e97..000000000 --- a/packages/@magic-sdk/react-native-bare/src/types/react-native-biometrics.d.ts +++ /dev/null @@ -1,26 +0,0 @@ -declare module '@sbaiahmed1/react-native-biometrics' { - export enum BiometricStrength { - Weak = 'WEAK', - Strong = 'STRONG', - } - - export function configureKeyAlias(keyAlias: string): Promise; - export function getDefaultKeyAlias(): Promise; - export function getKeyAttributes( - keyAlias?: string, - ): Promise<{ exists: boolean; attributes?: object; error?: string }>; - export function createKeys( - keyAlias?: string, - keyType?: 'rsa2048' | 'ec256', - biometricStrength?: BiometricStrength, - ): Promise<{ publicKey: string }>; - export function getAllKeys(customAlias?: string): Promise<{ keys: Array<{ alias: string; publicKey: string }> }>; - export function deleteKeys(keyAlias?: string): Promise<{ success: boolean }>; - export function verifyKeySignature( - keyAlias: string | undefined, - data: string, - promptTitle?: string, - promptSubtitle?: string, - cancelButtonText?: string, - ): Promise<{ success: boolean; signature?: string; error?: string }>; -} diff --git a/yarn.lock b/yarn.lock index fd4dffa5d..f3a25a9eb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3354,7 +3354,6 @@ __metadata: "@react-native-async-storage/async-storage": ^2.1.2 "@react-native-community/netinfo": ">11.0.0" "@react-native/babel-preset": ^0.79.0 - "@sbaiahmed1/react-native-biometrics": ^0.9.1 "@testing-library/react-native": ^13.2.0 "@types/lodash": ^4.14.158 buffer: ~5.6.0 @@ -4895,16 +4894,6 @@ __metadata: languageName: node linkType: hard -"@sbaiahmed1/react-native-biometrics@npm:^0.9.1": - version: 0.9.1 - resolution: "@sbaiahmed1/react-native-biometrics@npm:0.9.1" - peerDependencies: - react: "*" - react-native: "*" - checksum: 81a20b4101f3e48701f93983a894b3bf13d4f48270058671db20556b3d313f62b075d60d809f8e8b25ae0a8cde193dfff21bcd2bb2067c0cef7310a47db54962 - languageName: node - linkType: hard - "@scure/base@npm:~1.1.0": version: 1.1.1 resolution: "@scure/base@npm:1.1.1" From 36b994ccf2736fe5a8682a9f587df2b910c26cb3 Mon Sep 17 00:00:00 2001 From: sherzod-bakhodirov Date: Thu, 4 Dec 2025 19:57:41 +0500 Subject: [PATCH 10/17] chore: update comments --- packages/@magic-sdk/react-native-bare/src/native-crypto/dpop.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 1ef6c572f..8c863e368 100644 --- a/packages/@magic-sdk/react-native-bare/src/native-crypto/dpop.ts +++ b/packages/@magic-sdk/react-native-bare/src/native-crypto/dpop.ts @@ -1,4 +1,4 @@ -import uuid from 'react-native-uuid'; // Ensure you have installed: npm install react-native-uuid +import uuid from 'react-native-uuid'; import { toBase64Url } from './utils/uint8'; import { spkiToJwk } from './utils/jwk'; import { ALG, TYP } from './constants'; From 7a6369e95bd79c545a8083283230a97d1f044466 Mon Sep 17 00:00:00 2001 From: ethella Date: Thu, 4 Dec 2025 15:37:21 -0800 Subject: [PATCH 11/17] Add podspec --- .../MagicSdkReactNativeBare.podspec | 23 +++++++++++++++++++ .../@magic-sdk/react-native-bare/package.json | 3 ++- 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 packages/@magic-sdk/react-native-bare/MagicSdkReactNativeBare.podspec 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 9ff2116fc..82778fa94 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", From dfe5a756feacc4455e594817255ec0aeea72392d Mon Sep 17 00:00:00 2001 From: ethella Date: Thu, 4 Dec 2025 20:46:27 -0800 Subject: [PATCH 12/17] fix awaits --- .../src/react-native-webview-controller.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 54ac940b3..28a605bd3 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 @@ -279,12 +279,12 @@ export class ReactNativeWebViewController extends ViewController { return; } - setRefreshTokenInKeychain(event.data.rt); + await setRefreshTokenInKeychain(event.data.rt); } // Overrides parent method to retrieve refresh token from keychain while creating a request - async getRT() { - return getRefreshTokenInKeychain(); + async getRT(): Promise { + return await getRefreshTokenInKeychain(); } async getJWT(): Promise { From 309af02f67f12f2bcce034cd0f5c123a4f8dd1b7 Mon Sep 17 00:00:00 2001 From: sherzod-bakhodirov Date: Fri, 5 Dec 2025 19:17:58 +0500 Subject: [PATCH 13/17] feat: add tests for rt & dpop --- .../src/native-crypto/keychain.ts | 2 +- .../src/react-native-webview-controller.tsx | 2 +- .../test/spec/native-crypto/dpop.spec.ts | 170 +++++++++++++ .../test/spec/native-crypto/keychain.spec.ts | 111 +++++++++ .../test/spec/native-crypto/utils/der.spec.ts | 228 ++++++++++++++++++ .../test/spec/native-crypto/utils/jwk.spec.ts | 132 ++++++++++ .../native-crypto/utils/key-alias.spec.ts | 23 ++ .../spec/native-crypto/utils/uint8.spec.ts | 163 +++++++++++++ .../getJWT.spec.ts | 22 ++ .../getRT.spec.ts | 15 ++ .../persistMagicEventRefreshToken.spec.ts | 27 +++ 11 files changed, 893 insertions(+), 2 deletions(-) create mode 100644 packages/@magic-sdk/react-native-bare/test/spec/native-crypto/dpop.spec.ts create mode 100644 packages/@magic-sdk/react-native-bare/test/spec/native-crypto/keychain.spec.ts create mode 100644 packages/@magic-sdk/react-native-bare/test/spec/native-crypto/utils/der.spec.ts create mode 100644 packages/@magic-sdk/react-native-bare/test/spec/native-crypto/utils/jwk.spec.ts create mode 100644 packages/@magic-sdk/react-native-bare/test/spec/native-crypto/utils/key-alias.spec.ts create mode 100644 packages/@magic-sdk/react-native-bare/test/spec/native-crypto/utils/uint8.spec.ts create mode 100644 packages/@magic-sdk/react-native-bare/test/spec/react-native-webview-controller/getJWT.spec.ts create mode 100644 packages/@magic-sdk/react-native-bare/test/spec/react-native-webview-controller/getRT.spec.ts create mode 100644 packages/@magic-sdk/react-native-bare/test/spec/react-native-webview-controller/persistMagicEventRefreshToken.spec.ts 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 index 5ff2999ac..daa5bebb6 100644 --- a/packages/@magic-sdk/react-native-bare/src/native-crypto/keychain.ts +++ b/packages/@magic-sdk/react-native-bare/src/native-crypto/keychain.ts @@ -25,7 +25,7 @@ export const setRefreshTokenInKeychain = async (rt: string) => { } }; -export const getRefreshTokenInKeychain = async () => { +export const getRefreshTokenInKeychain = async (): Promise => { // Return cached value if available if (cachedRefreshToken !== null) { return cachedRefreshToken; 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 28a605bd3..09f8c73cc 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 @@ -275,7 +275,7 @@ export class ReactNativeWebViewController extends ViewController { // Overrides parent method to keep refresh token in keychain async persistMagicEventRefreshToken(event: MagicMessageEvent) { - if (!event.data.rt) { + if (!event?.data?.rt) { return; } 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'); + }); +}); From c4c1ed16503bc26fb3499efabe08055dc5d50d4e Mon Sep 17 00:00:00 2001 From: sherzod-bakhodirov Date: Fri, 5 Dec 2025 23:25:37 +0500 Subject: [PATCH 14/17] feat: show warning if peer dependencies are not installed --- .../@magic-sdk/react-native-bare/package.json | 10 ++- .../src/native-crypto/check-native-modules.ts | 89 +++++++++++++++++++ .../src/react-native-webview-controller.tsx | 2 + 3 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 packages/@magic-sdk/react-native-bare/src/native-crypto/check-native-modules.ts diff --git a/packages/@magic-sdk/react-native-bare/package.json b/packages/@magic-sdk/react-native-bare/package.json index 82778fa94..93ec002d4 100644 --- a/packages/@magic-sdk/react-native-bare/package.json +++ b/packages/@magic-sdk/react-native-bare/package.json @@ -27,10 +27,8 @@ "localforage": "^1.7.4", "lodash": "^4.17.19", "process": "~0.11.10", - "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-uuid": "^2.0.3", "regenerator-runtime": "0.13.9", "tslib": "^2.0.3", @@ -45,7 +43,9 @@ "react-native-device-info": "^10.3.0", "react-native-safe-area-context": "5.3.0", "react-native-webview": "^13.3.0", - "react-test-renderer": "^19.1.0" + "react-test-renderer": "^19.1.0", + "react-native-keychain": "^10.0.0", + "react-native-device-crypto": "^0.1.7" }, "peerDependencies": { "@react-native-async-storage/async-storage": ">=1.15.5", @@ -54,7 +54,9 @@ "react-native": ">=0.60", "react-native-device-info": ">=10.3.0", "react-native-safe-area-context": ">=4.4.1", - "react-native-webview": ">=12.4.0" + "react-native-webview": ">=12.4.0", + "react-native-keychain": "^10.0.0", + "react-native-device-crypto": "^0.1.7" }, "gitHead": "1ef062ea699d48d5e9a9375a93b7c147632b05ca" } 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/react-native-webview-controller.tsx b/packages/@magic-sdk/react-native-bare/src/react-native-webview-controller.tsx index 09f8c73cc..f490d83dd 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 @@ -12,6 +12,7 @@ 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'; @@ -61,6 +62,7 @@ export class ReactNativeWebViewController extends ViewController { private styles: any; protected init() { + checkNativeModules(); this.webView = null; this.container = null; this.styles = createWebViewStyles(); From c6b273eee06e71e29495ba8abe46c5bf8857e34b Mon Sep 17 00:00:00 2001 From: sherzod-bakhodirov Date: Fri, 5 Dec 2025 23:28:11 +0500 Subject: [PATCH 15/17] chore: update yarn.lock --- packages/@magic-sdk/react-native-bare/package.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/@magic-sdk/react-native-bare/package.json b/packages/@magic-sdk/react-native-bare/package.json index 93ec002d4..42c7fca74 100644 --- a/packages/@magic-sdk/react-native-bare/package.json +++ b/packages/@magic-sdk/react-native-bare/package.json @@ -40,23 +40,23 @@ "@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", - "react-native-keychain": "^10.0.0", - "react-native-device-crypto": "^0.1.7" + "react-test-renderer": "^19.1.0" }, "peerDependencies": { "@react-native-async-storage/async-storage": ">=1.15.5", "@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-safe-area-context": ">=4.4.1", - "react-native-webview": ">=12.4.0", "react-native-keychain": "^10.0.0", - "react-native-device-crypto": "^0.1.7" + "react-native-safe-area-context": ">=4.4.1", + "react-native-webview": ">=12.4.0" }, "gitHead": "1ef062ea699d48d5e9a9375a93b7c147632b05ca" } From 5751805aa885f0d32c57b9c701c74c0f19aa3227 Mon Sep 17 00:00:00 2001 From: sherzod-bakhodirov Date: Fri, 5 Dec 2025 23:28:24 +0500 Subject: [PATCH 16/17] chore: update yarn.lock --- yarn.lock | 2 ++ 1 file changed, 2 insertions(+) diff --git a/yarn.lock b/yarn.lock index a6d82f1dd..25411b0e8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3378,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 From ceb9c246bda31a184ee693b733193711771c508f Mon Sep 17 00:00:00 2001 From: sherzod-bakhodirov Date: Sat, 6 Dec 2025 01:05:32 +0500 Subject: [PATCH 17/17] feat: implement tests for native module checks --- .../src/react-native-webview-controller.tsx | 5 +- .../check-native-modules.spec.ts | 142 ++++++++++++++++++ 2 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 packages/@magic-sdk/react-native-bare/test/spec/native-crypto/check-native-modules.spec.ts 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 f490d83dd..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 @@ -62,7 +62,6 @@ export class ReactNativeWebViewController extends ViewController { private styles: any; protected init() { - checkNativeModules(); this.webView = null; this.container = null; this.styles = createWebViewStyles(); @@ -82,6 +81,10 @@ export class ReactNativeWebViewController extends ViewController { const [mountOverlay, setMountOverlay] = useState(true); const isConnected = useInternetConnection(); + useEffect(() => { + checkNativeModules(); + }, []); + useEffect(() => { this.isConnectedToInternet = isConnected; }, [isConnected]); 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