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)
}
}
}
1 change: 1 addition & 0 deletions Sources/Web5/Common/ISO8601Date.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import AnyCodable
import Foundation

/// Wrapper used to easily encode a `Date` to and decode a `Date` from an ISO 8601 formatted date string.
Expand Down
7 changes: 7 additions & 0 deletions Sources/Web5/Common/OneOrMany.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ public enum OneOrMany<T: Codable & Equatable>: Codable, Equatable {
return nil
}
}
/*
{
thing: "thing",
"grab": {
"thing" : "thing"
}
*/

public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
Expand Down
220 changes: 220 additions & 0 deletions Sources/Web5/Credentials/PresentationExchange.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
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]
}

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 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 {
print("this is testing it reaches this line")
kirahsapong marked this conversation as resolved.
Show resolved Hide resolved
throw Error.missingCredentials(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 missingCredentials(Int, Int)

public var errorDescription: String? {
switch self {
case .missingCredentialObject:
return "Failed to find Verifiable Credential object in parsed JWT"
case .missingCredentials(let totalNeeded, let actualReceived):
kirahsapong marked this conversation as resolved.
Show resolved Hide resolved
return """
Missing input descriptors: The presentation definition requires
\(totalNeeded) descriptors, but only
\(actualReceived) were found. Check and provide the missing descriptors.
"""
}
}
}
}
61 changes: 61 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,64 @@ 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 _ = String(parts[2])
kirahsapong marked this conversation as resolved.
Show resolved Hide resolved

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 let _ = jwtHeader.keyID 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)"
}
}
}
}
Loading
Loading