Skip to content

Commit 3f2a698

Browse files
feat(pollux): add jwt credential revocation support
Fixes ATL-7034 Signed-off-by: goncalo-frade-iohk <[email protected]>
1 parent 068acdd commit 3f2a698

File tree

14 files changed

+241
-10
lines changed

14 files changed

+241
-10
lines changed

Diff for: Core/Sources/Helpers/JSONDecoder+Helper.swift

+13
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,17 @@ public extension JSONDecoder {
77
decoder.keyDecodingStrategy = .convertFromSnakeCase
88
return decoder
99
}
10+
11+
static func backup() -> JSONDecoder {
12+
let decoder = JSONDecoder()
13+
decoder.dataDecodingStrategy = .base64
14+
decoder.keyDecodingStrategy = .convertFromSnakeCase
15+
decoder.dateDecodingStrategy = .custom({ decoder in
16+
let container = try decoder.singleValueContainer()
17+
let seconds = try container.decode(Int.self)
18+
let date = Date(timeIntervalSince1970: TimeInterval(seconds))
19+
return date
20+
})
21+
return decoder
22+
}
1023
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import Foundation
2+
3+
/// `RevocableCredential` is a protocol that defines the attributes and behaviors
4+
/// of a credential that can be revoked or suspended.
5+
public protocol RevocableCredential {
6+
/// Indicates whether the credential can be revoked.
7+
var canBeRevoked: Bool { get }
8+
9+
/// Indicates whether the credential can be suspended.
10+
var canBeSuspended: Bool { get }
11+
12+
/// Checks if the credential is currently revoked.
13+
///
14+
/// - Returns: A Boolean value indicating whether the credential is revoked.
15+
/// - Throws: An error if the status cannot be determined.
16+
var isRevoked: Bool { get async throws }
17+
18+
/// Checks if the credential is currently suspended.
19+
///
20+
/// - Returns: A Boolean value indicating whether the credential is suspended.
21+
/// - Throws: An error if the status cannot be determined.
22+
var isSuspended: Bool { get async throws }
23+
}
24+
25+
public extension Credential {
26+
/// A Boolean value indicating whether the credential can verify revocability.
27+
var isRevocable: Bool { self is RevocableCredential }
28+
29+
/// Returns the revocable representation of the credential.
30+
var revocable: RevocableCredential? { self as? RevocableCredential }
31+
}

Diff for: EdgeAgentSDK/Domain/Sources/Models/Errors.swift

