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.
- package/LICENSE +21 -0
- package/README.md +393 -0
- package/SecureKeypadJsi.podspec +45 -0
- package/android/CMakeLists.txt +28 -0
- package/android/build.gradle +58 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/cpp/JniBridge.cpp +118 -0
- package/android/src/main/java/expo/modules/securekeypadjsi/KeyboardCanvasView.kt +291 -0
- package/android/src/main/java/expo/modules/securekeypadjsi/KeypadCanvasView.kt +183 -0
- package/android/src/main/java/expo/modules/securekeypadjsi/PinSessionHandle.kt +54 -0
- package/android/src/main/java/expo/modules/securekeypadjsi/SecureKeypadJsiModule.kt +35 -0
- package/android/src/main/java/expo/modules/securekeypadjsi/SecureKeypadJsiView.kt +317 -0
- package/android/src/main/java/expo/modules/securekeypadjsi/ThemeColor.kt +27 -0
- package/android/src/main/java/expo/modules/securekeypadjsi/ThemedKeypadView.kt +78 -0
- package/build/SecureKeypad.d.ts +51 -0
- package/build/SecureKeypad.d.ts.map +1 -0
- package/build/SecureKeypad.js +51 -0
- package/build/SecureKeypad.js.map +1 -0
- package/build/SecureKeypadJsi.types.d.ts +94 -0
- package/build/SecureKeypadJsi.types.d.ts.map +1 -0
- package/build/SecureKeypadJsi.types.js +2 -0
- package/build/SecureKeypadJsi.types.js.map +1 -0
- package/build/SecureKeypadJsiView.d.ts +6 -0
- package/build/SecureKeypadJsiView.d.ts.map +1 -0
- package/build/SecureKeypadJsiView.js +10 -0
- package/build/SecureKeypadJsiView.js.map +1 -0
- package/build/index.d.ts +5 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +5 -0
- package/build/index.js.map +1 -0
- package/common/CMakeLists.txt +49 -0
- package/common/include/esk/Base64.h +20 -0
- package/common/include/esk/Cleanse.h +17 -0
- package/common/include/esk/Error.h +37 -0
- package/common/include/esk/KeypadCore.h +69 -0
- package/common/include/esk/PinEncryptor.h +39 -0
- package/common/include/esk/PublicKeyValidator.h +37 -0
- package/common/include/esk/SecureBuffer.h +60 -0
- package/common/include/esk/SecureRandom.h +24 -0
- package/common/include/esk/esk_c_api.h +61 -0
- package/common/src/Base64.cpp +43 -0
- package/common/src/Error.cpp +21 -0
- package/common/src/KeypadCore.cpp +105 -0
- package/common/src/PinEncryptor.cpp +110 -0
- package/common/src/PublicKeyValidator.cpp +111 -0
- package/common/src/SecureBuffer.cpp +108 -0
- package/common/src/SecureRandom.cpp +42 -0
- package/common/src/esk_c_api.cpp +158 -0
- package/docs/BUILD.ko.md +33 -0
- package/docs/BUILD.md +36 -0
- package/docs/README.ko.md +286 -0
- package/expo-module.config.json +10 -0
- package/ios/EskKeypadBridge.h +35 -0
- package/ios/EskKeypadBridge.mm +77 -0
- package/ios/KeyboardCoreGraphicsView.swift +257 -0
- package/ios/KeypadCoreGraphicsView.swift +197 -0
- package/ios/SecureKeypadJsiModule.swift +26 -0
- package/ios/SecureKeypadJsiView.swift +278 -0
- 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,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"
|