Skip to content

feat: support Expo Go with software-backed device signer fallback [WAL-9469]#1728

Open
albertoelias-crossmint wants to merge 5 commits intomainfrom
devin/1774043147-expo-go-device-signer-fallback-main
Open

feat: support Expo Go with software-backed device signer fallback [WAL-9469]#1728
albertoelias-crossmint wants to merge 5 commits intomainfrom
devin/1774043147-expo-go-device-signer-fallback-main

Conversation

@albertoelias-crossmint
Copy link
Collaborator

@albertoelias-crossmint albertoelias-crossmint commented Mar 21, 2026

Description

CrossmintWalletProvider crashes in Expo Go because it eagerly instantiates NativeDeviceSignerKeyStorage, which calls requireNativeModule("CrossmintDeviceSigner") — a custom native module unavailable in Expo Go.

This PR adds a software-backed fallback so the SDK works in Expo Go without a dev build:

  • SoftwareDeviceSignerKeyStorage: Pure JS implementation of DeviceSignerKeyStorage using @noble/curves/p256 for key generation/signing and expo-secure-store for encrypted persistence. Same interface as the native version but without hardware-backed security.
  • createDeviceSignerKeyStorage(): Factory that probes for the native module and auto-falls back to the software implementation.
  • CrossmintWalletProvider: Now uses the factory instead of directly constructing NativeDeviceSignerKeyStorage.
  • NativeDeviceSignerKeyStorage: Improved error message when native module is missing.
  • Signature format fix: SoftwareDeviceSignerKeyStorage.signMessage() formats r and s as 0x-prefixed hex strings (e.g. 0x00ab...), matching the format used by IframeDeviceSignerKeyStorage and required by the backend's PositiveBigIntString schema. Without the 0x prefix, the backend rejects the signature but surfaces a misleading error from a different union branch ("Invalid type: device" from the passkey schema instead of the actual BigInt validation error).

Items requiring human verification

  • Signing prehash behavior: signMessage signs raw bytes with prehash: false, assuming the message is already a hash digest (per wallet.ts comment about keccak256). Verify this matches the native CrossmintDeviceSigner Swift/Kotlin package behavior. If the native side hashes internally (e.g. ecdsaSignatureMessageX962SHA256), the software fallback needs prehash: true to produce compatible signatures.
  • expo-modules-core static import: NativeDeviceSignerKeyStorage.ts still has a top-level import { requireNativeModule } from "expo-modules-core". The factory uses dynamic require() to avoid this, but if NativeDeviceSignerKeyStorage is ever imported directly in Expo Go, it would fail at the import level before reaching the try/catch. Confirm this is acceptable given expo-modules-core is always present in Expo projects.
  • No automated tests: The crypto + storage logic in SoftwareDeviceSignerKeyStorage has no unit tests. Should be tested manually in Expo Go.
  • Public key index scalability: hasKey() stores all public keys as a JSON array in one SecureStore entry — may approach the 2KB value limit with many keys.

Test plan

  • Build verified locally (pnpm build:libs passes)
  • Lint verified locally (pnpm lint passes)
  • All existing tests pass (pnpm test:vitest)
  • Needs manual testing in an Expo Go environment to verify the fallback activates and signing works end-to-end

Package updates

  • @crossmint/expo-device-signer: minor — new SoftwareDeviceSignerKeyStorage, createDeviceSignerKeyStorage, isNativeModuleAvailable exports; added @noble/curves dep; expo-secure-store + expo-modules-core as optional peer deps
  • @crossmint/client-sdk-react-native-ui: patch — uses factory instead of direct NativeDeviceSignerKeyStorage
  • Changeset added via .changeset/expo-go-device-signer-fallback.md

Link to Devin session: https://crossmint.devinenterprise.com/sessions/d7ddc53fd2ed434989652db7b8824c21
Requested by: @albertoelias-crossmint


Open with Devin

devin-ai-integration bot and others added 2 commits March 21, 2026 04:33
…L-9469]

Co-Authored-By: Alberto Elias <alberto.elias@paella.dev>
…clarify prehash behavior

Co-Authored-By: Alberto Elias <alberto.elias@paella.dev>
@devin-ai-integration
Copy link
Contributor

Original prompt from Alberto Elias

'Support Expo Go when native modules are unavailable' (WAL-9469)

User instruction: @devin how do you suggest we can fix this?

@devin-ai-integration
Copy link
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR that start with 'DevinAI' or '@devin'.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

@changeset-bot
Copy link

changeset-bot bot commented Mar 21, 2026

🦋 Changeset detected

Latest commit: 3f19313

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
@crossmint/expo-device-signer Minor
@crossmint/client-sdk-react-native-ui Major
expo-demo Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 21, 2026

Prompt To Fix All With AI
This is a comment left during a code review.
Path: packages/client/device-signer-expo/src/createDeviceSignerKeyStorage.ts
Line: 18-20

