From fbf2b24a31185ba3fd64b3b7a94d5a0400f7be9a Mon Sep 17 00:00:00 2001 From: dadachi Date: Mon, 23 Mar 2026 08:46:50 +0900 Subject: [PATCH] Add TLS certificate pinning for API connections Pin leaf and intermediate CA public keys for api.nativeapptemplate.com to prevent MITM attacks. Centralizes URLSession creation via .pinned extension using a shared CertificatePinningDelegate, replacing per-service URLSession instances with a default pinned session in the Service protocol. Co-Authored-By: Claude Opus 4.6 (1M context) --- NativeAppTemplate.xcodeproj/project.pbxproj | 4 + NativeAppTemplate/Logging/Logger.swift | 4 + NativeAppTemplate/Login/SessionsService.swift | 2 +- NativeAppTemplate/Login/SignUpService.swift | 2 +- .../Network/CertificatePinningDelegate.swift | 114 ++++++++++++++++++ .../Network/NativeAppTemplateAPI.swift | 2 +- .../Services/AccountPasswordService.swift | 3 - .../Networking/Services/ItemTagsService.swift | 3 - .../Networking/Services/MeService.swift | 3 - .../Services/PermissionsService.swift | 3 - .../Networking/Services/Service.swift | 4 + .../Networking/Services/ShopsService.swift | 3 - 12 files changed, 129 insertions(+), 18 deletions(-) create mode 100644 NativeAppTemplate/Networking/Network/CertificatePinningDelegate.swift diff --git a/NativeAppTemplate.xcodeproj/project.pbxproj b/NativeAppTemplate.xcodeproj/project.pbxproj index 9cb2d77..81a4b74 100644 --- a/NativeAppTemplate.xcodeproj/project.pbxproj +++ b/NativeAppTemplate.xcodeproj/project.pbxproj @@ -46,6 +46,7 @@ 0172033925A9642E008FD63B /* JSONAPIRelationship.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0172030A25A9642E008FD63B /* JSONAPIRelationship.swift */; }; 0172033A25A9642E008FD63B /* JSONAPIDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0172030B25A9642E008FD63B /* JSONAPIDocument.swift */; }; 0172033B25A9642E008FD63B /* JSONAPIErrorSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0172030C25A9642E008FD63B /* JSONAPIErrorSource.swift */; }; + 7249A60C06FE44338E16BC50 /* CertificatePinningDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C597C0551370446BB931F19B /* CertificatePinningDelegate.swift */; }; 0172033C25A9642E008FD63B /* NativeAppTemplateAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0172030E25A9642E008FD63B /* NativeAppTemplateAPI.swift */; }; 0172033D25A9642E008FD63B /* NativeAppTemplateEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0172030F25A9642E008FD63B /* NativeAppTemplateEnvironment.swift */; }; 0172033E25A9642E008FD63B /* Parameters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0172031125A9642E008FD63B /* Parameters.swift */; }; @@ -221,6 +222,7 @@ 0172030A25A9642E008FD63B /* JSONAPIRelationship.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JSONAPIRelationship.swift; sourceTree = ""; }; 0172030B25A9642E008FD63B /* JSONAPIDocument.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JSONAPIDocument.swift; sourceTree = ""; }; 0172030C25A9642E008FD63B /* JSONAPIErrorSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JSONAPIErrorSource.swift; sourceTree = ""; }; + C597C0551370446BB931F19B /* CertificatePinningDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertificatePinningDelegate.swift; sourceTree = ""; }; 0172030E25A9642E008FD63B /* NativeAppTemplateAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NativeAppTemplateAPI.swift; sourceTree = ""; }; 0172030F25A9642E008FD63B /* NativeAppTemplateEnvironment.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NativeAppTemplateEnvironment.swift; sourceTree = ""; }; 0172031125A9642E008FD63B /* Parameters.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Parameters.swift; sourceTree = ""; }; @@ -529,6 +531,7 @@ 0172030D25A9642E008FD63B /* Network */ = { isa = PBXGroup; children = ( + C597C0551370446BB931F19B /* CertificatePinningDelegate.swift */, 0172030E25A9642E008FD63B /* NativeAppTemplateAPI.swift */, 0172030F25A9642E008FD63B /* NativeAppTemplateEnvironment.swift */, ); @@ -979,6 +982,7 @@ 01E0A60C25BD440300298D35 /* SignInEmailAndPasswordView.swift in Sources */, 0172033925A9642E008FD63B /* JSONAPIRelationship.swift in Sources */, 01B526562AF4E82A00655131 /* ScrollToTopID.swift in Sources */, + 7249A60C06FE44338E16BC50 /* CertificatePinningDelegate.swift in Sources */, 0172033C25A9642E008FD63B /* NativeAppTemplateAPI.swift in Sources */, 017203B325A96FD6008FD63B /* UIApplication+DismissKeyboard.swift in Sources */, 01A133A72E08BB66000AD24A /* SignUpViewModel.swift in Sources */, diff --git a/NativeAppTemplate/Logging/Logger.swift b/NativeAppTemplate/Logging/Logger.swift index 28bb82c..34dbdb2 100644 --- a/NativeAppTemplate/Logging/Logger.swift +++ b/NativeAppTemplate/Logging/Logger.swift @@ -32,6 +32,10 @@ struct Failure { .init(source: source, action: "destroy", reason: reason) } + static func certificatePinning(from source: (some Any).Type, reason: String) -> Self { + .init(source: source, action: "certificatePinning", reason: reason) + } + private init( source: Source.Type, action: String, diff --git a/NativeAppTemplate/Login/SessionsService.swift b/NativeAppTemplate/Login/SessionsService.swift index 90a3b15..92c654f 100644 --- a/NativeAppTemplate/Login/SessionsService.swift +++ b/NativeAppTemplate/Login/SessionsService.swift @@ -8,7 +8,7 @@ import SwiftyJSON struct SessionsService { var networkClient: NativeAppTemplateAPI - var session = URLSession(configuration: .default) + var session: URLSession = .pinned } extension SessionsService { diff --git a/NativeAppTemplate/Login/SignUpService.swift b/NativeAppTemplate/Login/SignUpService.swift index f420a6c..90af43c 100644 --- a/NativeAppTemplate/Login/SignUpService.swift +++ b/NativeAppTemplate/Login/SignUpService.swift @@ -8,7 +8,7 @@ import SwiftyJSON struct SignUpsService { var networkClient = NativeAppTemplateAPI() - var session = URLSession(configuration: .default) + var session: URLSession = .pinned } extension SignUpsService { diff --git a/NativeAppTemplate/Networking/Network/CertificatePinningDelegate.swift b/NativeAppTemplate/Networking/Network/CertificatePinningDelegate.swift new file mode 100644 index 0000000..ac18604 --- /dev/null +++ b/NativeAppTemplate/Networking/Network/CertificatePinningDelegate.swift @@ -0,0 +1,114 @@ +// +// CertificatePinningDelegate.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi. +// + +import CommonCrypto +import Foundation + +final class CertificatePinningDelegate: NSObject, URLSessionDelegate { + // SPKI SHA-256 hashes (base64-encoded) for api.nativeapptemplate.com + // The server uses Google Trust Services certificates (Render hosting). + // Pin the leaf public key and Google Trust Services intermediate CAs as backup. + static let pinnedHashes: Set = [ + // Leaf certificate public key (api.nativeapptemplate.com) + "54Il7gpV4QvX8fAyEKV+6fp8VGjgHqIAAqF5bLCfYNQ=", + // Google Trust Services WE1 intermediate CA + "kIdp6NNEd8wsugYyyIYFsi1ylMCED3hZbSR8ZFsa/A4=" + ] + + static let pinnedDomain = String.domain + + // ASN.1 header for EC 256-bit public key (SPKI prefix) + private static let ecDsaSecp256r1Asn1Header: [UInt8] = [ + 0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2A, 0x86, + 0x48, 0xCE, 0x3D, 0x02, 0x01, 0x06, 0x08, 0x2A, + 0x86, 0x48, 0xCE, 0x3D, 0x03, 0x01, 0x07, 0x03, + 0x42, 0x00 + ] + + // ASN.1 header for RSA 2048-bit public key (SPKI prefix) + private static let rsa2048Asn1Header: [UInt8] = [ + 0x30, 0x82, 0x01, 0x22, 0x30, 0x0D, 0x06, 0x09, + 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, + 0x01, 0x05, 0x00, 0x03, 0x82, 0x01, 0x0F, 0x00 + ] + + func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, + challenge.protectionSpace.host == Self.pinnedDomain, + let serverTrust = challenge.protectionSpace.serverTrust + else { + completionHandler(.performDefaultHandling, nil) + return + } + + let certificateCount = SecTrustGetCertificateCount(serverTrust) + var pinMatched = false + + for index in 0..? + guard let publicKeyData = SecKeyCopyExternalRepresentation(publicKey, &error) as Data? else { continue } + + let spkiHash = Self.sha256WithAsn1Header(for: publicKeyData) + + if Self.pinnedHashes.contains(spkiHash) { + pinMatched = true + break + } + } + + if pinMatched { + completionHandler(.useCredential, URLCredential(trust: serverTrust)) + } else { + Failure.certificatePinning( + from: Self.self, + reason: "Pin mismatch for \(challenge.protectionSpace.host)" + ).log() + completionHandler(.cancelAuthenticationChallenge, nil) + } + } + + private static func sha256WithAsn1Header(for publicKeyData: Data) -> String { + let header: [UInt8] = if publicKeyData.count == 65 { + ecDsaSecp256r1Asn1Header + } else { + rsa2048Asn1Header + } + + var spkiData = Data(header) + spkiData.append(publicKeyData) + + var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) + spkiData.withUnsafeBytes { buffer in + _ = CC_SHA256(buffer.baseAddress, CC_LONG(spkiData.count), &hash) + } + + return Data(hash).base64EncodedString() + } +} + +extension URLSession { + private static let pinningDelegate = CertificatePinningDelegate() + + static let pinned: URLSession = { + let configuration = URLSessionConfiguration.default + return URLSession( + configuration: configuration, + delegate: pinningDelegate, + delegateQueue: nil + ) + }() +} diff --git a/NativeAppTemplate/Networking/Network/NativeAppTemplateAPI.swift b/NativeAppTemplate/Networking/Network/NativeAppTemplateAPI.swift index b8c6bea..215d8a8 100644 --- a/NativeAppTemplate/Networking/Network/NativeAppTemplateAPI.swift +++ b/NativeAppTemplate/Networking/Network/NativeAppTemplateAPI.swift @@ -73,7 +73,7 @@ public struct NativeAppTemplateAPI: Equatable { // MARK: - Initializers nonisolated init( - session: URLSession = .init(configuration: .default), + session: URLSession = .pinned, environment: NativeAppTemplateEnvironment = .prod, authToken: String, client: String, diff --git a/NativeAppTemplate/Networking/Services/AccountPasswordService.swift b/NativeAppTemplate/Networking/Services/AccountPasswordService.swift index 10cbe7d..e3fd99a 100644 --- a/NativeAppTemplate/Networking/Services/AccountPasswordService.swift +++ b/NativeAppTemplate/Networking/Services/AccountPasswordService.swift @@ -3,11 +3,8 @@ // NativeAppTemplate // -import class Foundation.URLSession - struct AccountPasswordService: Service { var networkClient = NativeAppTemplateAPI() - let session = URLSession(configuration: .default) } extension AccountPasswordService { diff --git a/NativeAppTemplate/Networking/Services/ItemTagsService.swift b/NativeAppTemplate/Networking/Services/ItemTagsService.swift index 9347a8e..ff6414b 100644 --- a/NativeAppTemplate/Networking/Services/ItemTagsService.swift +++ b/NativeAppTemplate/Networking/Services/ItemTagsService.swift @@ -3,11 +3,8 @@ // NativeAppTemplate // -import class Foundation.URLSession - struct ItemTagsService: Service { var networkClient = NativeAppTemplateAPI() - let session = URLSession(configuration: .default) } extension ItemTagsService { diff --git a/NativeAppTemplate/Networking/Services/MeService.swift b/NativeAppTemplate/Networking/Services/MeService.swift index 0a44ddd..4ac18aa 100644 --- a/NativeAppTemplate/Networking/Services/MeService.swift +++ b/NativeAppTemplate/Networking/Services/MeService.swift @@ -3,11 +3,8 @@ // NativeAppTemplate // -import class Foundation.URLSession - struct MeService: Service { var networkClient = NativeAppTemplateAPI() - let session = URLSession(configuration: .default) } // MARK: - Internal diff --git a/NativeAppTemplate/Networking/Services/PermissionsService.swift b/NativeAppTemplate/Networking/Services/PermissionsService.swift index 785bb9f..e4088bf 100644 --- a/NativeAppTemplate/Networking/Services/PermissionsService.swift +++ b/NativeAppTemplate/Networking/Services/PermissionsService.swift @@ -3,11 +3,8 @@ // NativeAppTemplate // -import class Foundation.URLSession - struct PermissionsService: Service { var networkClient = NativeAppTemplateAPI() - let session = URLSession(configuration: .default) } extension PermissionsService { diff --git a/NativeAppTemplate/Networking/Services/Service.swift b/NativeAppTemplate/Networking/Services/Service.swift index d1205fb..43e107c 100644 --- a/NativeAppTemplate/Networking/Services/Service.swift +++ b/NativeAppTemplate/Networking/Services/Service.swift @@ -11,6 +11,10 @@ protocol Service { var session: URLSession { get } } +extension Service { + var session: URLSession { .pinned } +} + extension Service { var isAuthenticated: Bool { !networkClient.authToken.isEmpty diff --git a/NativeAppTemplate/Networking/Services/ShopsService.swift b/NativeAppTemplate/Networking/Services/ShopsService.swift index 945879c..51a440a 100644 --- a/NativeAppTemplate/Networking/Services/ShopsService.swift +++ b/NativeAppTemplate/Networking/Services/ShopsService.swift @@ -3,11 +3,8 @@ // NativeAppTemplate // -import class Foundation.URLSession - struct ShopsService: Service { var networkClient = NativeAppTemplateAPI() - let session = URLSession(configuration: .default) } // MARK: - Internal