diff --git a/Package.swift b/Package.swift index 77a4678..62dcc09 100644 --- a/Package.swift +++ b/Package.swift @@ -21,6 +21,8 @@ let package = Package( .package(url: "https://github.com/allegro/swift-junit.git", from: "2.1.0"), .package(url: "https://github.com/flight-school/anycodable.git", from: "0.6.7"), .package(url: "https://github.com/Bouke/DNS.git", from: "1.2.0"), + .package(url: "https://github.com/KittyMac/Sextant.git", exact: "0.4.0"), + .package(url: "https://github.com/kylef/JSONSchema.swift.git", from: "0.6.0") ], targets: [ .target( @@ -30,6 +32,8 @@ let package = Package( .product(name: "ExtrasBase64", package: "swift-extras-base64"), .product(name: "AnyCodable", package: "anycodable"), .product(name: "DNS", package: "DNS"), + .product(name: "Sextant", package: "sextant"), + .product(name: "JSONSchema", package: "jsonschema.swift") ] ), .testTarget( diff --git a/Sources/Web5/Common/FlatMap.swift b/Sources/Web5/Common/FlatMap.swift new file mode 100644 index 0000000..21ed483 --- /dev/null +++ b/Sources/Web5/Common/FlatMap.swift @@ -0,0 +1,29 @@ +import AnyCodable +import Foundation + +/// Wrapper used to easily encode a `[String: AnyCodable]` to and decode a `[String: AnyCodable]` from a flat map. +@propertyWrapper +struct FlatMap: Codable { + var wrappedValue: [String: AnyCodable]? + + init(wrappedValue: [String: AnyCodable]?) { + self.wrappedValue = wrappedValue + } + + init(from decoder: Decoder) throws { + let value = try decoder.singleValueContainer() + if let mapValue = try? value.decode([String: AnyCodable].self) { + wrappedValue = mapValue + } else { + throw DecodingError.typeMismatch(Date.self, DecodingError.Context(codingPath: [], debugDescription: "TODO")) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + if let wrappedValue { + try container.encode(wrappedValue) + } + } +} diff --git a/Sources/Web5/Credentials/PresentationExchange.swift b/Sources/Web5/Credentials/PresentationExchange.swift new file mode 100644 index 0000000..c2cf75b --- /dev/null +++ b/Sources/Web5/Credentials/PresentationExchange.swift @@ -0,0 +1,223 @@ +import AnyCodable +import Foundation +import Sextant +import JSONSchema + +public struct VCDataModel: Codable { + public let context: [String] + public let id: String + public let type: [String] + public let issuer: String + public let issuanceDate: String + public let expirationDate: String? + public let credentialSubject: [String: AnyCodable] + public let credentialStatus: CredentialStatus? + public let credentialSchema: CredentialSchema? + public let evidence: [String: AnyCodable]? + + enum CodingKeys: String, CodingKey { + case context = "@context" + case id + case type + case issuer + case issuanceDate + case expirationDate + case credentialSubject + case credentialStatus + case credentialSchema + case evidence + } +} + +public struct CredentialStatus: Codable { + public let id: String + public let type: String + public let statusPurpose: String + public let statusListIndex: String + public let statusListCredential: String +} + +public struct CredentialSchema: Codable { + public let id: String + public let type: String +} + +public struct PresentationDefinitionV2: Codable { + public let inputDescriptors: [InputDescriptorV2] + + enum CodingKeys: String, CodingKey { + case inputDescriptors = "input_descriptors" + } + +} + +public struct InputDescriptorV2: Codable, Hashable { + public let constraints: ConstraintsV2 +} + +public struct ConstraintsV2: Codable, Hashable { + public let fields: [FieldV2]? +} + +public struct FieldV2: Codable, Hashable { + public let id: String? + public let path: [String] + public let purpose: String? + public let filter: [String: AnyCodable]? + public let predicate: Optionality? + public let name: String? + public let optional: Bool? + + public init( + id: String? = nil, + path: [String], + purpose: String? = nil, + filter: [String: AnyCodable]? = nil, + predicate: Optionality? = nil, + name: String? = nil, + optional: Bool? = nil + ) { + self.id = id + self.path = path + self.purpose = purpose + self.filter = filter + self.predicate = predicate + self.name = name + self.optional = optional + } +} + +public enum Optionality: Codable { + case required + case preferred +} + +public enum PresentationExchange { + + // MARK: - Select Credentials + public static func selectCredentials( + vcJWTs: [String], + presentationDefinition: PresentationDefinitionV2 + ) throws -> [String] { + let inputDescriptorToVcMap = try mapInputDescriptorsToVCs(vcJWTList: vcJWTs, presentationDefinition: presentationDefinition) + return Array(Set(inputDescriptorToVcMap.flatMap { $0.value })) + } + + // MARK: - Satisfies Presentation Definition + public static func satisfiesPresentationDefinition( + vcJWTs: [String], + presentationDefinition: PresentationDefinitionV2 + ) throws -> Void { + let inputDescriptorToVcMap = try selectCredentials(vcJWTs: vcJWTs, presentationDefinition: presentationDefinition) + guard inputDescriptorToVcMap.count == presentationDefinition.inputDescriptors.count else { + throw Error.missingDescriptors(presentationDefinition.inputDescriptors.count, inputDescriptorToVcMap.count) + } + } + + // MARK: - Map Input Descriptors to VCs + private static func mapInputDescriptorsToVCs( + vcJWTList: [String], + presentationDefinition: PresentationDefinitionV2 + ) throws -> [InputDescriptorV2: [String]] { + let vcJWTListMap: [VCDataModel] = try vcJWTList.map { vcJWT in + let parsedJWT = try JWT.parse(jwtString: vcJWT) + guard let vcJSON = parsedJWT.payload["vc"]?.value as? [String: Any] else { + throw Error.missingCredentialObject + } + + let vcData = try JSONSerialization.data(withJSONObject: vcJSON) + let vc = try JSONDecoder().decode(VCDataModel.self, from: vcData) + + return vc + } + + let vcJwtListWithNodes = zip(vcJWTList, vcJWTListMap) + + let result = try presentationDefinition.inputDescriptors.reduce(into: [InputDescriptorV2: [String]]()) { result, inputDescriptor in + let vcJwtList = try vcJwtListWithNodes.filter { (_, node) in + try vcSatisfiesInputDescriptor(vc: node, inputDescriptor: inputDescriptor) + }.map { (vcJwt, _) in + vcJwt + } + if !vcJwtList.isEmpty { + result[inputDescriptor] = vcJwtList + } + } + return result + } + + // MARK: - VC Satisfies Input Descriptor + private static func vcSatisfiesInputDescriptor(vc: VCDataModel, inputDescriptor: InputDescriptorV2) throws -> Bool { + // If the Input Descriptor has constraints and fields defined, evaluate them. + guard let fields = inputDescriptor.constraints.fields else { + // If no fields are defined, VC satisfies + return true + } + + let requiredFields = fields.filter { !($0.optional ?? false) } + + for field in requiredFields { + + // Takes field.path and queries the vc to see if there is a corresponding path. + let vcJson = try JSONEncoder().encode(vc) + guard let matchedPathValues = vcJson.query(values: field.path) else { return false } + + if matchedPathValues.isEmpty { + // If no matching fields are found for a required field, the VC does not satisfy this Input Descriptor. + return false + } + + // If there is a filter, process it + if let filter = field.filter { + let fieldName = field.path[0] + + var filterProperties: [String: [String: String]] = [:] + + for (filterKey, filterValue) in filter { + guard let filterRule = filterValue.value as? String else { + return false + } + + filterProperties[fieldName, default: [:]][filterKey] = filterRule + } + + let satisfiesSchemaMatches = try matchedPathValues.filter { value in + let result = try JSONSchema.validate([fieldName: value], schema: [ + "properties": filterProperties + ]) + return result.valid + } + + if satisfiesSchemaMatches.isEmpty { + // If the field value does not satisfy the schema, the VC does not satisfy this Input Descriptor. + return false + } + } + } + + // If the VC passes all the checks, it satisfies the criteria of the Input Descriptor. + return true + } + +} + +// MARK: - Errors +extension PresentationExchange { + public enum Error: LocalizedError { + case missingCredentialObject + case missingDescriptors(Int, Int) + + public var errorDescription: String? { + switch self { + case .missingCredentialObject: + return "Failed to find Verifiable Credential object in parsed JWT" + case .missingDescriptors(let totalNeeded, let actualReceived): + return """ + Missing input descriptors: The presentation definition requires + \(totalNeeded) descriptors, but only + \(actualReceived) were found. Check and provide the missing descriptors. + """ + } + } + } +} diff --git a/Sources/Web5/Crypto/JOSE/JWT.swift b/Sources/Web5/Crypto/JOSE/JWT.swift index b1d9d3a..57f4efa 100644 --- a/Sources/Web5/Crypto/JOSE/JWT.swift +++ b/Sources/Web5/Crypto/JOSE/JWT.swift @@ -1,4 +1,5 @@ import Foundation +import AnyCodable public struct JWT { @@ -90,4 +91,63 @@ public struct JWT { ) ) } + + public struct ParsedJWT { + let header: JWS.Header + let payload: [String: AnyCodable] + + public init( + header: JWS.Header, + payload: [String: AnyCodable] + ) { + self.header = header + self.payload = payload + } + } + + public static func parse(jwtString: String) throws -> ParsedJWT { + let parts = jwtString.components(separatedBy: ".") + + guard parts.count == 3 else { + throw Error.verificationFailed("Malformed JWT. Expected 3 parts. Got \(parts.count)") + } + + let base64urlEncodedJwtHeader = String(parts[0]) + let base64urlEncodedJwtPayload = String(parts[1]) + + let jwtHeader: JWS.Header = try JSONDecoder().decode( + JWS.Header.self, + from: try base64urlEncodedJwtHeader.decodeBase64Url() + ) + + guard jwtHeader.type == "JWT" else { + throw Error.verificationFailed("Expected JWT header to contain typ property set to JWT") + } + + guard jwtHeader.keyID != nil else { + throw Error.verificationFailed("Expected JWT header to contain kid") + } + + let jwtPayload = try JSONDecoder().decode( + [String: AnyCodable].self, + from: base64urlEncodedJwtPayload.decodeBase64Url() + ) + + return ParsedJWT(header: jwtHeader, payload: jwtPayload) + } +} + +// MARK: - Errors + +extension JWT { + public enum Error: LocalizedError { + case verificationFailed(String) + + public var errorDescription: String? { + switch self { + case let .verificationFailed(reason): + return "Verification Failed: \(reason)" + } + } + } } diff --git a/Tests/Web5TestVectors/Web5TestVectorsPresentationExchange.swift b/Tests/Web5TestVectors/Web5TestVectorsPresentationExchange.swift new file mode 100644 index 0000000..abffde0 --- /dev/null +++ b/Tests/Web5TestVectors/Web5TestVectorsPresentationExchange.swift @@ -0,0 +1,56 @@ +import CustomDump +import Mocker +import XCTest + +@testable import Web5 + +final class Web5TestVectorsPresentationExchange: XCTestCase { + + func test_resolve() throws { + struct Input: Codable { + let presentationDefinition: PresentationDefinitionV2 + let credentialJwts: [String] + let mockServer: [String: [String: String]]? + + func mocks() throws -> [Mock] { + guard let mockServer = mockServer else { return [] } + + return try mockServer.map({ key, value in + return Mock( + url: URL(string: key)!, + contentType: .json, + statusCode: 200, + data: [ + .get: try JSONEncoder().encode(value) + ] + ) + }) + } + } + + struct Output: Codable { + let selectedCredentials: [String] + } + + let testVector = try TestVector( + fileName: "select_credentials", + subdirectory: "test-vectors/presentation_exchange" + ) + + testVector.run { vector in + let expectation = XCTestExpectation(description: "async resolve") + Task { + /// Register each of the mock network responses + try vector.input.mocks().forEach { $0.register() } + + /// Resolve each input didURI, make sure it matches output + let result = try PresentationExchange.selectCredentials(vcJWTs: vector.input.credentialJwts, presentationDefinition: vector.input.presentationDefinition) + XCTAssertEqual(result.sorted(), vector.output!.selectedCredentials.sorted()) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1) + } + } + +} diff --git a/Tests/Web5Tests/Credentials/PresentationExchangeTests.swift b/Tests/Web5Tests/Credentials/PresentationExchangeTests.swift new file mode 100644 index 0000000..3fda4c4 --- /dev/null +++ b/Tests/Web5Tests/Credentials/PresentationExchangeTests.swift @@ -0,0 +1,94 @@ +import XCTest + +@testable import Web5 + +class PresentationExchangeTests: XCTestCase { + + let vcJwt = """ + eyJraWQiOiJkaWQ6a2V5OnpRM3NoTkx0MWFNV1BiV1JHYThWb2VFYkpvZko3eEplNEZDUHBES3hxMU5aeWdwaXkjelEzc2hOTHQxYU1XUGJXUkdhOFZvZUViSm9mSjd4SmU0RkNQcERLeHExTlp5Z3BpeSIsInR5cCI6IkpXVCIsImFsZyI6IkVTMjU2SyJ9.eyJpc3MiOiJkaWQ6a2V5OnpRM3NoTkx0MWFNV1BiV1JHYThWb2VFYkpvZko3eEplNEZDUHBES3hxMU5aeWdwaXkiLCJzdWIiOiJkaWQ6a2V5OnpRM3Noa3BhdmpLUmV3b0JrNmFyUEpuaEE4N1p6aExERVdnVnZaS05ISzZRcVZKREIiLCJpYXQiOjE3MDEzMDI1OTMsInZjIjp7Imlzc3VhbmNlRGF0ZSI6IjIwMjMtMTEtMzBUMDA6MDM6MTNaIiwiY3JlZGVudGlhbFN1YmplY3QiOnsiaWQiOiJkaWQ6a2V5OnpRM3Noa3BhdmpLUmV3b0JrNmFyUEpuaEE4N1p6aExERVdnVnZaS05ISzZRcVZKREIiLCJsb2NhbFJlc3BlY3QiOiJoaWdoIiwibGVnaXQiOnRydWV9LCJpZCI6InVybjp1dWlkOjZjOGJiY2Y0LTg3YWYtNDQ5YS05YmZiLTMwYmYyOTk3NjIyNyIsInR5cGUiOlsiVmVyaWZpYWJsZUNyZWRlbnRpYWwiLCJTdHJlZXRDcmVkIl0sIkBjb250ZXh0IjpbImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIl0sImlzc3VlciI6ImRpZDprZXk6elEzc2hOTHQxYU1XUGJXUkdhOFZvZUViSm9mSjd4SmU0RkNQcERLeHExTlp5Z3BpeSJ9fQ.qoqF4-FinFsQ2J-NFSO46xCE8kUTZqZCU5fYr6tS0TQ6VP8y-ZnyR6R3oAqLs_Yo_CqQi23yi38uDjLjksiD2w + """ + + let vcJwt2 = """ + eyJraWQiOiJkaWQ6andrOmV5SnJkSGtpT2lKRlF5SXNJblZ6WlNJNkluTnBaeUlzSW1OeWRpSTZJbk5sWTNBeU5UWnJNU0lzSW10cFpDSTZJazVDWDNGc1ZVbHlNRFl0UVdsclZsWk5SbkpsY1RCc1l5MXZiVkYwZW1NMmJIZG9hR04yWjA4MmNqUWlMQ0o0SWpvaVJHUjBUamhYTm5oZk16UndRbDl1YTNoU01HVXhkRzFFYTA1dWMwcGxkWE5DUVVWUWVrdFhaMlpmV1NJc0lua2lPaUoxTTFjeE16VnBibTlrVEhGMFkwVmlPV3BPUjFNelNuTk5YM1ZHUzIxclNsTmlPRlJ5WXpsc2RWZEpJaXdpWVd4bklqb2lSVk15TlRaTEluMCMwIiwidHlwIjoiSldUIiwiYWxnIjoiRVMyNTZLIn0.eyJpc3MiOiJkaWQ6andrOmV5SnJkSGtpT2lKRlF5SXNJblZ6WlNJNkluTnBaeUlzSW1OeWRpSTZJbk5sWTNBeU5UWnJNU0lzSW10cFpDSTZJazVDWDNGc1ZVbHlNRFl0UVdsclZsWk5SbkpsY1RCc1l5MXZiVkYwZW1NMmJIZG9hR04yWjA4MmNqUWlMQ0o0SWpvaVJHUjBUamhYTm5oZk16UndRbDl1YTNoU01HVXhkRzFFYTA1dWMwcGxkWE5DUVVWUWVrdFhaMlpmV1NJc0lua2lPaUoxTTFjeE16VnBibTlrVEhGMFkwVmlPV3BPUjFNelNuTk5YM1ZHUzIxclNsTmlPRlJ5WXpsc2RWZEpJaXdpWVd4bklqb2lSVk15TlRaTEluMCIsInN1YiI6ImRpZDprZXk6elEzc2hrcGF2aktSZXdvQms2YXJQSm5oQTg3WnpoTERFV2dWdlpLTkhLNlFxVkpEQiIsImlhdCI6MTcwMTMwMjU5MywidmMiOnsiaXNzdWFuY2VEYXRlIjoiMjAyMy0xMS0zMFQwMDowMzoxM1oiLCJjcmVkZW50aWFsU3ViamVjdCI6eyJpZCI6ImRpZDprZXk6elEzc2hrcGF2aktSZXdvQms2YXJQSm5oQTg3WnpoTERFV2dWdlpLTkhLNlFxVkpEQiIsImxvY2FsUmVzcGVjdCI6ImhpZ2giLCJsZWdpdCI6dHJ1ZX0sImlkIjoidXJuOnV1aWQ6NmM4YmJjZjQtODdhZi00NDlhLTliZmItMzBiZjI5OTc2MjI3IiwidHlwZSI6WyJWZXJpZmlhYmxlQ3JlZGVudGlhbCIsIlN0cmVldENyZWQiXSwiQGNvbnRleHQiOlsiaHR0cHM6Ly93d3cudzMub3JnLzIwMTgvY3JlZGVudGlhbHMvdjEiXSwiaXNzdWVyIjoiZGlkOmp3azpleUpyZEhraU9pSkZReUlzSW5WelpTSTZJbk5wWnlJc0ltTnlkaUk2SW5ObFkzQXlOVFpyTVNJc0ltdHBaQ0k2SWs1Q1gzRnNWVWx5TURZdFFXbHJWbFpOUm5KbGNUQnNZeTF2YlZGMGVtTTJiSGRvYUdOMlowODJjalFpTENKNElqb2lSR1IwVGpoWE5uaGZNelJ3UWw5dWEzaFNNR1V4ZEcxRWEwNXVjMHBsZFhOQ1FVVlFla3RYWjJaZldTSXNJbmtpT2lKMU0xY3hNelZwYm05a1RIRjBZMFZpT1dwT1IxTXpTbk5OWDNWR1MyMXJTbE5pT0ZSeVl6bHNkVmRKSWl3aVlXeG5Jam9pUlZNeU5UWkxJbjAifX0.8AehkiboIK6SZy6LHC9ugy_OcT2VsjluzH4qzsgjfTtq9fEsGyY-cOW_xekNUa2RE2VzlP6FXk0gDn4xf6_r4g + """ + + let inputDescriptor = InputDescriptorV2( + constraints: ConstraintsV2( + fields: [ + FieldV2( + path: ["$.credentialSubject.legit"], + filter: ["type":"boolean"] + ), + FieldV2( + path: ["$.credentialSubject.localRespect"], + filter: ["type":"string", "const": "high"] + ), + FieldV2( + path: ["$.issuer"], + filter: ["type":"string", "const": "did:key:zQ3shNLt1aMWPbWRGa8VoeEbJofJ7xJe4FCPpDKxq1NZygpiy"] + ) + ] + ) + ) + + let inputDescriptor2 = InputDescriptorV2( + constraints: ConstraintsV2( + fields: [ + FieldV2( + path: ["$.credentialSubject.legit"], + filter: ["type":"boolean"] + ), + FieldV2( + path: ["$.credentialSubject.localStreet"], + filter: ["type":"string", "const": "low"] + ), + ] + ) + ) + + let inputDescriptor3 = InputDescriptorV2( + constraints: ConstraintsV2( + fields: [ + FieldV2( + path: ["$.credentialSubject.legit"], + filter: ["type":"boolean"] + ), + FieldV2( + path: ["$.credentialSubject.localRespect"], + filter: ["type":"string", "const": "high"] + ), + ] + ) + ) + + //select_credentials - web5-spec select_credentials -> Web5TestVectors folder + + func test_select_oneOfTwoCorrectCredentials() throws { + let pd = PresentationDefinitionV2( + inputDescriptors: [ + inputDescriptor, inputDescriptor2 + ] + ) + let result = try PresentationExchange.selectCredentials(vcJWTs: [vcJwt, vcJwt2], presentationDefinition: pd) + + XCTAssertEqual(result, [vcJwt]) + } + + func test_throw_zeroCorrectCredentials() throws { + let pd = PresentationDefinitionV2( + inputDescriptors: [ + inputDescriptor, inputDescriptor2 + ] + ) + XCTAssertThrowsError(try PresentationExchange.satisfiesPresentationDefinition(vcJWTs: [vcJwt], presentationDefinition: pd)) + } + + func test_select_twoOfTwoCorrectCredentials() throws { + let pd = PresentationDefinitionV2( + inputDescriptors: [ + inputDescriptor3 + ] + ) + XCTAssertThrowsError(try PresentationExchange.satisfiesPresentationDefinition(vcJWTs: [vcJwt, vcJwt2], presentationDefinition: pd)) + } +}