expo-secure-keypad-jsi 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +393 -0
  3. package/SecureKeypadJsi.podspec +45 -0
  4. package/android/CMakeLists.txt +28 -0
  5. package/android/build.gradle +58 -0
  6. package/android/src/main/AndroidManifest.xml +2 -0
  7. package/android/src/main/cpp/JniBridge.cpp +118 -0
  8. package/android/src/main/java/expo/modules/securekeypadjsi/KeyboardCanvasView.kt +291 -0
  9. package/android/src/main/java/expo/modules/securekeypadjsi/KeypadCanvasView.kt +183 -0
  10. package/android/src/main/java/expo/modules/securekeypadjsi/PinSessionHandle.kt +54 -0
  11. package/android/src/main/java/expo/modules/securekeypadjsi/SecureKeypadJsiModule.kt +35 -0
  12. package/android/src/main/java/expo/modules/securekeypadjsi/SecureKeypadJsiView.kt +317 -0
  13. package/android/src/main/java/expo/modules/securekeypadjsi/ThemeColor.kt +27 -0
  14. package/android/src/main/java/expo/modules/securekeypadjsi/ThemedKeypadView.kt +78 -0
  15. package/build/SecureKeypad.d.ts +51 -0
  16. package/build/SecureKeypad.d.ts.map +1 -0
  17. package/build/SecureKeypad.js +51 -0
  18. package/build/SecureKeypad.js.map +1 -0
  19. package/build/SecureKeypadJsi.types.d.ts +94 -0
  20. package/build/SecureKeypadJsi.types.d.ts.map +1 -0
  21. package/build/SecureKeypadJsi.types.js +2 -0
  22. package/build/SecureKeypadJsi.types.js.map +1 -0
  23. package/build/SecureKeypadJsiView.d.ts +6 -0
  24. package/build/SecureKeypadJsiView.d.ts.map +1 -0
  25. package/build/SecureKeypadJsiView.js +10 -0
  26. package/build/SecureKeypadJsiView.js.map +1 -0
  27. package/build/index.d.ts +5 -0
  28. package/build/index.d.ts.map +1 -0
  29. package/build/index.js +5 -0
  30. package/build/index.js.map +1 -0
  31. package/common/CMakeLists.txt +49 -0
  32. package/common/include/esk/Base64.h +20 -0
  33. package/common/include/esk/Cleanse.h +17 -0
  34. package/common/include/esk/Error.h +37 -0
  35. package/common/include/esk/KeypadCore.h +69 -0
  36. package/common/include/esk/PinEncryptor.h +39 -0
  37. package/common/include/esk/PublicKeyValidator.h +37 -0
  38. package/common/include/esk/SecureBuffer.h +60 -0
  39. package/common/include/esk/SecureRandom.h +24 -0
  40. package/common/include/esk/esk_c_api.h +61 -0
  41. package/common/src/Base64.cpp +43 -0
  42. package/common/src/Error.cpp +21 -0
  43. package/common/src/KeypadCore.cpp +105 -0
  44. package/common/src/PinEncryptor.cpp +110 -0
  45. package/common/src/PublicKeyValidator.cpp +111 -0
  46. package/common/src/SecureBuffer.cpp +108 -0
  47. package/common/src/SecureRandom.cpp +42 -0
  48. package/common/src/esk_c_api.cpp +158 -0
  49. package/docs/BUILD.ko.md +33 -0
  50. package/docs/BUILD.md +36 -0
  51. package/docs/README.ko.md +286 -0
  52. package/expo-module.config.json +10 -0
  53. package/ios/EskKeypadBridge.h +35 -0
  54. package/ios/EskKeypadBridge.mm +77 -0
  55. package/ios/KeyboardCoreGraphicsView.swift +257 -0
  56. package/ios/KeypadCoreGraphicsView.swift +197 -0
  57. package/ios/SecureKeypadJsiModule.swift +26 -0
  58. package/ios/SecureKeypadJsiView.swift +278 -0
  59. package/package.json +80 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"SecureKeypadJsiView.d.ts","sourceRoot":"","sources":["../src/SecureKeypadJsiView.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAE/B,OAAO,KAAK,EAAE,kBAAkB,EAAE,wBAAwB,EAAE,MAAM,yBAAyB,CAAC;AAM5F,mDAAmD;AACnD,QAAA,MAAM,mBAAmB,qGAIxB,CAAC;AAIF,eAAe,mBAAmB,CAAC"}
