RuntimeVerifier.swift
171 lines 7.0 KB
Raw
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