Skip to content

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

Closed
albertoelias-crossmint wants to merge 3 commits intowallets-v1from
devin/1774043147-expo-go-device-signer-fallback
Closed

feat: support Expo Go with software-backed device signer fallback [WAL-9469]#1714
albertoelias-crossmint wants to merge 3 commits intowallets-v1from
devin/1774043147-expo-go-device-signer-fallback

Conversation

@albertoelias-crossmint
Copy link
Collaborator

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

Description

The @crossmint/expo-device-signer package uses a custom Expo native module (CrossmintDeviceSigner) that is unavailable in Expo Go. This caused CrossmintWalletProvider to crash on mount when running in Expo Go because it eagerly instantiated NativeDeviceSignerKeyStorage.

This PR adds a software-backed fallback (SoftwareDeviceSignerKeyStorage) that uses @noble/curves for P-256 key operations and expo-secure-store for encrypted key persistence, along with a createDeviceSignerKeyStorage() factory that auto-detects the environment and returns the appropriate implementation.

Changes:

  • SoftwareDeviceSignerKeyStorage — pure JS implementation of DeviceSignerKeyStorage using @noble/curves/p256 + expo-secure-store. Not hardware-backed, but functionally equivalent for development/prototyping.
  • createDeviceSignerKeyStorage() — factory that tries to load the native module; falls back to the software implementation if unavailable.
  • NativeDeviceSignerKeyStorage — improved error message when the native module is missing (was an opaque crash).
  • CrossmintWalletProvider — swapped direct NativeDeviceSignerKeyStorage instantiation for the auto-detecting factory.

Updates since last revision

Addressed automated review feedback:

  • SecureStore key encoding: Added safeStoreKey() helper that converts base64 to base64url (+-, /_, strip =) before using public keys in SecureStore key names. Applied consistently in generateKey, mapAddressToKey, and deletePendingKey.
  • Prehash behavior: Analyzed the wallet SDK code — wallet.ts confirms the message passed to signMessage is already a hash digest (e.g. keccak256). Added inline comment documenting why prehash: false (the default) is correct.
  • Merged latest wallets-v1 to incorporate upstream changes.

Human review checklist

  • Signing compatibility (critical): The software fallback signs raw bytes with prehash: false, assuming the message is already a hash digest. This matches what wallet.ts sends (keccak256 hash), but should be verified against the native CrossmintDeviceSigner Swift package's actual signing behavior. If the native side applies SHA-256 internally via SecKeyCreateSignature(ecdsaSignatureMessageX962SHA256), the software side would need prehash: true to match.
  • btoa/atob usage: The software impl uses btoa/atob for base64 encoding. These are available in Hermes (RN 0.74+) — confirm this covers the supported RN version range (>=0.74.0 per peer deps).
  • generateKey type signature: The abstract DeviceSignerKeyStorage accepts biometricPolicy/biometricExpirationTime. The software impl ignores these params — confirm this is acceptable for the Expo Go use case.
  • expo-modules-core marked optional but statically imported: NativeDeviceSignerKeyStorage.ts has a top-level import { requireNativeModule } from "expo-modules-core". If the package were truly absent, this would fail before the try/catch runs. In practice expo-modules-core is always present in Expo projects — confirm this assumption is safe.
  • Public key index size: The hasKey() lookup stores all public keys as a JSON array in a single SecureStore entry. For apps with many keys, this could approach SecureStore's 2KB value limit.

Test plan

  • Build verified locally (pnpm build:libs passes)
  • Lint verified locally (pnpm lint passes)
  • All existing tests pass (pnpm test:vitest)
  • No automated tests added — this is crypto + storage code that should be tested manually in an Expo Go environment to verify the fallback activates correctly and signing works end-to-end.

Package updates

  • @crossmint/expo-device-signer: minor (new SoftwareDeviceSignerKeyStorage, createDeviceSignerKeyStorage, isNativeModuleAvailable exports; added @noble/curves dependency; 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/92aaa470742247e88827749387401d9e
Requested by: @albertoelias-crossmint


Open with Devin

…L-9469]

Co-Authored-By: Alberto Elias <alberto.elias@paella.dev>
@changeset-bot
Copy link

changeset-bot bot commented Mar 20, 2026

🦋 Changeset detected

Latest commit: 35685c5

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

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

@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

devin-ai-integration[bot]

This comment was marked as resolved.

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 20, 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: 82

Comment:
**Base64 characters invalid in SecureStore keys**