+18
Original file line numberDiff line numberDiff line change
@@ -805,6 +805,14 @@ public enum PolluxError: KnownPrismError {
805805
/// An error case indicating that the signature is invalid, with internal errors specified.
806806
case invalidSignature(internalErrors: [Error] = [])
807807

808+
/// An error case indicating that the credential is revoked.
809+
/// - Parameter jwtString: The JWT string representing the revoked credential.
810+
case credentialIsRevoked(jwtString: String)
811+
812+
/// An error case indicating that the credential is suspended.
813+
/// - Parameter jwtString: The JWT string representing the suspended credential.
814+
case credentialIsSuspended(jwtString: String)
815+
808816
/// The error code returned by the server.
809817
public var code: Int {
810818
switch self {
@@ -862,6 +870,10 @@ public enum PolluxError: KnownPrismError {
862870
return 76
863871
case .invalidSignature:
864872
return 77
873+
case .credentialIsRevoked:
874+
return 78
875+
case .credentialIsSuspended:
876+
return 79
865877
}
866878
}
867879

@@ -942,6 +954,12 @@ Cannot verify input descriptor field \(name.map { "with name: \($0)"} ?? ""), wi
942954
"""
943955
case .invalidSignature:
944956
return "Could not verify one or more JWT signatures"
957+
958+
case .credentialIsRevoked(let jwtString):
959+
return "Credential (\(jwtString)) is revoked"
960+
961+
case .credentialIsSuspended(let jwtString):
962+
return "Credential (\(jwtString)) is suspended"
945963
}
946964
}
947965
}

Diff for: EdgeAgentSDK/EdgeAgent/Sources/EdgeAgent+Backup.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ extension EdgeAgent {
209209
let messages = messages.compactMap { messageStr -> (Message, Message.Direction)? in
210210
guard
211211
let messageData = Data(base64URLEncoded: messageStr),
212-
let message = try? JSONDecoder.didComm().decode(Message.self, from: messageData)
212+
let message = try? JSONDecoder.backup().decode(Message.self, from: messageData)
213213
else {
214214
return nil
215215
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import Domain
2+
import Foundation
3+
4+
extension JWTCredential: RevocableCredential {
5+
public var canBeRevoked: Bool {
6+
self.jwtVerifiableCredential.verifiableCredential.credentialStatus?.statusPurpose == .revocation
7+
}
8+
9+
public var canBeSuspended: Bool {
10+
self.jwtVerifiableCredential.verifiableCredential.credentialStatus?.statusPurpose == .suspension
11+
}
12+
13+
public var isRevoked: Bool {
14+
get async throws {
15+
guard canBeRevoked else { return false }
16+
return try await JWTRevocationCheck(credential: self).checkIsRevoked()
17+
}
18+
}
19+
20+
public var isSuspended: Bool {
21+
get async throws {
22+
guard canBeSuspended else { return false }
23+
return try await JWTRevocationCheck(credential: self).checkIsRevoked()
24+
}
25+
}
26+
}

Diff for: EdgeAgentSDK/Pollux/Sources/Models/JWT/JWTPayload+Codable.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ extension JWTPayload.JWTVerfiableCredential: Codable {
4141
}
4242
let credentialSubject = try container.decode(AnyCodable.self, forKey: .credentialSubject)
4343
let credentialStatus = try? container.decode(
44-
VerifiableCredentialTypeContainer.self,
44+
JWTRevocationStatus.self,
4545
forKey: .credentialStatus
4646
)
4747
let credentialSchema = try? container.decode(

Diff for: EdgeAgentSDK/Pollux/Sources/Models/JWT/JWTPayload.swift

+2-2
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,10 @@ struct JWTPayload {
1919
let type: Set<String>
2020
let credentialSchema: VerifiableCredentialTypeContainer?
2121
let credentialSubject: AnyCodable
22-
let credentialStatus: VerifiableCredentialTypeContainer?
2322
let refreshService: VerifiableCredentialTypeContainer?
2423
let evidence: VerifiableCredentialTypeContainer?
2524
let termsOfUse: VerifiableCredentialTypeContainer?
25+
let credentialStatus: JWTRevocationStatus?
2626

2727
/**
2828
Initializes a new instance of `JWTVerifiableCredential`.
@@ -42,7 +42,7 @@ struct JWTPayload {
4242
type: Set<String> = Set(),
4343
credentialSchema: VerifiableCredentialTypeContainer? = nil,
4444
credentialSubject: AnyCodable,
45-
credentialStatus: VerifiableCredentialTypeContainer? = nil,
45+
credentialStatus: JWTRevocationStatus? = nil,
4646
refreshService: VerifiableCredentialTypeContainer? = nil,
4747
evidence: VerifiableCredentialTypeContainer? = nil,
4848
termsOfUse: VerifiableCredentialTypeContainer? = nil
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import Domain
2+
import Foundation
3+
import Gzip
4+
import JSONWebSignature
5+
6+
struct JWTRevocationCheck {
7+
let credential: JWTCredential
8+
9+
init(credential: JWTCredential) {
10+
self.credential = credential
11+
}
12+
13+
func checkIsRevoked() async throws -> Bool {
14+
guard let status = credential.jwtVerifiableCredential.verifiableCredential.credentialStatus else {
15+
return false
16+
}
17+
18+
guard status.type == "StatusList2021Entry" else {
19+
throw UnknownError.somethingWentWrongError(customMessage: nil, underlyingErrors: nil)
20+
}
21+
22+
let listData = try await DownloadDataWithResolver()
23+
.downloadFromEndpoint(urlOrDID: status.statusListCredential)
24+
let statusList = try JSONDecoder.didComm().decode(JWTRevocationStatusListCredential.self, from: listData)
25+
let encodedList = statusList.credentialSubject.encodedList
26+
let index = status.statusListIndex
27+
return try verifyRevocationOnEncodedList(encodedList.tryToData(), index: index)
28+
}
29+
30+
func verifyRevocationOnEncodedList(_ list: Data, index: Int) throws -> Bool {
31+
let encodedListData = try list.gunzipped()
32+
let bitList = encodedListData.bytes.flatMap { $0.toBits() }
33+
guard index < bitList.count else {
34+
throw UnknownError.somethingWentWrongError(customMessage: "Revocation index out of bounds", underlyingErrors: nil)
35+
}
36+
return bitList[index]
37+
}
38+
}
39+
40+
extension UInt8 {
41+
func toBits() -> [Bool] {
42+
var bits = [Bool](repeating: false, count: 8)
43+
for i in 0..<8 {
44+
bits[7 - i] = (self & (1 << i)) != 0
45+
}
46+
return bits
47+
}
48+
}
49+
50+
fileprivate struct DownloadDataWithResolver: Downloader {
51+
52+
public func downloadFromEndpoint(urlOrDID: String) async throws -> Data {
53+
let url: URL
54+
55+
if let validUrl = URL(string: urlOrDID.replacingOccurrences(of: "host.docker.internal", with: "localhost")) {
56+
url = validUrl
57+
} else {
58+
throw CommonError.invalidURLError(url: urlOrDID)
59+
}
60+
61+
let (data, urlResponse) = try await URLSession.shared.data(from: url)
62+
63+
guard
64+
let code = (urlResponse as? HTTPURLResponse)?.statusCode,
65+
200...299 ~= code
66+
else {
67+
throw CommonError.httpError(
68+
code: (urlResponse as? HTTPURLResponse)?.statusCode ?? 500,
69+
message: String(data: data, encoding: .utf8) ?? ""
70+
)
71+
}
72+
73+
return data
74+
}
75+
}
76+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import Foundation
2+
3+
struct JWTRevocationStatus: Codable {
4+
enum CredentialStatusListType: String, Codable {
5+
case statusList2021Entry = "StatusList2021Entry"
6+
}
7+
8+
enum CredentialStatusPurpose: String, Codable {
9+
case revocation
10+
case suspension
11+
}
12+
13+
let id: String
14+
let type: String
15+
let statusPurpose: CredentialStatusPurpose
16+
let statusListIndex: Int
17+
let statusListCredential: String
18+
}
19+
20+
struct JWTRevocationStatusListCredential: Codable {
21+
struct StatusListCredentialSubject: Codable {
22+
let type: String
23+
let statusPurpose: String
24+
let encodedList: String
25+
}
26+
let context: [String]
27+
let type: [String]
28+
let id: String
29+
let issuer: String
30+
let issuanceDate: String
31+
let credentialSubject: StatusListCredentialSubject
32+
}

Diff for: EdgeAgentSDK/Pollux/Sources/PolluxImpl+CredentialVerification.swift

+15
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ extension PolluxImpl {
116116
}
117117

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

139+
private func verifyJWTCredentialRevocation(jwtString: String) async throws {
140+
guard let credential = try? JWTCredential(data: jwtString.tryToData()) else {
141+
return
142+
}
143+
let isRevoked = try await credential.isRevoked
144+
let isSuspended = try await credential.isSuspended
145+
guard isRevoked else {
146+
throw PolluxError.credentialIsRevoked(jwtString: jwtString)
147+
}
148+
guard isSuspended else {
149+
throw PolluxError.credentialIsSuspended(jwtString: jwtString)
150+
}
151+
}
152+
138153
private func getDefinition(id: String) async throws -> PresentationExchangeRequest {
139154
guard
140155
let request = try await pluto.getMessage(id: id).first().await(),

Diff for: EdgeAgentSDK/Pollux/Tests/JWTTests.swift

+8
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,12 @@ final class JWTTests: XCTestCase {
2020
XCTAssertEqual(credential.claims.map(\.key).sorted(), ["id", "test"].sorted())
2121
XCTAssertEqual(credential.id, validJWTString)
2222
}
23+
24+
func testRevoked() throws {
25+
let validJWTString = try "eyJhbGciOiJFUzI1NksifQ.eyJpc3MiOiJkaWQ6cHJpc206MmU0MGZkNjkyYjgzYzE5ZjlhNTUzNjRjMmNhNWJmNjkyOGI4ODU1NGE1YmYxMTc0YTc4ZjY4NDk4ZDgwZGZjNjpDcmNCQ3JRQkVqa0tCV3RsZVMweEVBSktMZ29KYzJWamNESTFObXN4RWlFQ1pDbDV4aUREb3ZsVFlNNVVSeXdHODZPWjc2RWNTY3NjSEplaHRnbWNKTlFTT2dvR1lYVjBhQzB4RUFSS0xnb0pjMlZqY0RJMU5tc3hFaUVDRUMzTUNPak4xb1lNZjU2ZVVBaTA3NkxGX2hRZDRwbFFib3JKcnBkOHdHY1NPd29IYldGemRHVnlNQkFCU2k0S0NYTmxZM0F5TlRack1SSWhBeTVqVkc4UTRWOHRYV0RoUWNvb2xPTmFIdTZHaW5ockJ6SEtfRXYySW9yNSIsInN1YiI6ImRpZDpwcmlzbTo4ODYwN2Y4YjE3ZWJhZmNhODgwNDdmZDQ0YTMyZTE4NGI1MGYwM2QyNWZhZWQ1ZGRiYWQyZGRjNGYyZjg5YWYzOkNzY0JDc1FCRW1RS0QyRjFkR2hsYm5ScFkyRjBhVzl1TUJBRVFrOEtDWE5sWTNBeU5UWnJNUklncnFDMVhaN2ZsOUpLSjBNT3pTa2hSZFhESHpnSVQzTGJ1MlNLdTJvZWxKVWFJT3gxSzFvY2NDRG14SS05Zm9jRm84emhpTm5BYXBPUGFXQXY0UGg0azZjWkVsd0tCMjFoYzNSbGNqQVFBVUpQQ2dselpXTndNalUyYXpFU0lLNmd0VjJlMzVmU1NpZEREczBwSVVYVnd4ODRDRTl5Mjd0a2lydHFIcFNWR2lEc2RTdGFISEFnNXNTUHZYNkhCYVBNNFlqWndHcVRqMmxnTC1ENGVKT25HUSIsIm5iZiI6MTY4ODA1ODcyNywiZXhwIjoxNjg4MDYyMzI3LCJ2YyI6eyJjcmVkZW50aWFsU2NoZW1hIjp7ImlkIjoiaHR0cHM6XC9cL2s4cy1kZXYuYXRhbGFwcmlzbS5pb1wvcHJpc20tYWdlbnRcL3NjaGVtYS1yZWdpc3RyeVwvc2NoZW1hc1wvMDIwMTY5M2ItNGQ2ZC0zNmVjLWEzN2QtODFkODhlODcyNTM5IiwidHlwZSI6IkNyZWRlbnRpYWxTY2hlbWEyMDIyIn0sImNyZWRlbnRpYWxTdWJqZWN0Ijp7InRlc3QiOiJUZXN0MSIsImlkIjoiZGlkOnByaXNtOjg4NjA3ZjhiMTdlYmFmY2E4ODA0N2ZkNDRhMzJlMTg0YjUwZjAzZDI1ZmFlZDVkZGJhZDJkZGM0ZjJmODlhZjM6Q3NjQkNzUUJFbVFLRDJGMWRHaGxiblJwWTJGMGFXOXVNQkFFUWs4S0NYTmxZM0F5TlRack1SSWdycUMxWFo3Zmw5SktKME1PelNraFJkWERIemdJVDNMYnUyU0t1Mm9lbEpVYUlPeDFLMW9jY0NEbXhJLTlmb2NGbzh6aGlObkFhcE9QYVdBdjRQaDRrNmNaRWx3S0IyMWhjM1JsY2pBUUFVSlBDZ2x6WldOd01qVTJhekVTSUs2Z3RWMmUzNWZTU2lkRERzMHBJVVhWd3g4NENFOXkyN3RraXJ0cUhwU1ZHaURzZFN0YUhIQWc1c1NQdlg2SEJhUE00WWpad0dxVGoybGdMLUQ0ZUpPbkdRIn0sInR5cGUiOlsiVmVyaWZpYWJsZUNyZWRlbnRpYWwiXSwiQGNvbnRleHQiOlsiaHR0cHM6XC9cL3d3dy53My5vcmdcLzIwMThcL2NyZWRlbnRpYWxzXC92MSJdfX0.JZBqArVFvWgj2W0b7vVPSKR3mSH_X-VOC-YQ_jyLZSOEYUkortkRGi41xwA7SPFSqPdSCHl4iagpBir1tYMBOw".tryToData()
26+
let credential = try JWTCredential(data: validJWTString)
27+
let encodedList = Data(fromBase64URL: "H4sIAAAAAAAA_-3BMQ0AAAACIGf_0MbwARoAAAAAAAAAAAAAAAAAAADgbbmHB0sAQAAA")!
28+
XCTAssertFalse(try JWTRevocationCheck(credential: credential)
29+
.verifyRevocationOnEncodedList(encodedList, index: 94567))
30+
}
2331
}

Diff for: Package.swift

+4-2
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,14 @@ let package = Package(
5757
),
5858
.package(url: "https://github.com/apple/swift-protobuf.git", from: "1.7.0"),
5959
.package(url: "https://github.com/beatt83/didcomm-swift.git", from: "0.1.8"),
60-
.package(url: "https://github.com/beatt83/jose-swift.git", from: "3.1.0"),
60+
.package(url: "https://github.com/beatt83/jose-swift.git", from: "3.2.0"),
6161
.package(url: "https://github.com/beatt83/peerdid-swift.git", from: "3.0.1"),
6262
.package(url: "https://github.com/input-output-hk/anoncreds-rs.git", exact: "0.4.1"),
6363
.package(url: "https://github.com/input-output-hk/atala-prism-apollo.git", exact: "1.3.3"),
6464
.package(url: "https://github.com/KittyMac/Sextant.git", exact: "0.4.31"),
6565
.package(url: "https://github.com/kylef/JSONSchema.swift.git", exact: "0.6.0"),
66-
.package(url: "https://github.com/goncalo-frade-iohk/eudi-lib-sdjwt-swift.git", from: "0.0.2")
66+
.package(url: "https://github.com/goncalo-frade-iohk/eudi-lib-sdjwt-swift.git", from: "0.0.2"),
67+
.package(url: "https://github.com/1024jp/GzipSwift.git", exact: "6.0.0")
6768
],
6869
targets: [
6970
.target(
@@ -121,6 +122,7 @@ let package = Package(
121122
"jose-swift",
122123
"Sextant",
123124
"eudi-lib-sdjwt-swift",
125+
.product(name: "Gzip", package: "GzipSwift"),
124126
.product(name: "AnoncredsSwift", package: "anoncreds-rs"),
125127
.product(name: "JSONSchema", package: "JSONSchema.swift")
126128
],

Diff for: Sample/AtalaPrismWalletDemo/AtalaPrismWalletDemo/Modules/WalletDemo2/Backup/BackupView.swift

+6-2
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@ protocol BackupViewModel: ObservableObject {
99

1010
struct BackupView<ViewModel: BackupViewModel>: View {
1111
@StateObject var viewModel: ViewModel
12+
@Environment(\.dismiss) var dismiss
1213
@State private var jwe: String = ""
1314
var body: some View {
14-
VStack(spacing: 10) {
15-
VStack(spacing: 8) {
15+
VStack(spacing: 25) {
16+
VStack(spacing: 10) {
1617
AtalaButton(
1718
configuration: .primary,
1819
action: {
@@ -38,6 +39,9 @@ struct BackupView<ViewModel: BackupViewModel>: View {
3839
action: {
3940
Task {
4041
try await self.viewModel.backupWith(jwe)
42+
await MainActor.run {
43+
self.dismiss()
44+
}
4145
}
4246
},
4347
label: {

Diff for: Sample/AtalaPrismWalletDemo/AtalaPrismWalletDemo/Modules/WalletDemo2/Backup/BackupViewModel.swift

+8-2
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ final class BackupViewModelImpl: BackupViewModel {
66
@Published var newJWE: String? = nil
77

88
private let agent: EdgeAgent
9-
9+
1010
init(agent: EdgeAgent) {
1111
self.agent = agent
1212
}
@@ -20,6 +20,12 @@ final class BackupViewModelImpl: BackupViewModel {
2020
}
2121

2222
func backupWith(_ jwe: String) async throws {
23-
try await agent.recoverWallet(encrypted: jwe)
23+
do {
24+
try await agent.recoverWallet(encrypted: jwe)
25+
} catch {
26+
print(error)
27+
print()
28+
throw error
29+
}
2430
}
2531
}

0 commit comments

Comments
 (0)