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.
Files changed (39) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +376 -0
  3. package/WalletKeystore.podspec +29 -0
  4. package/android/build.gradle +62 -0
  5. package/android/src/main/AndroidManifest.xml +2 -0
  6. package/android/src/main/java/com/walletkeystore/Secp256k1.kt +158 -0
  7. package/android/src/main/java/com/walletkeystore/WalletKeystoreCrypto.kt +233 -0
  8. package/android/src/main/java/com/walletkeystore/WalletKeystoreModule.kt +627 -0
  9. package/android/src/main/java/com/walletkeystore/WalletKeystorePackage.kt +31 -0
  10. package/ios/WalletKeystore.h +5 -0
  11. package/ios/WalletKeystore.mm +895 -0
  12. package/lib/module/NativeWalletKeystore.js +15 -0
  13. package/lib/module/NativeWalletKeystore.js.map +1 -0
  14. package/lib/module/errors.js +57 -0
  15. package/lib/module/errors.js.map +1 -0
  16. package/lib/module/index.js +9 -0
  17. package/lib/module/index.js.map +1 -0
  18. package/lib/module/keystore.js +220 -0
  19. package/lib/module/keystore.js.map +1 -0
  20. package/lib/module/package.json +1 -0
  21. package/lib/module/viem.js +65 -0
  22. package/lib/module/viem.js.map +1 -0
  23. package/lib/typescript/package.json +1 -0
  24. package/lib/typescript/src/NativeWalletKeystore.d.ts +26 -0
  25. package/lib/typescript/src/NativeWalletKeystore.d.ts.map +1 -0
  26. package/lib/typescript/src/errors.d.ts +29 -0
  27. package/lib/typescript/src/errors.d.ts.map +1 -0
  28. package/lib/typescript/src/index.d.ts +3 -0
  29. package/lib/typescript/src/index.d.ts.map +1 -0
  30. package/lib/typescript/src/keystore.d.ts +100 -0
  31. package/lib/typescript/src/keystore.d.ts.map +1 -0
  32. package/lib/typescript/src/viem.d.ts +24 -0
  33. package/lib/typescript/src/viem.d.ts.map +1 -0
  34. package/package.json +210 -0
  35. package/src/NativeWalletKeystore.ts +43 -0
  36. package/src/errors.ts +94 -0
  37. package/src/index.tsx +6 -0
  38. package/src/keystore.ts +317 -0
  39. 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
+ [![npm](https://img.shields.io/npm/v/react-native-wallet-keystore.svg)](https://www.npmjs.com/package/react-native-wallet-keystore)
4
+ [![CI](https://github.com/miroosama/react-native-wallet-keystore/actions/workflows/ci.yml/badge.svg)](https://github.com/miroosama/react-native-wallet-keystore/actions/workflows/ci.yml)
5
+ [![license](https://img.shields.io/npm/l/react-native-wallet-keystore.svg)](./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,2 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+ </manifest>
@@ -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
+ }