Skip to content

Commit fbf2b24

Browse files
dadachiclaude
andcommitted
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) <noreply@anthropic.com>
1 parent 8b350d4 commit fbf2b24

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)