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
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// Copyright (c) 2026 expo-secure-keypad-jsi contributors. MIT License.
|
|
2
|
+
package expo.modules.securekeypadjsi
|
|
3
|
+
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import android.graphics.Color
|
|
6
|
+
import android.os.Handler
|
|
7
|
+
import android.os.Looper
|
|
8
|
+
import android.view.View
|
|
9
|
+
import android.widget.FrameLayout
|
|
10
|
+
import expo.modules.kotlin.AppContext
|
|
11
|
+
import expo.modules.kotlin.viewevent.EventDispatcher
|
|
12
|
+
import expo.modules.kotlin.views.ExpoView
|
|
13
|
+
import java.util.Collections
|
|
14
|
+
import java.util.WeakHashMap
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Hosts the Canvas keypad (digit) or QWERTY keyboard (full) and owns the
|
|
18
|
+
* [PinSessionHandle]. JS receives only the entered-key count and the
|
|
19
|
+
* ciphertext envelope. Screen capture and device integrity are deliberately
|
|
20
|
+
* out of scope — see README.
|
|
21
|
+
*/
|
|
22
|
+
class SecureKeypadJsiView(context: Context, appContext: AppContext) :
|
|
23
|
+
ExpoView(context, appContext), KeypadCanvasView.Listener, KeyboardCanvasView.Listener {
|
|
24
|
+
|
|
25
|
+
companion object {
|
|
26
|
+
// Digit pad policy cap; the full keyboard uses the 64-byte payload cap.
|
|
27
|
+
// Mirrors kMaxPinLength / kMaxSecretLength in the C++ core so the
|
|
28
|
+
// autoSubmit comparison below agrees with what the core actually stores.
|
|
29
|
+
private const val MAX_DIGIT_CAP = 12
|
|
30
|
+
private const val MAX_FULL_CAP = 64
|
|
31
|
+
|
|
32
|
+
// Live views, so the module's activity-background hook can reach them.
|
|
33
|
+
// Weak keys: a view that Fabric dropped without OnViewDestroys must not
|
|
34
|
+
// be pinned here. Main thread only.
|
|
35
|
+
private val liveViews: MutableSet<SecureKeypadJsiView> =
|
|
36
|
+
Collections.newSetFromMap(WeakHashMap())
|
|
37
|
+
|
|
38
|
+
/** Called by [SecureKeypadJsiModule] when the host activity pauses. */
|
|
39
|
+
fun onActivityEntersBackground() {
|
|
40
|
+
for (v in liveViews.toList()) v.onAppBackground()
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private val onDigitCountChanged by EventDispatcher<Map<String, Any>>()
|
|
45
|
+
private val onComplete by EventDispatcher<Map<String, Any>>()
|
|
46
|
+
private val onError by EventDispatcher<Map<String, Any>>()
|
|
47
|
+
|
|
48
|
+
private var digitKeypad: KeypadCanvasView? = null
|
|
49
|
+
private var fullKeyboard: KeyboardCanvasView? = null
|
|
50
|
+
private var handle: PinSessionHandle? = null
|
|
51
|
+
private val mainHandler = Handler(Looper.getMainLooper())
|
|
52
|
+
|
|
53
|
+
// Rebuilt on the next frame so a batch of prop updates collapses into one
|
|
54
|
+
// native session.
|
|
55
|
+
private var publicKeyPem: String? = null
|
|
56
|
+
private var keypadType = "digit" // digit | full
|
|
57
|
+
private var minLength = 4
|
|
58
|
+
private var maxLength = 6
|
|
59
|
+
private var autoSubmit = true
|
|
60
|
+
private var shuffleMode = KeypadCanvasView.ShuffleMode.MOUNT
|
|
61
|
+
private var theme: Map<String, Any?>? = null
|
|
62
|
+
|
|
63
|
+
private var rebuildScheduled = false
|
|
64
|
+
// Set by dispose(). A rebuild posted before OnViewDestroys must not create a
|
|
65
|
+
// session nobody will ever destroy.
|
|
66
|
+
private var disposed = false
|
|
67
|
+
|
|
68
|
+
// Last count handed to JS. A rebuild reports 0 for the fresh session even
|
|
69
|
+
// when JS was already at 0, and that redundant event can land before the JS
|
|
70
|
+
// component has mounted (a React "state update on a component that hasn't
|
|
71
|
+
// mounted yet" warning). Nothing changed, so do not send it.
|
|
72
|
+
private var lastReportedCount = 0
|
|
73
|
+
|
|
74
|
+
init {
|
|
75
|
+
setBackgroundColor(Color.TRANSPARENT)
|
|
76
|
+
rebuildInputView()
|
|
77
|
+
applyAccessibilityHardening()
|
|
78
|
+
scheduleRebuild()
|
|
79
|
+
liveViews.add(this)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** maxLength after the per-type clamp the C++ core applies. */
|
|
83
|
+
private fun effectiveMaxLength(): Int =
|
|
84
|
+
maxLength.coerceIn(4, if (keypadType == "full") MAX_FULL_CAP else MAX_DIGIT_CAP)
|
|
85
|
+
|
|
86
|
+
fun setPublicKeyPem(pem: String) { publicKeyPem = pem; scheduleRebuild() }
|
|
87
|
+
fun setMinLength(v: Int) { minLength = v.coerceIn(4, 64); scheduleRebuild() }
|
|
88
|
+
fun setMaxLength(v: Int) { maxLength = v.coerceIn(4, 64); scheduleRebuild() }
|
|
89
|
+
fun setAutoSubmit(v: Boolean) { autoSubmit = v }
|
|
90
|
+
|
|
91
|
+
fun setKeypadType(v: String) {
|
|
92
|
+
val next = if (v == "full") "full" else "digit"
|
|
93
|
+
if (next == keypadType) return
|
|
94
|
+
keypadType = next
|
|
95
|
+
rebuildInputView()
|
|
96
|
+
scheduleRebuild()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
fun setShuffle(mode: String) {
|
|
100
|
+
shuffleMode = when (mode) {
|
|
101
|
+
"perKey" -> KeypadCanvasView.ShuffleMode.PER_KEY
|
|
102
|
+
"off" -> KeypadCanvasView.ShuffleMode.OFF
|
|
103
|
+
else -> KeypadCanvasView.ShuffleMode.MOUNT
|
|
104
|
+
}
|
|
105
|
+
digitKeypad?.let { it.shuffleMode = shuffleMode; it.reshuffle() }
|
|
106
|
+
fullKeyboard?.let { it.shuffleMode = shuffleMode; it.reshuffle() }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
fun setTheme(t: Map<String, Any?>) {
|
|
110
|
+
theme = t
|
|
111
|
+
digitKeypad?.let { applyTheme(it) }
|
|
112
|
+
fullKeyboard?.let { applyTheme(it) }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// #RRGGBB / #RRGGBBAA, same reading as the iOS side.
|
|
116
|
+
private fun safeColor(s: String, fallback: Int): Int = ThemeColor.parse(s) ?: fallback
|
|
117
|
+
|
|
118
|
+
private fun applyTheme(v: ThemedKeypadView) {
|
|
119
|
+
val t = theme ?: return
|
|
120
|
+
(t["keyColor"] as? String)?.let { v.keyColor = safeColor(it, v.keyColor) }
|
|
121
|
+
(t["keyTextColor"] as? String)?.let { v.keyTextColor = safeColor(it, v.keyTextColor) }
|
|
122
|
+
(t["actionTextColor"] as? String)?.let { v.actionTextColor = safeColor(it, v.actionTextColor) }
|
|
123
|
+
(t["cornerRadius"] as? Number)?.let { v.keyCornerRadiusDp = it.toFloat() }
|
|
124
|
+
(t["digitTextSize"] as? Number)?.let { v.digitTextSizeDp = it.toFloat() }
|
|
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.
|
|
128
|
+
v.fontFamily = (t["fontFamily"] as? String)?.takeIf { it.isNotEmpty() }
|
|
129
|
+
v.invalidate()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private fun rebuildInputView() {
|
|
133
|
+
removeAllViews()
|
|
134
|
+
digitKeypad = null
|
|
135
|
+
fullKeyboard = null
|
|
136
|
+
val lp = FrameLayout.LayoutParams(
|
|
137
|
+
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)
|
|
138
|
+
if (keypadType == "full") {
|
|
139
|
+
val v = KeyboardCanvasView(context)
|
|
140
|
+
v.listener = this
|
|
141
|
+
v.shuffleMode = shuffleMode
|
|
142
|
+
v.digitsProvider = { handle?.shuffledLayout() ?: identityLayout() }
|
|
143
|
+
applyTheme(v)
|
|
144
|
+
addView(v, lp)
|
|
145
|
+
layoutChildNow(v)
|
|
146
|
+
fullKeyboard = v
|
|
147
|
+
} else {
|
|
148
|
+
val v = KeypadCanvasView(context)
|
|
149
|
+
v.listener = this
|
|
150
|
+
v.shuffleMode = shuffleMode
|
|
151
|
+
v.layoutProvider = { handle?.shuffledLayout() ?: identityLayout() }
|
|
152
|
+
applyTheme(v)
|
|
153
|
+
addView(v, lp)
|
|
154
|
+
layoutChildNow(v)
|
|
155
|
+
digitKeypad = v
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Sizes a child that was added *after* the first layout pass — what a
|
|
161
|
+
* `keypadType` switch does. Fabric drives layout from the shadow tree and
|
|
162
|
+
* ignores the [requestLayout] that `addView` schedules (React Native
|
|
163
|
+
* #17968), so such a child would stay 0x0 and draw nothing. At init the view
|
|
164
|
+
* has no size yet and the normal mount cascade handles it, hence the guard.
|
|
165
|
+
*/
|
|
166
|
+
private fun layoutChildNow(child: View) {
|
|
167
|
+
if (width <= 0 || height <= 0) return
|
|
168
|
+
child.measure(
|
|
169
|
+
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
|
|
170
|
+
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY))
|
|
171
|
+
child.layout(0, 0, width, height)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// A parent resize (rotation, a container animating) arrives as a real layout
|
|
175
|
+
// pass, but the children are laid out from their *measured* size, which is
|
|
176
|
+
// stale after a mid-life swap. Re-measure them against the new bounds.
|
|
177
|
+
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
|
178
|
+
super.onSizeChanged(w, h, oldw, oldh)
|
|
179
|
+
digitKeypad?.let { layoutChildNow(it) }
|
|
180
|
+
fullKeyboard?.let { layoutChildNow(it) }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private fun scheduleRebuild() {
|
|
184
|
+
if (rebuildScheduled) return
|
|
185
|
+
rebuildScheduled = true
|
|
186
|
+
mainHandler.post {
|
|
187
|
+
rebuildScheduled = false
|
|
188
|
+
rebuildHandle()
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private fun rebuildHandle() {
|
|
193
|
+
if (disposed) return
|
|
194
|
+
// A new session starts empty; tell JS so a masked-dot UI never shows
|
|
195
|
+
// digits that no longer exist in the buffer.
|
|
196
|
+
val hadSession = handle != null
|
|
197
|
+
handle?.destroy()
|
|
198
|
+
|
|
199
|
+
val type = if (keypadType == "full") PinSessionHandle.TYPE_FULL else PinSessionHandle.TYPE_DIGIT
|
|
200
|
+
val h = PinSessionHandle(minLength = minLength, maxLength = maxLength, keypadType = type)
|
|
201
|
+
handle = h
|
|
202
|
+
digitKeypad?.reshuffle()
|
|
203
|
+
fullKeyboard?.reshuffle()
|
|
204
|
+
if (hadSession) reportCount(0)
|
|
205
|
+
|
|
206
|
+
publicKeyPem?.let { pem ->
|
|
207
|
+
val err = h.arm(pem)
|
|
208
|
+
if (err != null) {
|
|
209
|
+
onError(mapOf("code" to err, "phase" to "arm"))
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// KeypadCanvasView.Listener (digit pad)
|
|
215
|
+
|
|
216
|
+
override fun onDigitPressed(digit: Int) {
|
|
217
|
+
val h = handle ?: return
|
|
218
|
+
val count = h.press(digit)
|
|
219
|
+
// Negative = the core refused the call (ESK_COUNT_ERROR); the buffer is
|
|
220
|
+
// unchanged, so do not tell JS "0 entered".
|
|
221
|
+
if (count < 0) return
|
|
222
|
+
reportCount(count)
|
|
223
|
+
if (autoSubmit && count >= effectiveMaxLength()) submit()
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// KeyboardCanvasView.Listener (full keyboard)
|
|
227
|
+
|
|
228
|
+
override fun onKeyPressed(asciiChar: Int) {
|
|
229
|
+
val h = handle ?: return
|
|
230
|
+
val count = h.pressKey(asciiChar)
|
|
231
|
+
if (count < 0) return
|
|
232
|
+
reportCount(count)
|
|
233
|
+
if (autoSubmit && count >= effectiveMaxLength()) submit()
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
override fun onDone() = submit()
|
|
237
|
+
|
|
238
|
+
// Shared listener members
|
|
239
|
+
|
|
240
|
+
override fun onBackspace() {
|
|
241
|
+
val h = handle ?: return
|
|
242
|
+
val count = h.backspace()
|
|
243
|
+
if (count < 0) return
|
|
244
|
+
reportCount(count)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
override fun onClear() {
|
|
248
|
+
val h = handle ?: return
|
|
249
|
+
h.clear()
|
|
250
|
+
reportCount(0)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// filterTouchesWhenObscured already dropped the touch; this only lets the
|
|
254
|
+
// host explain why the keypad did not respond (overlay app, screen dimmer).
|
|
255
|
+
override fun onObscuredTouch() {
|
|
256
|
+
onError(mapOf("code" to "ERR_OBSCURED_TOUCH", "phase" to "input"))
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private fun reportCount(count: Int) {
|
|
260
|
+
if (count == lastReportedCount) return
|
|
261
|
+
lastReportedCount = count
|
|
262
|
+
onDigitCountChanged(mapOf("count" to count))
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
fun submit() {
|
|
266
|
+
val h = handle ?: return
|
|
267
|
+
val result = h.submit()
|
|
268
|
+
if (result.startsWith("{")) {
|
|
269
|
+
onComplete(mapOf("envelope" to result))
|
|
270
|
+
reportCount(0)
|
|
271
|
+
} else {
|
|
272
|
+
onError(mapOf("code" to result, "phase" to "submit"))
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
fun clearPin() {
|
|
277
|
+
handle?.clear()
|
|
278
|
+
reportCount(0)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Backgrounding = the host activity pausing (home button, app switcher) or
|
|
282
|
+
// this view leaving the window (react-native-screens detaches inactive
|
|
283
|
+
// screens). Either way the buffer is wiped and JS is told the count is 0,
|
|
284
|
+
// so the masked-dot UI cannot drift from the real buffer.
|
|
285
|
+
private fun onAppBackground() {
|
|
286
|
+
val h = handle ?: return
|
|
287
|
+
h.onBackground()
|
|
288
|
+
reportCount(0)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
override fun onDetachedFromWindow() {
|
|
292
|
+
onAppBackground()
|
|
293
|
+
super.onDetachedFromWindow()
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private fun applyAccessibilityHardening() {
|
|
297
|
+
// 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.
|
|
301
|
+
importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
|
|
302
|
+
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
|
|
303
|
+
importantForAutofill = IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
fun dispose() {
|
|
308
|
+
disposed = true
|
|
309
|
+
// Drop any rebuild still queued from a prop set in this same frame;
|
|
310
|
+
// otherwise it would run after us and leak a fresh session.
|
|
311
|
+
mainHandler.removeCallbacksAndMessages(null)
|
|
312
|
+
rebuildScheduled = false
|
|
313
|
+
liveViews.remove(this)
|
|
314
|
+
handle?.destroy()
|
|
315
|
+
handle = null
|
|
316
|
+
}
|
|
317
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Copyright (c) 2026 expo-secure-keypad-jsi contributors. MIT License.
|
|
2
|
+
package expo.modules.securekeypadjsi
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Parses the theme colour format the JS API documents: `#RRGGBB` or
|
|
6
|
+
* `#RRGGBBAA`, alpha last. `android.graphics.Color.parseColor` is deliberately
|
|
7
|
+
* not used — it reads 8-digit values as `#AARRGGBB` and accepts colour names,
|
|
8
|
+
* so the same string would render differently from iOS. Pure Kotlin so the
|
|
9
|
+
* JVM unit test needs no Android runtime.
|
|
10
|
+
*/
|
|
11
|
+
object ThemeColor {
|
|
12
|
+
/** Packed ARGB int (what Paint.color takes), or null when not `#RRGGBB` / `#RRGGBBAA`. */
|
|
13
|
+
fun parse(s: String): Int? {
|
|
14
|
+
var hex = s.trim()
|
|
15
|
+
if (hex.startsWith("#")) hex = hex.substring(1)
|
|
16
|
+
if (hex.length != 6 && hex.length != 8) return null
|
|
17
|
+
if (!hex.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }) return null
|
|
18
|
+
val value = hex.toLong(16)
|
|
19
|
+
val r: Long; val g: Long; val b: Long; val a: Long
|
|
20
|
+
if (hex.length == 6) {
|
|
21
|
+
r = (value shr 16) and 0xff; g = (value shr 8) and 0xff; b = value and 0xff; a = 0xff
|
|
22
|
+
} else {
|
|
23
|
+
r = (value shr 24) and 0xff; g = (value shr 16) and 0xff; b = (value shr 8) and 0xff; a = value and 0xff
|
|
24
|
+
}
|
|
25
|
+
return ((a shl 24) or (r shl 16) or (g shl 8) or b).toInt()
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Copyright (c) 2026 expo-secure-keypad-jsi contributors. MIT License.
|
|
2
|
+
package expo.modules.securekeypadjsi
|
|
3
|
+
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import android.graphics.Canvas
|
|
6
|
+
import android.graphics.Color
|
|
7
|
+
import android.graphics.Paint
|
|
8
|
+
import android.graphics.RectF
|
|
9
|
+
import android.graphics.Typeface
|
|
10
|
+
import com.facebook.react.common.assets.ReactFontManager
|
|
11
|
+
|
|
12
|
+
/** Identity 0..9 order: the fallback whenever the native CSPRNG layout is unavailable. */
|
|
13
|
+
internal fun identityLayout(): ByteArray = ByteArray(10) { it.toByte() }
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The theme surface both canvas views expose, so [SecureKeypadJsiView] applies
|
|
17
|
+
* a JS theme map through one code path instead of one per keypad type.
|
|
18
|
+
*/
|
|
19
|
+
internal interface ThemedKeypadView {
|
|
20
|
+
var keyColor: Int
|
|
21
|
+
var keyTextColor: Int
|
|
22
|
+
var actionTextColor: Int
|
|
23
|
+
var keyCornerRadiusDp: Float
|
|
24
|
+
var digitTextSizeDp: Float
|
|
25
|
+
|
|
26
|
+
/** Digit / character glyphs only; action glyphs stay on the system font. */
|
|
27
|
+
var fontFamily: String?
|
|
28
|
+
|
|
29
|
+
/** false hides the press feedback entirely, for apps that cannot block capture. */
|
|
30
|
+
var pressedHighlight: Boolean
|
|
31
|
+
|
|
32
|
+
fun invalidate()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A held key is drawn at 70% opacity. One rule reads correctly on any theme,
|
|
37
|
+
* so no per-theme luminance branch is needed.
|
|
38
|
+
*/
|
|
39
|
+
internal fun pressedDim(color: Int, held: Boolean): Int =
|
|
40
|
+
if (held) {
|
|
41
|
+
Color.argb((Color.alpha(color) * 0.7f).toInt(), Color.red(color), Color.green(color), Color.blue(color))
|
|
42
|
+
} else {
|
|
43
|
+
color
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Shared by both canvas views: baseline-corrected centred text. */
|
|
47
|
+
internal fun drawCentered(canvas: Canvas, text: String, r: RectF, paint: Paint) {
|
|
48
|
+
val cx = r.centerX()
|
|
49
|
+
val cy = r.centerY() - (paint.descent() + paint.ascent()) / 2f
|
|
50
|
+
canvas.drawText(text, cx, cy, paint)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Resolves a theme `fontFamily` to a [Typeface], memoised because `onDraw`
|
|
56
|
+
* runs on every press and a miss hits the asset manager.
|
|
57
|
+
*
|
|
58
|
+
* [ReactFontManager] is the same registry `expo-font`'s `loadAsync` writes
|
|
59
|
+
* into, so a family loaded from JS resolves here under the name JS used, and
|
|
60
|
+
* `assets/fonts/<name>.ttf` bundled at build time resolves too. An unknown
|
|
61
|
+
* name degrades to [Typeface.create]'s own fallback (the system font) rather
|
|
62
|
+
* than throwing.
|
|
63
|
+
*/
|
|
64
|
+
internal object EskFont {
|
|
65
|
+
private val cache = HashMap<String, Typeface>()
|
|
66
|
+
|
|
67
|
+
fun resolve(context: Context, family: String?): Typeface? {
|
|
68
|
+
if (family.isNullOrEmpty()) return null
|
|
69
|
+
cache[family]?.let { return it }
|
|
70
|
+
val typeface = try {
|
|
71
|
+
ReactFontManager.getInstance().getTypeface(family, Typeface.NORMAL, context.assets)
|
|
72
|
+
} catch (_: Throwable) {
|
|
73
|
+
// No React font registry on the classpath, or a corrupt font file.
|
|
74
|
+
Typeface.create(family, Typeface.NORMAL)
|
|
75
|
+
}
|
|
76
|
+
return typeface?.also { cache[family] = it }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { type StyleProp, type ViewStyle } from 'react-native';
|
|
3
|
+
import type { KeypadError, KeypadTheme, KeypadType, SecureKeypadHandle, ShuffleMode } from './SecureKeypadJsi.types';
|
|
4
|
+
export interface SecureKeypadProps {
|
|
5
|
+
/** Server RSA-2048+ public key, PEM SubjectPublicKeyInfo. Required. */
|
|
6
|
+
publicKey: string;
|
|
7
|
+
/**
|
|
8
|
+
* 'digit' = 3x4 shuffled PIN pad. 'full' = QWERTY keyboard with shift and
|
|
9
|
+
* symbol layers (its Enter key submits). Default 'digit'.
|
|
10
|
+
*/
|
|
11
|
+
keypadType?: KeypadType;
|
|
12
|
+
/** Defaults: 4 / 6 ('digit') or 4 / 64 ('full'). Clamped to 4~12 / 4~64. */
|
|
13
|
+
minLength?: number;
|
|
14
|
+
maxLength?: number;
|
|
15
|
+
shuffle?: ShuffleMode;
|
|
16
|
+
/**
|
|
17
|
+
* Auto-encrypt at maxLength. Default true for 'digit', false for 'full' —
|
|
18
|
+
* passwords are variable-length, so the keyboard's Enter key submits.
|
|
19
|
+
*/
|
|
20
|
+
autoSubmit?: boolean;
|
|
21
|
+
theme?: KeypadTheme;
|
|
22
|
+
/**
|
|
23
|
+
* Applied to the keypad, or to the container when `accessory` is set.
|
|
24
|
+
*/
|
|
25
|
+
style?: StyleProp<ViewStyle>;
|
|
26
|
+
/**
|
|
27
|
+
* Rendered directly above the keypad, inside the same container — the place
|
|
28
|
+
* for a masked-length indicator or a confirm button when the keypad covers
|
|
29
|
+
* the field it fills (bottom sheets, overlays). Ordinary React content: it
|
|
30
|
+
* never receives the entered value, only what you pass it.
|
|
31
|
+
*/
|
|
32
|
+
accessory?: React.ReactNode;
|
|
33
|
+
/** Ciphertext envelope JSON — send this to your server. */
|
|
34
|
+
onComplete?: (envelope: string) => void;
|
|
35
|
+
/** Drive your masked-dot UI from this. */
|
|
36
|
+
onDigitCountChanged?: (count: number) => void;
|
|
37
|
+
/**
|
|
38
|
+
* Key rejected at mount ('arm'), encryption failed on submit ('submit'), or
|
|
39
|
+
* Android dropped an obscured touch ('input', "ERR_OBSCURED_TOUCH").
|
|
40
|
+
*/
|
|
41
|
+
onError?: (error: KeypadError) => void;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Unwraps `nativeEvent` for the caller. PIN digits never enter JavaScript —
|
|
45
|
+
* `onComplete` yields only the ciphertext envelope. The buffer is wiped
|
|
46
|
+
* whenever the app backgrounds or the view leaves the window; that is not
|
|
47
|
+
* configurable.
|
|
48
|
+
*/
|
|
49
|
+
export declare const SecureKeypad: React.ForwardRefExoticComponent<SecureKeypadProps & React.RefAttributes<SecureKeypadHandle>>;
|
|
50
|
+
export default SecureKeypad;
|
|
51
|
+
//# sourceMappingURL=SecureKeypad.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"SecureKeypad.d.ts","sourceRoot":"","sources":["../src/SecureKeypad.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAC/B,OAAO,EAAoB,KAAK,SAAS,EAAE,KAAK,SAAS,EAAE,MAAM,cAAc,CAAC;AAEhF,OAAO,KAAK,EACV,WAAW,EACX,WAAW,EACX,UAAU,EACV,kBAAkB,EAClB,WAAW,EACZ,MAAM,yBAAyB,CAAC;AAGjC,MAAM,WAAW,iBAAiB;IAChC,uEAAuE;IACvE,SAAS,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,4EAA4E;IAC5E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,WAAW,CAAC;IACtB;;;OAGG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB;;OAEG;IACH,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC7B;;;;;OAKG;IACH,SAAS,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IAE5B,2DAA2D;IAC3D,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC,0CAA0C;IAC1C,mBAAmB,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAC9C;;;OAGG;IACH,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,IAAI,CAAC;CACxC;AAwBD;;;;;GAKG;AACH,eAAO,MAAM,YAAY,8FAsDxB,CAAC;AAIF,eAAe,YAAY,CAAC"}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { StyleSheet, View } from 'react-native';
|
|
3
|
+
import SecureKeypadJsiView from './SecureKeypadJsiView';
|
|
4
|
+
// Yoga sizes Expo views purely from style — their shadow node has no measure
|
|
5
|
+
// function, so a keypad with no height renders at zero. Applied only when the
|
|
6
|
+
// caller gave nothing to size it by. 3x4 near-square keys for the digit pad;
|
|
7
|
+
// the QWERTY keyboard is 5 rows of ~10 keys, so it is wider than tall.
|
|
8
|
+
const digitSizing = { aspectRatio: 3 / 4 };
|
|
9
|
+
const fullSizing = { aspectRatio: 4 / 3 };
|
|
10
|
+
// With an accessory the caller's size lands on the container; the keypad
|
|
11
|
+
// then fills whatever the accessory leaves.
|
|
12
|
+
const fillRest = { flex: 1 };
|
|
13
|
+
function hasOwnSize(style) {
|
|
14
|
+
const flat = StyleSheet.flatten(style);
|
|
15
|
+
if (!flat)
|
|
16
|
+
return false;
|
|
17
|
+
return (flat.height != null ||
|
|
18
|
+
flat.aspectRatio != null ||
|
|
19
|
+
flat.flex != null ||
|
|
20
|
+
flat.flexGrow != null ||
|
|
21
|
+
flat.flexBasis != null);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Unwraps `nativeEvent` for the caller. PIN digits never enter JavaScript —
|
|
25
|
+
* `onComplete` yields only the ciphertext envelope. The buffer is wiped
|
|
26
|
+
* whenever the app backgrounds or the view leaves the window; that is not
|
|
27
|
+
* configurable.
|
|
28
|
+
*/
|
|
29
|
+
export const SecureKeypad = React.forwardRef((props, ref) => {
|
|
30
|
+
const { publicKey, keypadType = 'digit', minLength = 4, maxLength = keypadType === 'full' ? 64 : 6, shuffle = 'mount', autoSubmit = keypadType !== 'full', theme, style, accessory, onComplete, onDigitCountChanged, onError, } = props;
|
|
31
|
+
const sized = React.useMemo(() => hasOwnSize(style), [style]);
|
|
32
|
+
const defaultSizing = keypadType === 'full' ? fullSizing : digitSizing;
|
|
33
|
+
const hasAccessory = accessory != null && accessory !== false;
|
|
34
|
+
let keypadStyle;
|
|
35
|
+
if (hasAccessory) {
|
|
36
|
+
keypadStyle = sized ? fillRest : defaultSizing;
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
keypadStyle = sized ? style : [defaultSizing, style];
|
|
40
|
+
}
|
|
41
|
+
const keypad = (<SecureKeypadJsiView ref={ref} publicKey={publicKey} keypadType={keypadType} minLength={minLength} maxLength={maxLength} shuffle={shuffle} autoSubmit={autoSubmit} theme={theme} style={keypadStyle} onComplete={(e) => onComplete?.(e.nativeEvent.envelope)} onDigitCountChanged={(e) => onDigitCountChanged?.(e.nativeEvent.count)} onError={(e) => onError?.(e.nativeEvent)}/>);
|
|
42
|
+
if (!hasAccessory)
|
|
43
|
+
return keypad;
|
|
44
|
+
return (<View style={style}>
|
|
45
|
+
{accessory}
|
|
46
|
+
{keypad}
|
|
47
|
+
</View>);
|
|
48
|
+
});
|
|
49
|
+
SecureKeypad.displayName = 'SecureKeypad';
|
|
50
|
+
export default SecureKeypad;
|
|
51
|
+
//# sourceMappingURL=SecureKeypad.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"SecureKeypad.js","sourceRoot":"","sources":["../src/SecureKeypad.tsx"],"names":[],"mappings":"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","sourcesContent":["import * as React from 'react';\nimport { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';\n\nimport type {\n KeypadError,\n KeypadTheme,\n KeypadType,\n SecureKeypadHandle,\n ShuffleMode,\n} from './SecureKeypadJsi.types';\nimport SecureKeypadJsiView from './SecureKeypadJsiView';\n\nexport interface SecureKeypadProps {\n /** Server RSA-2048+ public key, PEM SubjectPublicKeyInfo. Required. */\n publicKey: string;\n /**\n * 'digit' = 3x4 shuffled PIN pad. 'full' = QWERTY keyboard with shift and\n * symbol layers (its Enter key submits). Default 'digit'.\n */\n keypadType?: KeypadType;\n /** Defaults: 4 / 6 ('digit') or 4 / 64 ('full'). Clamped to 4~12 / 4~64. */\n minLength?: number;\n maxLength?: number;\n shuffle?: ShuffleMode;\n /**\n * Auto-encrypt at maxLength. Default true for 'digit', false for 'full' —\n * passwords are variable-length, so the keyboard's Enter key submits.\n */\n autoSubmit?: boolean;\n theme?: KeypadTheme;\n /**\n * Applied to the keypad, or to the container when `accessory` is set.\n */\n style?: StyleProp<ViewStyle>;\n /**\n * Rendered directly above the keypad, inside the same container — the place\n * for a masked-length indicator or a confirm button when the keypad covers\n * the field it fills (bottom sheets, overlays). Ordinary React content: it\n * never receives the entered value, only what you pass it.\n */\n accessory?: React.ReactNode;\n\n /** Ciphertext envelope JSON — send this to your server. */\n onComplete?: (envelope: string) => void;\n /** Drive your masked-dot UI from this. */\n onDigitCountChanged?: (count: number) => void;\n /**\n * Key rejected at mount ('arm'), encryption failed on submit ('submit'), or\n * Android dropped an obscured touch ('input', \"ERR_OBSCURED_TOUCH\").\n */\n onError?: (error: KeypadError) => void;\n}\n\n// Yoga sizes Expo views purely from style — their shadow node has no measure\n// function, so a keypad with no height renders at zero. Applied only when the\n// caller gave nothing to size it by. 3x4 near-square keys for the digit pad;\n// the QWERTY keyboard is 5 rows of ~10 keys, so it is wider than tall.\nconst digitSizing: ViewStyle = { aspectRatio: 3 / 4 };\nconst fullSizing: ViewStyle = { aspectRatio: 4 / 3 };\n// With an accessory the caller's size lands on the container; the keypad\n// then fills whatever the accessory leaves.\nconst fillRest: ViewStyle = { flex: 1 };\n\nfunction hasOwnSize(style: StyleProp<ViewStyle>): boolean {\n const flat = StyleSheet.flatten(style);\n if (!flat) return false;\n return (\n flat.height != null ||\n flat.aspectRatio != null ||\n flat.flex != null ||\n flat.flexGrow != null ||\n flat.flexBasis != null\n );\n}\n\n/**\n * Unwraps `nativeEvent` for the caller. PIN digits never enter JavaScript —\n * `onComplete` yields only the ciphertext envelope. The buffer is wiped\n * whenever the app backgrounds or the view leaves the window; that is not\n * configurable.\n */\nexport const SecureKeypad = React.forwardRef<SecureKeypadHandle, SecureKeypadProps>(\n (props, ref) => {\n const {\n publicKey,\n keypadType = 'digit',\n minLength = 4,\n maxLength = keypadType === 'full' ? 64 : 6,\n shuffle = 'mount',\n autoSubmit = keypadType !== 'full',\n theme,\n style,\n accessory,\n onComplete,\n onDigitCountChanged,\n onError,\n } = props;\n\n const sized = React.useMemo(() => hasOwnSize(style), [style]);\n const defaultSizing = keypadType === 'full' ? fullSizing : digitSizing;\n const hasAccessory = accessory != null && accessory !== false;\n\n let keypadStyle: StyleProp<ViewStyle>;\n if (hasAccessory) {\n keypadStyle = sized ? fillRest : defaultSizing;\n } else {\n keypadStyle = sized ? style : [defaultSizing, style];\n }\n\n const keypad = (\n <SecureKeypadJsiView\n ref={ref}\n publicKey={publicKey}\n keypadType={keypadType}\n minLength={minLength}\n maxLength={maxLength}\n shuffle={shuffle}\n autoSubmit={autoSubmit}\n theme={theme}\n style={keypadStyle}\n onComplete={(e) => onComplete?.(e.nativeEvent.envelope)}\n onDigitCountChanged={(e) => onDigitCountChanged?.(e.nativeEvent.count)}\n onError={(e) => onError?.(e.nativeEvent)}\n />\n );\n\n if (!hasAccessory) return keypad;\n\n return (\n <View style={style}>\n {accessory}\n {keypad}\n </View>\n );\n }\n);\n\nSecureKeypad.displayName = 'SecureKeypad';\n\nexport default SecureKeypad;\n"]}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { StyleProp, ViewStyle } from 'react-native';
|
|
2
|
+
/**
|
|
3
|
+
* Which step failed. 'arm' = public key rejected at mount, 'submit' =
|
|
4
|
+
* encryption failed, 'input' = a touch was rejected before reaching the
|
|
5
|
+
* keypad (Android tapjacking filter; see KeypadError.code). None of these is
|
|
6
|
+
* a security finding — hooking, device posture and screen capture are out of
|
|
7
|
+
* scope, see README.
|
|
8
|
+
*/
|
|
9
|
+
export type KeypadErrorPhase = 'arm' | 'submit' | 'input';
|
|
10
|
+
export type ShuffleMode = 'mount' | 'perKey' | 'off';
|
|
11
|
+
/**
|
|
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.
|
|
17
|
+
*/
|
|
18
|
+
export type KeypadType = 'digit' | 'full';
|
|
19
|
+
export interface KeypadError {
|
|
20
|
+
/**
|
|
21
|
+
* Stable code, e.g. "ERR_WEAK_KEY", "ERR_TOO_SHORT". Phase 'input' only
|
|
22
|
+
* emits "ERR_OBSCURED_TOUCH": Android dropped a touch because another window
|
|
23
|
+
* (overlay app, screen dimmer) was drawn over the keypad. The tap was never
|
|
24
|
+
* registered — tell the user to close the overlay.
|
|
25
|
+
*/
|
|
26
|
+
code: string;
|
|
27
|
+
phase: KeypadErrorPhase;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Colors are #RRGGBB or #RRGGBBAA. Sizes are density-independent (dp on
|
|
31
|
+
* Android, points on iOS), so the same number looks the same on both.
|
|
32
|
+
*/
|
|
33
|
+
export interface KeypadTheme {
|
|
34
|
+
keyColor?: string;
|
|
35
|
+
keyTextColor?: string;
|
|
36
|
+
actionTextColor?: string;
|
|
37
|
+
cornerRadius?: number;
|
|
38
|
+
digitTextSize?: number;
|
|
39
|
+
/**
|
|
40
|
+
* Font for the digit / character glyphs. Any name the platform can already
|
|
41
|
+
* resolve: a family registered by `expo-font`'s `loadAsync` / `useFonts`, a
|
|
42
|
+
* font bundled at build time, or a system family ("Courier", "monospace").
|
|
43
|
+
* Unresolvable names fall back to the system font instead of throwing.
|
|
44
|
+
*
|
|
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.
|
|
49
|
+
*/
|
|
50
|
+
fontFamily?: string;
|
|
51
|
+
/**
|
|
52
|
+
* Whether a held key dims to 70% opacity (default true). NOTE: any visible
|
|
53
|
+
* press effect lets a screen recording reconstruct the input — block capture
|
|
54
|
+
* at the app level (FLAG_SECURE / UIScreen.isCaptured) or pass false here.
|
|
55
|
+
*/
|
|
56
|
+
pressedHighlight?: boolean;
|
|
57
|
+
}
|
|
58
|
+
export type OnCompleteEvent = {
|
|
59
|
+
nativeEvent: {
|
|
60
|
+
envelope: string;
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
export type OnDigitCountChangedEvent = {
|
|
64
|
+
nativeEvent: {
|
|
65
|
+
count: number;
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
export type OnErrorEvent = {
|
|
69
|
+
nativeEvent: KeypadError;
|
|
70
|
+
};
|
|
71
|
+
/** Raw native view props; most apps want `SecureKeypad` instead. */
|
|
72
|
+
export interface SecureKeypadJsiViewProps {
|
|
73
|
+
/** Server RSA-2048+ public key, PEM SubjectPublicKeyInfo. */
|
|
74
|
+
publicKey: string;
|
|
75
|
+
/** Default 'digit'. */
|
|
76
|
+
keypadType?: KeypadType;
|
|
77
|
+
/** Clamped to 4~12 ('digit') or 4~64 ('full'). */
|
|
78
|
+
minLength?: number;
|
|
79
|
+
maxLength?: number;
|
|
80
|
+
shuffle?: ShuffleMode;
|
|
81
|
+
autoSubmit?: boolean;
|
|
82
|
+
theme?: KeypadTheme;
|
|
83
|
+
onComplete?: (event: OnCompleteEvent) => void;
|
|
84
|
+
onDigitCountChanged?: (event: OnDigitCountChangedEvent) => void;
|
|
85
|
+
onError?: (event: OnErrorEvent) => void;
|
|
86
|
+
style?: StyleProp<ViewStyle>;
|
|
87
|
+
}
|
|
88
|
+
export interface SecureKeypadHandle {
|
|
89
|
+
/** Clears the entered digits; does not disarm. */
|
|
90
|
+
clear: () => Promise<void>;
|
|
91
|
+
/** Encrypts now if the PIN meets minLength. */
|
|
92
|
+
submit: () => Promise<void>;
|
|
93
|
+
}
|
|
94
|
+
//# sourceMappingURL=SecureKeypadJsi.types.d.ts.map
|
|
@@ -0,0 +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"}
|
|
@@ -0,0 +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"]}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import type { SecureKeypadHandle, SecureKeypadJsiViewProps } from './SecureKeypadJsi.types';
|
|
3
|
+
/** Most apps should use `SecureKeypad` instead. */
|
|
4
|
+
declare const SecureKeypadJsiView: React.ForwardRefExoticComponent<SecureKeypadJsiViewProps & React.RefAttributes<SecureKeypadHandle>>;
|
|
5
|
+
export default SecureKeypadJsiView;
|
|
6
|
+
//# sourceMappingURL=SecureKeypadJsiView.d.ts.map
|