expo-secure-keypad-jsi 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 (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +393 -0
  3. package/SecureKeypadJsi.podspec +45 -0
  4. package/android/CMakeLists.txt +28 -0
  5. package/android/build.gradle +58 -0
  6. package/android/src/main/AndroidManifest.xml +2 -0
  7. package/android/src/main/cpp/JniBridge.cpp +118 -0
  8. package/android/src/main/java/expo/modules/securekeypadjsi/KeyboardCanvasView.kt +291 -0
  9. package/android/src/main/java/expo/modules/securekeypadjsi/KeypadCanvasView.kt +183 -0
  10. package/android/src/main/java/expo/modules/securekeypadjsi/PinSessionHandle.kt +54 -0
  11. package/android/src/main/java/expo/modules/securekeypadjsi/SecureKeypadJsiModule.kt +35 -0
  12. package/android/src/main/java/expo/modules/securekeypadjsi/SecureKeypadJsiView.kt +317 -0
  13. package/android/src/main/java/expo/modules/securekeypadjsi/ThemeColor.kt +27 -0
  14. package/android/src/main/java/expo/modules/securekeypadjsi/ThemedKeypadView.kt +78 -0
  15. package/build/SecureKeypad.d.ts +51 -0
  16. package/build/SecureKeypad.d.ts.map +1 -0
  17. package/build/SecureKeypad.js +51 -0
  18. package/build/SecureKeypad.js.map +1 -0
  19. package/build/SecureKeypadJsi.types.d.ts +94 -0
  20. package/build/SecureKeypadJsi.types.d.ts.map +1 -0
  21. package/build/SecureKeypadJsi.types.js +2 -0
  22. package/build/SecureKeypadJsi.types.js.map +1 -0
  23. package/build/SecureKeypadJsiView.d.ts +6 -0
  24. package/build/SecureKeypadJsiView.d.ts.map +1 -0
  25. package/build/SecureKeypadJsiView.js +10 -0
  26. package/build/SecureKeypadJsiView.js.map +1 -0
  27. package/build/index.d.ts +5 -0
  28. package/build/index.d.ts.map +1 -0
  29. package/build/index.js +5 -0
  30. package/build/index.js.map +1 -0
  31. package/common/CMakeLists.txt +49 -0
  32. package/common/include/esk/Base64.h +20 -0
  33. package/common/include/esk/Cleanse.h +17 -0
  34. package/common/include/esk/Error.h +37 -0
  35. package/common/include/esk/KeypadCore.h +69 -0
  36. package/common/include/esk/PinEncryptor.h +39 -0
  37. package/common/include/esk/PublicKeyValidator.h +37 -0
  38. package/common/include/esk/SecureBuffer.h +60 -0
  39. package/common/include/esk/SecureRandom.h +24 -0
  40. package/common/include/esk/esk_c_api.h +61 -0
  41. package/common/src/Base64.cpp +43 -0
  42. package/common/src/Error.cpp +21 -0
  43. package/common/src/KeypadCore.cpp +105 -0
  44. package/common/src/PinEncryptor.cpp +110 -0
  45. package/common/src/PublicKeyValidator.cpp +111 -0
  46. package/common/src/SecureBuffer.cpp +108 -0
  47. package/common/src/SecureRandom.cpp +42 -0
  48. package/common/src/esk_c_api.cpp +158 -0
  49. package/docs/BUILD.ko.md +33 -0
  50. package/docs/BUILD.md +36 -0
  51. package/docs/README.ko.md +286 -0
  52. package/expo-module.config.json +10 -0
  53. package/ios/EskKeypadBridge.h +35 -0
  54. package/ios/EskKeypadBridge.mm +77 -0
  55. package/ios/KeyboardCoreGraphicsView.swift +257 -0
  56. package/ios/KeypadCoreGraphicsView.swift +197 -0
  57. package/ios/SecureKeypadJsiModule.swift +26 -0
  58. package/ios/SecureKeypadJsiView.swift +278 -0
  59. package/package.json +80 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 0610studio and expo-secure-keypad-jsi contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,393 @@
1
+ # expo-secure-keypad-jsi
2
+
3
+ 한국어 문서: [docs/README.ko.md](docs/README.ko.md)
4
+
5
+ A secure keypad for Expo apps: a numeric PIN pad (`'digit'`) and a QWERTY
6
+ keyboard (`'full'`). What the user types never enters the JavaScript runtime.
7
+ Touches are handled by a native view, the entered value accumulates in an
8
+ `mlock`'d C++ buffer, and only the **ciphertext** — RSA-OAEP against your
9
+ server's public key — is handed to JS.
10
+
11
+ <p align="center">
12
+ <img src="docs/demo.gif" width="260" alt="Shuffled PIN pad running in the example app">
13
+ <br>
14
+ <em>Shuffled PIN pad in the example app (iOS)</em>
15
+ </p>
16
+
17
+ ## Features
18
+
19
+ - The mapping between touch coordinates and key values exists only in native code. JS cannot know the shuffled layout.
20
+ - The entered value lives in an `mlock`'d C++ buffer and is wiped with `OPENSSL_cleanse` immediately after use.
21
+ - Only RSA-OAEP (SHA-256) ciphertext reaches JS.
22
+ - The envelope carries `kid`, `nonce` and `timestamp` so the server can detect key misconfiguration and replays.
23
+ - The buffer is wiped whenever the app backgrounds or the view leaves the window, and JS is told the count is 0. This is unconditional — there is no prop to turn it off, so a half-entered PIN never survives a trip to the app switcher.
24
+ - On Android, touches delivered through an obscured window (tapjacking) are rejected.
25
+
26
+ ## Non-goals
27
+
28
+ This library only keeps the entered value out of the JS runtime. Device
29
+ integrity, hooking and debugger detection, screen-capture blocking and JS
30
+ bundle integrity are app-wide concerns that belong to the host app and the
31
+ server — see [Threat model](#threat-model) for what is and is not defended,
32
+ and what to use instead.
33
+
34
+ > **Trust boundary**: the server public key comes from the JS bundle. An
35
+ > attacker who can tamper with the bundle can swap the key or draw their own
36
+ > input field instead of this keypad. The defence on that path is bundle
37
+ > integrity; this library only helps the server notice a wrong key via `kid`.
38
+
39
+ ## Requirements
40
+
41
+ - Expo SDK 57+, React Native 0.86+, New Architecture only
42
+ - A development build (`expo run:*`) or an EAS build, because the module ships
43
+ native code. Expo Go's prebuilt binary does not contain it, and an OTA update
44
+ replaces only JS, so it cannot add a native module either. Once the module is
45
+ in a build, later JS-only changes can still ship over OTA
46
+ - iOS 16.4+
47
+ - Android 7.0 (API 24)+ — the Expo SDK 57 default; the module adds no floor of
48
+ its own. Ships `arm64-v8a`, `armeabi-v7a`, `x86`, `x86_64`
49
+
50
+ ## Installation
51
+
52
+ ```sh
53
+ pnpm expo install expo-secure-keypad-jsi
54
+
55
+ # A development build. `expo run:*` runs pod install for you.
56
+ pnpm expo run:ios # or: pnpm expo run:android
57
+ ```
58
+
59
+ ## Usage
60
+
61
+ ```tsx
62
+ import { SecureKeypad } from 'expo-secure-keypad-jsi';
63
+ import { useState } from 'react';
64
+
65
+ export function PinScreen({ serverPublicKeyPem }: { serverPublicKeyPem: string }) {
66
+ const [count, setCount] = useState(0);
67
+
68
+ return (
69
+ <SecureKeypad
70
+ publicKey={serverPublicKeyPem} // RSA-2048 or stronger, PEM (SubjectPublicKeyInfo)
71
+ minLength={6}
72
+ maxLength={6}
73
+ shuffle="mount"
74
+ autoSubmit
75
+ onDigitCountChanged={setCount} // for the masked-dot UI
76
+ onComplete={(ciphertext) => {
77
+ fetch('/api/verify-pin', { method: 'POST', body: ciphertext });
78
+ }}
79
+ onError={(e) => console.warn(e.phase, e.code)}
80
+ />
81
+ );
82
+ }
83
+ ```
84
+
85
+ ### Props
86
+
87
+ | Prop | Type | Default | Description |
88
+ |---|---|---|---|
89
+ | `publicKey` | `string` | required | RSA 2048–8192 bit PEM. Weaker keys are rejected |
90
+ | `keypadType` | `'digit' \| 'full'` | `'digit'` | `'full'` is the QWERTY keyboard |
91
+ | `minLength` / `maxLength` | `number` | `4` / `6` (digit), `4` / `64` (full) | Valid range is 4–12 (digit) and 4–64 (full). An out-of-range **prop value** is clamped into it (`maxLength={20}` on the digit pad behaves as 12), and a `minLength` above `maxLength` is lowered to it. Once the input reaches `maxLength`, further key presses are ignored without an error |
92
+ | `shuffle` | `'mount' \| 'perKey' \| 'off'` | `'mount'` | When the layout is reshuffled |
93
+ | `autoSubmit` | `boolean` | `true` (digit), `false` (full) | Encrypt automatically once `maxLength` is reached |
94
+ | `theme` | `KeypadTheme` | see [Theme](#theme) | Colours, corner radius and text size |
95
+ | `accessory` | `ReactNode` | | React content rendered directly above the keypad. The place for a masked-length indicator or a confirm button when the keypad covers the field it fills (bottom sheets) |
96
+ | `style` | `StyleProp<ViewStyle>` | see [Sizing](#sizing) | Applied to the keypad, or to the container when `accessory` is set |
97
+
98
+ ### Accessibility
99
+
100
+ **This keypad does not support screen readers on either platform, and there is
101
+ no option to enable it.** The keys are canvas glyphs with no child views, so no
102
+ key is ever an accessibility node, and the container is hidden from a11y
103
+ services and autofill unconditionally.
104
+
105
+ The reason is that an Android `AccessibilityService` can read the node tree and
106
+ input events of other apps — the standard keylogging route — and an app cannot
107
+ tell a real screen reader from a malicious one. If you must serve screen-reader
108
+ users, provide a separate entry path.
109
+
110
+ ### Events
111
+
112
+ | Event | Argument | Description |
113
+ |---|---|---|
114
+ | `onComplete` | `string` | The ciphertext envelope JSON. Send it to your server |
115
+ | `onDigitCountChanged` | `number` | Current input length |
116
+ | `onError` | `{ code, phase }` | `phase`: `'arm'` (public key rejected), `'submit'` (encryption failed), `'input'` (Android rejected a touch delivered through an obscured window; `code` is `ERR_OBSCURED_TOUCH`) |
117
+
118
+ An `'input'` error is informational, not a threat detection. When an overlay app
119
+ such as a screen dimmer is active, Android silently drops the touch — use this
120
+ to tell the user why the keypad is not responding.
121
+
122
+ Ref API: `clear()`, `submit()`
123
+
124
+ Examples: [InlineDemo](example/demos/InlineDemo.tsx),
125
+ [FullKeyboardDemo](example/demos/FullKeyboardDemo.tsx),
126
+ [AccessoryDemo](example/demos/AccessoryDemo.tsx),
127
+ [BottomSheetDemo](example/demos/BottomSheetDemo.tsx)
128
+
129
+ ### QWERTY keyboard (`keypadType: 'full'`)
130
+
131
+ A password keyboard for upper- and lowercase letters, digits, and the 32 ASCII
132
+ specials (``!@#$%^&*()-_=+[]{}\|;:'",.<>?/`~``). Space is not accepted.
133
+
134
+ - Layout: digit row / `qwertyuiop` / `asdfghjkl` / `⇧ zxcvbnm ⌫` / `[!#1] [✕] [⏎]`.
135
+ `!#1` switches to the symbol layer
136
+ - Shuffle: the letter rows keep the standard QWERTY order while the digit row is
137
+ fully shuffled, and each letter row gets one blank dummy key at a random slot.
138
+ `'perKey'` redraws after every keystroke
139
+ - Shift: released after one letter, double tap for caps lock. Shift state and
140
+ the active layer exist only in native code
141
+ - Submit: passwords are variable-length, so `autoSubmit` defaults to `false`
142
+ and the `⏎` key submits
143
+ - No magnified key preview — it would expose the input to a screen recording
144
+
145
+ ### Theme
146
+
147
+ All colours are `#RRGGBB` or `#RRGGBBAA` (alpha last, interpreted identically on
148
+ both platforms; colour names are rejected). Sizes are density-independent (dp on
149
+ Android, points on iOS), so the same number looks the same on both.
150
+
151
+ | Field | Type | Default | Notes |
152
+ |---|---|---|---|
153
+ | `keyColor` | `string` | `#1C1C1E` | Key background |
154
+ | `keyTextColor` | `string` | `#FFFFFF` | Digit and letter glyphs |
155
+ | `actionTextColor` | `string` | `#8E8E93` | Action keys (`⌫`, `✕`, `⇧`, `!#1`, `⏎`) |
156
+ | `cornerRadius` | `number` | `12` (digit), `8` (full) | Key corner radius |
157
+ | `digitTextSize` | `number` | `32` | Base glyph size; despite the name it drives `keypadType: 'full'` too. Each kind of key scales off it — digit keys 1×, action keys 0.7×; on the QWERTY keyboard characters 0.6× and action keys 0.5×. The factors are the same on both platforms |
158
+ | `pressedHighlight` | `boolean` | `true` | A held key dims to 70% opacity. `false` disables the press highlight entirely |
159
+ | `fontFamily` | `string` | system font | Font for the digit / character glyphs. Any name the platform already resolves: a family registered by `expo-font` (`useFonts` / `loadAsync`), a font bundled at build time, or a system family. An unresolvable name falls back to the system font instead of throwing |
160
+
161
+ `fontFamily` covers only the glyphs drawn from a value — digits, letters,
162
+ symbols. The action glyphs (`⌫`, `✕`, `⇧`, `⏎`) always render in the system
163
+ font: most custom fonts have no glyph for them, and a missing glyph would draw
164
+ as tofu (□) on an unlabelled key. For `keypadType: 'full'`, pick a font that
165
+ covers all of printable ASCII (0x21~0x7E) or some keys will show tofu.
166
+ `example/demos/FontDemo.tsx` is a working screen with two `expo-font` families.
167
+
168
+ There is no `backgroundColor` theme field: the keypad is a regular view, so its
169
+ own `style={{ backgroundColor }}` carries it. Left unset (the default) the view
170
+ is transparent and the gaps between keys show whatever is behind the keypad.
171
+
172
+ ### Sizing
173
+
174
+ If you give neither a height, `flex`, nor `aspectRatio`, the wrapper applies
175
+ `aspectRatio: 3/4` for `'digit'` and `4/3` for `'full'`. Fabric sizes views
176
+ purely from style, so this default comes from the JS wrapper — if you use the
177
+ low-level `SecureKeypadJsiView` directly you must size it yourself.
178
+
179
+ With an `accessory`, `style` applies to the container that wraps the accessory
180
+ and the keypad, and the keypad fills whatever space the accessory leaves.
181
+ `accessory` is ordinary React content: it never receives the entered value, and
182
+ the only thing you can display is the length from `onDigitCountChanged`.
183
+
184
+ ```tsx
185
+ <SecureKeypad
186
+ publicKey={pem}
187
+ style={{ height: 380 }}
188
+ accessory={<Text style={styles.mask}>{'●'.repeat(count)}</Text>}
189
+ onDigitCountChanged={setCount}
190
+ />
191
+ ```
192
+
193
+ ## Server-side decryption
194
+
195
+ This is the JSON you receive from `onComplete`:
196
+
197
+ ```json
198
+ { "kid": "97349c2e876fcbf2", "nonce": "…base64…", "ct": "…base64…" }
199
+ ```
200
+
201
+ - `kid`: public key identifier, so the server can diagnose a key mismatch
202
+ - `nonce`: lets you drop replays before decrypting. It must match the nonce inside the plaintext
203
+ - `ct`: RSA-OAEP (SHA-256, MGF1-SHA256) ciphertext. 256 bytes for RSA-2048
204
+
205
+ There is deliberately no algorithm or version field in the envelope. Anything
206
+ outside the ciphertext can be forged, so the server pins the algorithm and reads
207
+ the version from the decrypted plaintext.
208
+
209
+ Decrypting `ct` yields a fixed 96-byte plaintext. `'digit'` and `'full'` share
210
+ the same structure.
211
+
212
+ | Offset | Size | Field | Notes |
213
+ |---|---|---|---|
214
+ | 0 | 2 | magic `"SK"` | Fixed. Confirms the plaintext came from this library |
215
+ | 2 | 1 | version = 2 | Version of this 96-byte layout. Reject anything you don't know |
216
+ | 3 | 1 | secretLength (4–64) | Actual input length; where to cut `secret` |
217
+ | 4 | 64 | secret (ASCII, zero-padded) | The input. Printable ASCII (0x21–0x7E), rest is zero |
218
+ | 68 | 16 | nonce | Single-use random. Must match the envelope's `nonce` |
219
+ | 84 | 8 | timestamp (unix seconds, big endian) | When it was encrypted; for the freshness check |
220
+ | 92 | 4 | reserved | Always zero. Room for the next version |
221
+
222
+ Node example (full implementation: [decrypt.mjs](example/scripts/decrypt.mjs)):
223
+
224
+ ```js
225
+ import crypto from 'node:crypto';
226
+
227
+ const msg = JSON.parse(body);
228
+ const plain = crypto.privateDecrypt(
229
+ { key: privateKeyPem,
230
+ padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
231
+ oaepHash: 'sha256' },
232
+ Buffer.from(msg.ct, 'base64')
233
+ );
234
+ if (plain.subarray(0, 2).toString() !== 'SK' || plain[2] !== 2) throw new Error('bad payload');
235
+ const secret = plain.subarray(4, 4 + plain[3]).toString('ascii');
236
+ const nonce = plain.subarray(68, 84).toString('base64');
237
+ const timestamp = Number(plain.readBigUInt64BE(84));
238
+ ```
239
+
240
+ What the server must verify:
241
+
242
+ - magic and version
243
+ - `nonce` not seen before (replay defence), and the JSON `nonce` matches the plaintext nonce
244
+ - `timestamp` freshness (say ±10 minutes)
245
+ - wipe the plaintext secret as soon as it has been checked
246
+
247
+ `timestamp` comes from the **device clock** (`time()`). Users with a wrong clock
248
+ will fail the freshness check, so return that failure as its own code — it lets
249
+ the app show "check your device time" — and pick the window with the trade-off
250
+ between replay risk and user drop-off in mind. To keep replay defence off the
251
+ device clock entirely, retain nonces for longer than the freshness window.
252
+
253
+ Other languages: Java `RSA/ECB/OAEPWithSHA-256AndMGF1Padding`, Python
254
+ `cryptography` `OAEP(mgf=MGF1(SHA256), algorithm=SHA256)`, Go
255
+ `rsa.DecryptOAEP(sha256, …)`.
256
+
257
+ Checking locally:
258
+
259
+ ```sh
260
+ node example/scripts/gen-keys.mjs # generate a demo keypair, paste the public key into example/demos/demoKey.ts
261
+ node example/scripts/decrypt.mjs '<JSON>' # decrypt an envelope shown by the demo app
262
+ ```
263
+
264
+ ## Architecture
265
+
266
+ ```
267
+ touch → native view (Canvas / CoreGraphics) → key value
268
+ → C++ SecureBuffer (mlock + OPENSSL_cleanse)
269
+ → RSA-OAEP → base64 JSON → JS → server
270
+ ```
271
+
272
+ | Layer | Android | iOS |
273
+ |---|---|---|
274
+ | Rendering | Kotlin `View` + Canvas (no TextView) | Swift `UIView` + CoreGraphics (no UILabel) |
275
+ | Bridge | JNI → C ABI | Objective-C++ → C ABI |
276
+ | Core | shared C++ (`common/`) | same |
277
+ | Crypto | OpenSSL 3.6.2 (`openssl-static` prefab 3.6.2-2, statically linked) | OpenSSL 3.6.2 (`OpenSSL-Universal` 3.6.2000, exact pin) |
278
+
279
+ No function in the native bridge (`esk_c_api.h`) lets an exception escape.
280
+ Internal failures become an error string or `ESK_COUNT_ERROR` (-1), and the
281
+ platform views never forward a negative count to JS — the buffer was not
282
+ modified, so it must not be reported as "0 entered". If the shuffle CSPRNG
283
+ refuses, the layout is left unchanged.
284
+
285
+ ### Verification
286
+
287
+ | Target | Method | How to run |
288
+ |---|---|---|
289
+ | React wrapper | 4 jest tests over the JS boundary (public key, background wiping, the three native events), tsc, eslint | `pnpm test`, `pnpm lint`, `pnpm exec tsc --noEmit -p tsconfig.json` |
290
+ | C++ core | 48 gtest tests in the documented Debug run (two more are Release-only, guarded by `NDEBUG`); both the core and the tests are instrumented with ASan/UBSan (asserted at configure time) | `pnpm test:cpp` |
291
+ | Android module | Kotlin compile, JNI/CMake link across 4 ABIs | `./gradlew :expo-secure-keypad-jsi:assembleDebug` in `example/android`, which `pnpm expo prebuild` generates |
292
+ | iOS module | Only compilation can be automated | Build the example app |
293
+ | Touch, rendering, lifecycle, on-device behaviour | Not automatable here — manual checks on real devices | The example app on a development build |
294
+
295
+ Provenance, pinning and the fallback plan for the OpenSSL binaries are in
296
+ [docs/BUILD.md](docs/BUILD.md).
297
+
298
+ #### On-device memory measurement
299
+
300
+ Measured on both platforms by typing a secret on the keypad and then searching
301
+ the process's resident read-write memory for it. The builds carried release
302
+ optimization; only debugger access was added.
303
+
304
+ An ordinary JS string, held in React state as a positive control, was found in
305
+ 46 (iOS) and 47 (Android) places during the same passes — so the `length_=0`
306
+ below is a measurement, not a scanner that read nothing.
307
+
308
+ ```
309
+ # Android — the buffer page read through /proc/<pid>/mem
310
+ SecureBuffer page_=0x7aede5c000 pageSize_=4096 length_=12 locked_=1
311
+
312
+ right after entry 71 76 7a 78 6d 6c 70 67 77 6b 68 64 00 00 00 00 ...
313
+ b'qvzxmlpgwkhd\x00\x00\x00\x00 ...'
314
+ after submit 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ... length_=0
315
+ after backgrounding 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ... length_=0
316
+ ```
317
+
318
+ ```
319
+ # iOS — the same page read through lldb
320
+ SecureBuffer page_=0x10cb88000 pageSize_=16384 length_=12 locked_=1
321
+
322
+ right after entry page content: b'qvzxmlpgwkhd'
323
+ after submit page content: b'' length_=0
324
+ after backgrounding page content: b'' length_=0
325
+
326
+ # attributes of the buffer page — its own read-write anonymous region, one dirty
327
+ # page. From a separate run, so the address differs from the one above
328
+ (lldb) memory region 0x102b64000
329
+ [0x0000000102b64000-0x0000000102b68000) rw-
330
+ Dirty pages: 0x102b64000.
331
+ ```
332
+
333
+ ## Threat model
334
+
335
+ | Threat | Defended | How / why |
336
+ |---|---|---|
337
+ | JS heap dump, Hermes snapshot | Yes | The value never enters the JS VM |
338
+ | Bridge / JSI traffic sniffing | Yes | Key mapping and the value exist only in native code |
339
+ | Layout Inspector, accessibility tree scraping | Yes | Glyphs are drawn directly; there are no text nodes, and the container is hidden from a11y services unconditionally |
340
+ | Userland memory scan | Mostly | cleanse always, mlock when it succeeds. The value lives for microseconds. Transient copies inside libcrypto do exist |
341
+ | Swap leakage | Conditional (Android) | mlock + MADV_DONTDUMP. On devices with a small `RLIMIT_MEMLOCK` mlock fails silently and only cleanse remains. iOS compresses RAM instead of swapping |
342
+ | Ciphertext replay | Yes (with server support) | The server verifies nonce + timestamp |
343
+ | Tapjacking, overlays | Partial (Android) | `filterTouchesWhenObscured` rejects the input |
344
+ | Screenshots, screen recording | No | The value is never rendered, but the press highlight reveals positions. Blocking capture is the app's job; failing that, set `pressedHighlight: false` |
345
+ | Hooking, debuggers | No | RASP territory |
346
+ | Rooted / jailbroken devices | No | RASP and server-side attestation territory |
347
+ | Bundle tampering to swap the public key | No | The defence is OTA code signing |
348
+ | Past traffic decrypted after a server private-key leak | No | RSA-OAEP has no forward secrecy. Rotate keys |
349
+ | OS keyloggers, kernel compromise, hardware attacks | No | Outside the app's authority |
350
+
351
+ Recommended counterparts for the rows marked "No": Play Integrity / App Attest
352
+ and a RASP SDK for device posture, per-screen `FLAG_SECURE` /
353
+ `UIScreen.isCaptured` for capture blocking, and expo-updates code signing plus a
354
+ controlled build pipeline for bundle integrity.
355
+
356
+ ## Contributing
357
+
358
+ Bug reports and PRs are welcome.
359
+
360
+ The app in [`example/`](example) autolinks the module from the repository root,
361
+ so a change under `src/`, `common/`, `android/` or `ios/` shows up directly. Run
362
+ `pnpm install && pnpm build` at the root, then `pnpm install && pnpm ios` (or
363
+ `pnpm android`) in `example/`. Expo Go cannot load a native module — use a
364
+ development build. Re-run `pnpm build` after touching `src/`, because the
365
+ example imports the compiled output rather than the TypeScript source.
366
+
367
+ Each test suite and how to run it is in the [Verification](#verification) table.
368
+ Anything touching rendering, touch handling, lifecycle or the iOS build has to
369
+ be checked by hand on a device, through the demos in `example/app/`.
370
+
371
+ The 96-byte plaintext is a contract with every server already decrypting these
372
+ envelopes. Changing a field means bumping `payload::kVersion` in
373
+ `common/include/esk/PinEncryptor.h`, updating the layout table in both READMEs
374
+ and in `example/scripts/decrypt.mjs`, and adding a round-trip test in
375
+ `tests/cpp/CryptoRoundtripTest.cpp`. The error-code strings in
376
+ `common/src/Error.cpp` reach JS verbatim, so renaming one is breaking too.
377
+
378
+ Keep the security invariants intact: the entered value must never cross into JS,
379
+ the C ABI must stay no-throw, and a negative count must never be forwarded to JS
380
+ as "0 entered". Say so in the PR description if a change comes near any of them.
381
+
382
+ Report a security problem privately through
383
+ [GitHub security advisories](https://github.com/0610studio/expo-secure-keypad-jsi/security/advisories/new),
384
+ not as a public issue. In scope is anything that lets the entered value or the
385
+ key mapping escape native code, survive a wipe, or weaken the envelope; the
386
+ rows marked "No" in the threat model above are documented non-goals, not
387
+ defects. Never attach a real PIN, password, or private key — a demo keypair
388
+ from `example/scripts/gen-keys.mjs` demonstrates anything about the wire
389
+ format.
390
+
391
+ ## License
392
+
393
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,45 @@
1
+ # Copyright (c) 2026 expo-secure-keypad-jsi contributors. MIT License.
2
+ #
3
+ # This podspec lives at the PACKAGE ROOT (not ios/) so its file patterns can
4
+ # reach the shared C++ core in common/ — CocoaPods file patterns cannot use
5
+ # `../`. Expo Apple autolinking discovers podspecs anywhere in the package.
6
+ require 'json'
7
+
8
+ package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
9
+
10
+ Pod::Spec.new do |s|
11
+ s.name = 'SecureKeypadJsi'
12
+ s.version = package['version']
13
+ s.summary = package['description']
14
+ s.description = package['description']
15
+ s.license = package['license']
16
+ s.author = package['author']
17
+ s.homepage = package['homepage']
18
+ s.platforms = { :ios => '16.4' }
19
+ s.swift_version = '5.9'
20
+ s.source = { git: 'https://github.com/0610studio/expo-secure-keypad-jsi' }
21
+ s.static_framework = true
22
+
23
+ s.dependency 'ExpoModulesCore'
24
+ # Precompiled OpenSSL 3.6.2 XCFramework — same upstream version as Android.
25
+ # Exact pin, not a range: a range would let an unreviewed repackage in.
26
+ # Bump deliberately — see docs/BUILD.md.
27
+ s.dependency 'OpenSSL-Universal', '3.6.2000'
28
+
29
+ s.pod_target_xcconfig = {
30
+ 'DEFINES_MODULE' => 'YES',
31
+ 'CLANG_CXX_LANGUAGE_STANDARD' => 'c++20',
32
+ 'HEADER_SEARCH_PATHS' => '"$(PODS_TARGET_SRCROOT)/common/include"',
33
+ # Do not expose OpenSSL headers to Swift; only the ObjC++ bridge uses them.
34
+ 'SWIFT_INCLUDE_PATHS' => '"$(PODS_TARGET_SRCROOT)/common/include"',
35
+ }
36
+
37
+ # Swift + ObjC++ bridge + the shared C++ core.
38
+ s.source_files = 'ios/**/*.{h,m,mm,swift}',
39
+ 'common/src/**/*.cpp',
40
+ 'common/include/**/*.h'
41
+
42
+ # Keep the C++ core headers private to the pod (only EskKeypadBridge.h is the
43
+ # Swift-facing surface, exposed via the generated umbrella header).
44
+ s.private_header_files = 'common/include/**/*.h'
45
+ end
@@ -0,0 +1,28 @@
1
+ # Copyright (c) 2026 expo-secure-keypad-jsi contributors. MIT License.
2
+ cmake_minimum_required(VERSION 3.22)
3
+ project(expo-secure-keypad-jsi LANGUAGES CXX)
4
+
5
+ # OpenSSL comes from the openssl-static prefab AAR (io.github.ronickg:
6
+ # openssl-static). Its prefab package name is `opensslstatic` and it exposes the
7
+ # `crypto` and `ssl` modules — we link only `opensslstatic::crypto`.
8
+ find_package(opensslstatic REQUIRED CONFIG)
9
+ set(ESK_OPENSSL_TARGET opensslstatic::crypto)
10
+
11
+ # Build the shared core (defines target `esk_core`).
12
+ add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/../common esk_core_build)
13
+
14
+ # JNI glue -> flat C API -> esk_core.
15
+ add_library(expo-secure-keypad-jsi SHARED
16
+ src/main/cpp/JniBridge.cpp)
17
+
18
+ target_link_libraries(expo-secure-keypad-jsi
19
+ esk_core
20
+ android
21
+ log)
22
+
23
+ # Hide every symbol pulled in from the static OpenSSL so it can never interpose
24
+ # with another OpenSSL present in the app process (see quick-crypto #1059), and
25
+ # keep 16KB page alignment for Android 15+.
26
+ target_link_options(expo-secure-keypad-jsi PRIVATE
27
+ "-Wl,--exclude-libs,ALL"
28
+ "-Wl,-z,max-page-size=16384")
@@ -0,0 +1,58 @@
1
+ plugins {
2
+ id 'com.android.library'
3
+ id 'expo-module-gradle-plugin'
4
+ }
5
+
6
+ group = 'expo.modules.securekeypadjsi'
7
+ version = '0.1.0'
8
+
9
+ android {
10
+ namespace "expo.modules.securekeypadjsi"
11
+
12
+ defaultConfig {
13
+ versionCode 1
14
+ versionName "0.1.0"
15
+
16
+ externalNativeBuild {
17
+ cmake {
18
+ cppFlags "-std=c++20", "-fvisibility=hidden", "-fvisibility-inlines-hidden"
19
+ arguments "-DANDROID_STL=c++_shared",
20
+ "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON"
21
+ }
22
+ }
23
+ }
24
+
25
+ externalNativeBuild {
26
+ cmake {
27
+ path "CMakeLists.txt"
28
+ }
29
+ }
30
+
31
+ // Consume the prefab OpenSSL AAR.
32
+ buildFeatures {
33
+ prefab true
34
+ }
35
+
36
+ // The app module already ships libc++_shared.so; avoid duplicate packaging.
37
+ packagingOptions {
38
+ excludes += ["**/libc++_shared.so"]
39
+ }
40
+
41
+ lintOptions {
42
+ abortOnError false
43
+ }
44
+
45
+ }
46
+
47
+ dependencies {
48
+ // Static libcrypto prefab (OpenSSL 3.6.2), the same artifact react-native
49
+ // -quick-crypto uses. Provenance, pinning and the fallback plan are in
50
+ // docs/BUILD.md.
51
+ implementation 'io.github.ronickg:openssl-static:3.6.2-2'
52
+
53
+ // ReactFontManager (theme.fontFamily) — the registry expo-font writes into.
54
+ // compileOnly: expo-modules-core pulls react-android in as `implementation`,
55
+ // so it is present at runtime but not on this module's compile classpath.
56
+ // Version comes from the host app's React Native gradle plugin.
57
+ compileOnly 'com.facebook.react:react-android'
58
+ }
@@ -0,0 +1,2 @@
1
+ <manifest>
2
+ </manifest>
@@ -0,0 +1,118 @@
1
+ // Copyright (c) 2026 expo-secure-keypad-jsi contributors. MIT License.
2
+ //
3
+ // JNI glue for PinSessionHandle.kt. The esk_keypad* lives in a Kotlin `long`.
4
+ #include <jni.h>
5
+
6
+ #include <cstdint>
7
+ #include <ctime>
8
+ #include <string>
9
+
10
+ #include "esk/esk_c_api.h"
11
+
12
+ namespace {
13
+
14
+ int64_t nowCb(void* /*user*/) {
15
+ return static_cast<int64_t>(::time(nullptr));
16
+ }
17
+
18
+ std::string jstr(JNIEnv* env, jstring s) {
19
+ if (s == nullptr) return {};
20
+ const char* c = env->GetStringUTFChars(s, nullptr);
21
+ std::string out(c ? c : "");
22
+ if (c) env->ReleaseStringUTFChars(s, c);
23
+ return out;
24
+ }
25
+
26
+ esk_keypad* asKeypad(jlong ptr) {
27
+ return reinterpret_cast<esk_keypad*>(static_cast<uintptr_t>(ptr));
28
+ }
29
+
30
+ } // namespace
31
+
32
+ extern "C" {
33
+
34
+ JNIEXPORT jlong JNICALL
35
+ Java_expo_modules_securekeypadjsi_PinSessionHandle_nativeCreate(
36
+ JNIEnv*, jobject, jint minLength, jint maxLength, jint keypadType) {
37
+ esk_config cfg{};
38
+ cfg.min_length = minLength;
39
+ cfg.max_length = maxLength;
40
+ cfg.keypad_type = keypadType;
41
+
42
+ esk_keypad* kp = esk_keypad_create(&cfg, &nowCb, nullptr);
43
+ return static_cast<jlong>(reinterpret_cast<uintptr_t>(kp));
44
+ }
45
+
46
+ JNIEXPORT void JNICALL
47
+ Java_expo_modules_securekeypadjsi_PinSessionHandle_nativeDestroy(JNIEnv*,
48
+ jobject,
49
+ jlong ptr) {
50
+ esk_keypad_destroy(asKeypad(ptr));
51
+ }
52
+
53
+ JNIEXPORT jstring JNICALL
54
+ Java_expo_modules_securekeypadjsi_PinSessionHandle_nativeArm(JNIEnv* env,
55
+ jobject, jlong ptr,
56
+ jstring pem) {
57
+ const std::string pemStr = jstr(env, pem);
58
+ const char* err = esk_keypad_arm(asKeypad(ptr), pemStr.c_str());
59
+ return err == nullptr ? nullptr : env->NewStringUTF(err);
60
+ }
61
+
62
+ JNIEXPORT jint JNICALL
63
+ Java_expo_modules_securekeypadjsi_PinSessionHandle_nativePress(JNIEnv*, jobject,
64
+ jlong ptr,
65
+ jint digit) {
66
+ return esk_keypad_press(asKeypad(ptr), static_cast<uint8_t>(digit));
67
+ }
68
+
69
+ JNIEXPORT jint JNICALL
70
+ Java_expo_modules_securekeypadjsi_PinSessionHandle_nativePressKey(
71
+ JNIEnv*, jobject, jlong ptr, jint asciiChar) {
72
+ return esk_keypad_press_key(asKeypad(ptr), static_cast<uint8_t>(asciiChar));
73
+ }
74
+
75
+ JNIEXPORT jint JNICALL
76
+ Java_expo_modules_securekeypadjsi_PinSessionHandle_nativeBackspace(JNIEnv*,
77
+ jobject,
78
+ jlong ptr) {
79
+ return esk_keypad_backspace(asKeypad(ptr));
80
+ }
81
+
82
+ JNIEXPORT void JNICALL
83
+ Java_expo_modules_securekeypadjsi_PinSessionHandle_nativeClear(JNIEnv*, jobject,
84
+ jlong ptr) {
85
+ esk_keypad_clear(asKeypad(ptr));
86
+ }
87
+
88
+ JNIEXPORT jstring JNICALL
89
+ Java_expo_modules_securekeypadjsi_PinSessionHandle_nativeSubmit(JNIEnv* env,
90
+ jobject,
91
+ jlong ptr) {
92
+ const char* err = nullptr;
93
+ char* envelope = esk_keypad_submit(asKeypad(ptr), &err);
94
+ if (envelope != nullptr) {
95
+ jstring out = env->NewStringUTF(envelope);
96
+ esk_free(envelope);
97
+ return out; // starts with '{'
98
+ }
99
+ return env->NewStringUTF(err ? err : "ERR_INTERNAL"); // starts with "ERR_"
100
+ }
101
+
102
+ JNIEXPORT jbyteArray JNICALL
103
+ Java_expo_modules_securekeypadjsi_PinSessionHandle_nativeShuffledLayout(
104
+ JNIEnv* env, jobject, jlong ptr) {
105
+ uint8_t layout[10] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
106
+ esk_keypad_shuffled_layout(asKeypad(ptr), layout);
107
+ jbyteArray arr = env->NewByteArray(10);
108
+ env->SetByteArrayRegion(arr, 0, 10, reinterpret_cast<jbyte*>(layout));
109
+ return arr;
110
+ }
111
+
112
+ JNIEXPORT void JNICALL
113
+ Java_expo_modules_securekeypadjsi_PinSessionHandle_nativeOnBackground(
114
+ JNIEnv*, jobject, jlong ptr) {
115
+ esk_keypad_on_background(asKeypad(ptr));
116
+ }
117
+
118
+ } // extern "C"