Skip to content

Commit 2fd205a

Browse files
authored
Merge pull request #10652 from nextcloud/backport/10597/stable-34.0
[stable-34.0] fix(mac-crafter): Verify all components are signed with matching team identifiers
2 parents 9eb2328 + 8c6959b commit 2fd205a

9 files changed

Lines changed: 459 additions & 4 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
2+
# SPDX-License-Identifier: GPL-2.0-or-later
3+
4+
name: mac-crafter CI
5+
6+
on:
7+
push:
8+
branches: ["master"]
9+
pull_request:
10+
branches: ["master"]
11+
types: [opened, reopened, synchronize, ready_for_review]
12+
13+
jobs:
14+
tests:
15+
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
16+
name: Test mac-crafter
17+
timeout-minutes: 15
18+
runs-on: macos-26
19+
defaults:
20+
run:
21+
working-directory: admin/osx/mac-crafter
22+
steps:
23+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
24+
with:
25+
fetch-depth: 1
26+
27+
- name: Setup Xcode
28+
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0
29+
with:
30+
xcode-version: latest-stable
31+
32+
- name: Run tests
33+
run: swift test

admin/osx/mac-crafter/Package.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,9 @@ let package = Package(
2626
.enableUpcomingFeature("StrictConcurrency")
2727
]
2828
),
29+
.testTarget(
30+
name: "mac-crafter-tests",
31+
dependencies: ["mac-crafter"]
32+
),
2933
]
3034
)

