From b6628b2dd32b781882410d370955dbfb3f182a89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliv=C3=A9r=20Falvai?= Date: Tue, 1 Sep 2026 13:43:27 +0200 Subject: [PATCH] Add CodePushCore Swift package for JWT signature verification --- .gitignore | 4 + ios/CodePushCore/Package.swift | 20 ++ .../JWTVerificationError.swift | 36 +++ .../SignatureVerification/JWTVerifier.swift | 82 +++++ .../SignatureVerification/RSAPublicKey.swift | 45 +++ .../CodePushCoreTests/JWTVerifierTests.swift | 303 ++++++++++++++++++ package.json | 4 +- 7 files changed, 493 insertions(+), 1 deletion(-) create mode 100644 ios/CodePushCore/Package.swift create mode 100644 ios/CodePushCore/Sources/CodePushCore/SignatureVerification/JWTVerificationError.swift create mode 100644 ios/CodePushCore/Sources/CodePushCore/SignatureVerification/JWTVerifier.swift create mode 100644 ios/CodePushCore/Sources/CodePushCore/SignatureVerification/RSAPublicKey.swift create mode 100644 ios/CodePushCore/Tests/CodePushCoreTests/JWTVerifierTests.swift diff --git a/.gitignore b/.gitignore index fec8e311..6a15bbe6 100644 --- a/.gitignore +++ b/.gitignore @@ -202,3 +202,7 @@ test/crash-logs/ *.rm .claude/settings.local.json + +# Swift Package Manager (ios/CodePushCore) +.build/ +.swiftpm/ diff --git a/ios/CodePushCore/Package.swift b/ios/CodePushCore/Package.swift new file mode 100644 index 00000000..d0d79c78 --- /dev/null +++ b/ios/CodePushCore/Package.swift @@ -0,0 +1,20 @@ +// swift-tools-version:5.9 +import PackageDescription + +// This manifest exists so the pure-Swift core can be tested without `pod install` or a React Native +// app. `npm run test:unit:ios:core` runs the tests on an iOS simulator. +let package = Package( + name: "CodePushCore", + platforms: [ + .iOS(.v15), + .tvOS(.v15), + .macOS(.v12), + ], + products: [ + .library(name: "CodePushCore", targets: ["CodePushCore"]), + ], + targets: [ + .target(name: "CodePushCore"), + .testTarget(name: "CodePushCoreTests", dependencies: ["CodePushCore"]), + ] +) diff --git a/ios/CodePushCore/Sources/CodePushCore/SignatureVerification/JWTVerificationError.swift b/ios/CodePushCore/Sources/CodePushCore/SignatureVerification/JWTVerificationError.swift new file mode 100644 index 00000000..d5ec235f --- /dev/null +++ b/ios/CodePushCore/Sources/CodePushCore/SignatureVerification/JWTVerificationError.swift @@ -0,0 +1,36 @@ +import Foundation + +public enum JWTVerificationError: Error, LocalizedError { + case malformedToken + case malformedHeader + case missingAlgorithm + case unsupportedAlgorithm(String) + case malformedPayload + case malformedSignature + case malformedPublicKey + case invalidPublicKeyData(reason: String) + case signatureMismatch + + public var errorDescription: String? { + switch self { + case .malformedToken: + return "The signature is not a well-formed JWT: expected three '.'-separated segments." + case .malformedHeader: + return "The JWT header could not be decoded as a JSON object." + case .missingAlgorithm: + return "The JWT header has no \"alg\" field. CodePush release signatures must use RS256." + case .unsupportedAlgorithm(let algorithm): + return "Unsupported JWT algorithm \"\(algorithm)\". CodePush release signatures must use RS256." + case .malformedPayload: + return "The JWT payload could not be decoded as a JSON object." + case .malformedSignature: + return "The JWT signature segment is empty, is not valid base64url, or does not have the length of the key." + case .malformedPublicKey: + return "The configured public key could not be parsed as an RSA public key." + case .invalidPublicKeyData(let reason): + return "The configured public key is not a valid RSA public key: \(reason)" + case .signatureMismatch: + return "The JWT signature was not produced by the configured public key." + } + } +} diff --git a/ios/CodePushCore/Sources/CodePushCore/SignatureVerification/JWTVerifier.swift b/ios/CodePushCore/Sources/CodePushCore/SignatureVerification/JWTVerifier.swift new file mode 100644 index 00000000..d44f26a6 --- /dev/null +++ b/ios/CodePushCore/Sources/CodePushCore/SignatureVerification/JWTVerifier.swift @@ -0,0 +1,82 @@ +import Foundation +import Security + +@objc(CPJWTVerifier) +public final class JWTVerifier: NSObject { + /// `publicKey` can be PEM-armored or have the armor already removed. + @objc(verifyAndDecodeJWT:publicKey:error:) + public static func verifyAndDecode(_ jwt: String, publicKey: String) throws -> [String: Any] { + let segments = jwt.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: ".") + guard segments.count == 3 else { + throw JWTVerificationError.malformedToken + } + + // Pin the algorithm before touching the signature, so a token can't ask + // to be verified as `none`, or as HS256 with the public key doubling as + // the HMAC secret. + guard let headerData = base64URLDecode(segments[0]), + let headerObject = try? JSONSerialization.jsonObject(with: headerData), + let header = headerObject as? [String: Any] else { + throw JWTVerificationError.malformedHeader + } + + guard let algorithm = header["alg"] as? String else { + throw JWTVerificationError.missingAlgorithm + } + guard algorithm == "RS256" else { + throw JWTVerificationError.unsupportedAlgorithm(algorithm) + } + + guard let signature = base64URLDecode(segments[2]), !signature.isEmpty, + let signedData = "\(segments[0]).\(segments[1])".data(using: .utf8) else { + throw JWTVerificationError.malformedSignature + } + + let key = try RSAPublicKey.make(from: publicKey) + + // RFC 8017 8.2.2: the signature is exactly as long as the modulus. + // SecKeyVerifySignature reports other lengths as errSecParam, which + // the error mapping below would blame on the key. + guard signature.count == SecKeyGetBlockSize(key) else { + throw JWTVerificationError.malformedSignature + } + + var verificationError: Unmanaged? + let isValid = SecKeyVerifySignature(key, + .rsaSignatureMessagePKCS1v15SHA256, + signedData as CFData, + signature as CFData, + &verificationError) + guard isValid else { + let error = verificationError?.takeRetainedValue() + // Only errSecVerifyFailed means the signature does not match. Other + // errors mean the key cannot verify RS256 at all. + if let error, CFErrorGetCode(error) != CFIndex(errSecVerifyFailed) { + throw JWTVerificationError.invalidPublicKeyData(reason: error.localizedDescription) + } + throw JWTVerificationError.signatureMismatch + } + + guard let payloadData = base64URLDecode(segments[1]), + let payloadObject = try? JSONSerialization.jsonObject(with: payloadData), + let payload = payloadObject as? [String: Any] else { + throw JWTVerificationError.malformedPayload + } + + return payload + } + + /// Decodes an unpadded base64url segment (RFC 7515) into its raw bytes. + private static func base64URLDecode(_ string: String) -> Data? { + var normalized = string + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + + let remainder = normalized.count % 4 + if remainder > 0 { + normalized += String(repeating: "=", count: 4 - remainder) + } + + return Data(base64Encoded: normalized) + } +} diff --git a/ios/CodePushCore/Sources/CodePushCore/SignatureVerification/RSAPublicKey.swift b/ios/CodePushCore/Sources/CodePushCore/SignatureVerification/RSAPublicKey.swift new file mode 100644 index 00000000..4ab953da --- /dev/null +++ b/ios/CodePushCore/Sources/CodePushCore/SignatureVerification/RSAPublicKey.swift @@ -0,0 +1,45 @@ +import Foundation +import Security + +enum RSAPublicKey { + /// Builds a `SecKey` from a public key in either PEM-armored or bare-base64 + /// form. `SecKeyCreateWithData` accepts both the X.509 SubjectPublicKeyInfo + /// that CodePush distributes and a bare PKCS#1 RSAPublicKey, so the DER is + /// passed through as is. See "Importing Cryptographic Keys" by Eskimo: + /// https://developer.apple.com/forums/thread/680572 + static func make(from string: String) throws -> SecKey { + guard let der = derBytes(from: string) else { + throw JWTVerificationError.malformedPublicKey + } + + let attributes: [CFString: Any] = [ + kSecAttrKeyType: kSecAttrKeyTypeRSA, + kSecAttrKeyClass: kSecAttrKeyClassPublic, + ] + + var creationError: Unmanaged? + guard let key = SecKeyCreateWithData(der as CFData, + attributes as CFDictionary, + &creationError) else { + let reason = creationError?.takeRetainedValue().localizedDescription ?? "unknown error" + throw JWTVerificationError.invalidPublicKeyData(reason: reason) + } + + return key + } + + private static func derBytes(from string: String) -> Data? { + // Not line by line: the ObjC caller removes every newline but only + // the `PUBLIC KEY` markers, so other armor can arrive inline. + let body = string + .replacingOccurrences(of: "-----[^-]+-----", with: "", options: .regularExpression) + .filter { !$0.isWhitespace } + + // Data(base64Encoded: "") is empty Data, not nil. + guard !body.isEmpty else { + return nil + } + + return Data(base64Encoded: body) + } +} diff --git a/ios/CodePushCore/Tests/CodePushCoreTests/JWTVerifierTests.swift b/ios/CodePushCore/Tests/CodePushCoreTests/JWTVerifierTests.swift new file mode 100644 index 00000000..f49b84d4 --- /dev/null +++ b/ios/CodePushCore/Tests/CodePushCoreTests/JWTVerifierTests.swift @@ -0,0 +1,303 @@ +import XCTest +@testable import CodePushCore + +/// Fixtures generated with OpenSSL 3.6.3: +/// +/// openssl genrsa -out priv.pem 2048 +/// openssl rsa -in priv.pem -pubout -out pub.pem +/// printf '%s' "$header.$payload" | openssl dgst -sha256 -sign priv.pem -binary +/// +/// where $header and $payload are the unpadded base64url encodings of the JSON +/// below, matching the shape the CodePush CLI emits into `.codepushrelease`. +private enum Fixture { + static let contentHash = "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90" + + static let publicKeyPEM = """ + -----BEGIN PUBLIC KEY----- + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqwrDQSqwFxanoMZWbv5K + ep3G1jwv8Kg4FMazguXkPX0Efp3hpAbKX+R3LPHTheR+rvoXNo4ioGMWSJjfvYFb + n4MZCpZJW9vc2LmWUgl3Suy5Rirf7sw/EnIyRbgtP/dtr4VMfoilD7u4sUqqGnwo + oD6HgT51TbnqNj+pKLJXslp+3qU9lFMVEFjVvYBUmB+tNTDTMJyeQGj0hLvKE18Y + bIdi2IisPrZNuqNngFgtxlz1LLnKn3kQf/nmJTXtXxnRIyZQiKJI5+4oZvwc/Z9u + 55/jNqQr1M526fbtoUbbj0TGiqyoc6sNr3KRgJgy4QTz4d737JJgp6B+1a8IWny1 + HwIDAQAB + -----END PUBLIC KEY----- + """ + + /// `publicKeyPEM` as a bare PKCS#1 RSAPublicKey: + /// `openssl rsa -pubin -in pub.pem -RSAPublicKey_out` + static let pkcs1PublicKeyPEM = """ + -----BEGIN RSA PUBLIC KEY----- + MIIBCgKCAQEAqwrDQSqwFxanoMZWbv5Kep3G1jwv8Kg4FMazguXkPX0Efp3hpAbK + X+R3LPHTheR+rvoXNo4ioGMWSJjfvYFbn4MZCpZJW9vc2LmWUgl3Suy5Rirf7sw/ + EnIyRbgtP/dtr4VMfoilD7u4sUqqGnwooD6HgT51TbnqNj+pKLJXslp+3qU9lFMV + EFjVvYBUmB+tNTDTMJyeQGj0hLvKE18YbIdi2IisPrZNuqNngFgtxlz1LLnKn3kQ + f/nmJTXtXxnRIyZQiKJI5+4oZvwc/Z9u55/jNqQr1M526fbtoUbbj0TGiqyoc6sN + r3KRgJgy4QTz4d737JJgp6B+1a8IWny1HwIDAQAB + -----END RSA PUBLIC KEY----- + """ + + /// A different keypair, used to prove verification is actually key-bound. + static let otherPublicKeyPEM = """ + -----BEGIN PUBLIC KEY----- + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoea7a3ZjWgjQntjnYdm4 + hfZCsE0CsgXxIUz9Dto/0IyS2iA6VgEl1f/Ghd9n9DlWVoMyZC77yWubtt3bKl7n + DcoX+3JZPFjV4w0tqdR2Ly/XBKxRDqMKYinFKaWuvjlWiQpjum+eE+vSddHko23P + +J6KcYT0rlpcfKTy4U3sr8OuB3zNwISy2x0H9Wos2xrCdY6DlANeH5RdeypV59Xi + 0Bm7aeX4WIfx3v7z2v+0EE6nOVy0rWqaFoRaqMT2CKBpez4Rz7Xmlhwb4HFEY09n + UKltxIPLyPlfFat+rPzdZsAMLNoE2e92HHBbbAcAKYlhqXRXka2UfGPjc8wmzUo5 + BwIDAQAB + -----END PUBLIC KEY----- + """ + + static let validToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGFpbXMiOnsiY29udGVudEhhc2giOiJhMWIyYzNkNGU1ZjYwNzE4MjkzYTRiNWM2ZDdlOGY5MGExYjJjM2Q0ZTVmNjA3MTgyOTNhNGI1YzZkN2U4ZjkwIn0sImNvbnRlbnRIYXNoIjoiYTFiMmMzZDRlNWY2MDcxODI5M2E0YjVjNmQ3ZThmOTBhMWIyYzNkNGU1ZjYwNzE4MjkzYTRiNWM2ZDdlOGY5MCJ9.Q-TCO7SgF3OFtI1o3-Jz079qZ9r7d0YVvD4mCQzd6JCX8Lij5E7JygKDp0CM9YLqxaFCzABYoZedrQaJCWhwygsedHuGOLxWRKk-3oNEIlzcNZJSDUv8bWlwR5Sj1Ujme7m05jRdkPtowxr9rkv38_Pexesq8ZJu2SiDNO9Yzl6K6R12xxQt5kqN9DyPi1l11zVPW-rLmkOTy55hsBOcbVHlCJqz7XEHWVr0OLKCqxWI2K86ADiE5mPdi-8JgXhh39Yk88PPWCNMHYPyblKRcv20PL0uR2MV25OgMca8yx3gSAF2NHxucA7PkAY2qutaFIt5GKc_xNLXkUvZy2QIHQ" + + /// Header claims `none`, signature segment left empty. + static let algNoneToken = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJjbGFpbXMiOnsiY29udGVudEhhc2giOiJhMWIyYzNkNGU1ZjYwNzE4MjkzYTRiNWM2ZDdlOGY5MGExYjJjM2Q0ZTVmNjA3MTgyOTNhNGI1YzZkN2U4ZjkwIn0sImNvbnRlbnRIYXNoIjoiYTFiMmMzZDRlNWY2MDcxODI5M2E0YjVjNmQ3ZThmOTBhMWIyYzNkNGU1ZjYwNzE4MjkzYTRiNWM2ZDdlOGY5MCJ9." + + /// Signed with HMAC-SHA256 using the PEM public key as the shared secret. + /// This is the classic algorithm-confusion attack against RSA-verifying servers. + static let hs256Token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGFpbXMiOnsiY29udGVudEhhc2giOiJhMWIyYzNkNGU1ZjYwNzE4MjkzYTRiNWM2ZDdlOGY5MGExYjJjM2Q0ZTVmNjA3MTgyOTNhNGI1YzZkN2U4ZjkwIn0sImNvbnRlbnRIYXNoIjoiYTFiMmMzZDRlNWY2MDcxODI5M2E0YjVjNmQ3ZThmOTBhMWIyYzNkNGU1ZjYwNzE4MjkzYTRiNWM2ZDdlOGY5MCJ9.q1ASbgJepaqy24fiO4_uVpLoBv36uHdxAuPB3rtrook" + + /// A PKCS#1 RSA private key (`openssl genrsa -traditional`). Its DER also + /// starts with SEQUENCE { INTEGER, INTEGER, ... }, same as a PKCS#1 + /// RSAPublicKey, so it must not be misread as one. + static let privateKeyPEM = """ + -----BEGIN RSA PRIVATE KEY----- + MIIEogIBAAKCAQEA2VhJhyOAIW3x8L8shzQ8g9vtWFxmJCaAAVWOZEJQxiLg9nuk + cNFBgm/aqUo6/mgy0GWzTE9v2k75eghKlo5wlIRs0P672PrfosuMweFCftcDv8w0 + aEyzF4+D7VpCUSmc5J1J9oCAzNdQDSh//COf3buLALDxKQddLwCYp8dVHpM5XtdE + Cex4VHgDOqlWCwI4yxIX8B/LZmwj9aDYIRXsJz54p/6TN9H0vdSr+963iwnd1MM4 + pM14ASQvY8YZt05yrWcuMVQmasyhiiSJwFbySQBa6qXOemB5NKcloBH9Xo/+O0nN + 0LrxMuWNfKp1VhNvjbOkE62yi6ETvDfId/fY/QIDAQABAoIBAELxuKQRQTQKw0yd + 9b8nTcRnhzGCNEvKN/8xNQyYiHye3Z/N8ZZ2PbFJdpZSMN2Phqc9y/4jMZaSYLE/ + wImjEfYM2997tqxciWmBPLezBGJBI/x9j17Y0QjewYnoFSkeoh6ge9mbNxDg+N00 + rkchSepoC6aI2Z8Dj6YcjuiOsx8ZNAFIP1hkSH3UxHAu1i967nBCEjIip6UqomuJ + V4iaYia6ZRNIZIBBOFzMwqlQudvu/1Xm2UTQ/E3sZ0DMaMv+8S3KZ2hZjagtYETI + 25jxwi4WDcUfi7LV8UOjAl3On5C/4WITk3xOUJegpwF4wQBOYTGBfliexB4XQGpD + QW+4XdkCgYEA/ufPgKRLaKd/KJ5Dy01LslEcHNB9/zyMWjxkVYAUOLG/JhkssP8/ + 98PeGzybVcUejySAiRlpM9g5pKN2MvCYmCgQAdAjPn2wv1iPpAJqNPLDGH2ninIp + GjaIoaaIrZineimytwIYPqz2y2U5KqUWMKCdw9VfQCeRmA2cpPhKygcCgYEA2kcw + vmRkGZ3wyOUMdWFmGMLzyzvGgI4vibpN/3jHvASYQgZKpRxRdDz8e2o5oJD5MNlS + mXtiORBuSyxrElI93c8UTGEtyT0yGMzyqekTyTwPgSLjUZJakz+BIZO2V0NxOg6I + yg6OPhPHQhpJT9opvaWMyOguYJMK9j9jd99tk9sCgYBTQigWnpM65aq0+6ytFNwq + rC+AgeEFoyM8vgkPnDv+oKAMs6BTV6vvI3zCbLNX+oCWxJdvMGuaUX/SdMnArbFJ + 0ZwY24EHgkpe1XBlY5yJGtn6f+BfahZex30tP5XZDcl2L3R+LiL5JcmeebuIYE+1 + JmEhe+oWrFXz0k5d2/OhtQKBgE1FJlw/7a+j2blEXESxVhMt8F/hqUOZEfsPmMz3 + VL2aYsv8rjAE9lDPxhFqaTwOFijUxgjPRNYumtzBvKWft4bt56GiRTMYFsPwyXac + rARw3ow4bYBS7v1xXaUlRUde+JY0zve4b2JKXh9obst1z5DzKgN1LfXCF/9UMUIe + NgxBAoGAE1zIkL2VT5subX6AaBp/jEOhZJv3KMY0tNLPV90dZqyS7OdwMNMG5fr4 + RjCOriggOMmgWDyQvVtJia/F83/77EOniEzlbF2jsOe9GdsIFF23WZkvaWopUqD3 + azYmUTYxq+xJH+OBbKqZ8vratPhdqIfIFgtE7ON7cYYmUVJ7ub0= + -----END RSA PRIVATE KEY----- + """ + + /// `openssl ecparam -name prime256v1 -genkey -noout | openssl ec -pubout` + static let ecPublicKeyPEM = """ + -----BEGIN PUBLIC KEY----- + MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEtIGNT+Gnne5Fimk02xR54AP5XKwi + 5A/mS+iuwhT/7Kr/JkOk3kplfXvEzj8jWRrn2zmKfCuUpNnHGdQ5/+r3cg== + -----END PUBLIC KEY----- + """ + + /// `openssl genpkey -algorithm rsa-pss | openssl pkey -pubout` + static let rsaPSSPublicKeyPEM = """ + -----BEGIN PUBLIC KEY----- + MIIBIDALBgkqhkiG9w0BAQoDggEPADCCAQoCggEBALcbbE7nAICNa+ovaoVfASCd + 1ZjEd6E8VnAZ82VMq7qcLBGN+JSCh+ztdDuqAGsbc3MG5C7RNCAnOTBgYJ+u/qvx + 7USd69kPsLSwg4H95+wi6oTPXmeVi1m3AoLCuYxKgsvMzi546DQHUDOSYnwFgEaW + 4e4kNv/L4ojJ0bedzZ1B1zYpEJZlxANt7EyMo5MgQI2k6uzduD2GE7C4vWglCsp1 + 0FTFBluPMrHulOZyQN6lHmgcT4XxxPO72VpqnL5Cl0YZeJowPGtGMKgUWZFENjDP + EEqQeyTAbYb+kxX6xi6z00T0TF5CefD9Go74ftHPHbNQIH73wz3i1g1W5VwaamEC + AwEAAQ== + -----END PUBLIC KEY----- + """ + + /// What `+[CodePushUpdateUtils getKeyValueFromPublicKeyString:]` passes + /// to the verifier. + static var strippedPublicKey: String { + publicKeyPEM + .replacingOccurrences(of: "-----BEGIN PUBLIC KEY-----\n", with: "") + .replacingOccurrences(of: "-----END PUBLIC KEY-----", with: "") + .replacingOccurrences(of: "\n", with: "") + } +} + +final class JWTVerifierTests: XCTestCase { + func testVerifiesGenuineTokenAndReturnsPayload() throws { + let payload = try JWTVerifier.verifyAndDecode(Fixture.validToken, publicKey: Fixture.publicKeyPEM) + + XCTAssertEqual(payload["contentHash"] as? String, Fixture.contentHash) + } + + func testAcceptsPublicKeyWithPEMArmorAlreadyStripped() throws { + let payload = try JWTVerifier.verifyAndDecode(Fixture.validToken, publicKey: Fixture.strippedPublicKey) + + XCTAssertEqual(payload["contentHash"] as? String, Fixture.contentHash) + } + + func testAcceptsPKCS1PublicKey() throws { + let payload = try JWTVerifier.verifyAndDecode(Fixture.validToken, publicKey: Fixture.pkcs1PublicKeyPEM) + + XCTAssertEqual(payload["contentHash"] as? String, Fixture.contentHash) + } + + func testAcceptsPKCS1PublicKeyOnOneLine() throws { + // getKeyValueFromPublicKeyString: removes every newline but keeps the + // `RSA PUBLIC KEY` markers. + let key = Fixture.pkcs1PublicKeyPEM.replacingOccurrences(of: "\n", with: "") + let payload = try JWTVerifier.verifyAndDecode(Fixture.validToken, publicKey: key) + + XCTAssertEqual(payload["contentHash"] as? String, Fixture.contentHash) + } + + func testAcceptsIndentedPEM() throws { + let key = Fixture.publicKeyPEM + .components(separatedBy: "\n") + .map { " \($0)" } + .joined(separator: "\n") + let payload = try JWTVerifier.verifyAndDecode(Fixture.validToken, publicKey: key) + + XCTAssertEqual(payload["contentHash"] as? String, Fixture.contentHash) + } + + func testAcceptsTokenWithTrailingNewline() throws { + let payload = try JWTVerifier.verifyAndDecode(Fixture.validToken + "\r\n", publicKey: Fixture.publicKeyPEM) + + XCTAssertEqual(payload["contentHash"] as? String, Fixture.contentHash) + } + + func testRejectsTamperedPayload() { + let segments = Fixture.validToken.components(separatedBy: ".") + let forged = base64URLEncode(#"{"contentHash":"0000000000000000000000000000000000000000000000000000000000000000"}"#) + let tampered = "\(segments[0]).\(forged).\(segments[2])" + + assertThrows(.signatureMismatch) { + try JWTVerifier.verifyAndDecode(tampered, publicKey: Fixture.publicKeyPEM) + } + } + + func testRejectsTokenSignedByADifferentKey() { + assertThrows(.signatureMismatch) { + try JWTVerifier.verifyAndDecode(Fixture.validToken, publicKey: Fixture.otherPublicKeyPEM) + } + } + + func testRejectsHeaderWithoutAlgorithm() { + let segments = Fixture.validToken.components(separatedBy: ".") + let header = base64URLEncode(#"{"typ":"JWT"}"#) + + assertThrows(.missingAlgorithm) { + try JWTVerifier.verifyAndDecode("\(header).\(segments[1]).\(segments[2])", publicKey: Fixture.publicKeyPEM) + } + } + + func testRejectsAlgNone() { + assertThrows(.unsupportedAlgorithm("none")) { + try JWTVerifier.verifyAndDecode(Fixture.algNoneToken, publicKey: Fixture.publicKeyPEM) + } + } + + func testRejectsHS256SignedWithThePublicKeyAsSecret() { + assertThrows(.unsupportedAlgorithm("HS256")) { + try JWTVerifier.verifyAndDecode(Fixture.hs256Token, publicKey: Fixture.publicKeyPEM) + } + } + + func testRejectsTokenWithWrongSegmentCount() { + let segments = Fixture.validToken.components(separatedBy: ".") + + assertThrows(.malformedToken) { + try JWTVerifier.verifyAndDecode("\(segments[0]).\(segments[1])", publicKey: Fixture.publicKeyPEM) + } + } + + func testRejectsEmptySignatureSegment() { + let segments = Fixture.validToken.components(separatedBy: ".") + + assertThrows(.malformedSignature) { + try JWTVerifier.verifyAndDecode("\(segments[0]).\(segments[1]).", publicKey: Fixture.publicKeyPEM) + } + } + + func testRejectsSignatureOfWrongLength() { + let segments = Fixture.validToken.components(separatedBy: ".") + + for signature in [String(segments[2].dropLast(4)), segments[2] + "AAAA"] { + assertThrows(.malformedSignature) { + try JWTVerifier.verifyAndDecode("\(segments[0]).\(segments[1]).\(signature)", publicKey: Fixture.publicKeyPEM) + } + } + } + + func testRejectsEmptyPublicKeyBody() { + assertThrows(.malformedPublicKey) { + try JWTVerifier.verifyAndDecode(Fixture.validToken, + publicKey: "-----BEGIN PUBLIC KEY-----\n-----END PUBLIC KEY-----") + } + } + + func testRejectsUnparseablePublicKey() { + assertThrows(.malformedPublicKey) { + try JWTVerifier.verifyAndDecode(Fixture.validToken, publicKey: "not a key") + } + } + + // The tests below pin the Security framework behavior that + // RSAPublicKey relies on: SecKeyCreateWithData rejects everything that is + // not RSA public key material. + + func testRejectsPrivateKeyConfiguredAsPublicKey() { + assertRejectsAsInvalidKeyData(Fixture.privateKeyPEM) + } + + func testRejectsECPublicKey() { + assertRejectsAsInvalidKeyData(Fixture.ecPublicKeyPEM) + } + + func testRejectsMalformedRSAKeyData() { + let truncated = Data(base64Encoded: Fixture.strippedPublicKey)!.dropLast(10).base64EncodedString() + // SEQUENCE { INTEGER 2, INTEGER 3 }: the PKCS#1 RSAPublicKey shape, + // but not usable RSA key material. + let unusable = "MAYCAQICAQM=" + + for key in [Fixture.rsaPSSPublicKeyPEM, truncated, unusable] { + assertRejectsAsInvalidKeyData(key) + } + } + + private func base64URLEncode(_ string: String) -> String { + Data(string.utf8).base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + private func assertRejectsAsInvalidKeyData(_ key: String, + file: StaticString = #filePath, + line: UInt = #line) { + do { + _ = try JWTVerifier.verifyAndDecode(Fixture.validToken, publicKey: key) + XCTFail("Expected invalidPublicKeyData but verification succeeded", file: file, line: line) + } catch JWTVerificationError.invalidPublicKeyData { + // The Security framework's message isn't a stable, documented + // string, so only the case is asserted, not its associated reason. + } catch { + XCTFail("Expected invalidPublicKeyData but got \(error)", file: file, line: line) + } + } + + private func assertThrows(_ expected: JWTVerificationError, + file: StaticString = #filePath, + line: UInt = #line, + _ body: () throws -> [String: Any]) { + do { + _ = try body() + XCTFail("Expected \(expected) but verification succeeded", file: file, line: line) + } catch let error as JWTVerificationError { + XCTAssertEqual(String(describing: error), String(describing: expected), file: file, line: line) + } catch { + XCTFail("Expected \(expected) but got \(error)", file: file, line: line) + } + } +} diff --git a/package.json b/package.json index 39dd9cc5..680bac34 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,9 @@ "test:debugger:ios": "mocha --extension mts --recursive --inspect-brk=0.0.0.0 test --ios", "test:unit:js": "mocha --config .mocharc.unit.cjs", "test:unit:android": "cd android && ./gradlew :app:test", - "test:unit:ios": "xcodebuild test -project ios/CodePush.xcodeproj -scheme CodePushTests -destination \"platform=iOS Simulator,id=$(xcrun simctl list devices available | grep -m1 iPhone | grep -oE '[0-9A-F-]{36}')\"", + "test:unit:ios": "npm run test:unit:ios:codepush && npm run test:unit:ios:core", + "test:unit:ios:codepush": "xcodebuild test -project ios/CodePush.xcodeproj -scheme CodePushTests -destination \"platform=iOS Simulator,id=$(xcrun simctl list devices available | grep -m1 iPhone | grep -oE '[0-9A-F-]{36}')\"", + "test:unit:ios:core": "cd ios/CodePushCore && xcodebuild test -scheme CodePushCore -destination \"platform=iOS Simulator,id=$(xcrun simctl list devices available | grep -m1 iPhone | grep -oE '[0-9A-F-]{36}')\"", "lint": "eslint --max-warnings 0 'src/**/*.ts' 'test/**/*.mts'", "lint:fix": "npm run lint -- --fix" },