Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 169 additions & 0 deletions Meshtastic/Model/EventFirmwareArtifactDownloader.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import CryptoKit
import Foundation

enum EventFirmwareArtifactDownloadError: Error, Equatable {
case unexpectedResponseURL
case httpStatus(Int)
case byteCountMismatch
case checksumMismatch
}

actor EventFirmwareArtifactDownloader {
typealias Download = @Sendable (URL, Int64) async throws -> (URL, URLResponse)

private let cacheDirectory: URL
private let download: Download

init(
cacheDirectory: URL = FileManager.default.urls(
for: .cachesDirectory,
in: .userDomainMask
)[0].appendingPathComponent("EventFirmware", isDirectory: true),
session: URLSession = .shared
) {
self.cacheDirectory = cacheDirectory
download = { url, maximumByteCount in
let delegate = EventFirmwareBoundedDownloadDelegate(
maximumByteCount: maximumByteCount
)
do {
return try await session.download(
for: URLRequest(url: url),
delegate: delegate
)
} catch {
if delegate.exceededMaximumByteCount {
throw EventFirmwareArtifactDownloadError.byteCountMismatch
}
throw error
}
}
}

init(
cacheDirectory: URL,
download: @escaping Download
) {
self.cacheDirectory = cacheDirectory
self.download = download
}

func prepare(_ artifact: EventFirmwareOTAArtifact) async throws -> URL {
try Task.checkCancellation()
try FileManager.default.createDirectory(
at: cacheDirectory,
withIntermediateDirectories: true
)

let destination = cacheDirectory.appendingPathComponent(
artifact.sha256.lowercased()
).appendingPathExtension(artifact.format.fileExtension)
if FileManager.default.fileExists(atPath: destination.path) {
if (try? verify(file: destination, against: artifact)) == true {
return destination
}
try FileManager.default.removeItem(at: destination)
}

let (downloadedURL, response) = try await download(
artifact.url,
artifact.byteCount
)
try Task.checkCancellation()
guard response.url == artifact.url else {
throw EventFirmwareArtifactDownloadError.unexpectedResponseURL
}
if let httpResponse = response as? HTTPURLResponse,
!(200...299).contains(httpResponse.statusCode) {
throw EventFirmwareArtifactDownloadError.httpStatus(httpResponse.statusCode)
}

let stagingURL = cacheDirectory.appendingPathComponent(
".\(UUID().uuidString).partial"
)
defer {
try? FileManager.default.removeItem(at: stagingURL)
}
try FileManager.default.moveItem(at: downloadedURL, to: stagingURL)
guard try verify(file: stagingURL, against: artifact) else {
throw EventFirmwareArtifactDownloadError.checksumMismatch
}
try FileManager.default.moveItem(at: stagingURL, to: destination)
return destination
}

private func verify(
file: URL,
against artifact: EventFirmwareOTAArtifact
) throws -> Bool {
let verification = try digestAndSize(of: file)
guard verification.byteCount == artifact.byteCount else {
throw EventFirmwareArtifactDownloadError.byteCountMismatch
}
return verification.sha256.caseInsensitiveCompare(artifact.sha256) == .orderedSame
}

private func digestAndSize(of file: URL) throws -> (sha256: String, byteCount: Int64) {
let handle = try FileHandle(forReadingFrom: file)
defer {
try? handle.close()
}
var hasher = SHA256()
var byteCount: Int64 = 0
while let chunk = try handle.read(upToCount: 64 * 1_024), !chunk.isEmpty {
try Task.checkCancellation()
byteCount += Int64(chunk.count)
hasher.update(data: chunk)
}
let sha256 = hasher.finalize().map { String(format: "%02x", $0) }.joined()
return (sha256, byteCount)
}
}

