-
Notifications
You must be signed in to change notification settings - Fork 33
feat: support Expo Go with software-backed device signer fallback [WAL-9469] #1728
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
Merged
albertoelias-crossmint
merged 12 commits into
main
from
devin/1774043147-expo-go-device-signer-fallback-main
Mar 25, 2026
Merged
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
fd8ce57
feat: support Expo Go with software-backed device signer fallback [WA…
devin-ai-integration[bot] 9f7c68d
fix: address PR review comments - safeStoreKey for SecureStore keys, …
devin-ai-integration[bot] bf9f010
fixes expo go check
AlbertoElias a7f343d
fix: add 0x prefix to signature r/s in SoftwareDeviceSignerKeyStorage
devin-ai-integration[bot] 3f19313
Fixes software device signer key management to work with expo go
AlbertoElias ff76441
fix: add index update lock and fix isSignerApproved for active status
devin-ai-integration[bot] a4b8dec
Update packages/client/device-signer-expo/src/SoftwareDeviceSignerKey…
albertoelias-crossmint 15e7948
refactor: extract shared isApprovedSignerStatus, add catch guard to u…
devin-ai-integration[bot] be07e3f
feat: add logging to createDeviceSignerKeyStorage for storage impleme…
devin-ai-integration[bot] 7f111aa
merge: resolve conflicts after merging origin/main (device-signer-exp…
devin-ai-integration[bot] f6d851f
fix: pin @noble/curves v1.x and add external deps for build compatibi…
devin-ai-integration[bot] 63513ae
fix: format package.json for biome lint
devin-ai-integration[bot] File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| --- | ||
| "@crossmint/client-sdk-react-native-ui": minor | ||
| --- | ||
|
|
||
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
282 changes: 282 additions & 0 deletions
282
packages/client/ui/react-native/src/native/SoftwareDeviceSignerKeyStorage.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,282 @@ | ||
| import "react-native-get-random-values"; | ||
| 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`; | ||
|
|
||
| type ExpoGlobal = typeof globalThis & { | ||
| expo?: { | ||
| modules?: { | ||
| ExpoCrypto?: { | ||
| getRandomValues: (array: Uint8Array) => void; | ||
| }; | ||
| }; | ||
| }; | ||
| }; | ||
|
|
||
| type CryptoLike = { | ||
| getRandomValues: (array: Uint8Array) => void; | ||
| }; | ||
|
|
||
| /** | ||
| * Converts a base64/base64url string back into padded canonical base64. | ||
| */ | ||
| function toBase64(base64: string): string { | ||
| const normalized = base64.replace(/-/g, "+").replace(/_/g, "/"); | ||
| const remainder = normalized.length % 4; | ||
|
|
||
| if (remainder === 0) { | ||
| return normalized; | ||
| } | ||
| if (remainder === 2) { | ||
| return `${normalized}==`; | ||
| } | ||
| if (remainder === 3) { | ||
| return `${normalized}=`; | ||
| } | ||
|
|
||
| throw new Error("Invalid base64url string"); | ||
| } | ||
|
|
||
| /** | ||
| * Converts a public-key string into canonical base64 for comparisons and API usage. | ||
| */ | ||
| function normalizePublicKeyEncoding(publicKey: string): string { | ||
| return toBase64(publicKey); | ||
| } | ||
|
|
||
| /** | ||
| * Converts a public-key string into a SecureStore-safe key. | ||
| */ | ||
| function safeStoreKey(publicKey: string): string { | ||
| return normalizePublicKeyEncoding(publicKey).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(toBase64(base64)); | ||
| const bytes = new Uint8Array(binary.length); | ||
| for (let i = 0; i < binary.length; i++) { | ||
| bytes[i] = binary.charCodeAt(i); | ||
| } | ||
| return bytes; | ||
| } | ||
|
|
||
| function fillRandomBytes(bytes: Uint8Array): Uint8Array { | ||
| if (globalThis.crypto?.getRandomValues != null) { | ||
| (globalThis.crypto as CryptoLike).getRandomValues(bytes); | ||
| return bytes; | ||
| } | ||
|
|
||
| const expoCrypto = (globalThis as ExpoGlobal).expo?.modules?.ExpoCrypto; | ||
| if (expoCrypto?.getRandomValues != null) { | ||
| expoCrypto.getRandomValues(bytes); | ||
| return bytes; | ||
| } | ||
|
|
||
| throw new Error("No secure random source is available for software device signer key generation"); | ||
| } | ||
|
|
||
| function generatePrivateKey(): Uint8Array { | ||
| // Rejection sampling avoids modulo bias; retries are vanishingly rare for P-256. | ||
| for (let attempt = 0; attempt < 8; attempt++) { | ||
| const privateKey = fillRandomBytes(new Uint8Array(32)); | ||
| if (p256.utils.isValidPrivateKey(privateKey)) { | ||
| return privateKey; | ||
| } | ||
| } | ||
|
|
||
| throw new Error("Failed to generate a valid P-256 private key"); | ||
| } | ||
|
|
||
| /** | ||
| * 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 { | ||
| private indexUpdateLock: Promise<void> = Promise.resolve(); | ||
|
|
||
| constructor() { | ||
| super(""); | ||
| } | ||
|
|
||
| async generateKey(params: { address?: string }): Promise<string> { | ||
|
albertoelias-crossmint marked this conversation as resolved.
|
||
| const privateKey = generatePrivateKey(); | ||
| 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`, | ||
| normalizePublicKeyEncoding(publicKeyBase64) | ||
| ); | ||
| } else { | ||
| await SecureStore.setItemAsync(`${PENDING_KEY_PREFIX}${safeStoreKey(publicKeyBase64)}`, privateKeyHex); | ||
| } | ||
|
|
||
| await this.trackPublicKey(publicKeyBase64); | ||
| return publicKeyBase64; | ||
| } | ||
|
albertoelias-crossmint marked this conversation as resolved.
|
||
|
|
||
| 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`, | ||
| normalizePublicKeyEncoding(publicKeyBase64) | ||
| ); | ||
| await SecureStore.deleteItemAsync(pendingKey); | ||
| } | ||
|
|
||
| async getKey(address: string): Promise<string | null> { | ||
| const publicKey = await SecureStore.getItemAsync(`${ADDRESS_KEY_PREFIX}${address}_pub`); | ||
| return publicKey == null ? null : normalizePublicKeyEncoding(publicKey); | ||
| } | ||
|
|
||
| async hasKey(publicKeyBase64: string): Promise<boolean> { | ||
| const index = await this.getPublicKeyIndex(); | ||
| const normalizedPublicKey = normalizePublicKeyEncoding(publicKeyBase64); | ||
| return index.some((key) => normalizePublicKeyEncoding(key) === normalizedPublicKey); | ||
| } | ||
|
|
||
| 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); | ||
| // Match the native implementations, which sign the decoded message bytes using the | ||
| // platform P-256 primitives. Those primitives apply SHA-256 before ECDSA signing. | ||
| const signature = p256.sign(messageBytes, privateKey, { lowS: true, prehash: true }); | ||
|
albertoelias-crossmint marked this conversation as resolved.
|
||
|
|
||
| return { | ||
| r: `0x${signature.r.toString(16).padStart(64, "0")}`, | ||
| s: `0x${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> { | ||
| this.indexUpdateLock = this.indexUpdateLock | ||
| .catch(() => {}) | ||
| .then(async () => { | ||
| const index = await this.getPublicKeyIndex(); | ||
| const normalizedPublicKey = normalizePublicKeyEncoding(publicKeyBase64); | ||
| if (!index.some((key) => normalizePublicKeyEncoding(key) === normalizedPublicKey)) { | ||
| index.push(normalizedPublicKey); | ||
| await this.savePublicKeyIndex(index); | ||
| } | ||
| }); | ||
| await this.indexUpdateLock; | ||
| } | ||
|
albertoelias-crossmint marked this conversation as resolved.
|
||
|
|
||
| private async untrackPublicKey(publicKeyBase64: string): Promise<void> { | ||
| this.indexUpdateLock = this.indexUpdateLock | ||
| .catch(() => {}) | ||
| .then(async () => { | ||
| const index = await this.getPublicKeyIndex(); | ||
| const normalizedPublicKey = normalizePublicKeyEncoding(publicKeyBase64); | ||
| const filtered = index.filter((key) => normalizePublicKeyEncoding(key) !== normalizedPublicKey); | ||
| await this.savePublicKeyIndex(filtered); | ||
| }); | ||
| await this.indexUpdateLock; | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.