diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 9893922..c1f07a0 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -1,16 +1,9 @@ name: Swift Build -on: - push: - branches-ignore: - - 'dependabot/*' - pull_request_target: - workflow_dispatch: - +on: [push] jobs: build: - runs-on: macos-latest-xlarge - + runs-on: macos-latest steps: - uses: maxim-lobanov/setup-xcode@v1 with: diff --git a/Package.resolved b/Package.resolved index f6d0008..801027f 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,4 +1,5 @@ { + "originHash" : "52a6b211db7b42edef80ca0a011ade90c5c72e8348f56a62085424df662c0bb9", "pins" : [ { "identity" : "blueecc", @@ -23,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/eu-digital-identity-wallet/eudi-lib-ios-iso18013-data-model.git", "state" : { - "revision" : "e604f0f0b67c86c3360f848defe85c9a9939b716", - "version" : "0.3.1" + "revision" : "39ba199744ad478544fbad3a73c4a47677f34ec7", + "version" : "0.3.2" } }, { @@ -32,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/eu-digital-identity-wallet/eudi-lib-ios-iso18013-data-transfer.git", "state" : { - "revision" : "0b2741f2ce2b9232e1cf10dce070fcfa3d714dcd", - "version" : "0.3.1" + "revision" : "abc00ef942e9b02e73786726661bd71eb2876b6e", + "version" : "0.3.2" } }, { @@ -41,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/eu-digital-identity-wallet/eudi-lib-ios-iso18013-security.git", "state" : { - "revision" : "9d4cc4f403ded786b89401bfbc455ab8f83635db", - "version" : "0.2.4" + "revision" : "89cccb0dec4e675d3d83e9e78076822d98d024bb", + "version" : "0.2.5" } }, { @@ -77,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/eu-digital-identity-wallet/eudi-lib-ios-wallet-storage.git", "state" : { - "revision" : "373a59f765c80b5319d4064d25c56f75a486af86", - "version" : "0.2.8" + "revision" : "ef829a7eb0d3db82a4dd4bdabc80c930e86d152e", + "version" : "0.2.9" } }, { @@ -86,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/KittyMac/Hitch.git", "state" : { - "revision" : "77b592f4c21c454a24da2fad80901e6b19751780", - "version" : "0.4.147" + "revision" : "d6c147a1d70992db39a141cb5bf9cf8fbb776250", + "version" : "0.4.148" } }, { @@ -167,8 +168,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "9bf03ff58ce34478e66aaee630e491823326fd06", - "version" : "1.1.3" + "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", + "version" : "1.1.4" } }, { @@ -176,8 +177,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-crypto.git", "state" : { - "revision" : "9f95b4d033a4edd3814b48608db3f2ca90c7218b", - "version" : "3.7.0" + "revision" : "ffca28be3c9c6a86a579949d23f68818a4b9b5d8", + "version" : "3.8.0" } }, { @@ -203,8 +204,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/niscy-eudiw/SwiftCBOR.git", "state" : { - "revision" : "310dbc3975a5653237fed304d88a6dd59d04dd30", - "version" : "0.5.7" + "revision" : "2c8c55273d4c4aae21bb46c2afbae79ee072eff4", + "version" : "0.6.2" } }, { @@ -226,5 +227,5 @@ } } ], - "version" : 2 + "version" : 3 } diff --git a/Package.swift b/Package.swift index 9280e59..97c9f61 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.9 +// swift-tools-version: 6.0 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -15,8 +15,8 @@ let package = Package( dependencies: [ .package(url: "https://github.com/apple/swift-log.git", from: "1.5.3"), .package(url: "https://github.com/crspybits/swift-log-file", from: "0.1.0"), - .package(url: "https://github.com/eu-digital-identity-wallet/eudi-lib-ios-iso18013-data-transfer.git", exact: "0.3.1"), - .package(url: "https://github.com/eu-digital-identity-wallet/eudi-lib-ios-wallet-storage.git", exact: "0.2.8"), + .package(url: "https://github.com/eu-digital-identity-wallet/eudi-lib-ios-iso18013-data-transfer.git", exact: "0.3.2"), + .package(url: "https://github.com/eu-digital-identity-wallet/eudi-lib-ios-wallet-storage.git", exact: "0.2.9"), .package(url: "https://github.com/eu-digital-identity-wallet/eudi-lib-ios-siop-openid4vp-swift.git", exact: "0.4.0"), .package(url: "https://github.com/eu-digital-identity-wallet/eudi-lib-ios-openid4vci-swift.git", exact: "0.6.0"), ], diff --git a/Sources/EudiWalletKit/EudiWallet.swift b/Sources/EudiWalletKit/EudiWallet.swift index 0b23ca1..718d035 100644 --- a/Sources/EudiWalletKit/EudiWallet.swift +++ b/Sources/EudiWalletKit/EudiWallet.swift @@ -31,6 +31,7 @@ import UIKit #endif /// User wallet implementation +@MainActor public final class EudiWallet: ObservableObject { /// Storage manager instance public private(set) var storage: StorageManager @@ -38,9 +39,9 @@ public final class EudiWallet: ObservableObject { /// Instance of the wallet initialized with default parameters public static private(set) var standard: EudiWallet = try! EudiWallet() /// The [service](https://developer.apple.com/documentation/security/ksecattrservice) used to store documents. Use a different service than the default one if you want to store documents in a different location. - public var serviceName: String { didSet { storage.storageService.serviceName = serviceName } } + public var serviceName: String { didSet { Task { try await setServiceParams() } } } /// The [access group](https://developer.apple.com/documentation/security/ksecattraccessgroup) that documents are stored in. - public var accessGroup: String? { didSet { storage.storageService.accessGroup = accessGroup } } + public var accessGroup: String? { didSet { Task { try await setServiceParams() } } } /// Whether user authentication via biometrics or passcode is required before sending user data public var userAuthenticationRequired: Bool /// Trusted root certificates to validate the reader authentication certificate included in the proximity request @@ -63,17 +64,16 @@ public final class EudiWallet: ObservableObject { public var urlSession: URLSession /// If not-nil, logging to the specified log file name will be configured public var logFileName: String? { didSet { try? initializeLogging() } } - public static var defaultClientId = "wallet-dev" - public static var defaultOpenID4VciRedirectUri = URL(string: "eudi-openid4ci://authorize")! - public static var defaultOpenId4VCIConfig = OpenId4VCIConfig(clientId: defaultClientId, authFlowRedirectionURI: defaultOpenID4VciRedirectUri) - - public static var defaultServiceName = "eudiw" + public static let defaultClientId = "wallet-dev" + public static let defaultOpenID4VciRedirectUri = URL(string: "eudi-openid4ci://authorize")! + public static let defaultOpenId4VCIConfig = OpenId4VCIConfig(clientId: defaultClientId, authFlowRedirectionURI: defaultOpenID4VciRedirectUri) + public static let defaultServiceName = "eudiw" /// Initialize a wallet instance. All parameters are optional. - public init(storageType: StorageType = .keyChain, serviceName: String = defaultServiceName, accessGroup: String? = nil, trustedReaderCertificates: [Data]? = nil, userAuthenticationRequired: Bool = true, verifierApiUri: String? = nil, openID4VciIssuerUrl: String? = nil, openID4VciConfig: OpenId4VCIConfig? = nil, urlSession: URLSession? = nil, logFileName: String? = nil, modelFactory: (any MdocModelFactory.Type)? = nil) throws { - guard !serviceName.isEmpty, !serviceName.contains(":") else { throw WalletError(description: "Not allowed service name, remove : character") } - self.serviceName = serviceName + public init(storageType: StorageType = .keyChain, serviceName: String? = nil, accessGroup: String? = nil, trustedReaderCertificates: [Data]? = nil, userAuthenticationRequired: Bool = true, verifierApiUri: String? = nil, openID4VciIssuerUrl: String? = nil, openID4VciConfig: OpenId4VCIConfig? = nil, urlSession: URLSession? = nil, logFileName: String? = nil, modelFactory: (any MdocModelFactory.Type)? = nil) throws { + try Self.validateServiceParams(serviceName: serviceName) + self.serviceName = serviceName ?? Self.defaultServiceName self.accessGroup = accessGroup - let keyChainObj = KeyChainStorageService(serviceName: serviceName, accessGroup: accessGroup) + let keyChainObj = KeyChainStorageService(serviceName: self.serviceName, accessGroup: accessGroup) let storageService = switch storageType { case .keyChain:keyChainObj } storage = StorageManager(storageService: storageService, modelFactory: modelFactory) self.trustedReaderCertificates = trustedReaderCertificates @@ -94,10 +94,26 @@ public final class EudiWallet: ObservableObject { /// The file is created in the caches directory /// - Parameter fileName: A file name /// - Returns: Th URL of a log file stored in the caches directory - public static func getLogFileURL(_ fileName: String) throws -> URL? { + nonisolated public static func getLogFileURL(_ fileName: String) throws -> URL? { return try FileManager.getCachesDirectory().appendingPathComponent(fileName) } + private static func validateServiceParams(serviceName: String? = nil) throws { + guard (serviceName?.contains(":") ?? false) == false else { + let msg = "Not allowed service name, contains : character" + logger.error("validateServiceParams:\(msg)") + throw WalletError(description: msg) + } + } + + private func setServiceParams() async throws { + if let keyChainObj = storage.storageService as? KeyChainStorageService { + try Self.validateServiceParams(serviceName: self.serviceName) + await keyChainObj.initialize(serviceName, accessGroup) + } + } + + /// Get the contents of a log file stored in the caches directory /// - Parameter fileName: A file name /// - Returns: The file contents @@ -116,20 +132,20 @@ public final class EudiWallet: ObservableObject { } private func initializeLogging() throws { - LoggingSystem.bootstrap { [unowned self] label in + LoggingSystem.bootstrap { [logFileName] label in var handlers:[LogHandler] = [] if _isDebugAssertConfiguration() { handlers.append(StreamLogHandler.standardOutput(label: label)) } #if canImport(UIKit) - if let logFileName { - do { - let logFileURL = try Self.getLogFileURL(logFileName) - guard let logFileURL else { throw WalletError(description: "Cannot create URL for file name \(logFileName)") } - let fileLogger = try FileLogging(to: logFileURL) - handlers.append(FileLogHandler(label: label, fileLogger: fileLogger)) - } catch { fatalError("Logging setup failed: \(error.localizedDescription)") } - } + if let logFileName { + do { + let logFileURL = try Self.getLogFileURL(logFileName) + guard let logFileURL else { throw WalletError(description: "Cannot create URL for file name \(logFileName)") } + let fileLogger = try FileLogging(to: logFileURL) + handlers.append(FileLogHandler(label: label, fileLogger: fileLogger)) + } catch { fatalError("Logging setup failed: \(error.localizedDescription)") } + } #endif return MultiplexLogHandler(handlers) } @@ -189,7 +205,6 @@ public final class EudiWallet: ObservableObject { /// - Parameter pendingDoc: A temporary document with pending status /// /// - Returns: The issued document in case it was approved in the backend and the pendingDoc data are valid, otherwise a pendingDoc status document - @MainActor @discardableResult public func resumePendingIssuance(pendingDoc: WalletStorage.Document, webUrl: URL?) async throws -> WalletStorage.Document { guard pendingDoc.status == .pending else { throw WalletError(description: "Invalid document status") } guard let pkt = pendingDoc.privateKeyType, let pk = pendingDoc.privateKey, let format = DataFormat(pendingDoc.docDataType) else { throw WalletError(description: "Invalid document") } @@ -226,11 +241,11 @@ public final class EudiWallet: ObservableObject { } let newDocStatus: WalletStorage.DocumentStatus = data.isDeferred ? .deferred : (data.isPending ? .pending : .issued) let newDocument = WalletStorage.Document(id: id, docType: docTypeToSave, docDataType: ddt, data: dataToSave, privateKeyType: (openId4VCIService.usedSecureEnclave ?? true) ? .secureEnclaveP256 : .x963EncodedP256, privateKey: issueReq.keyData, createdAt: Date(), displayName: displayName, status: newDocStatus) - if newDocStatus == .pending { await storage.appendDocModel(newDocument); return newDocument } - try issueReq.saveToStorage(storage.storageService, status: newDocStatus) - try endIssueDocument(newDocument) - await storage.appendDocModel(newDocument) - await storage.refreshPublishedVars() + if newDocStatus == .pending { storage.appendDocModel(newDocument); return newDocument } + try await issueReq.saveTo(storageService: storage.storageService, status: newDocStatus) + try await endIssueDocument(newDocument) + storage.appendDocModel(newDocument) + storage.refreshPublishedVars() if pds == nil { try await storage.removePendingOrDeferredDoc(id: id) } return newDocument } @@ -276,14 +291,14 @@ public final class EudiWallet: ObservableObject { /// - issuer: Issuer function public func beginIssueDocument(id: String, privateKeyType: PrivateKeyType = .secureEnclaveP256, saveToStorage: Bool = true, bDeferred: Bool = false) async throws -> IssueRequest { let request = try IssueRequest(id: id, privateKeyType: privateKeyType) - if saveToStorage { try request.saveToStorage(storage.storageService, status: bDeferred ? .deferred : .issued) } + if saveToStorage { try await request.saveTo(storageService: storage.storageService, status: bDeferred ? .deferred : .issued) } return request } /// End issuing by saving the issuing document (and its private key) in storage /// - Parameter issued: The issued document - public func endIssueDocument(_ issued: WalletStorage.Document) throws { - try storage.storageService.saveDocument(issued, allowOverwrite: true) + public func endIssueDocument(_ issued: WalletStorage.Document) async throws { + try await storage.storageService.saveDocument(issued, allowOverwrite: true) } /// Load documents with a specific status from storage @@ -351,17 +366,17 @@ public final class EudiWallet: ObservableObject { /// The mdoc data are stored in wallet storage as documents /// - Parameter sampleDataFiles: Names of sample files provided in the app bundle public func loadSampleData(sampleDataFiles: [String]? = nil) async throws { - try? storageService.deleteDocuments(status: .issued) + try? await storageService.deleteDocuments(status: .issued) let docSamples = (sampleDataFiles ?? ["EUDI_sample_data"]).compactMap { Data(name:$0) } .compactMap(SignUpResponse.decomposeCBORSignupResponse(data:)).flatMap {$0} .map { Document(docType: $0.docType, docDataType: .cbor, data: $0.issData, privateKeyType: .x963EncodedP256, privateKey: $0.pkData, createdAt: Date.distantPast, modifiedAt: nil, displayName: $0.docType == EuPidModel.euPidDocType ? "PID" : ($0.docType == IsoMdlModel.isoDocType ? "mDL" : $0.docType), status: .issued) } do { for docSample in docSamples { - try storageService.saveDocument(docSample, allowOverwrite: true) + try await storageService.saveDocument(docSample, allowOverwrite: true) } try await storage.loadDocuments(status: .issued) } catch { - await storage.setError(error) + storage.setError(error) throw WalletError(description: error.localizedDescription) } } @@ -371,11 +386,11 @@ public final class EudiWallet: ObservableObject { /// - docType: docType of documents to present (optional) /// - dataFormat: Exchanged data ``Format`` type /// - Returns: A data dictionary that can be used to initialize a presentation service - public func prepareServiceDataParameters(docType: String? = nil, dataFormat: DataFormat = .cbor ) throws -> [String: Any] { + public func prepareServiceDataParameters(docType: String? = nil, dataFormat: DataFormat = .cbor ) async throws -> [String: Any] { var parameters: [String: Any] switch dataFormat { case .cbor: - guard var docs = try storageService.loadDocuments(status: .issued), docs.count > 0 else { throw WalletError(description: "No documents found") } + guard var docs = try await storageService.loadDocuments(status: .issued), docs.count > 0 else { throw WalletError(description: "No documents found") } if let docType { docs = docs.filter { $0.docType == docType} } if let docType { guard docs.count > 0 else { throw WalletError(description: "No documents of type \(docType) found") } } let cborsWithKeys = docs.compactMap { $0.getCborData() } @@ -395,9 +410,9 @@ public final class EudiWallet: ObservableObject { /// - docType: DocType of documents to present (optional) /// - dataFormat: Exchanged data ``Format`` type /// - Returns: A presentation session instance, - public func beginPresentation(flow: FlowType, docType: String? = nil, dataFormat: DataFormat = .cbor) -> PresentationSession { + public func beginPresentation(flow: FlowType, docType: String? = nil, dataFormat: DataFormat = .cbor) async -> PresentationSession { do { - let parameters = try prepareServiceDataParameters(docType: docType, dataFormat: dataFormat) + let parameters = try await prepareServiceDataParameters(docType: docType, dataFormat: dataFormat) let docIdAndTypes = storage.getDocIdsToTypes() switch flow { case .ble: @@ -425,17 +440,15 @@ public final class EudiWallet: ObservableObject { PresentationSession(presentationService: service, docIdAndTypes: storage.getDocIdsToTypes(), userAuthenticationRequired: userAuthenticationRequired) } - @MainActor /// Perform an action after user authorization via TouchID/FaceID/Passcode /// - Parameters: /// - dismiss: Action to perform if the user cancels authorization /// - action: Action to perform after user authorization - public static func authorizedAction(action: () async throws -> T, disabled: Bool, dismiss: () -> Void, localizedReason: String) async throws -> T? { + public static func authorizedAction(action: () async throws -> T, disabled: Bool, dismiss: () -> Void, localizedReason: String) async throws -> T? { return try await authorizedAction(isFallBack: false, action: action, disabled: disabled, dismiss: dismiss, localizedReason: localizedReason) } - @MainActor - /// Executes an authorized action with optional fallback and dismissal handling. + /// Executes an authorized action with optional fallback and dismissal handling. /// The action is performed after successful biometric authentication (TouchID or FaceID). /// /// - Parameters: @@ -449,7 +462,7 @@ public final class EudiWallet: ObservableObject { /// - Returns: An optional result of type `T` if the action is successful, otherwise `nil`. /// /// - Throws: An error if the action fails. - static func authorizedAction(isFallBack: Bool = false, action: () async throws -> T, disabled: Bool, dismiss: () -> Void, localizedReason: String) async throws -> T? { + static func authorizedAction(isFallBack: Bool = false, action: () async throws -> T, disabled: Bool, dismiss: () -> Void, localizedReason: String) async throws -> T? { guard !disabled else { return try await action() } diff --git a/Sources/EudiWalletKit/Services/BlePresentationService.swift b/Sources/EudiWalletKit/Services/BlePresentationService.swift index 751ea17..7ab2c91 100644 --- a/Sources/EudiWalletKit/Services/BlePresentationService.swift +++ b/Sources/EudiWalletKit/Services/BlePresentationService.swift @@ -21,15 +21,16 @@ import MdocDataTransfer18013 /// Implements proximity attestation presentation with QR to BLE data transfer /// Implementation is based on the ISO/IEC 18013-5 specification +@MainActor public class BlePresentationService : PresentationService { var bleServerTransfer: MdocGattServer public var status: TransferStatus = .initializing var continuationQrCode: CheckedContinuation? - var continuationRequest: CheckedContinuation<[String: Any], Error>? + var continuationRequest: CheckedContinuation? var continuationResponse: CheckedContinuation? var handleSelected: ((Bool, RequestItems?) -> Void)? var deviceEngagement: String? - var request: [String: Any]? + var request: UserRequestInfo? public var flow: FlowType { .ble } public init(parameters: [String: Any]) throws { @@ -51,7 +52,7 @@ public class BlePresentationService : PresentationService { /// Receive request via BLE /// /// - Returns: The requested items. - public func receiveRequest() async throws -> [String: Any] { + public func receiveRequest() async throws -> UserRequestInfo { return try await withCheckedThrowingContinuation { c in continuationRequest = c } @@ -62,7 +63,7 @@ public class BlePresentationService : PresentationService { /// - Parameters: /// - userAccepted: True if user accepted to send the response /// - itemsToSend: The selected items to send organized in document types and namespaces - public func sendResponse(userAccepted: Bool, itemsToSend: RequestItems, onSuccess: ((URL?) -> Void)? ) async throws { + public func sendResponse(userAccepted: Bool, itemsToSend: RequestItems, onSuccess: ( @Sendable (URL?) -> Void)?) async throws { return try await withCheckedThrowingContinuation { c in continuationResponse = c handleSelected?(userAccepted, itemsToSend) @@ -102,7 +103,7 @@ extension BlePresentationService: MdocOfflineDelegate { /// - Parameters: /// - request: Request items keyed by §UserRequestKeys§ /// - handleSelected: Callback function to call after user selection of items to send - public func didReceiveRequest(_ request: [String : Any], handleSelected: @escaping (Bool, MdocDataTransfer18013.RequestItems?) -> Void) { + public func didReceiveRequest(_ request: UserRequestInfo, handleSelected: @escaping (Bool, MdocDataTransfer18013.RequestItems?) -> Void) { self.handleSelected = handleSelected self.request = request continuationRequest?.resume(returning: request) diff --git a/Sources/EudiWalletKit/Services/Enumerations.swift b/Sources/EudiWalletKit/Services/Enumerations.swift index 640431f..5747502 100644 --- a/Sources/EudiWalletKit/Services/Enumerations.swift +++ b/Sources/EudiWalletKit/Services/Enumerations.swift @@ -20,7 +20,7 @@ import Foundation import WalletStorage /// Data exchange flow type -public enum FlowType: Codable, Hashable { +public enum FlowType: Codable, Hashable, Sendable { case ble case openid4vp(qrCode: Data) @@ -31,7 +31,7 @@ public enum FlowType: Codable, Hashable { } /// Data format of the exchanged data -public enum DataFormat: String { +public enum DataFormat: String, Sendable { case cbor = "cbor" case sdjwt = "sdjwt" } @@ -46,7 +46,7 @@ public extension DataFormat { } } -public enum StorageType { +public enum StorageType: Sendable { case keyChain } diff --git a/Sources/EudiWalletKit/Services/FaultPresentationService.swift b/Sources/EudiWalletKit/Services/FaultPresentationService.swift index 9529d5d..17e1ddb 100644 --- a/Sources/EudiWalletKit/Services/FaultPresentationService.swift +++ b/Sources/EudiWalletKit/Services/FaultPresentationService.swift @@ -35,7 +35,7 @@ public class FaultPresentationService: PresentationService { throw error } - public func receiveRequest() async throws -> [String : Any] { + public func receiveRequest() async throws -> UserRequestInfo { throw error } diff --git a/Sources/EudiWalletKit/Services/OpenId4VciService.swift b/Sources/EudiWalletKit/Services/OpenId4VciService.swift index 06dd9ec..82a8d41 100644 --- a/Sources/EudiWalletKit/Services/OpenId4VciService.swift +++ b/Sources/EudiWalletKit/Services/OpenId4VciService.swift @@ -15,7 +15,7 @@ */ import Foundation -import OpenID4VCI +@preconcurrency import OpenID4VCI import JOSESwift import MdocDataModel18013 import AuthenticationServices @@ -25,6 +25,9 @@ import Security import WalletStorage import SwiftCBOR +extension CredentialIssuerSource: @unchecked Sendable {} + +@MainActor public class OpenId4VCIService: NSObject, ASWebAuthenticationPresentationContextProviding { let issueReq: IssueRequest let credentialIssuerURL: String @@ -114,20 +117,21 @@ public class OpenId4VCIService: NSObject, ASWebAuthenticationPresentationContext if case .presentation_request(let url) = authorizedOutcome, let parRequested { logger.info("Dynamic issuance request with url: \(url)") let uuids = credentialInfo.map { _ in UUID().uuidString } - uuids.forEach { Self.metadataCache[$0] = offer } + for uid in uuids { Self.metadataCache[uid] = offer } return credentialInfo.enumerated().map { .pending(PendingIssuanceModel(pendingReason: .presentation_request_url(url.absoluteString), identifier: $1.identifier, displayName: $1.displayName ?? "", metadataKey: uuids[$0], pckeCodeVerifier: parRequested.pkceVerifier.codeVerifier, pckeCodeVerifierMethod: parRequested.pkceVerifier.codeVerifierMethod )) } } guard case .authorized(let authorized) = authorizedOutcome else { throw WalletError(description: "Invalid authorized request outcome") } - let data = await credentialInfo.asyncCompactMap { + var data = [IssuanceOutcome]() + for ci in credentialInfo { do { - logger.info("Starting issuing with identifer \($0.identifier.value), scope \($0.scope), displayName: \($0.displayName ?? "-")") - let res = try await issueOfferedCredentialInternalValidated(authorized, offer: offer, issuer: issuer, credentialConfigurationIdentifier: $0.identifier, displayName: $0.displayName, claimSet: claimSet) + let id = ci.identifier.value; let sc = ci.scope; let dn = ci.displayName ?? "" + logger.info("Starting issuing with identifer \(id), scope \(sc), displayName: \(dn)") + let res = try await issueOfferedCredentialInternalValidated(authorized, offer: offer, issuer: issuer, credentialConfigurationIdentifier: ci.identifier, displayName: ci.displayName, claimSet: claimSet) // logger.info("Credential str:\n\(str)") - return res + data.append(res) } catch { - logger.error("Failed to issue document with scope \($0.scope)") + // logger.error("Failed to issue document with scope \(ci.scope)") logger.info("Exception: \(error)") - return nil } } Self.metadataCache.removeValue(forKey: offerUri) @@ -255,7 +259,7 @@ public class OpenId4VCIService: NSObject, ASWebAuthenticationPresentationContext case .success(let request): let authorizedRequest = await issuer.requestAccessToken(authorizationCode: request) if case let .success(authorized) = authorizedRequest, case let .noProofRequired(token, _, _, _) = authorized { - logger.info("--> [AUTHORIZATION] Authorization code exchanged with access token : \(token.accessToken)") + let at = token.accessToken; logger.info("--> [AUTHORIZATION] Authorization code exchanged with access token : \(at)") return authorized } throw WalletError(description: "Failed to get access token") @@ -268,7 +272,7 @@ public class OpenId4VCIService: NSObject, ASWebAuthenticationPresentationContext switch noProofRequiredState { case .noProofRequired(let accessToken, let refreshToken, _, let timeStamp): let payload: IssuanceRequestPayload = .configurationBased(credentialConfigurationIdentifier: credentialConfigurationIdentifier, claimSet: claimSet) - let responseEncryptionSpecProvider = { Issuer.createResponseEncryptionSpec($0) } + let responseEncryptionSpecProvider = { @Sendable in Issuer.createResponseEncryptionSpec($0) } let requestOutcome = try await issuer.requestSingle(noProofRequest: noProofRequiredState, requestPayload: payload, responseEncryptionSpecProvider: responseEncryptionSpecProvider) switch requestOutcome { case .success(let request): @@ -302,7 +306,7 @@ public class OpenId4VCIService: NSObject, ASWebAuthenticationPresentationContext guard case .proofRequired(let accessToken, let refreshToken, _, _, let timeStamp) = authorized else { throw WalletError(description: "Unexpected AuthorizedRequest case") } guard let credentialConfigurationIdentifier else { throw WalletError(description: "Credential configuration identifier not found") } let payload: IssuanceRequestPayload = .configurationBased(credentialConfigurationIdentifier: credentialConfigurationIdentifier, claimSet: claimSet) - let responseEncryptionSpecProvider = { Issuer.createResponseEncryptionSpec($0) } + let responseEncryptionSpecProvider = { @Sendable in Issuer.createResponseEncryptionSpec($0) } let requestOutcome = try await issuer.requestSingle(proofRequest: authorized, bindingKey: bindingKey, requestPayload: payload, responseEncryptionSpecProvider: responseEncryptionSpecProvider) switch requestOutcome { case .success(let request): diff --git a/Sources/EudiWalletKit/Services/OpenId4VpService.swift b/Sources/EudiWalletKit/Services/OpenId4VpService.swift index ab81f66..1689d98 100644 --- a/Sources/EudiWalletKit/Services/OpenId4VpService.swift +++ b/Sources/EudiWalletKit/Services/OpenId4VpService.swift @@ -21,13 +21,14 @@ import SwiftCBOR import MdocDataModel18013 import MdocSecurity18013 import MdocDataTransfer18013 -import SiopOpenID4VP +@preconcurrency import SiopOpenID4VP import JOSESwift import Logging import X509 /// Implements remote attestation presentation to online verifier /// Implementation is based on the OpenID4VP – Draft 18 specification +@MainActor public class OpenId4VpService: PresentationService { public var status: TransferStatus = .initialized var openid4VPlink: String @@ -71,7 +72,7 @@ public class OpenId4VpService: PresentationService { /// Receive request from an openid4vp URL /// /// - Returns: The requested items. - public func receiveRequest() async throws -> [String: Any] { + public func receiveRequest() async throws -> UserRequestInfo { guard status != .error, let openid4VPURI = URL(string: openid4VPlink) else { throw PresentationSession.makeError(str: "Invalid link \(openid4VPlink)") } siopOpenId4Vp = SiopOpenID4VP(walletConfiguration: getWalletConf(verifierApiUrl: openId4VpVerifierApiUri, verifierLegalName: openId4VpVerifierLegalName)) switch try await siopOpenId4Vp.authorize(url: openid4VPURI) { @@ -93,13 +94,13 @@ public class OpenId4VpService: PresentationService { self.presentationDefinition = vp.presentationDefinition let items = try Openid4VpUtils.parsePresentationDefinition(vp.presentationDefinition, logger: logger) guard let items else { throw PresentationSession.makeError(str: "Invalid presentation definition") } - var result: [String: Any] = [UserRequestKeys.valid_items_requested.rawValue: items] + var result = UserRequestInfo(validItemsRequested: items) logger.info("Verifer requested items: \(items)") - if let ln = resolvedRequestData.legalName { result[UserRequestKeys.reader_legal_name.rawValue] = ln } + if let ln = resolvedRequestData.legalName { result.readerLegalName = ln } if let readerCertificateIssuer { - result[UserRequestKeys.reader_auth_validated.rawValue] = readerAuthValidated - result[UserRequestKeys.reader_certificate_issuer.rawValue] = MdocHelpers.getCN(from: readerCertificateIssuer) - result[UserRequestKeys.reader_certificate_validation_message.rawValue] = readerCertificateValidationMessage + result.readerAuthValidated = readerAuthValidated + result.readerCertificateIssuer = MdocHelpers.getCN(from: readerCertificateIssuer) + result.readerCertificateValidationMessage = readerCertificateValidationMessage } return result default: throw PresentationSession.makeError(str: "SiopAuthentication request received, not supported yet.") @@ -144,14 +145,20 @@ public class OpenId4VpService: PresentationService { } lazy var chainVerifier: CertificateTrust = { [weak self] certificates in + guard let self else { return false } let chainVerifier = X509CertificateChainVerifier() let verified = try? chainVerifier.verifyCertificateChain(base64Certificates: certificates) var result = chainVerifier.isChainTrustResultSuccesful(verified ?? .failure) - guard let self, let b64cert = certificates.first, let data = Data(base64Encoded: b64cert), let cert = SecCertificateCreateWithData(nil, data as CFData), let x509 = try? X509.Certificate(derEncoded: [UInt8](data)) else { return result } - self.readerCertificateIssuer = x509.subject.description - let (isValid, validationMessages, _) = SecurityHelpers.isMdocCertificateValid(secCert: cert, usage: .mdocReaderAuth, rootCerts: self.iaca ?? []) - self.readerAuthValidated = isValid - self.readerCertificateValidationMessage = validationMessages.joined(separator: "\n") + let b64certs = certificates; let data = b64certs.compactMap { Data(base64Encoded: $0) } + let certs = data.compactMap { SecCertificateCreateWithData(nil, $0 as CFData) } + guard certs.count > 0, certs.count == b64certs.count else { return result } + guard let x509 = try? X509.Certificate(derEncoded: [UInt8](data.first!)) else { return result } + Task { @MainActor in + self.readerCertificateIssuer = x509.subject.description + let (isValid, validationMessages, _) = SecurityHelpers.isMdocX5cValid(secCerts: certs, usage: .mdocReaderAuth, rootCerts: self.iaca ?? []) + self.readerAuthValidated = isValid + self.readerCertificateValidationMessage = validationMessages.joined(separator: "\n") + } return result } diff --git a/Sources/EudiWalletKit/Services/PresentationService.swift b/Sources/EudiWalletKit/Services/PresentationService.swift index aa6913c..31f5f3b 100644 --- a/Sources/EudiWalletKit/Services/PresentationService.swift +++ b/Sources/EudiWalletKit/Services/PresentationService.swift @@ -21,6 +21,7 @@ import MdocDataTransfer18013 public typealias RequestItems = [String: [String: [String]]] /// Presentation service abstract protocol +@MainActor public protocol PresentationService { /// Status of the data transfer //var status: TransferStatus { get } @@ -31,12 +32,12 @@ public protocol PresentationService { /// /// - Returns: The requested items. /// Receive request. - func receiveRequest() async throws -> [String: Any] + func receiveRequest() async throws -> UserRequestInfo /// Send response to verifier /// - Parameters: /// - userAccepted: True if user accepted to send the response /// - itemsToSend: The selected items to send organized in document types and namespaces (see ``RequestItems``) - func sendResponse(userAccepted: Bool, itemsToSend: RequestItems, onSuccess: ((URL?) -> Void)?) async throws + func sendResponse(userAccepted: Bool, itemsToSend: RequestItems, onSuccess: ( @Sendable (URL?) -> Void)?) async throws } diff --git a/Sources/EudiWalletKit/Services/PresentationSession.swift b/Sources/EudiWalletKit/Services/PresentationSession.swift index b7fa68e..bec76c3 100644 --- a/Sources/EudiWalletKit/Services/PresentationSession.swift +++ b/Sources/EudiWalletKit/Services/PresentationSession.swift @@ -23,6 +23,7 @@ import LocalAuthentication /// Presentation session /// /// This class wraps the ``PresentationService`` instance, providing bindable fields to a SwifUI view +@MainActor public class PresentationSession: ObservableObject { public var presentationService: any PresentationService /// Reader certificate issuer (the Common Name (CN) from the verifier's certificate) @@ -60,24 +61,23 @@ public class PresentationSession: ObservableObject { /// /// The ``disclosedDocuments`` property will be set. Additionally ``readerCertIssuer`` and ``readerCertValidationMessage`` may be set /// - Parameter request: Keys are defined in the ``UserRequestKeys`` - func decodeRequest(_ request: [String: Any]) throws { + func decodeRequest(_ request: UserRequestInfo) throws { guard docIdAndTypes.count > 0 else { throw Self.makeError(str: "No documents added to session ")} // show the items as checkboxes - guard let validRequestItems = request[UserRequestKeys.valid_items_requested.rawValue] as? RequestItems else { return } disclosedDocuments = [DocElementsViewModel]() for (docId, (docType, displayName)) in docIdAndTypes { - var tmp = validRequestItems.toDocElementViewModels(docId: docId, docType: docType, displayName: displayName, valid: true) - if let errorRequestItems = request[UserRequestKeys.error_items_requested.rawValue] as? RequestItems, errorRequestItems.count > 0 { + var tmp = request.validItemsRequested.toDocElementViewModels(docId: docId, docType: docType, displayName: displayName, valid: true) + if let errorRequestItems = request.errorItemsRequested, errorRequestItems.count > 0 { tmp = tmp.merging(with: errorRequestItems.toDocElementViewModels(docId: docId, docType: docType, displayName: displayName, valid: false)) } disclosedDocuments.append(contentsOf: tmp) } - if let readerAuthority = request[UserRequestKeys.reader_certificate_issuer.rawValue] as? String { + if let readerAuthority = request.readerCertificateIssuer { readerCertIssuer = readerAuthority - readerCertIssuerValid = request[UserRequestKeys.reader_auth_validated.rawValue] as? Bool - readerCertValidationMessage = request[UserRequestKeys.reader_certificate_validation_message.rawValue] as? String + readerCertIssuerValid = request.readerAuthValidated + readerCertValidationMessage = request.readerCertificateValidationMessage } - readerLegalName = request[UserRequestKeys.reader_legal_name.rawValue] as? String + readerLegalName = request.readerLegalName status = .requestReceived } @@ -104,7 +104,7 @@ public class PresentationSession: ObservableObject { status = .qrEngagementReady } } - } catch { await setError(error) } + } catch { setError(error) } } @MainActor @@ -119,13 +119,13 @@ public class PresentationSession: ObservableObject { /// On success ``disclosedDocuments`` published variable will be set and ``status`` will be ``.requestReceived`` /// On error ``uiError`` will be filled and ``status`` will be ``.error`` /// - Returns: A request dictionary keyed by ``MdocDataTransfer.UserRequestKeys`` - public func receiveRequest() async -> [String: Any]? { + public func receiveRequest() async -> UserRequestInfo? { do { let request = try await presentationService.receiveRequest() - try await decodeRequest(request) + try decodeRequest(request) return request } catch { - await setError(error) + setError(error) return nil } } @@ -135,13 +135,13 @@ public class PresentationSession: ObservableObject { /// - userAccepted: Whether user confirmed to send the response /// - itemsToSend: Data to send organized into a hierarcy of doc.types and namespaces /// - onCancel: Action to perform if the user cancels the biometric authentication - public func sendResponse(userAccepted: Bool, itemsToSend: RequestItems, onCancel: (() -> Void)? = nil, onSuccess: ((URL?) -> Void)? = nil) async { + public func sendResponse(userAccepted: Bool, itemsToSend: RequestItems, onCancel: (() -> Void)? = nil, onSuccess: (@Sendable (URL?) -> Void)? = nil) async { do { await MainActor.run {status = .userSelected } let action = { [ weak self] in _ = try await self?.presentationService.sendResponse(userAccepted: userAccepted, itemsToSend: itemsToSend, onSuccess: onSuccess) } try await EudiWallet.authorizedAction(action: action, disabled: !userAuthenticationRequired, dismiss: { onCancel?()}, localizedReason: NSLocalizedString("authenticate_to_share_data", comment: "") ) await MainActor.run {status = .responseSent } - } catch { await setError(error) } + } catch { setError(error) } } diff --git a/Sources/EudiWalletKit/Services/StorageManager.swift b/Sources/EudiWalletKit/Services/StorageManager.swift index 26676a2..547c22c 100644 --- a/Sources/EudiWalletKit/Services/StorageManager.swift +++ b/Sources/EudiWalletKit/Services/StorageManager.swift @@ -22,6 +22,7 @@ import Logging import CryptoKit /// Storage manager. Provides services and view models +@MainActor public class StorageManager: ObservableObject { /// A static constant array containing known document types. /// This array includes document types from `EuPidModel` and `IsoMdlModel`. @@ -51,14 +52,12 @@ public class StorageManager: ObservableObject { self.modelFactory = modelFactory } - @MainActor func refreshPublishedVars() { hasData = !mdocModels.isEmpty || !deferredDocuments.isEmpty hasWellKnownData = hasData && !Set(mdocModels.map(\.docType)).isDisjoint(with: Self.knownDocTypes) docCount = mdocModels.count } - @MainActor /// Refreshes the document models with the specified status. /// /// - Parameters: @@ -75,7 +74,6 @@ public class StorageManager: ObservableObject { } } - @MainActor fileprivate func refreshDocModel(_ doc: WalletStorage.Document, docStatus: WalletStorage.DocumentStatus) { if docStatus == .issued && mdocModels.first(where: { $0.id == doc.id}) == nil || docStatus == .deferred && deferredDocuments.first(where: { $0.id == doc.id}) == nil || @@ -84,7 +82,6 @@ public class StorageManager: ObservableObject { } } - @MainActor @discardableResult func appendDocModel(_ doc: WalletStorage.Document) -> (any MdocDecodable)? { switch doc.status { case .issued: @@ -100,7 +97,6 @@ public class StorageManager: ObservableObject { } } - @MainActor func removePendingOrDeferredDoc(id: String) async throws { if let index = pendingDocuments.firstIndex(where: { $0.id == id }) { pendingDocuments.remove(at: index) @@ -141,12 +137,12 @@ public class StorageManager: ObservableObject { /// - Returns: An array of ``WalletStorage.Document`` objects @discardableResult public func loadDocuments(status: WalletStorage.DocumentStatus) async throws -> [WalletStorage.Document]? { do { - guard let docs = try storageService.loadDocuments(status: status) else { return nil } - await refreshDocModels(docs, docStatus: status) - await refreshPublishedVars() + guard let docs = try await storageService.loadDocuments(status: status) else { return nil } + refreshDocModels(docs, docStatus: status) + refreshPublishedVars() return docs } catch { - await setError(error) + setError(error) throw error } } @@ -158,12 +154,12 @@ public class StorageManager: ObservableObject { /// - Parameter status: Status of document to load @discardableResult public func loadDocument(id: String, status: DocumentStatus) async throws -> WalletStorage.Document? { do { - guard let doc = try storageService.loadDocument(id: id, status: status) else { return nil } - await refreshDocModel(doc, docStatus: status) - await refreshPublishedVars() + guard let doc = try await storageService.loadDocument(id: id, status: status) else { return nil } + refreshDocModel(doc, docStatus: status) + refreshPublishedVars() return doc } catch { - await setError(error) + setError(error) throw error } } @@ -215,17 +211,15 @@ public class StorageManager: ObservableObject { let index = switch status { case .issued: mdocModels.firstIndex(where: { $0.id == id}); default: deferredDocuments.firstIndex(where: { $0.id == id}) } guard let index else { throw WalletError(description: "Document not found") } do { - try storageService.deleteDocument(id: id, status: status) + try await storageService.deleteDocument(id: id, status: status) if status == .issued { - await MainActor.run { - _ = mdocModels.remove(at: index) - } - await refreshPublishedVars() + _ = mdocModels.remove(at: index) + refreshPublishedVars() } else if status == .deferred { - await MainActor.run { _ = deferredDocuments.remove(at: index) } + _ = deferredDocuments.remove(at: index) } } catch { - await setError(error) + setError(error) throw error } } @@ -234,20 +228,19 @@ public class StorageManager: ObservableObject { /// - Parameter status: Status of documents to delete public func deleteDocuments(status: DocumentStatus) async throws { do { - try storageService.deleteDocuments(status: status) + try await storageService.deleteDocuments(status: status) if status == .issued { - await MainActor.run { mdocModels = []; } - await refreshPublishedVars() + mdocModels = []; + refreshPublishedVars() } else if status == .deferred { - await MainActor.run { deferredDocuments.removeAll(keepingCapacity:false) } + deferredDocuments.removeAll(keepingCapacity:false) } } catch { - await setError(error) + setError(error) throw error } } - @MainActor func setError(_ error: Error) { uiError = WalletError(description: error.localizedDescription, userInfo: (error as NSError).userInfo) } diff --git a/Sources/EudiWalletKit/ViewModels/DocElementsViewModel.swift b/Sources/EudiWalletKit/ViewModels/DocElementsViewModel.swift index c3b8213..66bab18 100644 --- a/Sources/EudiWalletKit/ViewModels/DocElementsViewModel.swift +++ b/Sources/EudiWalletKit/ViewModels/DocElementsViewModel.swift @@ -18,7 +18,7 @@ import Foundation import MdocDataModel18013 /// View model used in SwiftUI for presentation request elements -public struct DocElementsViewModel: Identifiable { +public struct DocElementsViewModel: Identifiable, Sendable { public var id: String { docId } public var docId: String public let docType: String @@ -72,7 +72,7 @@ extension Array where Element == DocElementsViewModel { } } -public struct ElementViewModel: Identifiable { +public struct ElementViewModel: Identifiable, Sendable { public var id: String { "\(nameSpace)_\(elementIdentifier)" } public let nameSpace: String public let elementIdentifier: String diff --git a/Sources/EudiWalletKit/ViewModels/InternalssuanceModels.swift b/Sources/EudiWalletKit/ViewModels/InternalssuanceModels.swift index 39161d2..3c6b818 100644 --- a/Sources/EudiWalletKit/ViewModels/InternalssuanceModels.swift +++ b/Sources/EudiWalletKit/ViewModels/InternalssuanceModels.swift @@ -15,10 +15,10 @@ limitations under the License. */ import Foundation -import OpenID4VCI +@preconcurrency import OpenID4VCI import WalletStorage -struct DeferredIssuanceModel: Codable { +struct DeferredIssuanceModel: Codable, Sendable { let deferredCredentialEndpoint: CredentialIssuerEndpoint let accessToken: IssuanceAccessToken let refreshToken: IssuanceRefreshToken? diff --git a/Sources/EudiWalletKit/ViewModels/OfferedIssuanceModel.swift b/Sources/EudiWalletKit/ViewModels/OfferedIssuanceModel.swift index 23b4ee5..b2b1853 100644 --- a/Sources/EudiWalletKit/ViewModels/OfferedIssuanceModel.swift +++ b/Sources/EudiWalletKit/ViewModels/OfferedIssuanceModel.swift @@ -15,12 +15,12 @@ limitations under the License. */ import Foundation -import OpenID4VCI +@preconcurrency import OpenID4VCI /// Offered issue model contains information gathered by resolving an issue offer URL. /// /// This information is returned from ``EudiWallet/resolveOfferUrlDocTypes(uriOffer:format:useSecureEnclave:)`` -public struct OfferedIssuanceModel { +public struct OfferedIssuanceModel: Sendable { /// Issuer name (currently the URL) public let issuerName: String /// Document types included in the offer @@ -32,7 +32,7 @@ public struct OfferedIssuanceModel { } /// Information about an offered document type -public struct OfferedDocModel { +public struct OfferedDocModel: Sendable { /// Document type public let docType: String /// Display name for document type