private final class EventFirmwareBoundedDownloadDelegate: NSObject,
URLSessionDownloadDelegate,
@unchecked Sendable {

private let maximumByteCount: Int64
private let lock = NSLock()
private var exceeded = false

var exceededMaximumByteCount: Bool {
lock.withLock { exceeded }
}

init(maximumByteCount: Int64) {
self.maximumByteCount = maximumByteCount
}

func urlSession(
_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didFinishDownloadingTo location: URL
) {}

func urlSession(
_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didWriteData bytesWritten: Int64,
totalBytesWritten: Int64,
totalBytesExpectedToWrite: Int64
) {
guard totalBytesWritten > maximumByteCount else { return }
lock.withLock {
exceeded = true
}
downloadTask.cancel()
}
}

private extension EventFirmwareOTAArtifact.Format {
var fileExtension: String {
switch self {
case .bin:
return "bin"
case .otaZip:
return "zip"
}
}
}
116 changes: 116 additions & 0 deletions Meshtastic/Model/EventFirmwareOTAContract.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import CryptoKit
import Foundation

struct EventFirmwareOTAEnvelope: Codable, Equatable, Sendable {
var keyId: String
var payload: String
var signature: String
}

struct EventFirmwareOTAContract: Codable, Equatable, Sendable {
let schemaVersion: Int
let releaseId: String
let edition: String
let version: String
let issuedAt: Date
let expiresAt: Date
let artifacts: [EventFirmwareOTAArtifact]
let standardArtifacts: [EventFirmwareOTAArtifact]
}

struct EventFirmwareOTAArtifact: Codable, Equatable, Sendable {
enum Format: String, Codable, Sendable {
case bin
case otaZip
}

let pioEnv: String
let hwModel: Int
let architecture: String
let version: String
let format: Format
let url: URL
let sha256: String
let byteCount: Int64
let minimumSourceVersion: String
let partitionRole: String?
let partitionScheme: String?
let dfuProtocol: String?
let minimumBootloaderVersion: String?
}

enum EventFirmwareOTAContractError: Error, Equatable {
case malformedEnvelope
case unknownKey(String)
case invalidTrustedKey
case invalidSignature
case invalidPayload
case unsupportedSchema(Int)
case notYetValid
case invalidValidityWindow
case expired
}

struct EventFirmwareOTAContractVerifier {
private let trustedKeys: [String: Data]
private let now: () -> Date

init(
trustedKeys: [String: Data],
now: @escaping () -> Date = Date.init
) {
self.trustedKeys = trustedKeys
self.now = now
}

func verify(envelopeData: Data) throws -> EventFirmwareOTAContract {
let envelope: EventFirmwareOTAEnvelope
do {
envelope = try JSONDecoder().decode(EventFirmwareOTAEnvelope.self, from: envelopeData)
} catch {
throw EventFirmwareOTAContractError.malformedEnvelope
}

guard let payload = Data(base64Encoded: envelope.payload),
let signature = Data(base64Encoded: envelope.signature) else {
throw EventFirmwareOTAContractError.malformedEnvelope
}
guard let trustedKey = trustedKeys[envelope.keyId] else {
throw EventFirmwareOTAContractError.unknownKey(envelope.keyId)
}

let publicKey: Curve25519.Signing.PublicKey
do {
publicKey = try Curve25519.Signing.PublicKey(rawRepresentation: trustedKey)
} catch {
throw EventFirmwareOTAContractError.invalidTrustedKey
}
guard publicKey.isValidSignature(signature, for: payload) else {
throw EventFirmwareOTAContractError.invalidSignature
}

let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let contract: EventFirmwareOTAContract
do {
contract = try decoder.decode(EventFirmwareOTAContract.self, from: payload)
} catch {
throw EventFirmwareOTAContractError.invalidPayload
}

guard contract.schemaVersion == 1 else {
throw EventFirmwareOTAContractError.unsupportedSchema(contract.schemaVersion)
}
guard contract.expiresAt > contract.issuedAt else {
throw EventFirmwareOTAContractError.invalidValidityWindow
}
let verificationDate = now()
guard contract.issuedAt <= verificationDate.addingTimeInterval(300) else {
throw EventFirmwareOTAContractError.notYetValid
}
guard contract.expiresAt > verificationDate else {
throw EventFirmwareOTAContractError.expired
}
return contract
}
}
Loading
Loading