Skip to content

Commit a5d7c11

Browse files
authored
fix(security): harden iOS release and local state handling (#13)
1 parent 34180e3 commit a5d7c11

16 files changed

Lines changed: 273 additions & 115 deletions

File tree

.github/actions/load-ios-release-secrets/action.yml

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -60,20 +60,35 @@ runs:
6060
6161
match_git_host=""
6262
case "${MATCH_GIT_URL:-}" in
63-
git@*:*)
64-
match_git_host="${MATCH_GIT_URL#git@}"
65-
match_git_host="${match_git_host%%:*}"
63+
git@github.com:*)
64+
match_git_host="github.com"
6665
;;
67-
ssh://*)
68-
match_git_host="${MATCH_GIT_URL#ssh://}"
69-
match_git_host="${match_git_host%%/*}"
66+
ssh://git@github.com/* | ssh://github.com/*)
67+
match_git_host="github.com"
7068
;;
7169
esac
7270
73-
if [ -n "$match_git_host" ]; then
74-
mkdir -p "$HOME/.ssh"
75-
chmod 700 "$HOME/.ssh"
76-
touch "$HOME/.ssh/known_hosts"
77-
ssh-keyscan -H "$match_git_host" >> "$HOME/.ssh/known_hosts" 2>/dev/null || true
78-
chmod 600 "$HOME/.ssh/known_hosts"
71+
if [ -z "$match_git_host" ]; then
72+
echo "MATCH_GIT_URL must use a github.com SSH URL when MATCH_GIT_PRIVATE_KEY is used" >&2
73+
exit 1
7974
fi
75+
76+
host_key_path="$RUNNER_TEMP/github-ed25519-known-hosts"
77+
ssh-keyscan -T 10 -t ed25519 "$match_git_host" > "$host_key_path" 2>/dev/null
78+
if [ ! -s "$host_key_path" ]; then
79+
echo "No SSH host key was returned for $match_git_host" >&2
80+
exit 1
81+
fi
82+
83+
fingerprints="$(ssh-keygen -lf "$host_key_path" -E sha256 | awk '{print $2}' | sort -u)"
84+
fingerprint_count="$(printf '%s\n' "$fingerprints" | wc -l | tr -d ' ')"
85+
86+
if [ "$fingerprint_count" != "1" ] || [ "$fingerprints" != "SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU" ]; then
87+
echo "Unexpected SSH host key for $match_git_host: $fingerprints" >&2
88+
exit 1
89+
fi
90+
91+
mkdir -p "$HOME/.ssh"
92+
chmod 700 "$HOME/.ssh"
93+
cat "$host_key_path" >> "$HOME/.ssh/known_hosts"
94+
chmod 600 "$HOME/.ssh/known_hosts"

.github/workflows/ci.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,21 +28,21 @@ jobs:
2828

2929
steps:
3030
- name: Check out repository
31-
uses: actions/checkout@v6
31+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
3232

3333
- name: Select latest stable Xcode
34-
uses: maxim-lobanov/setup-xcode@v1
34+
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
3535
with:
3636
xcode-version: latest-stable
3737

3838
- name: Set up Ruby
39-
uses: ruby/setup-ruby@v1
39+
uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1
4040
with:
4141
ruby-version: .ruby-version
4242
bundler-cache: true
4343

4444
- name: Restore CocoaPods cache
45-
uses: actions/cache@v5
45+
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
4646
with:
4747
path: ~/Library/Caches/CocoaPods
4848
key: ${{ runner.os }}-cocoapods-downloads-${{ github.workflow }}-${{ hashFiles('Podfile.lock') }}

.github/workflows/e2e-simulator.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,23 +23,23 @@ jobs:
2323

2424
steps:
2525
- name: Check out repository
26-
uses: actions/checkout@v6
26+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
2727
with:
2828
ref: ${{ inputs.ref || github.ref }}
2929

3030
- name: Select latest stable Xcode
31-
uses: maxim-lobanov/setup-xcode@v1
31+
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
3232
with:
3333
xcode-version: latest-stable
3434

3535
- name: Set up Ruby
36-
uses: ruby/setup-ruby@v1
36+
uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1
3737
with:
3838
ruby-version: .ruby-version
3939
bundler-cache: true
4040

