diff --git a/.changeset/expo-go-device-signer-fallback.md b/.changeset/expo-go-device-signer-fallback.md new file mode 100644 index 000000000..d9387f7cf --- /dev/null +++ b/.changeset/expo-go-device-signer-fallback.md @@ -0,0 +1,12 @@ +--- +"@crossmint/expo-device-signer": minor +"@crossmint/client-sdk-react-native-ui": patch +--- + +feat: support Expo Go with software-backed device signer fallback + +Added `SoftwareDeviceSignerKeyStorage` — a pure JavaScript implementation of `DeviceSignerKeyStorage` that uses `@noble/curves` for P-256 key operations and `expo-secure-store` for encrypted key persistence. This allows the SDK to run in Expo Go where the native `CrossmintDeviceSigner` module is not available. + +Added `createDeviceSignerKeyStorage()` factory function that auto-detects whether the native module is available and returns the appropriate implementation (native hardware-backed in dev builds, software fallback in Expo Go). + +Improved error message in `NativeDeviceSignerKeyStorage` when the native module is unavailable. diff --git a/packages/client/device-signer-expo/package.json b/packages/client/device-signer-expo/package.json index c5a0b1b26..fab19d182 100644 --- a/packages/client/device-signer-expo/package.json +++ b/packages/client/device-signer-expo/package.json @@ -15,11 +15,13 @@ "dev": "cross-env NODE_OPTIONS='--max-old-space-size=8192' tsup --watch" }, "dependencies": { - "@crossmint/wallets-sdk": "workspace:*" + "@crossmint/wallets-sdk": "workspace:*", + "@noble/curves": "^1.8.0" }, "devDependencies": { "expo-device": "~8.0.10", "expo-modules-core": "^3.0.0", + "expo-secure-store": "~15.0.7", "@types/react": "19.1.10", "react": "19.1.1", "react-native": "0.82.1" @@ -27,7 +29,16 @@ "peerDependencies": { "expo-device": ">=7 <9", "expo-modules-core": ">=2.0.0", + "expo-secure-store": ">=14 <16", "react": ">=18.0.0", "react-native": ">=0.74.0" + }, + "peerDependenciesMeta": { + "expo-modules-core": { + "optional": true + }, + "expo-secure-store": { + "optional": true + } } } diff --git a/packages/client/device-signer-expo/src/NativeDeviceSignerKeyStorage.ts b/packages/client/device-signer-expo/src/NativeDeviceSignerKeyStorage.ts index 9503d7d74..aadd217d8 100644 --- a/packages/client/device-signer-expo/src/NativeDeviceSignerKeyStorage.ts +++ b/packages/client/device-signer-expo/src/NativeDeviceSignerKeyStorage.ts @@ -9,7 +9,16 @@ let _nativeModule: NativeModuleType | null = null; function getNativeModule(): NativeModuleType { if (_nativeModule == null) { - _nativeModule = requireNativeModule("CrossmintDeviceSigner") as NativeModuleType; + try { + _nativeModule = requireNativeModule("CrossmintDeviceSigner") as NativeModuleType; + } catch { + throw new Error( + "CrossmintDeviceSigner native module is not available. " + + "This typically means you are running in Expo Go, which does not support custom native modules. " + + "Use a development build (`npx expo run:ios` / `npx expo run:android`) or EAS Build, " + + "or use SoftwareDeviceSignerKeyStorage as a fallback for development." + ); + } } return _nativeModule; } diff --git a/packages/client/device-signer-expo/src/SoftwareDeviceSignerKeyStorage.ts b/packages/client/device-signer-expo/src/SoftwareDeviceSignerKeyStorage.ts new file mode 100644 index 000000000..30b1bc35f --- /dev/null +++ b/packages/client/device-signer-expo/src/SoftwareDeviceSignerKeyStorage.ts @@ -0,0 +1,193 @@ +import * as Device from "expo-device"; +import * as SecureStore from "expo-secure-store"; +import { p256 } from "@noble/curves/p256"; + +import { DeviceSignerKeyStorage } from "@crossmint/wallets-sdk"; + +const STORE_PREFIX = "crossmint_device_signer_"; +const PENDING_KEY_PREFIX = `${STORE_PREFIX}pending_`; +const ADDRESS_KEY_PREFIX = `${STORE_PREFIX}addr_`; +const PUBLIC_KEY_INDEX_KEY = `${STORE_PREFIX}pub_key_index`; + +/** + * Converts a base64 string to a SecureStore-safe key by replacing + * characters that are invalid in expo-secure-store keys. + * SecureStore only allows alphanumeric, '.', '-', and '_'. + */ +function safeStoreKey(base64: string): string { + return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); +} + +/** + * Converts a hex string to a Uint8Array. + */ +function hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = Number.parseInt(hex.substring(i, i + 2), 16); + } + return bytes; +} + +/** + * Converts a Uint8Array to a hex string. + */ +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +/** + * Converts a Uint8Array to a base64 string. + */ +function bytesToBase64(bytes: Uint8Array): string { + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +} + +/** + * Converts a base64 string to a Uint8Array. + */ +function base64ToBytes(base64: string): Uint8Array { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +/** + * Software-based implementation of DeviceSignerKeyStorage for environments + * where native modules are not available (e.g. Expo Go). + * + * Uses @noble/curves for P-256 key generation and signing, and expo-secure-store + * for encrypted key persistence. This provides the same interface as + * NativeDeviceSignerKeyStorage but without hardware-backed security + * (Secure Enclave / Android Keystore). + * + * Suitable for development and prototyping in Expo Go. For production use, + * prefer NativeDeviceSignerKeyStorage with a development build. + */ +export class SoftwareDeviceSignerKeyStorage extends DeviceSignerKeyStorage { + constructor() { + super(""); + } + + async generateKey(params: { address?: string }): Promise { + const privateKey = p256.utils.randomPrivateKey(); + const publicKeyBytes = p256.getPublicKey(privateKey, false); // uncompressed + const publicKeyBase64 = bytesToBase64(publicKeyBytes); + const privateKeyHex = bytesToHex(privateKey); + + if (params.address != null) { + await SecureStore.setItemAsync(`${ADDRESS_KEY_PREFIX}${params.address}`, privateKeyHex); + await SecureStore.setItemAsync(`${ADDRESS_KEY_PREFIX}${params.address}_pub`, publicKeyBase64); + } else { + await SecureStore.setItemAsync(`${PENDING_KEY_PREFIX}${safeStoreKey(publicKeyBase64)}`, privateKeyHex); + } + + await this.trackPublicKey(publicKeyBase64); + return publicKeyBase64; + } + + async mapAddressToKey(address: string, publicKeyBase64: string): Promise { + const pendingKey = `${PENDING_KEY_PREFIX}${safeStoreKey(publicKeyBase64)}`; + const privateKeyHex = await SecureStore.getItemAsync(pendingKey); + if (privateKeyHex == null) { + throw new Error(`No pending key found for public key: ${publicKeyBase64}`); + } + + await SecureStore.setItemAsync(`${ADDRESS_KEY_PREFIX}${address}`, privateKeyHex); + await SecureStore.setItemAsync(`${ADDRESS_KEY_PREFIX}${address}_pub`, publicKeyBase64); + await SecureStore.deleteItemAsync(pendingKey); + } + + async getKey(address: string): Promise { + return await SecureStore.getItemAsync(`${ADDRESS_KEY_PREFIX}${address}_pub`); + } + + async hasKey(publicKeyBase64: string): Promise { + const index = await this.getPublicKeyIndex(); + return index.includes(publicKeyBase64); + } + + async signMessage(address: string, message: string): Promise<{ r: string; s: string }> { + const privateKeyHex = await SecureStore.getItemAsync(`${ADDRESS_KEY_PREFIX}${address}`); + if (privateKeyHex == null) { + throw new Error(`No key found for address: ${address}`); + } + + const privateKey = hexToBytes(privateKeyHex); + const messageBytes = base64ToBytes(message); + // The message is already a hash digest (e.g. keccak256) provided by the wallet API, + // so we sign the raw bytes directly without re-hashing (prehash defaults to false). + const signature = p256.sign(messageBytes, privateKey, { lowS: true }); + + return { + r: signature.r.toString(16).padStart(64, "0"), + s: signature.s.toString(16).padStart(64, "0"), + }; + } + + async deleteKey(address: string): Promise { + const publicKeyBase64 = await SecureStore.getItemAsync(`${ADDRESS_KEY_PREFIX}${address}_pub`); + await SecureStore.deleteItemAsync(`${ADDRESS_KEY_PREFIX}${address}`); + await SecureStore.deleteItemAsync(`${ADDRESS_KEY_PREFIX}${address}_pub`); + if (publicKeyBase64 != null) { + await this.untrackPublicKey(publicKeyBase64); + } + } + + async deletePendingKey(publicKeyBase64: string): Promise { + await SecureStore.deleteItemAsync(`${PENDING_KEY_PREFIX}${safeStoreKey(publicKeyBase64)}`); + await this.untrackPublicKey(publicKeyBase64); + } + + getDeviceName(): string { + const model = Device.deviceName ?? Device.modelName ?? Device.brand; + const os = Device.osName; + + if (model != null && os != null) { + return `${model} (${os})`; + } + + return model ?? os ?? "Unknown Device"; + } + + // --- Public key index management (persisted in SecureStore) --- + + private async getPublicKeyIndex(): Promise { + const raw = await SecureStore.getItemAsync(PUBLIC_KEY_INDEX_KEY); + if (raw == null) { + return []; + } + try { + return JSON.parse(raw) as string[]; + } catch { + return []; + } + } + + private async savePublicKeyIndex(index: string[]): Promise { + await SecureStore.setItemAsync(PUBLIC_KEY_INDEX_KEY, JSON.stringify(index)); + } + + private async trackPublicKey(publicKeyBase64: string): Promise { + const index = await this.getPublicKeyIndex(); + if (!index.includes(publicKeyBase64)) { + index.push(publicKeyBase64); + await this.savePublicKeyIndex(index); + } + } + + private async untrackPublicKey(publicKeyBase64: string): Promise { + const index = await this.getPublicKeyIndex(); + const filtered = index.filter((k) => k !== publicKeyBase64); + await this.savePublicKeyIndex(filtered); + } +} diff --git a/packages/client/device-signer-expo/src/createDeviceSignerKeyStorage.ts b/packages/client/device-signer-expo/src/createDeviceSignerKeyStorage.ts new file mode 100644 index 000000000..87e6b2a9e --- /dev/null +++ b/packages/client/device-signer-expo/src/createDeviceSignerKeyStorage.ts @@ -0,0 +1,35 @@ +import type { DeviceSignerKeyStorage } from "@crossmint/wallets-sdk"; + +/** + * Creates the appropriate DeviceSignerKeyStorage implementation for the current environment. + * + * - In development builds (with native modules available): Uses `NativeDeviceSignerKeyStorage` + * backed by Secure Enclave (iOS) or Android Keystore (Android) for hardware-backed security. + * - In Expo Go (without native modules): Falls back to `SoftwareDeviceSignerKeyStorage` + * backed by `@noble/curves` P-256 + `expo-secure-store` for software-based key management. + * + * @returns A DeviceSignerKeyStorage instance suitable for the current environment. + */ +export function createDeviceSignerKeyStorage(): DeviceSignerKeyStorage { + if (isNativeModuleAvailable()) { + const { NativeDeviceSignerKeyStorage } = require("./NativeDeviceSignerKeyStorage"); + return new NativeDeviceSignerKeyStorage(); + } + + const { SoftwareDeviceSignerKeyStorage } = require("./SoftwareDeviceSignerKeyStorage"); + return new SoftwareDeviceSignerKeyStorage(); +} + +/** + * Checks whether the CrossmintDeviceSigner native module is available. + * Returns `false` in Expo Go or any environment where the native module is not installed. + */ +export function isNativeModuleAvailable(): boolean { + try { + const { requireNativeModule } = require("expo-modules-core"); + requireNativeModule("CrossmintDeviceSigner"); + return true; + } catch { + return false; + } +} diff --git a/packages/client/device-signer-expo/src/index.ts b/packages/client/device-signer-expo/src/index.ts index f73b1d122..264d082ba 100644 --- a/packages/client/device-signer-expo/src/index.ts +++ b/packages/client/device-signer-expo/src/index.ts @@ -1 +1,3 @@ export { NativeDeviceSignerKeyStorage } from "./NativeDeviceSignerKeyStorage"; +export { SoftwareDeviceSignerKeyStorage } from "./SoftwareDeviceSignerKeyStorage"; +export { createDeviceSignerKeyStorage, isNativeModuleAvailable } from "./createDeviceSignerKeyStorage"; diff --git a/packages/client/device-signer-expo/tsup.config.ts b/packages/client/device-signer-expo/tsup.config.ts index 9d25a4e90..834b97870 100644 --- a/packages/client/device-signer-expo/tsup.config.ts +++ b/packages/client/device-signer-expo/tsup.config.ts @@ -4,7 +4,7 @@ import { treeShakableConfig } from "../../../tsup.config.base"; const config: Options = { ...treeShakableConfig, - external: ["react", "react-native", "expo-modules-core"], + external: ["react", "react-native", "expo-modules-core", "expo-secure-store", "expo-device"], }; export default config; diff --git a/packages/client/ui/react-native/src/providers/CrossmintWalletProvider.tsx b/packages/client/ui/react-native/src/providers/CrossmintWalletProvider.tsx index d3a91f5e3..4b91f12b7 100644 --- a/packages/client/ui/react-native/src/providers/CrossmintWalletProvider.tsx +++ b/packages/client/ui/react-native/src/providers/CrossmintWalletProvider.tsx @@ -18,7 +18,7 @@ import { useCrossmint, } from "@crossmint/client-sdk-react-base"; import type { DeviceSignerKeyStorage } from "@crossmint/wallets-sdk"; -import { NativeDeviceSignerKeyStorage } from "@crossmint/expo-device-signer"; +import { createDeviceSignerKeyStorage } from "@crossmint/expo-device-signer"; import { EmailSignersDialog } from "@/components/signers/EmailSignersDialog"; import { PhoneSignersDialog } from "@/components/signers/PhoneSignersDialog"; @@ -108,7 +108,7 @@ function CrossmintWalletProviderInternal({ }: CrossmintWalletProviderProps) { // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally captures the initial value once to stabilize the reference const deviceSignerKeyStorage = useMemo( - () => deviceSignerKeyStorageProp ?? new NativeDeviceSignerKeyStorage(), // eslint-disable-line react-hooks/exhaustive-deps + () => deviceSignerKeyStorageProp ?? createDeviceSignerKeyStorage(), // eslint-disable-line react-hooks/exhaustive-deps [] ); const { crossmint } = useCrossmint("CrossmintWalletProvider must be used within CrossmintProvider"); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d2a869c95..04d5230bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -607,6 +607,9 @@ importers: '@crossmint/wallets-sdk': specifier: workspace:* version: link:../../wallets + '@noble/curves': + specifier: ^1.8.0 + version: 1.9.7 devDependencies: '@types/react': specifier: 19.1.10 @@ -617,6 +620,9 @@ importers: expo-modules-core: specifier: ^3.0.0 version: 3.0.28(react-native@0.82.1(@babel/core@7.28.5)(@types/react@19.1.10)(bufferutil@4.0.9)(react@19.1.2)(utf-8-validate@5.0.10))(react@19.1.2) + expo-secure-store: + specifier: ~15.0.7 + version: 15.0.8(expo@54.0.27(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(bufferutil@4.0.9)(expo-router@6.0.17)(graphql@16.12.0)(react-native-webview@13.15.0(react-native@0.82.1(@babel/core@7.28.5)(@types/react@19.1.10)(bufferutil@4.0.9)(react@19.1.2)(utf-8-validate@5.0.10))(react@19.1.2))(react-native@0.82.1(@babel/core@7.28.5)(@types/react@19.1.10)(bufferutil@4.0.9)(react@19.1.2)(utf-8-validate@5.0.10))(react@19.1.2)(utf-8-validate@5.0.10)) react: specifier: 19.1.2 version: 19.1.2 @@ -20912,7 +20918,7 @@ snapshots: '@scure/bip32@1.7.0': dependencies: - '@noble/curves': 1.9.2 + '@noble/curves': 1.9.7 '@noble/hashes': 1.8.0 '@scure/base': 1.2.6 @@ -30008,7 +30014,7 @@ snapshots: ox@0.1.2(typescript@5.9.3)(zod@3.25.76): dependencies: '@adraffy/ens-normalize': 1.11.1 - '@noble/curves': 1.7.0 + '@noble/curves': 1.9.7 '@noble/hashes': 1.8.0 '@scure/bip32': 1.6.0 '@scure/bip39': 1.5.0 @@ -30022,7 +30028,7 @@ snapshots: ox@0.6.7(typescript@5.9.3)(zod@3.22.4): dependencies: '@adraffy/ens-normalize': 1.11.1 - '@noble/curves': 1.8.1 + '@noble/curves': 1.9.7 '@noble/hashes': 1.8.0 '@scure/bip32': 1.6.2 '@scure/bip39': 1.5.4 @@ -30036,7 +30042,7 @@ snapshots: ox@0.6.7(typescript@5.9.3)(zod@3.25.76): dependencies: '@adraffy/ens-normalize': 1.11.1 - '@noble/curves': 1.8.1 + '@noble/curves': 1.9.7 '@noble/hashes': 1.8.0 '@scure/bip32': 1.6.2 '@scure/bip39': 1.5.4 @@ -30079,7 +30085,7 @@ snapshots: dependencies: '@adraffy/ens-normalize': 1.11.1 '@noble/ciphers': 1.3.0 - '@noble/curves': 1.9.2 + '@noble/curves': 1.9.7 '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 @@ -30094,7 +30100,7 @@ snapshots: dependencies: '@adraffy/ens-normalize': 1.11.1 '@noble/ciphers': 1.3.0 - '@noble/curves': 1.9.2 + '@noble/curves': 1.9.7 '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 @@ -30109,7 +30115,7 @@ snapshots: dependencies: '@adraffy/ens-normalize': 1.11.1 '@noble/ciphers': 1.3.0 - '@noble/curves': 1.9.2 + '@noble/curves': 1.9.7 '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 @@ -33207,7 +33213,7 @@ snapshots: webauthn-p256@0.0.10: dependencies: - '@noble/curves': 1.7.0 + '@noble/curves': 1.9.7 '@noble/hashes': 1.8.0 webextension-polyfill@0.10.0: {}