diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 065709d..9af38a5 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -1,26 +1,24 @@ -# This workflow will build a Swift project -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift +name: Swift Build -name: Swift - -on: [push] +on: + push: + branches-ignore: + - 'dependabot/*' + pull_request_target: + workflow_dispatch: jobs: build: - runs-on: macos-latest-xlarge steps: - uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: latest - - uses: swift-actions/setup-swift@v1 + xcode-version: latest-stable + - uses: swift-actions/setup-swift@v2 - name: Get swift version run: swift --version - - uses: actions/checkout@v3 - - name: Fix Up Private GitHub URLs - # Add personal access token to all private repo URLs - run: find . -type f \( -name 'Package.swift' -o -name 'Package.resolved' \) -exec sed -i '' "s/https:\/\/github.com\/eu-digital-identity-wallet/https:\/\/${{ secrets.USER_NAME }}:${{ secrets.USER_GITHUB_TOKEN }}@github.com\/eu-digital-identity-wallet/g" {} \; + - uses: actions/checkout@v4 - name: Build run: swift build - name: Run tests diff --git a/Sources/WalletStorage/DataStorageService.swift b/Sources/WalletStorage/DataStorageService.swift index 21c466b..7678038 100644 --- a/Sources/WalletStorage/DataStorageService.swift +++ b/Sources/WalletStorage/DataStorageService.swift @@ -20,10 +20,9 @@ import Foundation public protocol DataStorageService { var serviceName: String { get set } var accessGroup: String? { get set } - func loadDocument(id: String) throws -> Document? - func loadDocuments() throws -> [Document]? + func loadDocument(id: String, status: DocumentStatus) throws -> Document? + func loadDocuments(status: DocumentStatus) throws -> [Document]? func saveDocument(_ document: Document, allowOverwrite: Bool) throws - func saveDocumentData(_ document: Document, dataToSaveType: SavedKeyChainDataType, dataType: String, allowOverwrite: Bool) throws - func deleteDocument(id: String) throws - func deleteDocuments() throws + func deleteDocument(id: String, status: DocumentStatus) throws + func deleteDocuments(status: DocumentStatus) throws } diff --git a/Sources/WalletStorage/Document.swift b/Sources/WalletStorage/Document.swift index de304c1..5c6a19f 100644 --- a/Sources/WalletStorage/Document.swift +++ b/Sources/WalletStorage/Document.swift @@ -19,7 +19,7 @@ import MdocDataModel18013 /// wallet document structure public struct Document { - public init(id: String = UUID().uuidString, docType: String, docDataType: DocDataType, data: Data, privateKeyType: PrivateKeyType?, privateKey: Data?, createdAt: Date?, modifiedAt: Date? = nil) { + public init(id: String = UUID().uuidString, docType: String, docDataType: DocDataType, data: Data, privateKeyType: PrivateKeyType?, privateKey: Data?, createdAt: Date?, modifiedAt: Date? = nil, status: DocumentStatus) { self.id = id self.docType = docType self.docDataType = docDataType @@ -28,6 +28,7 @@ public struct Document { self.privateKey = privateKey self.createdAt = createdAt ?? Date() self.modifiedAt = modifiedAt + self.status = status } public var id: String = UUID().uuidString @@ -38,6 +39,8 @@ public struct Document { public let privateKey: Data? public let createdAt: Date public let modifiedAt: Date? + public let status: DocumentStatus + public var isDeferred: Bool { status == .deferred } /// get CBOR data and private key from document public func getCborData() -> (iss: (String, IssuerSigned), dpk: (String, CoseKeyPrivate))? { @@ -51,8 +54,6 @@ public struct Document { return ((id, iss), (id, dpk)) case .sjwt: fatalError("Format \(docDataType) not implemented") - case .deferred: - return nil } } } diff --git a/Sources/WalletStorage/Enumerations.swift b/Sources/WalletStorage/Enumerations.swift index 0ed55de..362a5cf 100644 --- a/Sources/WalletStorage/Enumerations.swift +++ b/Sources/WalletStorage/Enumerations.swift @@ -17,21 +17,22 @@ limitations under the License. import Foundation /// type of data to save in storage -public enum SavedKeyChainDataType{ - case doc - case key +/// ``doc``: Document data +/// ``key``: Private-key +public enum SavedKeyChainDataType: String { + case doc = "sdoc" + case key = "skey" } /// Format of document data /// ``cbor``: DeviceResponse cbor encoded /// ``sjwt``: sd-jwt ** not yet supported ** /// ``signupResponseJson``: DeviceResponse and PrivateKey json serialized -/// ``deferred``: Deferred issuance -public enum DocDataType: String { +/// ``deferred``: Deferred issuance data +public enum DocDataType: String { case cbor = "cbor" case sjwt = "sjwt" case signupResponseJson = "srjs" - case deferred = "defr" } /// Format of private key @@ -47,3 +48,8 @@ public enum PrivateKeyType: String { } +/// document status +public enum DocumentStatus: String { + case issued + case deferred +} diff --git a/Sources/WalletStorage/IssueRequest.swift b/Sources/WalletStorage/IssueRequest.swift index 880f43e..d9e144f 100644 --- a/Sources/WalletStorage/IssueRequest.swift +++ b/Sources/WalletStorage/IssueRequest.swift @@ -53,14 +53,14 @@ public struct IssueRequest { } } - public func saveToStorage(_ storageService: any DataStorageService) throws { + public func saveToStorage(_ storageService: any DataStorageService, status: DocumentStatus) throws { // save key data to storage with id - let docKey = Document(id: id, docType: docType ?? "P256", docDataType: .cbor, data: Data(), privateKeyType: privateKeyType, privateKey: keyData, createdAt: Date()) + let docKey = Document(id: id, docType: docType ?? "P256", docDataType: .cbor, data: Data(), privateKeyType: privateKeyType, privateKey: keyData, createdAt: Date(), status: status) try storageService.saveDocument(docKey, allowOverwrite: true) } - public init?(_ storageService: any DataStorageService, id: String) throws { - guard let doc = try storageService.loadDocument(id: id), let pk = doc.privateKey, let pkt = doc.privateKeyType else { return nil } + public init?(_ storageService: any DataStorageService, id: String, status: DocumentStatus) throws { + guard let doc = try storageService.loadDocument(id: id, status: status), let pk = doc.privateKey, let pkt = doc.privateKeyType else { return nil } self.id = id keyData = pk privateKeyType = pkt diff --git a/Sources/WalletStorage/KeyChainStorageService.swift b/Sources/WalletStorage/KeyChainStorageService.swift index 8a04f0e..84e3e6f 100644 --- a/Sources/WalletStorage/KeyChainStorageService.swift +++ b/Sources/WalletStorage/KeyChainStorageService.swift @@ -16,7 +16,10 @@ import Foundation /// Implements key-chain storage +/// Documents are saved as a pair of generic password items (document data and private key) +/// For implementation details see [Apple documentation](https://developer.apple.com/documentation/security/ksecclassgenericpassword) public class KeyChainStorageService: DataStorageService { + public init(serviceName: String, accessGroup: String? = nil) { self.serviceName = serviceName self.accessGroup = accessGroup @@ -24,39 +27,34 @@ public class KeyChainStorageService: DataStorageService { public var serviceName: String public var accessGroup: String? - + /// Gets the secret document by id passed in parameter /// - Parameter id: Document identifier /// - Returns: The document if exists - public func loadDocument(id: String) throws -> Document? { - guard let dict1 = try loadDocumentData(id: id, for: .doc) else { return nil } - let dict2 = try loadDocumentData(id: id, for: .key) - return makeDocument(dict1: dict1, dict2: dict2) + public func loadDocument(id: String, status: DocumentStatus) throws -> Document? { + try loadDocuments(id: id, status: status)?.first } - - func loadDocumentData(id: String, for type: SavedKeyChainDataType) throws -> NSDictionary? { - let query = makeQuery(id: id, for: type, bAll: false) - var result: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &result) - if status == errSecItemNotFound { return nil } - let statusMessage = SecCopyErrorMessageString(status, nil) as? String - guard status == errSecSuccess else { - throw StorageError(description: statusMessage ?? "", code: Int(status)) - } - return (result as! NSDictionary) + public func loadDocuments(status: DocumentStatus) throws -> [Document]? { + try loadDocuments(id: nil, status: status) } + // use is-negative to denote type of data + static func isDocumentDataRow(_ d: [String: Any]) -> Bool { if let b = d[kSecAttrIsNegative as String] as? Bool { !b } else { true } } + static func isPrivateKeyRow(_ d: [String: Any]) -> Bool { if let b = d[kSecAttrIsNegative as String] as? Bool { b } else { false } } + /// Gets all documents /// - Parameters: /// - Returns: The documents stored in keychain under the serviceName - public func loadDocuments() throws -> [Document]? { - guard let dicts1 = try loadDocumentsData(for: .doc) else { return nil } - let dicts2 = try loadDocumentsData(for: .key) - let documents = dicts1.compactMap { d1 in makeDocument(dict1: d1, dict2: dicts2?.first(where: { d2 in d1[kSecAttrAccount] as! String == d2[kSecAttrAccount] as! String})) } + func loadDocuments(id: String?, status: DocumentStatus) throws -> [Document]? { + guard var dicts1 = try loadDocumentsData(id: id, docStatus: status) else { return nil } + let dicts2 = dicts1.filter(Self.isPrivateKeyRow) + dicts1 = dicts1.filter(Self.isDocumentDataRow) + let documents = dicts1.compactMap { d1 in Self.makeDocument(dict1: d1, dict2: dicts2.first(where: { d2 in d1[kSecAttrAccount as String] as! String == d2[kSecAttrAccount as String] as! String}), status: status) } return documents } - func loadDocumentsData(for type: SavedKeyChainDataType) throws -> [NSDictionary]? { - let query = makeQuery(id: nil, for: type, bAll: true) + func loadDocumentsData(id: String?, docStatus: DocumentStatus, dataToLoadType: SavedKeyChainDataType = .doc, bCompatOldVersion: Bool = false) throws -> [[String: Any]]? { + var query = makeQuery(id: id, bForSave: false, status: docStatus, dataType: dataToLoadType) + if bCompatOldVersion { query[kSecAttrService as String] = if dataToLoadType == .doc { serviceName } else { serviceName + "_key" } } // to be removed in version 1 var result: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &result) if status == errSecItemNotFound { return nil } @@ -64,9 +62,16 @@ public class KeyChainStorageService: DataStorageService { guard status == errSecSuccess else { throw StorageError(description: statusMessage ?? "", code: Int(status)) } - return (result as! [NSDictionary]) + var res = result as! [[String: Any]] + if !bCompatOldVersion, dataToLoadType == .doc { + if let dicts2 = try loadDocumentsData(id: id, docStatus: docStatus, dataToLoadType: .key, bCompatOldVersion: bCompatOldVersion) { res.append(contentsOf: dicts2) } + } + // following lines to be removed in version 1 + if !bCompatOldVersion, dataToLoadType == .doc { if let dicts1 = try loadDocumentsData(id: id, docStatus: docStatus, dataToLoadType: .doc, bCompatOldVersion: true) { res.append(contentsOf: dicts1) } } + if !bCompatOldVersion, dataToLoadType == .key { if let dicts2 = try loadDocumentsData(id: id, docStatus: docStatus, dataToLoadType: .key, bCompatOldVersion: true) {dicts2.forEach { d in var d2 = d; d2[kSecAttrIsNegative as String] = true; res.append(d2) } } } + return res } - + /// Save the secret to keychain /// Note: the value passed in will be zeroed out after the secret is saved /// - Parameters: @@ -78,26 +83,45 @@ public class KeyChainStorageService: DataStorageService { } } - func serviceToSave(for dataToSaveType: SavedKeyChainDataType) -> String { - switch dataToSaveType { case .key: serviceName + "_key"; default: serviceName } + /// Make a query for a an item in keychain + /// - Parameters: + /// - id: id + /// - bAll: request all matching items + /// - Returns: The dictionary query + func makeQuery(id: String?, bForSave: Bool, status: DocumentStatus, dataType: SavedKeyChainDataType) -> [String: Any] { + let comps = [serviceName, dataType.rawValue, status.rawValue ] + let queryValue = comps.joined(separator: ":") + var query: [String: Any] = [kSecClass: kSecClassGenericPassword, kSecAttrService: queryValue] as [String: Any] + if !bForSave { + query[kSecReturnData as String] = true + query[kSecReturnAttributes as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitAll + } + if let id { query[kSecAttrAccount as String] = id} + if let accessGroup, !accessGroup.isEmpty { query[kSecAttrAccessGroup as String] = accessGroup } + return query } - + + static func getIsNegativeValueToUse(_ dataToSaveType: SavedKeyChainDataType) -> Bool { switch dataToSaveType { case .key: true; default: false } } + public func saveDocumentData(_ document: Document, dataToSaveType: SavedKeyChainDataType, dataType: String, allowOverwrite: Bool = true) throws { - // kSecAttrAccount is used to store the secret Id so that we can look it up later - // kSecAttrService is always set to serviceName to enable us to lookup all our secrets later if needed + // kSecAttrAccount is used to store the secret Id (we save the document ID) + // kSecAttrService is a key whose value is a string indicating the item's service. guard dataType.count == 4 else { throw StorageError(description: "Invalid type") } if dataToSaveType == .key && document.privateKey == nil { throw StorageError(description: "Private key not available") } - var query: [String: Any] = [kSecClass: kSecClassGenericPassword, kSecAttrService: serviceToSave(for: dataToSaveType), kSecAttrAccount: document.id] as [String: Any] + var query: [String: Any] = makeQuery(id: document.id, bForSave: true, status: document.status, dataType: dataToSaveType) #if os(macOS) query[kSecUseDataProtectionKeychain as String] = true #endif query[kSecValueData as String] = switch dataToSaveType { case .key: document.privateKey!; default: document.data } + // use this attribute to differentiate between document and key data + query[kSecAttrIsNegative as String] = Self.getIsNegativeValueToUse(dataToSaveType) query[kSecAttrLabel as String] = document.docType query[kSecAttrType as String] = dataType var status = SecItemAdd(query as CFDictionary, nil) if allowOverwrite && status == errSecDuplicateItem { - let updated = [kSecValueData: query[kSecValueData as String] as! Data, kSecAttrLabel: document.docType, kSecAttrType: dataType] as [String: Any] - query = [kSecClass: kSecClassGenericPassword, kSecAttrService: serviceToSave(for: dataToSaveType), kSecAttrAccount: document.id] as [String: Any] + let updated: [String: Any] = [kSecValueData: query[kSecValueData as String] as! Data, kSecAttrIsNegative: Self.getIsNegativeValueToUse(dataToSaveType), kSecAttrLabel: document.docType, kSecAttrType: dataType] as [String: Any] + query = makeQuery(id: document.id, bForSave: true, status: document.status, dataType: dataToSaveType) status = SecItemUpdate(query as CFDictionary, updated as CFDictionary) } let statusMessage = SecCopyErrorMessageString(status, nil) as? String @@ -110,63 +134,36 @@ public class KeyChainStorageService: DataStorageService { /// Note: the value passed in will be zeroed out after the secret is deleted /// - Parameters: /// - id: The Id of the secret - public func deleteDocument(id: String) throws { - try deleteDocumentData(id: id, for: .doc) - try? deleteDocumentData(id: id, for: .key) + public func deleteDocument(id: String, status: DocumentStatus) throws { + try deleteDocumentData(id: id, docStatus: status) } - public func deleteDocumentData(id: String, for saveType: SavedKeyChainDataType) throws { - let query: [String: Any] = [kSecClass: kSecClassGenericPassword, kSecAttrService: serviceToSave(for: saveType), kSecAttrAccount: id] as [String: Any] + public func deleteDocumentData(id: String?, docStatus: DocumentStatus, dataType: SavedKeyChainDataType = .doc) throws { + let query: [String: Any] = makeQuery(id: id, bForSave: true, status: docStatus, dataType: dataType) let status = SecItemDelete(query as CFDictionary) let statusMessage = SecCopyErrorMessageString(status, nil) as? String - guard status == errSecSuccess else { throw StorageError(description: statusMessage ?? "", code: Int(status)) } + guard status == errSecSuccess else { throw StorageError(description: statusMessage ?? "", code: Int(status)) } + if dataType == .doc { try deleteDocumentData(id: id, docStatus: docStatus, dataType: .key) } } /// Delete all documents from keychain /// - Parameters: /// - id: The Id of the secret - public func deleteDocuments() throws { - try deleteDocumentsData(for: .doc) - try? deleteDocumentsData(for: .key) - } - - public func deleteDocumentsData(for saveType: SavedKeyChainDataType) throws { - // kSecAttrAccount is used to store the secret Id so that we can look it up later - // kSecAttrService is always set to serviceName to enable us to lookup all our secrets later if needed - // kSecAttrType is used to store the secret type to allow us to cast it to the right Type on search - let query: [String: Any] = [kSecClass: kSecClassGenericPassword, kSecAttrService: serviceToSave(for: saveType)] as [String: Any] - let status = SecItemDelete(query as CFDictionary) - let statusMessage = SecCopyErrorMessageString(status, nil) as? String - guard status == errSecSuccess else { - throw StorageError(description: statusMessage ?? "", code: Int(status)) - } - } - - /// Make a query for a an item in keychain - /// - Parameters: - /// - id: id - /// - bAll: request all matching items - /// - Returns: The dictionary query - func makeQuery(id: String?, for saveType: SavedKeyChainDataType, bAll: Bool) -> [String: Any] { - guard id != nil || bAll else { fatalError("Invalid call to makeQuery") } - var query: [String: Any] = [kSecClass: kSecClassGenericPassword, kSecAttrService: serviceToSave(for: saveType), kSecReturnData: true, kSecReturnAttributes: true] as [String: Any] - if bAll { query[kSecMatchLimit as String] = kSecMatchLimitAll} - if let id { query[kSecAttrAccount as String] = id} - if let accessGroup, !accessGroup.isEmpty { query[kSecAttrAccessGroup as String] = accessGroup } - return query + public func deleteDocuments(status: DocumentStatus) throws { + try deleteDocumentData(id: nil, docStatus: status) } /// Make a document from a keychain item /// - Parameter dict: keychain item returned as dictionary /// - Returns: the document - func makeDocument(dict1: NSDictionary, dict2: NSDictionary?) -> Document { - var data = dict1[kSecValueData] as! Data + static func makeDocument(dict1: [String: Any], dict2: [String: Any]?, status: DocumentStatus) -> Document { + var data = dict1[kSecValueData as String] as! Data defer { let c = data.count; data.withUnsafeMutableBytes { memset_s($0.baseAddress, c, 0, c); return } } var keyType: PrivateKeyType? = nil; var privateKeyData: Data? = nil if let dict2 { - keyType = PrivateKeyType(rawValue: dict2[kSecAttrType] as? String ?? PrivateKeyType.derEncodedP256.rawValue)! - privateKeyData = (dict2[kSecValueData] as! Data) + keyType = PrivateKeyType(rawValue: dict2[kSecAttrType as String] as? String ?? PrivateKeyType.derEncodedP256.rawValue)! + privateKeyData = (dict2[kSecValueData as String] as! Data) } - return Document(id: dict1[kSecAttrAccount] as! String, docType: dict1[kSecAttrLabel] as? String ?? "", docDataType: DocDataType(rawValue: dict1[kSecAttrType] as? String ?? DocDataType.cbor.rawValue)!, data: data, privateKeyType: keyType, privateKey: privateKeyData, createdAt: (dict1[kSecAttrCreationDate] as! Date), modifiedAt: dict1[kSecAttrModificationDate] as? Date) + return Document(id: dict1[kSecAttrAccount as String] as! String, docType: dict1[kSecAttrLabel as String] as? String ?? "", docDataType: DocDataType(rawValue: dict1[kSecAttrType as String] as? String ?? DocDataType.cbor.rawValue) ?? DocDataType.cbor, data: data, privateKeyType: keyType, privateKey: privateKeyData, createdAt: (dict1[kSecAttrCreationDate as String] as! Date), modifiedAt: dict1[kSecAttrModificationDate as String] as? Date, status: status) } }