4141
- name: Restore CocoaPods cache
42-
uses: actions/cache@v5
42+
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
4343
with:
4444
path: ~/Library/Caches/CocoaPods
4545
key: ${{ runner.os }}-cocoapods-downloads-${{ github.workflow }}-${{ hashFiles('Podfile.lock') }}

Podfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ target 'Putio' do
66
pod 'google-cast-sdk-no-bluetooth-xcframework', '4.8.0'
77
pod 'Intercom', '19.5.7'
88
pod 'KeychainAccess'
9-
pod 'PutioSDK', :git => 'https://github.com/putdotio/putio-sdk-swift.git', :tag => '3.1.0'
9+
pod 'PutioSDK', :git => 'https://github.com/putdotio/putio-sdk-swift.git', :commit => '8192763563951797672c8101d6765dac3ec7e2df' # 3.1.0
1010
pod 'RealmSwift', '20.0.4'
1111
pod 'Sentry', '9.10.0'
1212
pod 'StatefulViewController', '~> 3.0'

Podfile.lock

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ DEPENDENCIES:
3030
- Intercom (= 19.5.7)
3131
- KeyboardAvoidingView (~> 5.2)
3232
- KeychainAccess
33-
- PutioSDK (from `https://github.com/putdotio/putio-sdk-swift.git`, tag `3.1.0`)
33+
- PutioSDK (from `https://github.com/putdotio/putio-sdk-swift.git`, commit `8192763563951797672c8101d6765dac3ec7e2df`)
3434
- RealmSwift (= 20.0.4)
3535
- Sentry (= 9.10.0)
3636
- StatefulViewController (~> 3.0)
@@ -57,13 +57,13 @@ SPEC REPOS:
5757

5858
EXTERNAL SOURCES:
5959
PutioSDK:
60+
:commit: 8192763563951797672c8101d6765dac3ec7e2df
6061
:git: https://github.com/putdotio/putio-sdk-swift.git
61-
:tag: 3.1.0
6262

6363
CHECKOUT OPTIONS:
6464
PutioSDK:
65+
:commit: 8192763563951797672c8101d6765dac3ec7e2df
6566
:git: https://github.com/putdotio/putio-sdk-swift.git
66-
:tag: 3.1.0
6767

6868
SPEC CHECKSUMS:
6969
google-cast-sdk-no-bluetooth-xcframework: 1783f52a1b2dc0293ac3ae995db5435989a87da7
@@ -82,6 +82,6 @@ SPEC CHECKSUMS:
8282
ViewState: 82f26f9b716f6673c19e491f8bf234c4ff519a0a
8383
VTAcknowledgementsViewController: 2cae5539fb84081196ab00d267caad390f418ce6
8484

85-
PODFILE CHECKSUM: 25e3bf2ed172db3661af3bd121d5675a4210b8ae
85+
PODFILE CHECKSUM: 903fdb6e713fb10751161ca56c5898d12a8e8180
8686

8787
COCOAPODS: 1.16.2

Putio/AppDelegate.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
278278
func logout() {
279279
PutioKeychain.sharedInstance.clearToken()
280280
api.clearToken()
281+
VideoPlaybackPositionStore.shared.clearAllPositions()
281282
Intercom.logout()
282283
presentLoginScreen()
283284
}

