Skip to content

Commit 501cf9c

Browse files
authored
Merge pull request #20 from nativeapptemplate/add_tls_certificate_pinning
Add TLS certificate pinning for API connections
2 parents 8b350d4 + fbf2b24 commit 501cf9c

12 files changed

Lines changed: 129 additions & 18 deletions

NativeAppTemplate.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
0172033925A9642E008FD63B /* JSONAPIRelationship.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0172030A25A9642E008FD63B /* JSONAPIRelationship.swift */; };
4747
0172033A25A9642E008FD63B /* JSONAPIDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0172030B25A9642E008FD63B /* JSONAPIDocument.swift */; };
4848
0172033B25A9642E008FD63B /* JSONAPIErrorSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0172030C25A9642E008FD63B /* JSONAPIErrorSource.swift */; };
49+
7249A60C06FE44338E16BC50 /* CertificatePinningDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C597C0551370446BB931F19B /* CertificatePinningDelegate.swift */; };
4950
0172033C25A9642E008FD63B /* NativeAppTemplateAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0172030E25A9642E008FD63B /* NativeAppTemplateAPI.swift */; };
5051
0172033D25A9642E008FD63B /* NativeAppTemplateEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0172030F25A9642E008FD63B /* NativeAppTemplateEnvironment.swift */; };
5152
0172033E25A9642E008FD63B /* Parameters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0172031125A9642E008FD63B /* Parameters.swift */; };
@@ -221,6 +222,7 @@
221222
0172030A25A9642E008FD63B /* JSONAPIRelationship.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JSONAPIRelationship.swift; sourceTree = "<group>"; };
222223
0172030B25A9642E008FD63B /* JSONAPIDocument.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JSONAPIDocument.swift; sourceTree = "<group>"; };
223224
0172030C25A9642E008FD63B /* JSONAPIErrorSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JSONAPIErrorSource.swift; sourceTree = "<group>"; };
225+
C597C0551370446BB931F19B /* CertificatePinningDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertificatePinningDelegate.swift; sourceTree = "<group>"; };
224226
0172030E25A9642E008FD63B /* NativeAppTemplateAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NativeAppTemplateAPI.swift; sourceTree = "<group>"; };
225227
0172030F25A9642E008FD63B /* NativeAppTemplateEnvironment.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NativeAppTemplateEnvironment.swift; sourceTree = "<group>"; };
226228
0172031125A9642E008FD63B /* Parameters.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Parameters.swift; sourceTree = "<group>"; };
@@ -529,6 +531,7 @@
529531
0172030D25A9642E008FD63B /* Network */ = {
530532
isa = PBXGroup;
531533
children = (
534+
C597C0551370446BB931F19B /* CertificatePinningDelegate.swift */,
532535
0172030E25A9642E008FD63B /* NativeAppTemplateAPI.swift */,
533536
0172030F25A9642E008FD63B /* NativeAppTemplateEnvironment.swift */,
534537
);
@@ -979,6 +982,7 @@
979982
01E0A60C25BD440300298D35 /* SignInEmailAndPasswordView.swift in Sources */,
980983
0172033925A9642E008FD63B /* JSONAPIRelationship.swift in Sources */,
981984
01B526562AF4E82A00655131 /* ScrollToTopID.swift in Sources */,
985+
7249A60C06FE44338E16BC50 /* CertificatePinningDelegate.swift in Sources */,
982986
0172033C25A9642E008FD63B /* NativeAppTemplateAPI.swift in Sources */,
983987
017203B325A96FD6008FD63B /* UIApplication+DismissKeyboard.swift in Sources */,
984988
01A133A72E08BB66000AD24A /* SignUpViewModel.swift in Sources */,

NativeAppTemplate/Logging/Logger.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ struct Failure {
3232
.init(source: source, action: "destroy", reason: reason)
3333
}
3434

35+
static func certificatePinning(from source: (some Any).Type, reason: String) -> Self {
36+
.init(source: source, action: "certificatePinning", reason: reason)
37+
}
38+
3539
private init<Source>(
3640
source: Source.Type,
3741
action: String,

NativeAppTemplate/Login/SessionsService.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import SwiftyJSON
88

99
struct SessionsService {
1010
var networkClient: NativeAppTemplateAPI
11-
var session = URLSession(configuration: .default)
11+
var session: URLSession = .pinned
1212
}
1313

1414
extension SessionsService {

NativeAppTemplate/Login/SignUpService.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import SwiftyJSON
88

99
struct SignUpsService {
1010
var networkClient = NativeAppTemplateAPI()
11-
var session = URLSession(configuration: .default)
11+
var session: URLSession = .pinned
1212
}
1313

1414
extension SignUpsService {
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
//
2+
// CertificatePinningDelegate.swift
3+
// NativeAppTemplate
4+
//
5+
// Created by Daisuke Adachi.
6+
//
7+
8+
import CommonCrypto
9+
import Foundation
10+
11+
final class CertificatePinningDelegate: NSObject, URLSessionDelegate {
12+
// SPKI SHA-256 hashes (base64-encoded) for api.nativeapptemplate.com
13+
// The server uses Google Trust Services certificates (Render hosting).
14+
// Pin the leaf public key and Google Trust Services intermediate CAs as backup.
15+
static let pinnedHashes: Set<String> = [
16+
// Leaf certificate public key (api.nativeapptemplate.com)
17+
"54Il7gpV4QvX8fAyEKV+6fp8VGjgHqIAAqF5bLCfYNQ=",
18+
// Google Trust Services WE1 intermediate CA
19+
"kIdp6NNEd8wsugYyyIYFsi1ylMCED3hZbSR8ZFsa/A4="
20+
]
21+
22+
static let pinnedDomain = String.domain
23+
24+
// ASN.1 header for EC 256-bit public key (SPKI prefix)
25+
private static let ecDsaSecp256r1Asn1Header: [UInt8] = [
26+
0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2A, 0x86,
27+
0x48, 0xCE, 0x3D, 0x02, 0x01, 0x06, 0x08, 0x2A,
28+
0x86, 0x48, 0xCE, 0x3D, 0x03, 0x01, 0x07, 0x03,
29+
0x42, 0x00
30+
]
31+
32+
// ASN.1 header for RSA 2048-bit public key (SPKI prefix)
33+
private static let rsa2048Asn1Header: [UInt8] = [
34+
0x30, 0x82, 0x01, 0x22, 0x30, 0x0D, 0x06, 0x09,
35+
0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01,
36+
0x01, 0x05, 0x00, 0x03, 0x82, 0x01, 0x0F, 0x00
37+
]
38+
39+
func urlSession(
40+
_ session: URLSession,
41+
didReceive challenge: URLAuthenticationChallenge,
42+
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
43+
) {
44+
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
45+
challenge.protectionSpace.host == Self.pinnedDomain,
46+
let serverTrust = challenge.protectionSpace.serverTrust
47+
else {
48+
completionHandler(.performDefaultHandling, nil)
49+
return
50+
}
51+
52+
let certificateCount = SecTrustGetCertificateCount(serverTrust)
53+
var pinMatched = false
54+
55+
for index in 0..<certificateCount {
56+
guard let certificate = SecTrustCopyCertificateChain(serverTrust)
57+
.map({ unsafeBitCast(CFArrayGetValueAtIndex($0, index), to: SecCertificate.self) })
58+
else { continue }
59+
60+
guard let publicKey = SecCertificateCopyKey(certificate) else { continue }
61+
62+
var error: Unmanaged<CFError>?
63+
guard let publicKeyData = SecKeyCopyExternalRepresentation(publicKey, &error) as Data? else { continue }
64+
65+
let spkiHash = Self.sha256WithAsn1Header(for: publicKeyData)
66+
67+
if Self.pinnedHashes.contains(spkiHash) {
68+
pinMatched = true
69+
break
70+
}
71+
}
72+
73+
if pinMatched {
74+
completionHandler(.useCredential, URLCredential(trust: serverTrust))
75+
} else {
76+
Failure.certificatePinning(
77+
from: Self.self,
78+
reason: "Pin mismatch for \(challenge.protectionSpace.host)"
79+
).log()
80+
completionHandler(.cancelAuthenticationChallenge, nil)
81+
}
82+
}
83+
84+
private static func sha256WithAsn1Header(for publicKeyData: Data) -> String {
85+
let header: [UInt8] = if publicKeyData.count == 65 {
86+
ecDsaSecp256r1Asn1Header
87+
} else {
88+
rsa2048Asn1Header
89+
}
90+
91+
var spkiData = Data(header)
92+
spkiData.append(publicKeyData)
93+
94+
var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
95+
spkiData.withUnsafeBytes { buffer in
96+
_ = CC_SHA256(buffer.baseAddress, CC_LONG(spkiData.count), &hash)
97+
}
98+
99+
return Data(hash).base64EncodedString()
100+
}
101+
}
102+
103+
extension URLSession {
104+
private static let pinningDelegate = CertificatePinningDelegate()
105+
106+
static let pinned: URLSession = {
107+
let configuration = URLSessionConfiguration.default
108+
return URLSession(
109+
configuration: configuration,
110+
delegate: pinningDelegate,
111+
delegateQueue: nil
112+
)
113+
}()
114+
}

NativeAppTemplate/Networking/Network/NativeAppTemplateAPI.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ public struct NativeAppTemplateAPI: Equatable {
7373
// MARK: - Initializers
7474

7575
nonisolated init(
76-
session: URLSession = .init(configuration: .default),
76+
session: URLSession = .pinned,
7777
environment: NativeAppTemplateEnvironment = .prod,
7878
authToken: String,
7979
client: String,

NativeAppTemplate/Networking/Services/AccountPasswordService.swift

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,8 @@
33
// NativeAppTemplate
44
//
55

6-
import class Foundation.URLSession
7-
86
struct AccountPasswordService: Service {
97
var networkClient = NativeAppTemplateAPI()
10-
let session = URLSession(configuration: .default)
118
}
129

1310
extension AccountPasswordService {

NativeAppTemplate/Networking/Services/ItemTagsService.swift

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,8 @@
33
// NativeAppTemplate
44
//
55

6-
import class Foundation.URLSession
7-
86
struct ItemTagsService: Service {
97
var networkClient = NativeAppTemplateAPI()
10-
let session = URLSession(configuration: .default)
118
}
129

1310
extension ItemTagsService {

NativeAppTemplate/Networking/Services/MeService.swift

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,8 @@
33
// NativeAppTemplate
44
//
55

6-
import class Foundation.URLSession
7-
86
struct MeService: Service {
97
var networkClient = NativeAppTemplateAPI()
10-
let session = URLSession(configuration: .default)
118
}
129

1310
// MARK: - Internal

NativeAppTemplate/Networking/Services/PermissionsService.swift

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,8 @@
33
// NativeAppTemplate
44
//
55

6-
import class Foundation.URLSession
7-
86
struct PermissionsService: Service {
97
var networkClient = NativeAppTemplateAPI()
10-
let session = URLSession(configuration: .default)
118
}
129

1310
extension PermissionsService {

0 commit comments

Comments
 (0)