@@ -0,0 +1,10 @@
1
+ import { requireNativeView } from 'expo';
2
+ import * as React from 'react';
3
+ const NativeView = requireNativeView('SecureKeypadJsi');
4
+ /** Most apps should use `SecureKeypad` instead. */
5
+ const SecureKeypadJsiView = React.forwardRef((props, ref) => {
6
+ return <NativeView {...props} ref={ref}/>;
7
+ });
8
+ SecureKeypadJsiView.displayName = 'SecureKeypadJsiView';
9
+ export default SecureKeypadJsiView;
10
+ //# sourceMappingURL=SecureKeypadJsiView.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"SecureKeypadJsiView.js","sourceRoot":"","sources":["../src/SecureKeypadJsiView.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAC;AACzC,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAI/B,MAAM,UAAU,GAEZ,iBAAiB,CAAC,iBAAiB,CAAC,CAAC;AAEzC,mDAAmD;AACnD,MAAM,mBAAmB,GAAG,KAAK,CAAC,UAAU,CAC1C,CAAC,KAAK,EAAE,GAAG,EAAE,EAAE;IACb,OAAO,CAAC,UAAU,CAAC,IAAI,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,EAAG,CAAC;AAC7C,CAAC,CACF,CAAC;AAEF,mBAAmB,CAAC,WAAW,GAAG,qBAAqB,CAAC;AAExD,eAAe,mBAAmB,CAAC","sourcesContent":["import { requireNativeView } from 'expo';\nimport * as React from 'react';\n\nimport type { SecureKeypadHandle, SecureKeypadJsiViewProps } from './SecureKeypadJsi.types';\n\nconst NativeView: React.ComponentType<\n SecureKeypadJsiViewProps & { ref?: React.Ref<SecureKeypadHandle> }\n> = requireNativeView('SecureKeypadJsi');\n\n/** Most apps should use `SecureKeypad` instead. */\nconst SecureKeypadJsiView = React.forwardRef<SecureKeypadHandle, SecureKeypadJsiViewProps>(\n (props, ref) => {\n return <NativeView {...props} ref={ref} />;\n }\n);\n\nSecureKeypadJsiView.displayName = 'SecureKeypadJsiView';\n\nexport default SecureKeypadJsiView;\n"]}
@@ -0,0 +1,5 @@
1
+ export { default as SecureKeypad } from './SecureKeypad';
2
+ export type { SecureKeypadProps } from './SecureKeypad';
3
+ export { default as SecureKeypadJsiView } from './SecureKeypadJsiView';
4
+ export * from './SecureKeypadJsi.types';
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,gBAAgB,CAAC;AACzD,YAAY,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AAExD,OAAO,EAAE,OAAO,IAAI,mBAAmB,EAAE,MAAM,uBAAuB,CAAC;AAEvE,cAAc,yBAAyB,CAAC"}
package/build/index.js ADDED
@@ -0,0 +1,5 @@
1
+ // PIN digits never enter the JS runtime — see README for the threat model.
2
+ export { default as SecureKeypad } from './SecureKeypad';
3
+ export { default as SecureKeypadJsiView } from './SecureKeypadJsiView';
4
+ export * from './SecureKeypadJsi.types';
5
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,2EAA2E;AAE3E,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAGzD,OAAO,EAAE,OAAO,IAAI,mBAAmB,EAAE,MAAM,uBAAuB,CAAC;AAEvE,cAAc,yBAAyB,CAAC","sourcesContent":["// PIN digits never enter the JS runtime — see README for the threat model.\n\nexport { default as SecureKeypad } from './SecureKeypad';\nexport type { SecureKeypadProps } from './SecureKeypad';\n\nexport { default as SecureKeypadJsiView } from './SecureKeypadJsiView';\n\nexport * from './SecureKeypadJsi.types';\n"]}
@@ -0,0 +1,49 @@
1
+ # Copyright (c) 2026 expo-secure-keypad-jsi contributors. MIT License.
2
+ #
3
+ # Reusable static library `esk_core` holding the platform-agnostic security
4
+ # core. Consumed by android/CMakeLists.txt (with prefab OpenSSL) and by
5
+ # tests/cpp/CMakeLists.txt (with host OpenSSL).
6
+ #
7
+ # The caller must provide the OpenSSL crypto target name via ESK_OPENSSL_TARGET
8
+ # before add_subdirectory():
9
+ # * host / CI: OpenSSL::Crypto (find_package(OpenSSL))
10
+ # * Android: openssl::crypto (prefab from the openssl-static AAR)
11
+
12
+ cmake_minimum_required(VERSION 3.22)
13
+ project(esk_core LANGUAGES CXX)
14
+
15
+ if(NOT DEFINED ESK_OPENSSL_TARGET)
16
+ set(ESK_OPENSSL_TARGET OpenSSL::Crypto)
17
+ endif()
18
+
19
+ # Listed explicitly, not globbed: a glob is cached at configure time, so adding
20
+ # or deleting a file leaves stale entries in an incremental NDK build.
21
+ set(ESK_CORE_SOURCES
22
+ src/Base64.cpp
23
+ src/Error.cpp
24
+ src/KeypadCore.cpp
25
+ src/PinEncryptor.cpp
26
+ src/PublicKeyValidator.cpp
27
+ src/SecureBuffer.cpp
28
+ src/SecureRandom.cpp
29
+ src/esk_c_api.cpp)
30
+ list(TRANSFORM ESK_CORE_SOURCES PREPEND "${CMAKE_CURRENT_SOURCE_DIR}/")
31
+
32
+ add_library(esk_core STATIC ${ESK_CORE_SOURCES})
33
+
34
+ target_include_directories(esk_core PUBLIC
35
+ "${CMAKE_CURRENT_SOURCE_DIR}/include")
36
+
37
+ target_compile_features(esk_core PUBLIC cxx_std_20)
38
+
39
+ set_target_properties(esk_core PROPERTIES
40
+ CXX_STANDARD 20
41
+ CXX_STANDARD_REQUIRED ON
42
+ CXX_EXTENSIONS OFF
43
+ POSITION_INDEPENDENT_CODE ON)
44
+
45
+ target_compile_options(esk_core PRIVATE
46
+ -Wall -Wextra -Wpedantic
47
+ $<$<CONFIG:Release>:-O2>)
48
+
49
+ target_link_libraries(esk_core PUBLIC ${ESK_OPENSSL_TARGET})
@@ -0,0 +1,20 @@
1
+ // Copyright (c) 2026 expo-secure-keypad-jsi contributors. MIT License.
2
+ #pragma once
3
+
4
+ #include <cstddef>
5
+ #include <cstdint>
6
+ #include <string>
7
+
8
+ namespace esk {
9
+ namespace base64 {
10
+
11
+ // Standard base64 (with padding) encode. Used only for ciphertext/nonce, which
12
+ // are not secret, so no constant-time requirement here.
13
+ std::string encode(const uint8_t* data, size_t len);
14
+
15
+ // Decode standard base64. Returns false on malformed input. Used by the host
16
+ // test suite's reference decryptor; the runtime paths only ever encode.
17
+ bool decode(const std::string& in, std::string& out);
18
+
19
+ } // namespace base64
20
+ } // namespace esk
@@ -0,0 +1,17 @@
1
+ // Copyright (c) 2026 expo-secure-keypad-jsi contributors. MIT License.
2
+ #pragma once
3
+
4
+ #include <cstddef>
5
+ #include <openssl/crypto.h>
6
+
7
+ namespace esk {
8
+
9
+ // Never memset() plaintext: the compiler is allowed to elide it, OPENSSL_cleanse
10
+ // is not.
11
+ inline void cleanse(void* ptr, size_t len) {
12
+ if (ptr != nullptr && len > 0) {
13
+ OPENSSL_cleanse(ptr, len);
14
+ }
15
+ }
16
+
17
+ } // namespace esk
@@ -0,0 +1,37 @@
1
+ // Copyright (c) 2026 expo-secure-keypad-jsi contributors. MIT License.
2
+ #pragma once
3
+
4
+ #include <stdexcept>
5
+ #include <string>
6
+
7
+ namespace esk {
8
+
9
+ // Reaches JS as the "ERR_..." strings in Error.cpp, so the names are API:
10
+ // renaming one is a breaking change.
11
+ enum class ErrorCode {
12
+ KeyParse,
13
+ KeyNotRsa,
14
+ WeakKey,
15
+ NotArmed,
16
+ Empty, // submit() with no digits
17
+ TooShort, // submit() below minLength
18
+ Encrypt,
19
+ Random,
20
+ Internal,
21
+ };
22
+
23
+ const char* errorCodeName(ErrorCode code);
24
+
25
+ class EskError : public std::runtime_error {
26
+ public:
27
+ EskError(ErrorCode code, const std::string& message)
28
+ : std::runtime_error(message), code_(code) {}
29
+
30
+ ErrorCode code() const noexcept { return code_; }
31
+ const char* codeName() const noexcept { return errorCodeName(code_); }
32
+
33
+ private:
34
+ ErrorCode code_;
35
+ };
36
+
37
+ } // namespace esk
@@ -0,0 +1,69 @@
1
+ // Copyright (c) 2026 expo-secure-keypad-jsi contributors. MIT License.
2
+ #pragma once
3
+
4
+ #include <array>
5
+ #include <functional>
6
+ #include <memory>
7
+ #include <optional>
8
+ #include <string>
9
+
10
+ #include "esk/PublicKeyValidator.h"
11
+ #include "esk/SecureBuffer.h"
12
+
13
+ namespace esk {
14
+
15
+ enum class KeypadState { Unarmed, Armed };
16
+
17
+ // Digit = numeric PIN pad, Full = QWERTY keyboard. Same wire format either way.
18
+ enum class KeypadType { Digit, Full };
19
+
20
+ // Owned by the platform native view. All methods must run on the owning (UI)
21
+ // thread — SecureBuffer enforces this.
22
+ class KeypadCore {
23
+ public:
24
+ // nowProvider is injected so the platform owns the clock and tests stay
25
+ // deterministic. Lengths clamp to [4,12] for Digit and [4,64] for Full.
26
+ explicit KeypadCore(std::function<int64_t()> nowProvider,
27
+ size_t minLength = kMinPinLength,
28
+ size_t maxLength = kMaxPinLength,
29
+ KeypadType type = KeypadType::Digit);
30
+ ~KeypadCore();
31
+
32
+ KeypadCore(const KeypadCore&) = delete;
33
+ KeypadCore& operator=(const KeypadCore&) = delete;
34
+
35
+ // nullopt on success; otherwise the error-code name (e.g. "ERR_WEAK_KEY").
36
+ std::optional<std::string> arm(const std::string& publicKeyPem);
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.
41
+ size_t pressDigit(uint8_t digit);
42
+ size_t pressKey(uint8_t asciiChar);
43
+ size_t backspace();
44
+ void clearPin();
45
+
46
+ // Encrypts, cleanses the buffer, returns the envelope JSON. Throws EskError
47
+ // on Empty / TooShort / NotArmed.
48
+ std::string submit();
49
+
50
+ size_t digitCount() const;
51
+ KeypadState state() const { return state_; }
52
+ KeypadType type() const { return type_; }
53
+
54
+ // The view uses this to place glyphs; the mapping never leaves native code.
55
+ std::array<uint8_t, 10> shuffledLayout() const;
56
+
57
+ void onHostBackground();
58
+
59
+ private:
60
+ SecureBuffer buffer_;
61
+ std::unique_ptr<PublicKey> key_;
62
+ std::function<int64_t()> nowProvider_;
63
+ size_t minLength_;
64
+ size_t maxLength_;
65
+ KeypadType type_ = KeypadType::Digit;
66
+ KeypadState state_ = KeypadState::Unarmed;
67
+ };
68
+
69
+ } // namespace esk
@@ -0,0 +1,39 @@
1
+ // Copyright (c) 2026 expo-secure-keypad-jsi contributors. MIT License.
2
+ #pragma once
3
+
4
+ #include <cstdint>
5
+ #include <string>
6
+
7
+ #include "esk/PublicKeyValidator.h"
8
+ #include "esk/SecureBuffer.h"
9
+
10
+ namespace esk {
11
+
12
+ // Wire format — servers decode against this, so it cannot change without a
13
+ // version bump. README carries the same table. Both keypad types emit it; the
14
+ // digit pad simply never fills more than 12 of the 64 secret bytes.
15
+ //
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
19
+ // | 92 4 zero
20
+ namespace payload {
21
+ inline constexpr uint8_t kMagic0 = 'S';
22
+ inline constexpr uint8_t kMagic1 = 'K';
23
+ inline constexpr uint8_t kVersion = 2;
24
+ inline constexpr size_t kSize = 96;
25
+ inline constexpr size_t kOffSecret = 4;
26
+ inline constexpr size_t kNonceSize = 16;
27
+ inline constexpr size_t kOffNonce = 68;
28
+ inline constexpr size_t kOffTimestamp = 84;
29
+ } // namespace payload
30
+
31
+ class PinEncryptor {
32
+ public:
33
+ // Returns the envelope JSON; throws EskError on failure. Does NOT clear
34
+ // `buffer` — KeypadCore owns that and cleanses after this returns.
35
+ static std::string encrypt(const SecureBuffer& buffer, const PublicKey& key,
36
+ int64_t nowUnixSeconds);
37
+ };
38
+
39
+ } // namespace esk
@@ -0,0 +1,37 @@
1
+ // Copyright (c) 2026 expo-secure-keypad-jsi contributors. MIT License.
2
+ #pragma once
3
+
4
+ #include <array>
5
+ #include <cstdint>
6
+ #include <memory>
7
+ #include <string>
8
+
9
+ #include <openssl/evp.h>
10
+
11
+ namespace esk {
12
+
13
+ // RAII wrapper over EVP_PKEY. A validated key is the precondition for arming.
14
+ class PublicKey {
15
+ public:
16
+ // Enforces: RSA, modulus in [minBits, maxBits], exponent odd and >= 65537.
17
+ // Throws EskError on any failure.
18
+ static std::unique_ptr<PublicKey> parse(const std::string& pem,
19
+ int minBits = 2048,
20
+ int maxBits = 8192);
21
+
22
+ ~PublicKey();
23
+ PublicKey(const PublicKey&) = delete;
24
+ PublicKey& operator=(const PublicKey&) = delete;
25
+
26
+ EVP_PKEY* evp() const { return pkey_; }
27
+
28
+ // Envelope `kid`: hex of the first 8 bytes of the SPKI SHA-256 digest.
29
+ std::string keyId() const;
30
+
31
+ private:
32
+ explicit PublicKey(EVP_PKEY* pkey) : pkey_(pkey) {}
33
+ EVP_PKEY* pkey_ = nullptr;
34
+ std::array<uint8_t, 32> spkiSha256_{};
35
+ };
36
+
37
+ } // namespace esk
@@ -0,0 +1,60 @@
1
+ // Copyright (c) 2026 expo-secure-keypad-jsi contributors. MIT License.
2
+ #pragma once
3
+
4
+ #include <cstddef>
5
+ #include <cstdint>
6
+ #include <functional>
7
+ #include <thread>
8
+
9
+ namespace esk {
10
+
11
+ // Digit keypad length caps (UI policy; the wire format is shared).
12
+ inline constexpr size_t kMaxPinLength = 12;
13
+ inline constexpr size_t kMinPinLength = 4;
14
+ // Full QWERTY keyboard hard cap and the payload's secret field size. 32 + 64 =
15
+ // 96-byte payload stays well inside the 190-byte RSA-2048 OAEP-SHA256 limit.
16
+ inline constexpr size_t kMaxSecretLength = 64;
17
+
18
+ // Only printable ASCII (no space) may enter the buffer; anything else is a
19
+ // caller bug, not user input.
20
+ inline constexpr bool isAllowedSecretByte(uint8_t ch) {
21
+ return ch > 0x20 && ch < 0x7F;
22
+ }
23
+
24
+ // Secret bytes on a single anonymous mmap'd page — never the heap, where
25
+ // realloc would leave un-cleansable copies. Single-owner (UI) thread, so no
26
+ // locking. Bytes are stored as the final ASCII characters ('0'..'9' for the
27
+ // digit keypad); PinEncryptor copies them into the payload verbatim.
28
+ class SecureBuffer {
29
+ public:
30
+ SecureBuffer();
31
+ ~SecureBuffer();
32
+
33
+ SecureBuffer(const SecureBuffer&) = delete;
34
+ SecureBuffer& operator=(const SecureBuffer&) = delete;
35
+ SecureBuffer(SecureBuffer&&) = delete;
36
+ SecureBuffer& operator=(SecureBuffer&&) = delete;
37
+
38
+ bool append(uint8_t asciiChar);
39
+ bool pop();
40
+ void clear();
41
+ size_t length() const;
42
+
43
+ // The only read path for the plaintext. Pointer is valid for the call only.
44
+ void withPlaintext(const std::function<void(const uint8_t*, size_t)>& fn) const;
45
+
46
+ // True if mlock() succeeded on the page. False means the swap guarantee is
47
+ // not in effect on this process (low RLIMIT_MEMLOCK); cleanse still runs.
48
+ bool isLocked() const { return locked_; }
49
+
50
+ private:
51
+ void assertOwnerThread() const;
52
+
53
+ uint8_t* page_ = nullptr;
54
+ size_t pageSize_ = 0;
55
+ size_t length_ = 0;
56
+ bool locked_ = false;
57
+ std::thread::id owner_;
58
+ };
59
+
60
+ } // namespace esk
@@ -0,0 +1,24 @@
1
+ // Copyright (c) 2026 expo-secure-keypad-jsi contributors. MIT License.
2
+ #pragma once
3
+
4
+ #include <array>
5
+ #include <cstddef>
6
+ #include <cstdint>
7
+
8
+ namespace esk {
9
+
10
+ // Thin wrapper over OpenSSL's CSPRNG (RAND_bytes). Throws EskError on failure
11
+ // rather than returning weak/predictable bytes.
12
+ namespace secure_random {
13
+
14
+ // Fills buf with cryptographically secure random bytes. Throws on RAND failure.
15
+ void bytes(uint8_t* buf, size_t len);
16
+
17
+ // Returns a shuffled 0..9 layout using Fisher-Yates seeded from RAND_bytes.
18
+ // The layout is generated natively and never leaves the native side except as
19
+ // on-screen glyph positions, so JS never learns which coordinate maps to which
20
+ // digit.
21
+ std::array<uint8_t, 10> shuffledDigits();
22
+
23
+ } // namespace secure_random
24
+ } // namespace esk
@@ -0,0 +1,61 @@
1
+ // Copyright (c) 2026 expo-secure-keypad-jsi contributors. MIT License.
2
+ //
3
+ // Flat C ABI over KeypadCore — the only surface the JNI and Objective-C++
4
+ // bridges call. Every function is a no-throw boundary: internal failures
5
+ // surface as error strings, a degraded return value, or are swallowed, never
6
+ // as an unwinding exception. Strings are NUL-terminated UTF-8.
7
+ #pragma once
8
+
9
+ #include <stdint.h>
10
+
11
+ #ifdef __cplusplus
12
+ extern "C" {
13
+ #endif
14
+
15
+ typedef struct esk_keypad esk_keypad;
16
+
17
+ // Unix seconds. Injected so the platform owns the clock.
18
+ typedef int64_t (*esk_now_cb)(void* user);
19
+
20
+ typedef struct {
21
+ int32_t min_length; // clamped to [4,12] (digit) or [4,64] (full)
22
+ int32_t max_length; // clamped to [4,12] (digit) or [4,64] (full)
23
+ int32_t keypad_type; // 0 = digit PIN pad, 1 = full QWERTY
24
+ } esk_config;
25
+
26
+ // NULL only on allocation failure.
27
+ esk_keypad* esk_keypad_create(const esk_config* config, esk_now_cb now_cb,
28
+ void* user);
29
+ void esk_keypad_destroy(esk_keypad* kp);
30
+
31
+ // NULL on success, else a static error-code string — do NOT free it.
32
+ const char* esk_keypad_arm(esk_keypad* kp, const char* pem);
33
+
34
+ // Key ops. Return the resulting entered-key count, or ESK_COUNT_ERROR (-1) if
35
+ // the core refused the call — in practice only a cross-thread access, which is
36
+ // a caller bug. Callers must not forward a negative count to JS as "0 entered":
37
+ // the buffer was NOT modified and still holds the previous input.
38
+ // esk_keypad_press takes a digit value 0..9; esk_keypad_press_key takes the
39
+ // final ASCII character (printable, no space — the view resolves shift/layer
40
+ // state first). Out-of-charset input is ignored, not an error.
41
+ #define ESK_COUNT_ERROR (-1)
42
+ int32_t esk_keypad_press(esk_keypad* kp, uint8_t digit);
43
+ int32_t esk_keypad_press_key(esk_keypad* kp, uint8_t ascii_char);
44
+ int32_t esk_keypad_backspace(esk_keypad* kp);
45
+ void esk_keypad_clear(esk_keypad* kp);
46
+
47
+ // Envelope JSON, caller frees with esk_free. NULL on failure, with a static
48
+ // error-code string written to *err_out (do NOT free that one).
49
+ char* esk_keypad_submit(esk_keypad* kp, const char** err_out);
50
+
51
+ // out10 must hold 10 bytes and be pre-filled with the identity layout; it is
52
+ // left untouched if the CSPRNG fails.
53
+ void esk_keypad_shuffled_layout(esk_keypad* kp, uint8_t* out10);
54
+
55
+ void esk_keypad_on_background(esk_keypad* kp);
56
+
57
+ void esk_free(char* s);
58
+
59
+ #ifdef __cplusplus
60
+ } // extern "C"
61
+ #endif
@@ -0,0 +1,43 @@
1
+ // Copyright (c) 2026 expo-secure-keypad-jsi contributors. MIT License.
2
+ #include "esk/Base64.h"
3
+
4
+ #include <openssl/evp.h>
5
+
6
+ #include <vector>
7
+
8
+ namespace esk {
9
+ namespace base64 {
10
+
11
+ std::string encode(const uint8_t* data, size_t len) {
12
+ if (len == 0) return std::string();
13
+ // EVP_EncodeBlock writes 4 chars per 3 bytes, plus a NUL.
14
+ const size_t outLen = 4 * ((len + 2) / 3);
15
+ std::string out(outLen, '\0');
16
+ const int written = EVP_EncodeBlock(reinterpret_cast<unsigned char*>(&out[0]),
17
+ data, static_cast<int>(len));
18
+ if (written < 0) return std::string();
19
+ out.resize(static_cast<size_t>(written));
20
+ return out;
21
+ }
22
+
23
+ bool decode(const std::string& in, std::string& out) {
24
+ if (in.empty()) {
25
+ out.clear();
26
+ return true;
27
+ }
28
+ std::vector<unsigned char> buf(3 * (in.size() / 4) + 3);
29
+ const int written = EVP_DecodeBlock(
30
+ buf.data(), reinterpret_cast<const unsigned char*>(in.data()),
31
+ static_cast<int>(in.size()));
32
+ if (written < 0) return false;
33
+ // EVP_DecodeBlock ignores '=' and always emits multiples of 3.
34
+ size_t realLen = static_cast<size_t>(written);
35
+ for (auto it = in.rbegin(); it != in.rend() && *it == '='; ++it) {
36
+ if (realLen > 0) --realLen;
37
+ }
38
+ out.assign(reinterpret_cast<char*>(buf.data()), realLen);
39
+ return true;
40
+ }
41
+
42
+ } // namespace base64
43
+ } // namespace esk
@@ -0,0 +1,21 @@
1
+ // Copyright (c) 2026 expo-secure-keypad-jsi contributors. MIT License.
2
+ #include "esk/Error.h"
3
+
4
+ namespace esk {
5
+
6
+ const char* errorCodeName(ErrorCode code) {
7
+ switch (code) {
8
+ case ErrorCode::KeyParse: return "ERR_KEY_PARSE";
9
+ case ErrorCode::KeyNotRsa: return "ERR_KEY_NOT_RSA";
10
+ case ErrorCode::WeakKey: return "ERR_WEAK_KEY";
11
+ case ErrorCode::NotArmed: return "ERR_NOT_ARMED";
12
+ case ErrorCode::Empty: return "ERR_EMPTY";
13
+ case ErrorCode::TooShort: return "ERR_TOO_SHORT";
14
+ case ErrorCode::Encrypt: return "ERR_ENCRYPT";
15
+ case ErrorCode::Random: return "ERR_RANDOM";
16
+ case ErrorCode::Internal: return "ERR_INTERNAL";
17
+ }
18
+ return "ERR_UNKNOWN";
19
+ }
20
+
21
+ } // namespace esk
@@ -0,0 +1,105 @@
1
+ // Copyright (c) 2026 expo-secure-keypad-jsi contributors. MIT License.
2
+ #include "esk/KeypadCore.h"
3
+
4
+ #include <algorithm>
5
+
6
+ #include "esk/Error.h"
7
+ #include "esk/PinEncryptor.h"
8
+ #include "esk/SecureRandom.h"
9
+
10
+ namespace esk {
11
+
12
+ KeypadCore::KeypadCore(std::function<int64_t()> nowProvider, size_t minLength,
13
+ size_t maxLength, KeypadType type)
14
+ : nowProvider_(std::move(nowProvider)), type_(type) {
15
+ const size_t cap =
16
+ type_ == KeypadType::Full ? kMaxSecretLength : kMaxPinLength;
17
+ minLength_ = std::clamp<size_t>(minLength, kMinPinLength, cap);
18
+ maxLength_ = std::clamp<size_t>(maxLength, kMinPinLength, cap);
19
+ if (minLength_ > maxLength_) minLength_ = maxLength_;
20
+ }
21
+
22
+ KeypadCore::~KeypadCore() = default;
23
+
24
+ std::optional<std::string> KeypadCore::arm(const std::string& publicKeyPem) {
25
+ try {
26
+ key_ = PublicKey::parse(publicKeyPem, 2048, 8192);
27
+ } catch (const EskError& e) {
28
+ // A rejected key must not leave the previous session's secret behind.
29
+ key_.reset();
30
+ buffer_.clear();
31
+ state_ = KeypadState::Unarmed;
32
+ return std::string(e.codeName());
33
+ }
34
+ buffer_.clear();
35
+ state_ = KeypadState::Armed;
36
+ return std::nullopt;
37
+ }
38
+
39
+ size_t KeypadCore::pressDigit(uint8_t digit) {
40
+ if (digit > 9) return buffer_.length();
41
+ return pressKey(static_cast<uint8_t>('0' + digit));
42
+ }
43
+
44
+ size_t KeypadCore::pressKey(uint8_t asciiChar) {
45
+ if (state_ != KeypadState::Armed) {
46
+ return buffer_.length();
47
+ }
48
+ const bool allowed = type_ == KeypadType::Full
49
+ ? isAllowedSecretByte(asciiChar)
50
+ : (asciiChar >= '0' && asciiChar <= '9');
51
+ if (!allowed) {
52
+ return buffer_.length();
53
+ }
54
+ // Enforce the configured maxLength_ (SecureBuffer only enforces the hard
55
+ // kMaxSecretLength cap). Silently ignore presses past the configured length.
56
+ if (buffer_.length() >= maxLength_) {
57
+ return buffer_.length();
58
+ }
59
+ buffer_.append(asciiChar);
60
+ return buffer_.length();
61
+ }
62
+
63
+ size_t KeypadCore::backspace() {
64
+ if (state_ != KeypadState::Armed) return buffer_.length();
65
+ buffer_.pop();
66
+ return buffer_.length();
67
+ }
68
+
69
+ void KeypadCore::clearPin() { buffer_.clear(); }
70
+
71
+ std::string KeypadCore::submit() {
72
+ if (state_ != KeypadState::Armed || !key_) {
73
+ throw EskError(ErrorCode::NotArmed, "keypad is not armed with a key");
74
+ }
75
+ const size_t len = buffer_.length();
76
+ if (len == 0) {
77
+ throw EskError(ErrorCode::Empty, "no digits entered");
78
+ }
79
+ if (len < minLength_) {
80
+ throw EskError(ErrorCode::TooShort, "PIN below configured minimum length");
81
+ }
82
+
83
+ const int64_t now = nowProvider_ ? nowProvider_() : 0;
84
+ std::string envelope;
85
+ try {
86
+ envelope = PinEncryptor::encrypt(buffer_, *key_, now);
87
+ } catch (...) {
88
+ buffer_.clear();
89
+ throw;
90
+ }
91
+ // Plaintext lifetime ends here.
92
+ buffer_.clear();
93
+ return envelope;
94
+ }
95
+
96
+ size_t KeypadCore::digitCount() const { return buffer_.length(); }
97
+
98
+ std::array<uint8_t, 10> KeypadCore::shuffledLayout() const {
99
+ return secure_random::shuffledDigits();
100
+ }
101
+
102
+ // Unconditional: the entered value must not outlive the foreground.
103
+ void KeypadCore::onHostBackground() { buffer_.clear(); }
104
+
105
+ } // namespace esk