field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

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 }