react-native-wallet-keystore 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +20 -0
- package/README.md +376 -0
- package/WalletKeystore.podspec +29 -0
- package/android/build.gradle +62 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/com/walletkeystore/Secp256k1.kt +158 -0
- package/android/src/main/java/com/walletkeystore/WalletKeystoreCrypto.kt +233 -0
- package/android/src/main/java/com/walletkeystore/WalletKeystoreModule.kt +627 -0
- package/android/src/main/java/com/walletkeystore/WalletKeystorePackage.kt +31 -0
- package/ios/WalletKeystore.h +5 -0
- package/ios/WalletKeystore.mm +895 -0
- package/lib/module/NativeWalletKeystore.js +15 -0
- package/lib/module/NativeWalletKeystore.js.map +1 -0
- package/lib/module/errors.js +57 -0
- package/lib/module/errors.js.map +1 -0
- package/lib/module/index.js +9 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/keystore.js +220 -0
- package/lib/module/keystore.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/viem.js +65 -0
- package/lib/module/viem.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/NativeWalletKeystore.d.ts +26 -0
- package/lib/typescript/src/NativeWalletKeystore.d.ts.map +1 -0
- package/lib/typescript/src/errors.d.ts +29 -0
- package/lib/typescript/src/errors.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +3 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/keystore.d.ts +100 -0
- package/lib/typescript/src/keystore.d.ts.map +1 -0
- package/lib/typescript/src/viem.d.ts +24 -0
- package/lib/typescript/src/viem.d.ts.map +1 -0
- package/package.json +210 -0
- package/src/NativeWalletKeystore.ts +43 -0
- package/src/errors.ts +94 -0
- package/src/index.tsx +6 -0
- package/src/keystore.ts +317 -0
- package/src/viem.ts +99 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Osama Miro
|
|
4
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
5
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
6
|
+
in the Software without restriction, including without limitation the rights
|
|
7
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
8
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
9
|
+
furnished to do so, subject to the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be included in all
|
|
12
|
+
copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
15
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
16
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
17
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
18
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
19
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
20
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
# react-native-wallet-keystore
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/react-native-wallet-keystore)
|
|
4
|
+
[](https://github.com/miroosama/react-native-wallet-keystore/actions/workflows/ci.yml)
|
|
5
|
+
[](./LICENSE)
|
|
6
|
+
|
|
7
|
+
Hardware-protected wallet keys for React Native. Store a secp256k1 private key
|
|
8
|
+
encrypted by a hardware-bound key, sign with it behind Face ID or fingerprint,
|
|
9
|
+
and plug it straight into viem.
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { createWalletClient, http, parseEther } from 'viem';
|
|
13
|
+
import { base } from 'viem/chains';
|
|
14
|
+
import { generateKey, hasSecret } from 'react-native-wallet-keystore';
|
|
15
|
+
import { toKeystoreAccount } from 'react-native-wallet-keystore/viem';
|
|
16
|
+
|
|
17
|
+
// Once, during onboarding. The private key never reaches JavaScript.
|
|
18
|
+
if (!(await hasSecret('my-wallet'))) {
|
|
19
|
+
await generateKey('my-wallet');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const account = await toKeystoreAccount('my-wallet');
|
|
23
|
+
const client = createWalletClient({ account, chain: base, transport: http() });
|
|
24
|
+
|
|
25
|
+
// Prompts for Face ID / fingerprint before signing.
|
|
26
|
+
await client.sendTransaction({ to: '0x…', value: parseEther('0.01') });
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
> **Status: 0.1.0, unaudited.** The cryptographic output is verified
|
|
30
|
+
> byte-identical to viem across both platforms, and the full API is tested on
|
|
31
|
+
> physical hardware. It has not had a third-party security review. Treat it
|
|
32
|
+
> accordingly for funds you can't afford to lose, and read
|
|
33
|
+
> [Known limitations](#known-limitations) before shipping.
|
|
34
|
+
|
|
35
|
+
## Why this exists
|
|
36
|
+
|
|
37
|
+
The iOS Secure Enclave and Android Keystore only support P-256. Ethereum uses
|
|
38
|
+
secp256k1. That means the existing hardware-backed signing libraries — good
|
|
39
|
+
libraries, solving a real problem — **cannot sign an Ethereum transaction**,
|
|
40
|
+
because the key they protect can't be the right kind of key.
|
|
41
|
+
|
|
42
|
+
This library takes the other approach. The wallet key is generated in software
|
|
43
|
+
and encrypted at rest by a key that *does* live in hardware — a P-256 key in the
|
|
44
|
+
Secure Enclave on iOS, an AES-256-GCM Keystore key on Android. Decryption
|
|
45
|
+
requires user authentication, signing happens in native memory, and the
|
|
46
|
+
plaintext key never crosses into JavaScript.
|
|
47
|
+
|
|
48
|
+
If you need P-256 device signing — passkeys, device binding, request
|
|
49
|
+
attestation — use a library built for that instead. This one is for wallet keys.
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
npm install react-native-wallet-keystore
|
|
55
|
+
cd ios && pod install
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`viem` is an optional peer dependency, needed only for the
|
|
59
|
+
`react-native-wallet-keystore/viem` adapter:
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
npm install viem
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Building requires **Node >= 22.12** (the version where `require(esm)` works
|
|
66
|
+
unflagged). This is enforced via `engines`.
|
|
67
|
+
|
|
68
|
+
> **Expo:** this module contains native code, so it cannot run in Expo Go. Use a
|
|
69
|
+
> development build — `npx expo prebuild` then `npx expo run:ios`.
|
|
70
|
+
|
|
71
|
+
## Platform setup
|
|
72
|
+
|
|
73
|
+
### iOS
|
|
74
|
+
|
|
75
|
+
Add `NSFaceIDUsageDescription` to `Info.plist`. Without it, iOS terminates the
|
|
76
|
+
app the first time Face ID is requested.
|
|
77
|
+
|
|
78
|
+
```xml
|
|
79
|
+
<key>NSFaceIDUsageDescription</key>
|
|
80
|
+
<string>Authenticate to unlock your wallet key.</string>
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Minimum deployment target follows React Native's. No other configuration — the
|
|
84
|
+
podspec links `LocalAuthentication`, `Security`, and `secp256k1.swift` itself.
|
|
85
|
+
|
|
86
|
+
### Android
|
|
87
|
+
|
|
88
|
+
`minSdkVersion` 24. The library declares its own dependencies
|
|
89
|
+
(`androidx.biometric`, `org.bouncycastle:bcprov-jdk18on`), so no manifest or
|
|
90
|
+
Gradle changes are required.
|
|
91
|
+
|
|
92
|
+
Your `MainActivity` must extend `FragmentActivity` — React Native's
|
|
93
|
+
`ReactActivity` already does, so this only matters if you have replaced it.
|
|
94
|
+
|
|
95
|
+
## API
|
|
96
|
+
|
|
97
|
+
All functions reject with a [`KeystoreError`](#error-codes); none resolve a
|
|
98
|
+
failure value.
|
|
99
|
+
|
|
100
|
+
**Keys and secrets share one namespace.** A wallet key *is* a stored secret —
|
|
101
|
+
`generateKey` wraps a secp256k1 key through the same path `storeSecret` uses for
|
|
102
|
+
arbitrary bytes. So `hasSecret`, `deleteSecret` and `getSecret` all operate on
|
|
103
|
+
keys too, and a `keyId` used by `generateKey` collides with one used by
|
|
104
|
+
`storeSecret`. This is why the quickstart checks `hasSecret('my-wallet')` before
|
|
105
|
+
calling `generateKey` — it reads like a mismatch and isn't.
|
|
106
|
+
|
|
107
|
+
### Keys
|
|
108
|
+
|
|
109
|
+
`generateKey` and `importPrivateKey` are alternatives, not steps — new wallet
|
|
110
|
+
versus restoring one the user already has.
|
|
111
|
+
|
|
112
|
+
> **`importPrivateKey` takes a raw 32-byte key, not a seed phrase.** Deriving
|
|
113
|
+
> one from twelve words is BIP-39/BIP-32 and stays in your app (viem's
|
|
114
|
+
> `mnemonicToAccount` does it). Note that derivation happens in JavaScript, so
|
|
115
|
+
> an imported key is briefly in the JS heap before it is wrapped —
|
|
116
|
+
> `generateKey` is the only path where the key never leaves native memory.
|
|
117
|
+
|
|
118
|
+
#### `generateKey(keyId, options?): Promise<PublicKeyHex>`
|
|
119
|
+
|
|
120
|
+
Generates a secp256k1 keypair in hardware-wrapped storage and returns the
|
|
121
|
+
uncompressed public key. Entropy comes from the platform CSPRNG; the private key
|
|
122
|
+
never crosses the bridge.
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
const publicKey = await generateKey('my-wallet', {
|
|
126
|
+
policy: 'biometricOrPasscode', // default
|
|
127
|
+
invalidation: 'never', // default
|
|
128
|
+
});
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
> ⚠️ **`invalidation: 'onEnrollmentChange'` can destroy funds.** It is stronger
|
|
132
|
+
> against coercion, and it will **permanently destroy the key** if the user adds
|
|
133
|
+
> a fingerprint or re-enrolls Face ID. The encrypted secret becomes
|
|
134
|
+
> unrecoverable and `getSecret`/`signDigest` reject with `KEY_INVALIDATED`. Do
|
|
135
|
+
> not ship it without mandatory backup during onboarding.
|
|
136
|
+
|
|
137
|
+
#### `importPrivateKey(keyId, privateKeyHex, options?): Promise<PublicKeyHex>`
|
|
138
|
+
|
|
139
|
+
Imports an existing 32-byte key — the restore-from-backup path. Rejects
|
|
140
|
+
`INVALID_KEY` unless the key is in `[1, n-1]`; out-of-range keys are rejected,
|
|
141
|
+
never clamped.
|
|
142
|
+
|
|
143
|
+
#### `getPublicKey(keyId): Promise<PublicKeyHex>`
|
|
144
|
+
|
|
145
|
+
The uncompressed public key. **Does not authenticate** — a public key is not
|
|
146
|
+
secret, and prompting to see your own address is hostile.
|
|
147
|
+
|
|
148
|
+
#### `exportPrivateKey(keyId, reason): Promise<string>`
|
|
149
|
+
|
|
150
|
+
Authenticates, then returns the raw private key. For user-initiated backup only;
|
|
151
|
+
see [Known limitations](#known-limitations).
|
|
152
|
+
|
|
153
|
+
### Signing
|
|
154
|
+
|
|
155
|
+
#### `signDigest(keyId, digestHex, reason): Promise<SignatureHex>`
|
|
156
|
+
|
|
157
|
+
Authenticates, then signs a **32-byte digest**. Returns 65 bytes as
|
|
158
|
+
`r || s || v`, low-s normalized per EIP-2 with `v` of 27/28 — byte-identical to
|
|
159
|
+
viem for the same key and digest.
|
|
160
|
+
|
|
161
|
+
Only a digest crosses the boundary, never a message or a transaction. Keccak and
|
|
162
|
+
all EIP-191/712/155 encoding stay in JavaScript, which keeps this module
|
|
163
|
+
curve-specific but chain-agnostic.
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
import { keccak256, toHex } from 'viem';
|
|
167
|
+
|
|
168
|
+
const signature = await signDigest(
|
|
169
|
+
'my-wallet',
|
|
170
|
+
keccak256(toHex('hello')),
|
|
171
|
+
'Sign this message'
|
|
172
|
+
);
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
#### `toKeystoreAccount(keyId, options?): Promise<LocalAccount>`
|
|
176
|
+
|
|
177
|
+
From `react-native-wallet-keystore/viem`. Returns a viem `LocalAccount` that
|
|
178
|
+
works anywhere viem accepts one — `signMessage`, `signTypedData`,
|
|
179
|
+
`signTransaction` and `sendTransaction` all route through `signDigest`.
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
const account = await toKeystoreAccount('my-wallet', {
|
|
183
|
+
reason: 'Approve this swap', // shown in the prompt
|
|
184
|
+
publicKey, // optional, skips a native round-trip
|
|
185
|
+
});
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Each signature raises its own prompt, so batching several will prompt several
|
|
189
|
+
times.
|
|
190
|
+
|
|
191
|
+
### Secrets
|
|
192
|
+
|
|
193
|
+
The same wrapping path works for any bytes — mnemonics, API keys, session
|
|
194
|
+
tokens.
|
|
195
|
+
|
|
196
|
+
| | |
|
|
197
|
+
| --- | --- |
|
|
198
|
+
| `storeSecret(keyId, secretHex, options?)` | Rejects `KEY_ALREADY_EXISTS` if taken |
|
|
199
|
+
| `getSecret(keyId, reason)` | Authenticates, returns hex |
|
|
200
|
+
| `hasSecret(keyId)` | Presence check, no prompt |
|
|
201
|
+
| `deleteSecret(keyId)` | Removes secret and wrapping key; idempotent |
|
|
202
|
+
|
|
203
|
+
### Authentication
|
|
204
|
+
|
|
205
|
+
#### `getBiometryType(): Promise<BiometryType>`
|
|
206
|
+
|
|
207
|
+
Which modality the *hardware* supports, whether or not anything is enrolled — so
|
|
208
|
+
you can write "Enable Face ID in Settings" rather than a generic message.
|
|
209
|
+
Resolves `'none'` when there is no hardware; never rejects.
|
|
210
|
+
|
|
211
|
+
#### `authenticate(reason, policy?): Promise<boolean>`
|
|
212
|
+
|
|
213
|
+
Prompts for device-owner authentication. See
|
|
214
|
+
[the security model](#whats-protected-and-what-isnt) before using this to gate
|
|
215
|
+
anything.
|
|
216
|
+
|
|
217
|
+
### Policies
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
type AuthPolicy = 'biometricOnly' | 'biometricOrPasscode';
|
|
221
|
+
type InvalidationPolicy = 'onEnrollmentChange' | 'never';
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
These are deliberately **orthogonal**. Which authenticators are accepted is a
|
|
225
|
+
separate question from when the key is destroyed. Several libraries bind them,
|
|
226
|
+
so that "biometric only" silently implies invalidation — and a user adding a
|
|
227
|
+
fingerprint destroys their wallet. Here, opting into that is explicit.
|
|
228
|
+
|
|
229
|
+
`biometricOrPasscode` is the default because for a wallet the failure modes are
|
|
230
|
+
asymmetric: biometric-only risks permanent loss of access to funds, while the
|
|
231
|
+
passcode fallback is a credential the device already depends on.
|
|
232
|
+
|
|
233
|
+
The policy is fixed at key creation. Changing it later means
|
|
234
|
+
`deleteSecret` and re-creating.
|
|
235
|
+
|
|
236
|
+
## Error codes
|
|
237
|
+
|
|
238
|
+
Every rejection is a `KeystoreError` with a `.code`. The distinctions are
|
|
239
|
+
load-bearing — each implies a different action.
|
|
240
|
+
|
|
241
|
+
```ts
|
|
242
|
+
import { KeystoreError } from 'react-native-wallet-keystore';
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
await signDigest('my-wallet', digest, 'Sign');
|
|
246
|
+
} catch (error) {
|
|
247
|
+
if (error instanceof KeystoreError && error.code === 'NOT_ENROLLED') {
|
|
248
|
+
// Send the user to Settings. Retrying will not help.
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
| Code | Meaning | What to do |
|
|
254
|
+
| --- | --- | --- |
|
|
255
|
+
| `NOT_AVAILABLE` | No biometric hardware, or unavailable | Fall back to another factor |
|
|
256
|
+
| `NOT_ENROLLED` | Nothing enrolled | Send to Settings — retrying cannot help |
|
|
257
|
+
| `USER_CANCELED` | User dismissed the prompt | Safe to re-prompt |
|
|
258
|
+
| `USER_FALLBACK` | User chose the fallback affordance | Offer a passcode path |
|
|
259
|
+
| `LOCKOUT` | Too many attempts, temporary | Retry after a cooldown |
|
|
260
|
+
| `LOCKOUT_PERMANENT` | Locked until device credential is used | Android only; prompt for PIN/pattern |
|
|
261
|
+
| `SYSTEM_CANCEL` | OS dismissed the prompt | Not user intent; retry later |
|
|
262
|
+
| `KEY_NOT_FOUND` | No secret under that `keyId` | Generate or import one |
|
|
263
|
+
| `KEY_ALREADY_EXISTS` | `keyId` is taken | `deleteSecret` first — overwrites are explicit |
|
|
264
|
+
| `KEY_INVALIDATED` | **The secret is gone for good** | Start recovery from backup |
|
|
265
|
+
| `INVALID_KEY` | Key outside `[1, n-1]`, or digest not 32 bytes | Fix the input |
|
|
266
|
+
| `STORAGE_ERROR` | Keychain/Keystore itself failed | Surface as unexpected |
|
|
267
|
+
| `UNKNOWN` | Unrecognized | Inspect `.nativeCode` for the raw value |
|
|
268
|
+
|
|
269
|
+
An unrecognized native code maps to `UNKNOWN` but preserves the original string
|
|
270
|
+
on `.nativeCode`, so a new platform error stays diagnosable.
|
|
271
|
+
|
|
272
|
+
## What's protected, and what isn't
|
|
273
|
+
|
|
274
|
+
**`authenticate()` is a UX gate, not a security boundary.** It returns a
|
|
275
|
+
boolean, and a compromised JavaScript bundle can pretend that boolean was
|
|
276
|
+
`true`. Use it to decide when to show a prompt, never to decide whether to
|
|
277
|
+
release a secret.
|
|
278
|
+
|
|
279
|
+
**`signDigest()` and `getSecret()` are the real boundary.** The key is unusable
|
|
280
|
+
until the OS validates authentication against hardware. On Android this is
|
|
281
|
+
enforced by a `BiometricPrompt.CryptoObject`: the `Cipher` is inoperable until
|
|
282
|
+
the OS authorizes it, so there is no code path that decrypts without a
|
|
283
|
+
successful prompt. Every stored key requires authentication — there is no
|
|
284
|
+
opt-out.
|
|
285
|
+
|
|
286
|
+
**The key is not inside the enclave.** It's encrypted by a key that is. A
|
|
287
|
+
sufficiently compromised device that can drive the biometric prompt can obtain
|
|
288
|
+
the plaintext. The protection is against key extraction at rest, not against an
|
|
289
|
+
attacker in control of an unlocked device.
|
|
290
|
+
|
|
291
|
+
**Normal signing never exposes the key to JavaScript.** `signDigest` takes a
|
|
292
|
+
32-byte digest and returns a signature; the private key stays in native memory.
|
|
293
|
+
`exportPrivateKey` exists for user-initiated backup and does return the key to
|
|
294
|
+
JS, where it lands in the heap and can't be reliably cleared. That's inherent,
|
|
295
|
+
which is why export is the exception and not the path.
|
|
296
|
+
|
|
297
|
+
## Known limitations
|
|
298
|
+
|
|
299
|
+
**Zeroing is best-effort.** Key buffers are overwritten immediately after use —
|
|
300
|
+
on iOS through a volatile pointer, because a plain `memset` over memory that is
|
|
301
|
+
never read again is legal for the compiler to eliminate entirely, so the naive
|
|
302
|
+
version looks right in review and does nothing at `-O3`. On Android buffers are
|
|
303
|
+
cleared with `ByteArray.fill(0)`, which the JVM offers no stronger guarantee
|
|
304
|
+
than. Either way this shrinks the window rather than closing it; managed
|
|
305
|
+
runtimes copy memory in ways you don't control.
|
|
306
|
+
|
|
307
|
+
**The iOS secp256k1 dependency is unmaintained.** `secp256k1.swift` vendors
|
|
308
|
+
genuine bitcoin-core source with the recovery module enabled, and its output is
|
|
309
|
+
verified byte-identical to viem, so this is a staleness risk rather than a
|
|
310
|
+
correctness one — later upstream hardening does not flow in. React Native 0.86
|
|
311
|
+
ships an `spm_dependency` helper that makes moving to the maintained package
|
|
312
|
+
practical; it needs a Swift bridging layer, since the current implementation
|
|
313
|
+
calls libsecp256k1's C API directly.
|
|
314
|
+
|
|
315
|
+
**Platform asymmetries, all real:**
|
|
316
|
+
|
|
317
|
+
- iOS has no `LOCKOUT_PERMANENT` — it resolves permanent lockout inside the
|
|
318
|
+
system prompt by requiring the device passcode, so the state never reaches the
|
|
319
|
+
app
|
|
320
|
+
- Android prompts on `storeSecret` as well as `getSecret`, because
|
|
321
|
+
`setUserAuthenticationRequired` governs every use of a symmetric key. iOS
|
|
322
|
+
encrypts with the public half of an enclave keypair and needs no
|
|
323
|
+
authentication to store
|
|
324
|
+
- Android can't report *which* biometric is enrolled — `PackageManager` reports
|
|
325
|
+
hardware presence only — so `getBiometryType` returns `'biometric'` when more
|
|
326
|
+
than one modality is present
|
|
327
|
+
- `biometricOrPasscode` degrades to biometric-only below API 30, where
|
|
328
|
+
`BIOMETRIC_STRONG or DEVICE_CREDENTIAL` is rejected by
|
|
329
|
+
`setAllowedAuthenticators`
|
|
330
|
+
|
|
331
|
+
**`KEY_INVALIDATED` is verified on Android, not iOS.** The logic is shared and
|
|
332
|
+
identical, but reproducing it on iOS means resetting Face ID on a device in
|
|
333
|
+
daily use.
|
|
334
|
+
|
|
335
|
+
## When not to use this
|
|
336
|
+
|
|
337
|
+
If you're building on smart-contract accounts, passkeys plus the secp256r1
|
|
338
|
+
precompile (RIP-7212, live on Base and other L2s) may be a better fit — there is
|
|
339
|
+
no private key to protect at all. This library is for applications that need a
|
|
340
|
+
real EOA key the user owns and can export.
|
|
341
|
+
|
|
342
|
+
## Contributing
|
|
343
|
+
|
|
344
|
+
Requires Node >= 22.12; the repo pins an exact version in `.nvmrc`.
|
|
345
|
+
|
|
346
|
+
```sh
|
|
347
|
+
nvm use
|
|
348
|
+
yarn
|
|
349
|
+
yarn example ios # or: yarn example android
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
The example app exercises every API call, grouped in the order you'd use them.
|
|
353
|
+
It is the fastest way to see a change working on a device.
|
|
354
|
+
|
|
355
|
+
```sh
|
|
356
|
+
yarn test # jest, including known-answer vectors against viem
|
|
357
|
+
yarn typecheck
|
|
358
|
+
yarn lint
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
The known-answer vectors in `src/__fixtures__` pin native output byte-for-byte
|
|
362
|
+
against viem. They are what catch low-s errors, recovery-id errors, and
|
|
363
|
+
divergence between libsecp256k1 on iOS and BouncyCastle on Android. Regenerate
|
|
364
|
+
them with `node src/__fixtures__/generate.mjs`.
|
|
365
|
+
|
|
366
|
+
Note that the example app resolves the library from `src/` via a custom export
|
|
367
|
+
condition, so it cannot catch packaging mistakes. Validate those by packing the
|
|
368
|
+
tarball and installing it into a fresh app:
|
|
369
|
+
|
|
370
|
+
```sh
|
|
371
|
+
npm pack
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
## License
|
|
375
|
+
|
|
376
|
+
MIT
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
package = JSON.parse(File.read(File.join(__dir__, "package.json")))
|
|
4
|
+
|
|
5
|
+
Pod::Spec.new do |s|
|
|
6
|
+
s.name = "WalletKeystore"
|
|
7
|
+
s.version = package["version"]
|
|
8
|
+
s.summary = package["description"]
|
|
9
|
+
s.homepage = package["homepage"]
|
|
10
|
+
s.license = package["license"]
|
|
11
|
+
s.authors = package["author"]
|
|
12
|
+
|
|
13
|
+
s.platforms = { :ios => min_ios_version_supported }
|
|
14
|
+
s.source = { :git => "https://github.com/miroosama/react-native-wallet-keystore.git", :tag => "#{s.version}" }
|
|
15
|
+
|
|
16
|
+
s.source_files = "ios/**/*.{h,m,mm,swift,cpp}"
|
|
17
|
+
s.private_header_files = "ios/**/*.h"
|
|
18
|
+
|
|
19
|
+
# Not linked by install_modules_dependencies, which only wires up the React
|
|
20
|
+
# Native dependencies.
|
|
21
|
+
s.frameworks = "LocalAuthentication", "Security"
|
|
22
|
+
|
|
23
|
+
# bitcoin-core/libsecp256k1 with the recovery module compiled in. The
|
|
24
|
+
# recovery module is what yields Ethereum's `v` directly, rather than
|
|
25
|
+
# recovering the public key four times and comparing.
|
|
26
|
+
s.dependency "secp256k1.swift", "~> 0.1"
|
|
27
|
+
|
|
28
|
+
install_modules_dependencies(s)
|
|
29
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
buildscript {
|
|
2
|
+
ext.WalletKeystore = [
|
|
3
|
+
kotlinVersion: "2.0.21",
|
|
4
|
+
minSdkVersion: 24,
|
|
5
|
+
compileSdkVersion: 36
|
|
6
|
+
]
|
|
7
|
+
|
|
8
|
+
ext.getExtOrDefault = { prop ->
|
|
9
|
+
if (rootProject.ext.has(prop)) {
|
|
10
|
+
return rootProject.ext.get(prop)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return WalletKeystore[prop]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
repositories {
|
|
17
|
+
google()
|
|
18
|
+
mavenCentral()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
dependencies {
|
|
22
|
+
classpath "com.android.tools.build:gradle:8.7.2"
|
|
23
|
+
// noinspection DifferentKotlinGradleVersion
|
|
24
|
+
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${getExtOrDefault('kotlinVersion')}"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
apply plugin: "com.android.library"
|
|
30
|
+
|
|
31
|
+
if (project.extensions.findByName("kotlin") == null) {
|
|
32
|
+
apply plugin: "kotlin-android"
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
apply plugin: "com.facebook.react"
|
|
37
|
+
|
|
38
|
+
android {
|
|
39
|
+
namespace "com.walletkeystore"
|
|
40
|
+
|
|
41
|
+
compileSdkVersion getExtOrDefault("compileSdkVersion")
|
|
42
|
+
|
|
43
|
+
defaultConfig {
|
|
44
|
+
minSdkVersion getExtOrDefault("minSdkVersion")
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
compileOptions {
|
|
48
|
+
sourceCompatibility JavaVersion.VERSION_17
|
|
49
|
+
targetCompatibility JavaVersion.VERSION_17
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
dependencies {
|
|
54
|
+
implementation "com.facebook.react:react-android"
|
|
55
|
+
|
|
56
|
+
// BiometricPrompt, plus the FragmentActivity it requires as a host.
|
|
57
|
+
implementation "androidx.biometric:biometric:1.1.0"
|
|
58
|
+
|
|
59
|
+
// secp256k1. Android's bundled BouncyCastle is stripped and shadowed, so the
|
|
60
|
+
// full provider is pulled in explicitly rather than relying on the platform's.
|
|
61
|
+
implementation "org.bouncycastle:bcprov-jdk18on:1.78.1"
|
|
62
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
package com.walletkeystore
|
|
2
|
+
|
|
3
|
+
import org.bouncycastle.asn1.x9.X9ECParameters
|
|
4
|
+
import org.bouncycastle.asn1.x9.X9IntegerConverter
|
|
5
|
+
import org.bouncycastle.crypto.digests.SHA256Digest
|
|
6
|
+
import org.bouncycastle.crypto.ec.CustomNamedCurves
|
|
7
|
+
import org.bouncycastle.crypto.params.ECDomainParameters
|
|
8
|
+
import org.bouncycastle.crypto.params.ECPrivateKeyParameters
|
|
9
|
+
import org.bouncycastle.crypto.signers.ECDSASigner
|
|
10
|
+
import org.bouncycastle.crypto.signers.HMacDSAKCalculator
|
|
11
|
+
import org.bouncycastle.math.ec.ECAlgorithms
|
|
12
|
+
import org.bouncycastle.math.ec.ECPoint
|
|
13
|
+
import java.math.BigInteger
|
|
14
|
+
import java.security.SecureRandom
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* secp256k1 signing with Ethereum's conventions. libsecp256k1 gives iOS the
|
|
18
|
+
* recovery id directly; BouncyCastle does not, so it is derived here. The
|
|
19
|
+
* known-answer vectors are what keep the two from diverging.
|
|
20
|
+
*/
|
|
21
|
+
internal object Secp256k1 {
|
|
22
|
+
|
|
23
|
+
private val CURVE_PARAMS: X9ECParameters = CustomNamedCurves.getByName("secp256k1")
|
|
24
|
+
private val CURVE = ECDomainParameters(
|
|
25
|
+
CURVE_PARAMS.curve,
|
|
26
|
+
CURVE_PARAMS.g,
|
|
27
|
+
CURVE_PARAMS.n,
|
|
28
|
+
CURVE_PARAMS.h
|
|
29
|
+
)
|
|
30
|
+
private val HALF_CURVE_ORDER: BigInteger = CURVE_PARAMS.n.shiftRight(1)
|
|
31
|
+
|
|
32
|
+
/** Valid private keys are [1, n-1]. */
|
|
33
|
+
fun isValidPrivateKey(d: BigInteger): Boolean =
|
|
34
|
+
d.signum() > 0 && d < CURVE.n
|
|
35
|
+
|
|
36
|
+
fun generatePrivateKey(): ByteArray {
|
|
37
|
+
val random = SecureRandom()
|
|
38
|
+
val bytes = ByteArray(32)
|
|
39
|
+
// Rejection sampling rather than reduction mod n: reducing would bias the
|
|
40
|
+
// distribution toward small keys. The retry probability is ~2^-128.
|
|
41
|
+
while (true) {
|
|
42
|
+
random.nextBytes(bytes)
|
|
43
|
+
val candidate = BigInteger(1, bytes)
|
|
44
|
+
if (isValidPrivateKey(candidate)) return bytes
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Uncompressed SEC1 encoding: 0x04 || X || Y, 65 bytes. */
|
|
49
|
+
fun publicKeyFrom(privateKey: ByteArray): ByteArray {
|
|
50
|
+
val d = BigInteger(1, privateKey)
|
|
51
|
+
require(isValidPrivateKey(d)) { "private key out of range" }
|
|
52
|
+
return CURVE.g.multiply(d).normalize().getEncoded(false)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Signs a 32-byte digest into 65 bytes: r || s || v, with v in 27/28.
|
|
57
|
+
*
|
|
58
|
+
* RFC 6979 nonces come from HMacDSAKCalculator. A nonce that repeats across
|
|
59
|
+
* two signatures reveals the private key, so never hand-roll this.
|
|
60
|
+
*/
|
|
61
|
+
fun sign(digest: ByteArray, privateKey: ByteArray): ByteArray {
|
|
62
|
+
require(digest.size == 32) { "digest must be 32 bytes" }
|
|
63
|
+
|
|
64
|
+
val d = BigInteger(1, privateKey)
|
|
65
|
+
require(isValidPrivateKey(d)) { "private key out of range" }
|
|
66
|
+
|
|
67
|
+
val signer = ECDSASigner(HMacDSAKCalculator(SHA256Digest()))
|
|
68
|
+
signer.init(true, ECPrivateKeyParameters(d, CURVE))
|
|
69
|
+
val components = signer.generateSignature(digest)
|
|
70
|
+
|
|
71
|
+
val r = components[0]
|
|
72
|
+
// EIP-2: only the low-s form is canonical. (r, s) and (r, n-s) are both
|
|
73
|
+
// valid ECDSA, but Ethereum rejects the high one, so roughly half of all
|
|
74
|
+
// signatures would fail on-chain without this.
|
|
75
|
+
val s = if (components[1] > HALF_CURVE_ORDER) CURVE.n.subtract(components[1])
|
|
76
|
+
else components[1]
|
|
77
|
+
|
|
78
|
+
val publicKey = CURVE.g.multiply(d).normalize()
|
|
79
|
+
val recId = recoveryId(r, s, digest, publicKey)
|
|
80
|
+
require(recId >= 0) { "could not determine recovery id" }
|
|
81
|
+
|
|
82
|
+
return toBytes32(r) + toBytes32(s) + byteArrayOf((recId + 27).toByte())
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Finds which candidate public key recoverable from (r, s) is ours, by trying
|
|
87
|
+
* each and comparing — libsecp256k1 gets this free from signing, BC does not.
|
|
88
|
+
*/
|
|
89
|
+
private fun recoveryId(
|
|
90
|
+
r: BigInteger,
|
|
91
|
+
s: BigInteger,
|
|
92
|
+
digest: ByteArray,
|
|
93
|
+
expected: ECPoint
|
|
94
|
+
): Int {
|
|
95
|
+
for (recId in 0..3) {
|
|
96
|
+
val candidate = recoverPublicKey(recId, r, s, digest) ?: continue
|
|
97
|
+
if (candidate.equals(expected)) return recId
|
|
98
|
+
}
|
|
99
|
+
return -1
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private fun recoverPublicKey(
|
|
103
|
+
recId: Int,
|
|
104
|
+
r: BigInteger,
|
|
105
|
+
s: BigInteger,
|
|
106
|
+
digest: ByteArray
|
|
107
|
+
): ECPoint? {
|
|
108
|
+
val n = CURVE.n
|
|
109
|
+
|
|
110
|
+
// recId's high bit selects which multiple of n was subtracted from x when
|
|
111
|
+
// r was reduced; its low bit selects the sign of y.
|
|
112
|
+
val i = BigInteger.valueOf(recId.toLong() / 2)
|
|
113
|
+
val x = r.add(i.multiply(n))
|
|
114
|
+
|
|
115
|
+
val prime = CURVE_PARAMS.curve.field.characteristic
|
|
116
|
+
if (x >= prime) return null
|
|
117
|
+
|
|
118
|
+
val R = decompressKey(x, (recId and 1) == 1) ?: return null
|
|
119
|
+
// A valid R must be n-torsion; if nR is not the point at infinity this
|
|
120
|
+
// candidate is spurious.
|
|
121
|
+
if (!R.multiply(n).isInfinity) return null
|
|
122
|
+
|
|
123
|
+
val e = BigInteger(1, digest)
|
|
124
|
+
val eInv = BigInteger.ZERO.subtract(e).mod(n)
|
|
125
|
+
val rInv = r.modInverse(n)
|
|
126
|
+
val srInv = rInv.multiply(s).mod(n)
|
|
127
|
+
val eInvrInv = rInv.multiply(eInv).mod(n)
|
|
128
|
+
|
|
129
|
+
return ECAlgorithms.sumOfTwoMultiplies(CURVE.g, eInvrInv, R, srInv).normalize()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private fun decompressKey(xBN: BigInteger, yBit: Boolean): ECPoint? {
|
|
133
|
+
val converter = X9IntegerConverter()
|
|
134
|
+
val compEnc = converter.integerToBytes(
|
|
135
|
+
xBN,
|
|
136
|
+
1 + converter.getByteLength(CURVE_PARAMS.curve)
|
|
137
|
+
)
|
|
138
|
+
compEnc[0] = if (yBit) 0x03 else 0x02
|
|
139
|
+
return try {
|
|
140
|
+
CURVE_PARAMS.curve.decodePoint(compEnc)
|
|
141
|
+
} catch (_: IllegalArgumentException) {
|
|
142
|
+
// x was not on the curve for this candidate.
|
|
143
|
+
null
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Left-pads to exactly 32 bytes, dropping BigInteger's sign byte. */
|
|
148
|
+
private fun toBytes32(value: BigInteger): ByteArray {
|
|
149
|
+
val raw = value.toByteArray()
|
|
150
|
+
val out = ByteArray(32)
|
|
151
|
+
when {
|
|
152
|
+
raw.size == 32 -> return raw
|
|
153
|
+
raw.size > 32 -> System.arraycopy(raw, raw.size - 32, out, 0, 32)
|
|
154
|
+
else -> System.arraycopy(raw, 0, out, 32 - raw.size, raw.size)
|
|
155
|
+
}
|
|
156
|
+
return out
|
|
157
|
+
}
|
|
158
|
+
}
|