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 NativeAppTemplate.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -221,6 +222,7 @@
0172030A25A9642E008FD63B /* JSONAPIRelationship.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JSONAPIRelationship.swift; sourceTree = "<group>"; };
0172030B25A9642E008FD63B /* JSONAPIDocument.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JSONAPIDocument.swift; sourceTree = "<group>"; };
0172030C25A9642E008FD63B /* JSONAPIErrorSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JSONAPIErrorSource.swift; sourceTree = "<group>"; };
C597C0551370446BB931F19B /* CertificatePinningDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertificatePinningDelegate.swift; sourceTree = "<group>"; };
0172030E25A9642E008FD63B /* NativeAppTemplateAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NativeAppTemplateAPI.swift; sourceTree = "<group>"; };
0172030F25A9642E008FD63B /* NativeAppTemplateEnvironment.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NativeAppTemplateEnvironment.swift; sourceTree = "<group>"; };
0172031125A9642E008FD63B /* Parameters.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Parameters.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -529,6 +531,7 @@
0172030D25A9642E008FD63B /* Network */ = {
isa = PBXGroup;
children = (
C597C0551370446BB931F19B /* CertificatePinningDelegate.swift */,
0172030E25A9642E008FD63B /* NativeAppTemplateAPI.swift */,
0172030F25A9642E008FD63B /* NativeAppTemplateEnvironment.swift */,
);
Expand Down Expand Up @@ -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 */,
Expand Down
4 changes: 4 additions & 0 deletions NativeAppTemplate/Logging/Logger.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: Source.Type,
action: String,
Expand Down
2 changes: 1 addition & 1 deletion NativeAppTemplate/Login/SessionsService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import SwiftyJSON

struct SessionsService {
var networkClient: NativeAppTemplateAPI
var session = URLSession(configuration: .default)
var session: URLSession = .pinned
}

extension SessionsService {
Expand Down
2 changes: 1 addition & 1 deletion NativeAppTemplate/Login/SignUpService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import SwiftyJSON

struct SignUpsService {
var networkClient = NativeAppTemplateAPI()
var session = URLSession(configuration: .default)
var session: URLSession = .pinned
}

extension SignUpsService {
Expand Down
114 changes: 114 additions & 0 deletions NativeAppTemplate/Networking/Network/CertificatePinningDelegate.swift
Original file line number Diff line number Diff line change
@@ -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<String> = [
// 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..<certificateCount {
guard let certificate = SecTrustCopyCertificateChain(serverTrust)
.map({ unsafeBitCast(CFArrayGetValueAtIndex($0, index), to: SecCertificate.self) })
else { continue }

guard let publicKey = SecCertificateCopyKey(certificate) else { continue }

var error: Unmanaged<CFError>?
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
)
}()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,8 @@
// NativeAppTemplate
//

import class Foundation.URLSession

struct AccountPasswordService: Service {
var networkClient = NativeAppTemplateAPI()
let session = URLSession(configuration: .default)
}

extension AccountPasswordService {
Expand Down
3 changes: 0 additions & 3 deletions NativeAppTemplate/Networking/Services/ItemTagsService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,8 @@
// NativeAppTemplate
//

import class Foundation.URLSession

struct ItemTagsService: Service {
var networkClient = NativeAppTemplateAPI()
let session = URLSession(configuration: .default)
}

extension ItemTagsService {
Expand Down
3 changes: 0 additions & 3 deletions NativeAppTemplate/Networking/Services/MeService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,8 @@
// NativeAppTemplate
//

import class Foundation.URLSession

struct MeService: Service {
var networkClient = NativeAppTemplateAPI()
let session = URLSession(configuration: .default)
}

// MARK: - Internal
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,8 @@
// NativeAppTemplate
//

import class Foundation.URLSession

struct PermissionsService: Service {
var networkClient = NativeAppTemplateAPI()
let session = URLSession(configuration: .default)
}

extension PermissionsService {
Expand Down
4 changes: 4 additions & 0 deletions NativeAppTemplate/Networking/Services/Service.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ protocol Service {
var session: URLSession { get }
}

extension Service {
var session: URLSession { .pinned }
}

extension Service {
var isAuthenticated: Bool {
!networkClient.authToken.isEmpty
Expand Down
3 changes: 0 additions & 3 deletions NativeAppTemplate/Networking/Services/ShopsService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,8 @@
// NativeAppTemplate
//

import class Foundation.URLSession

struct ShopsService: Service {
var networkClient = NativeAppTemplateAPI()
let session = URLSession(configuration: .default)
}

// MARK: - Internal
Expand Down
Loading