Putio/Common/Helpers/NetworkReachability.swift

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,13 @@ class NetworkReachability {
1414
monitor.pathUpdateHandler = { [weak self] path in
1515
guard let self = self else { return }
1616

17-
self.isReachable = path.status == .satisfied
18-
log.info("Network status changed: \(path.status)", context: nil)
19-
NotificationCenter.default.post(name: NetworkReachability.NOTIFICATION, object: nil)
17+
let isReachable = path.status == .satisfied
18+
let status = path.status
19+
DispatchQueue.main.async {
20+
self.isReachable = isReachable
21+
log.info("Network status changed: \(status)", context: nil)
22+
NotificationCenter.default.post(name: NetworkReachability.NOTIFICATION, object: nil)
23+
}
2024
}
2125

2226
monitor.start(queue: queue)

Putio/Common/Helpers/Realm.swift

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ class PutioRealm {
106106
private static func migrate(_ migration: Migration, _ oldSchemaVersion: UInt64) {
107107
if oldSchemaVersion < 1 {
108108
migration.enumerateObjects(ofType: Download.className()) { _, newDownload in
109-
newDownload!["fileType"] = 1
109+
newDownload!["fileTypeRaw"] = Download.FileType.audio.rawValue
110110
}
111111
}
112112

@@ -134,7 +134,7 @@ class PutioRealm {
134134
newAppUserSettings!["showOptimisticUsage"] = false
135135
}
136136

137-
migration.enumerateObjects(ofType: User.className()) { newAppUser, _ in
137+
migration.enumerateObjects(ofType: User.className()) { _, newAppUser in
138138
migration.enumerateObjects(ofType: UserDisk.className()) { _, newAppUserDisk in
139139
newAppUserDisk!["used"] = 0
140140
newAppUser!["disk"] = newAppUserDisk
@@ -161,10 +161,39 @@ class PutioRealm {
161161
}
162162
}
163163

164-
// Schema 12: Download columns renamed from state/fileType to
165-
// stateRaw/fileTypeRaw. No migration needed — old format v9
166-
// databases are deleted before this runs, and fresh databases
167-
// get the new column names automatically.
164+
if oldSchemaVersion < 12 {
165+
migrateDownloadRawColumns(migration)
166+
}
167+
}
168+
169+
private static func migrateDownloadRawColumns(_ migration: Migration) {
170+
let hasLegacyState = migration.oldSchema[Download.className()]?["state"] != nil
171+
let hasLegacyFileType = migration.oldSchema[Download.className()]?["fileType"] != nil
172+
173+
migration.enumerateObjects(ofType: Download.className()) { oldDownload, newDownload in
174+
let rawValues = legacyDownloadRawValues(
175+
state: hasLegacyState ? oldDownload?["state"] : nil,
176+
fileType: hasLegacyFileType ? oldDownload?["fileType"] : nil
177+
)
178+
179+
if let stateRaw = rawValues.stateRaw {
180+
newDownload!["stateRaw"] = stateRaw
181+
}
182+
183+
if let fileTypeRaw = rawValues.fileTypeRaw {
184+
newDownload!["fileTypeRaw"] = fileTypeRaw
185+
}
186+
}
187+
}
188+
189+
static func legacyDownloadRawValues(state: Any?, fileType: Any?) -> (stateRaw: Int?, fileTypeRaw: Int?) {
190+
return (legacyIntValue(state), legacyIntValue(fileType))
191+
}
192+
193+
private static func legacyIntValue(_ value: Any?) -> Int? {
194+
if let value = value as? Int { return value }
195+
if let value = value as? NSNumber { return value.intValue }
196+
return nil
168197
}
169198

170199
private static func updateUserSettings(_ migration: Migration, updates: @escaping (MigrationObject?) -> Void) {
@@ -212,16 +241,15 @@ class PutioRealm {
212241
// Fallback: scan Documents directory for audio files whose UserDefaults entries were lost
213242
if let contents = try? FileManager.default.contentsOfDirectory(atPath: documentsURL.path) {
214243
for filename in contents where filename.hasPrefix("putio_adm_") {
215-
// Extract file ID from "putio_adm_{id}.ext" or "putio_adm_{id}"
216-
let stripped = filename
217-
.replacingOccurrences(of: "putio_adm_", with: "")
218-
.components(separatedBy: ".").first ?? ""
219-
guard let fileId = Int(stripped), fileId > 0, !recoveredFileIds.contains(fileId) else { continue }
244+
guard let fileId = recoveredAudioFileId(from: filename),
245+
fileId > 0,
246+
!recoveredFileIds.contains(fileId) else { continue }
220247

221248
let fileURL = documentsURL.appendingPathComponent(filename)
222249
guard FileManager.default.fileExists(atPath: fileURL.path) else { continue }
223250

224251
addRecoveredDownload(buildRecoveredDownload(fileId: fileId, isVideo: false), to: realm)
252+
defaults.set(filename, forKey: String(fileId))
225253
recovered += 1
226254
recoveredFileIds.insert(fileId)
227255
}
@@ -243,6 +271,16 @@ class PutioRealm {
243271
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
244272
}
245273

274+
static func recoveredAudioFileId(from filename: String) -> Int? {
275+
let prefix = "putio_adm_"
276+
guard filename.hasPrefix(prefix) else { return nil }
277+
278+
let filenameWithoutExtension = (filename as NSString).deletingPathExtension
279+
let idAndSlug = String(filenameWithoutExtension.dropFirst(prefix.count))
280+
let id = idAndSlug.split(separator: "_", maxSplits: 1).first.map(String.init) ?? idAndSlug
281+
return Int(id)
282+
}
283+
246284
private static func buildRecoveredDownload(fileId: Int, defaults: UserDefaults, documentsURL: URL) -> Download? {
247285
if hasValidVideoBookmark(fileId: fileId, defaults: defaults) {
248286
return buildRecoveredDownload(fileId: fileId, isVideo: true)

Putio/Features/Auth/LoginViewController.swift

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import AuthenticationServices
44

55
class LoginViewController: UIViewController, UITextFieldDelegate {
66
var session: ASWebAuthenticationSession?
7+
var currentOAuthState: String?
78

89
@IBOutlet weak var loginButton: UIButton!
910

@@ -35,7 +36,9 @@ class LoginViewController: UIViewController, UITextFieldDelegate {
3536
api.clearToken()
3637

3738
let scheme = "putio"
38-
let url = api.getAuthURL(redirectURI: "\(scheme)://auth")
39+
let state = UUID().uuidString
40+
currentOAuthState = state
41+
let url = api.getAuthURL(redirectURI: "\(scheme)://auth", state: state)
3942

4043
session = ASWebAuthenticationSession(url: url, callbackURLScheme: scheme) { callbackURL, error in
4144
self.handleWebAuthResult(callbackURL: callbackURL, error: error)
@@ -63,6 +66,8 @@ class LoginViewController: UIViewController, UITextFieldDelegate {
6366
}
6467

6568
func handleWebAuthCallbackFailure(error: Error) {
69+
currentOAuthState = nil
70+
6671
let alertController = UIAlertController(
6772
title: NSLocalizedString("Authentication failed", comment: ""),
6873
message: error.localizedDescription,
@@ -75,6 +80,19 @@ class LoginViewController: UIViewController, UITextFieldDelegate {
7580

7681
// Callback URL: putio://auth#access_token={TOKEN}
7782
func handleWebAuthCallbackSuccess(callbackURL: URL) {
83+
defer { currentOAuthState = nil }
84+
85+
guard callbackURL.scheme == "putio",
86+
callbackURL.host == "auth",
87+
callbackURL.path.isEmpty || callbackURL.path == "/" else {
88+
let error = NSError(
89+
domain: "",
90+
code: 0,
91+
userInfo: [NSLocalizedDescriptionKey: NSLocalizedString("Unexpected authentication callback URL.", comment: "")]
92+
)
93+
return handleWebAuthCallbackFailure(error: error)
94+
}
95+
7896
var urlComponents = URLComponents()
7997
urlComponents.query = callbackURL.fragment
8098

@@ -87,6 +105,16 @@ class LoginViewController: UIViewController, UITextFieldDelegate {
87105
return handleWebAuthCallbackFailure(error: error)
88106
}
89107

108+
guard let returnedState = urlComponents.queryItems?.first(where: { $0.name == "state" })?.value,
109+
returnedState == currentOAuthState else {
110+
let error = NSError(
111+
domain: "",
112+
code: 0,
113+
userInfo: [NSLocalizedDescriptionKey: NSLocalizedString("Authentication state did not match the current session.", comment: "")]
114+
)
115+
return handleWebAuthCallbackFailure(error: error)
116+
}
117+
90118
authenticate(token: token)
91119
}
92120
}

Putio/Features/Files/FilePresenter.swift

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,6 @@ extension FilePresenter where Self: UIViewController {
4646
filesVC.viewModel.file = file
4747
filesVC.navigationItem.title = file.name
4848

49-
// Share the same bar button items so UIKit doesn't cross-fade during push
50-
if let selfVC = self as? FilesViewController {
51-
filesVC.fileActionsButton = selfVC.fileActionsButton
52-
filesVC.chromecastButton = selfVC.chromecastButton
53-
filesVC.navigationItem.rightBarButtonItems = navigationItem.rightBarButtonItems
54-
}
55-
5649
navigationController?.pushViewController(filesVC, animated: true)
5750
}
5851

0 commit comments

Comments
 (0)