`expo-secure-store` keys may only contain alphanumeric characters, `.`, `-`, and `_` ([docs](https://docs.expo.dev/versions/latest/sdk/securestore/)). Base64-encoded strings can contain `+`, `/`, and `=`, which are all invalid key characters. This will cause runtime errors when storing or retrieving pending keys.

For example, an uncompressed P-256 public key is 65 bytes → 88 base64 characters, and will very likely include `+`, `/`, or `=`.

The same issue applies to `mapAddressToKey` (line 90) and `deletePendingKey` (line 136) which also use `publicKeyBase64` in the key.

Consider using base64url encoding (replacing `+` with `-`, `/` with `_`, stripping `=`) or hex encoding for the SecureStore key portion:

```suggestion
            await SecureStore.setItemAsync(`${PENDING_KEY_PREFIX}${publicKeyBase64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')}`, privateKeyHex);
```

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: 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.

---

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

Comment:
**Inconsistent key encoding will break key lookups**

When `generateKey` stores a pending key at `PENDING_KEY_PREFIX + publicKeyBase64`, the `publicKeyBase64` contains standard base64 characters (`+`, `/`, `=`). If the base64 key issue from `generateKey` is fixed by using base64url, this lookup must use the same encoding. The same applies to `deletePendingKey` at line 136.

Recommend extracting a helper like `safeStoreKey(base64: string)` that consistently converts base64 to a SecureStore-safe format, and using it in all places that incorporate `publicKeyBase64` into a store key.

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

Last reviewed commit: "feat: support Expo G..."

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}${publicKeyBase64}`, privateKeyHex);
Copy link
Contributor

Choose a reason for hiding this comment

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

P0 Base64 characters invalid in SecureStore keys

expo-secure-store keys may only contain alphanumeric characters, ., -, and _ (docs). Base64-encoded strings can contain +, /, and =, which are all invalid key characters. This will cause runtime errors when storing or retrieving pending keys.

For example, an uncompressed P-256 public key is 65 bytes → 88 base64 characters, and will very likely include +, /, or =.

The same issue applies to mapAddressToKey (line 90) and deletePendingKey (line 136) which also use publicKeyBase64 in the key.

Consider using base64url encoding (replacing + with -, / with _, stripping =) or hex encoding for the SecureStore key portion:

Suggested change
await SecureStore.setItemAsync(`${PENDING_KEY_PREFIX}${publicKeyBase64}`, privateKeyHex);
await SecureStore.setItemAsync(`${PENDING_KEY_PREFIX}${publicKeyBase64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')}`, privateKeyHex);
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/client/device-signer-expo/src/SoftwareDeviceSignerKeyStorage.ts
Line: 82

Comment:
**Base64 characters invalid in SecureStore keys**

`expo-secure-store` keys may only contain alphanumeric characters, `.`, `-`, and `_` ([docs](https://docs.expo.dev/versions/latest/sdk/securestore/)). Base64-encoded strings can contain `+`, `/`, and `=`, which are all invalid key characters. This will cause runtime errors when storing or retrieving pending keys.

For example, an uncompressed P-256 public key is 65 bytes → 88 base64 characters, and will very likely include `+`, `/`, or `=`.

The same issue applies to `mapAddressToKey` (line 90) and `deletePendingKey` (line 136) which also use `publicKeyBase64` in the key.

Consider using base64url encoding (replacing `+` with `-`, `/` with `_`, stripping `=`) or hex encoding for the SecureStore key portion:

```suggestion
            await SecureStore.setItemAsync(`${PENDING_KEY_PREFIX}${publicKeyBase64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')}`, privateKeyHex);
```

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

Copy link
Contributor

Choose a reason for hiding this comment

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

Addressed in 35685c5. Added safeStoreKey() helper that converts base64 to base64url (+-, /_, strip =) before using public keys in SecureStore key names. Applied consistently in generateKey, mapAddressToKey, and deletePendingKey.

Comment on lines +117 to +118
const messageBytes = base64ToBytes(message);
const signature = p256.sign(messageBytes, privateKey, { lowS: true });
Copy link
Contributor

Choose a reason for hiding this comment

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

P1 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.

Prompt To Fix With AI
This 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.

devin-ai-integration bot and others added 2 commits March 20, 2026 23:59
Co-Authored-By: Alberto Elias <alberto.elias@paella.dev>
…clarify prehash behavior

Co-Authored-By: Alberto Elias <alberto.elias@paella.dev>
@github-actions
Copy link
Contributor

🔥 Smoke Test Results

Status: Passed

Statistics

  • Total Tests: 5
  • Passed: 5 ✅
  • Failed: 0
  • Skipped: 0
  • Duration: 3.44 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 21, 2026

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

@devin-ai-integration
Copy link
Contributor

Superseded by #1728 which targets main instead of wallets-v1. Closing this PR.

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