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

feat(pollux): add jwt credential revocation support #149

Merged
merged 1 commit into from
Jul 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions Core/Sources/Helpers/JSONDecoder+Helper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,17 @@ public extension JSONDecoder {
decoder.keyDecodingStrategy = .convertFromSnakeCase
return decoder
}

static func backup() -> JSONDecoder {
let decoder = JSONDecoder()
decoder.dataDecodingStrategy = .base64
decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .custom({ decoder in
let container = try decoder.singleValueContainer()
let seconds = try container.decode(Int.self)
let date = Date(timeIntervalSince1970: TimeInterval(seconds))
return date
})
return decoder
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import Foundation

/// `RevocableCredential` is a protocol that defines the attributes and behaviors
/// of a credential that can be revoked or suspended.
public protocol RevocableCredential {
/// Indicates whether the credential can be revoked.
var canBeRevoked: Bool { get }

/// Indicates whether the credential can be suspended.
var canBeSuspended: Bool { get }

/// Checks if the credential is currently revoked.
///
/// - Returns: A Boolean value indicating whether the credential is revoked.
/// - Throws: An error if the status cannot be determined.
var isRevoked: Bool { get async throws }

/// Checks if the credential is currently suspended.
///
/// - Returns: A Boolean value indicating whether the credential is suspended.
/// - Throws: An error if the status cannot be determined.
var isSuspended: Bool { get async throws }
}

public extension Credential {
/// A Boolean value indicating whether the credential can verify revocability.
var isRevocable: Bool { self is RevocableCredential }

/// Returns the revocable representation of the credential.
var revocable: RevocableCredential? { self as? RevocableCredential }
}
18 changes: 18 additions & 0 deletions EdgeAgentSDK/Domain/Sources/Models/Errors.swift
Original file line number Diff line number Diff line change
Expand Up @@ -805,6 +805,14 @@ public enum PolluxError: KnownPrismError {
/// An error case indicating that the signature is invalid, with internal errors specified.
case invalidSignature(internalErrors: [Error] = [])

/// An error case indicating that the credential is revoked.
/// - Parameter jwtString: The JWT string representing the revoked credential.
case credentialIsRevoked(jwtString: String)

/// An error case indicating that the credential is suspended.
/// - Parameter jwtString: The JWT string representing the suspended credential.
case credentialIsSuspended(jwtString: String)

/// The error code returned by the server.
public var code: Int {
switch self {
Expand Down Expand Up @@ -862,6 +870,10 @@ public enum PolluxError: KnownPrismError {
return 76
case .invalidSignature:
return 77
case .credentialIsRevoked:
return 78
case .credentialIsSuspended:
return 79
}
}

Expand Down Expand Up @@ -942,6 +954,12 @@ Cannot verify input descriptor field \(name.map { "with name: \($0)"} ?? ""), wi
"""
case .invalidSignature:
return "Could not verify one or more JWT signatures"

case .credentialIsRevoked(let jwtString):
return "Credential (\(jwtString)) is revoked"

case .credentialIsSuspended(let jwtString):
return "Credential (\(jwtString)) is suspended"
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion EdgeAgentSDK/EdgeAgent/Sources/EdgeAgent+Backup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ extension EdgeAgent {
let messages = messages.compactMap { messageStr -> (Message, Message.Direction)? in
guard
let messageData = Data(base64URLEncoded: messageStr),
let message = try? JSONDecoder.didComm().decode(Message.self, from: messageData)
let message = try? JSONDecoder.backup().decode(Message.self, from: messageData)
else {
return nil
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import Domain
import Foundation

extension JWTCredential: RevocableCredential {
public var canBeRevoked: Bool {
self.jwtVerifiableCredential.verifiableCredential.credentialStatus?.statusPurpose == .revocation
}

public var canBeSuspended: Bool {
self.jwtVerifiableCredential.verifiableCredential.credentialStatus?.statusPurpose == .suspension
}

public var isRevoked: Bool {
get async throws {
guard canBeRevoked else { return false }
return try await JWTRevocationCheck(credential: self).checkIsRevoked()
}
}

public var isSuspended: Bool {
get async throws {
guard canBeSuspended else { return false }
return try await JWTRevocationCheck(credential: self).checkIsRevoked()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ extension JWTPayload.JWTVerfiableCredential: Codable {
}
let credentialSubject = try container.decode(AnyCodable.self, forKey: .credentialSubject)
let credentialStatus = try? container.decode(
VerifiableCredentialTypeContainer.self,
JWTRevocationStatus.self,
forKey: .credentialStatus
)
let credentialSchema = try? container.decode(
Expand Down
4 changes: 2 additions & 2 deletions EdgeAgentSDK/Pollux/Sources/Models/JWT/JWTPayload.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ struct JWTPayload {
let type: Set<String>
let credentialSchema: VerifiableCredentialTypeContainer?
let credentialSubject: AnyCodable
let credentialStatus: VerifiableCredentialTypeContainer?
let refreshService: VerifiableCredentialTypeContainer?
let evidence: VerifiableCredentialTypeContainer?
let termsOfUse: VerifiableCredentialTypeContainer?
let credentialStatus: JWTRevocationStatus?

/**
Initializes a new instance of `JWTVerifiableCredential`.
Expand All @@ -42,7 +42,7 @@ struct JWTPayload {
type: Set<String> = Set(),
credentialSchema: VerifiableCredentialTypeContainer? = nil,
credentialSubject: AnyCodable,
credentialStatus: VerifiableCredentialTypeContainer? = nil,
credentialStatus: JWTRevocationStatus? = nil,
refreshService: VerifiableCredentialTypeContainer? = nil,
evidence: VerifiableCredentialTypeContainer? = nil,
termsOfUse: VerifiableCredentialTypeContainer? = nil
Expand Down
76 changes: 76 additions & 0 deletions EdgeAgentSDK/Pollux/Sources/Models/JWT/JWTRevocationCheck.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import Domain
import Foundation
import Gzip
import JSONWebSignature

struct JWTRevocationCheck {
let credential: JWTCredential

init(credential: JWTCredential) {
self.credential = credential
}

func checkIsRevoked() async throws -> Bool {
guard let status = credential.jwtVerifiableCredential.verifiableCredential.credentialStatus else {
return false
}

guard status.type == "StatusList2021Entry" else {
throw UnknownError.somethingWentWrongError(customMessage: nil, underlyingErrors: nil)
}

let listData = try await DownloadDataWithResolver()
.downloadFromEndpoint(urlOrDID: status.statusListCredential)
let statusList = try JSONDecoder.didComm().decode(JWTRevocationStatusListCredential.self, from: listData)
let encodedList = statusList.credentialSubject.encodedList
let index = status.statusListIndex
return try verifyRevocationOnEncodedList(encodedList.tryToData(), index: index)
}

func verifyRevocationOnEncodedList(_ list: Data, index: Int) throws -> Bool {
let encodedListData = try list.gunzipped()
let bitList = encodedListData.bytes.flatMap { $0.toBits() }
guard index < bitList.count else {
throw UnknownError.somethingWentWrongError(customMessage: "Revocation index out of bounds", underlyingErrors: nil)
}
return bitList[index]
}
}

extension UInt8 {
func toBits() -> [Bool] {
var bits = [Bool](repeating: false, count: 8)
for i in 0..<8 {
bits[7 - i] = (self & (1 << i)) != 0
}
return bits
}
}

fileprivate struct DownloadDataWithResolver: Downloader {

public func downloadFromEndpoint(urlOrDID: String) async throws -> Data {
let url: URL

if let validUrl = URL(string: urlOrDID.replacingOccurrences(of: "host.docker.internal", with: "localhost")) {
url = validUrl
} else {
throw CommonError.invalidURLError(url: urlOrDID)
}

let (data, urlResponse) = try await URLSession.shared.data(from: url)

guard
let code = (urlResponse as? HTTPURLResponse)?.statusCode,
200...299 ~= code
else {
throw CommonError.httpError(
code: (urlResponse as? HTTPURLResponse)?.statusCode ?? 500,
message: String(data: data, encoding: .utf8) ?? ""
)
}

return data
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import Foundation

struct JWTRevocationStatus: Codable {
enum CredentialStatusListType: String, Codable {
case statusList2021Entry = "StatusList2021Entry"
}

enum CredentialStatusPurpose: String, Codable {
case revocation
case suspension
}

let id: String
let type: String
let statusPurpose: CredentialStatusPurpose
let statusListIndex: Int
let statusListCredential: String
}

struct JWTRevocationStatusListCredential: Codable {
struct StatusListCredentialSubject: Codable {
let type: String
let statusPurpose: String
let encodedList: String
}
let context: [String]
let type: [String]
let id: String
let issuer: String
let issuanceDate: String
let credentialSubject: StatusListCredentialSubject
}
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ extension PolluxImpl {
}

private func verifyJWT(jwtString: String) async throws -> Bool {
try await verifyJWTCredentialRevocation(jwtString: jwtString)
let payload: DefaultJWTClaimsImpl = try JWT.getPayload(jwtString: jwtString)
guard let issuer = payload.iss else {
throw PolluxError.requiresThatIssuerExistsAndIsAPrismDID
Expand All @@ -135,6 +136,20 @@ extension PolluxImpl {
return !validations.isEmpty
}

private func verifyJWTCredentialRevocation(jwtString: String) async throws {
guard let credential = try? JWTCredential(data: jwtString.tryToData()) else {
return
}
let isRevoked = try await credential.isRevoked
let isSuspended = try await credential.isSuspended
guard isRevoked else {
throw PolluxError.credentialIsRevoked(jwtString: jwtString)
}
guard isSuspended else {
throw PolluxError.credentialIsSuspended(jwtString: jwtString)
}
}

private func getDefinition(id: String) async throws -> PresentationExchangeRequest {
guard
let request = try await pluto.getMessage(id: id).first().await(),
Expand Down
8 changes: 8 additions & 0 deletions EdgeAgentSDK/Pollux/Tests/JWTTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,12 @@ final class JWTTests: XCTestCase {
XCTAssertEqual(credential.claims.map(\.key).sorted(), ["id", "test"].sorted())
XCTAssertEqual(credential.id, validJWTString)
}

func testRevoked() throws {
let validJWTString = try "eyJhbGciOiJFUzI1NksifQ.eyJpc3MiOiJkaWQ6cHJpc206MmU0MGZkNjkyYjgzYzE5ZjlhNTUzNjRjMmNhNWJmNjkyOGI4ODU1NGE1YmYxMTc0YTc4ZjY4NDk4ZDgwZGZjNjpDcmNCQ3JRQkVqa0tCV3RsZVMweEVBSktMZ29KYzJWamNESTFObXN4RWlFQ1pDbDV4aUREb3ZsVFlNNVVSeXdHODZPWjc2RWNTY3NjSEplaHRnbWNKTlFTT2dvR1lYVjBhQzB4RUFSS0xnb0pjMlZqY0RJMU5tc3hFaUVDRUMzTUNPak4xb1lNZjU2ZVVBaTA3NkxGX2hRZDRwbFFib3JKcnBkOHdHY1NPd29IYldGemRHVnlNQkFCU2k0S0NYTmxZM0F5TlRack1SSWhBeTVqVkc4UTRWOHRYV0RoUWNvb2xPTmFIdTZHaW5ockJ6SEtfRXYySW9yNSIsInN1YiI6ImRpZDpwcmlzbTo4ODYwN2Y4YjE3ZWJhZmNhODgwNDdmZDQ0YTMyZTE4NGI1MGYwM2QyNWZhZWQ1ZGRiYWQyZGRjNGYyZjg5YWYzOkNzY0JDc1FCRW1RS0QyRjFkR2hsYm5ScFkyRjBhVzl1TUJBRVFrOEtDWE5sWTNBeU5UWnJNUklncnFDMVhaN2ZsOUpLSjBNT3pTa2hSZFhESHpnSVQzTGJ1MlNLdTJvZWxKVWFJT3gxSzFvY2NDRG14SS05Zm9jRm84emhpTm5BYXBPUGFXQXY0UGg0azZjWkVsd0tCMjFoYzNSbGNqQVFBVUpQQ2dselpXTndNalUyYXpFU0lLNmd0VjJlMzVmU1NpZEREczBwSVVYVnd4ODRDRTl5Mjd0a2lydHFIcFNWR2lEc2RTdGFISEFnNXNTUHZYNkhCYVBNNFlqWndHcVRqMmxnTC1ENGVKT25HUSIsIm5iZiI6MTY4ODA1ODcyNywiZXhwIjoxNjg4MDYyMzI3LCJ2YyI6eyJjcmVkZW50aWFsU2NoZW1hIjp7ImlkIjoiaHR0cHM6XC9cL2s4cy1kZXYuYXRhbGFwcmlzbS5pb1wvcHJpc20tYWdlbnRcL3NjaGVtYS1yZWdpc3RyeVwvc2NoZW1hc1wvMDIwMTY5M2ItNGQ2ZC0zNmVjLWEzN2QtODFkODhlODcyNTM5IiwidHlwZSI6IkNyZWRlbnRpYWxTY2hlbWEyMDIyIn0sImNyZWRlbnRpYWxTdWJqZWN0Ijp7InRlc3QiOiJUZXN0MSIsImlkIjoiZGlkOnByaXNtOjg4NjA3ZjhiMTdlYmFmY2E4ODA0N2ZkNDRhMzJlMTg0YjUwZjAzZDI1ZmFlZDVkZGJhZDJkZGM0ZjJmODlhZjM6Q3NjQkNzUUJFbVFLRDJGMWRHaGxiblJwWTJGMGFXOXVNQkFFUWs4S0NYTmxZM0F5TlRack1SSWdycUMxWFo3Zmw5SktKME1PelNraFJkWERIemdJVDNMYnUyU0t1Mm9lbEpVYUlPeDFLMW9jY0NEbXhJLTlmb2NGbzh6aGlObkFhcE9QYVdBdjRQaDRrNmNaRWx3S0IyMWhjM1JsY2pBUUFVSlBDZ2x6WldOd01qVTJhekVTSUs2Z3RWMmUzNWZTU2lkRERzMHBJVVhWd3g4NENFOXkyN3RraXJ0cUhwU1ZHaURzZFN0YUhIQWc1c1NQdlg2SEJhUE00WWpad0dxVGoybGdMLUQ0ZUpPbkdRIn0sInR5cGUiOlsiVmVyaWZpYWJsZUNyZWRlbnRpYWwiXSwiQGNvbnRleHQiOlsiaHR0cHM6XC9cL3d3dy53My5vcmdcLzIwMThcL2NyZWRlbnRpYWxzXC92MSJdfX0.JZBqArVFvWgj2W0b7vVPSKR3mSH_X-VOC-YQ_jyLZSOEYUkortkRGi41xwA7SPFSqPdSCHl4iagpBir1tYMBOw".tryToData()
let credential = try JWTCredential(data: validJWTString)
let encodedList = Data(fromBase64URL: "H4sIAAAAAAAA_-3BMQ0AAAACIGf_0MbwARoAAAAAAAAAAAAAAAAAAADgbbmHB0sAQAAA")!
XCTAssertFalse(try JWTRevocationCheck(credential: credential)
.verifyRevocationOnEncodedList(encodedList, index: 94567))
}
}
6 changes: 4 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,14 @@ let package = Package(
),
.package(url: "https://github.com/apple/swift-protobuf.git", from: "1.7.0"),
.package(url: "https://github.com/beatt83/didcomm-swift.git", from: "0.1.8"),
.package(url: "https://github.com/beatt83/jose-swift.git", from: "3.1.0"),
.package(url: "https://github.com/beatt83/jose-swift.git", from: "3.2.0"),
.package(url: "https://github.com/beatt83/peerdid-swift.git", from: "3.0.1"),
.package(url: "https://github.com/input-output-hk/anoncreds-rs.git", exact: "0.4.1"),
.package(url: "https://github.com/input-output-hk/atala-prism-apollo.git", exact: "1.3.3"),
.package(url: "https://github.com/KittyMac/Sextant.git", exact: "0.4.31"),
.package(url: "https://github.com/kylef/JSONSchema.swift.git", exact: "0.6.0"),
.package(url: "https://github.com/goncalo-frade-iohk/eudi-lib-sdjwt-swift.git", from: "0.0.2")
.package(url: "https://github.com/goncalo-frade-iohk/eudi-lib-sdjwt-swift.git", from: "0.0.2"),
.package(url: "https://github.com/1024jp/GzipSwift.git", exact: "6.0.0")
],
targets: [
.target(
Expand Down Expand Up @@ -121,6 +122,7 @@ let package = Package(
"jose-swift",
"Sextant",
"eudi-lib-sdjwt-swift",
.product(name: "Gzip", package: "GzipSwift"),
.product(name: "AnoncredsSwift", package: "anoncreds-rs"),
.product(name: "JSONSchema", package: "JSONSchema.swift")
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ protocol BackupViewModel: ObservableObject {

struct BackupView<ViewModel: BackupViewModel>: View {
@StateObject var viewModel: ViewModel
@Environment(\.dismiss) var dismiss
@State private var jwe: String = ""
var body: some View {
VStack(spacing: 10) {
VStack(spacing: 8) {
VStack(spacing: 25) {
VStack(spacing: 10) {
AtalaButton(
configuration: .primary,
action: {
Expand All @@ -38,6 +39,9 @@ struct BackupView<ViewModel: BackupViewModel>: View {
action: {
Task {
try await self.viewModel.backupWith(jwe)
await MainActor.run {
self.dismiss()
}
}
},
label: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ final class BackupViewModelImpl: BackupViewModel {
@Published var newJWE: String? = nil

private let agent: EdgeAgent

init(agent: EdgeAgent) {
self.agent = agent
}
Expand All @@ -20,6 +20,12 @@ final class BackupViewModelImpl: BackupViewModel {
}

func backupWith(_ jwe: String) async throws {
try await agent.recoverWallet(encrypted: jwe)
do {
try await agent.recoverWallet(encrypted: jwe)
} catch {
print(error)
print()
throw error
}
}
}
Loading