Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
10eaea5
feat: implement keychain for rt
sherzod-bakhodirov Nov 21, 2025
e67fa2d
feat: move createMagicRequest to the methods of View Controller
sherzod-bakhodirov Nov 27, 2025
427d086
feat: implement dpop generation using react-native-biometrics
sherzod-bakhodirov Dec 1, 2025
a8b9d6c
feat: implement dpop creation using device crypto
sherzod-bakhodirov Dec 1, 2025
2ec46d8
chore: update yarn.lock
sherzod-bakhodirov Dec 1, 2025
10d15f5
feat: implement unique key alias
sherzod-bakhodirov Dec 3, 2025
51717f0
feat: use key alias for rt and jwt
sherzod-bakhodirov Dec 3, 2025
f2ac877
chore: remove unused var
sherzod-bakhodirov Dec 3, 2025
260ebf7
feat: implement caching for refresh token
sherzod-bakhodirov Dec 3, 2025
c8cde0a
feat: implement native crypto & secure storage for rn expo
sherzod-bakhodirov Dec 4, 2025
97b954a
chore: update comments
sherzod-bakhodirov Dec 4, 2025
659b8eb
feat: implement tests for rt & dpop
sherzod-bakhodirov Dec 5, 2025
7c1adaf
chore: add podspec
sherzod-bakhodirov Dec 5, 2025
13c4e9c
Merge branch 'master' into implement-native-crypto-expo
sherzod-bakhodirov Dec 5, 2025
96a08da
chore: revert rn-bare changes
sherzod-bakhodirov Dec 8, 2025
6b2642a
feat: show warning if peer dependencies are not installed
sherzod-bakhodirov Dec 8, 2025
030b742
Merge branch 'master' into implement-native-crypto-expo
sherzod-bakhodirov Dec 8, 2025
37eb9d9
fix: check for expo native modules properly
sherzod-bakhodirov Dec 9, 2025
e2e951e
Merge branch 'master' into implement-native-crypto-expo
sherzod-bakhodirov Dec 9, 2025
d13cfd9
chore: update tests
sherzod-bakhodirov Dec 9, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 71 additions & 8 deletions packages/@magic-sdk/provider/src/core/view-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ import {
import { JsonRpcResponse } from './json-rpc';
import { createPromise } from '../util/promise-tools';
import { MagicSDKWarning, createModalNotReadyError } from './sdk-exceptions';
import { clearDeviceShares, encryptAndPersistDeviceShare } from '../util/device-share-web-crypto';
import {
createMagicRequest,
persistMagicEventRefreshToken,
standardizeResponse,
debounce,
} from '../util/view-controller-utils';
clearDeviceShares,
encryptAndPersistDeviceShare,
getDecryptedDeviceShare,
} from '../util/device-share-web-crypto';
import { standardizeResponse, debounce, StandardizedMagicRequest } from '../util/view-controller-utils';
import { setItem, getItem } from '../util/storage';
import { SDKEnvironment } from './sdk-environment';
import { createJwt } from '../util/web-crypto';

interface RemoveEventListenerFunction {
(): void;
Expand Down Expand Up @@ -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);

