|
| 1 | +import Foundation |
| 2 | + |
| 3 | +public enum ValidationError: Error { |
| 4 | + case fileNotFound |
| 5 | + case unableToCreateStaticCode |
| 6 | + case invalidSignature |
| 7 | + case unableToRetrieveInfo |
| 8 | + case invalidIdentifier(identifier: String?) |
| 9 | + case invalidTeamIdentifier(identifier: String?) |
| 10 | + case missingInfoPList |
| 11 | + case invalidVersion(version: String?) |
| 12 | + case belowMinimumCoderVersion |
| 13 | + |
| 14 | + public var description: String { |
| 15 | + switch self { |
| 16 | + case .fileNotFound: |
| 17 | + "The file does not exist." |
| 18 | + case .unableToCreateStaticCode: |
| 19 | + "Unable to create a static code object." |
| 20 | + case .invalidSignature: |
| 21 | + "The file's signature is invalid." |
| 22 | + case .unableToRetrieveInfo: |
| 23 | + "Unable to retrieve signing information." |
| 24 | + case let .invalidIdentifier(identifier): |
| 25 | + "Invalid identifier: \(identifier ?? "unknown")." |
| 26 | + case let .invalidVersion(version): |
| 27 | + "Invalid runtime version: \(version ?? "unknown")." |
| 28 | + case let .invalidTeamIdentifier(identifier): |
| 29 | + "Invalid team identifier: \(identifier ?? "unknown")." |
| 30 | + case .missingInfoPList: |
| 31 | + "Info.plist is not embedded within the dylib." |
| 32 | + case .belowMinimumCoderVersion: |
| 33 | + """ |
| 34 | + The Coder deployment must be version \(SignatureValidator.minimumCoderVersion) |
| 35 | + or higher to use Coder Desktop. |
| 36 | + """ |
| 37 | + } |
| 38 | + } |
| 39 | + |
| 40 | + public var localizedDescription: String { description } |
| 41 | +} |
| 42 | + |
| 43 | +public class SignatureValidator { |
| 44 | + // Whilst older dylibs exist, this app assumes v2.20 or later. |
| 45 | + public static let minimumCoderVersion = "2.20.0" |
| 46 | + |
| 47 | + private static let expectedName = "CoderVPN" |
| 48 | + private static let expectedIdentifier = "com.coder.Coder-Desktop.VPN.dylib" |
| 49 | + private static let expectedTeamIdentifier = "4399GN35BJ" |
| 50 | + |
| 51 | + private static let infoIdentifierKey = "CFBundleIdentifier" |
| 52 | + private static let infoNameKey = "CFBundleName" |
| 53 | + private static let infoShortVersionKey = "CFBundleShortVersionString" |
| 54 | + |
| 55 | + private static let signInfoFlags: SecCSFlags = .init(rawValue: kSecCSSigningInformation) |
| 56 | + |
| 57 | + // `expectedVersion` must be of the form `[0-9]+.[0-9]+.[0-9]+` |
| 58 | + public static func validate(path: URL, expectedVersion: String) throws(ValidationError) { |
| 59 | + guard FileManager.default.fileExists(atPath: path.path) else { |
| 60 | + throw .fileNotFound |
| 61 | + } |
| 62 | + |
| 63 | + var staticCode: SecStaticCode? |
| 64 | + let status = SecStaticCodeCreateWithPath(path as CFURL, SecCSFlags(), &staticCode) |
| 65 | + guard status == errSecSuccess, let code = staticCode else { |
| 66 | + throw .unableToCreateStaticCode |
| 67 | + } |
| 68 | + |
| 69 | + let validateStatus = SecStaticCodeCheckValidity(code, SecCSFlags(), nil) |
| 70 | + guard validateStatus == errSecSuccess else { |
| 71 | + throw .invalidSignature |
| 72 | + } |
| 73 | + |
| 74 | + var information: CFDictionary? |
| 75 | + let infoStatus = SecCodeCopySigningInformation(code, signInfoFlags, &information) |
| 76 | + guard infoStatus == errSecSuccess, let info = information as? [String: Any] else { |
| 77 | + throw .unableToRetrieveInfo |
| 78 | + } |
| 79 | + |
| 80 | + guard let identifier = info[kSecCodeInfoIdentifier as String] as? String, |
| 81 | + identifier == expectedIdentifier |
| 82 | + else { |
| 83 | + throw .invalidIdentifier(identifier: info[kSecCodeInfoIdentifier as String] as? String) |
| 84 | + } |
| 85 | + |
| 86 | + guard let teamIdentifier = info[kSecCodeInfoTeamIdentifier as String] as? String, |
| 87 | + teamIdentifier == expectedTeamIdentifier |
| 88 | + else { |
| 89 | + throw .invalidTeamIdentifier( |
| 90 | + identifier: info[kSecCodeInfoTeamIdentifier as String] as? String |
| 91 | + ) |
| 92 | + } |
| 93 | + |
| 94 | + guard let infoPlist = info[kSecCodeInfoPList as String] as? [String: AnyObject] else { |
| 95 | + throw .missingInfoPList |
| 96 | + } |
| 97 | + |
| 98 | + try validateInfo(infoPlist: infoPlist, expectedVersion: expectedVersion) |
| 99 | + } |
| 100 | + |
| 101 | + public static let peerRequirement = "anchor apple generic" + // Apple-issued certificate chain |
| 102 | + " and certificate leaf[subject.OU] = \"" + expectedTeamIdentifier + "\"" // Signed by the Coder team |
| 103 | + |
| 104 | + private static func validateInfo(infoPlist: [String: AnyObject], expectedVersion: String) throws(ValidationError) { |
| 105 | + guard let plistIdent = infoPlist[infoIdentifierKey] as? String, plistIdent == expectedIdentifier else { |
| 106 | + throw .invalidIdentifier(identifier: infoPlist[infoIdentifierKey] as? String) |
| 107 | + } |
| 108 | + |
| 109 | + guard let plistName = infoPlist[infoNameKey] as? String, plistName == expectedName else { |
| 110 | + throw .invalidIdentifier(identifier: infoPlist[infoNameKey] as? String) |
| 111 | + } |
| 112 | + |
| 113 | + // Downloaded dylib must match the version of the server |
| 114 | + guard let dylibVersion = infoPlist[infoShortVersionKey] as? String, |
| 115 | + expectedVersion == dylibVersion |
| 116 | + else { |
| 117 | + throw .invalidVersion(version: infoPlist[infoShortVersionKey] as? String) |
| 118 | + } |
| 119 | + |
| 120 | + // Downloaded dylib must be at least the minimum Coder server version |
| 121 | + guard let dylibVersion = infoPlist[infoShortVersionKey] as? String, |
| 122 | + // x.compare(y) is .orderedDescending if x > y |
| 123 | + minimumCoderVersion.compare(dylibVersion, options: .numeric) != .orderedDescending |
| 124 | + else { |
| 125 | + throw .belowMinimumCoderVersion |
| 126 | + } |
| 127 | + } |
| 128 | +} |
0 commit comments