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 c8cde0a60d22f0c3cce5c454a326d3e978d6fc7d Mon Sep 17 00:00:00 2001 From: sherzod-bakhodirov Date: Thu, 4 Dec 2025 19:56:49 +0500 Subject: [PATCH 10/17] feat: implement native crypto & secure storage for rn expo --- .../@magic-sdk/react-native-expo/app.json | 5 ++ .../@magic-sdk/react-native-expo/package.json | 3 + .../src/native-crypto/constants/index.ts | 2 + .../src/native-crypto/dpop.ts | 80 +++++++++++++++++++ .../src/native-crypto/keychain.ts | 76 ++++++++++++++++++ .../src/native-crypto/types/index.ts | 20 +++++ .../src/native-crypto/utils/der.ts | 46 +++++++++++ .../src/native-crypto/utils/jwk.ts | 26 ++++++ .../src/native-crypto/utils/key-alias.ts | 15 ++++ .../src/native-crypto/utils/uint8.ts | 72 +++++++++++++++++ .../src/react-native-webview-controller.tsx | 23 ++++++ yarn.lock | 12 +++ 12 files changed, 380 insertions(+) create mode 100644 packages/@magic-sdk/react-native-expo/app.json create mode 100644 packages/@magic-sdk/react-native-expo/src/native-crypto/constants/index.ts create mode 100644 packages/@magic-sdk/react-native-expo/src/native-crypto/dpop.ts create mode 100644 packages/@magic-sdk/react-native-expo/src/native-crypto/keychain.ts create mode 100644 packages/@magic-sdk/react-native-expo/src/native-crypto/types/index.ts create mode 100644 packages/@magic-sdk/react-native-expo/src/native-crypto/utils/der.ts create mode 100644 packages/@magic-sdk/react-native-expo/src/native-crypto/utils/jwk.ts create mode 100644 packages/@magic-sdk/react-native-expo/src/native-crypto/utils/key-alias.ts create mode 100644 packages/@magic-sdk/react-native-expo/src/native-crypto/utils/uint8.ts diff --git a/packages/@magic-sdk/react-native-expo/app.json b/packages/@magic-sdk/react-native-expo/app.json new file mode 100644 index 000000000..1dd87701a --- /dev/null +++ b/packages/@magic-sdk/react-native-expo/app.json @@ -0,0 +1,5 @@ +{ + "plugins": [ + "expo-secure-store" + ] +} diff --git a/packages/@magic-sdk/react-native-expo/package.json b/packages/@magic-sdk/react-native-expo/package.json index 361fab76b..c9edf22f5 100644 --- a/packages/@magic-sdk/react-native-expo/package.json +++ b/packages/@magic-sdk/react-native-expo/package.json @@ -24,10 +24,13 @@ "@types/lodash": "^4.14.158", "buffer": "~5.6.0", "expo-application": "^5.0.1", + "expo-secure-store": "~14.0.1", "localforage": "^1.7.4", "lodash": "^4.17.19", "process": "~0.11.10", + "react-native-device-crypto": "^0.1.7", "react-native-event-listeners": "^1.0.7", + "react-native-uuid": "^2.0.3", "tslib": "^2.0.3", "whatwg-url": "~8.1.0" }, diff --git a/packages/@magic-sdk/react-native-expo/src/native-crypto/constants/index.ts b/packages/@magic-sdk/react-native-expo/src/native-crypto/constants/index.ts new file mode 100644 index 000000000..1380f90ef --- /dev/null +++ b/packages/@magic-sdk/react-native-expo/src/native-crypto/constants/index.ts @@ -0,0 +1,2 @@ +export const ALG = 'ES256'; +export const TYP = 'dpop+jwt'; diff --git a/packages/@magic-sdk/react-native-expo/src/native-crypto/dpop.ts b/packages/@magic-sdk/react-native-expo/src/native-crypto/dpop.ts new file mode 100644 index 000000000..45779f639 --- /dev/null +++ b/packages/@magic-sdk/react-native-expo/src/native-crypto/dpop.ts @@ -0,0 +1,80 @@ +import uuid from 'react-native-uuid'; +import { toBase64Url } from './utils/uint8'; +import { spkiToJwk } from './utils/jwk'; +import { ALG, TYP } from './constants'; +import { derToRawSignature } from './utils/der'; +import { DpopClaims, DpopHeader } from './types'; +import DeviceCrypto, { AccessLevel } from 'react-native-device-crypto'; +import { getKeyAlias } from './utils/key-alias'; + +const KEY_ALIAS = getKeyAlias('dpop'); + +/** + * Generates the DPoP proof compatible with the Python backend. + * Handles key creation (if missing), JWK construction, and signing. + */ +export const getDpop = async (): Promise => { + console.log('Getting DPoP'); + try { + // 1. Get or Create Key in Secure Enclave + // We strictly disable authentication to avoid biometric prompts + const publicKey = await DeviceCrypto.getOrCreateAsymmetricKey(KEY_ALIAS, { + accessLevel: AccessLevel.ALWAYS, // Key is always accessible in this device + invalidateOnNewBiometry: false, + }); + + // 2. Prepare Public Key as JWK + // Toaster backend expects JWK in the header + const publicJwk = spkiToJwk(publicKey); + + // 3. Construct Payload + const now = Math.floor(Date.now() / 1000); + const claims: DpopClaims = { + iat: now, + jti: uuid.v4(), + }; + + const header: DpopHeader = { + typ: TYP, + alg: ALG, + jwk: publicJwk, + }; + + // 4. Prepare Signing Input + const headerB64 = toBase64Url(JSON.stringify(header)); + const payloadB64 = toBase64Url(JSON.stringify(claims)); + const signingInput = `${headerB64}.${payloadB64}`; + + // 5. Sign Data + // DeviceCrypto returns a Base64 signature. + const signatureBase64 = await DeviceCrypto.sign(KEY_ALIAS, signingInput, { + // Biometry prompts should not be fired since the key is always accessible in this device + biometryTitle: 'Sign DPoP', + biometrySubTitle: 'Sign DPoP', + biometryDescription: 'Sign DPoP', + }); + + // 6. Convert Signature (Toaster expects Raw R|S) + const signatureB64 = derToRawSignature(signatureBase64); + + console.log('Successfully Has Got DPoP', { dpop: `${signingInput}.${signatureB64}` }); + return `${signingInput}.${signatureB64}`; + } catch (error) { + console.error('DPoP Generation Error:', error); + return null; + } +}; + +/** + * Removes the keys from the Secure Enclave + * Returns true if the key was deleted successfully, false otherwise. + * @returns {Promise} True if the key was deleted successfully, false otherwise. + */ +export const deleteDpop = async (): Promise => { + try { + return await DeviceCrypto.deleteKey(KEY_ALIAS); + } catch (error) { + console.error('DPoP Deletion Error:', error); + return false; + } +}; diff --git a/packages/@magic-sdk/react-native-expo/src/native-crypto/keychain.ts b/packages/@magic-sdk/react-native-expo/src/native-crypto/keychain.ts new file mode 100644 index 000000000..05b203be2 --- /dev/null +++ b/packages/@magic-sdk/react-native-expo/src/native-crypto/keychain.ts @@ -0,0 +1,76 @@ +import * as SecureStore from 'expo-secure-store'; +import { getKeyAlias } from './utils/key-alias'; + +const SERVICE = getKeyAlias('refreshTokenService'); +const KEY = getKeyAlias('refreshToken'); + +let cachedRefreshToken: string | null = null; + +/** + * Stores the refresh token securely using Expo SecureStore. + * Uses 'WHEN_UNLOCKED_THIS_DEVICE_ONLY' for security similar to the original keychain accessible level. + */ +export const setRefreshTokenInSecureStore = async (rt: string): Promise => { + console.log('Setting Refresh Token In SECURE STORAGE', { rt }); + // Skip write if token hasn't changed + if (cachedRefreshToken === rt) { + console.log('Refresh Token Has NOT Changed'); + return true; + } + + try { + await SecureStore.setItemAsync(KEY, rt, { + keychainService: SERVICE, // Used on iOS to scope the item + keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY, + }); + + cachedRefreshToken = rt; // Update cache on successful write + console.log('Successfully Has Set Refresh Token'); + return true; + } catch (error) { + console.error('Failed to set refresh token in secure store', error); + return false; + } +}; + +/** + * Retrieves the refresh token from secure storage. + * Returns the cached value if available to improve performance. + */ +export const getRefreshTokenInSecureStore = async (): Promise => { + console.log('Getting Refresh Token In SECURE STORAGE'); + // Return cached value if available + if (cachedRefreshToken !== null) { + console.log('Refresh Token Has Been Cached'); + return cachedRefreshToken; + } + + try { + const token = await SecureStore.getItemAsync(KEY, { + keychainService: SERVICE, + }); + + if (!token) return null; + + cachedRefreshToken = token; + console.log('Successfully Has Got Refresh Token'); + return cachedRefreshToken; + } catch (error) { + console.error('Failed to get refresh token in secure store', error); + return null; + } +}; + +/** + * Removes the refresh token from secure storage and clears the local cache. + */ +export const removeRefreshTokenInSecureStore = async (): Promise => { + try { + cachedRefreshToken = null; // Clear cache immediately + await SecureStore.deleteItemAsync(KEY, { + keychainService: SERVICE, + }); + } catch (error) { + console.error('Failed to remove refresh token in secure store', error); + } +}; diff --git a/packages/@magic-sdk/react-native-expo/src/native-crypto/types/index.ts b/packages/@magic-sdk/react-native-expo/src/native-crypto/types/index.ts new file mode 100644 index 000000000..4069afd27 --- /dev/null +++ b/packages/@magic-sdk/react-native-expo/src/native-crypto/types/index.ts @@ -0,0 +1,20 @@ +export interface DpopHeader { + typ: 'dpop+jwt'; + alg: 'ES256'; + jwk: JsonWebKey; +} + +export interface DpopClaims { + iat: number; + jti: string; + htm?: string; + htu?: string; +} + +export interface JsonWebKey { + kty: string; + crv: string; + x: string; + y: string; + ext?: boolean; +} diff --git a/packages/@magic-sdk/react-native-expo/src/native-crypto/utils/der.ts b/packages/@magic-sdk/react-native-expo/src/native-crypto/utils/der.ts new file mode 100644 index 000000000..98b296f12 --- /dev/null +++ b/packages/@magic-sdk/react-native-expo/src/native-crypto/utils/der.ts @@ -0,0 +1,46 @@ +import { base64ToUint8Array, toBase64Url } from './uint8'; + +/** + * Converts a DER encoded signature (ASN.1) to a Raw R|S signature (64 bytes). + * Device Crypto returns DER; Toaster backend expects Raw P1363. + */ +export const derToRawSignature = (derBase64: string): string => { + const der = base64ToUint8Array(derBase64); + + // DER Structure: 0x30 | total_len | 0x02 | r_len | r_bytes | 0x02 | s_len | s_bytes + let offset = 2; // Skip Sequence Tag (0x30) and Total Length + + // --- Read R --- + if (der[offset] !== 0x02) throw new Error('Invalid DER: R tag missing'); + offset++; // skip tag + const rLen = der[offset++]; // read length + let rBytes = der.subarray(offset, offset + rLen); + offset += rLen; + + // Handle ASN.1 Integer padding for R (remove leading 0x00 if present) + if (rLen === 33 && rBytes[0] === 0x00) { + rBytes = rBytes.subarray(1); + } + + // --- Read S --- + if (der[offset] !== 0x02) throw new Error('Invalid DER: S tag missing'); + offset++; // skip tag + const sLen = der[offset++]; // read length + let sBytes = der.subarray(offset, offset + sLen); + + // Handle ASN.1 Integer padding for S + if (sLen === 33 && sBytes[0] === 0x00) { + sBytes = sBytes.subarray(1); + } + + // --- Construct Raw Signature (64 bytes) --- + const rawSignature = new Uint8Array(64); + + // Copy R into the first 32 bytes (right-aligned/padded) + rawSignature.set(rBytes, 32 - rBytes.length); + + // Copy S into the last 32 bytes (right-aligned/padded) + rawSignature.set(sBytes, 64 - sBytes.length); + + return toBase64Url(rawSignature); +}; diff --git a/packages/@magic-sdk/react-native-expo/src/native-crypto/utils/jwk.ts b/packages/@magic-sdk/react-native-expo/src/native-crypto/utils/jwk.ts new file mode 100644 index 000000000..3cde8102c --- /dev/null +++ b/packages/@magic-sdk/react-native-expo/src/native-crypto/utils/jwk.ts @@ -0,0 +1,26 @@ +import { base64ToUint8Array, toBase64Url } from './uint8'; + +/** + * Converts SPKI Public Key (Base64) to JWK format. + * Extracts Raw X and Y coordinates from the uncompressed point. + */ +export const spkiToJwk = (spkiBase64: string) => { + const buf = base64ToUint8Array(spkiBase64); + // P-256 SPKI Header is 26 bytes. The key data (0x04 + X + Y) is at the end. + // We explicitly look for the last 65 bytes (1 header byte + 32 bytes X + 32 bytes Y) + const rawKey = buf.subarray(buf.length - 65); + + if (rawKey[0] !== 0x04) { + throw new Error('Invalid Public Key format: Expected uncompressed point'); + } + + const x = rawKey.subarray(1, 33); + const y = rawKey.subarray(33, 65); + + return { + kty: 'EC', + crv: 'P-256', + x: toBase64Url(x), + y: toBase64Url(y), + }; +}; diff --git a/packages/@magic-sdk/react-native-expo/src/native-crypto/utils/key-alias.ts b/packages/@magic-sdk/react-native-expo/src/native-crypto/utils/key-alias.ts new file mode 100644 index 000000000..4e1b78e4d --- /dev/null +++ b/packages/@magic-sdk/react-native-expo/src/native-crypto/utils/key-alias.ts @@ -0,0 +1,15 @@ +import * as Application from 'expo-application'; + +const KEY_SUFFIX_MAP = { + dpop: 'magic.sdk.dpop', + refreshToken: 'magic.sdk.rt', + refreshTokenService: 'magic.sdk.rt.service', +}; + +/** + * Returns the key alias for the given key. + * Let's us to safely store the keys in the keychain and avoid conflicts with other apps using magic sdk. + */ +export function getKeyAlias(key: keyof typeof KEY_SUFFIX_MAP): string { + return `${Application.applicationId}.${KEY_SUFFIX_MAP[key]}`; +} diff --git a/packages/@magic-sdk/react-native-expo/src/native-crypto/utils/uint8.ts b/packages/@magic-sdk/react-native-expo/src/native-crypto/utils/uint8.ts new file mode 100644 index 000000000..ac4edcc8d --- /dev/null +++ b/packages/@magic-sdk/react-native-expo/src/native-crypto/utils/uint8.ts @@ -0,0 +1,72 @@ +/** + * Encodes a Uint8Array to a Base64 string. + * Uses native 'btoa' by converting bytes to a binary string first. + */ +export const uint8ArrayToBase64 = (bytes: Uint8Array): string => { + let binary = ''; + const len = bytes.byteLength; + for (let i = 0; i < len; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +}; + +/** + * Decodes a Base64 string to a Uint8Array. + * Uses native 'atob'. + */ +export const base64ToUint8Array = (base64: string): Uint8Array => { + const binaryString = atob(base64); + const len = binaryString.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes; +}; + +/** + * Converts a string (UTF-8) to Uint8Array. + * Polyfill for simple ASCII/UTF-8 if TextEncoder is not available, + * but TextEncoder is standard in RN 0.68+. + */ +export const stringToUint8Array = (str: string): Uint8Array => { + // Use TextEncoder if available (standard in modern RN) + if (typeof TextEncoder !== 'undefined') { + return new TextEncoder().encode(str); + } + // Fallback for older environments + const arr = []; + for (let i = 0; i < str.length; i++) { + let code = str.charCodeAt(i); + if (code < 0x80) { + arr.push(code); + } else if (code < 0x800) { + arr.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f)); + } else if (code < 0xd800 || code >= 0xe000) { + arr.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f)); + } else { + i++; + code = 0x10000 + (((code & 0x3ff) << 10) | (str.charCodeAt(i) & 0x3ff)); + arr.push(0xf0 | (code >> 18), 0x80 | ((code >> 12) & 0x3f), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f)); + } + } + return new Uint8Array(arr); +}; + +/** + * Encodes input (String or Uint8Array) to Base64Url (RFC 4648). + * Removes padding '=', replaces '+' with '-', and '/' with '_' + */ +export const toBase64Url = (input: string | Uint8Array): string => { + let bytes: Uint8Array; + + if (typeof input === 'string') { + bytes = stringToUint8Array(input); + } else { + bytes = input; + } + + const base64 = uint8ArrayToBase64(bytes); + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +}; diff --git a/packages/@magic-sdk/react-native-expo/src/react-native-webview-controller.tsx b/packages/@magic-sdk/react-native-expo/src/react-native-webview-controller.tsx index 108394fee..bf8efd05b 100644 --- a/packages/@magic-sdk/react-native-expo/src/react-native-webview-controller.tsx +++ b/packages/@magic-sdk/react-native-expo/src/react-native-webview-controller.tsx @@ -10,6 +10,8 @@ import { EventRegister } from 'react-native-event-listeners'; /* global NodeJS */ import Global = NodeJS.Global; import { useInternetConnection } from './hooks'; +import { getRefreshTokenInSecureStore, setRefreshTokenInSecureStore } from './native-crypto/keychain'; +import { getDpop } from './native-crypto/dpop'; const MAGIC_PAYLOAD_FLAG_TYPED_ARRAY = 'MAGIC_PAYLOAD_FLAG_TYPED_ARRAY'; const OPEN_IN_DEVICE_BROWSER = 'open_in_device_browser'; @@ -289,6 +291,27 @@ export class ReactNativeWebViewController extends ViewController { } } + async persistMagicEventRefreshToken(event: MagicMessageEvent) { + if (!event.data.rt) { + return; + } + + setRefreshTokenInSecureStore(event.data.rt); + } + + // Overrides parent method to retrieve refresh token from keychain while creating a request + async getRT() { + return getRefreshTokenInSecureStore(); + } + + async getJWT(): Promise { + try { + return await getDpop(); + } catch (e) { + return null; + } + } + // Todo - implement these methods /* istanbul ignore next */ protected checkRelayerExistsInDOM(): Promise { diff --git a/yarn.lock b/yarn.lock index f3a25a9eb..6ef5aae45 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3401,14 +3401,17 @@ __metadata: expo: ^52.0.44 expo-application: ^5.0.1 expo-modules-core: ^2.2.3 + expo-secure-store: ~14.0.1 jest-expo: ~52.0.6 localforage: ^1.7.4 lodash: ^4.17.19 process: ~0.11.10 react: ^19.1.0 react-native: ^0.78.2 + react-native-device-crypto: ^0.1.7 react-native-event-listeners: ^1.0.7 react-native-safe-area-context: ^5.3.0 + react-native-uuid: ^2.0.3 react-native-webview: ^13.13.5 react-test-renderer: ^19.1.0 regenerator-runtime: 0.13.9 @@ -10371,6 +10374,15 @@ __metadata: languageName: node linkType: hard +"expo-secure-store@npm:~14.0.1": + version: 14.0.1 + resolution: "expo-secure-store@npm:14.0.1" + peerDependencies: + expo: "*" + checksum: a38f4fc06ba1ff334c930a433fbc12c49e3af63e32687bd4bcfa0130da2aefa55ab721a5ce248387962399f2d7a13b6979ae3c81363d076376dbfa6bc4584dad + languageName: node + linkType: hard + "expo-web-browser@npm:14.0.2": version: 14.0.2 resolution: "expo-web-browser@npm:14.0.2" From 97b954ab98099330891a906fb278090b605b839f Mon Sep 17 00:00:00 2001 From: sherzod-bakhodirov Date: Thu, 4 Dec 2025 19:58:58 +0500 Subject: [PATCH 11/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 659b8ebab7d0875e40318ed7f1b98a47ae3136b0 Mon Sep 17 00:00:00 2001 From: sherzod-bakhodirov Date: Fri, 5 Dec 2025 19:06:28 +0500 Subject: [PATCH 12/17] feat: implement tests for rt & dpop --- .../src/native-crypto/keychain.ts | 2 +- .../src/react-native-webview-controller.tsx | 8 +- .../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 +++ .../src/react-native-webview-controller.tsx | 8 +- .../test/spec/native-crypto/dpop.spec.ts | 181 ++++++++++++++ .../test/spec/native-crypto/keychain.spec.ts | 186 ++++++++++++++ .../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 | 26 ++ .../spec/native-crypto/utils/uint8.spec.ts | 163 +++++++++++++ .../getJWT.spec.ts | 22 ++ .../getRT.spec.ts | 15 ++ .../persistMagicEventRefreshToken.spec.ts | 27 +++ 21 files changed, 1880 insertions(+), 9 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 create mode 100644 packages/@magic-sdk/react-native-expo/test/spec/native-crypto/dpop.spec.ts create mode 100644 packages/@magic-sdk/react-native-expo/test/spec/native-crypto/keychain.spec.ts create mode 100644 packages/@magic-sdk/react-native-expo/test/spec/native-crypto/utils/der.spec.ts create mode 100644 packages/@magic-sdk/react-native-expo/test/spec/native-crypto/utils/jwk.spec.ts create mode 100644 packages/@magic-sdk/react-native-expo/test/spec/native-crypto/utils/key-alias.spec.ts create mode 100644 packages/@magic-sdk/react-native-expo/test/spec/native-crypto/utils/uint8.spec.ts create mode 100644 packages/@magic-sdk/react-native-expo/test/spec/react-native-webview-controller/getJWT.spec.ts create mode 100644 packages/@magic-sdk/react-native-expo/test/spec/react-native-webview-controller/getRT.spec.ts create mode 100644 packages/@magic-sdk/react-native-expo/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 54ac940b3..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,16 +275,16 @@ 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; } - 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 { diff --git a/packages/@magic-sdk/react-native-bare/test/spec/native-crypto/dpop.spec.ts b/packages/@magic-sdk/react-native-bare/test/spec/native-crypto/dpop.spec.ts new file mode 100644 index 000000000..daff90f34 --- /dev/null +++ b/packages/@magic-sdk/react-native-bare/test/spec/native-crypto/dpop.spec.ts @@ -0,0 +1,170 @@ +// Mock react-native-device-crypto before any imports +jest.mock('react-native-device-crypto', () => ({ + __esModule: true, + default: { + getOrCreateAsymmetricKey: jest.fn(), + sign: jest.fn(), + deleteKey: jest.fn(), + }, + AccessLevel: { + ALWAYS: 'ALWAYS', + }, +})); + +jest.mock('react-native-uuid', () => ({ + v4: () => 'test-uuid-1234', +})); + +// react-native-device-info is already mocked in test/mocks.ts + +import DeviceCrypto from 'react-native-device-crypto'; +import { getDpop, deleteDpop } from '../../../src/native-crypto/dpop'; +import { uint8ArrayToBase64 } from '../../../src/native-crypto/utils/uint8'; + +// Helper to create a mock SPKI public key +const createMockPublicKey = () => { + const xCoord = new Uint8Array(32).fill(0x11); + const yCoord = new Uint8Array(32).fill(0x22); + const spkiHeader = new Uint8Array([ + 0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, + 0x3d, 0x03, 0x01, 0x07, 0x03, 0x42, 0x00, + ]); + const keyData = new Uint8Array([0x04, ...xCoord, ...yCoord]); + return uint8ArrayToBase64(new Uint8Array([...spkiHeader, ...keyData])); +}; + +// Helper to create a mock DER signature +const createMockDerSignature = () => { + const r = new Uint8Array(32).fill(0x33); + const s = new Uint8Array(32).fill(0x44); + return uint8ArrayToBase64(new Uint8Array([0x30, 68, 0x02, 32, ...r, 0x02, 32, ...s])); +}; + +describe('dpop', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getDpop', () => { + it('should generate a valid DPoP token', async () => { + const mockPublicKey = createMockPublicKey(); + const mockSignature = createMockDerSignature(); + + (DeviceCrypto.getOrCreateAsymmetricKey as jest.Mock).mockResolvedValue(mockPublicKey); + (DeviceCrypto.sign as jest.Mock).mockResolvedValue(mockSignature); + + const result = await getDpop(); + + expect(result).not.toBeNull(); + expect(typeof result).toBe('string'); + + // Verify the token structure (header.payload.signature) + const parts = result!.split('.'); + expect(parts.length).toBe(3); + + // Verify header + const header = JSON.parse(Buffer.from(parts[0], 'base64').toString()); + expect(header.typ).toBe('dpop+jwt'); + expect(header.alg).toBe('ES256'); + expect(header.jwk).toBeDefined(); + expect(header.jwk.kty).toBe('EC'); + expect(header.jwk.crv).toBe('P-256'); + + // Verify payload + const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString()); + expect(payload.jti).toBe('test-uuid-1234'); + expect(typeof payload.iat).toBe('number'); + }); + + it('should call DeviceCrypto with correct key alias and options', async () => { + const mockPublicKey = createMockPublicKey(); + const mockSignature = createMockDerSignature(); + + (DeviceCrypto.getOrCreateAsymmetricKey as jest.Mock).mockResolvedValue(mockPublicKey); + (DeviceCrypto.sign as jest.Mock).mockResolvedValue(mockSignature); + + await getDpop(); + + expect(DeviceCrypto.getOrCreateAsymmetricKey).toHaveBeenCalledWith('com.apple.mockApp.magic.sdk.dpop', { + accessLevel: 'ALWAYS', + invalidateOnNewBiometry: false, + }); + + expect(DeviceCrypto.sign).toHaveBeenCalledWith( + 'com.apple.mockApp.magic.sdk.dpop', + expect.stringContaining('.'), + expect.objectContaining({ + biometryTitle: 'Sign DPoP', + biometrySubTitle: 'Sign DPoP', + biometryDescription: 'Sign DPoP', + }), + ); + }); + + it('should return null when key creation fails', async () => { + (DeviceCrypto.getOrCreateAsymmetricKey as jest.Mock).mockRejectedValue(new Error('Key creation failed')); + + const result = await getDpop(); + + expect(result).toBeNull(); + }); + + it('should return null when signing fails', async () => { + const mockPublicKey = createMockPublicKey(); + + (DeviceCrypto.getOrCreateAsymmetricKey as jest.Mock).mockResolvedValue(mockPublicKey); + (DeviceCrypto.sign as jest.Mock).mockRejectedValue(new Error('Signing failed')); + + const result = await getDpop(); + + expect(result).toBeNull(); + }); + + it('should include correct timestamp in claims', async () => { + const mockDate = new Date('2024-01-15T12:00:00Z'); + jest.spyOn(global.Date, 'now').mockReturnValue(mockDate.getTime()); + + const mockPublicKey = createMockPublicKey(); + const mockSignature = createMockDerSignature(); + + (DeviceCrypto.getOrCreateAsymmetricKey as jest.Mock).mockResolvedValue(mockPublicKey); + (DeviceCrypto.sign as jest.Mock).mockResolvedValue(mockSignature); + + const result = await getDpop(); + + const parts = result!.split('.'); + const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString()); + + expect(payload.iat).toBe(Math.floor(mockDate.getTime() / 1000)); + + jest.restoreAllMocks(); + }); + }); + + describe('deleteDpop', () => { + it('should return true when key is deleted successfully', async () => { + (DeviceCrypto.deleteKey as jest.Mock).mockResolvedValue(true); + + const result = await deleteDpop(); + + expect(result).toBe(true); + expect(DeviceCrypto.deleteKey).toHaveBeenCalledWith('com.apple.mockApp.magic.sdk.dpop'); + }); + + it('should return false when deletion throws an error', async () => { + (DeviceCrypto.deleteKey as jest.Mock).mockRejectedValue(new Error('Deletion failed')); + + const result = await deleteDpop(); + + expect(result).toBe(false); + }); + + it('should return false when deleteKey returns false', async () => { + (DeviceCrypto.deleteKey as jest.Mock).mockResolvedValue(false); + + const result = await deleteDpop(); + + expect(result).toBe(false); + }); + }); +}); diff --git a/packages/@magic-sdk/react-native-bare/test/spec/native-crypto/keychain.spec.ts b/packages/@magic-sdk/react-native-bare/test/spec/native-crypto/keychain.spec.ts new file mode 100644 index 000000000..7eade850f --- /dev/null +++ b/packages/@magic-sdk/react-native-bare/test/spec/native-crypto/keychain.spec.ts @@ -0,0 +1,111 @@ +import { + getRefreshTokenInKeychain, + removeRefreshTokenInKeychain, + setRefreshTokenInKeychain, +} from '../../../src/native-crypto/keychain'; +import { setGenericPassword, getGenericPassword, resetGenericPassword, ACCESSIBLE } from 'react-native-keychain'; + +jest.mock('react-native-device-info', () => ({ + getBundleId: () => 'com.apple.mockApp', +})); + +jest.mock('react-native-keychain', () => ({ + setGenericPassword: jest.fn(() => Promise.resolve('mock-result')), + getGenericPassword: jest.fn(() => Promise.resolve({ password: 'test-token' })), + resetGenericPassword: jest.fn(() => Promise.resolve(true)), + ACCESSIBLE: { + AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY: 'AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY', + }, +})); + +describe('setRefreshTokenInKeychain', () => { + it('should store refresh token successfully', async () => { + await removeRefreshTokenInKeychain(); + const result = await setRefreshTokenInKeychain('test-token'); + + expect(setGenericPassword).toHaveBeenCalledWith('com.apple.mockApp.magic.sdk.rt', 'test-token', { + service: 'com.apple.mockApp.magic.sdk.rt.service', + accessible: ACCESSIBLE.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY, + }); + expect(result).toBe('mock-result'); + }); + it('should skip write if token has not changed (cached)', async () => { + await setRefreshTokenInKeychain('test-token'); + await setRefreshTokenInKeychain('test-token'); + + expect(setGenericPassword).toHaveBeenCalledTimes(1); + }); + it('should return false on error', async () => { + (setGenericPassword as jest.Mock).mockRejectedValueOnce(new Error('test-error')); + + await removeRefreshTokenInKeychain(); + const result = await setRefreshTokenInKeychain('test-token-2'); + + expect(result).toBe(false); + expect(setGenericPassword).toHaveBeenCalledWith('com.apple.mockApp.magic.sdk.rt', 'test-token', { + service: 'com.apple.mockApp.magic.sdk.rt.service', + accessible: ACCESSIBLE.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY, + }); + }); +}); + +describe('getRefreshTokenInKeychain', () => { + it('should retrieve refresh token successfully', async () => { + await removeRefreshTokenInKeychain(); // make sure to reset cache; + + const result = await getRefreshTokenInKeychain(); + + expect(result).toBe('test-token'); + expect(getGenericPassword).toHaveBeenCalledWith({ service: 'com.apple.mockApp.magic.sdk.rt.service' }); + }); + it('should return cached token if available', async () => { + jest.clearAllMocks(); + await setRefreshTokenInKeychain('cached-token'); + + const result = await getRefreshTokenInKeychain(); + + expect(result).toBe('cached-token'); + expect(getGenericPassword).not.toHaveBeenCalled(); + }); + it('should return null on error', async () => { + (getGenericPassword as jest.Mock).mockRejectedValueOnce(new Error('test-error')); + + await removeRefreshTokenInKeychain(); + const result = await getRefreshTokenInKeychain(); + + expect(result).toBeNull(); + }); + it('should return null if no refresh token found in keychain', async () => { + (getGenericPassword as jest.Mock).mockResolvedValueOnce(undefined); + + await removeRefreshTokenInKeychain(); + const result = await getRefreshTokenInKeychain(); + + expect(result).toBeNull(); + }); +}); + +describe('removeRefreshTokenInKeychain', () => { + it('should remove refresh token successfully', async () => { + const result = await removeRefreshTokenInKeychain(); + expect(result).toBe(true); + expect(resetGenericPassword).toHaveBeenCalledWith({ service: 'com.apple.mockApp.magic.sdk.rt.service' }); + }); + it('should clear cache when removing token', async () => { + await setRefreshTokenInKeychain('cached-token'); // cache the token + + const success = await removeRefreshTokenInKeychain(); // remove both from cache & keychain + const refreshToken = await getRefreshTokenInKeychain(); // try to get it from keychain and get mock value + + expect(success).toBe(true); + expect(refreshToken).toBe('test-token'); + expect(resetGenericPassword).toHaveBeenCalledWith({ service: 'com.apple.mockApp.magic.sdk.rt.service' }); + expect(getGenericPassword).toHaveBeenCalledWith({ service: 'com.apple.mockApp.magic.sdk.rt.service' }); + }); + it('should handle error gracefully', async () => { + (resetGenericPassword as jest.Mock).mockRejectedValue(new Error('test-error')); + const result = await removeRefreshTokenInKeychain(); + expect(result).toBeNull(); + expect(resetGenericPassword).toHaveBeenCalledWith({ service: 'com.apple.mockApp.magic.sdk.rt.service' }); + }); +}); diff --git a/packages/@magic-sdk/react-native-bare/test/spec/native-crypto/utils/der.spec.ts b/packages/@magic-sdk/react-native-bare/test/spec/native-crypto/utils/der.spec.ts new file mode 100644 index 000000000..def2cc840 --- /dev/null +++ b/packages/@magic-sdk/react-native-bare/test/spec/native-crypto/utils/der.spec.ts @@ -0,0 +1,228 @@ +import { derToRawSignature } from '../../../../src/native-crypto/utils/der'; +import { uint8ArrayToBase64 } from '../../../../src/native-crypto/utils/uint8'; + +describe('der utilities', () => { + describe('derToRawSignature', () => { + it('should convert DER signature to raw P1363 format', () => { + // Create a valid DER signature structure + // DER: 0x30 | total_len | 0x02 | r_len | r_bytes | 0x02 | s_len | s_bytes + const r = new Uint8Array(32).fill(0x11); + const s = new Uint8Array(32).fill(0x22); + + // Build DER encoded signature + const derSignature = new Uint8Array([ + 0x30, + 68, // total length: 2 + 32 + 2 + 32 = 68 + 0x02, + 32, // R length + ...r, + 0x02, + 32, // S length + ...s, + ]); + + const derBase64 = uint8ArrayToBase64(derSignature); + const result = derToRawSignature(derBase64); + + // Decode the result to verify + const decoded = Buffer.from(result, 'base64'); + expect(decoded.length).toBe(64); + expect(decoded.subarray(0, 32)).toEqual(Buffer.from(r)); + expect(decoded.subarray(32, 64)).toEqual(Buffer.from(s)); + }); + + it('should handle DER signature with padded R (33 bytes with leading 0x00)', () => { + // When R has high bit set, it gets padded with 0x00 in DER + const r = new Uint8Array(32); + r[0] = 0x80; // High bit set + r.fill(0x11, 1); + + const s = new Uint8Array(32).fill(0x22); + + // Build DER with padded R + const derSignature = new Uint8Array([ + 0x30, + 69, // total length: 2 + 33 + 2 + 32 = 69 + 0x02, + 33, // R length (padded) + 0x00, // padding byte + ...r, + 0x02, + 32, // S length + ...s, + ]); + + const derBase64 = uint8ArrayToBase64(derSignature); + const result = derToRawSignature(derBase64); + + const decoded = Buffer.from(result, 'base64'); + expect(decoded.length).toBe(64); + expect(decoded.subarray(0, 32)).toEqual(Buffer.from(r)); + expect(decoded.subarray(32, 64)).toEqual(Buffer.from(s)); + }); + + it('should handle DER signature with padded S (33 bytes with leading 0x00)', () => { + const r = new Uint8Array(32).fill(0x11); + + // When S has high bit set, it gets padded with 0x00 in DER + const s = new Uint8Array(32); + s[0] = 0x80; // High bit set + s.fill(0x22, 1); + + // Build DER with padded S + const derSignature = new Uint8Array([ + 0x30, + 69, // total length: 2 + 32 + 2 + 33 = 69 + 0x02, + 32, // R length + ...r, + 0x02, + 33, // S length (padded) + 0x00, // padding byte + ...s, + ]); + + const derBase64 = uint8ArrayToBase64(derSignature); + const result = derToRawSignature(derBase64); + + const decoded = Buffer.from(result, 'base64'); + expect(decoded.length).toBe(64); + expect(decoded.subarray(0, 32)).toEqual(Buffer.from(r)); + expect(decoded.subarray(32, 64)).toEqual(Buffer.from(s)); + }); + + it('should handle DER signature with both R and S padded', () => { + const r = new Uint8Array(32); + r[0] = 0x80; + r.fill(0x11, 1); + + const s = new Uint8Array(32); + s[0] = 0x80; + s.fill(0x22, 1); + + // Build DER with both padded + const derSignature = new Uint8Array([ + 0x30, + 70, // total length: 2 + 33 + 2 + 33 = 70 + 0x02, + 33, // R length (padded) + 0x00, + ...r, + 0x02, + 33, // S length (padded) + 0x00, + ...s, + ]); + + const derBase64 = uint8ArrayToBase64(derSignature); + const result = derToRawSignature(derBase64); + + const decoded = Buffer.from(result, 'base64'); + expect(decoded.length).toBe(64); + expect(decoded.subarray(0, 32)).toEqual(Buffer.from(r)); + expect(decoded.subarray(32, 64)).toEqual(Buffer.from(s)); + }); + + it('should throw error if R tag is missing', () => { + const invalidDer = new Uint8Array([ + 0x30, + 4, + 0x03, // Invalid tag (should be 0x02) + 2, + 0x11, + 0x22, + ]); + + const derBase64 = uint8ArrayToBase64(invalidDer); + expect(() => derToRawSignature(derBase64)).toThrow('Invalid DER: R tag missing'); + }); + + it('should throw error if S tag is missing', () => { + const r = new Uint8Array(32).fill(0x11); + const invalidDer = new Uint8Array([ + 0x30, + 38, + 0x02, + 32, + ...r, + 0x03, // Invalid tag (should be 0x02) + 2, + 0x22, + 0x33, + ]); + + const derBase64 = uint8ArrayToBase64(invalidDer); + expect(() => derToRawSignature(derBase64)).toThrow('Invalid DER: S tag missing'); + }); + + it('should produce base64url encoded output', () => { + const r = new Uint8Array(32).fill(0xff); + const s = new Uint8Array(32).fill(0xff); + + const derSignature = new Uint8Array([0x30, 68, 0x02, 32, ...r, 0x02, 32, ...s]); + + const derBase64 = uint8ArrayToBase64(derSignature); + const result = derToRawSignature(derBase64); + + // Should be base64url encoded (no +, /, or =) + expect(result).not.toContain('+'); + expect(result).not.toContain('/'); + expect(result).not.toContain('='); + }); + + it('should handle shorter R value with proper padding', () => { + // R value is only 31 bytes (needs left-padding in output) + const r = new Uint8Array(31).fill(0x11); + const s = new Uint8Array(32).fill(0x22); + + const derSignature = new Uint8Array([ + 0x30, + 67, // total length: 2 + 31 + 2 + 32 = 67 + 0x02, + 31, // R length (shorter) + ...r, + 0x02, + 32, // S length + ...s, + ]); + + const derBase64 = uint8ArrayToBase64(derSignature); + const result = derToRawSignature(derBase64); + + const decoded = Buffer.from(result, 'base64'); + expect(decoded.length).toBe(64); + // R should be left-padded with one zero byte + expect(decoded[0]).toBe(0); + expect(decoded.subarray(1, 32)).toEqual(Buffer.from(r)); + expect(decoded.subarray(32, 64)).toEqual(Buffer.from(s)); + }); + + it('should handle shorter S value with proper padding', () => { + const r = new Uint8Array(32).fill(0x11); + // S value is only 31 bytes (needs left-padding in output) + const s = new Uint8Array(31).fill(0x22); + + const derSignature = new Uint8Array([ + 0x30, + 67, // total length: 2 + 32 + 2 + 31 = 67 + 0x02, + 32, // R length + ...r, + 0x02, + 31, // S length (shorter) + ...s, + ]); + + const derBase64 = uint8ArrayToBase64(derSignature); + const result = derToRawSignature(derBase64); + + const decoded = Buffer.from(result, 'base64'); + expect(decoded.length).toBe(64); + expect(decoded.subarray(0, 32)).toEqual(Buffer.from(r)); + // S should be left-padded with one zero byte + expect(decoded[32]).toBe(0); + expect(decoded.subarray(33, 64)).toEqual(Buffer.from(s)); + }); + }); +}); + diff --git a/packages/@magic-sdk/react-native-bare/test/spec/native-crypto/utils/jwk.spec.ts b/packages/@magic-sdk/react-native-bare/test/spec/native-crypto/utils/jwk.spec.ts new file mode 100644 index 000000000..02465ac4b --- /dev/null +++ b/packages/@magic-sdk/react-native-bare/test/spec/native-crypto/utils/jwk.spec.ts @@ -0,0 +1,132 @@ +import { spkiToJwk } from '../../../../src/native-crypto/utils/jwk'; +import { uint8ArrayToBase64 } from '../../../../src/native-crypto/utils/uint8'; + +describe('jwk utilities', () => { + describe('spkiToJwk', () => { + it('should convert SPKI public key to JWK format', () => { + // Create a mock P-256 SPKI public key + // SPKI header for P-256 is 26 bytes, followed by 65 bytes of key data + const spkiHeader = new Uint8Array([ + 0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, + 0x3d, 0x03, 0x01, 0x07, 0x03, 0x42, 0x00, + ]); + + // 65 bytes: 0x04 (uncompressed point indicator) + 32 bytes X + 32 bytes Y + const xCoord = new Uint8Array(32); + xCoord.fill(0x11); + const yCoord = new Uint8Array(32); + yCoord.fill(0x22); + + const keyData = new Uint8Array([0x04, ...xCoord, ...yCoord]); + + const spkiKey = new Uint8Array([...spkiHeader, ...keyData]); + const spkiBase64 = uint8ArrayToBase64(spkiKey); + + const result = spkiToJwk(spkiBase64); + + expect(result).toEqual({ + kty: 'EC', + crv: 'P-256', + x: expect.any(String), + y: expect.any(String), + }); + + // Verify X and Y are base64url encoded + expect(result.x).not.toContain('+'); + expect(result.x).not.toContain('/'); + expect(result.x).not.toContain('='); + expect(result.y).not.toContain('+'); + expect(result.y).not.toContain('/'); + expect(result.y).not.toContain('='); + }); + + it('should extract correct X and Y coordinates', () => { + // Create predictable coordinates + const xCoord = new Uint8Array(32); + for (let i = 0; i < 32; i++) xCoord[i] = i; + + const yCoord = new Uint8Array(32); + for (let i = 0; i < 32; i++) yCoord[i] = 32 + i; + + const spkiHeader = new Uint8Array([ + 0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, + 0x3d, 0x03, 0x01, 0x07, 0x03, 0x42, 0x00, + ]); + + const keyData = new Uint8Array([0x04, ...xCoord, ...yCoord]); + const spkiKey = new Uint8Array([...spkiHeader, ...keyData]); + const spkiBase64 = uint8ArrayToBase64(spkiKey); + + const result = spkiToJwk(spkiBase64); + + // Decode and verify + const decodedX = Buffer.from(result.x, 'base64'); + const decodedY = Buffer.from(result.y, 'base64'); + + expect(decodedX).toEqual(Buffer.from(xCoord)); + expect(decodedY).toEqual(Buffer.from(yCoord)); + }); + + it('should throw error for compressed point format', () => { + const spkiHeader = new Uint8Array([ + 0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, + 0x3d, 0x03, 0x01, 0x07, 0x03, 0x42, 0x00, + ]); + + // Compressed point starts with 0x02 or 0x03 instead of 0x04 + const compressedKey = new Uint8Array([0x02, ...new Uint8Array(64).fill(0x11)]); + const spkiKey = new Uint8Array([...spkiHeader, ...compressedKey]); + const spkiBase64 = uint8ArrayToBase64(spkiKey); + + expect(() => spkiToJwk(spkiBase64)).toThrow('Invalid Public Key format: Expected uncompressed point'); + }); + + it('should handle minimal SPKI with just the required 65 bytes at the end', () => { + // The function extracts the last 65 bytes regardless of header + const xCoord = new Uint8Array(32).fill(0xaa); + const yCoord = new Uint8Array(32).fill(0xbb); + + // Just some arbitrary header bytes + const header = new Uint8Array(10).fill(0x00); + const keyData = new Uint8Array([0x04, ...xCoord, ...yCoord]); + + const spkiKey = new Uint8Array([...header, ...keyData]); + const spkiBase64 = uint8ArrayToBase64(spkiKey); + + const result = spkiToJwk(spkiBase64); + + expect(result.kty).toBe('EC'); + expect(result.crv).toBe('P-256'); + + const decodedX = Buffer.from(result.x, 'base64'); + const decodedY = Buffer.from(result.y, 'base64'); + + expect(decodedX).toEqual(Buffer.from(xCoord)); + expect(decodedY).toEqual(Buffer.from(yCoord)); + }); + + it('should handle real-world SPKI key format', () => { + // A realistic P-256 SPKI public key structure with proper uncompressed point (0x04 prefix) + // SPKI header (26 bytes) + 0x04 + X (32 bytes) + Y (32 bytes) + const xCoord = new Uint8Array(32).fill(0x11); + const yCoord = new Uint8Array(32).fill(0x22); + const spkiHeader = new Uint8Array([ + 0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, + 0x3d, 0x03, 0x01, 0x07, 0x03, 0x42, 0x00, + ]); + const keyData = new Uint8Array([0x04, ...xCoord, ...yCoord]); + const spkiKey = new Uint8Array([...spkiHeader, ...keyData]); + const realSpkiBase64 = uint8ArrayToBase64(spkiKey); + + const result = spkiToJwk(realSpkiBase64); + + expect(result).toEqual({ + kty: 'EC', + crv: 'P-256', + x: expect.any(String), + y: expect.any(String), + }); + }); + }); +}); + diff --git a/packages/@magic-sdk/react-native-bare/test/spec/native-crypto/utils/key-alias.spec.ts b/packages/@magic-sdk/react-native-bare/test/spec/native-crypto/utils/key-alias.spec.ts new file mode 100644 index 000000000..be87be54b --- /dev/null +++ b/packages/@magic-sdk/react-native-bare/test/spec/native-crypto/utils/key-alias.spec.ts @@ -0,0 +1,23 @@ +import { getKeyAlias } from '../../../../src/native-crypto/utils/key-alias'; + +// react-native-device-info is already mocked in test/mocks.ts + +describe('key-alias utilities', () => { + describe('getKeyAlias', () => { + it('should return correct alias for dpop key', () => { + const result = getKeyAlias('dpop'); + expect(result).toBe('com.apple.mockApp.magic.sdk.dpop'); + }); + + it('should return correct alias for refreshToken key', () => { + const result = getKeyAlias('refreshToken'); + expect(result).toBe('com.apple.mockApp.magic.sdk.rt'); + }); + + it('should return correct alias for refreshTokenService key', () => { + const result = getKeyAlias('refreshTokenService'); + expect(result).toBe('com.apple.mockApp.magic.sdk.rt.service'); + }); + }); +}); + diff --git a/packages/@magic-sdk/react-native-bare/test/spec/native-crypto/utils/uint8.spec.ts b/packages/@magic-sdk/react-native-bare/test/spec/native-crypto/utils/uint8.spec.ts new file mode 100644 index 000000000..9582e5486 --- /dev/null +++ b/packages/@magic-sdk/react-native-bare/test/spec/native-crypto/utils/uint8.spec.ts @@ -0,0 +1,163 @@ +import { + uint8ArrayToBase64, + base64ToUint8Array, + stringToUint8Array, + toBase64Url, +} from '../../../../src/native-crypto/utils/uint8'; + +describe('uint8 utilities', () => { + describe('uint8ArrayToBase64', () => { + it('should encode empty array to empty string', () => { + const result = uint8ArrayToBase64(new Uint8Array([])); + expect(result).toBe(''); + }); + + it('should encode simple byte array', () => { + // "Hello" in ASCII bytes + const bytes = new Uint8Array([72, 101, 108, 108, 111]); + const result = uint8ArrayToBase64(bytes); + expect(result).toBe('SGVsbG8='); + }); + + it('should encode binary data correctly', () => { + const bytes = new Uint8Array([0x00, 0xff, 0x80, 0x7f]); + const result = uint8ArrayToBase64(bytes); + expect(result).toBe('AP+Afw=='); + }); + }); + + describe('base64ToUint8Array', () => { + it('should decode empty string to empty array', () => { + const result = base64ToUint8Array(''); + expect(result).toEqual(new Uint8Array([])); + }); + + it('should decode base64 string to byte array', () => { + const result = base64ToUint8Array('SGVsbG8='); + expect(result).toEqual(new Uint8Array([72, 101, 108, 108, 111])); + }); + + it('should decode binary data correctly', () => { + const result = base64ToUint8Array('AP+Afw=='); + expect(result).toEqual(new Uint8Array([0x00, 0xff, 0x80, 0x7f])); + }); + + it('should roundtrip with uint8ArrayToBase64', () => { + const original = new Uint8Array([1, 2, 3, 4, 5, 255, 128, 0]); + const encoded = uint8ArrayToBase64(original); + const decoded = base64ToUint8Array(encoded); + expect(decoded).toEqual(original); + }); + }); + + describe('stringToUint8Array', () => { + const originalTextEncoder = global.TextEncoder; + + afterEach(() => { + // Restore original TextEncoder after each test + global.TextEncoder = originalTextEncoder; + }); + + it('uses TextEncoder when it exists', () => { + const mockEncode = jest.fn().mockReturnValue(new Uint8Array([97, 98, 99])); // "abc" + const mockConstructor = jest.fn(() => ({ encode: mockEncode })); + + // Set up a mock TextEncoder on the global object + global.TextEncoder = mockConstructor as any; + + const result = stringToUint8Array('abc'); + + expect(mockConstructor).toHaveBeenCalledTimes(1); + expect(mockEncode).toHaveBeenCalledWith('abc'); + expect(result).toEqual(new Uint8Array([97, 98, 99])); + }); + + it('should encode ASCII string', () => { + const result = stringToUint8Array('Hello'); + expect(result).toEqual(new Uint8Array([72, 101, 108, 108, 111])); + }); + + it('should encode empty string', () => { + const result = stringToUint8Array(''); + expect(result).toEqual(new Uint8Array([])); + }); + + it('should encode UTF-8 string with 2-byte characters', () => { + // "é" = 0xC3 0xA9 in UTF-8 + const result = stringToUint8Array('é'); + expect(result).toEqual(new Uint8Array([0xc3, 0xa9])); + }); + + it('should encode UTF-8 string with 3-byte characters', () => { + // "€" = 0xE2 0x82 0xAC in UTF-8 + const result = stringToUint8Array('€'); + expect(result).toEqual(new Uint8Array([0xe2, 0x82, 0xac])); + }); + + it('should encode UTF-8 string with 4-byte characters (surrogate pairs)', () => { + // "𝄞" (musical G clef) = 0xF0 0x9D 0x84 0x9E in UTF-8 + const result = stringToUint8Array('𝄞'); + expect(result).toEqual(new Uint8Array([0xf0, 0x9d, 0x84, 0x9e])); + }); + + it('should use fallback when TextEncoder is unavailable', () => { + const originalTextEncoder = global.TextEncoder; + // @ts-ignore - Testing fallback behavior + delete global.TextEncoder; + + try { + // ASCII + expect(stringToUint8Array('abc')).toEqual(new Uint8Array([97, 98, 99])); + + // 2-byte UTF-8 + expect(stringToUint8Array('é')).toEqual(new Uint8Array([0xc3, 0xa9])); + + // 3-byte UTF-8 + expect(stringToUint8Array('€')).toEqual(new Uint8Array([0xe2, 0x82, 0xac])); + + // 4-byte UTF-8 (surrogate pair) + expect(stringToUint8Array('𝄞')).toEqual(new Uint8Array([0xf0, 0x9d, 0x84, 0x9e])); + } finally { + global.TextEncoder = originalTextEncoder; + } + }); + }); + + describe('toBase64Url', () => { + it('should encode string to base64url', () => { + const result = toBase64Url('Hello'); + expect(result).toBe('SGVsbG8'); + }); + + it('should encode Uint8Array to base64url', () => { + const bytes = new Uint8Array([72, 101, 108, 108, 111]); + const result = toBase64Url(bytes); + expect(result).toBe('SGVsbG8'); + }); + + it('should replace + with - and / with _', () => { + // Create bytes that result in + and / in standard base64 + // 0xFB, 0xFF, 0xFE produces "+//+" in standard base64 + const bytes = new Uint8Array([0xfb, 0xff, 0xfe]); + const result = toBase64Url(bytes); + expect(result).not.toContain('+'); + expect(result).not.toContain('/'); + expect(result).toBe('-__-'); + }); + + it('should remove padding characters', () => { + // "Hi" produces "SGk=" in standard base64 + const result = toBase64Url('Hi'); + expect(result).not.toContain('='); + expect(result).toBe('SGk'); + }); + + it('should handle JSON object strings', () => { + const obj = { typ: 'dpop+jwt', alg: 'ES256' }; + const result = toBase64Url(JSON.stringify(obj)); + expect(result).not.toContain('+'); + expect(result).not.toContain('/'); + expect(result).not.toContain('='); + }); + }); +}); diff --git a/packages/@magic-sdk/react-native-bare/test/spec/react-native-webview-controller/getJWT.spec.ts b/packages/@magic-sdk/react-native-bare/test/spec/react-native-webview-controller/getJWT.spec.ts new file mode 100644 index 000000000..1ac805f1a --- /dev/null +++ b/packages/@magic-sdk/react-native-bare/test/spec/react-native-webview-controller/getJWT.spec.ts @@ -0,0 +1,22 @@ +import { getDpop } from '../../../src/native-crypto/dpop'; +import { createReactNativeWebViewController } from '../../factories'; + +jest.mock('../../../src/native-crypto/dpop', () => ({ + getDpop: jest.fn(), +})); + +describe('getJWT', () => { + it('should call getDpop', async () => { + const viewController = createReactNativeWebViewController('asdf'); + await viewController.getJWT(); + + expect(getDpop).toHaveBeenCalled(); + }); + + it('should return null if getDpop throws an error', async () => { + const viewController = createReactNativeWebViewController('asdf'); + (getDpop as jest.Mock).mockRejectedValueOnce(new Error('test-error')); + const result = await viewController.getJWT(); + expect(result).toBeNull(); + }); +}); diff --git a/packages/@magic-sdk/react-native-bare/test/spec/react-native-webview-controller/getRT.spec.ts b/packages/@magic-sdk/react-native-bare/test/spec/react-native-webview-controller/getRT.spec.ts new file mode 100644 index 000000000..da6528d31 --- /dev/null +++ b/packages/@magic-sdk/react-native-bare/test/spec/react-native-webview-controller/getRT.spec.ts @@ -0,0 +1,15 @@ +import { getRefreshTokenInKeychain } from '../../../src/native-crypto/keychain'; +import { createReactNativeWebViewController } from '../../factories'; + +jest.mock('../../../src/native-crypto/keychain', () => ({ + getRefreshTokenInKeychain: jest.fn(), +})); + +describe('getRT', () => { + it('should call getRefreshTokenInKeychain', async () => { + const viewController = createReactNativeWebViewController('asdf'); + await viewController.getRT(); + + expect(getRefreshTokenInKeychain).toHaveBeenCalled(); + }); +}); diff --git a/packages/@magic-sdk/react-native-bare/test/spec/react-native-webview-controller/persistMagicEventRefreshToken.spec.ts b/packages/@magic-sdk/react-native-bare/test/spec/react-native-webview-controller/persistMagicEventRefreshToken.spec.ts new file mode 100644 index 000000000..a213c6d1e --- /dev/null +++ b/packages/@magic-sdk/react-native-bare/test/spec/react-native-webview-controller/persistMagicEventRefreshToken.spec.ts @@ -0,0 +1,27 @@ +import { setRefreshTokenInKeychain } from '../../../src/native-crypto/keychain'; +import { createReactNativeWebViewController } from '../../factories'; + +jest.mock('../../../src/native-crypto/keychain', () => ({ + setRefreshTokenInKeychain: jest.fn(), +})); + +describe('persistMagicEventRefreshToken', () => { + it('should not call setRefreshTokenInKeychain if no data.rt', async () => { + const viewController = createReactNativeWebViewController('asdf'); + const result = await viewController.persistMagicEventRefreshToken({}); + + expect(result).toBeUndefined(); + expect(setRefreshTokenInKeychain).not.toHaveBeenCalled(); + }); + + it('should call setRefreshTokenInKeychain', async () => { + const viewController = createReactNativeWebViewController('asdf'); + await viewController.persistMagicEventRefreshToken({ + data: { + rt: 'test-token', + }, + }); + + expect(setRefreshTokenInKeychain).toHaveBeenCalledWith('test-token'); + }); +}); diff --git a/packages/@magic-sdk/react-native-expo/src/react-native-webview-controller.tsx b/packages/@magic-sdk/react-native-expo/src/react-native-webview-controller.tsx index bf8efd05b..7449df604 100644 --- a/packages/@magic-sdk/react-native-expo/src/react-native-webview-controller.tsx +++ b/packages/@magic-sdk/react-native-expo/src/react-native-webview-controller.tsx @@ -292,16 +292,16 @@ export class ReactNativeWebViewController extends ViewController { } async persistMagicEventRefreshToken(event: MagicMessageEvent) { - if (!event.data.rt) { + if (!event?.data?.rt) { return; } - setRefreshTokenInSecureStore(event.data.rt); + await setRefreshTokenInSecureStore(event.data.rt); } // Overrides parent method to retrieve refresh token from keychain while creating a request - async getRT() { - return getRefreshTokenInSecureStore(); + async getRT(): Promise { + return await getRefreshTokenInSecureStore(); } async getJWT(): Promise { diff --git a/packages/@magic-sdk/react-native-expo/test/spec/native-crypto/dpop.spec.ts b/packages/@magic-sdk/react-native-expo/test/spec/native-crypto/dpop.spec.ts new file mode 100644 index 000000000..fe445f70a --- /dev/null +++ b/packages/@magic-sdk/react-native-expo/test/spec/native-crypto/dpop.spec.ts @@ -0,0 +1,181 @@ +// Mock dependencies before imports +const mockGetOrCreateAsymmetricKey = jest.fn(); +const mockSign = jest.fn(); +const mockDeleteKey = jest.fn(); + +jest.mock('react-native-device-crypto', () => ({ + __esModule: true, + default: { + getOrCreateAsymmetricKey: mockGetOrCreateAsymmetricKey, + sign: mockSign, + deleteKey: mockDeleteKey, + }, + AccessLevel: { + ALWAYS: 'ALWAYS', + }, +})); + +jest.mock('react-native-uuid', () => ({ + v4: () => 'test-uuid-1234', +})); + +// Mock expo-application +jest.mock('expo-application', () => ({ + applicationId: 'com.test.app', +})); + +import { getDpop, deleteDpop } from '../../../src/native-crypto/dpop'; +import { uint8ArrayToBase64 } from '../../../src/native-crypto/utils/uint8'; + +describe('dpop', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getDpop', () => { + it('should generate a valid DPoP token', async () => { + // Create a mock SPKI public key (P-256 format) + const xCoord = new Uint8Array(32).fill(0x11); + const yCoord = new Uint8Array(32).fill(0x22); + const spkiHeader = new Uint8Array([ + 0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, + 0xce, 0x3d, 0x03, 0x01, 0x07, 0x03, 0x42, 0x00, + ]); + const keyData = new Uint8Array([0x04, ...xCoord, ...yCoord]); + const mockPublicKey = uint8ArrayToBase64(new Uint8Array([...spkiHeader, ...keyData])); + + mockGetOrCreateAsymmetricKey.mockResolvedValue(mockPublicKey); + + // Create a mock DER signature + const r = new Uint8Array(32).fill(0x33); + const s = new Uint8Array(32).fill(0x44); + const derSignature = new Uint8Array([0x30, 68, 0x02, 32, ...r, 0x02, 32, ...s]); + mockSign.mockResolvedValue(uint8ArrayToBase64(derSignature)); + + const result = await getDpop(); + + expect(result).not.toBeNull(); + expect(typeof result).toBe('string'); + + // Verify the token structure (header.payload.signature) + const parts = result!.split('.'); + expect(parts.length).toBe(3); + + // Verify header + const header = JSON.parse(Buffer.from(parts[0], 'base64').toString()); + expect(header.typ).toBe('dpop+jwt'); + expect(header.alg).toBe('ES256'); + expect(header.jwk).toBeDefined(); + expect(header.jwk.kty).toBe('EC'); + expect(header.jwk.crv).toBe('P-256'); + + // Verify payload + const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString()); + expect(payload.jti).toBe('test-uuid-1234'); + expect(typeof payload.iat).toBe('number'); + + // Verify DeviceCrypto was called correctly + expect(mockGetOrCreateAsymmetricKey).toHaveBeenCalledWith('com.test.app.magic.sdk.dpop', { + accessLevel: 'ALWAYS', + invalidateOnNewBiometry: false, + }); + + expect(mockSign).toHaveBeenCalledWith( + 'com.test.app.magic.sdk.dpop', + expect.stringContaining('.'), + expect.objectContaining({ + biometryTitle: 'Sign DPoP', + biometrySubTitle: 'Sign DPoP', + biometryDescription: 'Sign DPoP', + }), + ); + }); + + it('should return null on key creation error', async () => { + mockGetOrCreateAsymmetricKey.mockRejectedValue(new Error('Key creation failed')); + + const result = await getDpop(); + + expect(result).toBeNull(); + }); + + it('should return null on signing error', async () => { + // Create a mock SPKI public key + const xCoord = new Uint8Array(32).fill(0x11); + const yCoord = new Uint8Array(32).fill(0x22); + const spkiHeader = new Uint8Array([ + 0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, + 0xce, 0x3d, 0x03, 0x01, 0x07, 0x03, 0x42, 0x00, + ]); + const keyData = new Uint8Array([0x04, ...xCoord, ...yCoord]); + const mockPublicKey = uint8ArrayToBase64(new Uint8Array([...spkiHeader, ...keyData])); + + mockGetOrCreateAsymmetricKey.mockResolvedValue(mockPublicKey); + mockSign.mockRejectedValue(new Error('Signing failed')); + + const result = await getDpop(); + + expect(result).toBeNull(); + }); + + it('should use correct timestamp in claims', async () => { + const mockDate = new Date('2024-01-15T12:00:00Z'); + jest.spyOn(global.Date, 'now').mockReturnValue(mockDate.getTime()); + + // Create a mock SPKI public key + const xCoord = new Uint8Array(32).fill(0x11); + const yCoord = new Uint8Array(32).fill(0x22); + const spkiHeader = new Uint8Array([ + 0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, + 0xce, 0x3d, 0x03, 0x01, 0x07, 0x03, 0x42, 0x00, + ]); + const keyData = new Uint8Array([0x04, ...xCoord, ...yCoord]); + const mockPublicKey = uint8ArrayToBase64(new Uint8Array([...spkiHeader, ...keyData])); + + mockGetOrCreateAsymmetricKey.mockResolvedValue(mockPublicKey); + + // Create a mock DER signature + const r = new Uint8Array(32).fill(0x33); + const s = new Uint8Array(32).fill(0x44); + const derSignature = new Uint8Array([0x30, 68, 0x02, 32, ...r, 0x02, 32, ...s]); + mockSign.mockResolvedValue(uint8ArrayToBase64(derSignature)); + + const result = await getDpop(); + + const parts = result!.split('.'); + const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString()); + + expect(payload.iat).toBe(Math.floor(mockDate.getTime() / 1000)); + + jest.restoreAllMocks(); + }); + }); + + describe('deleteDpop', () => { + it('should delete the DPoP key successfully', async () => { + mockDeleteKey.mockResolvedValue(true); + + const result = await deleteDpop(); + + expect(result).toBe(true); + expect(mockDeleteKey).toHaveBeenCalledWith('com.test.app.magic.sdk.dpop'); + }); + + it('should return false on deletion error', async () => { + mockDeleteKey.mockRejectedValue(new Error('Deletion failed')); + + const result = await deleteDpop(); + + expect(result).toBe(false); + }); + + it('should return false when deleteKey returns false', async () => { + mockDeleteKey.mockResolvedValue(false); + + const result = await deleteDpop(); + + expect(result).toBe(false); + }); + }); +}); + diff --git a/packages/@magic-sdk/react-native-expo/test/spec/native-crypto/keychain.spec.ts b/packages/@magic-sdk/react-native-expo/test/spec/native-crypto/keychain.spec.ts new file mode 100644 index 000000000..e8446667a --- /dev/null +++ b/packages/@magic-sdk/react-native-expo/test/spec/native-crypto/keychain.spec.ts @@ -0,0 +1,186 @@ +// Mock expo-secure-store before importing the module +const mockSetItemAsync = jest.fn(); +const mockGetItemAsync = jest.fn(); +const mockDeleteItemAsync = jest.fn(); + +jest.mock('expo-secure-store', () => ({ + setItemAsync: mockSetItemAsync, + getItemAsync: mockGetItemAsync, + deleteItemAsync: mockDeleteItemAsync, + AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY: 'AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY', +})); + +// Mock expo-application +jest.mock('expo-application', () => ({ + applicationId: 'com.test.app', +})); + +import { + setRefreshTokenInSecureStore, + getRefreshTokenInSecureStore, + removeRefreshTokenInSecureStore, +} from '../../../src/native-crypto/keychain'; + +describe('keychain', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Reset the module to clear cached token + jest.resetModules(); + }); + + describe('setRefreshTokenInSecureStore', () => { + it('should store refresh token successfully', async () => { + mockSetItemAsync.mockResolvedValue(undefined); + + // Need to re-import to get fresh module state + jest.isolateModules(async () => { + const { setRefreshTokenInSecureStore: setToken } = require('../../../src/native-crypto/keychain'); + const result = await setToken('test-token'); + + expect(result).toBe(true); + expect(mockSetItemAsync).toHaveBeenCalledWith('com.test.app.magic.sdk.rt', 'test-token', { + keychainService: 'com.test.app.magic.sdk.rt.service', + keychainAccessible: 'AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY', + }); + }); + }); + + it('should skip write if token has not changed (cached)', async () => { + mockSetItemAsync.mockResolvedValue(undefined); + + // Set the token first + await setRefreshTokenInSecureStore('same-token'); + mockSetItemAsync.mockClear(); + + // Try to set the same token again + const result = await setRefreshTokenInSecureStore('same-token'); + + expect(result).toBe(true); + expect(mockSetItemAsync).not.toHaveBeenCalled(); + }); + + it('should return false on error', async () => { + jest.isolateModules(async () => { + const mockSetItem = jest.fn().mockRejectedValue(new Error('Storage error')); + jest.doMock('expo-secure-store', () => ({ + setItemAsync: mockSetItem, + AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY: 'AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY', + })); + + const { setRefreshTokenInSecureStore: setToken } = require('../../../src/native-crypto/keychain'); + const result = await setToken('test-token'); + + expect(result).toBe(false); + }); + }); + }); + + describe('getRefreshTokenInSecureStore', () => { + it('should retrieve refresh token successfully', async () => { + jest.isolateModules(async () => { + const mockGetItem = jest.fn().mockResolvedValue('stored-token'); + jest.doMock('expo-secure-store', () => ({ + getItemAsync: mockGetItem, + })); + jest.doMock('expo-application', () => ({ + applicationId: 'com.test.app', + })); + + const { getRefreshTokenInSecureStore: getToken } = require('../../../src/native-crypto/keychain'); + const result = await getToken(); + + expect(result).toBe('stored-token'); + expect(mockGetItem).toHaveBeenCalledWith('com.test.app.magic.sdk.rt', { + keychainService: 'com.test.app.magic.sdk.rt.service', + }); + }); + }); + + it('should return cached token if available', async () => { + mockSetItemAsync.mockResolvedValue(undefined); + mockGetItemAsync.mockResolvedValue('stored-token'); + + // First set a token to populate the cache + await setRefreshTokenInSecureStore('cached-token'); + + // Now get should return cached value without calling getItemAsync + mockGetItemAsync.mockClear(); + const result = await getRefreshTokenInSecureStore(); + + expect(result).toBe('cached-token'); + expect(mockGetItemAsync).not.toHaveBeenCalled(); + }); + + it('should return null if no token exists', async () => { + jest.isolateModules(async () => { + const mockGetItem = jest.fn().mockResolvedValue(null); + jest.doMock('expo-secure-store', () => ({ + getItemAsync: mockGetItem, + })); + jest.doMock('expo-application', () => ({ + applicationId: 'com.test.app', + })); + + const { getRefreshTokenInSecureStore: getToken } = require('../../../src/native-crypto/keychain'); + const result = await getToken(); + + expect(result).toBeNull(); + }); + }); + + it('should return null on error', async () => { + jest.isolateModules(async () => { + const mockGetItem = jest.fn().mockRejectedValue(new Error('Storage error')); + jest.doMock('expo-secure-store', () => ({ + getItemAsync: mockGetItem, + })); + jest.doMock('expo-application', () => ({ + applicationId: 'com.test.app', + })); + + const { getRefreshTokenInSecureStore: getToken } = require('../../../src/native-crypto/keychain'); + const result = await getToken(); + + expect(result).toBeNull(); + }); + }); + }); + + describe('removeRefreshTokenInSecureStore', () => { + it('should remove refresh token successfully', async () => { + mockDeleteItemAsync.mockResolvedValue(undefined); + + await removeRefreshTokenInSecureStore(); + + expect(mockDeleteItemAsync).toHaveBeenCalledWith('com.test.app.magic.sdk.rt', { + keychainService: 'com.test.app.magic.sdk.rt.service', + }); + }); + + it('should clear cache when removing token', async () => { + mockSetItemAsync.mockResolvedValue(undefined); + mockDeleteItemAsync.mockResolvedValue(undefined); + mockGetItemAsync.mockResolvedValue('new-token'); + + // Set a token first + await setRefreshTokenInSecureStore('cached-token'); + + // Remove the token + await removeRefreshTokenInSecureStore(); + + // Now get should call getItemAsync since cache is cleared + const result = await getRefreshTokenInSecureStore(); + + expect(mockGetItemAsync).toHaveBeenCalled(); + expect(result).toBe('new-token'); + }); + + it('should handle error gracefully', async () => { + mockDeleteItemAsync.mockRejectedValue(new Error('Delete error')); + + // Should not throw + await expect(removeRefreshTokenInSecureStore()).resolves.not.toThrow(); + }); + }); +}); + diff --git a/packages/@magic-sdk/react-native-expo/test/spec/native-crypto/utils/der.spec.ts b/packages/@magic-sdk/react-native-expo/test/spec/native-crypto/utils/der.spec.ts new file mode 100644 index 000000000..def2cc840 --- /dev/null +++ b/packages/@magic-sdk/react-native-expo/test/spec/native-crypto/utils/der.spec.ts @@ -0,0 +1,228 @@ +import { derToRawSignature } from '../../../../src/native-crypto/utils/der'; +import { uint8ArrayToBase64 } from '../../../../src/native-crypto/utils/uint8'; + +describe('der utilities', () => { + describe('derToRawSignature', () => { + it('should convert DER signature to raw P1363 format', () => { + // Create a valid DER signature structure + // DER: 0x30 | total_len | 0x02 | r_len | r_bytes | 0x02 | s_len | s_bytes + const r = new Uint8Array(32).fill(0x11); + const s = new Uint8Array(32).fill(0x22); + + // Build DER encoded signature + const derSignature = new Uint8Array([ + 0x30, + 68, // total length: 2 + 32 + 2 + 32 = 68 + 0x02, + 32, // R length + ...r, + 0x02, + 32, // S length + ...s, + ]); + + const derBase64 = uint8ArrayToBase64(derSignature); + const result = derToRawSignature(derBase64); + + // Decode the result to verify + const decoded = Buffer.from(result, 'base64'); + expect(decoded.length).toBe(64); + expect(decoded.subarray(0, 32)).toEqual(Buffer.from(r)); + expect(decoded.subarray(32, 64)).toEqual(Buffer.from(s)); + }); + + it('should handle DER signature with padded R (33 bytes with leading 0x00)', () => { + // When R has high bit set, it gets padded with 0x00 in DER + const r = new Uint8Array(32); + r[0] = 0x80; // High bit set + r.fill(0x11, 1); + + const s = new Uint8Array(32).fill(0x22); + + // Build DER with padded R + const derSignature = new Uint8Array([ + 0x30, + 69, // total length: 2 + 33 + 2 + 32 = 69 + 0x02, + 33, // R length (padded) + 0x00, // padding byte + ...r, + 0x02, + 32, // S length + ...s, + ]); + + const derBase64 = uint8ArrayToBase64(derSignature); + const result = derToRawSignature(derBase64); + + const decoded = Buffer.from(result, 'base64'); + expect(decoded.length).toBe(64); + expect(decoded.subarray(0, 32)).toEqual(Buffer.from(r)); + expect(decoded.subarray(32, 64)).toEqual(Buffer.from(s)); + }); + + it('should handle DER signature with padded S (33 bytes with leading 0x00)', () => { + const r = new Uint8Array(32).fill(0x11); + + // When S has high bit set, it gets padded with 0x00 in DER + const s = new Uint8Array(32); + s[0] = 0x80; // High bit set + s.fill(0x22, 1); + + // Build DER with padded S + const derSignature = new Uint8Array([ + 0x30, + 69, // total length: 2 + 32 + 2 + 33 = 69 + 0x02, + 32, // R length + ...r, + 0x02, + 33, // S length (padded) + 0x00, // padding byte + ...s, + ]); + + const derBase64 = uint8ArrayToBase64(derSignature); + const result = derToRawSignature(derBase64); + + const decoded = Buffer.from(result, 'base64'); + expect(decoded.length).toBe(64); + expect(decoded.subarray(0, 32)).toEqual(Buffer.from(r)); + expect(decoded.subarray(32, 64)).toEqual(Buffer.from(s)); + }); + + it('should handle DER signature with both R and S padded', () => { + const r = new Uint8Array(32); + r[0] = 0x80; + r.fill(0x11, 1); + + const s = new Uint8Array(32); + s[0] = 0x80; + s.fill(0x22, 1); + + // Build DER with both padded + const derSignature = new Uint8Array([ + 0x30, + 70, // total length: 2 + 33 + 2 + 33 = 70 + 0x02, + 33, // R length (padded) + 0x00, + ...r, + 0x02, + 33, // S length (padded) + 0x00, + ...s, + ]); + + const derBase64 = uint8ArrayToBase64(derSignature); + const result = derToRawSignature(derBase64); + + const decoded = Buffer.from(result, 'base64'); + expect(decoded.length).toBe(64); + expect(decoded.subarray(0, 32)).toEqual(Buffer.from(r)); + expect(decoded.subarray(32, 64)).toEqual(Buffer.from(s)); + }); + + it('should throw error if R tag is missing', () => { + const invalidDer = new Uint8Array([ + 0x30, + 4, + 0x03, // Invalid tag (should be 0x02) + 2, + 0x11, + 0x22, + ]); + + const derBase64 = uint8ArrayToBase64(invalidDer); + expect(() => derToRawSignature(derBase64)).toThrow('Invalid DER: R tag missing'); + }); + + it('should throw error if S tag is missing', () => { + const r = new Uint8Array(32).fill(0x11); + const invalidDer = new Uint8Array([ + 0x30, + 38, + 0x02, + 32, + ...r, + 0x03, // Invalid tag (should be 0x02) + 2, + 0x22, + 0x33, + ]); + + const derBase64 = uint8ArrayToBase64(invalidDer); + expect(() => derToRawSignature(derBase64)).toThrow('Invalid DER: S tag missing'); + }); + + it('should produce base64url encoded output', () => { + const r = new Uint8Array(32).fill(0xff); + const s = new Uint8Array(32).fill(0xff); + + const derSignature = new Uint8Array([0x30, 68, 0x02, 32, ...r, 0x02, 32, ...s]); + + const derBase64 = uint8ArrayToBase64(derSignature); + const result = derToRawSignature(derBase64); + + // Should be base64url encoded (no +, /, or =) + expect(result).not.toContain('+'); + expect(result).not.toContain('/'); + expect(result).not.toContain('='); + }); + + it('should handle shorter R value with proper padding', () => { + // R value is only 31 bytes (needs left-padding in output) + const r = new Uint8Array(31).fill(0x11); + const s = new Uint8Array(32).fill(0x22); + + const derSignature = new Uint8Array([ + 0x30, + 67, // total length: 2 + 31 + 2 + 32 = 67 + 0x02, + 31, // R length (shorter) + ...r, + 0x02, + 32, // S length + ...s, + ]); + + const derBase64 = uint8ArrayToBase64(derSignature); + const result = derToRawSignature(derBase64); + + const decoded = Buffer.from(result, 'base64'); + expect(decoded.length).toBe(64); + // R should be left-padded with one zero byte + expect(decoded[0]).toBe(0); + expect(decoded.subarray(1, 32)).toEqual(Buffer.from(r)); + expect(decoded.subarray(32, 64)).toEqual(Buffer.from(s)); + }); + + it('should handle shorter S value with proper padding', () => { + const r = new Uint8Array(32).fill(0x11); + // S value is only 31 bytes (needs left-padding in output) + const s = new Uint8Array(31).fill(0x22); + + const derSignature = new Uint8Array([ + 0x30, + 67, // total length: 2 + 32 + 2 + 31 = 67 + 0x02, + 32, // R length + ...r, + 0x02, + 31, // S length (shorter) + ...s, + ]); + + const derBase64 = uint8ArrayToBase64(derSignature); + const result = derToRawSignature(derBase64); + + const decoded = Buffer.from(result, 'base64'); + expect(decoded.length).toBe(64); + expect(decoded.subarray(0, 32)).toEqual(Buffer.from(r)); + // S should be left-padded with one zero byte + expect(decoded[32]).toBe(0); + expect(decoded.subarray(33, 64)).toEqual(Buffer.from(s)); + }); + }); +}); + diff --git a/packages/@magic-sdk/react-native-expo/test/spec/native-crypto/utils/jwk.spec.ts b/packages/@magic-sdk/react-native-expo/test/spec/native-crypto/utils/jwk.spec.ts new file mode 100644 index 000000000..02465ac4b --- /dev/null +++ b/packages/@magic-sdk/react-native-expo/test/spec/native-crypto/utils/jwk.spec.ts @@ -0,0 +1,132 @@ +import { spkiToJwk } from '../../../../src/native-crypto/utils/jwk'; +import { uint8ArrayToBase64 } from '../../../../src/native-crypto/utils/uint8'; + +describe('jwk utilities', () => { + describe('spkiToJwk', () => { + it('should convert SPKI public key to JWK format', () => { + // Create a mock P-256 SPKI public key + // SPKI header for P-256 is 26 bytes, followed by 65 bytes of key data + const spkiHeader = new Uint8Array([ + 0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, + 0x3d, 0x03, 0x01, 0x07, 0x03, 0x42, 0x00, + ]); + + // 65 bytes: 0x04 (uncompressed point indicator) + 32 bytes X + 32 bytes Y + const xCoord = new Uint8Array(32); + xCoord.fill(0x11); + const yCoord = new Uint8Array(32); + yCoord.fill(0x22); + + const keyData = new Uint8Array([0x04, ...xCoord, ...yCoord]); + + const spkiKey = new Uint8Array([...spkiHeader, ...keyData]); + const spkiBase64 = uint8ArrayToBase64(spkiKey); + + const result = spkiToJwk(spkiBase64); + + expect(result).toEqual({ + kty: 'EC', + crv: 'P-256', + x: expect.any(String), + y: expect.any(String), + }); + + // Verify X and Y are base64url encoded + expect(result.x).not.toContain('+'); + expect(result.x).not.toContain('/'); + expect(result.x).not.toContain('='); + expect(result.y).not.toContain('+'); + expect(result.y).not.toContain('/'); + expect(result.y).not.toContain('='); + }); + + it('should extract correct X and Y coordinates', () => { + // Create predictable coordinates + const xCoord = new Uint8Array(32); + for (let i = 0; i < 32; i++) xCoord[i] = i; + + const yCoord = new Uint8Array(32); + for (let i = 0; i < 32; i++) yCoord[i] = 32 + i; + + const spkiHeader = new Uint8Array([ + 0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, + 0x3d, 0x03, 0x01, 0x07, 0x03, 0x42, 0x00, + ]); + + const keyData = new Uint8Array([0x04, ...xCoord, ...yCoord]); + const spkiKey = new Uint8Array([...spkiHeader, ...keyData]); + const spkiBase64 = uint8ArrayToBase64(spkiKey); + + const result = spkiToJwk(spkiBase64); + + // Decode and verify + const decodedX = Buffer.from(result.x, 'base64'); + const decodedY = Buffer.from(result.y, 'base64'); + + expect(decodedX).toEqual(Buffer.from(xCoord)); + expect(decodedY).toEqual(Buffer.from(yCoord)); + }); + + it('should throw error for compressed point format', () => { + const spkiHeader = new Uint8Array([ + 0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, + 0x3d, 0x03, 0x01, 0x07, 0x03, 0x42, 0x00, + ]); + + // Compressed point starts with 0x02 or 0x03 instead of 0x04 + const compressedKey = new Uint8Array([0x02, ...new Uint8Array(64).fill(0x11)]); + const spkiKey = new Uint8Array([...spkiHeader, ...compressedKey]); + const spkiBase64 = uint8ArrayToBase64(spkiKey); + + expect(() => spkiToJwk(spkiBase64)).toThrow('Invalid Public Key format: Expected uncompressed point'); + }); + + it('should handle minimal SPKI with just the required 65 bytes at the end', () => { + // The function extracts the last 65 bytes regardless of header + const xCoord = new Uint8Array(32).fill(0xaa); + const yCoord = new Uint8Array(32).fill(0xbb); + + // Just some arbitrary header bytes + const header = new Uint8Array(10).fill(0x00); + const keyData = new Uint8Array([0x04, ...xCoord, ...yCoord]); + + const spkiKey = new Uint8Array([...header, ...keyData]); + const spkiBase64 = uint8ArrayToBase64(spkiKey); + + const result = spkiToJwk(spkiBase64); + + expect(result.kty).toBe('EC'); + expect(result.crv).toBe('P-256'); + + const decodedX = Buffer.from(result.x, 'base64'); + const decodedY = Buffer.from(result.y, 'base64'); + + expect(decodedX).toEqual(Buffer.from(xCoord)); + expect(decodedY).toEqual(Buffer.from(yCoord)); + }); + + it('should handle real-world SPKI key format', () => { + // A realistic P-256 SPKI public key structure with proper uncompressed point (0x04 prefix) + // SPKI header (26 bytes) + 0x04 + X (32 bytes) + Y (32 bytes) + const xCoord = new Uint8Array(32).fill(0x11); + const yCoord = new Uint8Array(32).fill(0x22); + const spkiHeader = new Uint8Array([ + 0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, + 0x3d, 0x03, 0x01, 0x07, 0x03, 0x42, 0x00, + ]); + const keyData = new Uint8Array([0x04, ...xCoord, ...yCoord]); + const spkiKey = new Uint8Array([...spkiHeader, ...keyData]); + const realSpkiBase64 = uint8ArrayToBase64(spkiKey); + + const result = spkiToJwk(realSpkiBase64); + + expect(result).toEqual({ + kty: 'EC', + crv: 'P-256', + x: expect.any(String), + y: expect.any(String), + }); + }); + }); +}); + diff --git a/packages/@magic-sdk/react-native-expo/test/spec/native-crypto/utils/key-alias.spec.ts b/packages/@magic-sdk/react-native-expo/test/spec/native-crypto/utils/key-alias.spec.ts new file mode 100644 index 000000000..de0ed22bd --- /dev/null +++ b/packages/@magic-sdk/react-native-expo/test/spec/native-crypto/utils/key-alias.spec.ts @@ -0,0 +1,26 @@ +import { getKeyAlias } from '../../../../src/native-crypto/utils/key-alias'; + +// Mock expo-application +jest.mock('expo-application', () => ({ + applicationId: 'com.test.app', +})); + +describe('key-alias utilities', () => { + describe('getKeyAlias', () => { + it('should return correct alias for dpop key', () => { + const result = getKeyAlias('dpop'); + expect(result).toBe('com.test.app.magic.sdk.dpop'); + }); + + it('should return correct alias for refreshToken key', () => { + const result = getKeyAlias('refreshToken'); + expect(result).toBe('com.test.app.magic.sdk.rt'); + }); + + it('should return correct alias for refreshTokenService key', () => { + const result = getKeyAlias('refreshTokenService'); + expect(result).toBe('com.test.app.magic.sdk.rt.service'); + }); + }); +}); + diff --git a/packages/@magic-sdk/react-native-expo/test/spec/native-crypto/utils/uint8.spec.ts b/packages/@magic-sdk/react-native-expo/test/spec/native-crypto/utils/uint8.spec.ts new file mode 100644 index 000000000..9582e5486 --- /dev/null +++ b/packages/@magic-sdk/react-native-expo/test/spec/native-crypto/utils/uint8.spec.ts @@ -0,0 +1,163 @@ +import { + uint8ArrayToBase64, + base64ToUint8Array, + stringToUint8Array, + toBase64Url, +} from '../../../../src/native-crypto/utils/uint8'; + +describe('uint8 utilities', () => { + describe('uint8ArrayToBase64', () => { + it('should encode empty array to empty string', () => { + const result = uint8ArrayToBase64(new Uint8Array([])); + expect(result).toBe(''); + }); + + it('should encode simple byte array', () => { + // "Hello" in ASCII bytes + const bytes = new Uint8Array([72, 101, 108, 108, 111]); + const result = uint8ArrayToBase64(bytes); + expect(result).toBe('SGVsbG8='); + }); + + it('should encode binary data correctly', () => { + const bytes = new Uint8Array([0x00, 0xff, 0x80, 0x7f]); + const result = uint8ArrayToBase64(bytes); + expect(result).toBe('AP+Afw=='); + }); + }); + + describe('base64ToUint8Array', () => { + it('should decode empty string to empty array', () => { + const result = base64ToUint8Array(''); + expect(result).toEqual(new Uint8Array([])); + }); + + it('should decode base64 string to byte array', () => { + const result = base64ToUint8Array('SGVsbG8='); + expect(result).toEqual(new Uint8Array([72, 101, 108, 108, 111])); + }); + + it('should decode binary data correctly', () => { + const result = base64ToUint8Array('AP+Afw=='); + expect(result).toEqual(new Uint8Array([0x00, 0xff, 0x80, 0x7f])); + }); + + it('should roundtrip with uint8ArrayToBase64', () => { + const original = new Uint8Array([1, 2, 3, 4, 5, 255, 128, 0]); + const encoded = uint8ArrayToBase64(original); + const decoded = base64ToUint8Array(encoded); + expect(decoded).toEqual(original); + }); + }); + + describe('stringToUint8Array', () => { + const originalTextEncoder = global.TextEncoder; + + afterEach(() => { + // Restore original TextEncoder after each test + global.TextEncoder = originalTextEncoder; + }); + + it('uses TextEncoder when it exists', () => { + const mockEncode = jest.fn().mockReturnValue(new Uint8Array([97, 98, 99])); // "abc" + const mockConstructor = jest.fn(() => ({ encode: mockEncode })); + + // Set up a mock TextEncoder on the global object + global.TextEncoder = mockConstructor as any; + + const result = stringToUint8Array('abc'); + + expect(mockConstructor).toHaveBeenCalledTimes(1); + expect(mockEncode).toHaveBeenCalledWith('abc'); + expect(result).toEqual(new Uint8Array([97, 98, 99])); + }); + + it('should encode ASCII string', () => { + const result = stringToUint8Array('Hello'); + expect(result).toEqual(new Uint8Array([72, 101, 108, 108, 111])); + }); + + it('should encode empty string', () => { + const result = stringToUint8Array(''); + expect(result).toEqual(new Uint8Array([])); + }); + + it('should encode UTF-8 string with 2-byte characters', () => { + // "é" = 0xC3 0xA9 in UTF-8 + const result = stringToUint8Array('é'); + expect(result).toEqual(new Uint8Array([0xc3, 0xa9])); + }); + + it('should encode UTF-8 string with 3-byte characters', () => { + // "€" = 0xE2 0x82 0xAC in UTF-8 + const result = stringToUint8Array('€'); + expect(result).toEqual(new Uint8Array([0xe2, 0x82, 0xac])); + }); + + it('should encode UTF-8 string with 4-byte characters (surrogate pairs)', () => { + // "𝄞" (musical G clef) = 0xF0 0x9D 0x84 0x9E in UTF-8 + const result = stringToUint8Array('𝄞'); + expect(result).toEqual(new Uint8Array([0xf0, 0x9d, 0x84, 0x9e])); + }); + + it('should use fallback when TextEncoder is unavailable', () => { + const originalTextEncoder = global.TextEncoder; + // @ts-ignore - Testing fallback behavior + delete global.TextEncoder; + + try { + // ASCII + expect(stringToUint8Array('abc')).toEqual(new Uint8Array([97, 98, 99])); + + // 2-byte UTF-8 + expect(stringToUint8Array('é')).toEqual(new Uint8Array([0xc3, 0xa9])); + + // 3-byte UTF-8 + expect(stringToUint8Array('€')).toEqual(new Uint8Array([0xe2, 0x82, 0xac])); + + // 4-byte UTF-8 (surrogate pair) + expect(stringToUint8Array('𝄞')).toEqual(new Uint8Array([0xf0, 0x9d, 0x84, 0x9e])); + } finally { + global.TextEncoder = originalTextEncoder; + } + }); + }); + + describe('toBase64Url', () => { + it('should encode string to base64url', () => { + const result = toBase64Url('Hello'); + expect(result).toBe('SGVsbG8'); + }); + + it('should encode Uint8Array to base64url', () => { + const bytes = new Uint8Array([72, 101, 108, 108, 111]); + const result = toBase64Url(bytes); + expect(result).toBe('SGVsbG8'); + }); + + it('should replace + with - and / with _', () => { + // Create bytes that result in + and / in standard base64 + // 0xFB, 0xFF, 0xFE produces "+//+" in standard base64 + const bytes = new Uint8Array([0xfb, 0xff, 0xfe]); + const result = toBase64Url(bytes); + expect(result).not.toContain('+'); + expect(result).not.toContain('/'); + expect(result).toBe('-__-'); + }); + + it('should remove padding characters', () => { + // "Hi" produces "SGk=" in standard base64 + const result = toBase64Url('Hi'); + expect(result).not.toContain('='); + expect(result).toBe('SGk'); + }); + + it('should handle JSON object strings', () => { + const obj = { typ: 'dpop+jwt', alg: 'ES256' }; + const result = toBase64Url(JSON.stringify(obj)); + expect(result).not.toContain('+'); + expect(result).not.toContain('/'); + expect(result).not.toContain('='); + }); + }); +}); diff --git a/packages/@magic-sdk/react-native-expo/test/spec/react-native-webview-controller/getJWT.spec.ts b/packages/@magic-sdk/react-native-expo/test/spec/react-native-webview-controller/getJWT.spec.ts new file mode 100644 index 000000000..1ac805f1a --- /dev/null +++ b/packages/@magic-sdk/react-native-expo/test/spec/react-native-webview-controller/getJWT.spec.ts @@ -0,0 +1,22 @@ +import { getDpop } from '../../../src/native-crypto/dpop'; +import { createReactNativeWebViewController } from '../../factories'; + +jest.mock('../../../src/native-crypto/dpop', () => ({ + getDpop: jest.fn(), +})); + +describe('getJWT', () => { + it('should call getDpop', async () => { + const viewController = createReactNativeWebViewController('asdf'); + await viewController.getJWT(); + + expect(getDpop).toHaveBeenCalled(); + }); + + it('should return null if getDpop throws an error', async () => { + const viewController = createReactNativeWebViewController('asdf'); + (getDpop as jest.Mock).mockRejectedValueOnce(new Error('test-error')); + const result = await viewController.getJWT(); + expect(result).toBeNull(); + }); +}); diff --git a/packages/@magic-sdk/react-native-expo/test/spec/react-native-webview-controller/getRT.spec.ts b/packages/@magic-sdk/react-native-expo/test/spec/react-native-webview-controller/getRT.spec.ts new file mode 100644 index 000000000..92befe4d9 --- /dev/null +++ b/packages/@magic-sdk/react-native-expo/test/spec/react-native-webview-controller/getRT.spec.ts @@ -0,0 +1,15 @@ +import { getRefreshTokenInSecureStore } from '../../../src/native-crypto/keychain'; +import { createReactNativeWebViewController } from '../../factories'; + +jest.mock('../../../src/native-crypto/keychain', () => ({ + getRefreshTokenInSecureStore: jest.fn(), +})); + +describe('getRT', () => { + it('should call getRefreshTokenInSecureStore', async () => { + const viewController = createReactNativeWebViewController('asdf'); + await viewController.getRT(); + + expect(getRefreshTokenInSecureStore).toHaveBeenCalled(); + }); +}); diff --git a/packages/@magic-sdk/react-native-expo/test/spec/react-native-webview-controller/persistMagicEventRefreshToken.spec.ts b/packages/@magic-sdk/react-native-expo/test/spec/react-native-webview-controller/persistMagicEventRefreshToken.spec.ts new file mode 100644 index 000000000..fce8466a3 --- /dev/null +++ b/packages/@magic-sdk/react-native-expo/test/spec/react-native-webview-controller/persistMagicEventRefreshToken.spec.ts @@ -0,0 +1,27 @@ +import { setRefreshTokenInSecureStore } from '../../../src/native-crypto/keychain'; +import { createReactNativeWebViewController } from '../../factories'; + +jest.mock('../../../src/native-crypto/keychain', () => ({ + setRefreshTokenInSecureStore: jest.fn(), +})); + +describe('persistMagicEventRefreshToken', () => { + it('should not call setRefreshTokenInSecureStore if no data.rt', async () => { + const viewController = createReactNativeWebViewController('asdf'); + const result = await viewController.persistMagicEventRefreshToken({}); + + expect(result).toBeUndefined(); + expect(setRefreshTokenInSecureStore).not.toHaveBeenCalled(); + }); + + it('should call setRefreshTokenInSecureStore', async () => { + const viewController = createReactNativeWebViewController('asdf'); + await viewController.persistMagicEventRefreshToken({ + data: { + rt: 'test-token', + }, + }); + + expect(setRefreshTokenInSecureStore).toHaveBeenCalledWith('test-token'); + }); +}); From 7c1adafe351056d606a2f70444e8061e1ce5e884 Mon Sep 17 00:00:00 2001 From: sherzod-bakhodirov Date: Fri, 5 Dec 2025 19:09:42 +0500 Subject: [PATCH 13/17] chore: add podspec --- .../MagicSdkReactNativeBare.podspec | 23 +++++++++++++++++++ .../MagicSdkReactNativeExpo.podspec | 23 +++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 packages/@magic-sdk/react-native-bare/MagicSdkReactNativeBare.podspec create mode 100644 packages/@magic-sdk/react-native-expo/MagicSdkReactNativeExpo.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..37e2b0f44 --- /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 \ No newline at end of file diff --git a/packages/@magic-sdk/react-native-expo/MagicSdkReactNativeExpo.podspec b/packages/@magic-sdk/react-native-expo/MagicSdkReactNativeExpo.podspec new file mode 100644 index 000000000..5cbcead94 --- /dev/null +++ b/packages/@magic-sdk/react-native-expo/MagicSdkReactNativeExpo.podspec @@ -0,0 +1,23 @@ +require "json" + +package = JSON.parse(File.read(File.join(__dir__, "package.json"))) + +Pod::Spec.new do |s| + s.name = "MagicSdkReactNativeExpo" + 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-device-crypto) will be + # automatically linked via React Native autolinking when this package is installed + + s.dependency "React-Core" +end \ No newline at end of file From 96a08da2110d87d0158db5f0bb5f3887aefc4bf1 Mon Sep 17 00:00:00 2001 From: sherzod-bakhodirov Date: Mon, 8 Dec 2025 16:04:49 +0500 Subject: [PATCH 14/17] chore: revert rn-bare changes --- .../MagicSdkReactNativeBare.podspec | 23 -- .../@magic-sdk/react-native-bare/package.json | 3 - .../src/native-crypto/constants/index.ts | 2 - .../src/native-crypto/dpop.ts | 78 ------ .../src/native-crypto/keychain.ts | 54 ----- .../src/native-crypto/types/index.ts | 20 -- .../src/native-crypto/utils/der.ts | 46 ---- .../src/native-crypto/utils/jwk.ts | 26 -- .../src/native-crypto/utils/key-alias.ts | 16 -- .../src/native-crypto/utils/uint8.ts | 72 ------ .../src/react-native-webview-controller.tsx | 52 ++-- .../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 --- 20 files changed, 23 insertions(+), 1260 deletions(-) delete mode 100644 packages/@magic-sdk/react-native-bare/MagicSdkReactNativeBare.podspec delete mode 100644 packages/@magic-sdk/react-native-bare/src/native-crypto/constants/index.ts delete mode 100644 packages/@magic-sdk/react-native-bare/src/native-crypto/dpop.ts delete mode 100644 packages/@magic-sdk/react-native-bare/src/native-crypto/keychain.ts delete mode 100644 packages/@magic-sdk/react-native-bare/src/native-crypto/types/index.ts delete mode 100644 packages/@magic-sdk/react-native-bare/src/native-crypto/utils/der.ts delete mode 100644 packages/@magic-sdk/react-native-bare/src/native-crypto/utils/jwk.ts delete mode 100644 packages/@magic-sdk/react-native-bare/src/native-crypto/utils/key-alias.ts delete mode 100644 packages/@magic-sdk/react-native-bare/src/native-crypto/utils/uint8.ts delete mode 100644 packages/@magic-sdk/react-native-bare/test/spec/native-crypto/dpop.spec.ts delete mode 100644 packages/@magic-sdk/react-native-bare/test/spec/native-crypto/keychain.spec.ts delete mode 100644 packages/@magic-sdk/react-native-bare/test/spec/native-crypto/utils/der.spec.ts delete mode 100644 packages/@magic-sdk/react-native-bare/test/spec/native-crypto/utils/jwk.spec.ts delete mode 100644 packages/@magic-sdk/react-native-bare/test/spec/native-crypto/utils/key-alias.spec.ts delete mode 100644 packages/@magic-sdk/react-native-bare/test/spec/native-crypto/utils/uint8.spec.ts delete mode 100644 packages/@magic-sdk/react-native-bare/test/spec/react-native-webview-controller/getJWT.spec.ts delete mode 100644 packages/@magic-sdk/react-native-bare/test/spec/react-native-webview-controller/getRT.spec.ts delete 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/MagicSdkReactNativeBare.podspec b/packages/@magic-sdk/react-native-bare/MagicSdkReactNativeBare.podspec deleted file mode 100644 index 37e2b0f44..000000000 --- a/packages/@magic-sdk/react-native-bare/MagicSdkReactNativeBare.podspec +++ /dev/null @@ -1,23 +0,0 @@ -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 \ No newline at end of file diff --git a/packages/@magic-sdk/react-native-bare/package.json b/packages/@magic-sdk/react-native-bare/package.json index 9ff2116fc..8b3d23299 100644 --- a/packages/@magic-sdk/react-native-bare/package.json +++ b/packages/@magic-sdk/react-native-bare/package.json @@ -26,11 +26,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", "whatwg-url": "~8.1.0" 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 deleted file mode 100644 index 1380f90ef..000000000 --- a/packages/@magic-sdk/react-native-bare/src/native-crypto/constants/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const ALG = 'ES256'; -export const TYP = 'dpop+jwt'; diff --git a/packages/@magic-sdk/react-native-bare/src/native-crypto/dpop.ts b/packages/@magic-sdk/react-native-bare/src/native-crypto/dpop.ts deleted file mode 100644 index 8c863e368..000000000 --- a/packages/@magic-sdk/react-native-bare/src/native-crypto/dpop.ts +++ /dev/null @@ -1,78 +0,0 @@ -import uuid from 'react-native-uuid'; -import { toBase64Url } from './utils/uint8'; -import { spkiToJwk } from './utils/jwk'; -import { ALG, TYP } from './constants'; -import { derToRawSignature } from './utils/der'; -import { DpopClaims, DpopHeader } from './types'; -import DeviceCrypto, { AccessLevel } from 'react-native-device-crypto'; -import { getKeyAlias } from './utils/key-alias'; - -const KEY_ALIAS = getKeyAlias('dpop'); - -/** - * Generates the DPoP proof compatible with the Python backend. - * Handles key creation (if missing), JWK construction, and signing. - */ -export const getDpop = async (): Promise => { - try { - // 1. Get or Create Key in Secure Enclave - // We strictly disable authentication to avoid biometric prompts - const publicKey = await DeviceCrypto.getOrCreateAsymmetricKey(KEY_ALIAS, { - accessLevel: AccessLevel.ALWAYS, // Key is always accessible in this device - invalidateOnNewBiometry: false, - }); - - // 2. Prepare Public Key as JWK - // Toaster backend expects JWK in the header - const publicJwk = spkiToJwk(publicKey); - - // 3. Construct Payload - const now = Math.floor(Date.now() / 1000); - const claims: DpopClaims = { - iat: now, - jti: uuid.v4(), - }; - - const header: DpopHeader = { - typ: TYP, - alg: ALG, - jwk: publicJwk, - }; - - // 4. Prepare Signing Input - const headerB64 = toBase64Url(JSON.stringify(header)); - const payloadB64 = toBase64Url(JSON.stringify(claims)); - const signingInput = `${headerB64}.${payloadB64}`; - - // 5. Sign Data - // DeviceCrypto returns a Base64 signature. - const signatureBase64 = await DeviceCrypto.sign(KEY_ALIAS, signingInput, { - // Biometry prompts should not be fired since the key is always accessible in this device - biometryTitle: 'Sign DPoP', - biometrySubTitle: 'Sign DPoP', - biometryDescription: 'Sign DPoP', - }); - - // 6. Convert Signature (Toaster expects Raw R|S) - const signatureB64 = derToRawSignature(signatureBase64); - - return `${signingInput}.${signatureB64}`; - } catch (error) { - console.error('DPoP Generation Error:', error); - return null; - } -}; - -/** - * Removes the keys from the Secure Enclave - * Returns true if the key was deleted successfully, false otherwise. - * @returns {Promise} True if the key was deleted successfully, false otherwise. - */ -export const deleteDpop = async (): Promise => { - try { - return await DeviceCrypto.deleteKey(KEY_ALIAS); - } catch (error) { - console.error('DPoP Deletion Error:', error); - return false; - } -}; diff --git a/packages/@magic-sdk/react-native-bare/src/native-crypto/keychain.ts b/packages/@magic-sdk/react-native-bare/src/native-crypto/keychain.ts deleted file mode 100644 index daa5bebb6..000000000 --- a/packages/@magic-sdk/react-native-bare/src/native-crypto/keychain.ts +++ /dev/null @@ -1,54 +0,0 @@ -import * as Keychain from 'react-native-keychain'; -import { getKeyAlias } from './utils/key-alias'; - -const SERVICE = getKeyAlias('refreshTokenService'); -const KEY = getKeyAlias('refreshToken'); - -let cachedRefreshToken: string | null = null; - -export const setRefreshTokenInKeychain = async (rt: string) => { - // Skip write if token hasn't changed - if (cachedRefreshToken === rt) { - return true; - } - - try { - const result = await Keychain.setGenericPassword(KEY, rt, { - service: SERVICE, - accessible: Keychain.ACCESSIBLE.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY, - }); - cachedRefreshToken = rt; // Update cache on successful write - return result; - } catch (error) { - console.error('Failed to set refresh token in keychain', error); - return false; - } -}; - -export const getRefreshTokenInKeychain = async (): Promise => { - // Return cached value if available - if (cachedRefreshToken !== null) { - return cachedRefreshToken; - } - - try { - const keychainEntry = await Keychain.getGenericPassword({ service: SERVICE }); - if (!keychainEntry) return null; - - cachedRefreshToken = keychainEntry.password; - return cachedRefreshToken; - } catch (error) { - console.error('Failed to get refresh token in keychain', error); - return null; - } -}; - -export const removeRefreshTokenInKeychain = async () => { - try { - cachedRefreshToken = null; // Clear cache - return await Keychain.resetGenericPassword({ service: SERVICE }); - } catch (error) { - console.error('Failed to remove refresh token in keychain', error); - return null; - } -}; diff --git a/packages/@magic-sdk/react-native-bare/src/native-crypto/types/index.ts b/packages/@magic-sdk/react-native-bare/src/native-crypto/types/index.ts deleted file mode 100644 index 4069afd27..000000000 --- a/packages/@magic-sdk/react-native-bare/src/native-crypto/types/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -export interface DpopHeader { - typ: 'dpop+jwt'; - alg: 'ES256'; - jwk: JsonWebKey; -} - -export interface DpopClaims { - iat: number; - jti: string; - htm?: string; - htu?: string; -} - -export interface JsonWebKey { - kty: string; - crv: string; - x: string; - y: string; - ext?: boolean; -} diff --git a/packages/@magic-sdk/react-native-bare/src/native-crypto/utils/der.ts b/packages/@magic-sdk/react-native-bare/src/native-crypto/utils/der.ts deleted file mode 100644 index 98b296f12..000000000 --- a/packages/@magic-sdk/react-native-bare/src/native-crypto/utils/der.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { base64ToUint8Array, toBase64Url } from './uint8'; - -/** - * Converts a DER encoded signature (ASN.1) to a Raw R|S signature (64 bytes). - * Device Crypto returns DER; Toaster backend expects Raw P1363. - */ -export const derToRawSignature = (derBase64: string): string => { - const der = base64ToUint8Array(derBase64); - - // DER Structure: 0x30 | total_len | 0x02 | r_len | r_bytes | 0x02 | s_len | s_bytes - let offset = 2; // Skip Sequence Tag (0x30) and Total Length - - // --- Read R --- - if (der[offset] !== 0x02) throw new Error('Invalid DER: R tag missing'); - offset++; // skip tag - const rLen = der[offset++]; // read length - let rBytes = der.subarray(offset, offset + rLen); - offset += rLen; - - // Handle ASN.1 Integer padding for R (remove leading 0x00 if present) - if (rLen === 33 && rBytes[0] === 0x00) { - rBytes = rBytes.subarray(1); - } - - // --- Read S --- - if (der[offset] !== 0x02) throw new Error('Invalid DER: S tag missing'); - offset++; // skip tag - const sLen = der[offset++]; // read length - let sBytes = der.subarray(offset, offset + sLen); - - // Handle ASN.1 Integer padding for S - if (sLen === 33 && sBytes[0] === 0x00) { - sBytes = sBytes.subarray(1); - } - - // --- Construct Raw Signature (64 bytes) --- - const rawSignature = new Uint8Array(64); - - // Copy R into the first 32 bytes (right-aligned/padded) - rawSignature.set(rBytes, 32 - rBytes.length); - - // Copy S into the last 32 bytes (right-aligned/padded) - rawSignature.set(sBytes, 64 - sBytes.length); - - return toBase64Url(rawSignature); -}; diff --git a/packages/@magic-sdk/react-native-bare/src/native-crypto/utils/jwk.ts b/packages/@magic-sdk/react-native-bare/src/native-crypto/utils/jwk.ts deleted file mode 100644 index 3cde8102c..000000000 --- a/packages/@magic-sdk/react-native-bare/src/native-crypto/utils/jwk.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { base64ToUint8Array, toBase64Url } from './uint8'; - -/** - * Converts SPKI Public Key (Base64) to JWK format. - * Extracts Raw X and Y coordinates from the uncompressed point. - */ -export const spkiToJwk = (spkiBase64: string) => { - const buf = base64ToUint8Array(spkiBase64); - // P-256 SPKI Header is 26 bytes. The key data (0x04 + X + Y) is at the end. - // We explicitly look for the last 65 bytes (1 header byte + 32 bytes X + 32 bytes Y) - const rawKey = buf.subarray(buf.length - 65); - - if (rawKey[0] !== 0x04) { - throw new Error('Invalid Public Key format: Expected uncompressed point'); - } - - const x = rawKey.subarray(1, 33); - const y = rawKey.subarray(33, 65); - - return { - kty: 'EC', - crv: 'P-256', - x: toBase64Url(x), - y: toBase64Url(y), - }; -}; diff --git a/packages/@magic-sdk/react-native-bare/src/native-crypto/utils/key-alias.ts b/packages/@magic-sdk/react-native-bare/src/native-crypto/utils/key-alias.ts deleted file mode 100644 index 6147d4380..000000000 --- a/packages/@magic-sdk/react-native-bare/src/native-crypto/utils/key-alias.ts +++ /dev/null @@ -1,16 +0,0 @@ -import DeviceInfo from 'react-native-device-info'; - -const KEY_SUFFIX_MAP = { - dpop: 'magic.sdk.dpop', - refreshToken: 'magic.sdk.rt', - refreshTokenService: 'magic.sdk.rt.service', -}; - -/** - * Returns the key alias for the given key. - * Let's us to safely store the keys in the keychain and avoid conflicts with other apps using magic sdk. - */ -export function getKeyAlias(key: keyof typeof KEY_SUFFIX_MAP): string { - const appId = DeviceInfo.getBundleId(); - return `${appId}.${KEY_SUFFIX_MAP[key]}`; -} diff --git a/packages/@magic-sdk/react-native-bare/src/native-crypto/utils/uint8.ts b/packages/@magic-sdk/react-native-bare/src/native-crypto/utils/uint8.ts deleted file mode 100644 index ac4edcc8d..000000000 --- a/packages/@magic-sdk/react-native-bare/src/native-crypto/utils/uint8.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * 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 09f8c73cc..7a252f576 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,6 @@ import { EventRegister } from 'react-native-event-listeners'; /* global NodeJS */ import Global = NodeJS.Global; import { useInternetConnection } from './hooks'; -import { getRefreshTokenInKeychain, setRefreshTokenInKeychain } from './native-crypto/keychain'; -import { getDpop } from './native-crypto/dpop'; const MAGIC_PAYLOAD_FLAG_TYPED_ARRAY = 'MAGIC_PAYLOAD_FLAG_TYPED_ARRAY'; const OPEN_IN_DEVICE_BROWSER = 'open_in_device_browser'; @@ -24,7 +22,10 @@ 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, @@ -37,9 +38,15 @@ function createWebViewStyles() { bottom: 0, }, - show: { zIndex: 10000, elevation: 10000 }, + show: { + zIndex: 10000, + elevation: 10000, + }, - hide: { zIndex: -10000, elevation: 0 }, + hide: { + zIndex: -10000, + elevation: 0, + }, }); } @@ -125,7 +132,11 @@ export class ReactNativeWebViewController extends ViewController { * display styles. */ const containerRef = useCallback((view: any): void => { - this.container = { ...view, showOverlay, hideOverlay }; + this.container = { + ...view, + showOverlay, + hideOverlay, + }; }, []); /** @@ -145,7 +156,12 @@ 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]); @@ -273,28 +289,6 @@ export class ReactNativeWebViewController extends ViewController { } } - // Overrides parent method to keep refresh token in keychain - async persistMagicEventRefreshToken(event: MagicMessageEvent) { - if (!event?.data?.rt) { - return; - } - - await setRefreshTokenInKeychain(event.data.rt); - } - - // Overrides parent method to retrieve refresh token from keychain while creating a request - async getRT(): Promise { - return await getRefreshTokenInKeychain(); - } - - async getJWT(): Promise { - try { - return await getDpop(); - } catch (e) { - return null; - } - } - // Todo - implement these methods /* istanbul ignore next */ protected checkRelayerExistsInDOM(): Promise { diff --git a/packages/@magic-sdk/react-native-bare/test/spec/native-crypto/dpop.spec.ts b/packages/@magic-sdk/react-native-bare/test/spec/native-crypto/dpop.spec.ts deleted file mode 100644 index daff90f34..000000000 --- a/packages/@magic-sdk/react-native-bare/test/spec/native-crypto/dpop.spec.ts +++ /dev/null @@ -1,170 +0,0 @@ -// 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 deleted file mode 100644 index 7eade850f..000000000 --- a/packages/@magic-sdk/react-native-bare/test/spec/native-crypto/keychain.spec.ts +++ /dev/null @@ -1,111 +0,0 @@ -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 deleted file mode 100644 index def2cc840..000000000 --- a/packages/@magic-sdk/react-native-bare/test/spec/native-crypto/utils/der.spec.ts +++ /dev/null @@ -1,228 +0,0 @@ -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 deleted file mode 100644 index 02465ac4b..000000000 --- a/packages/@magic-sdk/react-native-bare/test/spec/native-crypto/utils/jwk.spec.ts +++ /dev/null @@ -1,132 +0,0 @@ -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 deleted file mode 100644 index be87be54b..000000000 --- a/packages/@magic-sdk/react-native-bare/test/spec/native-crypto/utils/key-alias.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index 9582e5486..000000000 --- a/packages/@magic-sdk/react-native-bare/test/spec/native-crypto/utils/uint8.spec.ts +++ /dev/null @@ -1,163 +0,0 @@ -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 deleted file mode 100644 index 1ac805f1a..000000000 --- a/packages/@magic-sdk/react-native-bare/test/spec/react-native-webview-controller/getJWT.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index da6528d31..000000000 --- a/packages/@magic-sdk/react-native-bare/test/spec/react-native-webview-controller/getRT.spec.ts +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index a213c6d1e..000000000 --- a/packages/@magic-sdk/react-native-bare/test/spec/react-native-webview-controller/persistMagicEventRefreshToken.spec.ts +++ /dev/null @@ -1,27 +0,0 @@ -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 6b2642a14a560f3e67bb783cdfcce4f02590d815 Mon Sep 17 00:00:00 2001 From: sherzod-bakhodirov Date: Mon, 8 Dec 2025 16:28:21 +0500 Subject: [PATCH 15/17] feat: show warning if peer dependencies are not installed --- .../MagicSdkReactNativeExpo.podspec | 23 --- .../@magic-sdk/react-native-expo/package.json | 6 +- .../src/native-crypto/check-native-modules.ts | 88 +++++++++++ .../src/react-native-webview-controller.tsx | 5 + .../check-native-modules.spec.ts | 142 ++++++++++++++++++ yarn.lock | 12 +- 6 files changed, 241 insertions(+), 35 deletions(-) delete mode 100644 packages/@magic-sdk/react-native-expo/MagicSdkReactNativeExpo.podspec create mode 100644 packages/@magic-sdk/react-native-expo/src/native-crypto/check-native-modules.ts create mode 100644 packages/@magic-sdk/react-native-expo/test/spec/native-crypto/check-native-modules.spec.ts diff --git a/packages/@magic-sdk/react-native-expo/MagicSdkReactNativeExpo.podspec b/packages/@magic-sdk/react-native-expo/MagicSdkReactNativeExpo.podspec deleted file mode 100644 index 5cbcead94..000000000 --- a/packages/@magic-sdk/react-native-expo/MagicSdkReactNativeExpo.podspec +++ /dev/null @@ -1,23 +0,0 @@ -require "json" - -package = JSON.parse(File.read(File.join(__dir__, "package.json"))) - -Pod::Spec.new do |s| - s.name = "MagicSdkReactNativeExpo" - 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-device-crypto) will be - # automatically linked via React Native autolinking when this package is installed - - s.dependency "React-Core" -end \ No newline at end of file diff --git a/packages/@magic-sdk/react-native-expo/package.json b/packages/@magic-sdk/react-native-expo/package.json index c9edf22f5..5bf17bf31 100644 --- a/packages/@magic-sdk/react-native-expo/package.json +++ b/packages/@magic-sdk/react-native-expo/package.json @@ -24,11 +24,9 @@ "@types/lodash": "^4.14.158", "buffer": "~5.6.0", "expo-application": "^5.0.1", - "expo-secure-store": "~14.0.1", "localforage": "^1.7.4", "lodash": "^4.17.19", "process": "~0.11.10", - "react-native-device-crypto": "^0.1.7", "react-native-event-listeners": "^1.0.7", "react-native-uuid": "^2.0.3", "tslib": "^2.0.3", @@ -42,9 +40,11 @@ "babel-preset-expo": "^12.0.11", "expo": "^52.0.44", "expo-modules-core": "^2.2.3", + "expo-secure-store": "~14.0.1", "jest-expo": "~52.0.6", "react": "^19.1.0", "react-native": "^0.78.2", + "react-native-device-crypto": "^0.1.7", "react-native-safe-area-context": "^5.3.0", "react-native-webview": "^13.13.5", "react-test-renderer": "^19.1.0", @@ -53,8 +53,10 @@ "peerDependencies": { "@react-native-community/netinfo": ">=9.0.0", "expo": "*", + "expo-secure-store": "~14.0.1", "react": ">=17", "react-native": ">=0.60", + "react-native-device-crypto": "^0.1.7", "react-native-safe-area-context": ">=4.4.1", "react-native-webview": ">=12.4.0" }, diff --git a/packages/@magic-sdk/react-native-expo/src/native-crypto/check-native-modules.ts b/packages/@magic-sdk/react-native-expo/src/native-crypto/check-native-modules.ts new file mode 100644 index 000000000..c482e4e44 --- /dev/null +++ b/packages/@magic-sdk/react-native-expo/src/native-crypto/check-native-modules.ts @@ -0,0 +1,88 @@ +import { NativeModules, Platform } from 'react-native'; + +interface NativeModuleCheck { + name: string; + nativeModuleName: string; + packageName: string; +} + +const REQUIRED_NATIVE_MODULES: NativeModuleCheck[] = [ + { + name: 'expo-secure-store', + nativeModuleName: 'ExpoSecureStore', + packageName: 'expo-secure-store', + }, + { + 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) => `npx expo install ${m.packageName}`).join('\n'); + + const iosInstructions = + platform === 'ios' + ? ` +For iOS, run: + npx expo run:ios +` + : ''; + + const androidInstructions = + platform === 'android' + ? ` +For Android, run: + npx expo run:android +` + : ''; + + console.warn( + `@magic-sdk/react-native-expo: Missing native modules detected. + +The following native modules are not linked: +${moduleList} + +The SDK will continue to work, but some security features may not function properly. + +Note: If you're running in a simulator/emulator, some native modules (like react-native-device-crypto) +require hardware features (Secure Enclave) that are only available on physical devices. + +If you're on a physical device and see this warning, please ensure the packages are installed and linked: + +1. Install the missing packages: +${installCommands} + +2. Rebuild your app: +${iosInstructions}${androidInstructions} +`, + ); + } +}; diff --git a/packages/@magic-sdk/react-native-expo/src/react-native-webview-controller.tsx b/packages/@magic-sdk/react-native-expo/src/react-native-webview-controller.tsx index 7449df604..91343ac1c 100644 --- a/packages/@magic-sdk/react-native-expo/src/react-native-webview-controller.tsx +++ b/packages/@magic-sdk/react-native-expo/src/react-native-webview-controller.tsx @@ -12,6 +12,7 @@ import Global = NodeJS.Global; import { useInternetConnection } from './hooks'; import { getRefreshTokenInSecureStore, setRefreshTokenInSecureStore } from './native-crypto/keychain'; import { getDpop } from './native-crypto/dpop'; +import { checkNativeModules } from './native-crypto/check-native-modules'; const MAGIC_PAYLOAD_FLAG_TYPED_ARRAY = 'MAGIC_PAYLOAD_FLAG_TYPED_ARRAY'; const OPEN_IN_DEVICE_BROWSER = 'open_in_device_browser'; @@ -89,6 +90,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-expo/test/spec/native-crypto/check-native-modules.spec.ts b/packages/@magic-sdk/react-native-expo/test/spec/native-crypto/check-native-modules.spec.ts new file mode 100644 index 000000000..0fa714841 --- /dev/null +++ b/packages/@magic-sdk/react-native-expo/test/spec/native-crypto/check-native-modules.spec.ts @@ -0,0 +1,142 @@ +const mockNativeModules: Record = {}; +const mockPlatform: { OS: string } = { OS: 'ios' }; + +jest.mock('react-native', () => ({ + NativeModules: mockNativeModules, + Platform: mockPlatform, +})); + +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['ExpoSecureStore']; + delete mockNativeModules['DeviceCrypto']; + mockPlatform.OS = 'ios'; + }); + + afterEach(() => { + consoleWarnSpy.mockRestore(); + }); + + it('should not log any warning when all native modules are present', () => { + mockNativeModules.ExpoSecureStore = {}; + mockNativeModules.DeviceCrypto = {}; + + const { checkNativeModules } = require('../../../src/native-crypto/check-native-modules'); + checkNativeModules(); + + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it('should log a warning when expo-secure-store is missing', () => { + mockNativeModules.ExpoSecureStore = undefined; + mockNativeModules.DeviceCrypto = {}; + + const { checkNativeModules } = require('../../../src/native-crypto/check-native-modules'); + checkNativeModules(); + + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('expo-secure-store')); + }); + + it('should log a warning when react-native-device-crypto is missing', () => { + mockNativeModules.ExpoSecureStore = {}; + 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.ExpoSecureStore = undefined; + mockNativeModules.DeviceCrypto = undefined; + + const { checkNativeModules } = require('../../../src/native-crypto/check-native-modules'); + checkNativeModules(); + + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('expo-secure-store')); + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('react-native-device-crypto')); + }); + + it('should only log warning once even if called multiple times', () => { + mockNativeModules.ExpoSecureStore = 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.ExpoSecureStore = undefined; + mockNativeModules.DeviceCrypto = {}; + + const { checkNativeModules } = require('../../../src/native-crypto/check-native-modules'); + checkNativeModules(); + + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('npx expo run:ios')); + }); + + it('should include Android instructions when platform is android', () => { + mockPlatform.OS = 'android'; + mockNativeModules.ExpoSecureStore = undefined; + mockNativeModules.DeviceCrypto = {}; + + const { checkNativeModules } = require('../../../src/native-crypto/check-native-modules'); + checkNativeModules(); + + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('npx expo run:android')); + }); + + it('should not include iOS instructions when platform is android', () => { + mockPlatform.OS = 'android'; + mockNativeModules.ExpoSecureStore = undefined; + mockNativeModules.DeviceCrypto = {}; + + const { checkNativeModules } = require('../../../src/native-crypto/check-native-modules'); + checkNativeModules(); + + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy).not.toHaveBeenCalledWith(expect.stringContaining('npx expo run:ios')); + }); + + it('should not include Android instructions when platform is ios', () => { + mockPlatform.OS = 'ios'; + mockNativeModules.ExpoSecureStore = undefined; + mockNativeModules.DeviceCrypto = {}; + + const { checkNativeModules } = require('../../../src/native-crypto/check-native-modules'); + checkNativeModules(); + + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy).not.toHaveBeenCalledWith(expect.stringContaining('npx expo run:android')); + }); + + it('should include install commands for missing packages', () => { + mockNativeModules.ExpoSecureStore = undefined; + mockNativeModules.DeviceCrypto = undefined; + + const { checkNativeModules } = require('../../../src/native-crypto/check-native-modules'); + checkNativeModules(); + + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('npx expo install expo-secure-store')); + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('npx expo install react-native-device-crypto')); + }); +}); \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 60a2e4fc6..f2308bbb0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3362,12 +3362,9 @@ __metadata: process: ~0.11.10 react: ~19.1.0 react-native: ~0.78.1 - react-native-device-crypto: ^0.1.7 react-native-device-info: ^10.3.0 react-native-event-listeners: ^1.0.7 - react-native-keychain: ^10.0.0 react-native-safe-area-context: 5.3.0 - react-native-uuid: ^2.0.3 react-native-webview: ^13.3.0 react-test-renderer: ^19.1.0 regenerator-runtime: 0.13.9 @@ -3420,8 +3417,10 @@ __metadata: peerDependencies: "@react-native-community/netinfo": ">=9.0.0" expo: "*" + expo-secure-store: ~14.0.1 react: ">=17" react-native: ">=0.60" + react-native-device-crypto: ^0.1.7 react-native-safe-area-context: ">=4.4.1" react-native-webview: ">=12.4.0" languageName: unknown @@ -17325,13 +17324,6 @@ __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 37eb9d99707a35d3b580bce2cd40c5a4cdb220bf Mon Sep 17 00:00:00 2001 From: sherzod-bakhodirov Date: Tue, 9 Dec 2025 08:56:36 +0500 Subject: [PATCH 16/17] fix: check for expo native modules properly --- .../src/native-crypto/check-native-modules.ts | 18 ++++++++++++++---- .../src/native-crypto/dpop.ts | 2 -- .../src/native-crypto/keychain.ts | 6 ------ .../src/react-native-webview-controller.tsx | 2 +- 4 files changed, 15 insertions(+), 13 deletions(-) diff --git a/packages/@magic-sdk/react-native-expo/src/native-crypto/check-native-modules.ts b/packages/@magic-sdk/react-native-expo/src/native-crypto/check-native-modules.ts index c482e4e44..6de63f10b 100644 --- a/packages/@magic-sdk/react-native-expo/src/native-crypto/check-native-modules.ts +++ b/packages/@magic-sdk/react-native-expo/src/native-crypto/check-native-modules.ts @@ -1,9 +1,11 @@ import { NativeModules, Platform } from 'react-native'; +import { requireOptionalNativeModule } from 'expo-modules-core'; interface NativeModuleCheck { name: string; nativeModuleName: string; packageName: string; + isExpoModule: boolean; } const REQUIRED_NATIVE_MODULES: NativeModuleCheck[] = [ @@ -11,11 +13,13 @@ const REQUIRED_NATIVE_MODULES: NativeModuleCheck[] = [ name: 'expo-secure-store', nativeModuleName: 'ExpoSecureStore', packageName: 'expo-secure-store', + isExpoModule: true, }, { name: 'react-native-device-crypto', nativeModuleName: 'DeviceCrypto', packageName: 'react-native-device-crypto', + isExpoModule: false, }, ]; @@ -36,8 +40,14 @@ export const checkNativeModules = (): void => { const missingModules: NativeModuleCheck[] = []; for (const module of REQUIRED_NATIVE_MODULES) { - if (!NativeModules[module.nativeModuleName]) { - missingModules.push(module); + if (module.isExpoModule) { + if (!requireOptionalNativeModule(module.nativeModuleName)) { + missingModules.push(module); + } + } else { + if (!NativeModules[module.nativeModuleName]) { + missingModules.push(module); + } } } @@ -45,8 +55,8 @@ export const checkNativeModules = (): void => { hasWarned = true; const platform = Platform.OS; - const moduleList = missingModules.map((m) => ` - ${m.packageName}`).join('\n'); - const installCommands = missingModules.map((m) => `npx expo install ${m.packageName}`).join('\n'); + const moduleList = missingModules.map(m => ` - ${m.packageName}`).join('\n'); + const installCommands = missingModules.map(m => `npx expo install ${m.packageName}`).join('\n'); const iosInstructions = platform === 'ios' diff --git a/packages/@magic-sdk/react-native-expo/src/native-crypto/dpop.ts b/packages/@magic-sdk/react-native-expo/src/native-crypto/dpop.ts index 45779f639..8c863e368 100644 --- a/packages/@magic-sdk/react-native-expo/src/native-crypto/dpop.ts +++ b/packages/@magic-sdk/react-native-expo/src/native-crypto/dpop.ts @@ -14,7 +14,6 @@ const KEY_ALIAS = getKeyAlias('dpop'); * Handles key creation (if missing), JWK construction, and signing. */ export const getDpop = async (): Promise => { - console.log('Getting DPoP'); try { // 1. Get or Create Key in Secure Enclave // We strictly disable authentication to avoid biometric prompts @@ -57,7 +56,6 @@ export const getDpop = async (): Promise => { // 6. Convert Signature (Toaster expects Raw R|S) const signatureB64 = derToRawSignature(signatureBase64); - console.log('Successfully Has Got DPoP', { dpop: `${signingInput}.${signatureB64}` }); return `${signingInput}.${signatureB64}`; } catch (error) { console.error('DPoP Generation Error:', error); diff --git a/packages/@magic-sdk/react-native-expo/src/native-crypto/keychain.ts b/packages/@magic-sdk/react-native-expo/src/native-crypto/keychain.ts index 05b203be2..3f0ec88e7 100644 --- a/packages/@magic-sdk/react-native-expo/src/native-crypto/keychain.ts +++ b/packages/@magic-sdk/react-native-expo/src/native-crypto/keychain.ts @@ -11,10 +11,8 @@ let cachedRefreshToken: string | null = null; * Uses 'WHEN_UNLOCKED_THIS_DEVICE_ONLY' for security similar to the original keychain accessible level. */ export const setRefreshTokenInSecureStore = async (rt: string): Promise => { - console.log('Setting Refresh Token In SECURE STORAGE', { rt }); // Skip write if token hasn't changed if (cachedRefreshToken === rt) { - console.log('Refresh Token Has NOT Changed'); return true; } @@ -25,7 +23,6 @@ export const setRefreshTokenInSecureStore = async (rt: string): Promise }); cachedRefreshToken = rt; // Update cache on successful write - console.log('Successfully Has Set Refresh Token'); return true; } catch (error) { console.error('Failed to set refresh token in secure store', error); @@ -38,10 +35,8 @@ export const setRefreshTokenInSecureStore = async (rt: string): Promise * Returns the cached value if available to improve performance. */ export const getRefreshTokenInSecureStore = async (): Promise => { - console.log('Getting Refresh Token In SECURE STORAGE'); // Return cached value if available if (cachedRefreshToken !== null) { - console.log('Refresh Token Has Been Cached'); return cachedRefreshToken; } @@ -53,7 +48,6 @@ export const getRefreshTokenInSecureStore = async (): Promise => if (!token) return null; cachedRefreshToken = token; - console.log('Successfully Has Got Refresh Token'); return cachedRefreshToken; } catch (error) { console.error('Failed to get refresh token in secure store', error); diff --git a/packages/@magic-sdk/react-native-expo/src/react-native-webview-controller.tsx b/packages/@magic-sdk/react-native-expo/src/react-native-webview-controller.tsx index 91343ac1c..8e05550b4 100644 --- a/packages/@magic-sdk/react-native-expo/src/react-native-webview-controller.tsx +++ b/packages/@magic-sdk/react-native-expo/src/react-native-webview-controller.tsx @@ -309,7 +309,7 @@ export class ReactNativeWebViewController extends ViewController { return await getRefreshTokenInSecureStore(); } - async getJWT(): Promise { + async getJWT(): Promise { try { return await getDpop(); } catch (e) { From d13cfd992b4c21b0625180964c59f8ea0363b89d Mon Sep 17 00:00:00 2001 From: sherzod-bakhodirov Date: Tue, 9 Dec 2025 09:23:12 +0500 Subject: [PATCH 17/17] chore: update tests --- .../check-native-modules.spec.ts | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/@magic-sdk/react-native-expo/test/spec/native-crypto/check-native-modules.spec.ts b/packages/@magic-sdk/react-native-expo/test/spec/native-crypto/check-native-modules.spec.ts index 0fa714841..0b605c7d8 100644 --- a/packages/@magic-sdk/react-native-expo/test/spec/native-crypto/check-native-modules.spec.ts +++ b/packages/@magic-sdk/react-native-expo/test/spec/native-crypto/check-native-modules.spec.ts @@ -6,6 +6,10 @@ jest.mock('react-native', () => ({ Platform: mockPlatform, })); +jest.mock('expo-modules-core', () => ({ + requireOptionalNativeModule: jest.fn(() => false), +})); + describe('checkNativeModules', () => { let consoleWarnSpy: jest.SpyInstance; @@ -16,7 +20,6 @@ describe('checkNativeModules', () => { consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); // Clear native modules - delete mockNativeModules['ExpoSecureStore']; delete mockNativeModules['DeviceCrypto']; mockPlatform.OS = 'ios'; }); @@ -26,7 +29,9 @@ describe('checkNativeModules', () => { }); it('should not log any warning when all native modules are present', () => { - mockNativeModules.ExpoSecureStore = {}; + // Re-require after resetModules to get the fresh mock instance + const { requireOptionalNativeModule: mockRequire } = require('expo-modules-core'); + (mockRequire as jest.Mock).mockReturnValue(true); mockNativeModules.DeviceCrypto = {}; const { checkNativeModules } = require('../../../src/native-crypto/check-native-modules'); @@ -36,7 +41,6 @@ describe('checkNativeModules', () => { }); it('should log a warning when expo-secure-store is missing', () => { - mockNativeModules.ExpoSecureStore = undefined; mockNativeModules.DeviceCrypto = {}; const { checkNativeModules } = require('../../../src/native-crypto/check-native-modules'); @@ -47,7 +51,9 @@ describe('checkNativeModules', () => { }); it('should log a warning when react-native-device-crypto is missing', () => { - mockNativeModules.ExpoSecureStore = {}; + // Re-require after resetModules to get the fresh mock instance + const { requireOptionalNativeModule: mockRequire } = require('expo-modules-core'); + (mockRequire as jest.Mock).mockReturnValue(true); mockNativeModules.DeviceCrypto = undefined; const { checkNativeModules } = require('../../../src/native-crypto/check-native-modules'); @@ -58,7 +64,6 @@ describe('checkNativeModules', () => { }); it('should log a warning when both native modules are missing', () => { - mockNativeModules.ExpoSecureStore = undefined; mockNativeModules.DeviceCrypto = undefined; const { checkNativeModules } = require('../../../src/native-crypto/check-native-modules'); @@ -70,7 +75,6 @@ describe('checkNativeModules', () => { }); it('should only log warning once even if called multiple times', () => { - mockNativeModules.ExpoSecureStore = undefined; mockNativeModules.DeviceCrypto = undefined; const { checkNativeModules } = require('../../../src/native-crypto/check-native-modules'); @@ -83,7 +87,6 @@ describe('checkNativeModules', () => { it('should include iOS instructions when platform is ios', () => { mockPlatform.OS = 'ios'; - mockNativeModules.ExpoSecureStore = undefined; mockNativeModules.DeviceCrypto = {}; const { checkNativeModules } = require('../../../src/native-crypto/check-native-modules'); @@ -95,7 +98,6 @@ describe('checkNativeModules', () => { it('should include Android instructions when platform is android', () => { mockPlatform.OS = 'android'; - mockNativeModules.ExpoSecureStore = undefined; mockNativeModules.DeviceCrypto = {}; const { checkNativeModules } = require('../../../src/native-crypto/check-native-modules'); @@ -107,7 +109,6 @@ describe('checkNativeModules', () => { it('should not include iOS instructions when platform is android', () => { mockPlatform.OS = 'android'; - mockNativeModules.ExpoSecureStore = undefined; mockNativeModules.DeviceCrypto = {}; const { checkNativeModules } = require('../../../src/native-crypto/check-native-modules'); @@ -130,7 +131,6 @@ describe('checkNativeModules', () => { }); it('should include install commands for missing packages', () => { - mockNativeModules.ExpoSecureStore = undefined; mockNativeModules.DeviceCrypto = undefined; const { checkNativeModules } = require('../../../src/native-crypto/check-native-modules'); @@ -139,4 +139,4 @@ describe('checkNativeModules', () => { expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('npx expo install expo-secure-store')); expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('npx expo install react-native-device-crypto')); }); -}); \ No newline at end of file +});