-
Notifications
You must be signed in to change notification settings - Fork 31
feat: support Expo Go with software-backed device signer fallback [WAL-9469] #1714
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5c7b027
31231ab
35685c5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string> { | ||
| 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<void> { | ||
| 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<string | null> { | ||
| return await SecureStore.getItemAsync(`${ADDRESS_KEY_PREFIX}${address}_pub`); | ||
| } | ||
|
|
||
| async hasKey(publicKeyBase64: string): Promise<boolean> { | ||
| 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 }); | ||
|
Comment on lines
+126
to
+129
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
With The native iOS Secure Enclave typically hashes the message internally (via Looking at
This is the signing compatibility item flagged in the PR's review checklist and is critical to verify before merging. Prompt To Fix With AIThis is a comment left during a code review.
Path: packages/client/device-signer-expo/src/SoftwareDeviceSignerKeyStorage.ts
Line: 117-118
Comment:
**Verify signing prehash behavior matches native side**
With `@noble/curves` v1.x (resolved to 1.9.7), the default is `prehash: false`, meaning `p256.sign()` treats the input as an **already-hashed digest** and signs it directly without hashing.
The native iOS Secure Enclave typically hashes the message internally (via `SecKeyCreateSignature` with `ecdsaSignatureMessageX962SHA256`). If the native side hashes the incoming bytes and then signs, but this software fallback does **not** hash (it passes the bytes directly to the ECDSA math), then signatures will be incompatible — a key generated on one side cannot verify against the other.
Looking at `wallet.ts`, the API may already provide a pre-hashed digest (keccak256), which means the native side might be using a raw-signing mode too. Please verify that:
1. The native `SecureEnclaveKeyStorage.signMessage()` and `KeystoreKeyStorage.signMessage()` treat the decoded base64 message bytes the same way (raw ECDSA without re-hashing), **or**
2. If the native side does hash, add `{ prehash: true }` here to match.
This is the signing compatibility item flagged in the PR's review checklist and is critical to verify before merging.
How can I resolve this? If you propose a fix, please make it concise. |
||
|
|
||
| return { | ||
| r: signature.r.toString(16).padStart(64, "0"), | ||
| s: signature.s.toString(16).padStart(64, "0"), | ||
| }; | ||
| } | ||
|
|
||
| async deleteKey(address: string): Promise<void> { | ||
| 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<void> { | ||
| 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<string[]> { | ||
| 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<void> { | ||
| await SecureStore.setItemAsync(PUBLIC_KEY_INDEX_KEY, JSON.stringify(index)); | ||
| } | ||
|
|
||
| private async trackPublicKey(publicKeyBase64: string): Promise<void> { | ||
| const index = await this.getPublicKeyIndex(); | ||
| if (!index.includes(publicKeyBase64)) { | ||
| index.push(publicKeyBase64); | ||
| await this.savePublicKeyIndex(index); | ||
| } | ||
| } | ||
|
|
||
| private async untrackPublicKey(publicKeyBase64: string): Promise<void> { | ||
| const index = await this.getPublicKeyIndex(); | ||
| const filtered = index.filter((k) => k !== publicKeyBase64); | ||
| await this.savePublicKeyIndex(filtered); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,3 @@ | ||
| export { NativeDeviceSignerKeyStorage } from "./NativeDeviceSignerKeyStorage"; | ||
| export { SoftwareDeviceSignerKeyStorage } from "./SoftwareDeviceSignerKeyStorage"; | ||
| export { createDeviceSignerKeyStorage, isNativeModuleAvailable } from "./createDeviceSignerKeyStorage"; |
Uh oh!
There was an error while loading. Please reload this page.