diff --git a/packages/react-native-platform/src/platform/defaults.ts b/packages/react-native-platform/src/platform/defaults.ts index a348117..00ff88d 100644 --- a/packages/react-native-platform/src/platform/defaults.ts +++ b/packages/react-native-platform/src/platform/defaults.ts @@ -9,4 +9,4 @@ import { type PlatformDependencies } from '@okta/auth-foundation/core'; // }; // TODO: remove - placeholder -export const PlatformDefaults: PlatformDependencies = FoundationalPlatformDefaults; \ No newline at end of file +export const PlatformDefaults: PlatformDependencies = FoundationalPlatformDefaults; diff --git a/packages/react-native-webcrypto-bridge/android/build.gradle b/packages/react-native-webcrypto-bridge/android/build.gradle index c242f10..4886a7b 100644 --- a/packages/react-native-webcrypto-bridge/android/build.gradle +++ b/packages/react-native-webcrypto-bridge/android/build.gradle @@ -19,11 +19,11 @@ def safeExtGet(prop, fallback) { } android { - compileSdkVersion safeExtGet('compileSdkVersion', 33) + compileSdkVersion safeExtGet('compileSdkVersion', 35) defaultConfig { minSdkVersion safeExtGet('minSdkVersion', 23) - targetSdkVersion safeExtGet('targetSdkVersion', 33) + targetSdkVersion safeExtGet('targetSdkVersion', 35) versionCode 1 versionName "1.0" } @@ -52,4 +52,4 @@ repositories { dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation 'com.facebook.react:react-native:+' -} \ No newline at end of file +} diff --git a/packages/react-native-webcrypto-bridge/android/main/java/com/okta/webcryptobridge/WebCryptoBridgeModule.kt b/packages/react-native-webcrypto-bridge/android/main/java/com/okta/webcryptobridge/WebCryptoBridgeModule.kt index f9dff52..e29228c 100644 --- a/packages/react-native-webcrypto-bridge/android/main/java/com/okta/webcryptobridge/WebCryptoBridgeModule.kt +++ b/packages/react-native-webcrypto-bridge/android/main/java/com/okta/webcryptobridge/WebCryptoBridgeModule.kt @@ -19,27 +19,13 @@ class WebCryptoBridgeModule(reactContext: ReactApplicationContext) : data class KeyPairEntry( val publicKey: PublicKey, - val privateKey: PrivateKey, + val privateKey: PrivateKey?, val extractable: Boolean ) override fun getName(): String = NAME // MARK: - Helper Methods - - private fun readableArrayToByteArray(array: ReadableArray): ByteArray { - val bytes = ByteArray(array.size()) - for (i in 0 until array.size()) { - bytes[i] = array.getInt(i).toByte() - } - return bytes - } - - private fun byteArrayToWritableArray(bytes: ByteArray): WritableArray { - val array = Arguments.createArray() - bytes.forEach { array.pushInt(it.toInt() and 0xFF) } - return array - } private fun toUnsignedByteArray(value: BigInteger): ByteArray { val bytes = value.toByteArray() @@ -72,12 +58,12 @@ class WebCryptoBridgeModule(reactContext: ReactApplicationContext) : // MARK: - Synchronous Methods @ReactMethod(isBlockingSynchronousMethod = true) - fun getRandomValues(length: Double): WritableArray { + fun getRandomValues(length: Double): String { val len = length.toInt() val random = SecureRandom() val bytes = ByteArray(len) random.nextBytes(bytes) - return byteArrayToWritableArray(bytes) + return Base64.encodeToString(bytes, Base64.NO_WRAP) } @ReactMethod(isBlockingSynchronousMethod = true) @@ -90,7 +76,7 @@ class WebCryptoBridgeModule(reactContext: ReactApplicationContext) : @ReactMethod fun digest( algorithm: String, - data: ReadableArray, + data: String, promise: Promise ) { try { @@ -99,12 +85,11 @@ class WebCryptoBridgeModule(reactContext: ReactApplicationContext) : return } - val inputData = readableArrayToByteArray(data) + val inputData = Base64.decode(data, Base64.NO_WRAP) val digest = MessageDigest.getInstance("SHA-256") val hash = digest.digest(inputData) - val result = byteArrayToWritableArray(hash) - promise.resolve(result) + promise.resolve(Base64.encodeToString(hash, Base64.NO_WRAP)) } catch (e: Exception) { promise.reject("digest_failed", "Failed to compute digest", e) } @@ -167,13 +152,13 @@ class WebCryptoBridgeModule(reactContext: ReactApplicationContext) : return } - val keyPairEntry = keyStore[keyId] + val keyPairEntry = synchronized(keyStore) { keyStore[keyId] } if (keyPairEntry == null) { promise.reject("key_not_found", "Key not found") return } - val key = if (keyType == "public") keyPair.public else keyPair.private + val key = if (keyType == "public") keyPairEntry.publicKey else keyPairEntry.privateKey val rsaPublicKey = key as? java.security.interfaces.RSAPublicKey if (rsaPublicKey == null) { @@ -181,19 +166,11 @@ class WebCryptoBridgeModule(reactContext: ReactApplicationContext) : return } - // Extract modulus and exponent - val modulus = rsaPublicKey.modulus.toByteArray() - val exponent = rsaPublicKey.publicExponent.toByteArray() - - // Remove leading zero bytes if present (BigInteger adds these for sign) - val modulusClean = if (modulus[0].toInt() == 0) modulus.copyOfRange(1, modulus.size) else modulus - val exponentClean = if (exponent[0].toInt() == 0) exponent.copyOfRange(1, exponent.size) else exponent - val jwk = JSONObject() jwk.put("kty", "RSA") jwk.put("alg", "RS256") - jwk.put("n", base64URLEncode(modulusClean)) - jwk.put("e", base64URLEncode(exponentClean)) + jwk.put("n", base64URLEncode(toUnsignedByteArray(rsaPublicKey.modulus))) + jwk.put("e", base64URLEncode(toUnsignedByteArray(rsaPublicKey.publicExponent))) promise.resolve(jwk.toString()) } catch (e: Exception) { @@ -230,17 +207,21 @@ class WebCryptoBridgeModule(reactContext: ReactApplicationContext) : val modulusBytes = base64URLDecode(nString) val exponentBytes = base64URLDecode(eString) - // Build ASN.1 DER encoded RSA public key - val publicKeyData = constructRSAPublicKeyData(modulusBytes, exponentBytes) + val modulus = BigInteger(1, modulusBytes) + val exponent = BigInteger(1, exponentBytes) - // Import the key val keyFactory = KeyFactory.getInstance("RSA") - val keySpec = X509EncodedKeySpec(publicKeyData) + val keySpec = RSAPublicKeySpec(modulus, exponent) val publicKey = keyFactory.generatePublic(keySpec) val keyId = UUID.randomUUID().toString() - // Store as KeyPair with null private key - keyStore[keyId] = KeyPair(publicKey, null) + synchronized(keyStore) { + keyStore[keyId] = KeyPairEntry( + publicKey = publicKey, + privateKey = null, + extractable = extractable + ) + } promise.resolve(keyId) } catch (e: Exception) { @@ -252,25 +233,30 @@ class WebCryptoBridgeModule(reactContext: ReactApplicationContext) : fun sign( algorithmJson: String, keyId: String, - data: ReadableArray, + data: String, promise: Promise ) { try { - val keyPairEntry = keyStore[keyId] + val keyPairEntry = synchronized(keyStore) { keyStore[keyId] } if (keyPairEntry == null) { promise.reject("key_not_found", "Key not found") return } - val inputData = readableArrayToByteArray(data) + val privateKey = keyPairEntry.privateKey + if (privateKey == null) { + promise.reject("key_not_found", "Private key not available for this key") + return + } + + val inputData = Base64.decode(data, Base64.NO_WRAP) val signature = Signature.getInstance("SHA256withRSA") - signature.initSign(keyPairEntry.privateKey) + signature.initSign(privateKey) signature.update(inputData) val signatureBytes = signature.sign() - val result = byteArrayToWritableArray(signatureBytes) - promise.resolve(result) + promise.resolve(Base64.encodeToString(signatureBytes, Base64.NO_WRAP)) } catch (e: Exception) { promise.reject("signing_failed", "Failed to sign data", e) } @@ -280,19 +266,19 @@ class WebCryptoBridgeModule(reactContext: ReactApplicationContext) : fun verify( algorithmJson: String, keyId: String, - signatureArray: ReadableArray, - data: ReadableArray, + signatureBase64: String, + data: String, promise: Promise ) { try { - val keyPairEntry = keyStore[keyId] + val keyPairEntry = synchronized(keyStore) { keyStore[keyId] } if (keyPairEntry == null) { promise.reject("key_not_found", "Key not found") return } - val inputData = readableArrayToByteArray(data) - val signatureBytes = readableArrayToByteArray(signatureArray) + val inputData = Base64.decode(data, Base64.NO_WRAP) + val signatureBytes = Base64.decode(signatureBase64, Base64.NO_WRAP) val signature = Signature.getInstance("SHA256withRSA") signature.initVerify(keyPairEntry.publicKey) @@ -305,76 +291,4 @@ class WebCryptoBridgeModule(reactContext: ReactApplicationContext) : } } - // MARK: - ASN.1 Encoding Helpers - - private fun constructRSAPublicKeyData(modulus: ByteArray, exponent: ByteArray): ByteArray { - // Build X.509 SubjectPublicKeyInfo structure - // This matches the Swift implementation - - var modulusBytes = modulus - val exponentBytes = exponent - - // Ensure modulus starts with 0x00 if MSB is set - if (modulusBytes[0].toInt() and 0x80 != 0) { - modulusBytes = byteArrayOf(0x00) + modulusBytes - } - - // Build inner SEQUENCE: SEQUENCE { INTEGER modulus, INTEGER exponent } - val innerSequence = encodeASN1Integer(modulusBytes) + encodeASN1Integer(exponentBytes) - val innerSequenceEncoded = encodeASN1Sequence(innerSequence) - - // Wrap in BIT STRING - val bitString = byteArrayOf(0x00) + innerSequenceEncoded - val bitStringEncoded = encodeASN1BitString(bitString) - - // Algorithm identifier: SEQUENCE { OID rsaEncryption, NULL } - val rsaOID = byteArrayOf( - 0x06, 0x09, 0x2a.toByte(), 0x86.toByte(), 0x48, 0x86.toByte(), - 0xf7.toByte(), 0x0d, 0x01, 0x01, 0x01 - ) - val nullTag = byteArrayOf(0x05, 0x00) - val algorithmIdentifier = encodeASN1Sequence(rsaOID + nullTag) - - // Build outer SEQUENCE - val outerSequence = algorithmIdentifier + bitStringEncoded - return encodeASN1Sequence(outerSequence) - } - - private fun encodeASN1Integer(data: ByteArray): ByteArray { - var intData = data - - // Remove leading zeros (but keep one if needed for sign) - while (intData.size > 1 && intData[0].toInt() == 0 && intData[1].toInt() and 0x80 == 0) { - intData = intData.copyOfRange(1, intData.size) - } - - // Add leading zero if high bit is set - if (intData[0].toInt() and 0x80 != 0) { - intData = byteArrayOf(0x00) + intData - } - - return byteArrayOf(0x02) + encodeLength(intData.size) + intData - } - - private fun encodeASN1Sequence(data: ByteArray): ByteArray { - return byteArrayOf(0x30) + encodeLength(data.size) + data - } - - private fun encodeASN1BitString(data: ByteArray): ByteArray { - return byteArrayOf(0x03) + encodeLength(data.size) + data - } - - private fun encodeLength(length: Int): ByteArray { - return if (length < 128) { - byteArrayOf(length.toByte()) - } else { - val lengthBytes = mutableListOf() - var len = length - while (len > 0) { - lengthBytes.add(0, (len and 0xFF).toByte()) - len = len shr 8 - } - byteArrayOf((0x80 or lengthBytes.size).toByte()) + lengthBytes.toByteArray() - } - } -} \ No newline at end of file +} diff --git a/packages/react-native-webcrypto-bridge/android/main/java/com/okta/webcryptobridge/WebCryptoBridgePackage.kt b/packages/react-native-webcrypto-bridge/android/main/java/com/okta/webcryptobridge/WebCryptoBridgePackage.kt index 2efc0c6..85a2c10 100644 --- a/packages/react-native-webcrypto-bridge/android/main/java/com/okta/webcryptobridge/WebCryptoBridgePackage.kt +++ b/packages/react-native-webcrypto-bridge/android/main/java/com/okta/webcryptobridge/WebCryptoBridgePackage.kt @@ -13,4 +13,4 @@ class WebCryptoBridgePackage : ReactPackage { override fun createViewManagers(reactContext: ReactApplicationContext): List> { return emptyList() } -} \ No newline at end of file +} diff --git a/packages/react-native-webcrypto-bridge/ios/RSAKeyUtils.swift b/packages/react-native-webcrypto-bridge/ios/RSAKeyUtils.swift new file mode 100644 index 0000000..c7ce302 --- /dev/null +++ b/packages/react-native-webcrypto-bridge/ios/RSAKeyUtils.swift @@ -0,0 +1,183 @@ +import Foundation + +// MARK: - RSA Public Key Components + +/// Parsed components of an RSA public key, suitable for JWK serialization. +struct RSAPublicKeyComponents { + /// The RSA modulus (`n`), with any ASN.1 leading-zero padding stripped. + let modulus: Data + + /// The RSA public exponent (`e`), with any ASN.1 leading-zero padding stripped. + let exponent: Data + + /// Parse components from a PKCS#1 `RSAPublicKey` DER structure: + /// ``` + /// SEQUENCE { + /// INTEGER modulus + /// INTEGER exponent + /// } + /// ``` + /// + /// - Parameter derData: The raw bytes returned by `SecKeyCopyExternalRepresentation` + /// for an RSA public key. + /// - Returns: `nil` if the data is not a valid PKCS#1 structure. + init?(derData: Data) { + let bytes = [UInt8](derData) + var offset = 0 + + // Expect SEQUENCE tag (0x30) + guard offset < bytes.count, bytes[offset] == 0x30 else { return nil } + offset += 1 + + // Skip SEQUENCE length + guard RSAKeyUtils.readDERLength(bytes: bytes, offset: &offset) != nil else { return nil } + + // Read first INTEGER (modulus) + guard offset < bytes.count, bytes[offset] == 0x02 else { return nil } + offset += 1 + guard let modulusLength = RSAKeyUtils.readDERLength(bytes: bytes, offset: &offset) else { return nil } + guard offset + modulusLength <= bytes.count else { return nil } + + var modulusBytes = Array(bytes[offset..<(offset + modulusLength)]) + offset += modulusLength + + // Strip leading zero byte used for ASN.1 sign encoding + if modulusBytes.first == 0x00 && modulusBytes.count > 1 { + modulusBytes.removeFirst() + } + + // Read second INTEGER (exponent) + guard offset < bytes.count, bytes[offset] == 0x02 else { return nil } + offset += 1 + guard let exponentLength = RSAKeyUtils.readDERLength(bytes: bytes, offset: &offset) else { return nil } + guard offset + exponentLength <= bytes.count else { return nil } + + var exponentBytes = Array(bytes[offset..<(offset + exponentLength)]) + + // Strip leading zero byte used for ASN.1 sign encoding + if exponentBytes.first == 0x00 && exponentBytes.count > 1 { + exponentBytes.removeFirst() + } + + self.modulus = Data(modulusBytes) + self.exponent = Data(exponentBytes) + } + + /// Create components directly from raw modulus and exponent data. + init(modulus: Data, exponent: Data) { + self.modulus = modulus + self.exponent = exponent + } + + // MARK: - DER Serialization + + /// Construct a PKCS#1 `RSAPublicKey` DER structure from this key's components. + /// + /// The resulting `Data` can be passed directly to `SecKeyCreateWithData` with + /// `kSecAttrKeyTypeRSA` / `kSecAttrKeyClassPublic` attributes. + var derData: Data { + var modulusBytes = [UInt8](modulus) + let exponentBytes = [UInt8](exponent) + + // Ensure modulus has a leading 0x00 if MSB is set (ASN.1 sign bit) + if let first = modulusBytes.first, first & 0x80 != 0 { + modulusBytes.insert(0x00, at: 0) + } + + let modulusLengthOctets = RSAKeyUtils.encodeDERLength(modulusBytes.count) + let exponentLengthOctets = RSAKeyUtils.encodeDERLength(exponentBytes.count) + + // +1 per INTEGER accounts for the tag byte (0x02) + let contentLength = 1 + modulusLengthOctets.count + modulusBytes.count + + 1 + exponentLengthOctets.count + exponentBytes.count + let sequenceLengthOctets = RSAKeyUtils.encodeDERLength(contentLength) + + var result = Data() + result.reserveCapacity(1 + sequenceLengthOctets.count + contentLength) + + // SEQUENCE tag and length + result.append(0x30) + result.append(contentsOf: sequenceLengthOctets) + + // INTEGER tag, length, and modulus + result.append(0x02) + result.append(contentsOf: modulusLengthOctets) + result.append(contentsOf: modulusBytes) + + // INTEGER tag, length, and exponent + result.append(0x02) + result.append(contentsOf: exponentLengthOctets) + result.append(contentsOf: exponentBytes) + + return result + } + + /// The key size in bits, derived from the modulus length. + var keySizeInBits: Int { + modulus.count * 8 + } +} + +// MARK: - RSA Key Utilities + +/// Pure-Swift utilities for converting between PKCS#1 DER-encoded RSA public keys +/// and their individual components (modulus + exponent). +/// +/// Apple's Security framework (`SecKeyCopyExternalRepresentation` / `SecKeyCreateWithData`) +/// operates on raw PKCS#1 DER blobs but provides no API to extract or inject individual +/// components. These utilities bridge that gap for JWK ↔ SecKey conversion. +enum RSAKeyUtils { + + // MARK: - DER Length Encoding/Decoding + + /// Read a DER length field from a byte array, advancing `offset` past the length bytes. + /// + /// Supports both short-form (single byte < 128) and long-form lengths. + /// Returns `nil` for invalid or indefinite-length encodings. + static func readDERLength(bytes: [UInt8], offset: inout Int) -> Int? { + guard offset < bytes.count else { return nil } + let first = bytes[offset] + offset += 1 + + if first < 0x80 { + // Short form: length is the byte value itself + return Int(first) + } else if first == 0x80 { + // Indefinite length — not valid for DER + return nil + } else { + // Long form: lower 7 bits indicate number of subsequent length bytes + let numLengthBytes = Int(first & 0x7F) + guard offset + numLengthBytes <= bytes.count else { return nil } + + var length = 0 + for i in 0.. [UInt8] { + if length < 128 { + return [UInt8(length)] + } + + // Determine how many bytes are needed to represent `length` + let byteCount = (length / 256) + 1 + var remaining = length + var result: [UInt8] = [UInt8(byteCount + 0x80)] + + for _ in 0..> 8 + } + + return result + } +} diff --git a/packages/react-native-webcrypto-bridge/ios/WebCryptoBridge.h b/packages/react-native-webcrypto-bridge/ios/WebCryptoBridge.h index d9ec15e..e5ffe7c 100644 --- a/packages/react-native-webcrypto-bridge/ios/WebCryptoBridge.h +++ b/packages/react-native-webcrypto-bridge/ios/WebCryptoBridge.h @@ -8,4 +8,4 @@ @interface WebCryptoBridge : NSObject #endif -@end \ No newline at end of file +@end diff --git a/packages/react-native-webcrypto-bridge/ios/WebCryptoBridge.swift b/packages/react-native-webcrypto-bridge/ios/WebCryptoBridge.swift index 0842034..ff2e7b3 100644 --- a/packages/react-native-webcrypto-bridge/ios/WebCryptoBridge.swift +++ b/packages/react-native-webcrypto-bridge/ios/WebCryptoBridge.swift @@ -5,35 +5,46 @@ import React extension String { - public var base64URLDecoded: String { convertToBase64URLDecoded() } + public var base64URLDecoded: String { convertToBase64URLDecoded() } - private func convertToBase64URLDecoded() -> String { - var result = replacingOccurrences(of: "-", with: "+") - .replacingOccurrences(of: "_", with: "/") + private func convertToBase64URLDecoded() -> String { + var result = replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") - while result.count % 4 != 0 { - result.append(contentsOf: "=") - } + while result.count % 4 != 0 { + result.append(contentsOf: "=") + } - return result - } + return result + } } extension Data { - public func base64URLEncodedString() -> String { - var base64 = self.base64EncodedString() - base64 = base64.replacingOccurrences(of: "+", with: "-") - base64 = base64.replacingOccurrences(of: "/", with: "_") - base64 = base64.replacingOccurrences(of: "=", with: "") - return base64 - } + public func base64URLEncodedString() -> String { + var base64 = self.base64EncodedString() + base64 = base64.replacingOccurrences(of: "+", with: "-") + base64 = base64.replacingOccurrences(of: "/", with: "_") + base64 = base64.replacingOccurrences(of: "=", with: "") + return base64 + } } +// MARK: - Key Entry + +/// Type-safe key storage entry, replacing the previous `[String: Any]` dictionary. +struct KeyEntry { + let publicKey: SecKey + let privateKey: SecKey? + let extractable: Bool +} + +// MARK: - WebCryptoBridge + @objc(WebCryptoBridge) class WebCryptoBridge: NSObject { - // Key storage - private static var keyStore: [String: [String: Any]] = [:] + // Key storage — typed struct instead of [String: Any] + private static var keyStore: [String: KeyEntry] = [:] private static let keyStoreLock = NSLock() @objc @@ -47,56 +58,57 @@ class WebCryptoBridge: NSObject { } @objc - func constantsToExport() -> [AnyHashable : Any]! { + func constantsToExport() -> [AnyHashable: Any]! { return [:] } - + // MARK: - Helper Methods - - private func byteArrayToData(_ byteArray: [NSNumber]) -> Data { - var data = Data(capacity: byteArray.count) - for byte in byteArray { - data.append(byte.uint8Value) - } - return data + + /// Decode a standard Base64 string to Data. + private func base64ToData(_ base64: String) -> Data? { + return Data(base64Encoded: base64) } - - private func dataToByteArray(_ data: Data) -> [NSNumber] { - return data.map { NSNumber(value: $0) } + + /// Encode Data to a standard Base64 string. + private func dataToBase64(_ data: Data) -> String { + return data.base64EncodedString() } - + // MARK: - Synchronous Methods - + @objc(getRandomValues:) - func getRandomValues(_ length: Double) -> [NSNumber] { + func getRandomValues(_ length: Double) -> String { let len = Int(length) var randomData = Data(count: len) - + let result = randomData.withUnsafeMutableBytes { bytes -> Int32 in guard let baseAddress = bytes.baseAddress else { return errSecParam } return SecRandomCopyBytes(kSecRandomDefault, len, baseAddress) } - + if result != errSecSuccess { - return [] + // SecRandomCopyBytes failure indicates a fundamentally broken system RNG. + // Returning empty/zero data would silently produce weak randomness, which + // is a critical security failure. Crash explicitly rather than risk it. + fatalError("WebCryptoBridge: SecRandomCopyBytes failed with status \(result). The system CSPRNG is unavailable.") } - - return dataToByteArray(randomData) + + return dataToBase64(randomData) } - + @objc func randomUUID() -> String { return UUID().uuidString.lowercased() } - + // MARK: - Async Methods - + @objc(digest:data:resolve:reject:) func digest( _ algorithm: String, - data: [NSNumber], + data: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock ) { @@ -104,20 +116,21 @@ class WebCryptoBridge: NSObject { reject("unsupported_algorithm", "Only SHA-256 is supported", nil) return } - - let inputData = byteArrayToData(data) - + + guard let inputData = base64ToData(data) else { + reject("invalid_input", "Invalid Base64 input data", nil) + return + } + var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) inputData.withUnsafeBytes { bytes in _ = CC_SHA256(bytes.baseAddress, CC_LONG(inputData.count), &hash) } - + let hashData = Data(hash) - let result = dataToByteArray(hashData) - - resolve(result) + resolve(dataToBase64(hashData)) } - + @objc(generateKey:extractable:keyUsages:resolve:reject:) func generateKey( _ algorithmJson: String, @@ -132,7 +145,7 @@ class WebCryptoBridge: NSObject { reject("unsupported_algorithm", "Only RSASSA-PKCS1-v1_5 is supported", nil) return } - + let attributes: [String: Any] = [ kSecAttrKeyType as String: kSecAttrKeyTypeRSA, kSecAttrKeySizeInBits as String: 2048, @@ -143,29 +156,29 @@ class WebCryptoBridge: NSObject { kSecAttrIsPermanent as String: false ] ] - + var error: Unmanaged? guard let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else { let err = error?.takeRetainedValue() reject("key_generation_failed", err?.localizedDescription ?? "Unknown error", err) return } - + guard let publicKey = SecKeyCopyPublicKey(privateKey) else { reject("key_generation_failed", "Failed to get public key", nil) return } - + let keyId = UUID().uuidString - + Self.keyStoreLock.lock() - Self.keyStore[keyId] = [ - "publicKey": publicKey, - "privateKey": privateKey, - "extractable": extractable - ] + Self.keyStore[keyId] = KeyEntry( + publicKey: publicKey, + privateKey: privateKey, + extractable: extractable + ) Self.keyStoreLock.unlock() - + let result = ["id": keyId] if let jsonData = try? JSONSerialization.data(withJSONObject: result), let jsonString = String(data: jsonData, encoding: .utf8) { @@ -174,7 +187,7 @@ class WebCryptoBridge: NSObject { reject("serialization_failed", "Failed to serialize result", nil) } } - + @objc(exportKey:keyId:keyType:resolve:reject:) func exportKey( _ format: String, @@ -187,39 +200,50 @@ class WebCryptoBridge: NSObject { reject("unsupported_format", "Only JWK format is supported", nil) return } - + Self.keyStoreLock.lock() - let keyPair = Self.keyStore[keyId] + let entry = Self.keyStore[keyId] Self.keyStoreLock.unlock() - - guard let keyPair = keyPair, - let key = keyPair[keyType == "public" ? "publicKey" : "privateKey"] as! SecKey? else { + + guard let entry = entry else { reject("key_not_found", "Key not found", nil) return } - + + let key: SecKey + if keyType == "public" { + key = entry.publicKey + } else { + guard let privateKey = entry.privateKey else { + reject("key_not_found", "Private key not available for this key", nil) + return + } + key = privateKey + } + var error: Unmanaged? guard let keyData = SecKeyCopyExternalRepresentation(key, &error) as Data? else { let err = error?.takeRetainedValue() reject("export_failed", err?.localizedDescription ?? "Export failed", err) return } - - // Create JWK (simplified - proper ASN.1 parsing would be better) + var jwk: [String: Any] = [ "kty": "RSA", "alg": "RS256" ] - - // Extract RSA components (simplified) + if keyType == "public" { - let modulus = extractModulus(from: keyData) - let exponent = Data([0x01, 0x00, 0x01]) // Standard exponent (65537) - - jwk["n"] = modulus.base64URLEncodedString() - jwk["e"] = exponent.base64URLEncodedString() + // SecKeyCopyExternalRepresentation returns PKCS#1 RSAPublicKey for RSA public keys: + // SEQUENCE { INTEGER modulus, INTEGER exponent } + guard let components = RSAPublicKeyComponents(derData: keyData) else { + reject("export_failed", "Failed to parse RSA public key components", nil) + return + } + jwk["n"] = components.modulus.base64URLEncodedString() + jwk["e"] = components.exponent.base64URLEncodedString() } - + if let jsonData = try? JSONSerialization.data(withJSONObject: jwk), let jsonString = String(data: jsonData, encoding: .utf8) { resolve(jsonString) @@ -227,7 +251,7 @@ class WebCryptoBridge: NSObject { reject("serialization_failed", "Failed to serialize JWK", nil) } } - + @objc(importKey:keyData:algorithm:extractable:keyUsages:resolve:reject:) func importKey( _ format: String, @@ -260,15 +284,17 @@ class WebCryptoBridge: NSObject { let keyId = UUID().uuidString - // Construct key data (simplified - proper ASN.1 encoding needed) - let publicKeyData = constructRSAPublicKeyData(modulus: modulusData, exponent: exponentData) + let components = RSAPublicKeyComponents(modulus: modulusData, exponent: exponentData) + let publicKeyData = components.derData + + // Key size is derived from the modulus, not the DER blob + let keySizeInBits = components.keySizeInBits - // Import public key var error: Unmanaged? let attributes: [CFString: Any] = [ kSecAttrKeyType: kSecAttrKeyTypeRSA, kSecAttrKeyClass: kSecAttrKeyClassPublic, - kSecAttrKeySizeInBits: NSNumber(value: publicKeyData.count * 8) + kSecAttrKeySizeInBits: NSNumber(value: keySizeInBits) ] guard let publicKey = SecKeyCreateWithData(publicKeyData as NSData, attributes as NSDictionary, &error) else { @@ -278,35 +304,43 @@ class WebCryptoBridge: NSObject { } Self.keyStoreLock.lock() - Self.keyStore[keyId] = [ - "publicKey": publicKey, - "extractable": extractable - ] + Self.keyStore[keyId] = KeyEntry( + publicKey: publicKey, + privateKey: nil, + extractable: extractable + ) Self.keyStoreLock.unlock() resolve(keyId) } - + @objc(sign:keyId:data:resolve:reject:) func sign( _ algorithmJson: String, keyId: String, - data: [NSNumber], + data: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock ) { Self.keyStoreLock.lock() - let keyPair = Self.keyStore[keyId] + let entry = Self.keyStore[keyId] Self.keyStoreLock.unlock() - - guard let keyPair = keyPair, - let privateKey = keyPair["privateKey"] as! SecKey? else { - reject("key_not_found", "Private key not found", nil) + + guard let entry = entry else { + reject("key_not_found", "Key not found", nil) + return + } + + guard let privateKey = entry.privateKey else { + reject("key_not_found", "Private key not available for this key", nil) + return + } + + guard let inputData = base64ToData(data) else { + reject("invalid_input", "Invalid Base64 input data", nil) return } - - let inputData = byteArrayToData(data) - + var error: Unmanaged? guard let signature = SecKeyCreateSignature( privateKey, @@ -318,33 +352,40 @@ class WebCryptoBridge: NSObject { reject("signing_failed", err?.localizedDescription ?? "Signing failed", err) return } - - let result = dataToByteArray(signature) - resolve(result) + + resolve(dataToBase64(signature)) } - + @objc(verify:keyId:signature:data:resolve:reject:) func verify( _ algorithmJson: String, keyId: String, - signature: [NSNumber], - data: [NSNumber], + signature: String, + data: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock ) { Self.keyStoreLock.lock() - let keyPair = Self.keyStore[keyId] + let entry = Self.keyStore[keyId] Self.keyStoreLock.unlock() - - guard let keyPair = keyPair, - let publicKey = keyPair["publicKey"] as! SecKey? else { + + guard let entry = entry else { reject("key_not_found", "Public key not found", nil) return } - - let inputData = byteArrayToData(data) - let signatureData = byteArrayToData(signature) - + + guard let inputData = base64ToData(data) else { + reject("invalid_input", "Invalid Base64 input data", nil) + return + } + + guard let signatureData = base64ToData(signature) else { + reject("invalid_input", "Invalid Base64 signature data", nil) + return + } + + let publicKey = entry.publicKey + var error: Unmanaged? let verified = SecKeyVerifySignature( publicKey, @@ -353,81 +394,13 @@ class WebCryptoBridge: NSObject { signatureData as CFData, &error ) - + if let err = error?.takeRetainedValue() { reject("verification_failed", err.localizedDescription, err as Error) return } - + resolve(verified) } - - // MARK: - Helper Methods for RSA Key Handling - - private func extractModulus(from keyData: Data) -> Data { - // Simplified extraction - skip ASN.1 header - // In production, use proper ASN.1 parsing - let offset = min(24, keyData.count) - let length = min(256, keyData.count - offset) - return keyData.subdata(in: offset..<(offset + length)) - } - - private func constructRSAPublicKeyData(modulus: Data, exponent: Data) -> Data { - // Based on Okta's Data+SigningExtensions.swift - - var modulusBytes = [UInt8](modulus) - let exponentBytes = [UInt8](exponent) - - // Make sure modulus starts with 0x00 - if let prefix = modulusBytes.first, prefix != 0x00 { - modulusBytes.insert(0x00, at: 0) - } - - // Encode lengths - let modulusLengthOctets = encodedOctets(modulusBytes.count) - let exponentLengthOctets = encodedOctets(exponentBytes.count) - - // Total length - let totalLength = modulusLengthOctets.count + modulusBytes.count + exponentLengthOctets.count + exponentBytes.count + 2 - let totalLengthOctets = encodedOctets(totalLength) - - // Build the data - var result = Data() - - // SEQUENCE tag and length - result.append(0x30) - result.append(contentsOf: totalLengthOctets) - - // INTEGER tag, length, and modulus - result.append(0x02) - result.append(contentsOf: modulusLengthOctets) - result.append(contentsOf: modulusBytes) - - // INTEGER tag, length, and exponent - result.append(0x02) - result.append(contentsOf: exponentLengthOctets) - result.append(contentsOf: exponentBytes) - - return result - } - private func encodedOctets(_ length: Int) -> [UInt8] { - // Short form - if length < 128 { - return [UInt8(length)] - } - - // Long form - let index = (length / 256) + 1 - var len = length - var result: [UInt8] = [UInt8(index + 0x80)] - - for _ in 0..> 8 - } - - return result - } } - diff --git a/packages/react-native-webcrypto-bridge/ios/WebCryptoBridgeModule.m b/packages/react-native-webcrypto-bridge/ios/WebCryptoBridgeModule.m index 3d9f2be..5723206 100644 --- a/packages/react-native-webcrypto-bridge/ios/WebCryptoBridgeModule.m +++ b/packages/react-native-webcrypto-bridge/ios/WebCryptoBridgeModule.m @@ -3,7 +3,7 @@ @interface RCT_EXTERN_MODULE(WebCryptoBridge, NSObject) RCT_EXTERN_METHOD(digest:(NSString *)algorithm - data:(NSArray *)data + data:(NSString *)data resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) @@ -29,14 +29,14 @@ @interface RCT_EXTERN_MODULE(WebCryptoBridge, NSObject) RCT_EXTERN_METHOD(sign:(NSString *)algorithm keyId:(NSString *)keyId - data:(NSArray *)data + data:(NSString *)data resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) RCT_EXTERN_METHOD(verify:(NSString *)algorithm keyId:(NSString *)keyId - signature:(NSArray *)signature - data:(NSArray *)data + signature:(NSString *)signature + data:(NSString *)data resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) @@ -44,4 +44,4 @@ @interface RCT_EXTERN_MODULE(WebCryptoBridge, NSObject) RCT_EXTERN_METHOD(randomUUID) -@end \ No newline at end of file +@end diff --git a/packages/react-native-webcrypto-bridge/jest.config.js b/packages/react-native-webcrypto-bridge/jest.config.js index 9f9d97e..49caa1e 100644 --- a/packages/react-native-webcrypto-bridge/jest.config.js +++ b/packages/react-native-webcrypto-bridge/jest.config.js @@ -6,8 +6,8 @@ const config = { ], moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], testMatch: [ - '**/__tests__/**/*.test.ts', - '**/__tests__/**/*.test.tsx', + '/test/spec/**/*.spec.ts', + '/test/spec/**/*.spec.tsx', ], transform: { '^.+\\.(js|jsx|ts|tsx)$': 'babel-jest', @@ -15,7 +15,7 @@ const config = { transformIgnorePatterns: [ 'node_modules/(?!(react-native|@react-native|@okta)/)', ], - setupFiles: ['/jest.setup.js'], + setupFiles: ['/test/jest.setup.js'], collectCoverageFrom: [ 'src/**/*.{ts,tsx}', '!src/**/__tests__/**', diff --git a/packages/react-native-webcrypto-bridge/package.json b/packages/react-native-webcrypto-bridge/package.json index 4e65392..b315f5e 100644 --- a/packages/react-native-webcrypto-bridge/package.json +++ b/packages/react-native-webcrypto-bridge/package.json @@ -10,7 +10,6 @@ "source": "src/index.ts", "author": "jared.perreault@okta.com", "license": "Apache-2.0", - "private": true, "engines": { "node": ">=20.11.0" }, @@ -19,7 +18,6 @@ "./dist", "./ios", "./android", - "./cpp", "*.md", "package.json", "react-native-webcrypto-bridge.podspec" diff --git a/packages/react-native-webcrypto-bridge/src/NativeWebCryptoBridge.ts b/packages/react-native-webcrypto-bridge/src/NativeWebCryptoBridge.ts index 797cde7..d50b6df 100644 --- a/packages/react-native-webcrypto-bridge/src/NativeWebCryptoBridge.ts +++ b/packages/react-native-webcrypto-bridge/src/NativeWebCryptoBridge.ts @@ -4,9 +4,11 @@ import { TurboModuleRegistry } from 'react-native'; export interface Spec extends TurboModule { /** - * Generate SHA-256 digest (async) + * Generate SHA-256 digest (async). + * @param data - Standard Base64-encoded input data + * @returns Standard Base64-encoded digest */ - digest(algorithm: string, data: number[]): Promise; + digest(algorithm: string, data: string): Promise; /** * Generate RSA key pair (async) @@ -34,24 +36,30 @@ export interface Spec extends TurboModule { ): Promise; /** - * Sign data with a private key (async) + * Sign data with a private key (async). + * @param data - Standard Base64-encoded input data + * @returns Standard Base64-encoded signature */ - sign(algorithm: string, keyId: string, data: number[]): Promise; + sign(algorithm: string, keyId: string, data: string): Promise; /** - * Verify a signature (async) + * Verify a signature (async). + * @param signature - Standard Base64-encoded signature + * @param data - Standard Base64-encoded input data */ verify( algorithm: string, keyId: string, - signature: number[], - data: number[] + signature: string, + data: string ): Promise; /** - * Generate cryptographically secure random values (sync) + * Generate cryptographically secure random values (sync). + * @param length - Number of random bytes to generate + * @returns Standard Base64-encoded random bytes */ - getRandomValues(length: number): number[]; + getRandomValues(length: number): string; /** * Generate a random UUID v4 (sync) @@ -59,4 +67,4 @@ export interface Spec extends TurboModule { randomUUID(): string; } -export default TurboModuleRegistry.getEnforcing('WebCryptoBridge'); \ No newline at end of file +export default TurboModuleRegistry.getEnforcing('WebCryptoBridge'); diff --git a/packages/react-native-webcrypto-bridge/src/WebCryptoPolyfill.ts b/packages/react-native-webcrypto-bridge/src/WebCryptoPolyfill.ts index b0e42b1..65e44cc 100644 --- a/packages/react-native-webcrypto-bridge/src/WebCryptoPolyfill.ts +++ b/packages/react-native-webcrypto-bridge/src/WebCryptoPolyfill.ts @@ -1,86 +1,74 @@ /** * WebCrypto API polyfill for React Native - * - * Uses crypto utilities from @okta/auth-foundation for encoding/decoding + * + * Bridges JavaScript WebCrypto API calls to native platform cryptography + * (Apple Security / Android JCA) via a React Native TurboModule. + * + * Binary data crosses the bridge as standard Base64 strings for efficiency. */ import NativeWebCryptoBridge from './NativeWebCryptoBridge.ts'; import { WebCryptoBridgeError } from './lib.ts'; -// NOTE: Does not use `buf` or `b64` from `auth-foundation` because converting -// to a byte array (number[]) makes the bridge code much simplier and avoids -// doing any string encoding in the native code -type ByteArray = number[]; - -/** +/** * @internal * Maps `CryptoKey` instances to the id assigned by the native code */ const cryptoKeyMap = new WeakMap(); -// MARK: - ArryBuffer Converters +// MARK: - ArrayBuffer ↔ Base64 Converters /** * Converts a `BufferSource` instance to an `ArrayBuffer` */ -function toArrayBuffer (source: BufferSource): ArrayBuffer { +function toArrayBuffer(source: BufferSource): ArrayBuffer { if (source instanceof ArrayBuffer) { - // If it is already an ArrayBuffer, return it directly. return source; } else if (ArrayBuffer.isView(source)) { - // If it is an ArrayBufferView (Uint8Array, DataView, Buffer, etc.): const typedArray = source; - - // For a Node.js Buffer or a standard typed array, the .buffer property - // gives the underlying ArrayBuffer. - // Use .slice() to create a *copy* of only the relevant bytes if the view - // does not span the entire underlying buffer's length. This is crucial for + // .slice() creates a copy of only the relevant bytes. This is crucial for // Node.js Buffers which might share a larger memory pool. return typedArray.buffer.slice(typedArray.byteOffset, typedArray.byteOffset + typedArray.byteLength); } else { - // Handle other potential cases or throw an error if the type is unexpected - throw new Error("Unsupported BufferSource type."); + throw new WebCryptoBridgeError('Unsupported BufferSource type.'); } } /** - * Convert ArrayBuffer to byte array for native bridge + * Encode an ArrayBuffer to a standard Base64 string for the native bridge. */ -function arrayBufferToByteArray(buffer: ArrayBuffer): ByteArray { - return Array.from(new Uint8Array(buffer)); +function arrayBufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); } /** - * Convert byte array from native bridge to ArrayBuffer + * Decode a standard Base64 string from the native bridge to an ArrayBuffer. */ -function byteArrayToArrayBuffer(bytes: ByteArray): ArrayBuffer { - return new Uint8Array(bytes).buffer; -} - -function getCryptoAlg (alg: string) { - switch (alg) { - case 'RS256': - return { - name: 'RSASSA-PKCS1-v1_5', - hash: { name: 'SHA-256' } - }; - default: - throw new WebCryptoBridgeError('Unknown crypto algorithm', { context: { alg } }); +function base64ToArrayBuffer(base64: string): ArrayBuffer { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); } + return bytes.buffer; } -// MARK: - SubtleCrypto Methods +// MARK: - SubtleCrypto Methods const digest: SubtleCrypto['digest'] = async (algorithm, data) => { if (algorithm !== 'SHA-256') { throw new WebCryptoBridgeError(`Unsupported algorithm: ${algorithm}`); } - // Convert ArrayBuffer to byte array for native bridge - const bytes = arrayBufferToByteArray(toArrayBuffer(data)); - const resultBytes = await NativeWebCryptoBridge.digest('SHA-256', bytes); - return byteArrayToArrayBuffer(resultBytes); -} + const base64Data = arrayBufferToBase64(toArrayBuffer(data)); + const resultBase64 = await NativeWebCryptoBridge.digest('SHA-256', base64Data); + return base64ToArrayBuffer(resultBase64); +}; const importKey: SubtleCrypto['importKey'] = async ( format, @@ -88,7 +76,7 @@ const importKey: SubtleCrypto['importKey'] = async ( algorithm, extractable, keyUsages - ) => { +) => { if (format !== 'jwk') { throw new WebCryptoBridgeError(`Unsupported format: ${format}`); } @@ -109,8 +97,8 @@ const importKey: SubtleCrypto['importKey'] = async ( const key: CryptoKey = { algorithm, - extractable, // TODO: can extractable even be set to `true` and be used in a bridge? - type: 'public', // TODO: A string identifying whether the key is a symmetric ('secret') or asymmetric ('private' or 'public') key. + extractable, + type: 'public', // TODO: Determine from key data when private key import is supported usages: keyUsages }; @@ -119,22 +107,22 @@ const importKey: SubtleCrypto['importKey'] = async ( return key; }; -// TODO: DPoP -const exportKey: SubtleCrypto['exportKey'] = async (format, key) => { - throw new Error('Not Implemented'); -} +// TODO: DPoP — wire to native implementation when ready +const exportKey: SubtleCrypto['exportKey'] = async (_format, _key) => { + throw new WebCryptoBridgeError('exportKey is not yet implemented'); +}; -// TODO: DPoP -const sign: SubtleCrypto['sign'] = async (algorithm, key, data) => { - throw new Error('Not Implemented'); -} +// TODO: DPoP — wire to native implementation when ready +const sign: SubtleCrypto['sign'] = async (_algorithm, _key, _data) => { + throw new WebCryptoBridgeError('sign is not yet implemented'); +}; -// TODO: DPoP +// TODO: DPoP — wire to native implementation when ready const generateKey: SubtleCrypto['generateKey'] = async ( - algorithm, extractable, keyUsages + _algorithm, _extractable, _keyUsages ) => { - throw new Error('Not Implemented'); -} + throw new WebCryptoBridgeError('generateKey is not yet implemented'); +}; const verify: SubtleCrypto['verify'] = async (algorithm, key, signature, data) => { const keyId = cryptoKeyMap.get(key); @@ -147,19 +135,23 @@ const verify: SubtleCrypto['verify'] = async (algorithm, key, signature, data) = throw new WebCryptoBridgeError('Unsupported algorithm'); } - // TODO: check if `algorithm` matches the provided `key`? Is that worth it? + if (!key.usages.includes('verify')) { + throw new WebCryptoBridgeError('Key does not support verify operation', { + context: { usages: key.usages } + }); + } const algorithmJson = JSON.stringify(key.algorithm); - const signatureBytes = arrayBufferToByteArray(toArrayBuffer(signature)); - const dataBytes = arrayBufferToByteArray(toArrayBuffer(data)); + const signatureBase64 = arrayBufferToBase64(toArrayBuffer(signature)); + const dataBase64 = arrayBufferToBase64(toArrayBuffer(data)); return await NativeWebCryptoBridge.verify( algorithmJson, keyId, - signatureBytes, - dataBytes + signatureBase64, + dataBase64 ); -} +}; // MARK: - Types & Exports @@ -177,8 +169,8 @@ const subtle: Partial = { export interface WebCryptoPolyfill { subtle: Partial; - getRandomValues: Crypto['getRandomValues'] - randomUUID: Crypto['randomUUID'] + getRandomValues: Crypto['getRandomValues']; + randomUUID: Crypto['randomUUID']; } /** @@ -188,16 +180,25 @@ const cryptoPolyfill: WebCryptoPolyfill = { subtle, getRandomValues(array: T): T { const uint8Array = new Uint8Array(array.buffer, array.byteOffset, array.byteLength); + const requestedLength = uint8Array.length; - const randomBytes = NativeWebCryptoBridge.getRandomValues(uint8Array.length); + const base64Result = NativeWebCryptoBridge.getRandomValues(requestedLength); - for (let i = 0; i < randomBytes.length; i++) { - uint8Array[i] = randomBytes[i]; + // Decode the Base64 string from the native bridge + const binary = atob(base64Result); + if (binary.length !== requestedLength) { + throw new WebCryptoBridgeError( + `getRandomValues: expected ${requestedLength} bytes, received ${binary.length}` + ); + } + + for (let i = 0; i < binary.length; i++) { + uint8Array[i] = binary.charCodeAt(i); } return array; }, - randomUUID () { + randomUUID() { return NativeWebCryptoBridge.randomUUID() as ReturnType; } }; @@ -215,4 +216,4 @@ export function installWebCryptoPolyfill(): void { // @ts-expect-error - Adding crypto to window window.crypto = cryptoPolyfill; } -} \ No newline at end of file +} diff --git a/packages/react-native-webcrypto-bridge/test/jest.setup.js b/packages/react-native-webcrypto-bridge/test/jest.setup.js index 9af6522..9153bea 100644 --- a/packages/react-native-webcrypto-bridge/test/jest.setup.js +++ b/packages/react-native-webcrypto-bridge/test/jest.setup.js @@ -1,30 +1,29 @@ -// Mock the native module for Jest tests -jest.mock('./src/NativeWebCryptoBridge', () => ({ - default: { - digest: jest.fn(async (algorithm, data) => data), - generateKey: jest.fn(async () => JSON.stringify({ id: 'test-key-id' })), - exportKey: jest.fn(async () => JSON.stringify({ kty: 'RSA', n: 'test', e: 'AQAB' })), - importKey: jest.fn(async () => 'imported-key-id'), - sign: jest.fn(async () => 'signature'), - verify: jest.fn(async () => true), - getRandomValues: jest.fn(async (length) => { - return Buffer.from(Array(length).fill(0).map(() => Math.floor(Math.random() * 256))).toString('base64url'); - }), - }, -})); - -// Polyfill global crypto for tests -global.crypto = { - getRandomValues: (arr) => { - for (let i = 0; i < arr.length; i++) { - arr[i] = Math.floor(Math.random() * 256); - } - return arr; - }, -}; - -// Mock React Native modules +// Mock the native module for Jest tests. +// All binary data is transported as standard Base64 strings, matching +// the NativeWebCryptoBridge.Spec interface. jest.mock('react-native', () => ({ + TurboModuleRegistry: { + getEnforcing: jest.fn(() => ({ + digest: jest.fn(async (algorithm, base64Data) => { + // Echo back base64 data as-is (mock doesn't compute real digest) + return base64Data; + }), + generateKey: jest.fn(async () => JSON.stringify({ id: 'test-key-id' })), + exportKey: jest.fn(async () => JSON.stringify({ kty: 'RSA', n: 'test', e: 'AQAB' })), + importKey: jest.fn(async () => 'imported-key-id'), + sign: jest.fn(async () => { + // Return Base64-encoded mock signature bytes + return btoa(String.fromCharCode(...Array(32).fill(0).map(() => Math.floor(Math.random() * 256)))); + }), + verify: jest.fn(async () => true), + getRandomValues: jest.fn((length) => { + // Return Base64-encoded random bytes (synchronous) + const bytes = Array(length).fill(0).map(() => Math.floor(Math.random() * 256)); + return btoa(String.fromCharCode(...bytes)); + }), + randomUUID: jest.fn(() => '550e8400-e29b-41d4-a716-446655440000'), + })), + }, NativeModules: { WebCryptoBridge: {}, }, diff --git a/packages/react-native-webcrypto-bridge/test/spec/digest.spec.ts b/packages/react-native-webcrypto-bridge/test/spec/digest.spec.ts new file mode 100644 index 0000000..23460de --- /dev/null +++ b/packages/react-native-webcrypto-bridge/test/spec/digest.spec.ts @@ -0,0 +1,37 @@ +import { installWebCryptoPolyfill } from '../../src/index'; + +describe('subtle.digest', () => { + beforeAll(() => { + installWebCryptoPolyfill(); + }); + + it('should call native digest with SHA-256 and return an ArrayBuffer', async () => { + const data = new TextEncoder().encode('hello world'); + const result = await global.crypto.subtle.digest('SHA-256', data); + + expect(result).toBeInstanceOf(ArrayBuffer); + // The mock echoes data back, so the result should have content + expect(result.byteLength).toBeGreaterThan(0); + }); + + it('should accept an ArrayBuffer directly', async () => { + const data = new Uint8Array([1, 2, 3, 4]).buffer; + const result = await global.crypto.subtle.digest('SHA-256', data); + + expect(result).toBeInstanceOf(ArrayBuffer); + }); + + it('should reject unsupported algorithms', async () => { + const data = new Uint8Array([1, 2, 3]); + await expect( + global.crypto.subtle.digest('SHA-512', data) + ).rejects.toThrow(/Unsupported algorithm/); + }); + + it('should reject SHA-1', async () => { + const data = new Uint8Array([1, 2, 3]); + await expect( + global.crypto.subtle.digest('SHA-1', data) + ).rejects.toThrow(/Unsupported algorithm/); + }); +}); diff --git a/packages/react-native-webcrypto-bridge/test/spec/getRandomValues.spec.ts b/packages/react-native-webcrypto-bridge/test/spec/getRandomValues.spec.ts new file mode 100644 index 0000000..02ae16b --- /dev/null +++ b/packages/react-native-webcrypto-bridge/test/spec/getRandomValues.spec.ts @@ -0,0 +1,42 @@ +import { installWebCryptoPolyfill } from '../../src/index'; + +describe('getRandomValues', () => { + beforeAll(() => { + installWebCryptoPolyfill(); + }); + + it('should fill a Uint8Array with random bytes', () => { + const array = new Uint8Array(32); + const result = global.crypto.getRandomValues(array); + + expect(result).toBe(array); + expect(result.length).toBe(32); + + // Check that values are not all zeros (statistically near-impossible with real randomness) + const hasNonZero = Array.from(result).some(v => v !== 0); + expect(hasNonZero).toBe(true); + }); + + it('should fill a Uint8Array of length 1', () => { + const array = new Uint8Array(1); + const result = global.crypto.getRandomValues(array); + expect(result).toBe(array); + expect(result.length).toBe(1); + }); + + it('should throw if native bridge returns wrong length', () => { + // Access the mock directly to override it for this test + const { TurboModuleRegistry } = require('react-native'); + const nativeMock = TurboModuleRegistry.getEnforcing('WebCryptoBridge'); + const originalFn = nativeMock.getRandomValues; + + // Mock returns Base64 for 1 byte when we request more + nativeMock.getRandomValues = jest.fn(() => btoa('x')); // 1 byte + + const array = new Uint8Array(32); + expect(() => global.crypto.getRandomValues(array)).toThrow(/expected 32 bytes/); + + // Restore + nativeMock.getRandomValues = originalFn; + }); +}); diff --git a/packages/react-native-webcrypto-bridge/test/spec/importKey.spec.ts b/packages/react-native-webcrypto-bridge/test/spec/importKey.spec.ts new file mode 100644 index 0000000..ed7d382 --- /dev/null +++ b/packages/react-native-webcrypto-bridge/test/spec/importKey.spec.ts @@ -0,0 +1,55 @@ +import { installWebCryptoPolyfill } from '../../src/index'; + +describe('subtle.importKey', () => { + beforeAll(() => { + installWebCryptoPolyfill(); + }); + + it('should import a JWK and return a CryptoKey', async () => { + const jwk = { + kty: 'RSA', + n: 'test-modulus', + e: 'AQAB', + alg: 'RS256', + }; + + const algorithm = { name: 'RSASSA-PKCS1-v1_5', hash: { name: 'SHA-256' } }; + const key = await global.crypto.subtle.importKey( + 'jwk', + jwk, + algorithm, + false, + ['verify'] + ); + + expect(key).toBeDefined(); + expect(key.type).toBe('public'); + expect(key.extractable).toBe(false); + expect(key.usages).toEqual(['verify']); + expect(key.algorithm).toEqual(algorithm); + }); + + it('should reject unsupported format', async () => { + await expect( + global.crypto.subtle.importKey( + 'raw' as any, + new Uint8Array([1, 2, 3]), + { name: 'RSASSA-PKCS1-v1_5', hash: { name: 'SHA-256' } }, + false, + ['verify'] + ) + ).rejects.toThrow(/Unsupported format/); + }); + + it('should reject SPKI format', async () => { + await expect( + global.crypto.subtle.importKey( + 'spki' as any, + new Uint8Array([1, 2, 3]), + { name: 'RSASSA-PKCS1-v1_5', hash: { name: 'SHA-256' } }, + false, + ['verify'] + ) + ).rejects.toThrow(/Unsupported format/); + }); +}); diff --git a/packages/react-native-webcrypto-bridge/test/spec/polyfill.spec.ts b/packages/react-native-webcrypto-bridge/test/spec/polyfill.spec.ts index a4273c8..ee320c4 100644 --- a/packages/react-native-webcrypto-bridge/test/spec/polyfill.spec.ts +++ b/packages/react-native-webcrypto-bridge/test/spec/polyfill.spec.ts @@ -1,4 +1,4 @@ -import { installWebCryptoPolyfill } from 'src/polyfill'; +import { installWebCryptoPolyfill } from '../../src/index'; describe('@okta/react-native-webcrypto-bridge', () => { beforeAll(() => { @@ -20,15 +20,11 @@ describe('@okta/react-native-webcrypto-bridge', () => { expect(typeof global.crypto.subtle.verify).toBe('function'); }); - it('should generate random values', () => { - const array = new Uint8Array(32); - const result = global.crypto.getRandomValues(array); - - expect(result).toBe(array); - expect(result.length).toBe(32); - - // Check that values are not all zeros - const hasNonZero = Array.from(result).some(v => v !== 0); - expect(hasNonZero).toBe(true); + it('should have randomUUID', () => { + expect(typeof global.crypto.randomUUID).toBe('function'); + const uuid = global.crypto.randomUUID(); + expect(uuid).toBeDefined(); + expect(typeof uuid).toBe('string'); }); }); + diff --git a/packages/react-native-webcrypto-bridge/test/spec/verify.spec.ts b/packages/react-native-webcrypto-bridge/test/spec/verify.spec.ts new file mode 100644 index 0000000..5cef276 --- /dev/null +++ b/packages/react-native-webcrypto-bridge/test/spec/verify.spec.ts @@ -0,0 +1,145 @@ +import { installWebCryptoPolyfill } from '../../src/index'; + +describe('subtle.verify', () => { + let testKey: CryptoKey; + + beforeAll(async () => { + installWebCryptoPolyfill(); + + // Import a test key to use for verification + const jwk = { + kty: 'RSA', + n: 'test-modulus', + e: 'AQAB', + alg: 'RS256', + }; + + testKey = await global.crypto.subtle.importKey( + 'jwk', + jwk, + { name: 'RSASSA-PKCS1-v1_5', hash: { name: 'SHA-256' } }, + false, + ['verify'] + ); + }); + + it('should verify a signature (mock returns true)', async () => { + const signature = new Uint8Array(256).buffer; // mock RSA signature + const data = new TextEncoder().encode('test data'); + + const result = await global.crypto.subtle.verify( + { name: 'RSASSA-PKCS1-v1_5' }, + testKey, + signature, + data + ); + + expect(result).toBe(true); + }); + + it('should accept algorithm as a string', async () => { + const signature = new Uint8Array(256).buffer; + const data = new TextEncoder().encode('test data'); + + const result = await global.crypto.subtle.verify( + 'RSASSA-PKCS1-v1_5', + testKey, + signature, + data + ); + + expect(result).toBe(true); + }); + + it('should reject if key is not in the key map', async () => { + const orphanKey: CryptoKey = { + algorithm: { name: 'RSASSA-PKCS1-v1_5' }, + extractable: false, + type: 'public', + usages: ['verify'], + }; + + const signature = new Uint8Array(256).buffer; + const data = new TextEncoder().encode('test data'); + + await expect( + global.crypto.subtle.verify( + { name: 'RSASSA-PKCS1-v1_5' }, + orphanKey, + signature, + data + ) + ).rejects.toThrow(/Unable to locate key/); + }); + + it('should reject unsupported algorithm', async () => { + const signature = new Uint8Array(256).buffer; + const data = new TextEncoder().encode('test data'); + + await expect( + global.crypto.subtle.verify( + { name: 'RSA-PSS' }, + testKey, + signature, + data + ) + ).rejects.toThrow(/Unsupported algorithm/); + }); + + it('should reject if key usages do not include verify', async () => { + const signOnlyJwk = { + kty: 'RSA', + n: 'another-modulus', + e: 'AQAB', + alg: 'RS256', + }; + + const signOnlyKey = await global.crypto.subtle.importKey( + 'jwk', + signOnlyJwk, + { name: 'RSASSA-PKCS1-v1_5', hash: { name: 'SHA-256' } }, + false, + ['sign'] // no 'verify' usage + ); + + const signature = new Uint8Array(256).buffer; + const data = new TextEncoder().encode('test data'); + + await expect( + global.crypto.subtle.verify( + { name: 'RSASSA-PKCS1-v1_5' }, + signOnlyKey, + signature, + data + ) + ).rejects.toThrow(/does not support verify/); + }); +}); + +describe('stubbed DPoP methods', () => { + beforeAll(() => { + installWebCryptoPolyfill(); + }); + + it('exportKey should throw not implemented', async () => { + await expect( + global.crypto.subtle.exportKey('jwk', {} as CryptoKey) + ).rejects.toThrow(/not yet implemented/); + }); + + it('sign should throw not implemented', async () => { + await expect( + global.crypto.subtle.sign('RSASSA-PKCS1-v1_5', {} as CryptoKey, new ArrayBuffer(0)) + ).rejects.toThrow(/not yet implemented/); + }); + + it('generateKey should throw not implemented', async () => { + await expect( + global.crypto.subtle.generateKey( + { name: 'RSASSA-PKCS1-v1_5' } as any, + false, + ['sign', 'verify'] + ) + ).rejects.toThrow(/not yet implemented/); + }); +});