Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -202,3 +202,7 @@ test/crash-logs/
*.rm

.claude/settings.local.json

# Swift Package Manager (ios/CodePushCore)
.build/
.swiftpm/
20 changes: 20 additions & 0 deletions ios/CodePushCore/Package.swift
Original file line number Diff line number Diff line change
@@ -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"]),
]
)
Original file line number Diff line number Diff line change
@@ -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."
}
}
}
Original file line number Diff line number Diff line change
@@ -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<CFError>?
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)
}
}
Original file line number Diff line number Diff line change
@@ -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<CFError>?
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)
}
}
Loading
Loading