expo-secure-keypad-jsi 0.1.0 → 0.1.2
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/README.md +50 -22
- package/SecureKeypadJsi.podspec +1 -1
- package/android/src/main/cpp/JniBridge.cpp +4 -3
- package/android/src/main/java/expo/modules/securekeypadjsi/KeyboardCanvasView.kt +46 -12
- package/android/src/main/java/expo/modules/securekeypadjsi/KeypadCanvasView.kt +15 -5
- package/android/src/main/java/expo/modules/securekeypadjsi/PinSessionHandle.kt +3 -3
- package/android/src/main/java/expo/modules/securekeypadjsi/SecureKeypadJsiView.kt +10 -7
- package/android/src/main/java/expo/modules/securekeypadjsi/ThemedKeypadView.kt +30 -4
- package/build/SecureKeypadJsi.types.d.ts +26 -9
- package/build/SecureKeypadJsi.types.d.ts.map +1 -1
- package/build/SecureKeypadJsi.types.js.map +1 -1
- package/common/include/esk/KeypadCore.h +6 -4
- package/common/include/esk/PinEncryptor.h +6 -2
- package/common/include/esk/SecureBuffer.h +35 -9
- package/common/include/esk/esk_c_api.h +8 -7
- package/common/src/KeypadCore.cpp +19 -17
- package/common/src/SecureBuffer.cpp +32 -8
- package/common/src/esk_c_api.cpp +2 -2
- package/docs/README.ko.md +41 -19
- package/ios/EskKeypadBridge.h +3 -3
- package/ios/EskKeypadBridge.mm +3 -2
- package/ios/KeyboardCoreGraphicsView.swift +56 -20
- package/ios/KeypadCoreGraphicsView.swift +50 -10
- package/ios/SecureKeypadJsiView.swift +11 -5
- package/package.json +6 -4
package/README.md
CHANGED
|
@@ -38,13 +38,13 @@ and what to use instead.
|
|
|
38
38
|
|
|
39
39
|
## Requirements
|
|
40
40
|
|
|
41
|
-
- Expo SDK
|
|
41
|
+
- Expo SDK 54+, React Native 0.81+, New Architecture only
|
|
42
42
|
- A development build (`expo run:*`) or an EAS build, because the module ships
|
|
43
43
|
native code. Expo Go's prebuilt binary does not contain it, and an OTA update
|
|
44
44
|
replaces only JS, so it cannot add a native module either. Once the module is
|
|
45
45
|
in a build, later JS-only changes can still ship over OTA
|
|
46
|
-
- iOS
|
|
47
|
-
- Android 7.0 (API 24)+ — the Expo SDK
|
|
46
|
+
- iOS 15.1+
|
|
47
|
+
- Android 7.0 (API 24)+ — the Expo SDK default; the module adds no floor of
|
|
48
48
|
its own. Ships `arm64-v8a`, `armeabi-v7a`, `x86`, `x86_64`
|
|
49
49
|
|
|
50
50
|
## Installation
|
|
@@ -89,7 +89,7 @@ export function PinScreen({ serverPublicKeyPem }: { serverPublicKeyPem: string }
|
|
|
89
89
|
| `publicKey` | `string` | required | RSA 2048–8192 bit PEM. Weaker keys are rejected |
|
|
90
90
|
| `keypadType` | `'digit' \| 'full'` | `'digit'` | `'full'` is the QWERTY keyboard |
|
|
91
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 |
|
|
92
|
+
| `shuffle` | `'mount' \| 'perKey' \| 'off'` | `'mount'` | When the layout is reshuffled. `'off'` lays the digits out phone-style, 1–9 then 0 |
|
|
93
93
|
| `autoSubmit` | `boolean` | `true` (digit), `false` (full) | Encrypt automatically once `maxLength` is reached |
|
|
94
94
|
| `theme` | `KeypadTheme` | see [Theme](#theme) | Colours, corner radius and text size |
|
|
95
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) |
|
|
@@ -99,8 +99,11 @@ export function PinScreen({ serverPublicKeyPem }: { serverPublicKeyPem: string }
|
|
|
99
99
|
|
|
100
100
|
**This keypad does not support screen readers on either platform, and there is
|
|
101
101
|
no option to enable it.** The keys are canvas glyphs with no child views, so no
|
|
102
|
-
key is ever an accessibility node
|
|
103
|
-
|
|
102
|
+
key is ever an accessibility node and no digit or character is ever exposed as
|
|
103
|
+
text. On Android the container is also marked not important for accessibility
|
|
104
|
+
and excluded from autofill, so screen readers skip it; a service that requests
|
|
105
|
+
`FLAG_INCLUDE_NOT_IMPORTANT_VIEWS` still sees the keypad as one text-less node
|
|
106
|
+
(its position and size only).
|
|
104
107
|
|
|
105
108
|
The reason is that an Android `AccessibilityService` can read the node tree and
|
|
106
109
|
input events of other apps — the standard keylogging route — and an app cannot
|
|
@@ -128,11 +131,20 @@ Examples: [InlineDemo](example/demos/InlineDemo.tsx),
|
|
|
128
131
|
|
|
129
132
|
### QWERTY keyboard (`keypadType: 'full'`)
|
|
130
133
|
|
|
131
|
-
A password keyboard for upper- and lowercase letters, digits,
|
|
132
|
-
specials (``!@#$%^&*()-_=+[]{}\|;:'",.<>?/`~``)
|
|
134
|
+
A password keyboard for upper- and lowercase letters, digits, the 32 ASCII
|
|
135
|
+
specials (``!@#$%^&*()-_=+[]{}\|;:'",.<>?/`~``), and the 33 non-ASCII symbols
|
|
136
|
+
the stock iOS and Android (Gboard, Samsung Keyboard) keyboards show on their
|
|
137
|
+
symbol layers: `₩ € £ ¥ ¢ ¤ § ¶ © ® ™ ✓ ° • × ÷ √ π ∆ ¡ ¿ 《 》 ○ ● □ ■ ▪ ◇ ☆ ♤ ♡ ♧`.
|
|
138
|
+
Long-press alternates are not included. Space is not accepted.
|
|
133
139
|
|
|
134
140
|
- Layout: digit row / `qwertyuiop` / `asdfghjkl` / `⇧ zxcvbnm ⌫` / `[!#1] [✕] [⏎]`.
|
|
135
|
-
`!#1` switches to the symbol layer
|
|
141
|
+
`!#1` switches to the symbol layer, where the `1/2` key flips between the
|
|
142
|
+
ASCII page and the non-ASCII page (`2/2`)
|
|
143
|
+
- Length: `minLength` / `maxLength` and `onDigitCountChanged` count characters.
|
|
144
|
+
The non-ASCII symbols are 2–3 bytes each in UTF-8 and the secret field holds
|
|
145
|
+
64 bytes, so a symbol-heavy input can stop accepting keys before `maxLength`
|
|
146
|
+
(21 × `♡` = 63 bytes, for example). The extra key is ignored like any press
|
|
147
|
+
past `maxLength`
|
|
136
148
|
- Shuffle: the letter rows keep the standard QWERTY order while the digit row is
|
|
137
149
|
fully shuffled, and each letter row gets one blank dummy key at a random slot.
|
|
138
150
|
`'perKey'` redraws after every keystroke
|
|
@@ -152,17 +164,27 @@ Android, points on iOS), so the same number looks the same on both.
|
|
|
152
164
|
|---|---|---|---|
|
|
153
165
|
| `keyColor` | `string` | `#1C1C1E` | Key background |
|
|
154
166
|
| `keyTextColor` | `string` | `#FFFFFF` | Digit and letter glyphs |
|
|
155
|
-
| `actionTextColor` | `string` | `#8E8E93` | Action keys (`⌫`, `✕`, `⇧`, `!#1`, `⏎`) |
|
|
167
|
+
| `actionTextColor` | `string` | `#8E8E93` | Action keys (`⌫`, `✕`, `⇧`, `!#1`, `1/2`, `⏎`), including `clearKeyLabel` / `submitKeyLabel` text |
|
|
156
168
|
| `cornerRadius` | `number` | `12` (digit), `8` (full) | Key corner radius |
|
|
157
169
|
| `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` |
|
|
159
|
-
| `
|
|
160
|
-
|
|
161
|
-
`
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
170
|
+
| `pressedHighlight` | `boolean` | `true` | Whether a held key shows a press effect. `false` disables it entirely, `pressedKeyColor` included |
|
|
171
|
+
| `pressedKeyColor` | `string` | `keyColor` at 70% opacity | Background of a held key. On the digit pad the `✕` / `⌫` cells, which have no background, get this fill while held |
|
|
172
|
+
| `clearKeyLabel` | `string` | `✕` | Text shown on the clear key instead of the glyph, e.g. `"취소"` / `"Cancel"` |
|
|
173
|
+
| `submitKeyLabel` | `string` | `⏎` | Text shown on the submit key instead of the glyph, e.g. `"완료"` / `"Done"`. `keypadType: 'full'` only — the digit pad has no submit key |
|
|
174
|
+
| `fontFamily` | `string` | system font | Font for the digit / character / symbol 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 |
|
|
175
|
+
|
|
176
|
+
`fontFamily` covers every glyph drawn from a value — digits, letters, and both
|
|
177
|
+
symbol pages. A character the font has no glyph for is drawn by the platform's
|
|
178
|
+
per-character font fallback (a system font), not as tofu: with Space Mono, for
|
|
179
|
+
example, `€` and `π` come from Space Mono while `♡` and `₩` come from the
|
|
180
|
+
fallback. The action glyphs (`⌫`, `✕`, `⇧`, `⏎`) always render in the system
|
|
181
|
+
font, so the action keys look the same whatever font the app picks. For
|
|
182
|
+
`keypadType: 'full'`, a font that covers printable ASCII (0x21~0x7E) keeps
|
|
183
|
+
every letter and digit key in one face.
|
|
184
|
+
|
|
185
|
+
`clearKeyLabel` / `submitKeyLabel` render in the system font with
|
|
186
|
+
`actionTextColor`, at the action-glyph size, and shrink to fit when the text is
|
|
187
|
+
wider than the key. Leaving them unset (or `""`) keeps the glyphs.
|
|
166
188
|
`example/demos/FontDemo.tsx` is a working screen with two `expo-font` families.
|
|
167
189
|
|
|
168
190
|
There is no `backgroundColor` theme field: the keypad is a regular view, so its
|
|
@@ -213,8 +235,8 @@ the same structure.
|
|
|
213
235
|
|---|---|---|---|
|
|
214
236
|
| 0 | 2 | magic `"SK"` | Fixed. Confirms the plaintext came from this library |
|
|
215
237
|
| 2 | 1 | version = 2 | Version of this 96-byte layout. Reject anything you don't know |
|
|
216
|
-
| 3 | 1 | secretLength (4–64) |
|
|
217
|
-
| 4 | 64 | secret (
|
|
238
|
+
| 3 | 1 | secretLength (4–64) | Input length **in bytes**; where to cut `secret` |
|
|
239
|
+
| 4 | 64 | secret (UTF-8, zero-padded) | The input. Printable ASCII (0x21–0x7E) is one byte each; the full keyboard's non-ASCII symbols are 2–3 bytes. Rest is zero |
|
|
218
240
|
| 68 | 16 | nonce | Single-use random. Must match the envelope's `nonce` |
|
|
219
241
|
| 84 | 8 | timestamp (unix seconds, big endian) | When it was encrypted; for the freshness check |
|
|
220
242
|
| 92 | 4 | reserved | Always zero. Room for the next version |
|
|
@@ -232,11 +254,17 @@ const plain = crypto.privateDecrypt(
|
|
|
232
254
|
Buffer.from(msg.ct, 'base64')
|
|
233
255
|
);
|
|
234
256
|
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('
|
|
257
|
+
const secret = plain.subarray(4, 4 + plain[3]).toString('utf8');
|
|
236
258
|
const nonce = plain.subarray(68, 84).toString('base64');
|
|
237
259
|
const timestamp = Number(plain.readBigUInt64BE(84));
|
|
238
260
|
```
|
|
239
261
|
|
|
262
|
+
Decode `secret` as UTF-8. Digit PINs and ASCII-only passwords are
|
|
263
|
+
byte-identical to earlier releases, so an `'ascii'` decoder keeps working for
|
|
264
|
+
them, but it garbles `₩` or `♡`. The symbols are sent exactly as the stock
|
|
265
|
+
keyboards produce them (`₩` is U+20A9, not the fullwidth U+FFE6), so a
|
|
266
|
+
password set through an ordinary text field compares equal byte for byte.
|
|
267
|
+
|
|
240
268
|
What the server must verify:
|
|
241
269
|
|
|
242
270
|
- magic and version
|
|
@@ -336,7 +364,7 @@ Dirty pages: 0x102b64000.
|
|
|
336
364
|
|---|---|---|
|
|
337
365
|
| JS heap dump, Hermes snapshot | Yes | The value never enters the JS VM |
|
|
338
366
|
| 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
|
|
367
|
+
| Layout Inspector, accessibility tree scraping | Yes | Glyphs are drawn directly; there are no text nodes. A service that asks for not-important views sees only a text-less keypad node (position and size) |
|
|
340
368
|
| Userland memory scan | Mostly | cleanse always, mlock when it succeeds. The value lives for microseconds. Transient copies inside libcrypto do exist |
|
|
341
369
|
| 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
370
|
| Ciphertext replay | Yes (with server support) | The server verifies nonce + timestamp |
|
package/SecureKeypadJsi.podspec
CHANGED
|
@@ -15,7 +15,7 @@ Pod::Spec.new do |s|
|
|
|
15
15
|
s.license = package['license']
|
|
16
16
|
s.author = package['author']
|
|
17
17
|
s.homepage = package['homepage']
|
|
18
|
-
s.platforms = { :ios => '
|
|
18
|
+
s.platforms = { :ios => '15.1' }
|
|
19
19
|
s.swift_version = '5.9'
|
|
20
20
|
s.source = { git: 'https://github.com/0610studio/expo-secure-keypad-jsi' }
|
|
21
21
|
s.static_framework = true
|
|
@@ -68,8 +68,8 @@ Java_expo_modules_securekeypadjsi_PinSessionHandle_nativePress(JNIEnv*, jobject,
|
|
|
68
68
|
|
|
69
69
|
JNIEXPORT jint JNICALL
|
|
70
70
|
Java_expo_modules_securekeypadjsi_PinSessionHandle_nativePressKey(
|
|
71
|
-
JNIEnv*, jobject, jlong ptr, jint
|
|
72
|
-
return esk_keypad_press_key(asKeypad(ptr), static_cast<
|
|
71
|
+
JNIEnv*, jobject, jlong ptr, jint codePoint) {
|
|
72
|
+
return esk_keypad_press_key(asKeypad(ptr), static_cast<uint32_t>(codePoint));
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
JNIEXPORT jint JNICALL
|
|
@@ -102,7 +102,8 @@ Java_expo_modules_securekeypadjsi_PinSessionHandle_nativeSubmit(JNIEnv* env,
|
|
|
102
102
|
JNIEXPORT jbyteArray JNICALL
|
|
103
103
|
Java_expo_modules_securekeypadjsi_PinSessionHandle_nativeShuffledLayout(
|
|
104
104
|
JNIEnv* env, jobject, jlong ptr) {
|
|
105
|
-
|
|
105
|
+
// Unshuffled fallback, same 1..9,0 order as identityLayout() in Kotlin.
|
|
106
|
+
uint8_t layout[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 0};
|
|
106
107
|
esk_keypad_shuffled_layout(asKeypad(ptr), layout);
|
|
107
108
|
jbyteArray arr = env->NewByteArray(10);
|
|
108
109
|
env->SetByteArrayRegion(arr, 0, 10, reinterpret_cast<jbyte*>(layout));
|
|
@@ -13,7 +13,7 @@ import java.security.SecureRandom
|
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Full QWERTY secure keyboard drawn with [Canvas] and no child views, so key
|
|
16
|
-
* glyphs never become scrapeable text. Touches resolve to a final
|
|
16
|
+
* glyphs never become scrapeable text. Touches resolve to a final code point
|
|
17
17
|
* here (shift and symbol layer included); the on-screen position never leaves
|
|
18
18
|
* this class.
|
|
19
19
|
*
|
|
@@ -26,8 +26,8 @@ import java.security.SecureRandom
|
|
|
26
26
|
class KeyboardCanvasView(context: Context) : View(context), ThemedKeypadView {
|
|
27
27
|
|
|
28
28
|
interface Listener {
|
|
29
|
-
/** Final
|
|
30
|
-
fun onKeyPressed(
|
|
29
|
+
/** Final Unicode code point — shift/layer already applied. */
|
|
30
|
+
fun onKeyPressed(codePoint: Int)
|
|
31
31
|
fun onBackspace()
|
|
32
32
|
fun onClear()
|
|
33
33
|
fun onDone()
|
|
@@ -38,7 +38,7 @@ class KeyboardCanvasView(context: Context) : View(context), ThemedKeypadView {
|
|
|
38
38
|
|
|
39
39
|
var shuffleMode: KeypadCanvasView.ShuffleMode = KeypadCanvasView.ShuffleMode.MOUNT
|
|
40
40
|
|
|
41
|
-
/** Shuffled 0..9 from the native core;
|
|
41
|
+
/** Shuffled 0..9 from the native core; 1..9,0 when shuffle is off. */
|
|
42
42
|
var digitsProvider: (() -> ByteArray)? = null
|
|
43
43
|
|
|
44
44
|
private val density = context.resources.displayMetrics.density
|
|
@@ -51,6 +51,9 @@ class KeyboardCanvasView(context: Context) : View(context), ThemedKeypadView {
|
|
|
51
51
|
override var fontFamily: String? = null
|
|
52
52
|
|
|
53
53
|
override var pressedHighlight: Boolean = true
|
|
54
|
+
override var pressedKeyColor: Int? = null
|
|
55
|
+
override var clearKeyLabel: String? = null
|
|
56
|
+
override var submitKeyLabel: String? = null
|
|
54
57
|
|
|
55
58
|
private companion object {
|
|
56
59
|
const val ACTION_SHIFT = -1
|
|
@@ -59,18 +62,26 @@ class KeyboardCanvasView(context: Context) : View(context), ThemedKeypadView {
|
|
|
59
62
|
const val ACTION_DONE = -4
|
|
60
63
|
const val ACTION_TOGGLE = -5
|
|
61
64
|
const val DUMMY = -6
|
|
65
|
+
const val ACTION_SYMBOL_PAGE = -7
|
|
62
66
|
|
|
63
67
|
const val ROW_QWERTY_TOP = "qwertyuiop"
|
|
64
68
|
const val ROW_QWERTY_MID = "asdfghjkl"
|
|
65
69
|
const val ROW_QWERTY_BOT = "zxcvbnm"
|
|
66
|
-
//
|
|
70
|
+
// Symbol page 1/2: all 32 printable ASCII specials, three rows + a short fourth row.
|
|
67
71
|
const val ROW_SYM_0 = "!@#$%^&*()"
|
|
68
72
|
const val ROW_SYM_1 = "-_=+[]{}\\|"
|
|
69
73
|
const val ROW_SYM_2 = ";:'\",.<>?/"
|
|
70
74
|
const val ROW_SYM_3 = "`~"
|
|
75
|
+
// Symbol page 2/2: the non-ASCII symbols the stock iOS / Gboard / Samsung
|
|
76
|
+
// keyboards show. Must match kExtraSymbols in common/include/esk/SecureBuffer.h,
|
|
77
|
+
// or the core silently drops the key.
|
|
78
|
+
const val ROW_EXT_0 = "₩€£¥¢¤§¶©®"
|
|
79
|
+
const val ROW_EXT_1 = "™✓°•×÷√π∆"
|
|
80
|
+
const val ROW_EXT_2 = "¡¿《》○●□■▪"
|
|
81
|
+
const val ROW_EXT_3 = "◇☆♤♡♧"
|
|
71
82
|
}
|
|
72
83
|
|
|
73
|
-
private enum class Layer { LETTERS, SYMBOLS }
|
|
84
|
+
private enum class Layer { LETTERS, SYMBOLS, SYMBOLS_EXTRA }
|
|
74
85
|
private enum class ShiftState { OFF, ONE_SHOT, LOCK }
|
|
75
86
|
|
|
76
87
|
private data class Key(val code: Int, val weight: Float = 1f)
|
|
@@ -87,6 +98,9 @@ class KeyboardCanvasView(context: Context) : View(context), ThemedKeypadView {
|
|
|
87
98
|
private var keyRects = ArrayList<Pair<RectF, Key>>(48)
|
|
88
99
|
|
|
89
100
|
private val keyPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL }
|
|
101
|
+
// Every character key, symbols included, uses the theme font. A glyph that
|
|
102
|
+
// font lacks (Space Mono has no ♡) is drawn by the typeface's built-in
|
|
103
|
+
// system fallback, per character, instead of tofu.
|
|
90
104
|
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { textAlign = Paint.Align.CENTER }
|
|
91
105
|
private val actionPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { textAlign = Paint.Align.CENTER }
|
|
92
106
|
private val gap = 6f * density
|
|
@@ -134,15 +148,27 @@ class KeyboardCanvasView(context: Context) : View(context), ThemedKeypadView {
|
|
|
134
148
|
it.addAll(letters)
|
|
135
149
|
it.add(Key(ACTION_BACKSPACE, 1.5f))
|
|
136
150
|
})
|
|
137
|
-
} else {
|
|
151
|
+
} else if (layer == Layer.SYMBOLS) {
|
|
138
152
|
rows.add(ArrayList<Key>(ROW_SYM_0.map { Key(it.code) }).also { withDummy(it, 4) })
|
|
139
153
|
rows.add(ArrayList<Key>(ROW_SYM_1.map { Key(it.code) }).also { withDummy(it, 0) })
|
|
140
154
|
rows.add(ArrayList<Key>(ROW_SYM_2.map { Key(it.code) }).also { withDummy(it, 1) })
|
|
141
155
|
rows.add(ArrayList<Key>().also {
|
|
156
|
+
it.add(Key(ACTION_SYMBOL_PAGE, 1.5f))
|
|
142
157
|
val syms = ArrayList<Key>(ROW_SYM_3.map { c -> Key(c.code, 2f) })
|
|
143
158
|
withDummy(syms, 3)
|
|
144
159
|
it.addAll(syms)
|
|
145
|
-
it.add(Key(ACTION_BACKSPACE,
|
|
160
|
+
it.add(Key(ACTION_BACKSPACE, 1.5f))
|
|
161
|
+
})
|
|
162
|
+
} else {
|
|
163
|
+
rows.add(ArrayList<Key>(ROW_EXT_0.map { Key(it.code) }).also { withDummy(it, 4) })
|
|
164
|
+
rows.add(ArrayList<Key>(ROW_EXT_1.map { Key(it.code) }).also { withDummy(it, 0) })
|
|
165
|
+
rows.add(ArrayList<Key>(ROW_EXT_2.map { Key(it.code) }).also { withDummy(it, 1) })
|
|
166
|
+
rows.add(ArrayList<Key>().also {
|
|
167
|
+
it.add(Key(ACTION_SYMBOL_PAGE, 1.5f))
|
|
168
|
+
val syms = ArrayList<Key>(ROW_EXT_3.map { c -> Key(c.code) })
|
|
169
|
+
withDummy(syms, 3)
|
|
170
|
+
it.addAll(syms)
|
|
171
|
+
it.add(Key(ACTION_BACKSPACE, 1.5f))
|
|
146
172
|
})
|
|
147
173
|
}
|
|
148
174
|
rows.add(listOf(Key(ACTION_TOGGLE, 1f), Key(ACTION_CLEAR, 1f), Key(ACTION_DONE, 1f)))
|
|
@@ -180,9 +206,10 @@ class KeyboardCanvasView(context: Context) : View(context), ThemedKeypadView {
|
|
|
180
206
|
DUMMY -> null
|
|
181
207
|
ACTION_SHIFT -> "⇧"
|
|
182
208
|
ACTION_BACKSPACE -> "⌫"
|
|
183
|
-
ACTION_CLEAR -> "✕"
|
|
184
|
-
ACTION_DONE -> "⏎"
|
|
209
|
+
ACTION_CLEAR -> clearKeyLabel ?: "✕"
|
|
210
|
+
ACTION_DONE -> submitKeyLabel ?: "⏎"
|
|
185
211
|
ACTION_TOGGLE -> if (layer == Layer.LETTERS) "!#1" else "abc"
|
|
212
|
+
ACTION_SYMBOL_PAGE -> if (layer == Layer.SYMBOLS) "1/2" else "2/2"
|
|
186
213
|
else -> {
|
|
187
214
|
val c = key.code.toChar()
|
|
188
215
|
if (shift != ShiftState.OFF && c in 'a'..'z') c.uppercaseChar().toString() else c.toString()
|
|
@@ -200,7 +227,7 @@ class KeyboardCanvasView(context: Context) : View(context), ThemedKeypadView {
|
|
|
200
227
|
for ((index, entry) in keyRects.withIndex()) {
|
|
201
228
|
val (rect, key) = entry
|
|
202
229
|
val held = pressedHighlight && index == pressedIndex
|
|
203
|
-
keyPaint.color =
|
|
230
|
+
keyPaint.color = keyFill(held)
|
|
204
231
|
canvas.drawRoundRect(rect, r, r, keyPaint)
|
|
205
232
|
val label = labelFor(key)
|
|
206
233
|
if (label != null) {
|
|
@@ -211,7 +238,9 @@ class KeyboardCanvasView(context: Context) : View(context), ThemedKeypadView {
|
|
|
211
238
|
} else {
|
|
212
239
|
actionTextColor
|
|
213
240
|
}
|
|
214
|
-
|
|
241
|
+
val custom = (key.code == ACTION_CLEAR && clearKeyLabel != null) ||
|
|
242
|
+
(key.code == ACTION_DONE && submitKeyLabel != null)
|
|
243
|
+
drawCentered(canvas, label, rect, paint, fit = custom)
|
|
215
244
|
}
|
|
216
245
|
}
|
|
217
246
|
}
|
|
@@ -271,6 +300,11 @@ class KeyboardCanvasView(context: Context) : View(context), ThemedKeypadView {
|
|
|
271
300
|
layoutKeys()
|
|
272
301
|
invalidate()
|
|
273
302
|
}
|
|
303
|
+
ACTION_SYMBOL_PAGE -> {
|
|
304
|
+
layer = if (layer == Layer.SYMBOLS) Layer.SYMBOLS_EXTRA else Layer.SYMBOLS
|
|
305
|
+
layoutKeys()
|
|
306
|
+
invalidate()
|
|
307
|
+
}
|
|
274
308
|
else -> {
|
|
275
309
|
var c = key.code.toChar()
|
|
276
310
|
if (shift != ShiftState.OFF && c in 'a'..'z') c = c.uppercaseChar()
|
|
@@ -44,6 +44,10 @@ class KeypadCanvasView(context: Context) : View(context), ThemedKeypadView {
|
|
|
44
44
|
override var fontFamily: String? = null
|
|
45
45
|
|
|
46
46
|
override var pressedHighlight: Boolean = true
|
|
47
|
+
override var pressedKeyColor: Int? = null
|
|
48
|
+
override var clearKeyLabel: String? = null
|
|
49
|
+
// No submit key on the digit pad (autoSubmit / ref.submit()); kept for the interface.
|
|
50
|
+
override var submitKeyLabel: String? = null
|
|
47
51
|
|
|
48
52
|
var layoutProvider: (() -> ByteArray)? = null
|
|
49
53
|
|
|
@@ -111,17 +115,23 @@ class KeypadCanvasView(context: Context) : View(context), ThemedKeypadView {
|
|
|
111
115
|
|
|
112
116
|
val digit = digitAtCell[cell]
|
|
113
117
|
val held = pressedHighlight && cell == pressedCell
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
118
|
+
val r = keyCornerRadiusDp * density
|
|
119
|
+
keyPaint.color = keyFill(held)
|
|
120
|
+
// Action cells have no key fill. A held one gets the pressedKeyColor
|
|
121
|
+
// fill when the theme sets it; otherwise the glyph itself carries the dim.
|
|
122
|
+
val actionFill = held && digit < 0 && pressedKeyColor != null
|
|
123
|
+
actionPaint.color = if (actionFill) actionTextColor else pressedDim(actionTextColor, held)
|
|
124
|
+
if (actionFill && (cell == backspaceCell || cell == clearCell)) {
|
|
125
|
+
canvas.drawRoundRect(rect, r, r, keyPaint)
|
|
126
|
+
}
|
|
117
127
|
when {
|
|
118
128
|
digit >= 0 -> {
|
|
119
|
-
val r = keyCornerRadiusDp * density
|
|
120
129
|
canvas.drawRoundRect(rect, r, r, keyPaint)
|
|
121
130
|
drawCentered(canvas, digit.toString(), rect, textPaint)
|
|
122
131
|
}
|
|
123
132
|
cell == backspaceCell -> drawCentered(canvas, "⌫", rect, actionPaint)
|
|
124
|
-
cell == clearCell ->
|
|
133
|
+
cell == clearCell ->
|
|
134
|
+
drawCentered(canvas, clearKeyLabel ?: "✕", rect, actionPaint, fit = clearKeyLabel != null)
|
|
125
135
|
}
|
|
126
136
|
}
|
|
127
137
|
}
|
|
@@ -22,8 +22,8 @@ class PinSessionHandle(minLength: Int, maxLength: Int, keypadType: Int = TYPE_DI
|
|
|
22
22
|
|
|
23
23
|
fun press(digit: Int): Int = if (ptr != 0L) nativePress(ptr, digit) else 0
|
|
24
24
|
|
|
25
|
-
/** Final
|
|
26
|
-
fun pressKey(
|
|
25
|
+
/** Final code point (view resolves shift/layer first). Out-of-charset is ignored. */
|
|
26
|
+
fun pressKey(codePoint: Int): Int = if (ptr != 0L) nativePressKey(ptr, codePoint) else 0
|
|
27
27
|
fun backspace(): Int = if (ptr != 0L) nativeBackspace(ptr) else 0
|
|
28
28
|
fun clear() { if (ptr != 0L) nativeClear(ptr) }
|
|
29
29
|
|
|
@@ -45,7 +45,7 @@ class PinSessionHandle(minLength: Int, maxLength: Int, keypadType: Int = TYPE_DI
|
|
|
45
45
|
private external fun nativeDestroy(ptr: Long)
|
|
46
46
|
private external fun nativeArm(ptr: Long, pem: String): String?
|
|
47
47
|
private external fun nativePress(ptr: Long, digit: Int): Int
|
|
48
|
-
private external fun nativePressKey(ptr: Long,
|
|
48
|
+
private external fun nativePressKey(ptr: Long, codePoint: Int): Int
|
|
49
49
|
private external fun nativeBackspace(ptr: Long): Int
|
|
50
50
|
private external fun nativeClear(ptr: Long)
|
|
51
51
|
private external fun nativeSubmit(ptr: Long): String
|
|
@@ -123,9 +123,13 @@ class SecureKeypadJsiView(context: Context, appContext: AppContext) :
|
|
|
123
123
|
(t["cornerRadius"] as? Number)?.let { v.keyCornerRadiusDp = it.toFloat() }
|
|
124
124
|
(t["digitTextSize"] as? Number)?.let { v.digitTextSizeDp = it.toFloat() }
|
|
125
125
|
(t["pressedHighlight"] as? Boolean)?.let { v.pressedHighlight = it }
|
|
126
|
-
// Absent key vs. explicit null: both mean "
|
|
127
|
-
//
|
|
126
|
+
// Absent key vs. explicit null: both mean "back to the default" (system
|
|
127
|
+
// font, 70% dim, the ✕ / ⏎ glyphs), so these are assigned unconditionally
|
|
128
|
+
// instead of only on a hit.
|
|
128
129
|
v.fontFamily = (t["fontFamily"] as? String)?.takeIf { it.isNotEmpty() }
|
|
130
|
+
v.pressedKeyColor = (t["pressedKeyColor"] as? String)?.let { ThemeColor.parse(it) }
|
|
131
|
+
v.clearKeyLabel = (t["clearKeyLabel"] as? String)?.takeIf { it.isNotEmpty() }
|
|
132
|
+
v.submitKeyLabel = (t["submitKeyLabel"] as? String)?.takeIf { it.isNotEmpty() }
|
|
129
133
|
v.invalidate()
|
|
130
134
|
}
|
|
131
135
|
|
|
@@ -225,9 +229,9 @@ class SecureKeypadJsiView(context: Context, appContext: AppContext) :
|
|
|
225
229
|
|
|
226
230
|
// KeyboardCanvasView.Listener (full keyboard)
|
|
227
231
|
|
|
228
|
-
override fun onKeyPressed(
|
|
232
|
+
override fun onKeyPressed(codePoint: Int) {
|
|
229
233
|
val h = handle ?: return
|
|
230
|
-
val count = h.pressKey(
|
|
234
|
+
val count = h.pressKey(codePoint)
|
|
231
235
|
if (count < 0) return
|
|
232
236
|
reportCount(count)
|
|
233
237
|
if (autoSubmit && count >= effectiveMaxLength()) submit()
|
|
@@ -295,9 +299,8 @@ class SecureKeypadJsiView(context: Context, appContext: AppContext) :
|
|
|
295
299
|
|
|
296
300
|
private fun applyAccessibilityHardening() {
|
|
297
301
|
// Unconditional. The keys are canvas glyphs with no child nodes, so a11y
|
|
298
|
-
// services could never read them anyway;
|
|
299
|
-
//
|
|
300
|
-
// The keypad is not screen-reader usable by design — see README.
|
|
302
|
+
// services could never read them anyway; this keeps screen readers off the
|
|
303
|
+
// container too. The keypad is not screen-reader usable by design — see README.
|
|
301
304
|
importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
|
|
302
305
|
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
|
|
303
306
|
importantForAutofill = IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS
|
|
@@ -9,8 +9,11 @@ import android.graphics.RectF
|
|
|
9
9
|
import android.graphics.Typeface
|
|
10
10
|
import com.facebook.react.common.assets.ReactFontManager
|
|
11
11
|
|
|
12
|
-
/**
|
|
13
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Unshuffled order, phone-keypad style (1..9 then 0): what `shuffle: 'off'`
|
|
14
|
+
* shows, and the fallback whenever the native CSPRNG layout is unavailable.
|
|
15
|
+
*/
|
|
16
|
+
internal fun identityLayout(): ByteArray = ByteArray(10) { ((it + 1) % 10).toByte() }
|
|
14
17
|
|
|
15
18
|
/**
|
|
16
19
|
* The theme surface both canvas views expose, so [SecureKeypadJsiView] applies
|
|
@@ -29,6 +32,15 @@ internal interface ThemedKeypadView {
|
|
|
29
32
|
/** false hides the press feedback entirely, for apps that cannot block capture. */
|
|
30
33
|
var pressedHighlight: Boolean
|
|
31
34
|
|
|
35
|
+
/** Fill of a held key; null keeps the default 70% dim of [keyColor]. */
|
|
36
|
+
var pressedKeyColor: Int?
|
|
37
|
+
|
|
38
|
+
/** Text drawn instead of the ✕ glyph; null keeps the glyph. */
|
|
39
|
+
var clearKeyLabel: String?
|
|
40
|
+
|
|
41
|
+
/** Text drawn instead of the ⏎ glyph (full keyboard only); null keeps the glyph. */
|
|
42
|
+
var submitKeyLabel: String?
|
|
43
|
+
|
|
32
44
|
fun invalidate()
|
|
33
45
|
}
|
|
34
46
|
|
|
@@ -43,11 +55,25 @@ internal fun pressedDim(color: Int, held: Boolean): Int =
|
|
|
43
55
|
color
|
|
44
56
|
}
|
|
45
57
|
|
|
46
|
-
/**
|
|
47
|
-
internal fun
|
|
58
|
+
/** Key fill: [ThemedKeypadView.pressedKeyColor] while held when set, else the 70% dim. */
|
|
59
|
+
internal fun ThemedKeypadView.keyFill(held: Boolean): Int =
|
|
60
|
+
if (held) pressedKeyColor ?: pressedDim(keyColor, true) else keyColor
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Shared by both canvas views: baseline-corrected centred text. [fit] shrinks
|
|
64
|
+
* the text to the key's width — for theme labels, whose length is the app's.
|
|
65
|
+
*/
|
|
66
|
+
internal fun drawCentered(canvas: Canvas, text: String, r: RectF, paint: Paint, fit: Boolean = false) {
|
|
67
|
+
val size = paint.textSize
|
|
68
|
+
if (fit) {
|
|
69
|
+
val maxWidth = r.width() * 0.85f
|
|
70
|
+
val width = paint.measureText(text)
|
|
71
|
+
if (width > maxWidth && width > 0f) paint.textSize = size * maxWidth / width
|
|
72
|
+
}
|
|
48
73
|
val cx = r.centerX()
|
|
49
74
|
val cy = r.centerY() - (paint.descent() + paint.ascent()) / 2f
|
|
50
75
|
canvas.drawText(text, cx, cy, paint)
|
|
76
|
+
paint.textSize = size
|
|
51
77
|
}
|
|
52
78
|
|
|
53
79
|
|
|
@@ -10,10 +10,11 @@ export type KeypadErrorPhase = 'arm' | 'submit' | 'input';
|
|
|
10
10
|
export type ShuffleMode = 'mount' | 'perKey' | 'off';
|
|
11
11
|
/**
|
|
12
12
|
* 'digit' = 3x4 shuffled PIN pad.
|
|
13
|
-
* 'full' = QWERTY keyboard with shift + symbol
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* 'full' = QWERTY keyboard with shift + two symbol pages (ASCII specials, then
|
|
14
|
+
* the non-ASCII symbols of the stock iOS / Android keyboards such as ₩ and ♡).
|
|
15
|
+
* The digit row is fully shuffled and each character row gets a blank dummy
|
|
16
|
+
* key at a random slot, so the same character is not at the same coordinate
|
|
17
|
+
* across sessions. Layout and shift state live only in native code.
|
|
17
18
|
*/
|
|
18
19
|
export type KeypadType = 'digit' | 'full';
|
|
19
20
|
export interface KeypadError {
|
|
@@ -42,18 +43,34 @@ export interface KeypadTheme {
|
|
|
42
43
|
* font bundled at build time, or a system family ("Courier", "monospace").
|
|
43
44
|
* Unresolvable names fall back to the system font instead of throwing.
|
|
44
45
|
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
46
|
+
* Applies to every character key, symbols (₩ ♡ …) included. A character
|
|
47
|
+
* the font has no glyph for is drawn by the platform's per-character font
|
|
48
|
+
* fallback (a system font), not as tofu. The action glyphs (⌫ ✕ ⇧ ⏎) always
|
|
49
|
+
* render in the system font.
|
|
49
50
|
*/
|
|
50
51
|
fontFamily?: string;
|
|
51
52
|
/**
|
|
52
|
-
* Whether a held key
|
|
53
|
+
* Whether a held key shows a press effect (default true). NOTE: any visible
|
|
53
54
|
* press effect lets a screen recording reconstruct the input — block capture
|
|
54
55
|
* at the app level (FLAG_SECURE / UIScreen.isCaptured) or pass false here.
|
|
55
56
|
*/
|
|
56
57
|
pressedHighlight?: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Background of a held key. Unset = `keyColor` at 70% opacity. On the digit
|
|
60
|
+
* pad the ✕ / ⌫ cells, which have no background, get this fill while held.
|
|
61
|
+
* Ignored when `pressedHighlight` is false.
|
|
62
|
+
*/
|
|
63
|
+
pressedKeyColor?: string;
|
|
64
|
+
/**
|
|
65
|
+
* Text shown on the clear key instead of ✕, e.g. "취소". Drawn in the system
|
|
66
|
+
* font with `actionTextColor`, shrunk to fit the key. Unset or "" = ✕.
|
|
67
|
+
*/
|
|
68
|
+
clearKeyLabel?: string;
|
|
69
|
+
/**
|
|
70
|
+
* Text shown on the submit key instead of ⏎, e.g. "완료". `keypadType:
|
|
71
|
+
* 'full'` only — the digit pad has no submit key. Unset or "" = ⏎.
|
|
72
|
+
*/
|
|
73
|
+
submitKeyLabel?: string;
|
|
57
74
|
}
|
|
58
75
|
export type OnCompleteEvent = {
|
|
59
76
|
nativeEvent: {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SecureKeypadJsi.types.d.ts","sourceRoot":"","sources":["../src/SecureKeypadJsi.types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAEzD;;;;;;GAMG;AACH,MAAM,MAAM,gBAAgB,GAAG,KAAK,GAAG,QAAQ,GAAG,OAAO,CAAC;AAE1D,MAAM,MAAM,WAAW,GAAG,OAAO,GAAG,QAAQ,GAAG,KAAK,CAAC;AAErD
|
|
1
|
+
{"version":3,"file":"SecureKeypadJsi.types.d.ts","sourceRoot":"","sources":["../src/SecureKeypadJsi.types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAEzD;;;;;;GAMG;AACH,MAAM,MAAM,gBAAgB,GAAG,KAAK,GAAG,QAAQ,GAAG,OAAO,CAAC;AAE1D,MAAM,MAAM,WAAW,GAAG,OAAO,GAAG,QAAQ,GAAG,KAAK,CAAC;AAErD;;;;;;;GAOG;AACH,MAAM,MAAM,UAAU,GAAG,OAAO,GAAG,MAAM,CAAC;AAE1C,MAAM,WAAW,WAAW;IAC1B;;;;;OAKG;IACH,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,gBAAgB,CAAC;CACzB;AAED;;;GAGG;AACH,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;;;;;;;OAUG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B;;;;OAIG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,MAAM,eAAe,GAAG;IAAE,WAAW,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,CAAC;AACpE,MAAM,MAAM,wBAAwB,GAAG;IAAE,WAAW,EAAE;QAAE,KAAK,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,CAAC;AAC1E,MAAM,MAAM,YAAY,GAAG;IAAE,WAAW,EAAE,WAAW,CAAA;CAAE,CAAC;AAExD,oEAAoE;AACpE,MAAM,WAAW,wBAAwB;IACvC,6DAA6D;IAC7D,SAAS,EAAE,MAAM,CAAC;IAClB,uBAAuB;IACvB,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,kDAAkD;IAClD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,WAAW,CAAC;IACtB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,CAAC;IAC9C,mBAAmB,CAAC,EAAE,CAAC,KAAK,EAAE,wBAAwB,KAAK,IAAI,CAAC;IAChE,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,YAAY,KAAK,IAAI,CAAC;IACxC,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CAC9B;AAED,MAAM,WAAW,kBAAkB;IACjC,kDAAkD;IAClD,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3B,+CAA+C;IAC/C,MAAM,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC7B"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SecureKeypadJsi.types.js","sourceRoot":"","sources":["../src/SecureKeypadJsi.types.ts"],"names":[],"mappings":"","sourcesContent":["import type { StyleProp, ViewStyle } from 'react-native';\n\n/**\n * Which step failed. 'arm' = public key rejected at mount, 'submit' =\n * encryption failed, 'input' = a touch was rejected before reaching the\n * keypad (Android tapjacking filter; see KeypadError.code). None of these is\n * a security finding — hooking, device posture and screen capture are out of\n * scope, see README.\n */\nexport type KeypadErrorPhase = 'arm' | 'submit' | 'input';\n\nexport type ShuffleMode = 'mount' | 'perKey' | 'off';\n\n/**\n * 'digit' = 3x4 shuffled PIN pad.\n * 'full' = QWERTY keyboard with shift + symbol
|
|
1
|
+
{"version":3,"file":"SecureKeypadJsi.types.js","sourceRoot":"","sources":["../src/SecureKeypadJsi.types.ts"],"names":[],"mappings":"","sourcesContent":["import type { StyleProp, ViewStyle } from 'react-native';\n\n/**\n * Which step failed. 'arm' = public key rejected at mount, 'submit' =\n * encryption failed, 'input' = a touch was rejected before reaching the\n * keypad (Android tapjacking filter; see KeypadError.code). None of these is\n * a security finding — hooking, device posture and screen capture are out of\n * scope, see README.\n */\nexport type KeypadErrorPhase = 'arm' | 'submit' | 'input';\n\nexport type ShuffleMode = 'mount' | 'perKey' | 'off';\n\n/**\n * 'digit' = 3x4 shuffled PIN pad.\n * 'full' = QWERTY keyboard with shift + two symbol pages (ASCII specials, then\n * the non-ASCII symbols of the stock iOS / Android keyboards such as ₩ and ♡).\n * The digit row is fully shuffled and each character row gets a blank dummy\n * key at a random slot, so the same character is not at the same coordinate\n * across sessions. Layout and shift state live only in native code.\n */\nexport type KeypadType = 'digit' | 'full';\n\nexport interface KeypadError {\n /**\n * Stable code, e.g. \"ERR_WEAK_KEY\", \"ERR_TOO_SHORT\". Phase 'input' only\n * emits \"ERR_OBSCURED_TOUCH\": Android dropped a touch because another window\n * (overlay app, screen dimmer) was drawn over the keypad. The tap was never\n * registered — tell the user to close the overlay.\n */\n code: string;\n phase: KeypadErrorPhase;\n}\n\n/**\n * Colors are #RRGGBB or #RRGGBBAA. Sizes are density-independent (dp on\n * Android, points on iOS), so the same number looks the same on both.\n */\nexport interface KeypadTheme {\n keyColor?: string;\n keyTextColor?: string;\n actionTextColor?: string;\n cornerRadius?: number;\n digitTextSize?: number;\n /**\n * Font for the digit / character glyphs. Any name the platform can already\n * resolve: a family registered by `expo-font`'s `loadAsync` / `useFonts`, a\n * font bundled at build time, or a system family (\"Courier\", \"monospace\").\n * Unresolvable names fall back to the system font instead of throwing.\n *\n * Applies to every character key, symbols (₩ ♡ …) included. A character\n * the font has no glyph for is drawn by the platform's per-character font\n * fallback (a system font), not as tofu. The action glyphs (⌫ ✕ ⇧ ⏎) always\n * render in the system font.\n */\n fontFamily?: string;\n /**\n * Whether a held key shows a press effect (default true). NOTE: any visible\n * press effect lets a screen recording reconstruct the input — block capture\n * at the app level (FLAG_SECURE / UIScreen.isCaptured) or pass false here.\n */\n pressedHighlight?: boolean;\n /**\n * Background of a held key. Unset = `keyColor` at 70% opacity. On the digit\n * pad the ✕ / ⌫ cells, which have no background, get this fill while held.\n * Ignored when `pressedHighlight` is false.\n */\n pressedKeyColor?: string;\n /**\n * Text shown on the clear key instead of ✕, e.g. \"취소\". Drawn in the system\n * font with `actionTextColor`, shrunk to fit the key. Unset or \"\" = ✕.\n */\n clearKeyLabel?: string;\n /**\n * Text shown on the submit key instead of ⏎, e.g. \"완료\". `keypadType:\n * 'full'` only — the digit pad has no submit key. Unset or \"\" = ⏎.\n */\n submitKeyLabel?: string;\n}\n\nexport type OnCompleteEvent = { nativeEvent: { envelope: string } };\nexport type OnDigitCountChangedEvent = { nativeEvent: { count: number } };\nexport type OnErrorEvent = { nativeEvent: KeypadError };\n\n/** Raw native view props; most apps want `SecureKeypad` instead. */\nexport interface SecureKeypadJsiViewProps {\n /** Server RSA-2048+ public key, PEM SubjectPublicKeyInfo. */\n publicKey: string;\n /** Default 'digit'. */\n keypadType?: KeypadType;\n /** Clamped to 4~12 ('digit') or 4~64 ('full'). */\n minLength?: number;\n maxLength?: number;\n shuffle?: ShuffleMode;\n autoSubmit?: boolean;\n theme?: KeypadTheme;\n onComplete?: (event: OnCompleteEvent) => void;\n onDigitCountChanged?: (event: OnDigitCountChangedEvent) => void;\n onError?: (event: OnErrorEvent) => void;\n style?: StyleProp<ViewStyle>;\n}\n\nexport interface SecureKeypadHandle {\n /** Clears the entered digits; does not disarm. */\n clear: () => Promise<void>;\n /** Encrypts now if the PIN meets minLength. */\n submit: () => Promise<void>;\n}\n"]}
|
|
@@ -35,11 +35,12 @@ class KeypadCore {
|
|
|
35
35
|
// nullopt on success; otherwise the error-code name (e.g. "ERR_WEAK_KEY").
|
|
36
36
|
std::optional<std::string> arm(const std::string& publicKeyPem);
|
|
37
37
|
|
|
38
|
-
// Return the resulting entered-
|
|
39
|
-
//
|
|
40
|
-
// outside the type's charset are silently ignored
|
|
38
|
+
// Return the resulting entered-character count. pressKey takes the final
|
|
39
|
+
// Unicode code point (the view resolves shift/layer state before calling);
|
|
40
|
+
// characters outside the type's charset are silently ignored, and so is a
|
|
41
|
+
// character whose UTF-8 bytes would overflow the 64-byte secret field.
|
|
41
42
|
size_t pressDigit(uint8_t digit);
|
|
42
|
-
size_t pressKey(
|
|
43
|
+
size_t pressKey(uint32_t codepoint);
|
|
43
44
|
size_t backspace();
|
|
44
45
|
void clearPin();
|
|
45
46
|
|
|
@@ -47,6 +48,7 @@ class KeypadCore {
|
|
|
47
48
|
// on Empty / TooShort / NotArmed.
|
|
48
49
|
std::string submit();
|
|
49
50
|
|
|
51
|
+
// Characters, not bytes — a multi-byte symbol counts once.
|
|
50
52
|
size_t digitCount() const;
|
|
51
53
|
KeypadState state() const { return state_; }
|
|
52
54
|
KeypadType type() const { return type_; }
|
|
@@ -14,9 +14,13 @@ namespace esk {
|
|
|
14
14
|
// digit pad simply never fills more than 12 of the 64 secret bytes.
|
|
15
15
|
//
|
|
16
16
|
// v2 (96 bytes):
|
|
17
|
-
// 0 2 magic {'S','K'} | 2 1 version | 3 1 secretLength
|
|
18
|
-
// 4 64 secret
|
|
17
|
+
// 0 2 magic {'S','K'} | 2 1 version | 3 1 secretLength (bytes)
|
|
18
|
+
// 4 64 secret UTF-8, zero-padded | 68 16 nonce | 84 8 unix seconds BE
|
|
19
19
|
// | 92 4 zero
|
|
20
|
+
//
|
|
21
|
+
// The secret was printable ASCII only until the extra symbol page was added;
|
|
22
|
+
// ASCII is a UTF-8 subset and the layout is unchanged, so that widening kept
|
|
23
|
+
// version 2 — an ASCII-only secret is byte-identical to before.
|
|
20
24
|
namespace payload {
|
|
21
25
|
inline constexpr uint8_t kMagic0 = 'S';
|
|
22
26
|
inline constexpr uint8_t kMagic1 = 'K';
|