Expand All @@ -111,7 +113,7 @@ export abstract class ViewController {
*/
const acknowledgeResponse = (removeEventListener: RemoveEventListenerFunction) => (event: MagicMessageEvent) => {
const { id, response } = standardizeResponse(payload, event);
persistMagicEventRefreshToken(event);
this.persistMagicEventRefreshToken(event);
if (response?.payload.error?.message === 'User denied account access.') {
clearDeviceShares();
} else if (event.data.deviceShare) {
Expand Down Expand Up @@ -249,4 +251,65 @@ export abstract class ViewController {
this.heartbeatIntervalTimer = null;
}
}

async persistMagicEventRefreshToken(event: MagicMessageEvent) {
if (!event.data.rt) {
return;
}

await setItem('rt', event.data.rt);
}

async createMagicRequest(
msgType: string,
payload: JsonRpcRequestPayload | JsonRpcRequestPayload[],
networkHash: string,
) {
const request: StandardizedMagicRequest = { msgType, payload };

const rt = await this.getRT();
const jwt = await this.getJWT();
const decryptedDeviceShare = await this.getDecryptedDeviceShare(networkHash);

if (jwt) {
request.jwt = jwt;
}

if (jwt && rt) {
request.rt = rt;
}

// Grab the device share if it exists for the network
if (decryptedDeviceShare) {
request.deviceShare = decryptedDeviceShare;
}

return request;
}

async getJWT(): Promise<string | null | undefined> {
// only for webcrypto platforms
if (SDKEnvironment.platform === 'web') {
try {
const jwtFromStorage = await getItem<string>('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<string | null> {
return await getItem<string>('rt');
}

async getDecryptedDeviceShare(networkHash: string) {
return await getDecryptedDeviceShare(networkHash);
}
}
45 changes: 1 addition & 44 deletions packages/@magic-sdk/provider/src/util/view-controller-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ interface StandardizedResponse {
response?: JsonRpcResponse;
}

interface StandardizedMagicRequest {
export interface StandardizedMagicRequest {
msgType: string;
payload: JsonRpcRequestPayload | JsonRpcRequestPayload[];
jwt?: string;
Expand Down Expand Up @@ -53,49 +53,6 @@ export function standardizeResponse(
return {};
}

export async function createMagicRequest(
msgType: string,
payload: JsonRpcRequestPayload | JsonRpcRequestPayload[],
networkHash: string,
) {
const rt = await getItem<string>('rt');
let jwt;

// only for webcrypto platforms
if (SDKEnvironment.platform === 'web') {
try {
jwt = (await getItem<string>('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<T extends (...args: unknown[]) => void>(func: T, delay: number) {
let timeoutId: ReturnType<typeof setTimeout> | null = null;

Expand Down
5 changes: 5 additions & 0 deletions packages/@magic-sdk/react-native-expo/app.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"plugins": [
"expo-secure-store"
]
}
5 changes: 5 additions & 0 deletions packages/@magic-sdk/react-native-expo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"lodash": "^4.17.19",
"process": "~0.11.10",
"react-native-event-listeners": "^1.0.7",
"react-native-uuid": "^2.0.3",
"tslib": "^2.0.3",
"whatwg-url": "~8.1.0"
},
Expand All @@ -39,9 +40,11 @@
"babel-preset-expo": "^12.0.11",
"expo": "^52.0.44",
"expo-modules-core": "^2.2.3",
"expo-secure-store": "~14.0.1",
"jest-expo": "~52.0.6",
"react": "^19.1.0",
"react-native": "^0.78.2",
"react-native-device-crypto": "^0.1.7",
"react-native-safe-area-context": "^5.3.0",
"react-native-webview": "^13.13.5",
"react-test-renderer": "^19.1.0",
Expand All @@ -50,8 +53,10 @@
"peerDependencies": {
"@react-native-community/netinfo": ">=9.0.0",
"expo": "*",
"expo-secure-store": "~14.0.1",
"react": ">=17",
"react-native": ">=0.60",
"react-native-device-crypto": "^0.1.7",
"react-native-safe-area-context": ">=4.4.1",
"react-native-webview": ">=12.4.0"
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { NativeModules, Platform } from 'react-native';
import { requireOptionalNativeModule } from 'expo-modules-core';

interface NativeModuleCheck {
name: string;
nativeModuleName: string;
packageName: string;
isExpoModule: boolean;
}

const REQUIRED_NATIVE_MODULES: NativeModuleCheck[] = [
{
name: 'expo-secure-store',
nativeModuleName: 'ExpoSecureStore',
packageName: 'expo-secure-store',
isExpoModule: true,
},
{
name: 'react-native-device-crypto',
nativeModuleName: 'DeviceCrypto',
packageName: 'react-native-device-crypto',
isExpoModule: false,
},
];

// Track if warning has been shown to avoid spamming
let hasWarned = false;

/**
* Checks if all required native modules are properly installed and linked.
* Logs a warning if any native module is missing.
*
* Note: Some native modules (like react-native-device-crypto) require hardware
* features (e.g., Secure Enclave) that are not available in simulators/emulators.
* The SDK will continue to work but certain security features may be degraded.
*/
export const checkNativeModules = (): void => {
if (hasWarned) return;

const missingModules: NativeModuleCheck[] = [];

for (const module of REQUIRED_NATIVE_MODULES) {
if (module.isExpoModule) {
if (!requireOptionalNativeModule(module.nativeModuleName)) {
missingModules.push(module);
}
} else {
if (!NativeModules[module.nativeModuleName]) {
missingModules.push(module);
}
}
}

if (missingModules.length > 0) {
hasWarned = true;

const platform = Platform.OS;
const moduleList = missingModules.map(m => ` - ${m.packageName}`).join('\n');
const installCommands = missingModules.map(m => `npx expo install ${m.packageName}`).join('\n');

const iosInstructions =
platform === 'ios'
? `
For iOS, run:
npx expo run:ios
`
: '';

const androidInstructions =
platform === 'android'
? `
For Android, run:
npx expo run:android
`
: '';

console.warn(
`@magic-sdk/react-native-expo: Missing native modules detected.

The following native modules are not linked:
${moduleList}

The SDK will continue to work, but some security features may not function properly.

Note: If you're running in a simulator/emulator, some native modules (like react-native-device-crypto)
require hardware features (Secure Enclave) that are only available on physical devices.

If you're on a physical device and see this warning, please ensure the packages are installed and linked:

1. Install the missing packages:
${installCommands}

2. Rebuild your app:
${iosInstructions}${androidInstructions}
`,
);
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const ALG = 'ES256';
export const TYP = 'dpop+jwt';
78 changes: 78 additions & 0 deletions packages/@magic-sdk/react-native-expo/src/native-crypto/dpop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import uuid from 'react-native-uuid';
import { toBase64Url } from './utils/uint8';
import { spkiToJwk } from './utils/jwk';
import { ALG, TYP } from './constants';
import { derToRawSignature } from './utils/der';
import { DpopClaims, DpopHeader } from './types';
import DeviceCrypto, { AccessLevel } from 'react-native-device-crypto';
import { getKeyAlias } from './utils/key-alias';

const KEY_ALIAS = getKeyAlias('dpop');

/**
* Generates the DPoP proof compatible with the Python backend.
* Handles key creation (if missing), JWK construction, and signing.
*/
export const getDpop = async (): Promise<string | null> => {
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<boolean>} True if the key was deleted successfully, false otherwise.
*/
export const deleteDpop = async (): Promise<boolean> => {
try {
return await DeviceCrypto.deleteKey(KEY_ALIAS);
} catch (error) {
console.error('DPoP Deletion Error:', error);
return false;
}
};
Loading