TeraRemoteQualificationEvidence.swift (5508B)
1 import CryptoKit 2 import Foundation 3 import TeraKitBindings 4 5 struct TeraRemoteQualificationAuthorizationEvidence: Codable, Equatable { 6 static let schema = "radroots.ios.remote-qualification.authorization-evidence.v1" 7 static let digestDomain = "radroots.ios.remote-qualification.authorization-digest.v1\0" 8 static let maximumEncodedBytes = 512 9 10 let schema: String 11 let schemaVersion: UInt16 12 let authorizationDigestSHA256: String 13 14 enum CodingKeys: String, CodingKey { 15 case schema 16 case schemaVersion = "schema_version" 17 case authorizationDigestSHA256 = "authorization_digest_sha256" 18 } 19 20 init(runID: String, request: HostSigningRequest, signatureHex: String) { 21 schema = Self.schema 22 schemaVersion = 1 23 authorizationDigestSHA256 = Self.digest( 24 fields: [ 25 Data(runID.utf8), 26 Data(request.operationId.utf8), 27 Data(request.artifactId.utf8), 28 Data(request.signerRequestId.utf8), 29 Data(request.publicKey.utf8), 30 Data(request.expectedEventId.utf8), 31 request.eventIdDigest, 32 Data(signatureHex.utf8), 33 ] 34 ) 35 } 36 37 private static func digest(fields: [Data]) -> String { 38 var hasher = SHA256() 39 hasher.update(data: Data(digestDomain.utf8)) 40 for field in fields { 41 var length = UInt64(field.count).bigEndian 42 withUnsafeBytes(of: &length) { hasher.update(bufferPointer: $0) } 43 hasher.update(data: field) 44 } 45 return hasher.finalize().map { String(format: "%02x", $0) }.joined() 46 } 47 } 48 49 struct TeraRemoteQualificationEvidenceStore { 50 static let evidenceFileName = "authorization-evidence.v1.json" 51 static let legacyAuthorizationFileName = "radroots-remote-qualification-bud11.json" 52 53 let runID: String 54 let directory: URL 55 let evidenceFile: URL 56 let legacyAuthorizationFile: URL 57 let fileManager: FileManager 58 59 init( 60 runID: String, 61 temporaryRoot: URL, 62 documentsRoot: URL, 63 fileManager: FileManager = .default 64 ) { 65 self.runID = runID 66 directory = temporaryRoot 67 .appendingPathComponent("radroots-qualification-evidence", isDirectory: true) 68 .appendingPathComponent(runID, isDirectory: true) 69 evidenceFile = directory.appendingPathComponent(Self.evidenceFileName) 70 legacyAuthorizationFile = documentsRoot.appendingPathComponent( 71 Self.legacyAuthorizationFileName 72 ) 73 self.fileManager = fileManager 74 } 75 76 static func current(fileManager: FileManager = .default) throws -> Self? { 77 guard let environment = try TeraRemoteQualificationEnvironment.current() else { 78 return nil 79 } 80 guard let documentsRoot = fileManager.urls( 81 for: .documentDirectory, 82 in: .userDomainMask 83 ).first else { 84 throw CocoaError(.fileNoSuchFile) 85 } 86 return Self( 87 runID: environment.runID, 88 temporaryRoot: fileManager.temporaryDirectory, 89 documentsRoot: documentsRoot, 90 fileManager: fileManager 91 ) 92 } 93 94 static func cleanupLegacyAuthorization(fileManager: FileManager = .default) throws { 95 guard let documentsRoot = fileManager.urls( 96 for: .documentDirectory, 97 in: .userDomainMask 98 ).first else { 99 throw CocoaError(.fileNoSuchFile) 100 } 101 let legacy = documentsRoot.appendingPathComponent(Self.legacyAuthorizationFileName) 102 if fileManager.fileExists(atPath: legacy.path) { 103 try fileManager.removeItem(at: legacy) 104 } 105 } 106 107 func prepare() throws { 108 try cleanup() 109 } 110 111 func record(request: HostSigningRequest, signatureHex: String) throws { 112 let evidence = TeraRemoteQualificationAuthorizationEvidence( 113 runID: runID, 114 request: request, 115 signatureHex: signatureHex 116 ) 117 let encoder = JSONEncoder() 118 encoder.outputFormatting = [.sortedKeys] 119 let data = try encoder.encode(evidence) 120 guard data.count <= TeraRemoteQualificationAuthorizationEvidence.maximumEncodedBytes else { 121 throw CocoaError(.fileWriteOutOfSpace) 122 } 123 try fileManager.createDirectory( 124 at: directory, 125 withIntermediateDirectories: true, 126 attributes: [.posixPermissions: 0o700] 127 ) 128 let values = try directory.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) 129 guard values.isDirectory == true, values.isSymbolicLink != true else { 130 throw CocoaError(.fileWriteInvalidFileName) 131 } 132 try data.write(to: evidenceFile, options: [.atomic, .completeFileProtection]) 133 try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: evidenceFile.path) 134 } 135 136 func cleanup() throws { 137 try removeIfPresent(legacyAuthorizationFile) 138 try removeIfPresent(evidenceFile) 139 if fileManager.fileExists(atPath: directory.path) { 140 try fileManager.removeItem(at: directory) 141 } 142 } 143 144 private func removeIfPresent(_ url: URL) throws { 145 if fileManager.fileExists(atPath: url.path) { 146 try fileManager.removeItem(at: url) 147 } 148 } 149 } 150 151 enum TeraRemoteQualificationEvidence { 152 static func prepare() throws -> TeraRemoteQualificationEvidenceStore? { 153 try TeraRemoteQualificationEvidenceStore.cleanupLegacyAuthorization() 154 let store = try TeraRemoteQualificationEvidenceStore.current() 155 try store?.prepare() 156 return store 157 } 158 159 static func recordBlossomAuthorization( 160 request: HostSigningRequest, 161 signatureHex: String 162 ) throws { 163 guard try TeraRemoteQualificationEnvironment.current()?.networkMode 164 == .isolatedLoopback 165 else { return } 166 try TeraRemoteQualificationEvidenceStore.current()?.record( 167 request: request, 168 signatureHex: signatureHex 169 ) 170 } 171 }