diff --git a/Sources/Entities/AccessManagement/IdentityAndAccessManagementMetadata.swift b/Sources/Entities/AccessManagement/IdentityAndAccessManagementMetadata.swift index 7d5f8194..d03d46ac 100644 --- a/Sources/Entities/AccessManagement/IdentityAndAccessManagementMetadata.swift +++ b/Sources/Entities/AccessManagement/IdentityAndAccessManagementMetadata.swift @@ -60,4 +60,13 @@ public enum IdentityAndAccessManagementMetadata { return URL(string: metaData.authorizationEndpoint ?? "") } } + + var tokenEndpointAuthMethods: [String] { + switch self { + case .oidc(let metaData): + return metaData.tokenEndpointAuthMethodsSupported ?? [] + case .oauth(let metaData): + return metaData.tokenEndpointAuthMethodsSupported ?? [] + } + } } diff --git a/Sources/Entities/JOSE/SignatureAlgorithm+Extentions.swift b/Sources/Entities/JOSE/SignatureAlgorithm+Extentions.swift new file mode 100644 index 00000000..a03365ea --- /dev/null +++ b/Sources/Entities/JOSE/SignatureAlgorithm+Extentions.swift @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2023 European Commission + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import Foundation +import JOSESwift + +public extension SignatureAlgorithm { + /// Returns `true` if the algorithm is NOT a MAC-based algorithm (HMAC). + var isNotMacAlgorithm: Bool { + switch self { + case .HS256, .HS384, .HS512: + return false // These are HMAC algorithms + default: + return true // All other algorithms are not MAC-based + } + } +} + diff --git a/Sources/Entities/Types/JWTClaimNames.swift b/Sources/Entities/Types/JWTClaimNames.swift index fe4150c5..3670f105 100644 --- a/Sources/Entities/Types/JWTClaimNames.swift +++ b/Sources/Entities/Types/JWTClaimNames.swift @@ -49,4 +49,5 @@ public extension JWTClaimNames { static let type = "typ" static let algorithm = "alg" static let JWK = "jwk" + static let cnf = "cnf" } diff --git a/Sources/Entities/Wallet/Config.swift b/Sources/Entities/Wallet/Config.swift index 7f04a86f..c6999982 100644 --- a/Sources/Entities/Wallet/Config.swift +++ b/Sources/Entities/Wallet/Config.swift @@ -24,21 +24,28 @@ public typealias ClientId = String public typealias ClientSecret = String public struct OpenId4VCIConfig { - public let clientId: ClientId + public let client: Client public let authFlowRedirectionURI: URL public let authorizeIssuanceConfig: AuthorizeIssuanceConfig public let usePAR: Bool + public let dPoPConstructor: DPoPConstructorType? + public let clientAttestationPoPBuilder: ClientAttestationPoPBuilder? + public init( - clientId: ClientId, + client: Client, authFlowRedirectionURI: URL, authorizeIssuanceConfig: AuthorizeIssuanceConfig = .favorScopes, - usePAR: Bool = true + usePAR: Bool = true, + dPoPConstructor: DPoPConstructorType? = nil, + clientAttestationPoPBuilder: ClientAttestationPoPBuilder? = nil ) { - self.clientId = clientId + self.client = client self.authFlowRedirectionURI = authFlowRedirectionURI self.authorizeIssuanceConfig = authorizeIssuanceConfig self.usePAR = usePAR + self.dPoPConstructor = dPoPConstructor + self.clientAttestationPoPBuilder = clientAttestationPoPBuilder } } diff --git a/Sources/Extensions/Dictionary+Extensions.swift b/Sources/Extensions/Dictionary+Extensions.swift index feacca89..762074b2 100644 --- a/Sources/Extensions/Dictionary+Extensions.swift +++ b/Sources/Extensions/Dictionary+Extensions.swift @@ -16,6 +16,10 @@ import Foundation import SwiftyJSON +public func + (lhs: [String: String], rhs: [String: String]) -> [String: String] { + return lhs.merging(rhs) { (current, new) in new } +} + public extension Dictionary where Key == String, Value == Any { func toThrowingJSONData() throws -> Data { diff --git a/Sources/Issuers/Issuer.swift b/Sources/Issuers/Issuer.swift index 1becb61c..35f4c47c 100644 --- a/Sources/Issuers/Issuer.swift +++ b/Sources/Issuers/Issuer.swift @@ -25,7 +25,7 @@ public protocol IssuerType { func authorizeWithPreAuthorizationCode( credentialOffer: CredentialOffer, authorizationCode: IssuanceAuthorization, - clientId: String, + client: Client, transactionCode: String?, authorizationDetailsInTokenRequest: AuthorizationDetailsInTokenRequest ) async -> Result @@ -109,8 +109,12 @@ public actor Issuer: IssuerType { dpopConstructor: dpopConstructor ) + try config.client.ensureSupportedByAuthorizationServer( + self.authorizationServerMetadata + ) + issuanceRequester = IssuanceRequester( - issuerMetadata: issuerMetadata, + issuerMetadata: issuerMetadata, poster: requesterPoster, dpopConstructor: dpopConstructor ) @@ -132,24 +136,24 @@ public actor Issuer: IssuerType { let credentials = credentialOffer.credentialConfigurationIdentifiers let issuerState: String? = switch credentialOffer.grants { case .authorizationCode(let code), - .both(let code, _): + .both(let code, _): code.issuerState default: nil } let (scopes, credentialConfogurationIdentifiers) = try scopesAndCredentialConfigurationIds(credentialOffer: credentialOffer) - + let authorizationServerSupportsPar = credentialOffer.authorizationServerMetadata.authorizationServerSupportsPar && config.usePAR - + let state = StateValue().value - + if authorizationServerSupportsPar { do { let resource: String? = issuerMetadata.authorizationServers.map { _ in credentialOffer.credentialIssuerIdentifier.url.absoluteString } - + let result: ( verifier: PKCEVerifier, code: GetAuthorizationCodeURL, @@ -205,14 +209,14 @@ public actor Issuer: IssuerType { } catch { return .failure(ValidationError.error(reason: error.localizedDescription)) } - + } } public func authorizeWithPreAuthorizationCode( credentialOffer: CredentialOffer, authorizationCode: IssuanceAuthorization, - clientId: String, + client: Client, transactionCode: String?, authorizationDetailsInTokenRequest: AuthorizationDetailsInTokenRequest = .doNotInclude ) async -> Result { @@ -221,13 +225,13 @@ public actor Issuer: IssuerType { case .preAuthorizationCode(let authorisation, let txCode): do { if let transactionCode, let txCode { - if txCode.length != transactionCode.count { - throw ValidationError.error(reason: "Expected transaction code length is \(txCode.length ?? 0) but code of length \(transactionCode.count) passed") - } - - if txCode.inputMode != .numeric { - throw ValidationError.error(reason: "Issuers expects transaction code to be numeric but is not.") - } + if txCode.length != transactionCode.count { + throw ValidationError.error(reason: "Expected transaction code length is \(txCode.length ?? 0) but code of length \(transactionCode.count) passed") + } + + if txCode.inputMode != .numeric { + throw ValidationError.error(reason: "Issuers expects transaction code to be numeric but is not.") + } } let credConfigIdsAsAuthDetails: [CredentialConfigurationIdentifier] = switch authorizationDetailsInTokenRequest { @@ -238,7 +242,7 @@ public actor Issuer: IssuerType { let response = try await authorizer.requestAccessTokenPreAuthFlow( preAuthorizedCode: authorisation, txCode: txCode, - clientId: clientId, + client: client, transactionCode: transactionCode, identifiers: credConfigIdsAsAuthDetails, dpopNonce: nil, @@ -254,14 +258,14 @@ public actor Issuer: IssuerType { .proofRequired( accessToken: try .init( accessToken: accessToken.accessToken, - tokenType: accessToken.tokenType, + tokenType: accessToken.tokenType, expiresIn: expiresIn?.asTimeInterval ?? .zero ), refreshToken: try .init( refreshToken: refreshToken.refreshToken ), cNonce: cNonce, - credentialIdentifiers: identifiers, + credentialIdentifiers: identifiers, timeStamp: Date().timeIntervalSinceReferenceDate, dPopNonce: dPopNonce ) @@ -627,7 +631,7 @@ private extension Issuer { credentialConfigurationIdentifier: CredentialConfigurationIdentifier, responseEncryptionSpecProvider: (_ issuerResponseEncryptionMetadata: CredentialResponseEncryption) -> IssuanceResponseEncryptionSpec? ) async throws -> Result { - + guard let supportedCredential = issuerMetadata .credentialsSupported[credentialConfigurationIdentifier] else { throw ValidationError.error(reason: "Invalid Supported credential for requestSingle") @@ -647,7 +651,7 @@ private extension Issuer { ) { return try supportedCredential.toIssuanceRequest( requester: issuanceRequester, - claimSet: claimSet, + claimSet: claimSet, proofs: proofs, responseEncryptionSpecProvider: responseEncryptionSpecProvider ) @@ -732,9 +736,9 @@ public extension Issuer { try Issuer( authorizationServerMetadata: .oauth( .init( - authorizationEndpoint: Constants.url, - tokenEndpoint: Constants.url, - pushedAuthorizationRequestEndpoint: Constants.url + authorizationEndpoint: Constants.url, + tokenEndpoint: Constants.url, + pushedAuthorizationRequestEndpoint: Constants.url ) ), issuerMetadata: .init( @@ -744,7 +748,7 @@ public extension Issuer { deferredRequesterPoster: deferredRequesterPoster ) } - + static func createResponseEncryptionSpec(_ issuerResponseEncryptionMetadata: CredentialResponseEncryption) -> IssuanceResponseEncryptionSpec? { switch issuerResponseEncryptionMetadata { case .notRequired: @@ -885,3 +889,24 @@ public extension Issuer { return .success(authorizedRequest) } } + +internal extension Client { + + private static let ATTEST_JWT_CLIENT_AUTH = "attest_jwt_client_auth" + + func ensureSupportedByAuthorizationServer(_ authorizationServerMetadata: IdentityAndAccessManagementMetadata) throws { + + let tokenEndpointAuthMethods = authorizationServerMetadata.tokenEndpointAuthMethods + + switch self { + case .attested: + let expectedMethod = Self.ATTEST_JWT_CLIENT_AUTH + + guard tokenEndpointAuthMethods.contains(expectedMethod) else { + throw ValidationError.error(reason:("\(Self.ATTEST_JWT_CLIENT_AUTH) not supported by authorization server")) + } + default: + break + } + } +} diff --git a/Sources/Main/AttestationBasedClient/Client.swift b/Sources/Main/AttestationBasedClient/Client.swift new file mode 100644 index 00000000..2aa13fad --- /dev/null +++ b/Sources/Main/AttestationBasedClient/Client.swift @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2023 European Commission + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import Foundation +import JOSESwift + +public enum Client { + + /// Represents a Public client + case `public`(id: ClientId) + + /// Represents an Attested client + case attested(attestationJWT: ClientAttestationJWT, popJwtSpec: ClientAttestationPoPJWTSpec) + + // Computed property for 'id' (common property for both cases) + public var id: ClientId { + switch self { + case .public(let id): + return id + case .attested(let attestationJWT, _): + return attestationJWT.clientId + } + } + + // MARK: - Validation + public init(public id: ClientId) { + self = .public(id: id) + } + + public init(attestationJWT: ClientAttestationJWT, popJwtSpec: ClientAttestationPoPJWTSpec) throws { + // Validate clientId + let clientId = attestationJWT.clientId + + guard !clientId.isEmpty && !clientId.trimmingCharacters(in: .whitespaces).isEmpty else { + throw ClientAttestationError.invalidClientId + } + + // Validate public key + guard (attestationJWT.pubKey?.isPublicKey ?? false) else { + throw ClientAttestationError.missingJwkClaim + } + + self = .attested(attestationJWT: attestationJWT, popJwtSpec: popJwtSpec) + } +} + +extension JWK { + /// Determines if the JWK is a private key + var isPrivateKey: Bool { + switch self { + case let rsaKey as RSAPrivateKey: + return !rsaKey.privateExponent.isEmpty + case let ecKey as ECPrivateKey: + return !ecKey.privateKey.isEmpty + default: + return false + } + } + + /// Determines if the JWK is a public key + var isPublicKey: Bool { + return !isPrivateKey + } +} + diff --git a/Sources/Main/AttestationBasedClient/ClientAttestation.swift b/Sources/Main/AttestationBasedClient/ClientAttestation.swift new file mode 100644 index 00000000..fdf3f1ce --- /dev/null +++ b/Sources/Main/AttestationBasedClient/ClientAttestation.swift @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2023 European Commission + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import Foundation +import JOSESwift +import SwiftyJSON + +public struct ClientAttestationJWT { + public let jws: JWS + private let payload: JSON + + public init(jws: JWS) throws { + self.jws = jws + + guard jws.header.algorithm != nil else { + throw ClientAttestationError.notSigned + } + + let payloadData = jws.payload.data() + guard let jsonObject = try JSONSerialization.jsonObject( + with: payloadData, + options: [] + ) as? [String: Any] else { + throw ClientAttestationError.invalidPayload + } + self.payload = JSON(jsonObject) + + /* + guard payload[JWTClaimNames.subject].string != nil else { + throw ClientAttestationError.missingSubject + } + */ + + guard let cnf = payload[JWTClaimNames.cnf].dictionary else { + throw ClientAttestationError.missingCnfClaim + } + + guard cnf[JWTClaimNames.JWK] != nil else { + throw ClientAttestationError.missingJwkClaim + } + + guard payload[JWTClaimNames.expirationTime].number != nil else { + throw ClientAttestationError.missingExpirationClaim + } + } + + public var clientId: ClientId { + return payload[JWTClaimNames.subject].string ?? "" + } + + public var cnf: JSON { + return JSON(payload[JWTClaimNames.cnf]) + } + + public var pubKey: JWK? { + if let rsa = try? RSAPublicKey(data: cnf[JWTClaimNames.JWK].rawData()) { + return rsa + } else if let ec = try? ECPublicKey(data: cnf[JWTClaimNames.JWK].rawData()) { + return ec + } + return nil + } +} diff --git a/Sources/Main/AttestationBasedClient/ClientAttestationError.swift b/Sources/Main/AttestationBasedClient/ClientAttestationError.swift new file mode 100644 index 00000000..d7227fc8 --- /dev/null +++ b/Sources/Main/AttestationBasedClient/ClientAttestationError.swift @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2023 European Commission + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import Foundation + +enum ClientAttestationError: Error, LocalizedError { + case notSigned + case invalidPayload + case invalidClientId + case missingSubject + case missingCnfClaim + case missingJwkClaim + case missingExpirationClaim + case missingIssuerClaim + case missingJtiClaim + case missingAudienceClaim + case invalidClient + + var errorDescription: String? { + switch self { + case .notSigned: return "Invalid Attestation JWT. Not properly signed." + case .invalidPayload: return "Invalid Attestation JWT. Cannot parse payload." + case .missingSubject: return "Invalid Attestation JWT. Missing subject claim." + case .missingCnfClaim: return "Invalid Attestation JWT. Missing cnf claim." + case .missingJwkClaim: return "Invalid Attestation JWT. Missing jwk claim from cnf." + case .missingExpirationClaim: return "Invalid Attestation JWT. Missing exp claim." + case .missingIssuerClaim: return "Invalid Attestation JWT. Missing issuer claim." + case .missingJtiClaim: return "Invalid Attestation JWT. Missing jti claim." + case .missingAudienceClaim: return "Invalid Attestation JWT. Missing aud claim." + case .invalidClientId: return "Invalid client ID" + case .invalidClient: return "Invalid Client" + } + } +} + diff --git a/Sources/Main/AttestationBasedClient/ClientAttestationPoPBuilder.swift b/Sources/Main/AttestationBasedClient/ClientAttestationPoPBuilder.swift new file mode 100644 index 00000000..271d002c --- /dev/null +++ b/Sources/Main/AttestationBasedClient/ClientAttestationPoPBuilder.swift @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2023 European Commission + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import Foundation +import JOSESwift +import SwiftyJSON + +public protocol ClockType { + func now() -> Date +} + +public struct Clock: ClockType { + public init() {} + public func now() -> Date { + return Date() + } +} + +public protocol ClientAttestationPoPBuilder { + /// Builds a PoP JWT + /// - Parameters: + /// - client: The `Attested` client for which to create the PoP JWT. + /// - clock: Wallet's clock. + /// - authServerId: The issuer claim of the OAUTH2/OIDC server. + /// - Returns: A `ClientAttestationPoPJWT`. + func buildAttestationPoPJWT( + for client: Client, + clock: ClockType, + authServerId: URL + ) throws -> ClientAttestationPoPJWT +} + +public struct DefaultClientAttestationPoPBuilder: ClientAttestationPoPBuilder { + public func buildAttestationPoPJWT( + for client: Client, + clock: ClockType, + authServerId: URL + ) throws -> ClientAttestationPoPJWT { + + switch client { + case .attested(let attestationJWT, let popJwtSpec): + let now = Date() + let exp = now.addingTimeInterval(popJwtSpec.duration) + let jws = try JWS.init( + header: try .init(parameters: [ + JWTClaimNames.algorithm: popJwtSpec.signingAlgorithm.rawValue, + JWTClaimNames.type: popJwtSpec.typ + ]), + payload: .init(JSON([ + JWTClaimNames.issuer: attestationJWT.clientId, + JWTClaimNames.jwtId: UUID().uuidString, + JWTClaimNames.expirationTime: exp, + JWTClaimNames.issuedAt: now, + JWTClaimNames.audience: authServerId.absoluteString + ]).rawData()), + signer: popJwtSpec.jwsSigner + ) + return try .init(jws: jws) + default: + throw ClientAttestationError.invalidClient + } + } +} + +public extension DefaultClientAttestationPoPBuilder { + /// Default builder instance + static var `default`: ClientAttestationPoPBuilder { + return DefaultClientAttestationPoPBuilder() + } +} diff --git a/Sources/Main/AttestationBasedClient/ClientAttestationPoPJWT.swift b/Sources/Main/AttestationBasedClient/ClientAttestationPoPJWT.swift new file mode 100644 index 00000000..f6c4964b --- /dev/null +++ b/Sources/Main/AttestationBasedClient/ClientAttestationPoPJWT.swift @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2023 European Commission + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import Foundation +import JOSESwift +import SwiftyJSON + +public struct ClientAttestationPoPJWT { + public let jws: JWS + private let payload: JSON + + public init(jws: JWS) throws { + self.jws = jws + + let payloadData = jws.payload.data() + guard let jsonObject = try JSONSerialization.jsonObject( + with: payloadData, + options: [] + ) as? [String: Any] else { + throw ClientAttestationError.invalidPayload + } + self.payload = JSON(jsonObject) + + guard payload[JWTClaimNames.issuer].string != nil else { + throw ClientAttestationError.missingIssuerClaim + } + + guard payload[JWTClaimNames.expirationTime].number != nil else { + throw ClientAttestationError.missingExpirationClaim + } + + guard payload[JWTClaimNames.jwtId].string != nil else { + throw ClientAttestationError.missingExpirationClaim + } + + guard payload[JWTClaimNames.audience].string != nil else { + throw ClientAttestationError.missingExpirationClaim + } + } +} + + + diff --git a/Sources/Main/AttestationBasedClient/ClientAttestationPoPJWTSpec.swift b/Sources/Main/AttestationBasedClient/ClientAttestationPoPJWTSpec.swift new file mode 100644 index 00000000..6fc90647 --- /dev/null +++ b/Sources/Main/AttestationBasedClient/ClientAttestationPoPJWTSpec.swift @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2023 European Commission + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import Foundation +import JOSESwift + +enum ClientAttestationPoPJWTSpecError: Error, LocalizedError { + case invalidAlgorithm + case nonPositiveDuration + + var errorDescription: String? { + switch self { + case .invalidAlgorithm: + return "Signing algorithm cannot be a MAC algorithm." + case .nonPositiveDuration: + return "JWT duration must be positive." + } + } +} + +public struct ClientAttestationPoPJWTSpec { + // MARK: - Properties + + public let signingAlgorithm: SignatureAlgorithm + public let duration: TimeInterval + public let typ: String? + public let jwsSigner: Signer + + // MARK: - Initializer + + public init( + signingAlgorithm: SignatureAlgorithm, + duration: TimeInterval = 300, // Default to 5 minutes + typ: String? = nil, + jwsSigner: Signer + ) throws { + // Validate the signing algorithm (must not be MAC) + try Self.requireIsNotMAC(signingAlgorithm) + + // Validate that the duration is positive + guard duration > 0 else { + throw ClientAttestationPoPJWTSpecError.nonPositiveDuration + } + + self.signingAlgorithm = signingAlgorithm + self.duration = duration + self.typ = typ + self.jwsSigner = jwsSigner + } + + // MARK: - Helper Functions + + /// Ensure that the algorithm is not a MAC algorithm + private static func requireIsNotMAC(_ algorithm: SignatureAlgorithm) throws { + let macAlgorithms: [SignatureAlgorithm] = [.HS256, .HS384, .HS512] // HMAC algorithms + if macAlgorithms.contains(algorithm) { + throw ClientAttestationPoPJWTSpecError.invalidAlgorithm + } + } +} + + + + diff --git a/Sources/Main/AttestationBasedClient/ClientAuthenticationMethod.swift b/Sources/Main/AttestationBasedClient/ClientAuthenticationMethod.swift new file mode 100644 index 00000000..27a11c67 --- /dev/null +++ b/Sources/Main/AttestationBasedClient/ClientAuthenticationMethod.swift @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2023 European Commission + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import Foundation + +public final class ClientAuthenticationMethod: Hashable { + // MARK: - Properties + public let value: String + + // MARK: - Static Constants + public static let clientSecretBasic = ClientAuthenticationMethod("client_secret_basic") + public static let clientSecretPost = ClientAuthenticationMethod("client_secret_post") + public static let clientSecretJWT = ClientAuthenticationMethod("client_secret_jwt") + public static let privateKeyJWT = ClientAuthenticationMethod("private_key_jwt") + public static let tlsClientAuth = ClientAuthenticationMethod("tls_client_auth") + public static let selfSignedTlsClientAuth = ClientAuthenticationMethod("self_signed_tls_client_auth") + public static let requestObject = ClientAuthenticationMethod("request_object") + public static let none = ClientAuthenticationMethod("none") + + // MARK: - Initializer + public init(_ value: String) { + self.value = value + } + + // MARK: - Default Method + public static func getDefault() -> ClientAuthenticationMethod { + return .clientSecretBasic + } + + // MARK: - Parsing Method + public static func parse(_ value: String) -> ClientAuthenticationMethod { + switch value.lowercased() { + case clientSecretBasic.value: + return .clientSecretBasic + case clientSecretPost.value: + return .clientSecretPost + case clientSecretJWT.value: + return .clientSecretJWT + case privateKeyJWT.value: + return .privateKeyJWT + case tlsClientAuth.value.lowercased(): + return .tlsClientAuth + case selfSignedTlsClientAuth.value.lowercased(): + return .selfSignedTlsClientAuth + case requestObject.value.lowercased(): + return .requestObject + case none.value: + return .none + default: + return ClientAuthenticationMethod(value) + } + } + + // MARK: - Hashable + public static func == (lhs: ClientAuthenticationMethod, rhs: ClientAuthenticationMethod) -> Bool { + return lhs.value == rhs.value + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(value) + } + + // MARK: - Custom Description + public var description: String { + return value + } +} + diff --git a/Sources/Main/Authorisers/AuthorizationServerClient.swift b/Sources/Main/Authorisers/AuthorizationServerClient.swift index 1c77a403..b4fd1942 100644 --- a/Sources/Main/Authorisers/AuthorizationServerClient.swift +++ b/Sources/Main/Authorisers/AuthorizationServerClient.swift @@ -65,7 +65,7 @@ public protocol AuthorizationServerClientType { func requestAccessTokenPreAuthFlow( preAuthorizedCode: String, txCode: TxCode?, - clientId: String, + client: Client, transactionCode: String?, identifiers: [CredentialConfigurationIdentifier], dpopNonce: Nonce?, @@ -103,7 +103,7 @@ public actor AuthorizationServerClient: AuthorizationServerClientType { public let authorizationEndpoint: URL public let tokenEndpoint: URL public let redirectionURI: URL - public let clientId: String + public let client: Client public let authorizationServerMetadata: IdentityAndAccessManagementMetadata public let credentialIssuerIdentifier: CredentialIssuerId public let dpopConstructor: DPoPConstructorType? @@ -130,7 +130,7 @@ public actor AuthorizationServerClient: AuthorizationServerClientType { self.credentialIssuerIdentifier = credentialIssuerIdentifier self.redirectionURI = config.authFlowRedirectionURI - self.clientId = ClientId(config.clientId) + self.client = config.client self.dpopConstructor = dpopConstructor @@ -195,7 +195,7 @@ public actor AuthorizationServerClient: AuthorizationServerClientType { let authzRequest = AuthorizationRequest( responseType: Self.responseType, - clientId: config.clientId, + clientId: config.client.id, redirectUri: config.authFlowRedirectionURI.absoluteString, scope: scopes.map { $0.value }.joined(separator: " ").appending(" ").appending(Constants.OPENID_SCOPE), credentialConfigurationIds: toAuthorizationDetail(credentialConfigurationIds: credentialConfigurationIdentifiers), @@ -240,7 +240,7 @@ public actor AuthorizationServerClient: AuthorizationServerClientType { let codeVerifier = PKCEGenerator.codeVerifier() ?? "" let authRequest = AuthorizationRequest( responseType: Self.responseType, - clientId: config.clientId, + clientId: config.client.id, redirectUri: config.authFlowRedirectionURI.absoluteString, scope: scopes.map { $0.value }.joined(separator: " "), credentialConfigurationIds: toAuthorizationDetail(credentialConfigurationIds: credentialConfigurationIdentifiers), @@ -256,13 +256,24 @@ public actor AuthorizationServerClient: AuthorizationServerClientType { throw ValidationError.error(reason: "Missing PAR endpoint") } + let clientAttestationHeaders = clientAttestationHeaders( + clientAttestation: try generateClientAttestationIfNeeded( + clock: Clock(), + authServerId: URL( + string: authorizationServerMetadata.issuer ?? "" + ) + ) + ) + + let tokenHeaders = try await tokenEndPointHeaders( + dpopNonce: dpopNonce + ) + let response: ResponseWithHeaders = try await service.formPost( poster: parPoster, url: parEndpoint, request: authRequest, - headers: tokenEndPointHeaders( - dpopNonce: dpopNonce - ) + headers: clientAttestationHeaders + tokenHeaders ) switch response.body { @@ -273,7 +284,7 @@ public actor AuthorizationServerClient: AuthorizationServerClientType { ) let queryParams = [ - GetAuthorizationCodeURL.PARAM_CLIENT_ID: config.clientId, + GetAuthorizationCodeURL.PARAM_CLIENT_ID: config.client.id, GetAuthorizationCodeURL.PARAM_REQUEST_STATE: state, GetAuthorizationCodeURL.PARAM_REQUEST_URI: requestURI ] @@ -340,18 +351,29 @@ public actor AuthorizationServerClient: AuthorizationServerClientType { let parameters: JSON = authCodeFlow( authorizationCode: authorizationCode, redirectionURI: redirectionURI, - clientId: clientId, + clientId: client.id, codeVerifier: codeVerifier, identifiers: identifiers ) do { + let clientAttestationHeaders = clientAttestationHeaders( + clientAttestation: try generateClientAttestationIfNeeded( + clock: Clock(), + authServerId: URL( + string: authorizationServerMetadata.issuer ?? "" + ) + ) + ) + + let tokenHeaders = try await tokenEndPointHeaders( + dpopNonce: dpopNonce + ) + let response: ResponseWithHeaders = try await service.formPost( poster: tokenPoster, url: tokenEndpoint, - headers: try tokenEndPointHeaders( - dpopNonce: dpopNonce - ), + headers: clientAttestationHeaders + tokenHeaders, parameters: parameters.toDictionary().convertToDictionaryOfStrings() ) @@ -502,7 +524,7 @@ public actor AuthorizationServerClient: AuthorizationServerClientType { public func requestAccessTokenPreAuthFlow( preAuthorizedCode: String, txCode: TxCode?, - clientId: String, + client: Client, transactionCode: String?, identifiers: [CredentialConfigurationIdentifier], dpopNonce: Nonce?, @@ -517,18 +539,29 @@ public actor AuthorizationServerClient: AuthorizationServerClientType { let parameters: JSON = try await preAuthCodeFlow( preAuthorizedCode: preAuthorizedCode, txCode: txCode, - clientId: clientId, + client: client, transactionCode: transactionCode, identifiers: identifiers ) do { + let clientAttestationHeaders = clientAttestationHeaders( + clientAttestation: try generateClientAttestationIfNeeded( + clock: Clock(), + authServerId: URL( + string: authorizationServerMetadata.issuer ?? "" + ) + ) + ) + + let tokenHeaders = try await tokenEndPointHeaders( + dpopNonce: dpopNonce + ) + let response: ResponseWithHeaders = try await service.formPost( poster: tokenPoster, url: tokenEndpoint, - headers: try tokenEndPointHeaders( - dpopNonce: dpopNonce - ), + headers: clientAttestationHeaders + tokenHeaders, parameters: parameters.toDictionary().convertToDictionaryOfStrings() ) @@ -567,7 +600,7 @@ public actor AuthorizationServerClient: AuthorizationServerClientType { return try await requestAccessTokenPreAuthFlow( preAuthorizedCode: preAuthorizedCode, txCode: txCode, - clientId: clientId, + client: client, transactionCode: transactionCode, identifiers: identifiers, dpopNonce: nonce, @@ -604,6 +637,43 @@ public actor AuthorizationServerClient: AuthorizationServerClientType { private extension AuthorizationServerClient { + func clientAttestationHeaders( + clientAttestation: (ClientAttestationJWT, ClientAttestationPoPJWT)? + ) -> [String: String] { + guard let clientAttestation = clientAttestation else { + return [:] + } + + return [ + "OAuth-Client-Attestation": clientAttestation.0.jws.compactSerializedString, + "OAuth-Client-Attestation-PoP": clientAttestation.1.jws.compactSerializedString + ] + } + + func generateClientAttestationIfNeeded( + clock: ClockType, + authServerId: URL? + ) throws -> (ClientAttestationJWT, ClientAttestationPoPJWT)? { + switch client { + case .public: + return nil + case .attested(let attestationJWT, _): + guard let clientAttestationPoPBuilder = config.clientAttestationPoPBuilder else { + return nil + } + + guard let authServerId = authServerId else { + return nil + } + let popJWT = try clientAttestationPoPBuilder.buildAttestationPoPJWT( + for: client, + clock: clock, + authServerId: authServerId + ) + return (attestationJWT, popJWT) + } + } + func tokenEndPointHeaders(dpopNonce: Nonce? = nil) async throws -> [String: String] { if let dpopConstructor { let jwt = try await dpopConstructor.jwt( @@ -645,12 +715,12 @@ private extension AuthorizationServerClient { func preAuthCodeFlow( preAuthorizedCode: String, txCode: TxCode?, - clientId: String, + client: Client, transactionCode: String?, identifiers: [CredentialConfigurationIdentifier] ) async throws -> JSON { var params: [String: String?] = [ - Constants.CLIENT_ID_PARAM: clientId, + Constants.CLIENT_ID_PARAM: client.id, Constants.GRANT_TYPE_PARAM: Constants.GRANT_TYPE_PARAM_VALUE, Constants.PRE_AUTHORIZED_CODE_PARAM: preAuthorizedCode ] @@ -712,7 +782,6 @@ extension Array where Element == AuthorizationDetail { } } } catch {} - return nil } } diff --git a/Tests/AttestationBased/AttestationBasedTests.swift b/Tests/AttestationBased/AttestationBasedTests.swift new file mode 100644 index 00000000..f1d5c351 --- /dev/null +++ b/Tests/AttestationBased/AttestationBasedTests.swift @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2023 European Commission + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import Foundation +import XCTest +import JOSESwift +import SwiftyJSON + +@testable import OpenID4VCI + +class AttestationBasedTests: XCTestCase { + + override func setUp() async throws { + try await super.setUp() + } + + override func tearDown() { + super.tearDown() + } + + func testClientAttestation() async throws { + + let clientAttestation = try ClientAttestationJWT( + jws: JWS( + compactSerialization: TestsConstants.CNF_JWT + ) + ) + + let jwk = clientAttestation.pubKey + XCTAssertNotNil(jwk) + } + + func testClientAttestationPopJwt() async throws { + + let clientAttestation = try? ClientAttestationPoPJWT( + jws: JWS( + compactSerialization: TestsConstants.CNF_JWT + ) + ) + + XCTAssertNotNil(clientAttestation) + } + + func testClient() async throws { + + let clientAttestationPop = try? ClientAttestationPoPJWT( + jws: JWS( + compactSerialization: TestsConstants.CNF_JWT + ) + ) + + XCTAssertNotNil(clientAttestationPop) + } + + func testClientAttestationJWT() async throws { + + let client = try selfSignedClient( + clientId: "wallet-dev", + privateKey: try! KeyController.generateECDHPrivateKey() + ) + + XCTAssertNotNil(client) + } +} diff --git a/Tests/Constants/TestsConstants.swift b/Tests/Constants/TestsConstants.swift index 6da786a3..b193b81e 100644 --- a/Tests/Constants/TestsConstants.swift +++ b/Tests/Constants/TestsConstants.swift @@ -77,8 +77,17 @@ let MDL_CredentialOffer = """ } """ -let config: OpenId4VCIConfig = .init( - clientId: "wallet-dev", +let clientConfig: OpenId4VCIConfig = .init( + client: .public(id: "wallet-dev"), + authFlowRedirectionURI: URL(string: "urn:ietf:wg:oauth:2.0:oob")!, + authorizeIssuanceConfig: .favorScopes +) + +let attestationConfig: OpenId4VCIConfig = .init( + client: try! selfSignedClient( + clientId: "wallet-dev", + privateKey: try KeyController.generateECDHPrivateKey() + ), authFlowRedirectionURI: URL(string: "urn:ietf:wg:oauth:2.0:oob")!, authorizeIssuanceConfig: .favorScopes ) @@ -273,6 +282,8 @@ struct TestsConstants { source: .fetchByReference(url: .stub()) ).get() } + + static let CNF_JWT = "eyJ4NWMiOlsiTUlJRExUQ0NBcktnQXdJQkFnSVVMOHM1VHM2MzVrNk9oclJGTWxzU1JBU1lvNll3Q2dZSUtvWkl6ajBFQXdJd1hERWVNQndHQTFVRUF3d1ZVRWxFSUVsemMzVmxjaUJEUVNBdElGVlVJREF4TVMwd0t3WURWUVFLRENSRlZVUkpJRmRoYkd4bGRDQlNaV1psY21WdVkyVWdTVzF3YkdWdFpXNTBZWFJwYjI0eEN6QUpCZ05WQkFZVEFsVlVNQjRYRFRJME1URXlPVEV4TWpnek5Wb1hEVEkyTVRFeU9URXhNamd6TkZvd2FURWRNQnNHQTFVRUF3d1VSVlZFU1NCU1pXMXZkR1VnVm1WeWFXWnBaWEl4RERBS0JnTlZCQVVUQXpBd01URXRNQ3NHQTFVRUNnd2tSVlZFU1NCWFlXeHNaWFFnVW1WbVpYSmxibU5sSUVsdGNHeGxiV1Z1ZEdGMGFXOXVNUXN3Q1FZRFZRUUdFd0pWVkRCWk1CTUdCeXFHU000OUFnRUdDQ3FHU000OUF3RUhBMElBQkFXYTlVYXI3b1AxWmJHRmJzRkE0ZzMxUHpOR1pjd2gydlI3UENrazBZaUFMNGNocnNsZzljajFrQnlueVppN25acllnUE9KN3gwYXRSRmRreGZYanRDamdnRkRNSUlCUHpBTUJnTlZIUk1CQWY4RUFqQUFNQjhHQTFVZEl3UVlNQmFBRkxOc3VKRVhITmVrR21ZeGgwTGhpOEJBekpVYk1DY0dBMVVkRVFRZ01CNkNIR1JsZGk1cGMzTjFaWEl0WW1GamEyVnVaQzVsZFdScGR5NWtaWFl3RWdZRFZSMGxCQXN3Q1FZSEtJR01YUVVCQmpCREJnTlZIUjhFUERBNk1EaWdOcUEwaGpKb2RIUndjem92TDNCeVpYQnliMlF1Y0d0cExtVjFaR2wzTG1SbGRpOWpjbXd2Y0dsa1gwTkJYMVZVWHpBeExtTnliREFkQmdOVkhRNEVGZ1FVOGVIQS9NWHZreUNGNFExaW91WFAwc3BpTVVnd0RnWURWUjBQQVFIL0JBUURBZ2VBTUYwR0ExVWRFZ1JXTUZTR1VtaDBkSEJ6T2k4dloybDBhSFZpTG1OdmJTOWxkUzFrYVdkcGRHRnNMV2xrWlc1MGFYUjVMWGRoYkd4bGRDOWhjbU5vYVhSbFkzUjFjbVV0WVc1a0xYSmxabVZ5Wlc1alpTMW1jbUZ0WlhkdmNtc3dDZ1lJS29aSXpqMEVBd0lEYVFBd1pnSXhBSmpLU0EzQTdrWU9CWXdKQ09PY3JjYVJDRGVWVGZjdllZQ1I4QWp5blVpMjVJL3Rrc0RDRkE1K21hQ0xmbWtVS1FJeEFPVmpHc2dsdVF3VE41MG85N1dtaWxIYmxXNE44K3FBcm1zQkM4alRJdXRuS2ZjNHlaM3U1UTF1WllJbGJ0S1NyZz09Il0sImtpZCI6IjI3Mjg1NDYwOTcyMTEyMDczMjkzODg2ODI5ODc5OTI0NTAzNDE3NDEwMjkzODUzNCIsInR5cCI6InZjK3NkLWp3dCIsImFsZyI6IkVTMjU2In0.eyJwbGFjZV9vZl9iaXJ0aCI6eyJfc2QiOlsiYldoMGNTdjdDbk9TZjlHczg1TDhPYkN0Y0hRUzRHVXEyYTBqSVdOUTl6byIsImtUbXhLMmFFU2YtbFNLOGpabjR3cDRtMUNsb0pVMmwyUm1ZWFlFR2tscm8iXX0sIl9zZCI6WyItSHZVbDhCQ2RxVHJjLTdyOU82dWdSMUdSSlR5cXdEeDVoVWxTX0M5X2pRIiwiMEgwWllkMm8xUzE4LWNyNnEzTFN6c01VNXpnbEt0RWxMWWNpZDRuTTBNRSIsIjF4YnQyal8zRm5XYmppNnNRbE1XaVV5Z1g0NWluZ2YzRmYzQV80NVdTQW8iLCJBaDZWemg5dExERTZKYkg0TENSdnJ4RV9BVFBMbWtRUlpJS0FtTVRSYktFIiwiRVU5REkzdXlLS3YtX3hRV3drUVVRWUdESVlkX2lGaVJMQ1JfY3lQcFhPOCIsIkY1TkEyT0J6SHFrR1c3RkhGYnZ1dUk5SWVfaVVlcFhMNU9OTWp5TkJpWUEiLCJHZmJrZlRvNDRjQy13aXg0UDFZVGdGX3VZUTMtQmduMkFadWJ1eDhvLXFRIiwiSkhINUFSd3RTZE44d3pncDh5M3NRQmg3SFNYQ2RuckZUa0xWbEhVVlV6USIsIkxwMHR2S1JPRGFhaVoyZjBPMy1SLUk5TjAwNnJ5MGozeExqdDN0MnhLX1EiLCJPTm12VVExYW5TNWtsY2M0QnFMU29oMXFYM0hrOUxESlltMFdCd3hVRDk0IiwiVlIzQXhGQjJqSVRMZUNFQmtvN2JyR3VfdkZPOFM1UHU3cnRBY0REbktPcyIsImNaemtWZ3lZMHJuYWp2X2xhSXM2UmZyaVJkUFozN25KWTJsdkdBa2dsQXciLCJsVmk2OGJNWnBVTF9xV0EweUNESGF1ZlA1dWlEUmpsZno2eXRNdUpBQk1BIiwic3JtSE9xWTE4ZFo1RnBNc3hQdUwxcGUwNEI1ZzcwMjRRUXlSQUZ4cjNWZyJdLCJhZGRyZXNzIjp7Il9zZCI6WyI2OWdNSVIxN1lqMk04YXEyTS1pOUdQLVVlVUpCQ2prLTNTQ2tqN19hSU00IiwiSG9rWEhrVWhuQWNLbFhyODJTb2YtZGdzMXVJemRDYVJQM2ltclJ2STZSTSIsIlRTMEZHR2dtZFE4ZGxOYXVFdEpQQ1MxSllCU3NjYjdVVmY4VXEwNlUwRnciLCJZWnBhaTlldGpNcTNMeHZiOHVBOGIyZUs3WnZYSUU3TWY2RTJoLTRKTUtvIiwiakRVYXVPYjh4NFVMV28xYnFjckw4VEN2bDR6YW9jMFJMVF85TnIyeVdIZyIsIm1mdWZpR2dWTmxXTEhSTFlnY0xIV3Z1VlQ3X2hjem1CMWFmemZ2WUlKNUUiXX0sInZjdCI6InVybjpldS5ldXJvcGEuZWMuZXVkaTpwaWQ6MSIsIl9zZF9hbGciOiJzaGEzLTI1NiIsImlzcyI6Imh0dHBzOi8vZGV2Lmlzc3Vlci1iYWNrZW5kLmV1ZGl3LmRldiIsImNuZiI6eyJqd2siOnsia3R5IjoiRUMiLCJ1c2UiOiJzaWciLCJjcnYiOiJQLTI1NiIsImtpZCI6IjAxMTU5MkZBLUY1MDEtNEM4OS04RTg3LTQ1NjJGMzNCNkZCRSIsIngiOiJ4OC1STzlNRFRZdG10U2RWV3dZcHc1SUZBYUhPaGRqLVhjeFhYV2RmWnhrIiwieSI6InVVSXB2TGJJWmFKQUFEVnp5U2VJQUxwcExLTzBhdDVfVFVLRTZiWGRUa3MiLCJhbGciOiJFUzI1NiJ9fSwiZXhwIjoxNzM3MDMzNzI4LCJqdGkiOiJpZCIsImF1ZCI6ImF1ZCIsImFnZV9lcXVhbF9vcl9vdmVyIjp7Il9zZCI6WyJxOGhfeE9vaUFxaFczOGhHeDV1ZWIxR1B1QnZFaUs0dUJ0ODUyRTREZUtFIl19fQ.pBDorCaD3rtuqYw6JJLCvxtNd1EAmnhLS2tUwBkJJYin5LdYLcDxbX8euAAcfVMUGio0-FOC2JDFlKSwn5ZQ9g" } class TestSinger: AsyncSignerProtocol { diff --git a/Tests/Helpers/SelfSignedClientAttestation.swift b/Tests/Helpers/SelfSignedClientAttestation.swift new file mode 100644 index 00000000..a9b44ce5 --- /dev/null +++ b/Tests/Helpers/SelfSignedClientAttestation.swift @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2023 European Commission + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import Foundation +import JOSESwift + +@testable import OpenID4VCI + +internal func selfSignedClient( + clientId: String, + algorithm: SignatureAlgorithm = .ES256, + privateKey: SecKey +) throws -> Client { + + guard isECPrivateKey(privateKey) else { + fatalError("Key shoulb be EC and private") + } + + guard algorithm.isNotMacAlgorithm else { + fatalError("MAC not supported") + } + + let header: JWSHeader = .init( + algorithm: algorithm + ) + + let payload: Payload = try! .init([ + "iss": clientId, + "clientId": clientId, + "sub": clientId, + "exp": 1800000000, + "cnf": [ + "jwk": ECPublicKey( + publicKey: try! KeyController.generateECDHPublicKey( + from: privateKey + ) + ).toDictionary() + ] + ].toThrowingJSONData()) + + let signer = Signer( + signatureAlgorithm: algorithm, + key: privateKey + )! + + return try .attested( + attestationJWT: .init( + jws: .init( + header: header, + payload: payload, + signer: signer + ) + ), + popJwtSpec: .init( + signingAlgorithm: algorithm, + jwsSigner: signer + ) + ) + + func isECPrivateKey(_ secKey: SecKey) -> Bool { + guard let attributes = SecKeyCopyAttributes(secKey) as? [CFString: Any] else { + return false + } + + let isPrivateKey = (attributes[kSecAttrKeyClass] as? String) == (kSecAttrKeyClassPrivate as String) + let isECKey = (attributes[kSecAttrKeyType] as? String) == (kSecAttrKeyTypeEC as String) + + return isPrivateKey && isECKey + } +} diff --git a/Tests/Helpers/Wallet.swift b/Tests/Helpers/Wallet.swift index f8a3a099..8e3b5c5e 100644 --- a/Tests/Helpers/Wallet.swift +++ b/Tests/Helpers/Wallet.swift @@ -20,18 +20,15 @@ import Foundation struct Wallet { let actingUser: ActingUser let bindingKeys: [BindingKey] - let dPoPConstructor: DPoPConstructorType? let session: Networking init( actingUser: ActingUser, bindingKeys: [BindingKey], - dPoPConstructor: DPoPConstructorType?, session: Networking = Self.walletSession ) { self.actingUser = actingUser self.bindingKeys = bindingKeys - self.dPoPConstructor = dPoPConstructor self.session = session } @@ -50,7 +47,8 @@ struct Wallet { extension Wallet { func issueByCredentialIdentifier( _ identifier: String, - claimSet: ClaimSet? = nil + claimSet: ClaimSet? = nil, + config: OpenId4VCIConfig ) async throws -> Credential { let credentialConfigurationIdentifier = try CredentialConfigurationIdentifier(value: identifier) let credentialIssuerIdentifier = try CredentialIssuerId(CREDENTIAL_ISSUER_PUBLIC_URL) @@ -85,7 +83,8 @@ extension Wallet { return try await issueOfferedCredentialNoProof( offer: offer, credentialConfigurationIdentifier: credentialConfigurationIdentifier, - claimSet: claimSet + claimSet: claimSet, + config: config ) } else { @@ -98,7 +97,8 @@ extension Wallet { private func issueMultipleOfferedCredentialWithProof( offer: CredentialOffer, - claimSet: ClaimSet? = nil + claimSet: ClaimSet? = nil, + config: OpenId4VCIConfig ) async throws -> [(String, Credential)] { let issuerMetadata = offer.credentialIssuerMetadata @@ -160,7 +160,8 @@ extension Wallet { private func issueOfferedCredentialNoProof( offer: CredentialOffer, credentialConfigurationIdentifier: CredentialConfigurationIdentifier, - claimSet: ClaimSet? = nil + claimSet: ClaimSet? = nil, + config: OpenId4VCIConfig ) async throws -> Credential { let issuer = try Issuer( @@ -203,7 +204,8 @@ extension Wallet { func issueByCredentialOfferUrlMultipleFormats( offerUri: String, - claimSet: ClaimSet? = nil + claimSet: ClaimSet? = nil, + config: OpenId4VCIConfig ) async throws -> [(String, Credential)] { let resolver = CredentialOfferRequestResolver( fetcher: Fetcher(session: self.session), @@ -226,7 +228,8 @@ extension Wallet { case .success(let offer): return try await issueMultipleOfferedCredentialWithProof( offer: offer, - claimSet: claimSet + claimSet: claimSet, + config: config ) case .failure(let error): throw ValidationError.error(reason: "Unable to resolve credential offer: \(error.localizedDescription)") @@ -236,7 +239,8 @@ extension Wallet { func issueByCredentialOfferUrl( offerUri: String, scope: String, - claimSet: ClaimSet? = nil + claimSet: ClaimSet? = nil, + config: OpenId4VCIConfig ) async throws -> Credential { let result = await CredentialOfferRequestResolver( fetcher: Fetcher(session: self.session), @@ -258,7 +262,8 @@ extension Wallet { return try await issueOfferedCredentialWithProof( offer: offer, scope: scope, - claimSet: claimSet + claimSet: claimSet, + config: config ) case .failure(let error): throw ValidationError.error(reason: "Unable to resolve credential offer: \(error.localizedDescription)") @@ -268,7 +273,8 @@ extension Wallet { func issueByCredentialOfferUrl_DPoP( offerUri: String, scope: String, - claimSet: ClaimSet? = nil + claimSet: ClaimSet? = nil, + config: OpenId4VCIConfig ) async throws -> Credential { let result = await CredentialOfferRequestResolver( fetcher: Fetcher(session: self.session), @@ -290,7 +296,8 @@ extension Wallet { return try await issueOfferedCredentialWithProof_DPoP( offer: offer, scope: scope, - claimSet: claimSet + claimSet: claimSet, + config: config ) case .failure(let error): throw ValidationError.error(reason: "Unable to resolve credential offer: \(error.localizedDescription)") @@ -300,7 +307,8 @@ extension Wallet { private func issueOfferedCredentialWithProof( offer: CredentialOffer, scope: String, - claimSet: ClaimSet? = nil + claimSet: ClaimSet? = nil, + config: OpenId4VCIConfig ) async throws -> Credential { let issuerMetadata = offer.credentialIssuerMetadata @@ -345,7 +353,8 @@ extension Wallet { private func issueOfferedCredentialWithProof_DPoP( offer: CredentialOffer, scope: String, - claimSet: ClaimSet? = nil + claimSet: ClaimSet? = nil, + config: OpenId4VCIConfig ) async throws -> Credential { let issuerMetadata = offer.credentialIssuerMetadata @@ -362,7 +371,7 @@ extension Wallet { requesterPoster: Poster(session: self.session), deferredRequesterPoster: Poster(session: self.session), notificationPoster: Poster(session: self.session), - dpopConstructor: dPoPConstructor + dpopConstructor: config.dPoPConstructor ) let authorized = try await authorizeRequestWithAuthCodeUseCase( diff --git a/Tests/Issuance/IssuanceAuthorizationTest.swift b/Tests/Issuance/IssuanceAuthorizationTest.swift index d477a8d3..8f9e6177 100644 --- a/Tests/Issuance/IssuanceAuthorizationTest.swift +++ b/Tests/Issuance/IssuanceAuthorizationTest.swift @@ -22,7 +22,7 @@ import JOSESwift class IssuanceAuthorizationTest: XCTestCase { let config: OpenId4VCIConfig = .init( - clientId: "wallet-dev", + client: .public(id: "wallet-dev"), authFlowRedirectionURI: URL(string: "urn:ietf:wg:oauth:2.0:oob")!, authorizeIssuanceConfig: .favorScopes ) @@ -305,7 +305,7 @@ class IssuanceAuthorizationTest: XCTestCase { preAuthorizationCode: code.preAuthorizedCode, txCode: code.txCode ), - clientId: "218232426", + client: .public(id: "218232426"), transactionCode: "123456" ) @@ -374,7 +374,7 @@ class IssuanceAuthorizationTest: XCTestCase { preAuthorizationCode: code.preAuthorizedCode, txCode: code.txCode ), - clientId: "218232426", + client: .public(id: "218232426"), transactionCode: "12345" ) @@ -474,7 +474,7 @@ class IssuanceAuthorizationTest: XCTestCase { preAuthorizationCode: code.preAuthorizedCode, txCode: code.txCode ), - clientId: "wallet-dev", + client: .public(id: "wallet-dev"), transactionCode: "12345" ) @@ -601,7 +601,7 @@ class IssuanceAuthorizationTest: XCTestCase { preAuthorizationCode: code.preAuthorizedCode, txCode: code.txCode ), - clientId: "218232426", + client: .public(id: "218232426"), transactionCode: "12345" ) diff --git a/Tests/Issuance/IssuanceBatchRequestTest.swift b/Tests/Issuance/IssuanceBatchRequestTest.swift index e19f2146..3ff9d773 100644 --- a/Tests/Issuance/IssuanceBatchRequestTest.swift +++ b/Tests/Issuance/IssuanceBatchRequestTest.swift @@ -23,7 +23,7 @@ import SwiftyJSON class IssuanceBatchRequestTest: XCTestCase { let config: OpenId4VCIConfig = .init( - clientId: "wallet-dev", + client: .public(id: "wallet-dev"), authFlowRedirectionURI: URL(string: "urn:ietf:wg:oauth:2.0:oob")!, authorizeIssuanceConfig: .favorScopes ) diff --git a/Tests/Issuance/IssuanceDeferredRequestTest.swift b/Tests/Issuance/IssuanceDeferredRequestTest.swift index fd09aa24..19d34641 100644 --- a/Tests/Issuance/IssuanceDeferredRequestTest.swift +++ b/Tests/Issuance/IssuanceDeferredRequestTest.swift @@ -22,7 +22,7 @@ import JOSESwift class IssuanceDeferredRequestTest: XCTestCase { let config: OpenId4VCIConfig = .init( - clientId: "wallet-dev", + client: .public(id: "wallet-dev"), authFlowRedirectionURI: URL(string: "urn:ietf:wg:oauth:2.0:oob")!, authorizeIssuanceConfig: .favorScopes ) diff --git a/Tests/Issuance/IssuanceEncryptionTest.swift b/Tests/Issuance/IssuanceEncryptionTest.swift index ac3e846d..713fd127 100644 --- a/Tests/Issuance/IssuanceEncryptionTest.swift +++ b/Tests/Issuance/IssuanceEncryptionTest.swift @@ -22,7 +22,7 @@ import JOSESwift class IssuanceEncryptionTest: XCTestCase { let config: OpenId4VCIConfig = .init( - clientId: "wallet-dev", + client: .public(id: "wallet-dev"), authFlowRedirectionURI: URL(string: "urn:ietf:wg:oauth:2.0:oob")!, authorizeIssuanceConfig: .favorScopes ) diff --git a/Tests/Issuance/IssuanceNotificationTest.swift b/Tests/Issuance/IssuanceNotificationTest.swift index 4aaf77a4..58e70c8a 100644 --- a/Tests/Issuance/IssuanceNotificationTest.swift +++ b/Tests/Issuance/IssuanceNotificationTest.swift @@ -22,7 +22,7 @@ import JOSESwift class IssuanceNotificationTest: XCTestCase { let config: OpenId4VCIConfig = .init( - clientId: "wallet-dev", + client: .public(id: "wallet-dev"), authFlowRedirectionURI: URL(string: "urn:ietf:wg:oauth:2.0:oob")!, authorizeIssuanceConfig: .favorScopes ) diff --git a/Tests/Issuance/IssuanceSingleRequestTest.swift b/Tests/Issuance/IssuanceSingleRequestTest.swift index a2158fed..64ed2755 100644 --- a/Tests/Issuance/IssuanceSingleRequestTest.swift +++ b/Tests/Issuance/IssuanceSingleRequestTest.swift @@ -22,7 +22,7 @@ import JOSESwift class IssuanceSingleRequestTest: XCTestCase { let config: OpenId4VCIConfig = .init( - clientId: "wallet-dev", + client: .public(id: "wallet-dev"), authFlowRedirectionURI: URL(string: "urn:ietf:wg:oauth:2.0:oob")!, authorizeIssuanceConfig: .favorScopes ) @@ -230,7 +230,7 @@ class IssuanceSingleRequestTest: XCTestCase { let unAuthorized = await issuer.authorizeWithPreAuthorizationCode( credentialOffer: offer, authorizationCode: issuanceAuthorization, - clientId: "218232426", + client: .public(id: "218232426"), transactionCode: "12345" ) diff --git a/Tests/Issue/IssuanceFlowsTest.swift b/Tests/Issue/IssuanceFlowsTest.swift index f90e93c6..192aa870 100644 --- a/Tests/Issue/IssuanceFlowsTest.swift +++ b/Tests/Issue/IssuanceFlowsTest.swift @@ -34,7 +34,7 @@ class IssuanceFlowsTest: XCTestCase { deferredCredentialEndpoint: .init(string: "https://www.example.com"), deferredRequesterPoster: Poster(), config: .init( - clientId: .init(), + client: .public(id: ""), authFlowRedirectionURI: URL(string: "https://www.example.com")! ) ) diff --git a/Tests/Wallet/VCIFlowNoOffer.swift b/Tests/Wallet/VCIFlowNoOffer.swift index 2dc28132..2224cea0 100644 --- a/Tests/Wallet/VCIFlowNoOffer.swift +++ b/Tests/Wallet/VCIFlowNoOffer.swift @@ -65,8 +65,7 @@ class VCIFlowNoOffer: XCTestCase { let wallet = Wallet( actingUser: user, - bindingKeys: [bindingKey], - dPoPConstructor: nil + bindingKeys: [bindingKey] ) do { @@ -110,8 +109,7 @@ class VCIFlowNoOffer: XCTestCase { let wallet = Wallet( actingUser: user, - bindingKeys: [bindingKey], - dPoPConstructor: nil + bindingKeys: [bindingKey] ) do { @@ -154,8 +152,7 @@ class VCIFlowNoOffer: XCTestCase { let wallet = Wallet( actingUser: user, - bindingKeys: [bindingKey], - dPoPConstructor: nil + bindingKeys: [bindingKey] ) let claimSetMsoMdoc = MsoMdocFormat.MsoMdocClaimSet( @@ -207,8 +204,7 @@ class VCIFlowNoOffer: XCTestCase { let wallet = Wallet( actingUser: user, - bindingKeys: [bindingKey, bindingKey], - dPoPConstructor: nil + bindingKeys: [bindingKey, bindingKey] ) do { @@ -251,8 +247,7 @@ class VCIFlowNoOffer: XCTestCase { let wallet = Wallet( actingUser: user, - bindingKeys: [bindingKey], - dPoPConstructor: nil + bindingKeys: [bindingKey] ) do { @@ -268,6 +263,66 @@ class VCIFlowNoOffer: XCTestCase { XCTAssert(true) } + + func testNoOfferSdJWTClientAuthentication() async throws { + + let privateKey = try KeyController.generateECDHPrivateKey() + let publicKey = try KeyController.generateECDHPublicKey(from: privateKey) + + let alg = JWSAlgorithm(.ES256) + let publicKeyJWK = try ECPublicKey( + publicKey: publicKey, + additionalParameters: [ + "alg": alg.name, + "use": "sig", + "kid": UUID().uuidString + ]) + + let bindingKey: BindingKey = .jwk( + algorithm: alg, + jwk: publicKeyJWK, + privateKey: .secKey(privateKey) + ) + + let user = ActingUser( + username: "tneal", + password: "password" + ) + + let wallet = Wallet( + actingUser: user, + bindingKeys: [bindingKey] + ) + + do { + try await walletInitiatedIssuanceNoOfferSdJwtClientAuthentication( + wallet: wallet + ) + + } catch { + + XCTExpectFailure() + XCTAssert(false, error.localizedDescription) + } + + XCTAssert(true) + } +} + +private func walletInitiatedIssuanceNoOfferSdJwtClientAuthentication( + wallet: Wallet, + claimSet: ClaimSet? = nil +) async throws { + + print("[[Scenario: No offer passed, wallet initiates issuance by credetial scopes and client authentication]]") + + let credential = try await wallet.issueByCredentialIdentifier( + PID_SdJwtVC_config_id, + claimSet: claimSet, + config: attestationConfig + ) + + print("--> [ISSUANCE] Issued PID in format \(PID_SdJwtVC_config_id): \(credential)") } private func walletInitiatedIssuanceNoOfferSdJwt( @@ -279,7 +334,8 @@ private func walletInitiatedIssuanceNoOfferSdJwt( let credential = try await wallet.issueByCredentialIdentifier( PID_SdJwtVC_config_id, - claimSet: claimSet + claimSet: claimSet, + config: clientConfig ) print("--> [ISSUANCE] Issued PID in format \(PID_SdJwtVC_config_id): \(credential)") @@ -294,7 +350,8 @@ private func walletInitiatedIssuanceNoOfferMdoc( let credential = try await wallet.issueByCredentialIdentifier( PID_MsoMdoc_config_id, - claimSet: claimSet + claimSet: claimSet, + config: clientConfig ) print("--> [ISSUANCE] Issued PID in format \(PID_MsoMdoc_config_id): \(credential)") @@ -306,7 +363,8 @@ private func walletInitiatedIssuanceNoOfferMDL(wallet: Wallet, claimSet: ClaimSe let credential = try await wallet.issueByCredentialIdentifier( MDL_config_id, - claimSet: claimSet + claimSet: claimSet, + config: clientConfig ) print("--> [ISSUANCE] Issued PID in format \(MDL_config_id): \(credential)") diff --git a/Tests/Wallet/VCIFlowWithOffer.swift b/Tests/Wallet/VCIFlowWithOffer.swift index 291f9ddc..f1900295 100644 --- a/Tests/Wallet/VCIFlowWithOffer.swift +++ b/Tests/Wallet/VCIFlowWithOffer.swift @@ -57,7 +57,6 @@ class VCIFlowWithOffer: XCTestCase { let wallet = Wallet( actingUser: user, bindingKeys: [bindingKey], - dPoPConstructor: nil, session: Wallet.walletSession ) @@ -106,7 +105,6 @@ class VCIFlowWithOffer: XCTestCase { let wallet = Wallet( actingUser: user, bindingKeys: [bindingKey], - dPoPConstructor: nil, session: Wallet.walletSession ) @@ -150,8 +148,7 @@ class VCIFlowWithOffer: XCTestCase { let wallet = Wallet( actingUser: user, - bindingKeys: [bindingKey], - dPoPConstructor: nil + bindingKeys: [bindingKey] ) do { @@ -194,8 +191,7 @@ class VCIFlowWithOffer: XCTestCase { let wallet = Wallet( actingUser: user, - bindingKeys: [bindingKey], - dPoPConstructor: nil + bindingKeys: [bindingKey] ) do { @@ -239,7 +235,6 @@ class VCIFlowWithOffer: XCTestCase { let wallet = Wallet( actingUser: user, bindingKeys: [bindingKey], - dPoPConstructor: nil, session: Wallet.walletSession ) @@ -283,8 +278,7 @@ class VCIFlowWithOffer: XCTestCase { let wallet = Wallet( actingUser: user, - bindingKeys: [bindingKey], - dPoPConstructor: nil + bindingKeys: [bindingKey] ) do { @@ -329,7 +323,13 @@ class VCIFlowWithOffer: XCTestCase { let wallet = Wallet( actingUser: user, - bindingKeys: [bindingKey], + bindingKeys: [bindingKey] + ) + + let dPoPClientConfig: OpenId4VCIConfig = .init( + client: .public(id: "wallet-dev"), + authFlowRedirectionURI: URL(string: "urn:ietf:wg:oauth:2.0:oob")!, + authorizeIssuanceConfig: .favorScopes, dPoPConstructor: DPoPConstructor( algorithm: alg, jwk: publicKeyJWK, @@ -339,7 +339,8 @@ class VCIFlowWithOffer: XCTestCase { do { try await walletInitiatedIssuanceWithOfferMDL_DPoP( - wallet: wallet + wallet: wallet, + config: dPoPClientConfig ) } catch { @@ -378,7 +379,13 @@ class VCIFlowWithOffer: XCTestCase { let wallet = Wallet( actingUser: user, - bindingKeys: [bindingKey], + bindingKeys: [bindingKey] + ) + + let dPoPClientConfig: OpenId4VCIConfig = .init( + client: .public(id: "wallet-dev"), + authFlowRedirectionURI: URL(string: "urn:ietf:wg:oauth:2.0:oob")!, + authorizeIssuanceConfig: .favorScopes, dPoPConstructor: DPoPConstructor( algorithm: alg, jwk: publicKeyJWK, @@ -388,7 +395,8 @@ class VCIFlowWithOffer: XCTestCase { do { try await walletInitiatedIssuanceWithOfferSDJWT_DPoP( - wallet: wallet + wallet: wallet, + config: dPoPClientConfig ) } catch { @@ -427,7 +435,13 @@ class VCIFlowWithOffer: XCTestCase { let wallet = Wallet( actingUser: user, - bindingKeys: [bindingKey, bindingKey], + bindingKeys: [bindingKey, bindingKey] + ) + + let dPoPClientConfig: OpenId4VCIConfig = .init( + client: .public(id: "wallet-dev"), + authFlowRedirectionURI: URL(string: "urn:ietf:wg:oauth:2.0:oob")!, + authorizeIssuanceConfig: .favorScopes, dPoPConstructor: DPoPConstructor( algorithm: alg, jwk: publicKeyJWK, @@ -437,7 +451,8 @@ class VCIFlowWithOffer: XCTestCase { do { try await walletInitiatedIssuanceWithOfferSDJWT_DPoP( - wallet: wallet + wallet: wallet, + config: dPoPClientConfig ) } catch { @@ -460,7 +475,8 @@ private func walletInitiatedIssuanceWithOfferSdJWT( let credential = try await wallet.issueByCredentialOfferUrl( offerUri: url, scope: PID_SdJwtVC_config_id, - claimSet: claimSet + claimSet: claimSet, + config: clientConfig ) print("--> [ISSUANCE] Issued credential: \(credential)") @@ -477,7 +493,8 @@ private func walletInitiatedIssuanceWithOfferMDL( let credential = try await wallet.issueByCredentialOfferUrl( offerUri: url, scope: MDL_config_id, - claimSet: claimSet + claimSet: claimSet, + config: clientConfig ) print("--> [ISSUANCE] Issued credential : \(credential)") @@ -485,6 +502,7 @@ private func walletInitiatedIssuanceWithOfferMDL( private func walletInitiatedIssuanceWithOfferMDL_DPoP( wallet: Wallet, + config: OpenId4VCIConfig, claimSet: ClaimSet? = nil ) async throws { @@ -494,7 +512,8 @@ private func walletInitiatedIssuanceWithOfferMDL_DPoP( let credential = try await wallet.issueByCredentialOfferUrl_DPoP( offerUri: url, scope: MDL_config_id, - claimSet: claimSet + claimSet: claimSet, + config: config ) print("--> [ISSUANCE] Issued credential : \(credential)") @@ -502,6 +521,7 @@ private func walletInitiatedIssuanceWithOfferMDL_DPoP( private func walletInitiatedIssuanceWithOfferSDJWT_DPoP( wallet: Wallet, + config: OpenId4VCIConfig, claimSet: ClaimSet? = nil ) async throws { @@ -511,7 +531,8 @@ private func walletInitiatedIssuanceWithOfferSDJWT_DPoP( let credential = try await wallet.issueByCredentialOfferUrl_DPoP( offerUri: url, scope: PID_SdJwtVC_config_id, - claimSet: claimSet + claimSet: claimSet, + config: config ) print("--> [ISSUANCE] Issued credential : \(credential)") @@ -528,7 +549,8 @@ private func walletInitiatedIssuanceWithOfferMdoc( let credential = try await wallet.issueByCredentialOfferUrl( offerUri: url, scope: PID_MsoMdoc_config_id, - claimSet: claimSet + claimSet: claimSet, + config: clientConfig ) print("--> [ISSUANCE] Issued credential : \(credential)") @@ -544,7 +566,8 @@ private func walletInitiatedIssuanceWithOfferArray( let url = "\(CREDENTIAL_ISSUER_PUBLIC_URL)/credentialoffer?credential_offer=\(All_Supported_CredentialOffer)" let credentials = try await wallet.issueByCredentialOfferUrlMultipleFormats( offerUri: url, - claimSet: claimSet + claimSet: claimSet, + config: clientConfig ) print("--> [ISSUANCE] Issued credentials:") @@ -569,7 +592,8 @@ private func walletInitiatedIssuanceWithOfferUrl( let credentials = try await wallet.issueByCredentialOfferUrlMultipleFormats( offerUri: url, - claimSet: claimSet + claimSet: claimSet, + config: clientConfig ) print("--> [ISSUANCE] Issued credentials:")