Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 11 additions & 0 deletions .changeset/expo-go-device-signer-fallback.md
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.
16 changes: 9 additions & 7 deletions packages/client/ui/react-native/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,24 +23,26 @@
"@crossmint/common-sdk-auth": "workspace:*",
"@crossmint/common-sdk-base": "workspace:*",
"@crossmint/wallets-sdk": "workspace:*",
"@noble/curves": "1.9.7",
"@solana/web3.js": "1.98.1",
"bs58": "^5.0.0",
"lodash.clonedeep": "4.5.0",
"lodash.isequal": "4.5.0",
"mitt": "3.0.1",
"zod": "3.22.4",
"expo-constants": "~18.0.9",
"expo-device": "~8.0.9",
"expo-secure-store": "~15.0.7",
"expo-web-browser": "~15.0.8",
"lodash.clonedeep": "4.5.0",
"lodash.isequal": "4.5.0",
"mitt": "3.0.1",
"react-native-get-random-values": "2.0.0",
"react-native-svg": "15.14.0",
"react-native-webview": "13.15.0"
"react-native-webview": "13.15.0",
"zod": "3.22.4"
},
"devDependencies": {
"@expo/config-plugins": "^9.0.17",
"typedoc": "0.28.16",
"@types/react": "19.1.10",
"expo-modules-core": "~3.0.29"
"expo-modules-core": "~3.0.29",
"typedoc": "0.28.16"
},
"peerDependencies": {
"@expo/config-plugins": ">=9.0.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,16 @@ let _nativeModule: CrossmintDeviceSignerModule | null = null;

function getNativeModule(): CrossmintDeviceSignerModule {
if (_nativeModule == null) {
_nativeModule = requireNativeModule<CrossmintDeviceSignerModule>("CrossmintDeviceSigner");
try {
_nativeModule = requireNativeModule<CrossmintDeviceSignerModule>("CrossmintDeviceSigner");
} 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;
}
Expand Down
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;
}
Comment thread
albertoelias-crossmint marked this conversation as resolved.

/**
* 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> {
Comment thread
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;
}
Comment thread
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 });
Comment thread
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;
}
Comment thread
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;
}
}
Loading
Loading