admin/osx/mac-crafter/Sources/Commands/Build.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -434,7 +434,10 @@ struct Build: AsyncParsableCommand {
434434
try await Signer.signMainBundle(
435435
at: clientAppURL,
436436
codeSignIdentity: codeSignIdentity,
437-
entitlements: entitlements
437+
entitlements: entitlements,
438+
expectedTeamIdentifier: try CMakeConfiguration.developmentTeamIdentifier(
439+
at: repoRootURL.appendingPathComponent("NEXTCLOUD.cmake")
440+
)
438441
)
439442
}
440443

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
2+
// SPDX-License-Identifier: GPL-2.0-or-later
3+
4+
import Foundation
5+
6+
///
7+
/// Reads signing-related values from the client's CMake configuration.
8+
///
9+
enum CMakeConfiguration {
10+
static func developmentTeamIdentifier(at file: URL) throws -> String {
11+
let contents: String
12+
13+
do {
14+
contents = try String(contentsOf: file, encoding: .utf8)
15+
} catch {
16+
throw MacCrafterError.signing("Unable to read \(file.path): \(error.localizedDescription)")
17+
}
18+
19+
let pattern = #"(?m)^[ \t]*set[ \t]*\([ \t]*DEVELOPMENT_TEAM[ \t]+[\"]?([^\"\s\)]+)"#
20+
guard let expression = try? NSRegularExpression(pattern: pattern),
21+
let match = expression.firstMatch(
22+
in: contents,
23+
range: NSRange(contents.startIndex..., in: contents)
24+
),
25+
let teamIdentifierRange = Range(match.range(at: 1), in: contents)
26+
else {
27+
throw MacCrafterError.signing("Unable to find DEVELOPMENT_TEAM in \(file.path)")
28+
}
29+
30+
return String(contents[teamIdentifierRange])
31+
}
32+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
2+
// SPDX-License-Identifier: GPL-2.0-or-later
3+
4+
import Foundation
5+
6+
///
7+
/// Discovers signed code components and verifies their team identifiers.
8+
///
9+
enum CodeSignatureVerifier {
10+
static func verify(at location: URL, expectedTeamIdentifier: String? = nil) throws {
11+
try verify(
12+
at: location,
13+
expectedTeamIdentifier: expectedTeamIdentifier,
14+
isMachO: { isMachO($0) },
15+
signatureDetails: { try codesignDetails(at: $0) }
16+
)
17+
}
18+
19+
static func verify(
20+
at location: URL,
21+
expectedTeamIdentifier: String? = nil,
22+
isMachO: (URL) -> Bool,
23+
signatureDetails: (URL) throws -> String
24+
) throws {
25+
let components = try discoverCodeComponents(at: location, isMachO: isMachO)
26+
let signatures = try components.map { component in
27+
(
28+
location: component.path,
29+
teamIdentifier: TeamIdentifierVerifier.teamIdentifier(from: try signatureDetails(component))
30+
)
31+
}
32+
33+
if let error = TeamIdentifierVerifier.validationError(for: signatures) {
34+
throw MacCrafterError.signing(error)
35+
}
36+
37+
if let expectedTeamIdentifier,
38+
let error = TeamIdentifierVerifier.validationError(
39+
for: signatures,
40+
expectedTeamIdentifier: expectedTeamIdentifier
41+
)
42+
{
43+
throw MacCrafterError.signing(error)
44+
}
45+
46+
Log.info("Verified matching TeamIdentifier for \(signatures.count) code components")
47+
}
48+
49+
static func discoverCodeComponents(at url: URL, isMachO: (URL) -> Bool) throws -> [URL] {
50+
let codeBundleExtensions = ["app", "appex", "framework", "xpc"]
51+
let codeSearchDirectories = ["/Contents/MacOS/", "/Contents/Frameworks/", "/Contents/PlugIns/"]
52+
var components = [URL]()
53+
54+
guard let enumerator = FileManager.default.enumerator(
55+
at: url,
56+
includingPropertiesForKeys: [.isRegularFileKey]
57+
) else {
58+
throw MacCrafterError.environmentError("Failed to get enumerator for: \(url.path)")
59+
}
60+
61+
for case let candidate as URL in enumerator {
62+
let pathExtension = candidate.pathExtension.lowercased()
63+
64+
if codeBundleExtensions.contains(pathExtension) || pathExtension == "dylib" {
65+
components.append(candidate)
66+
continue
67+
}
68+
69+
guard codeSearchDirectories.contains(where: candidate.path.contains),
70+
try candidate.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile == true,
71+
isMachO(candidate)
72+
else {
73+
continue
74+
}
75+
76+
components.append(candidate)
77+
}
78+
79+
return [url] + components.sorted { $0.path < $1.path }
80+
}
81+
82+
private static func isMachO(_ file: URL) -> Bool {
83+
let task = Process()
84+
let outputPipe = Pipe()
85+
task.executableURL = URL(fileURLWithPath: "/usr/bin/file")
86+
task.arguments = ["-b", file.path]
87+
task.standardOutput = outputPipe
88+
task.standardError = Pipe()
89+
90+
do {
91+
try task.run()
92+
} catch {
93+
return false
94+
}
95+
96+
let output = String(data: outputPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
97+
task.waitUntilExit()
98+
return task.terminationStatus == 0 && output.contains("Mach-O")
99+
}
100+
101+
private static func codesignDetails(at location: URL) throws -> String {
102+
let task = Process()
103+
let standardOutput = Pipe()
104+
let standardError = Pipe()
105+
task.executableURL = URL(fileURLWithPath: "/usr/bin/codesign")
106+
task.arguments = ["--display", "--verbose=4", location.path]
107+
task.standardOutput = standardOutput
108+
task.standardError = standardError
109+
110+
do {
111+
try task.run()
112+
} catch {
113+
throw MacCrafterError.signing("Unable to inspect the code signature of \(location.path): \(error.localizedDescription)")
114+
}
115+
116+
let outputData = standardOutput.fileHandleForReading.readDataToEndOfFile()
117+
let errorData = standardError.fileHandleForReading.readDataToEndOfFile()
118+
task.waitUntilExit()
119+
120+
guard task.terminationStatus == 0 else {
121+
let errorOutput = String(data: errorData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "unknown codesign error"
122+
throw MacCrafterError.signing("Unable to inspect the code signature of \(location.path): \(errorOutput)")
123+
}
124+
125+
return [outputData, errorData]
126+
.compactMap { String(data: $0, encoding: .utf8) }
127+
.joined(separator: "\n")
128+
}
129+
}

admin/osx/mac-crafter/Sources/Utils/Signer.swift

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -293,13 +293,15 @@ enum Signer: Signing {
293293
}
294294
}
295295

296-
private static func verify(at location: URL) async throws {
296+
private static func verify(at location: URL, expectedTeamIdentifier: String?) async throws {
297297
Log.info("Verifying: \(location.path)")
298298
let code = await shell("codesign --verify --deep --strict --verbose=2 \"\(location.path)\"")
299299

300300
if code > 0 {
301301
throw MacCrafterError.signing("Signing verification failed because the codesign command terminated with code \(code)")
302302
}
303+
304+
try CodeSignatureVerifier.verify(at: location, expectedTeamIdentifier: expectedTeamIdentifier)
303305
}
304306

305307
// MARK: - Public
@@ -310,7 +312,8 @@ enum Signer: Signing {
310312
static func signMainBundle(
311313
at location: URL,
312314
codeSignIdentity: String,
313-
entitlements: [String: URL]
315+
entitlements: [String: URL],
316+
expectedTeamIdentifier: String? = nil
314317
) async throws {
315318
// Signing is inside-out: nested code first, the containing bundle last. Login items come
316319
// before the app for that reason, and the outer sign is deliberately not --deep.
@@ -390,7 +393,7 @@ enum Signer: Signing {
390393
}
391394

392395
await sign(at: location, with: codeSignIdentity, entitlements: mainAppEntitlements)
393-
try await verify(at: location)
396+
try await verify(at: location, expectedTeamIdentifier: expectedTeamIdentifier)
394397
}
395398

396399
///
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
2+
// SPDX-License-Identifier: GPL-2.0-or-later
3+
4+
import Foundation
5+
6+
///
7+
/// Team identifier verification helpers.
8+
///
9+
enum TeamIdentifierVerifier {
10+
static func teamIdentifier(from codesignOutput: String) -> String? {
11+
let prefix = "TeamIdentifier="
12+
13+
guard let line = codesignOutput.split(whereSeparator: \.isNewline).first(where: { $0.hasPrefix(prefix) }) else {
14+
return nil
15+
}
16+
17+
let teamIdentifier = line.dropFirst(prefix.count).trimmingCharacters(in: .whitespacesAndNewlines)
18+
return teamIdentifier == "not set" || teamIdentifier.isEmpty ? nil : teamIdentifier
19+
}
20+
21+
static func validationError(for components: [(location: String, teamIdentifier: String?)]) -> String? {
22+
guard let firstComponent = components.first else {
23+
return "Signing verification failed because no signed code components were found"
24+
}
25+
26+
guard let expectedTeamIdentifier = firstComponent.teamIdentifier else {
27+
return "Signing verification failed because \(firstComponent.location) has no TeamIdentifier"
28+
}
29+
30+
for component in components.dropFirst() {
31+
guard let teamIdentifier = component.teamIdentifier else {
32+
return "Signing verification failed because \(component.location) has no TeamIdentifier"
33+
}
34+
35+
guard teamIdentifier == expectedTeamIdentifier else {
36+
return "Signing verification failed because \(component.location) has TeamIdentifier \(teamIdentifier), expected \(expectedTeamIdentifier) from \(firstComponent.location)"
37+
}
38+
}
39+
40+
return nil
41+
}
42+
43+
static func validationError(
44+
for components: [(location: String, teamIdentifier: String?)],
45+
expectedTeamIdentifier: String
46+
) -> String? {
47+
guard let component = components.first else {
48+
return "Signing verification failed because no signed code components were found"
49+
}
50+
51+
guard let teamIdentifier = component.teamIdentifier else {
52+
return "Signing verification failed because \(component.location) has no TeamIdentifier"
53+
}
54+
55+
guard teamIdentifier == expectedTeamIdentifier else {
56+
return "Signing verification failed because \(component.location) has TeamIdentifier \(teamIdentifier), expected \(expectedTeamIdentifier) from NEXTCLOUD.cmake"
57+
}
58+
59+
return nil
60+
}
61+
}

0 commit comments

Comments
 (0)