Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add PresentationExchange.selectCredentials #25

Merged
merged 15 commits into from
Mar 18, 2024
4 changes: 4 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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(
Expand Down
29 changes: 29 additions & 0 deletions Sources/Web5/Common/FlatMap.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
223 changes: 223 additions & 0 deletions Sources/Web5/Credentials/PresentationExchange.swift
Original file line number Diff line number Diff line change
@@ -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]] = [:]
kirahsapong marked this conversation as resolved.
Show resolved Hide resolved

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.
"""
}
}
}
}
60 changes: 60 additions & 0 deletions Sources/Web5/Crypto/JOSE/JWT.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import AnyCodable

public struct JWT {

Expand Down Expand Up @@ -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 {
Copy link
Collaborator

@nitro-neal nitro-neal Mar 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just fyi as parse is getting renamed in 'decode' I think (this is how it is in go):

lots of new decoding logic here for making sure jwt is valid vcjwt
decentralized-identity/web5-js#421

and when we get to the verify part of the vcJwt the plan is to split up verify into lots of parts with verify options - decentralized-identity/web5-js#425

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i will add in a follow up PR. tracked here: #28

thanks!

cc: @jiyoontbd

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)"
}
}
}
}
56 changes: 56 additions & 0 deletions Tests/Web5TestVectors/Web5TestVectorsPresentationExchange.swift
Original file line number Diff line number Diff line change
@@ -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<Input, Output>(
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)
}
}

}
Loading
Loading