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 CHANGED
@@ -38,13 +38,13 @@ and what to use instead.
38
38
 
39
39
  ## Requirements
40
40
 
41
- - Expo SDK 57+, React Native 0.86+, New Architecture only
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 16.4+
47
- - Android 7.0 (API 24)+ — the Expo SDK 57 default; the module adds no floor of
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, and the container is hidden from a11y
103
- services and autofill unconditionally.
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, and the 32 ASCII
132
- specials (``!@#$%^&*()-_=+[]{}\|;:'",.<>?/`~``). Space is not accepted.
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` | 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.
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) | Actual input length; where to cut `secret` |
217
- | 4 | 64 | secret (ASCII, zero-padded) | The input. Printable ASCII (0x21–0x7E), rest is zero |
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('ascii');
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, and the container is hidden from a11y services unconditionally |
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 |
@@ -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 => '16.4' }
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 asciiChar) {
72
- return esk_keypad_press_key(asKeypad(ptr), static_cast<uint8_t>(asciiChar));
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
- uint8_t layout[10] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
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 ASCII value
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 ASCII character — shift/layer already applied. */
30
- fun onKeyPressed(asciiChar: Int)
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; identity order when shuffle is off. */
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
- // All 32 printable ASCII specials, three rows + a short fourth row.
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, 2f))
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 = pressedDim(keyColor, held)
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
- drawCentered(canvas, label, rect, paint)
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
- keyPaint.color = pressedDim(keyColor, held)
115
- // Action cells have no key fill, so the glyph itself carries the dim.
116
- actionPaint.color = pressedDim(actionTextColor, held)
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 -> drawCentered(canvas, "✕", rect, actionPaint)
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 ASCII char (view resolves shift/layer first). Out-of-charset is ignored. */
26
- fun pressKey(asciiChar: Int): Int = if (ptr != 0L) nativePressKey(ptr, asciiChar) else 0
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, asciiChar: Int): Int
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 "system font", so this one is
127
- // assigned unconditionally instead of only on a hit.
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(asciiChar: Int) {
232
+ override fun onKeyPressed(codePoint: Int) {
229
233
  val h = handle ?: return
230
- val count = h.pressKey(asciiChar)
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; hiding the container too denies
299
- // a malicious AccessibilityService even the fact that a keypad is here.
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
- /** Identity 0..9 order: the fallback whenever the native CSPRNG layout is unavailable. */
13
- internal fun identityLayout(): ByteArray = ByteArray(10) { it.toByte() }
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
- /** Shared by both canvas views: baseline-corrected centred text. */
47
- internal fun drawCentered(canvas: Canvas, text: String, r: RectF, paint: Paint) {
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 layers. The digit
14
- * row is fully shuffled and each character row gets a blank dummy key at a
15
- * random slot, so the same character is not at the same coordinate across
16
- * sessions. Layout and shift state live only in native code.
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
- * NOTE: the action glyphs (⌫ ✕ ⇧ ⏎) always render in the system font. Most
46
- * custom fonts have no glyph for them, and a missing glyph would draw as
47
- * tofu (□) on an unlabelled key. For `keypadType: 'full'`, pick a font that
48
- * covers all of printable ASCII (0x21~0x7E) or some keys will show tofu.
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 dims to 70% opacity (default true). NOTE: any visible
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;;;;;;GAMG;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;CAC5B;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
+ {"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 layers. The digit\n * row is fully shuffled and each character row gets a blank dummy key at a\n * random slot, so the same character is not at the same coordinate across\n * 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 * NOTE: the action glyphs (⌫ ✕ ⇧ ⏎) always render in the system font. Most\n * custom fonts have no glyph for them, and a missing glyph would draw as\n * tofu (□) on an unlabelled key. For `keypadType: 'full'`, pick a font that\n * covers all of printable ASCII (0x21~0x7E) or some keys will show tofu.\n */\n fontFamily?: string;\n /**\n * Whether a held key dims to 70% opacity (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\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"]}
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-key count. pressKey takes the final ASCII
39
- // character (the view resolves shift/layer state before calling); characters
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(uint8_t asciiChar);
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 ASCII, zero-padded | 68 16 nonce | 84 8 unix seconds BE
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';