RuntimeVerifier.swift
sha256:c88bf098b52c796e46edc39ec71f9f0627d305439b73972d3fada73cefde0da9
fix: put release binaries before lipo architecture checks
Human
minor
⚠ breaking
26 days ago
| 1 | import CryptoKit |
| 2 | import Foundation |
| 3 | import Security |
| 4 | |
| 5 | public struct RuntimeManifest: Codable, Equatable { |
| 6 | public struct Entry: Codable, Equatable { |
| 7 | public let path: String |
| 8 | public let sha256: String |
| 9 | public let bytes: UInt64 |
| 10 | public let executable: Bool |
| 11 | |
| 12 | public init(path: String, sha256: String, bytes: UInt64, executable: Bool) { |
| 13 | self.path = path |
| 14 | self.sha256 = sha256 |
| 15 | self.bytes = bytes |
| 16 | self.executable = executable |
| 17 | } |
| 18 | } |
| 19 | |
| 20 | public let schema: Int |
| 21 | public let releaseVersion: String |
| 22 | public let releaseBuild: Int |
| 23 | public let architecture: String |
| 24 | public let files: [Entry] |
| 25 | |
| 26 | public init(schema: Int, releaseVersion: String, releaseBuild: Int, architecture: String, files: [Entry]) { |
| 27 | self.schema = schema |
| 28 | self.releaseVersion = releaseVersion |
| 29 | self.releaseBuild = releaseBuild |
| 30 | self.architecture = architecture |
| 31 | self.files = files |
| 32 | } |
| 33 | } |
| 34 | |
| 35 | public enum RuntimeVerificationError: Error, Equatable { |
| 36 | case invalidManifest |
| 37 | case invalidPath(String) |
| 38 | case duplicateEntry(String) |
| 39 | case missingFile(String) |
| 40 | case extraFile(String) |
| 41 | case symbolicLink(String) |
| 42 | case sizeMismatch(String) |
| 43 | case digestMismatch(String) |
| 44 | case executableMismatch(String) |
| 45 | case invalidCodeSignature(String) |
| 46 | } |
| 47 | |
| 48 | public enum RuntimeVerifier { |
| 49 | public static func loadManifest(at url: URL) throws -> RuntimeManifest { |
| 50 | let data = try Data(contentsOf: url, options: [.mappedIfSafe]) |
| 51 | let manifest = try JSONDecoder().decode(RuntimeManifest.self, from: data) |
| 52 | guard manifest.schema == 1, |
| 53 | manifest.releaseVersion == KnowtationReleaseContract.version, |
| 54 | manifest.releaseBuild == KnowtationReleaseContract.build, |
| 55 | manifest.architecture == KnowtationReleaseContract.architecture, |
| 56 | !manifest.files.isEmpty else { |
| 57 | throw RuntimeVerificationError.invalidManifest |
| 58 | } |
| 59 | return manifest |
| 60 | } |
| 61 | |
| 62 | public static func verifyTree(root: URL, manifest: RuntimeManifest) throws { |
| 63 | let verifiedRoot = root.resolvingSymlinksInPath().standardizedFileURL |
| 64 | var expected = Set<String>() |
| 65 | for entry in manifest.files { |
| 66 | guard isSafeRelativePath(entry.path), |
| 67 | entry.sha256.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil else { |
| 68 | throw RuntimeVerificationError.invalidPath(entry.path) |
| 69 | } |
| 70 | guard expected.insert(entry.path).inserted else { |
| 71 | throw RuntimeVerificationError.duplicateEntry(entry.path) |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | var actual = Set<String>() |
| 76 | let keys: [URLResourceKey] = [.isRegularFileKey, .isSymbolicLinkKey] |
| 77 | guard let enumerator = FileManager.default.enumerator( |
| 78 | at: verifiedRoot, |
| 79 | includingPropertiesForKeys: keys, |
| 80 | options: [], |
| 81 | errorHandler: { _, _ in false } |
| 82 | ) else { |
| 83 | throw RuntimeVerificationError.invalidManifest |
| 84 | } |
| 85 | for case let url as URL in enumerator { |
| 86 | let prefix = verifiedRoot.path.hasSuffix("/") ? verifiedRoot.path : verifiedRoot.path + "/" |
| 87 | let values = try url.resourceValues(forKeys: Set(keys)) |
| 88 | if values.isSymbolicLink == true { |
| 89 | throw RuntimeVerificationError.symbolicLink(url.lastPathComponent) |
| 90 | } |
| 91 | let resolvedPath = url.resolvingSymlinksInPath().standardizedFileURL.path |
| 92 | guard resolvedPath.hasPrefix(prefix) else { |
| 93 | throw RuntimeVerificationError.invalidPath(resolvedPath) |
| 94 | } |
| 95 | let relative = String(resolvedPath.dropFirst(prefix.count)) |
| 96 | if values.isRegularFile == true, relative != "runtime-manifest.json" { |
| 97 | actual.insert(relative) |
| 98 | } |
| 99 | } |
| 100 | if let missing = expected.subtracting(actual).sorted().first { |
| 101 | throw RuntimeVerificationError.missingFile(missing) |
| 102 | } |
| 103 | if let extra = actual.subtracting(expected).sorted().first { |
| 104 | throw RuntimeVerificationError.extraFile(extra) |
| 105 | } |
| 106 | |
| 107 | for entry in manifest.files { |
| 108 | let url = verifiedRoot.appendingPathComponent(entry.path, isDirectory: false) |
| 109 | let attributes: [FileAttributeKey: Any] |
| 110 | do { |
| 111 | attributes = try FileManager.default.attributesOfItem(atPath: url.path) |
| 112 | } catch { |
| 113 | throw RuntimeVerificationError.missingFile(entry.path) |
| 114 | } |
| 115 | if attributes[.type] as? FileAttributeType == .typeSymbolicLink { |
| 116 | throw RuntimeVerificationError.symbolicLink(entry.path) |
| 117 | } |
| 118 | let size = (attributes[.size] as? NSNumber)?.uint64Value |
| 119 | guard size == entry.bytes else { |
| 120 | throw RuntimeVerificationError.sizeMismatch(entry.path) |
| 121 | } |
| 122 | let permissions = (attributes[.posixPermissions] as? NSNumber)?.uint16Value ?? 0 |
| 123 | let isExecutable = permissions & 0o111 != 0 |
| 124 | guard isExecutable == entry.executable else { |
| 125 | throw RuntimeVerificationError.executableMismatch(entry.path) |
| 126 | } |
| 127 | let data = try Data(contentsOf: url, options: [.mappedIfSafe]) |
| 128 | let digest = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() |
| 129 | guard digest == entry.sha256 else { |
| 130 | throw RuntimeVerificationError.digestMismatch(entry.path) |
| 131 | } |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | public static func verifyCodeSignature( |
| 136 | at url: URL, |
| 137 | expectedIdentifier: String, |
| 138 | expectedTeamIdentifier: String |
| 139 | ) throws { |
| 140 | var staticCode: SecStaticCode? |
| 141 | let createStatus = SecStaticCodeCreateWithPath(url as CFURL, [], &staticCode) |
| 142 | guard createStatus == errSecSuccess, let staticCode else { |
| 143 | throw RuntimeVerificationError.invalidCodeSignature(url.path) |
| 144 | } |
| 145 | let checkStatus = SecStaticCodeCheckValidity( |
| 146 | staticCode, |
| 147 | SecCSFlags(rawValue: kSecCSStrictValidate | kSecCSCheckAllArchitectures), |
| 148 | nil |
| 149 | ) |
| 150 | guard checkStatus == errSecSuccess else { |
| 151 | throw RuntimeVerificationError.invalidCodeSignature(url.path) |
| 152 | } |
| 153 | var information: CFDictionary? |
| 154 | guard SecCodeCopySigningInformation( |
| 155 | staticCode, |
| 156 | SecCSFlags(rawValue: kSecCSSigningInformation), |
| 157 | &information |
| 158 | ) == errSecSuccess, |
| 159 | let dictionary = information as? [String: Any], |
| 160 | dictionary[kSecCodeInfoIdentifier as String] as? String == expectedIdentifier, |
| 161 | dictionary[kSecCodeInfoTeamIdentifier as String] as? String == expectedTeamIdentifier else { |
| 162 | throw RuntimeVerificationError.invalidCodeSignature(url.path) |
| 163 | } |
| 164 | } |
| 165 | |
| 166 | private static func isSafeRelativePath(_ path: String) -> Bool { |
| 167 | guard !path.isEmpty, !path.hasPrefix("/"), !path.contains("\\") else { return false } |
| 168 | let components = path.split(separator: "/", omittingEmptySubsequences: false) |
| 169 | return !components.contains(where: { $0.isEmpty || $0 == "." || $0 == ".." }) |
| 170 | } |
| 171 | } |
File History
1 commit
sha256:c88bf098b52c796e46edc39ec71f9f0627d305439b73972d3fada73cefde0da9
fix: put release binaries before lipo architecture checks
Human
minor
⚠
26 days ago