Comment:
**Silent fallback to software-backed keys**

When the native module is unavailable, this factory silently falls back to the software-backed implementation. Since this is a security-relevant decision (software-backed keys lack hardware-backed protections like Secure Enclave / Android Keystore), a `console.warn` would help developers notice when they are running with the software fallback — especially if a production build accidentally ships without the native module.

```suggestion
    console.warn(
        "[CrossmintDeviceSigner] Native module not available — using software-backed key storage. " +
            "This is suitable for development in Expo Go but not recommended for production."
    );
    const { SoftwareDeviceSignerKeyStorage } = require("./SoftwareDeviceSignerKeyStorage");
    return new SoftwareDeviceSignerKeyStorage();
```

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: "fix: address PR revi..."

Comment on lines +18 to +20

const { SoftwareDeviceSignerKeyStorage } = require("./SoftwareDeviceSignerKeyStorage");
return new SoftwareDeviceSignerKeyStorage();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Silent fallback to software-backed keys

When the native module is unavailable, this factory silently falls back to the software-backed implementation. Since this is a security-relevant decision (software-backed keys lack hardware-backed protections like Secure Enclave / Android Keystore), a console.warn would help developers notice when they are running with the software fallback — especially if a production build accidentally ships without the native module.

Suggested change
const { SoftwareDeviceSignerKeyStorage } = require("./SoftwareDeviceSignerKeyStorage");
return new SoftwareDeviceSignerKeyStorage();
console.warn(
"[CrossmintDeviceSigner] Native module not available — using software-backed key storage. " +
"This is suitable for development in Expo Go but not recommended for production."
);
const { SoftwareDeviceSignerKeyStorage } = require("./SoftwareDeviceSignerKeyStorage");
return new SoftwareDeviceSignerKeyStorage();
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/client/device-signer-expo/src/createDeviceSignerKeyStorage.ts
Line: 18-20

Comment:
**Silent fallback to software-backed keys**

When the native module is unavailable, this factory silently falls back to the software-backed implementation. Since this is a security-relevant decision (software-backed keys lack hardware-backed protections like Secure Enclave / Android Keystore), a `console.warn` would help developers notice when they are running with the software fallback — especially if a production build accidentally ships without the native module.

```suggestion
    console.warn(
        "[CrossmintDeviceSigner] Native module not available — using software-backed key storage. " +
            "This is suitable for development in Expo Go but not recommended for production."
    );
    const { SoftwareDeviceSignerKeyStorage } = require("./SoftwareDeviceSignerKeyStorage");
    return new SoftwareDeviceSignerKeyStorage();
```

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 5 additional findings.

Open in Devin Review

AlbertoElias and others added 2 commits March 23, 2026 17:38
@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 23, 2026

Prompt To Fix All With AI
This is a comment left during a code review.
Path: packages/client/device-signer-expo/src/SoftwareDeviceSignerKeyStorage.ts
Line: 222-228

Comment:
**Race condition in public key index read-modify-write**

`trackPublicKey` (and `untrackPublicKey`) performs a read-modify-write on the shared `PUBLIC_KEY_INDEX_KEY` SecureStore entry without any serialization. If two concurrent `generateKey` calls overlap, both will read the same index, each appends its key, and the second `savePublicKeyIndex` call will overwrite the first — losing a key from the index.

While concurrent `generateKey` calls may be unlikely in practice, this could be guarded with a simple in-memory mutex/lock or by serializing writes through a queue.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: packages/client/device-signer-expo/src/SoftwareDeviceSignerKeyStorage.ts
Line: 123

Comment:
**`generateKey` silently ignores biometric parameters**

The base class `DeviceSignerKeyStorage` defines `generateKey` with overloads accepting `biometricPolicy` and `biometricExpirationTime` parameters, but this implementation only destructures `address` and silently discards the rest. While the software fallback can't enforce biometric policies, callers have no indication their security requirement was dropped.

Consider adding a `console.warn` when `biometricPolicy` is passed with a value other than `"none"` so developers are aware the software fallback cannot enforce it.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (2): Last reviewed commit: "fix: add 0x prefix to signature r/s in S..." | Re-trigger Greptile

