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/package.json ADDED
@@ -0,0 +1,210 @@
1
+ {
2
+ "name": "react-native-wallet-keystore",
3
+ "version": "0.1.0",
4
+ "description": "Hardware-protected secp256k1 wallet keys for React Native \u2014 Secure Enclave and Android Keystore, with a viem adapter",
5
+ "main": "./lib/module/index.js",
6
+ "types": "./lib/typescript/src/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "react-native-wallet-keystore-source": "./src/index.tsx",
10
+ "types": "./lib/typescript/src/index.d.ts",
11
+ "default": "./lib/module/index.js"
12
+ },
13
+ "./viem": {
14
+ "react-native-wallet-keystore-source": "./src/viem.ts",
15
+ "types": "./lib/typescript/src/viem.d.ts",
16
+ "default": "./lib/module/viem.js"
17
+ },
18
+ "./package.json": "./package.json"
19
+ },
20
+ "typesVersions": {
21
+ "*": {
22
+ "viem": [
23
+ "./lib/typescript/src/viem.d.ts"
24
+ ]
25
+ }
26
+ },
27
+ "files": [
28
+ "src",
29
+ "lib",
30
+ "android",
31
+ "ios",
32
+ "cpp",
33
+ "*.podspec",
34
+ "react-native.config.js",
35
+ "!ios/build",
36
+ "!android/build",
37
+ "!android/gradle",
38
+ "!android/gradlew",
39
+ "!android/gradlew.bat",
40
+ "!android/local.properties",
41
+ "!**/__tests__",
42
+ "!**/__fixtures__",
43
+ "!**/__mocks__",
44
+ "!**/.*"
45
+ ],
46
+ "scripts": {
47
+ "example": "yarn workspace react-native-wallet-keystore-example",
48
+ "clean": "del-cli android/build example/android/build example/android/app/build example/ios/build lib",
49
+ "prepare": "bob build",
50
+ "typecheck": "tsc",
51
+ "lint": "eslint \"**/*.{js,ts,tsx}\"",
52
+ "test": "jest",
53
+ "release": "release-it --only-version"
54
+ },
55
+ "keywords": [
56
+ "react-native",
57
+ "ios",
58
+ "android",
59
+ "ethereum",
60
+ "wallet",
61
+ "secp256k1",
62
+ "viem",
63
+ "secure-enclave",
64
+ "android-keystore",
65
+ "biometrics",
66
+ "signing",
67
+ "keychain",
68
+ "web3"
69
+ ],
70
+ "repository": {
71
+ "type": "git",
72
+ "url": "git+https://github.com/miroosama/react-native-wallet-keystore.git"
73
+ },
74
+ "author": "Osama Miro <o.miro650@gmail.com> (https://github.com/miroosama)",
75
+ "license": "MIT",
76
+ "bugs": {
77
+ "url": "https://github.com/miroosama/react-native-wallet-keystore/issues"
78
+ },
79
+ "homepage": "https://github.com/miroosama/react-native-wallet-keystore#readme",
80
+ "publishConfig": {
81
+ "registry": "https://registry.npmjs.org/"
82
+ },
83
+ "devDependencies": {
84
+ "@commitlint/config-conventional": "^21.2.0",
85
+ "@eslint/compat": "^2.1.0",
86
+ "@eslint/eslintrc": "^3.3.6",
87
+ "@eslint/js": "^9.39.5",
88
+ "@jest/globals": "^29.7.0",
89
+ "@react-native/babel-preset": "0.86.2",
90
+ "@react-native/eslint-config": "0.86.2",
91
+ "@react-native/jest-preset": "0.86.2",
92
+ "@release-it/conventional-changelog": "^12.0.0",
93
+ "@types/react": "^19.2.0",
94
+ "commitlint": "^21.2.1",
95
+ "del-cli": "^7.0.0",
96
+ "eslint": "^9.39.5",
97
+ "eslint-config-prettier": "^10.1.8",
98
+ "eslint-plugin-ft-flow": "^3.0.11",
99
+ "eslint-plugin-prettier": "^5.5.6",
100
+ "jest": "^29.7.0",
101
+ "lefthook": "^2.1.10",
102
+ "prettier": "^3.9.6",
103
+ "react": "19.2.3",
104
+ "react-native": "0.86.2",
105
+ "react-native-builder-bob": "^0.43.1",
106
+ "release-it": "^21.0.1",
107
+ "turbo": "^2.10.8",
108
+ "typescript": "^6.0.3",
109
+ "viem": "^2.56.8"
110
+ },
111
+ "peerDependencies": {
112
+ "react": "*",
113
+ "react-native": "*",
114
+ "viem": ">=2.0.0"
115
+ },
116
+ "workspaces": [
117
+ "example"
118
+ ],
119
+ "engines": {
120
+ "node": ">=22.12"
121
+ },
122
+ "packageManager": "yarn@4.11.0",
123
+ "react-native-builder-bob": {
124
+ "source": "src",
125
+ "output": "lib",
126
+ "targets": [
127
+ [
128
+ "module",
129
+ {
130
+ "esm": true
131
+ }
132
+ ],
133
+ [
134
+ "typescript",
135
+ {
136
+ "project": "tsconfig.build.json"
137
+ }
138
+ ]
139
+ ]
140
+ },
141
+ "codegenConfig": {
142
+ "name": "WalletKeystoreSpec",
143
+ "type": "modules",
144
+ "jsSrcsDir": "src",
145
+ "android": {
146
+ "javaPackageName": "com.walletkeystore"
147
+ }
148
+ },
149
+ "prettier": {
150
+ "quoteProps": "consistent",
151
+ "singleQuote": true,
152
+ "tabWidth": 2,
153
+ "trailingComma": "es5",
154
+ "useTabs": false
155
+ },
156
+ "jest": {
157
+ "preset": "@react-native/jest-preset",
158
+ "testEnvironmentOptions": {
159
+ "customExportConditions": [
160
+ "require",
161
+ "react-native",
162
+ "react-native-wallet-keystore-source"
163
+ ]
164
+ },
165
+ "modulePathIgnorePatterns": [
166
+ "<rootDir>/example/node_modules",
167
+ "<rootDir>/lib/"
168
+ ]
169
+ },
170
+ "commitlint": {
171
+ "extends": [
172
+ "@commitlint/config-conventional"
173
+ ]
174
+ },
175
+ "release-it": {
176
+ "git": {
177
+ "commitMessage": "chore: release ${version}",
178
+ "tagName": "v${version}"
179
+ },
180
+ "npm": {
181
+ "publish": true
182
+ },
183
+ "github": {
184
+ "release": true
185
+ },
186
+ "plugins": {
187
+ "@release-it/conventional-changelog": {
188
+ "preset": {
189
+ "name": "angular"
190
+ }
191
+ }
192
+ }
193
+ },
194
+ "create-react-native-library": {
195
+ "type": "turbo-module",
196
+ "languages": "kotlin-objc",
197
+ "tools": [
198
+ "eslint",
199
+ "jest",
200
+ "lefthook",
201
+ "release-it"
202
+ ],
203
+ "version": "0.63.1"
204
+ },
205
+ "peerDependenciesMeta": {
206
+ "viem": {
207
+ "optional": true
208
+ }
209
+ }
210
+ }
@@ -0,0 +1,43 @@
1
+ import { TurboModuleRegistry, type TurboModule } from 'react-native';
2
+
3
+ /**
4
+ * Codegen contract.
5
+ *
6
+ * Everything crossing the bridge is a plain `string` on purpose: codegen
7
+ * supports a narrow set of types, and string-literal unions are handled
8
+ * inconsistently across React Native versions. The typed surface lives in
9
+ * `keystore.ts`, which narrows these values.
10
+ */
11
+ export interface Spec extends TurboModule {
12
+ getBiometryType(): Promise<string>;
13
+ authenticate(reason: string, policy: string): Promise<boolean>;
14
+
15
+ storeSecret(
16
+ keyId: string,
17
+ secretHex: string,
18
+ policy: string,
19
+ invalidation: string
20
+ ): Promise<void>;
21
+ getSecret(keyId: string, reason: string): Promise<string>;
22
+ hasSecret(keyId: string): Promise<boolean>;
23
+ deleteSecret(keyId: string): Promise<void>;
24
+
25
+ generateKey(
26
+ keyId: string,
27
+ policy: string,
28
+ invalidation: string
29
+ ): Promise<string>;
30
+ importPrivateKey(
31
+ keyId: string,
32
+ privateKeyHex: string,
33
+ policy: string,
34
+ invalidation: string
35
+ ): Promise<string>;
36
+ getPublicKey(keyId: string): Promise<string>;
37
+
38
+ /** Returns 65 bytes of hex: `r || s || v`, low-s normalized, `v` of 27/28. */
39
+ signDigest(keyId: string, digestHex: string, reason: string): Promise<string>;
40
+ exportPrivateKey(keyId: string, reason: string): Promise<string>;
41
+ }
42
+
43
+ export default TurboModuleRegistry.getEnforcing<Spec>('WalletKeystore');
package/src/errors.ts ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Stable, cross-platform error codes.
3
+ *
4
+ * The distinctions are load-bearing: `USER_CANCELED` means re-prompt,
5
+ * `NOT_ENROLLED` means send the user to Settings, and `KEY_INVALIDATED` means
6
+ * the secret is gone and recovery must start. Collapsing them makes callers
7
+ * guess.
8
+ */
9
+ const CODES = [
10
+ 'NOT_AVAILABLE',
11
+ 'NOT_ENROLLED',
12
+ 'USER_CANCELED',
13
+ 'USER_FALLBACK',
14
+ 'LOCKOUT',
15
+ 'LOCKOUT_PERMANENT',
16
+ 'SYSTEM_CANCEL',
17
+ 'KEY_NOT_FOUND',
18
+ 'KEY_ALREADY_EXISTS',
19
+ 'KEY_INVALIDATED',
20
+ 'STORAGE_ERROR',
21
+ 'INVALID_KEY',
22
+ 'UNKNOWN',
23
+ ] as const;
24
+
25
+ export type KeystoreErrorCode = (typeof CODES)[number];
26
+
27
+ const KNOWN_CODES: ReadonlySet<string> = new Set(CODES);
28
+
29
+ export class KeystoreError extends Error {
30
+ readonly code: KeystoreErrorCode;
31
+
32
+ /** The raw native code, kept even when `code` has fallen back to `UNKNOWN`. */
33
+ readonly nativeCode?: string;
34
+
35
+ constructor(
36
+ code: KeystoreErrorCode,
37
+ message: string,
38
+ options?: { nativeCode?: string; cause?: unknown }
39
+ ) {
40
+ super(message);
41
+ this.name = 'KeystoreError';
42
+ this.code = code;
43
+ this.nativeCode = options?.nativeCode;
44
+
45
+ if (options && 'cause' in options) {
46
+ // Assigned rather than passed to super() to support pre-ES2022 targets.
47
+ (this as { cause?: unknown }).cause = options.cause;
48
+ }
49
+
50
+ // Without this, extending a built-in breaks `instanceof` once compiled to
51
+ // ES5 — silently, and only for consumers.
52
+ Object.setPrototypeOf(this, KeystoreError.prototype);
53
+
54
+ const capture = (
55
+ Error as unknown as {
56
+ captureStackTrace?: (t: object, c?: unknown) => void;
57
+ }
58
+ ).captureStackTrace;
59
+ if (typeof capture === 'function') {
60
+ capture(this, KeystoreError);
61
+ }
62
+ }
63
+ }
64
+
65
+ function isKeystoreErrorCode(value: unknown): value is KeystoreErrorCode {
66
+ return typeof value === 'string' && KNOWN_CODES.has(value);
67
+ }
68
+
69
+ /**
70
+ * Normalizes any rejection into a `KeystoreError`.
71
+ *
72
+ * React Native surfaces `reject(code, message)` as an `Error` carrying `code`,
73
+ * but a JS-side failure can produce any shape at all, so everything degrades to
74
+ * `UNKNOWN` rather than throwing while building the error.
75
+ */
76
+ export function toKeystoreError(value: unknown): KeystoreError {
77
+ if (value instanceof KeystoreError) {
78
+ return value;
79
+ }
80
+
81
+ const raw = (value as { code?: unknown } | null | undefined)?.code;
82
+ const message =
83
+ (value as { message?: unknown } | null | undefined)?.message ??
84
+ 'The keystore operation failed.';
85
+
86
+ return new KeystoreError(
87
+ isKeystoreErrorCode(raw) ? raw : 'UNKNOWN',
88
+ typeof message === 'string' ? message : String(message),
89
+ {
90
+ nativeCode: typeof raw === 'string' ? raw : undefined,
91
+ cause: value,
92
+ }
93
+ );
94
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,6 @@
1
+ export * from './keystore';
2
+ export { KeystoreError, type KeystoreErrorCode } from './errors';
3
+
4
+ // The viem adapter is intentionally not re-exported. It ships as the
5
+ // `react-native-wallet-keystore/viem` subpath with viem as an optional peer, so
6
+ // importing the core API never requires viem to be installed.
@@ -0,0 +1,317 @@
1
+ import NativeWalletKeystore from './NativeWalletKeystore';
2
+ import { KeystoreError, toKeystoreError } from './errors';
3
+
4
+ /**
5
+ * Android reports which biometric *hardware* exists, not which modality is
6
+ * enrolled, so it returns `'biometric'` when more than one is present.
7
+ */
8
+ export type BiometryType =
9
+ | 'faceId'
10
+ | 'touchId'
11
+ | 'opticId'
12
+ | 'fingerprint'
13
+ | 'face'
14
+ | 'iris'
15
+ | 'biometric'
16
+ | 'none';
17
+
18
+ export type AuthPolicy = 'biometricOnly' | 'biometricOrPasscode';
19
+
20
+ /**
21
+ * Whether the wrapping key is destroyed when biometric enrollment changes.
22
+ *
23
+ * Orthogonal to {@link AuthPolicy} on purpose: `'biometricOnly'` must never
24
+ * imply invalidation, or a user adding a fingerprint silently destroys their
25
+ * wallet. Opting in is explicit.
26
+ */
27
+ export type InvalidationPolicy = 'onEnrollmentChange' | 'never';
28
+
29
+ export const DEFAULT_AUTH_POLICY: AuthPolicy = 'biometricOrPasscode';
30
+ export const DEFAULT_INVALIDATION_POLICY: InvalidationPolicy = 'never';
31
+
32
+ /** Uncompressed SEC1 public key: `0x04` followed by 64 bytes. */
33
+ export type PublicKeyHex = `0x${string}`;
34
+
35
+ /** 65-byte Ethereum signature: `r || s || v`, with `v` 27 or 28. */
36
+ export type SignatureHex = `0x${string}`;
37
+
38
+ type KeyOptions = {
39
+ policy?: AuthPolicy;
40
+ invalidation?: InvalidationPolicy;
41
+ };
42
+
43
+ const BIOMETRY_TYPES: ReadonlySet<string> = new Set<BiometryType>([
44
+ 'faceId',
45
+ 'touchId',
46
+ 'opticId',
47
+ 'fingerprint',
48
+ 'face',
49
+ 'iris',
50
+ 'biometric',
51
+ 'none',
52
+ ]);
53
+
54
+ const HEX_PATTERN = /^(?:[0-9a-fA-F]{2})+$/;
55
+ const HEX_32_BYTES = /^(?:0x)?[0-9a-fA-F]{64}$/;
56
+
57
+ function strip0x(value: string): string {
58
+ return value.startsWith('0x') || value.startsWith('0X')
59
+ ? value.slice(2)
60
+ : value;
61
+ }
62
+
63
+ function prefix0x(value: string): `0x${string}` {
64
+ return (value.startsWith('0x') ? value : `0x${value}`) as `0x${string}`;
65
+ }
66
+
67
+ function assertKeyId(keyId: string): void {
68
+ if (typeof keyId !== 'string' || keyId.trim() === '') {
69
+ throw new KeystoreError('UNKNOWN', 'A non-empty `keyId` is required.');
70
+ }
71
+ }
72
+
73
+ function assertReason(reason: string, action: string): void {
74
+ // iOS raises on an empty localizedReason rather than failing gracefully, so
75
+ // this is checked before the bridge to keep both platforms consistent.
76
+ if (typeof reason !== 'string' || reason.trim() === '') {
77
+ throw new KeystoreError(
78
+ 'UNKNOWN',
79
+ `A non-empty \`reason\` is required to ${action}.`
80
+ );
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Which biometric modality the hardware supports, whether or not anything is
86
+ * enrolled. Resolves `'none'` when there is no hardware; never rejects.
87
+ *
88
+ * Enrollment state comes from {@link authenticate} via `NOT_ENROLLED`.
89
+ */
90
+ export async function getBiometryType(): Promise<BiometryType> {
91
+ const type = await NativeWalletKeystore.getBiometryType();
92
+ // A native layer newer than this JS could return a modality we don't know.
93
+ return (BIOMETRY_TYPES.has(type) ? type : 'biometric') as BiometryType;
94
+ }
95
+
96
+ /**
97
+ * Prompts for device-owner authentication.
98
+ *
99
+ * A UX gate, not a security boundary — the boolean can be faked by a
100
+ * compromised bundle. Use {@link signDigest} where it actually matters.
101
+ *
102
+ * Never resolves `false`; every failure rejects.
103
+ *
104
+ * @throws {KeystoreError}
105
+ */
106
+ export async function authenticate(
107
+ reason: string,
108
+ policy: AuthPolicy = DEFAULT_AUTH_POLICY
109
+ ): Promise<boolean> {
110
+ try {
111
+ assertReason(reason, 'authenticate');
112
+ return await NativeWalletKeystore.authenticate(reason, policy);
113
+ } catch (error) {
114
+ throw toKeystoreError(error);
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Stores a secret encrypted under a hardware-bound wrapping key.
120
+ *
121
+ * @throws {KeystoreError} `KEY_ALREADY_EXISTS` if `keyId` is taken; overwriting
122
+ * is always deliberate.
123
+ */
124
+ export async function storeSecret(
125
+ keyId: string,
126
+ secretHex: string,
127
+ options: KeyOptions = {}
128
+ ): Promise<void> {
129
+ try {
130
+ assertKeyId(keyId);
131
+
132
+ if (typeof secretHex !== 'string' || !HEX_PATTERN.test(secretHex)) {
133
+ throw new KeystoreError(
134
+ 'UNKNOWN',
135
+ '`secretHex` must be a non-empty, even-length hex string.'
136
+ );
137
+ }
138
+
139
+ await NativeWalletKeystore.storeSecret(
140
+ keyId,
141
+ secretHex,
142
+ options.policy ?? DEFAULT_AUTH_POLICY,
143
+ options.invalidation ?? DEFAULT_INVALIDATION_POLICY
144
+ );
145
+ } catch (error) {
146
+ throw toKeystoreError(error);
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Authenticates and returns the stored secret as hex.
152
+ *
153
+ * The prompt is raised by the keystore as a precondition of using the wrapping
154
+ * key, so it cannot be bypassed from JS.
155
+ *
156
+ * @throws {KeystoreError} `KEY_NOT_FOUND`, `KEY_INVALIDATED`, or any auth code.
157
+ */
158
+ export async function getSecret(
159
+ keyId: string,
160
+ reason: string
161
+ ): Promise<string> {
162
+ try {
163
+ assertKeyId(keyId);
164
+ assertReason(reason, 'read a secret');
165
+ return await NativeWalletKeystore.getSecret(keyId, reason);
166
+ } catch (error) {
167
+ throw toKeystoreError(error);
168
+ }
169
+ }
170
+
171
+ /** Whether a secret is stored under `keyId`. Does not authenticate. */
172
+ export async function hasSecret(keyId: string): Promise<boolean> {
173
+ try {
174
+ assertKeyId(keyId);
175
+ return await NativeWalletKeystore.hasSecret(keyId);
176
+ } catch (error) {
177
+ throw toKeystoreError(error);
178
+ }
179
+ }
180
+
181
+ /** Removes the secret and its wrapping key. Idempotent. */
182
+ export async function deleteSecret(keyId: string): Promise<void> {
183
+ try {
184
+ assertKeyId(keyId);
185
+ await NativeWalletKeystore.deleteSecret(keyId);
186
+ } catch (error) {
187
+ throw toKeystoreError(error);
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Generates a secp256k1 keypair in hardware-wrapped storage.
193
+ *
194
+ * Entropy comes from the platform CSPRNG, never from JavaScript, and the
195
+ * private key never crosses the bridge.
196
+ *
197
+ * @returns The uncompressed public key.
198
+ */
199
+ export async function generateKey(
200
+ keyId: string,
201
+ options: KeyOptions = {}
202
+ ): Promise<PublicKeyHex> {
203
+ try {
204
+ assertKeyId(keyId);
205
+ return prefix0x(
206
+ await NativeWalletKeystore.generateKey(
207
+ keyId,
208
+ options.policy ?? DEFAULT_AUTH_POLICY,
209
+ options.invalidation ?? DEFAULT_INVALIDATION_POLICY
210
+ )
211
+ );
212
+ } catch (error) {
213
+ throw toKeystoreError(error);
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Imports an existing secp256k1 private key.
219
+ *
220
+ * @throws {KeystoreError} `INVALID_KEY` unless the key is in [1, n-1]. Keys
221
+ * outside that range are rejected rather than clamped.
222
+ */
223
+ export async function importPrivateKey(
224
+ keyId: string,
225
+ privateKeyHex: string,
226
+ options: KeyOptions = {}
227
+ ): Promise<PublicKeyHex> {
228
+ try {
229
+ assertKeyId(keyId);
230
+
231
+ if (
232
+ typeof privateKeyHex !== 'string' ||
233
+ !HEX_32_BYTES.test(privateKeyHex)
234
+ ) {
235
+ throw new KeystoreError(
236
+ 'INVALID_KEY',
237
+ 'A private key must be exactly 32 bytes of hex.'
238
+ );
239
+ }
240
+
241
+ // The range check stays native, where the curve order is already at hand.
242
+ return prefix0x(
243
+ await NativeWalletKeystore.importPrivateKey(
244
+ keyId,
245
+ strip0x(privateKeyHex),
246
+ options.policy ?? DEFAULT_AUTH_POLICY,
247
+ options.invalidation ?? DEFAULT_INVALIDATION_POLICY
248
+ )
249
+ );
250
+ } catch (error) {
251
+ throw toKeystoreError(error);
252
+ }
253
+ }
254
+
255
+ /** The uncompressed public key. Does not authenticate. */
256
+ export async function getPublicKey(keyId: string): Promise<PublicKeyHex> {
257
+ try {
258
+ assertKeyId(keyId);
259
+ return prefix0x(await NativeWalletKeystore.getPublicKey(keyId));
260
+ } catch (error) {
261
+ throw toKeystoreError(error);
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Authenticates, then signs a 32-byte digest.
267
+ *
268
+ * Only a digest crosses the boundary — never a message or a transaction.
269
+ * Keccak and EIP-191/712/155 encoding stay in JS, which keeps this module
270
+ * curve-specific but chain-agnostic.
271
+ *
272
+ * @returns 65 bytes, `r || s || v`, low-s normalized per EIP-2 with `v` of
273
+ * 27/28 — byte-identical to viem.
274
+ */
275
+ export async function signDigest(
276
+ keyId: string,
277
+ digestHex: string,
278
+ reason: string
279
+ ): Promise<SignatureHex> {
280
+ try {
281
+ assertKeyId(keyId);
282
+
283
+ if (typeof digestHex !== 'string' || !HEX_32_BYTES.test(digestHex)) {
284
+ throw new KeystoreError(
285
+ 'INVALID_KEY',
286
+ 'A digest must be exactly 32 bytes of hex. Hash the message in JS first.'
287
+ );
288
+ }
289
+
290
+ assertReason(reason, 'sign');
291
+
292
+ return prefix0x(
293
+ await NativeWalletKeystore.signDigest(keyId, strip0x(digestHex), reason)
294
+ );
295
+ } catch (error) {
296
+ throw toKeystoreError(error);
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Authenticates, then returns the raw private key.
302
+ *
303
+ * For user-initiated backup only. The result lands in the JS heap where it
304
+ * cannot be zeroed, so prefer {@link signDigest} for everyday use.
305
+ */
306
+ export async function exportPrivateKey(
307
+ keyId: string,
308
+ reason: string
309
+ ): Promise<string> {
310
+ try {
311
+ assertKeyId(keyId);
312
+ assertReason(reason, 'export a private key');
313
+ return prefix0x(await NativeWalletKeystore.exportPrivateKey(keyId, reason));
314
+ } catch (error) {
315
+ throw toKeystoreError(error);
316
+ }
317
+ }