Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
4 changes: 3 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,9 @@ let package = Package(
.target(
name: "Cryptography",
dependencies: baseDependencies + [
.product(name: "secp256k1", package: "swift-secp256k1")
.product(name: "secp256k1", package: "swift-secp256k1"),
"CrossmintService",
"Http"
],
plugins: basePlugins
),
Expand Down
104 changes: 104 additions & 0 deletions Sources/Cryptography/DstackVerifier.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import Foundation

public struct PhalaQuoteBody: Codable, Sendable {
public let reportdata: String
public let rtmr3: String
}

public struct PhalaQuote: Codable, Sendable {
public let body: PhalaQuoteBody
}

public struct PhalaQuoteResponse: Codable, Sendable {
public let success: Bool
public let quote: PhalaQuote
}

public enum DstackVerifierError: Error, Equatable {
case verificationFailed(String)
case invalidResponse
case networkError(String)
}

public struct DstackVerifier: TEEQuoteVerifier, Sendable {
private static let defaultPhalaApiURL = URL(
string: "https://cloud-api.phala.com/crossmint/attestations/verify"
)

private let phalaApiURL: URL

public init(phalaApiURL: URL? = nil) {
if let url = phalaApiURL {
self.phalaApiURL = url
} else if let defaultURL = Self.defaultPhalaApiURL {
self.phalaApiURL = defaultURL
} else {
fatalError("Invalid default Phala API URL")
}
}

public func verifyTEEReportAndExtractTD(quote: String) async throws -> TEEReportData {
let verifiedQuote = try await verifyTEEReport(quote: quote)
return extractTDFromReport(report: verifiedQuote)
}

private func verifyTEEReport(quote: String) async throws -> PhalaQuoteResponse {
let boundary = UUID().uuidString
var request = URLRequest(url: phalaApiURL)
request.httpMethod = "POST"
request.setValue(
"multipart/form-data; boundary=\(boundary)",
forHTTPHeaderField: "Content-Type"
)

var body = Data()
body.append("--\(boundary)\r\n".data(using: .utf8) ?? Data())
body.append(
"Content-Disposition: form-data; name=\"hex\"\r\n\r\n".data(using: .utf8) ?? Data()
)
body.append("\(quote)\r\n".data(using: .utf8) ?? Data())
body.append("--\(boundary)--\r\n".data(using: .utf8) ?? Data())

request.httpBody = body

let (data, response): (Data, URLResponse)
do {
(data, response) = try await URLSession.shared.data(for: request)
} catch {
throw DstackVerifierError.networkError(error.localizedDescription)
}

guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
throw DstackVerifierError.networkError("HTTP request failed")
}

let decoder = JSONDecoder()
let verifiedQuote: PhalaQuoteResponse
do {
verifiedQuote = try decoder.decode(PhalaQuoteResponse.self, from: data)
} catch {
throw DstackVerifierError.invalidResponse
}

if !verifiedQuote.success {
throw DstackVerifierError.verificationFailed("TEE attestation is invalid")
}

return verifiedQuote
}

private func extractTDFromReport(report: PhalaQuoteResponse) -> TEEReportData {
var reportData = report.quote.body.reportdata
if reportData.hasPrefix("0x") {
reportData = String(reportData.dropFirst(2))
}

var rtMr3 = report.quote.body.rtmr3
if rtMr3.hasPrefix("0x") {
rtMr3 = String(rtMr3.dropFirst(2))
}

return TEEReportData(reportData: reportData, rtMr3: rtMr3)
}
}
228 changes: 228 additions & 0 deletions Sources/Cryptography/TEEAttestationService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
import CrossmintService
import CryptoKit
import Foundation
import Http

public struct AttestationResponse: Codable, Sendable {
public let publicKey: String
public let timestamp: Int
public let quote: String
public let eventLog: String
public let hashAlgorithm: String
public let prefix: String

enum CodingKeys: String, CodingKey {
case publicKey
case timestamp
case quote
case eventLog = "event_log"
case hashAlgorithm = "hash_algorithm"
case prefix
}
}

public struct TEEReportData: Sendable {
public let reportData: String
public let rtMr3: String
}

public enum TEEAttestationEndpoint {
case getAttestation(headers: [String: String] = [:])

var endpoint: Endpoint {
switch self {
case .getAttestation(let headers):
return Endpoint(
path: "/ncs/v1/attestation",
method: .get,
headers: headers
)
}
}
}

public enum TEEAttestationError: Error, Equatable, ServiceError {
case notInitialized
case attestationFetchFailed(String)
case verificationFailed(String)
case invalidPublicKey
case publicKeyImportFailed
case attestationExpired

public static func fromServiceError(_ error: CrossmintServiceError) -> TEEAttestationError {
.attestationFetchFailed(error.errorMessage)
}

public static func fromNetworkError(_ error: NetworkError) -> TEEAttestationError {
let message = error.serviceErrorMessage ?? error.localizedDescription
return .attestationFetchFailed(message)
}

public var errorMessage: String {
switch self {
case .notInitialized:
return "TEE attestation service has not been initialized"
case .attestationFetchFailed(let message):
return "Failed to fetch TEE attestation: \(message)"
case .verificationFailed(let message):
return "TEE verification failed: \(message)"
case .invalidPublicKey:
return "Invalid TEE public key"
case .publicKeyImportFailed:
return "Failed to import TEE public key"
case .attestationExpired:
return "TEE attestation has expired"
}
}
}

public protocol TEEQuoteVerifier: Sendable {
func verifyTEEReportAndExtractTD(quote: String) async throws -> TEEReportData
}

public actor TEEAttestationService {
private let service: CrossmintService
private let verifier: TEEQuoteVerifier
private var publicKey: P256.KeyAgreement.PublicKey?

private static let teeReportDataPrefix = "app-data:"
private static let teeReportExpiryMs: Int = 24 * 60 * 60 * 1000

public init(service: CrossmintService, verifier: TEEQuoteVerifier) {
self.service = service
self.verifier = verifier
}

public func initialize() async throws {
let attestation = try await fetchAttestation()

let reportData = try await verifier.verifyTEEReportAndExtractTD(quote: attestation.quote)

try await verifyTEEPublicKey(
reportData: reportData.reportData,
publicKey: attestation.publicKey,
timestamp: attestation.timestamp
)

self.publicKey = try importPublicKey(base64Key: attestation.publicKey)
}

public func getAttestedPublicKey() throws -> P256.KeyAgreement.PublicKey {
guard let publicKey = publicKey else {
throw TEEAttestationError.notInitialized
}
return publicKey
}

private func fetchAttestation() async throws -> AttestationResponse {
do {
return try await service.executeRequest(
TEEAttestationEndpoint.getAttestation().endpoint,
errorType: TEEAttestationError.self
)
} catch let error as TEEAttestationError {
throw error
} catch {
throw TEEAttestationError.attestationFetchFailed(error.localizedDescription)
}
}

private func verifyTEEPublicKey(
reportData: String,
publicKey: String,
timestamp: Int
) async throws {
let currentTime = Int(Date().timeIntervalSince1970 * 1000)
if currentTime - timestamp > Self.teeReportExpiryMs {
throw TEEAttestationError.attestationExpired
}

let isValid = try await verifyReportAttestsPublicKey(
reportData: reportData,
publicKey: publicKey,
timestamp: timestamp
)

if !isValid {
throw TEEAttestationError.verificationFailed(
"TEE reported public key does not match attestation report"
)
}
}

private func verifyReportAttestsPublicKey(
reportData: String,
publicKey: String,
timestamp: Int
) async throws -> Bool {
guard let reportDataHash = Data(hexString: reportData) else {
return false
}

if reportDataHash.count != 64 {
return false
}

let attestedData: [String: Any] = [
"publicKey": publicKey,
"timestamp": timestamp
]

guard let attestedDataJson = try? JSONSerialization.data(
withJSONObject: attestedData,
options: [.sortedKeys]
) else {
return false
}

let prefixData = Self.teeReportDataPrefix.data(using: .utf8) ?? Data()
var reconstructedReportData = prefixData
reconstructedReportData.append(attestedDataJson)

let hash = SHA512.hash(data: reconstructedReportData)
let hashData = Data(hash)

return hashData == reportDataHash
}

private func importPublicKey(base64Key: String) throws -> P256.KeyAgreement.PublicKey {
guard let keyData = Data(base64Encoded: base64Key) else {
throw TEEAttestationError.invalidPublicKey
}

do {
return try P256.KeyAgreement.PublicKey(x963Representation: keyData)
} catch {
do {
return try P256.KeyAgreement.PublicKey(rawRepresentation: keyData)
} catch {
throw TEEAttestationError.publicKeyImportFailed
}
}
}
}

private extension Data {
init?(hexString: String) {
var hex = hexString
if hex.hasPrefix("0x") {
hex = String(hex.dropFirst(2))
}

guard hex.count % 2 == 0 else { return nil }

var data = Data()
var index = hex.startIndex

while index < hex.endIndex {
let nextIndex = hex.index(index, offsetBy: 2)
guard let byte = UInt8(hex[index..<nextIndex], radix: 16) else {
return nil
}
data.append(byte)
index = nextIndex
}

self = data
}
}
Loading
Loading