Comment on lines +222 to +228
private async trackPublicKey(publicKeyBase64: string): Promise<void> {
const index = await this.getPublicKeyIndex();
if (!index.includes(publicKeyBase64)) {
index.push(publicKeyBase64);
await this.savePublicKeyIndex(index);
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Race condition in public key index read-modify-write

trackPublicKey (and untrackPublicKey) performs a read-modify-write on the shared PUBLIC_KEY_INDEX_KEY SecureStore entry without any serialization. If two concurrent generateKey calls overlap, both will read the same index, each appends its key, and the second savePublicKeyIndex call will overwrite the first — losing a key from the index.

While concurrent generateKey calls may be unlikely in practice, this could be guarded with a simple in-memory mutex/lock or by serializing writes through a queue.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/client/device-signer-expo/src/SoftwareDeviceSignerKeyStorage.ts
Line: 222-228

Comment:
**Race condition in public key index read-modify-write**

`trackPublicKey` (and `untrackPublicKey`) performs a read-modify-write on the shared `PUBLIC_KEY_INDEX_KEY` SecureStore entry without any serialization. If two concurrent `generateKey` calls overlap, both will read the same index, each appends its key, and the second `savePublicKeyIndex` call will overwrite the first — losing a key from the index.

While concurrent `generateKey` calls may be unlikely in practice, this could be guarded with a simple in-memory mutex/lock or by serializing writes through a queue.

How can I resolve this? If you propose a fix, please make it concise.

super("");
}

async generateKey(params: { address?: string }): Promise<string> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 generateKey silently ignores biometric parameters

The base class DeviceSignerKeyStorage defines generateKey with overloads accepting biometricPolicy and biometricExpirationTime parameters, but this implementation only destructures address and silently discards the rest. While the software fallback can't enforce biometric policies, callers have no indication their security requirement was dropped.

Consider adding a console.warn when biometricPolicy is passed with a value other than "none" so developers are aware the software fallback cannot enforce it.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/client/device-signer-expo/src/SoftwareDeviceSignerKeyStorage.ts
Line: 123

Comment:
**`generateKey` silently ignores biometric parameters**

The base class `DeviceSignerKeyStorage` defines `generateKey` with overloads accepting `biometricPolicy` and `biometricExpirationTime` parameters, but this implementation only destructures `address` and silently discards the rest. While the software fallback can't enforce biometric policies, callers have no indication their security requirement was dropped.

Consider adding a `console.warn` when `biometricPolicy` is passed with a value other than `"none"` so developers are aware the software fallback cannot enforce it.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 9 additional findings in Devin Review.

Open in Devin Review

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 isSignerApproved not updated to recognize new "active" status as approved

The PR adds "active" to the SignerStatus type (packages/wallets/src/wallets/types.ts:115) and introduces isApprovedDeviceSignerStatus in recover() (packages/wallets/src/wallets/wallet.ts:915-916) that correctly treats both "success" and "active" as approved states. However, the public isSignerApproved() method at line 869 still only checks status === "success", meaning it will return false for signers with "active" status even though they are approved. This creates an inconsistency: the internal recovery flow considers "active" approved, but the public API does not.

(Refers to line 869)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@github-actions
Copy link
Contributor

🔥 Smoke Test Results

Status: Passed

Statistics

  • Total Tests: 5
  • Passed: 5 ✅
  • Failed: 0
  • Skipped: 0
  • Duration: 3.29 min

✅ All smoke tests passed!

All critical flows are working correctly.


This is a non-blocking smoke test. Full regression tests run separately.

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 23, 2026

Prompt To Fix All With AI
This is a comment left during a code review.
Path: packages/client/device-signer-expo/src/SoftwareDeviceSignerKeyStorage.ts
Line: 200-204

Comment:
**Clear raw key bytes after use**

In `signMessage`, the raw bytes from `hexToBytes(...)` remain in memory after signing. Similarly in `generateKey` (line 149–152), the raw bytes are not zeroed after being converted to hex. While JavaScript doesn't guarantee immediate garbage collection, zeroing `Uint8Array` buffers after use is standard practice in cryptographic code to reduce the window of exposure — consider adding `fill(0)` on the byte arrays after they're no longer needed.

For example after the sign call on line 204, zero out the bytes. Same for `generateKey` after line 152.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (3): Last reviewed commit: "Fixes software device signer key managem..." | Re-trigger Greptile

Comment on lines +200 to +204
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 });
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Clear raw key bytes after use

In signMessage, the raw bytes from hexToBytes(...) remain in memory after signing. Similarly in generateKey (line 149–152), the raw bytes are not zeroed after being converted to hex. While JavaScript doesn't guarantee immediate garbage collection, zeroing Uint8Array buffers after use is standard practice in cryptographic code to reduce the window of exposure — consider adding fill(0) on the byte arrays after they're no longer needed.

For example after the sign call on line 204, zero out the bytes. Same for generateKey after line 152.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/client/device-signer-expo/src/SoftwareDeviceSignerKeyStorage.ts
Line: 200-204

Comment:
**Clear raw key bytes after use**

In `signMessage`, the raw bytes from `hexToBytes(...)` remain in memory after signing. Similarly in `generateKey` (line 149–152), the raw bytes are not zeroed after being converted to hex. While JavaScript doesn't guarantee immediate garbage collection, zeroing `Uint8Array` buffers after use is standard practice in cryptographic code to reduce the window of exposure — consider adding `fill(0)` on the byte arrays after they're no longer needed.

For example after the sign call on line 204, zero out the bytes. Same for `generateKey` after line 152.

How can I resolve this? If you propose a fix, please make it concise.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants