Skip to content
Open
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
3 changes: 3 additions & 0 deletions Feather/Backend/Observable/OptionsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ struct Options: Codable, Equatable {
var changeLanguageFilesForCustomDisplayName: Bool
/// If tweaks should be injected into all app extensions (PlugIns and Extensions)
var injectIntoExtensions: Bool
/// If app should use unique keychain groups derived from its bundle identifier
var keychainIsolation: Bool

// MARK: Experiments

Expand Down Expand Up @@ -146,6 +148,7 @@ struct Options: Codable, Equatable {
removeProvisioning: false,
changeLanguageFilesForCustomDisplayName: false,
injectIntoExtensions: false,
keychainIsolation: false,

// MARK: Experiments

Expand Down
69 changes: 68 additions & 1 deletion Feather/Utilities/Handlers/SigningHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,9 @@ final class SigningHandler: NSObject {

// iOS "26" (19) needs special treatment
try await _locateMachosAndFixupArm64eSlice(for: movedAppPath)


try await _modifyEntitlementsForKeychainIsolation(for: movedAppPath)

let handler = ZsignHandler(appUrl: movedAppPath, options: _options, cert: appCertificate)
try await handler.disinject()

Expand Down Expand Up @@ -179,6 +181,71 @@ final class SigningHandler: NSObject {
}

extension SigningHandler {
/// Replaces wildcard keychain-access-groups in entitlements with the app's
/// bundle identifier so each signed app gets its own keychain namespace.
private func _modifyEntitlementsForKeychainIsolation(for app: URL) async throws {
guard _options.keychainIsolation, let cert = appCertificate else {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Options should be checked before the function is ran.

Also, certificate isn't required, provided entitlements seems to be a used as well.

return
}

// Determine the bundle identifier the signed app will use
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is AI generated, obviously, these comments aren't necessary.

Also instead of getting the identifier from Info.plist use the app object.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok I will do the change and tone down comments

Copy link
Copy Markdown
Author

@devnoname120 devnoname120 Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should I do _options.appIdentifier ?? app.identifier instead?

Why does _modifyPluginIdentifiers() use let oldIdentifier = infoDictionary["CFBundleIdentifier"] as? String instead of app.identifier?

let bundleId: String
if let customId = _options.appIdentifier {
bundleId = customId
} else {
guard
let infoPlist = NSDictionary(contentsOf: app.appendingPathComponent("Info.plist")),
let id = infoPlist["CFBundleIdentifier"] as? String
else {
return
}
bundleId = id
}

guard !bundleId.isEmpty else { return }

// Read entitlements: prefer user-supplied file, fall back to provisioning profile
let entitlementsDict: NSMutableDictionary

if let customFile = _options.appEntitlementsFile {
guard let dict = NSMutableDictionary(contentsOf: customFile) else { return }
entitlementsDict = dict
} else {
guard
let decoded = Storage.shared.getProvisionFileDecoded(for: cert),
let entitlements = decoded.Entitlements
else {
return
}
entitlementsDict = NSMutableDictionary(dictionary: entitlements.mapValues { $0.value })
}

// Replace wildcards in keychain-access-groups (e.g. "TEAMID.*" → "TEAMID.com.example.app")
// and filter out entries without a valid team ID prefix (e.g. com.apple.token)
guard var groups = entitlementsDict["keychain-access-groups"] as? [String] else { return }

let teamIdPattern = try NSRegularExpression(pattern: "^[A-Z0-9]{10}\\.")
groups = groups.compactMap { group in
let replaced = group.replacingOccurrences(of: "*", with: bundleId)
let range = NSRange(replaced.startIndex..., in: replaced)
guard teamIdPattern.firstMatch(in: replaced, range: range) != nil else { return nil }
return replaced
}

entitlementsDict["keychain-access-groups"] = groups

// Write modified entitlements to a temp file for zsign to use
let entitlementsURL = _uniqueWorkDir.appendingPathComponent("entitlements.plist")
let plistData = try PropertyListSerialization.data(
fromPropertyList: entitlementsDict,
format: .xml,
options: 0
)
try plistData.write(to: entitlementsURL)

_options.appEntitlementsFile = entitlementsURL
}

private func _modifyDict(using infoDictionary: NSMutableDictionary, with options: Options, to app: URL) async throws {
if options.fileSharing { infoDictionary.setObject(true, forKey: "UISupportsDocumentBrowser" as NSCopying) }
if options.itunesFileSharing { infoDictionary.setObject(true, forKey: "UIFileSharingEnabled" as NSCopying) }
Expand Down
11 changes: 11 additions & 0 deletions Feather/Views/Signing/Shared/SigningOptionsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,17 @@ struct SigningOptionsView: View {
Text(.localized("Enabling any protection will append a random string to the bundleidentifiers of the apps you sign, this is to ensure your Apple ID does not get flagged by Apple. However, when using a signing service you can ignore this."))
}
}

NBSection(.localized("Security")) {
_toggle(
.localized("Keychain Isolation"),
systemImage: "key",
isOn: $options.keychainIsolation,
temporaryValue: temporaryOptions?.keychainIsolation
)
} footer: {
Text(.localized("Replaces wildcard keychain groups with the app's bundle identifier, preventing sideloaded apps from accessing each other's keychain entries."))
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're missing translation keys.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems that it’s not the only missing one. I just copy-pasted the approach in

Text(.localized("Enabling any protection will append a random string to the bundleidentifiers of the apps you sign, this is to ensure your Apple ID does not get flagged by Apple. However, when using a signing service you can ignore this."))
and I thought that it was all that was needed considering that this string was not referenced anywhere else.

Once I’m back to my computer I will add this string and other missing strings (maybe in a follow-up PR to keep this PR tidy)

}

NBSection(.localized("General")) {
Self.picker(
Expand Down