From 65b51134d7479550a6d6a086e0ac7eab20735af8 Mon Sep 17 00:00:00 2001 From: Ryan Tate Date: Thu, 12 Dec 2024 08:09:33 -0800 Subject: [PATCH] Add bitstring status list support for LdpVc Credentials (#54) Signed-off-by: Ryan Tate --- .../Sources/MobileSdkRs/mobile_sdk_rs.swift | 423 ++++++++++++++++++ src/credential/json_vc.rs | 42 +- src/credential/mod.rs | 14 + src/credential/status.rs | 157 +++++++ src/oid4vp/holder.rs | 4 +- src/oid4vp/verifier.rs | 5 - 6 files changed, 636 insertions(+), 9 deletions(-) create mode 100644 src/credential/status.rs diff --git a/MobileSdkRs/Sources/MobileSdkRs/mobile_sdk_rs.swift b/MobileSdkRs/Sources/MobileSdkRs/mobile_sdk_rs.swift index 50ae592..79679e1 100644 --- a/MobileSdkRs/Sources/MobileSdkRs/mobile_sdk_rs.swift +++ b/MobileSdkRs/Sources/MobileSdkRs/mobile_sdk_rs.swift @@ -384,6 +384,19 @@ fileprivate class UniffiHandleMap { // Public interface members begin here. +fileprivate struct FfiConverterUInt8: FfiConverterPrimitive { + typealias FfiType = UInt8 + typealias SwiftType = UInt8 + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> UInt8 { + return try lift(readInt(&buf)) + } + + public static func write(_ value: UInt8, into buf: inout [UInt8]) { + writeInt(&buf, lower(value)) + } +} + fileprivate struct FfiConverterUInt16: FfiConverterPrimitive { typealias FfiType = UInt16 typealias SwiftType = UInt16 @@ -1789,6 +1802,12 @@ public protocol JsonVcProtocol : AnyObject { */ func keyAlias() -> KeyAlias? + /** + * Returns the status of the credential, resolving the value in the status list, + * along with the purpose of the status. + */ + func status() async throws -> Status + /** * The type of this credential. Note that if there is more than one type (i.e. `types()` * returns more than one value), then the types will be concatenated with a "+". @@ -1904,6 +1923,27 @@ open func keyAlias() -> KeyAlias? { }) } + /** + * Returns the status of the credential, resolving the value in the status list, + * along with the purpose of the status. + */ +open func status()async throws -> Status { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_mobile_sdk_rs_fn_method_jsonvc_status( + self.uniffiClonePointer() + + ) + }, + pollFunc: ffi_mobile_sdk_rs_rust_future_poll_pointer, + completeFunc: ffi_mobile_sdk_rs_rust_future_complete_pointer, + freeFunc: ffi_mobile_sdk_rs_rust_future_free_pointer, + liftFunc: FfiConverterTypeStatus.lift, + errorHandler: FfiConverterTypeStatusListError.lift + ) +} + /** * The type of this credential. Note that if there is more than one type (i.e. `types()` * returns more than one value), then the types will be concatenated with a "+". @@ -3972,6 +4012,188 @@ public func FfiConverterTypeRequestedField_lower(_ value: RequestedField) -> Uns +/** + * Status provides a value and purpose for a status, + * + * The value is the raw value of the status at the entry list index, + * and the purpose is the purpose of the credential, which is used + * to interpret the value. + */ +public protocol StatusProtocol : AnyObject { + + /** + * Return whether the credential status has a message. + */ + func isMessage() -> Bool + + /** + * Return whether the credential status is revoked. + */ + func isRevoked() -> Bool + + /** + * Return whether the credential status is suspended. + */ + func isSuspended() -> Bool + + /** + * Return the message of the credential status. + */ + func messages() -> [StatusMessage] + + /** + * Return the purpose of the status. + */ + func purpose() -> StatusPurpose + +} + +/** + * Status provides a value and purpose for a status, + * + * The value is the raw value of the status at the entry list index, + * and the purpose is the purpose of the credential, which is used + * to interpret the value. + */ +open class Status: + StatusProtocol { + fileprivate let pointer: UnsafeMutableRawPointer! + + /// Used to instantiate a [FFIObject] without an actual pointer, for fakes in tests, mostly. + public struct NoPointer { + public init() {} + } + + // TODO: We'd like this to be `private` but for Swifty reasons, + // we can't implement `FfiConverter` without making this `required` and we can't + // make it `required` without making it `public`. + required public init(unsafeFromRawPointer pointer: UnsafeMutableRawPointer) { + self.pointer = pointer + } + + /// This constructor can be used to instantiate a fake object. + /// - Parameter noPointer: Placeholder value so we can have a constructor separate from the default empty one that may be implemented for classes extending [FFIObject]. + /// + /// - Warning: + /// Any object instantiated with this constructor cannot be passed to an actual Rust-backed object. Since there isn't a backing [Pointer] the FFI lower functions will crash. + public init(noPointer: NoPointer) { + self.pointer = nil + } + + public func uniffiClonePointer() -> UnsafeMutableRawPointer { + return try! rustCall { uniffi_mobile_sdk_rs_fn_clone_status(self.pointer, $0) } + } + // No primary constructor declared for this class. + + deinit { + guard let pointer = pointer else { + return + } + + try! rustCall { uniffi_mobile_sdk_rs_fn_free_status(pointer, $0) } + } + + + + + /** + * Return whether the credential status has a message. + */ +open func isMessage() -> Bool { + return try! FfiConverterBool.lift(try! rustCall() { + uniffi_mobile_sdk_rs_fn_method_status_is_message(self.uniffiClonePointer(),$0 + ) +}) +} + + /** + * Return whether the credential status is revoked. + */ +open func isRevoked() -> Bool { + return try! FfiConverterBool.lift(try! rustCall() { + uniffi_mobile_sdk_rs_fn_method_status_is_revoked(self.uniffiClonePointer(),$0 + ) +}) +} + + /** + * Return whether the credential status is suspended. + */ +open func isSuspended() -> Bool { + return try! FfiConverterBool.lift(try! rustCall() { + uniffi_mobile_sdk_rs_fn_method_status_is_suspended(self.uniffiClonePointer(),$0 + ) +}) +} + + /** + * Return the message of the credential status. + */ +open func messages() -> [StatusMessage] { + return try! FfiConverterSequenceTypeStatusMessage.lift(try! rustCall() { + uniffi_mobile_sdk_rs_fn_method_status_messages(self.uniffiClonePointer(),$0 + ) +}) +} + + /** + * Return the purpose of the status. + */ +open func purpose() -> StatusPurpose { + return try! FfiConverterTypeStatusPurpose.lift(try! rustCall() { + uniffi_mobile_sdk_rs_fn_method_status_purpose(self.uniffiClonePointer(),$0 + ) +}) +} + + +} + +public struct FfiConverterTypeStatus: FfiConverter { + + typealias FfiType = UnsafeMutableRawPointer + typealias SwiftType = Status + + public static func lift(_ pointer: UnsafeMutableRawPointer) throws -> Status { + return Status(unsafeFromRawPointer: pointer) + } + + public static func lower(_ value: Status) -> UnsafeMutableRawPointer { + return value.uniffiClonePointer() + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> Status { + let v: UInt64 = try readInt(&buf) + // The Rust code won't compile if a pointer won't fit in a UInt64. + // We have to go via `UInt` because that's the thing that's the size of a pointer. + let ptr = UnsafeMutableRawPointer(bitPattern: UInt(truncatingIfNeeded: v)) + if (ptr == nil) { + throw UniffiInternalError.unexpectedNullPointer + } + return try lift(ptr!) + } + + public static func write(_ value: Status, into buf: inout [UInt8]) { + // This fiddling is because `Int` is the thing that's the same size as a pointer. + // The Rust code won't compile if a pointer won't fit in a `UInt64`. + writeInt(&buf, UInt64(bitPattern: Int64(Int(bitPattern: lower(value))))) + } +} + + + + +public func FfiConverterTypeStatus_lift(_ pointer: UnsafeMutableRawPointer) throws -> Status { + return try FfiConverterTypeStatus.lift(pointer) +} + +public func FfiConverterTypeStatus_lower(_ value: Status) -> UnsafeMutableRawPointer { + return FfiConverterTypeStatus.lower(value) +} + + + + /** * Interface: StorageManagerInterface * @@ -5825,6 +6047,75 @@ public func FfiConverterTypeMDLReaderSessionData_lower(_ value: MdlReaderSession return FfiConverterTypeMDLReaderSessionData.lower(value) } + +public struct StatusMessage { + /** + * The value of the entry in the status list + */ + public var status: UInt8 + /** + * Message that corresponds the the value. + */ + public var message: String + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init( + /** + * The value of the entry in the status list + */status: UInt8, + /** + * Message that corresponds the the value. + */message: String) { + self.status = status + self.message = message + } +} + + + +extension StatusMessage: Equatable, Hashable { + public static func ==(lhs: StatusMessage, rhs: StatusMessage) -> Bool { + if lhs.status != rhs.status { + return false + } + if lhs.message != rhs.message { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(status) + hasher.combine(message) + } +} + + +public struct FfiConverterTypeStatusMessage: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> StatusMessage { + return + try StatusMessage( + status: FfiConverterUInt8.read(from: &buf), + message: FfiConverterString.read(from: &buf) + ) + } + + public static func write(_ value: StatusMessage, into buf: inout [UInt8]) { + FfiConverterUInt8.write(value.status, into: &buf) + FfiConverterString.write(value.message, into: &buf) + } +} + + +public func FfiConverterTypeStatusMessage_lift(_ buf: RustBuffer) throws -> StatusMessage { + return try FfiConverterTypeStatusMessage.lift(buf) +} + +public func FfiConverterTypeStatusMessage_lower(_ value: StatusMessage) -> RustBuffer { + return FfiConverterTypeStatusMessage.lower(value) +} + // Note that we don't yet support `indirect` for enums. // See https://github.com/mozilla/uniffi-rs/issues/396 for further discussion. /** @@ -8466,6 +8757,64 @@ extension SignatureError: Foundation.LocalizedError { } +public enum StatusListError { + + + + case Resolution(String + ) + case UnsupportedCredentialFormat +} + + +public struct FfiConverterTypeStatusListError: FfiConverterRustBuffer { + typealias SwiftType = StatusListError + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> StatusListError { + let variant: Int32 = try readInt(&buf) + switch variant { + + + + + case 1: return .Resolution( + try FfiConverterString.read(from: &buf) + ) + case 2: return .UnsupportedCredentialFormat + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: StatusListError, into buf: inout [UInt8]) { + switch value { + + + + + + case let .Resolution(v1): + writeInt(&buf, Int32(1)) + FfiConverterString.write(v1, into: &buf) + + + case .UnsupportedCredentialFormat: + writeInt(&buf, Int32(2)) + + } + } +} + + +extension StatusListError: Equatable, Hashable {} + +extension StatusListError: Foundation.LocalizedError { + public var errorDescription: String? { + String(reflecting: self) + } +} + + /** * Enum: StorageManagerError * @@ -9715,6 +10064,28 @@ fileprivate struct FfiConverterSequenceTypeItemsRequest: FfiConverterRustBuffer } } +fileprivate struct FfiConverterSequenceTypeStatusMessage: FfiConverterRustBuffer { + typealias SwiftType = [StatusMessage] + + public static func write(_ value: [StatusMessage], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypeStatusMessage.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [StatusMessage] { + let len: Int32 = try readInt(&buf) + var seq = [StatusMessage]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterTypeStatusMessage.read(from: &buf)) + } + return seq + } +} + fileprivate struct FfiConverterSequenceTypeMDocItem: FfiConverterRustBuffer { typealias SwiftType = [MDocItem] @@ -10193,6 +10564,40 @@ public func FfiConverterTypeNamespace_lower(_ value: Namespace) -> RustBuffer { +/** + * Typealias from the type name used in the UDL file to the builtin type. This + * is needed because the UDL type name is used in function/method signatures. + */ +public typealias StatusPurpose = String +public struct FfiConverterTypeStatusPurpose: FfiConverter { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> StatusPurpose { + return try FfiConverterString.read(from: &buf) + } + + public static func write(_ value: StatusPurpose, into buf: inout [UInt8]) { + return FfiConverterString.write(value, into: &buf) + } + + public static func lift(_ value: RustBuffer) throws -> StatusPurpose { + return try FfiConverterString.lift(value) + } + + public static func lower(_ value: StatusPurpose) -> RustBuffer { + return FfiConverterString.lower(value) + } +} + + +public func FfiConverterTypeStatusPurpose_lift(_ value: RustBuffer) throws -> StatusPurpose { + return try FfiConverterTypeStatusPurpose.lift(value) +} + +public func FfiConverterTypeStatusPurpose_lower(_ value: StatusPurpose) -> RustBuffer { + return FfiConverterTypeStatusPurpose.lower(value) +} + + + /** * Typealias from the type name used in the UDL file to the builtin type. This * is needed because the UDL type name is used in function/method signatures. @@ -10733,6 +11138,9 @@ private var initializationResult: InitializationResult = { if (uniffi_mobile_sdk_rs_checksum_method_jsonvc_key_alias() != 36306) { return InitializationResult.apiChecksumMismatch } + if (uniffi_mobile_sdk_rs_checksum_method_jsonvc_status() != 56187) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_mobile_sdk_rs_checksum_method_jsonvc_type() != 48063) { return InitializationResult.apiChecksumMismatch } @@ -10898,6 +11306,21 @@ private var initializationResult: InitializationResult = { if (uniffi_mobile_sdk_rs_checksum_method_requestedfield_retained() != 21715) { return InitializationResult.apiChecksumMismatch } + if (uniffi_mobile_sdk_rs_checksum_method_status_is_message() != 61380) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_mobile_sdk_rs_checksum_method_status_is_revoked() != 37392) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_mobile_sdk_rs_checksum_method_status_is_suspended() != 54379) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_mobile_sdk_rs_checksum_method_status_messages() != 26217) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_mobile_sdk_rs_checksum_method_status_purpose() != 51769) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_mobile_sdk_rs_checksum_method_storagemanagerinterface_add() != 60217) { return InitializationResult.apiChecksumMismatch } diff --git a/src/credential/json_vc.rs b/src/credential/json_vc.rs index a79e84f..04bc5f2 100644 --- a/src/credential/json_vc.rs +++ b/src/credential/json_vc.rs @@ -1,4 +1,7 @@ -use super::{Credential, CredentialEncodingError, CredentialFormat, VcdmVersion}; +use super::{ + status::{BitStringStatusListResolver, Status, StatusListError}, + Credential, CredentialEncodingError, CredentialFormat, VcdmVersion, +}; use crate::{ oid4vp::{ error::OID4VPError, @@ -17,6 +20,7 @@ use openid4vp::{ JsonPath, }; use serde_json::Value as Json; +use ssi::status::bitstring_status_list::BitstringStatusListEntry; use ssi::{ claims::vc::{ syntax::{IdOr, NonEmptyObject, NonEmptyVec}, @@ -61,7 +65,7 @@ pub struct JsonVc { key_alias: Option, } -#[uniffi::export] +#[uniffi::export(async_runtime = "tokio")] impl JsonVc { #[uniffi::constructor] /// Construct a new credential from UTF-8 encoded JSON. @@ -120,6 +124,12 @@ impl JsonVc { ssi::claims::vc::AnySpecializedJsonCredential::V2(vc) => vc.additional_types().to_vec(), } } + + /// Returns the status of the credential, resolving the value in the status list, + /// along with the purpose of the status. + pub async fn status(&self) -> Result { + self.status_list_value().await + } } impl JsonVc { @@ -251,6 +261,7 @@ impl CredentialPresentation for JsonVc { .map(|p| p.clone().into()) .collect::>(); } + let holder_id = IdOr::Id(options.signer.did().parse().map_err(|e| { CredentialEncodingError::VpToken(format!("Error parsing DID: {e:?}")) })?); @@ -268,6 +279,33 @@ impl CredentialPresentation for JsonVc { } } +impl BitStringStatusListResolver for JsonVc { + fn status_list_entry(&self) -> Result { + let value = match &self.parsed { + AnyJsonCredential::V1(credential) => credential + .credential_status + .first() + .map(serde_json::to_value), + AnyJsonCredential::V2(credential) => credential + .credential_status + .first() + .map(serde_json::to_value), + } + .ok_or(StatusListError::Resolution( + "Credential status not found in credential".into(), + ))? + .map_err(|e| StatusListError::Resolution(format!("{e:?}")))?; + + let entry = serde_json::from_value(value).map_err(|e| { + StatusListError::Resolution(format!("Failed to parse credential status: {e:?}")) + })?; + + Ok(entry) + } + + // NOTE: The remaining methods are default implemented in the trait. +} + impl TryFrom for Arc { type Error = JsonVcInitError; diff --git a/src/credential/mod.rs b/src/credential/mod.rs index 6ac767a..8946fd3 100644 --- a/src/credential/mod.rs +++ b/src/credential/mod.rs @@ -1,6 +1,7 @@ pub mod json_vc; pub mod jwt_vc; pub mod mdoc; +pub mod status; pub mod vcdm2_sd_jwt; use std::sync::Arc; @@ -21,6 +22,7 @@ use openid4vp::core::{ response::parameters::VpTokenItem, }; use serde::{Deserialize, Serialize}; +use status::BitStringStatusListResolver; use vcdm2_sd_jwt::{SdJwtError, VCDM2SdJwt}; /// An unparsed credential, retrieved from storage. @@ -319,6 +321,18 @@ impl ParsedCredential { } } +impl BitStringStatusListResolver for ParsedCredential { + fn status_list_entry( + &self, + ) -> Result + { + match &self.inner { + ParsedCredentialInner::LdpVc(cred) => cred.status_list_entry(), + _ => Err(status::StatusListError::UnsupportedCredentialFormat), + } + } +} + impl TryFrom for Arc { type Error = CredentialDecodingError; diff --git a/src/credential/status.rs b/src/credential/status.rs new file mode 100644 index 0000000..85aa309 --- /dev/null +++ b/src/credential/status.rs @@ -0,0 +1,157 @@ +use std::str::FromStr; + +use reqwest::StatusCode; +use ssi::status::bitstring_status_list::{ + BitString, BitstringStatusListCredential, BitstringStatusListEntry, + StatusMessage as BitStringStatusMessage, StatusPurpose, +}; +use url::Url; + +use crate::UniffiCustomTypeConverter; + +#[derive(Debug, uniffi::Error, thiserror::Error)] +pub enum StatusListError { + #[error("Failed to resolve status list credential: {0}")] + Resolution(String), + #[error("Credential Format Not Supported for Status List")] + UnsupportedCredentialFormat, +} + +uniffi::custom_type!(StatusPurpose, String); +impl UniffiCustomTypeConverter for StatusPurpose { + type Builtin = String; + fn into_custom(purpose: Self::Builtin) -> uniffi::Result { + let custom = StatusPurpose::from_str(&purpose) + .map_err(|e| StatusListError::Resolution(format!("{e:?}")))?; + + Ok(custom) + } + fn from_custom(purpose: Self) -> Self::Builtin { + purpose.to_string() + } +} + +#[derive(uniffi::Record, Debug, Clone)] +pub struct StatusMessage { + /// The value of the entry in the status list + pub status: u8, + /// Message that corresponds the the value. + pub message: String, +} + +impl From for StatusMessage { + fn from(status_message: BitStringStatusMessage) -> Self { + Self { + status: status_message.status, + message: status_message.message, + } + } +} + +/// Status provides a value and purpose for a status, +/// +/// The value is the raw value of the status at the entry list index, +/// and the purpose is the purpose of the credential, which is used +/// to interpret the value. +#[derive(Debug, uniffi::Object)] +pub struct Status { + /// The raw value of the status at the entry list index, + /// which depends on the purpose of the status for its + /// meaning. + pub(crate) value: u8, + /// The purpose of the credential. + pub(crate) purpose: StatusPurpose, + /// List of status messages to include if the purpose is a message. + pub status_messages: Vec, +} + +#[uniffi::export] +impl Status { + /// Return the purpose of the status. + pub fn purpose(&self) -> StatusPurpose { + self.purpose + } + + /// Return whether the credential status is revoked. + pub fn is_revoked(&self) -> bool { + self.purpose == StatusPurpose::Revocation && self.value == 1 + } + + /// Return whether the credential status is suspended. + pub fn is_suspended(&self) -> bool { + self.purpose == StatusPurpose::Suspension && self.value == 1 + } + + /// Return whether the credential status has a message. + pub fn is_message(&self) -> bool { + self.purpose == StatusPurpose::Message + } + + /// Return the message of the credential status. + pub fn messages(&self) -> Vec { + self.status_messages.clone() + } +} + +/// Interface for resolving the status of a credential +/// using a bitstring status list credential. +/// +/// Only the `entry` method is required to be implemented. +#[async_trait::async_trait] +pub trait BitStringStatusListResolver { + /// Returns the BitstringStatusListEntry of the credential. + fn status_list_entry(&self) -> Result; + + /// Resolves the status list as an `BitstringStatusList` type. + async fn status_list_credential( + &self, + ) -> Result { + let entry = self.status_list_entry()?; + let url: Url = entry + .status_list_credential + .parse() + .map_err(|e| StatusListError::Resolution(format!("{e:?}")))?; + + let response = reqwest::get(url) + .await + .map_err(|e| StatusListError::Resolution(format!("{e:?}")))?; + + if response.status() != StatusCode::OK { + return Err(StatusListError::Resolution(format!( + "Failed to resolve status list credential: {}", + response.status() + ))); + } + + response + .json() + .await + .map_err(|e| StatusListError::Resolution(format!("{e:?}"))) + } + + /// Returns the status of the credential, returning + /// an object that provides the value in the status list, + /// and the purpose of the status. + async fn status_list_value(&self) -> Result { + let entry = self.status_list_entry()?; + let credential = self.status_list_credential().await?; + let bit_string = credential + .credential_subject + .encoded_list + .decode(None) + .map(BitString::from_bytes) + .map_err(|e| StatusListError::Resolution(format!("{e:?}")))?; + + let value = bit_string + .get(entry.status_size, entry.status_list_index) + .ok_or(StatusListError::Resolution( + "No status found at index".to_string(), + ))?; + + Ok(Status { + value, + purpose: credential.credential_subject.status_purpose, + status_messages: entry.status_messages.into_iter().map(Into::into).collect(), + }) + } +} diff --git a/src/oid4vp/holder.rs b/src/oid4vp/holder.rs index b6599ff..b045ed1 100644 --- a/src/oid4vp/holder.rs +++ b/src/oid4vp/holder.rs @@ -337,11 +337,11 @@ pub(crate) mod tests { async fn sign(&self, payload: Vec) -> Result, PresentationError> { let sig = self .jwk - .sign(payload) + .sign_bytes(&payload) .await .expect("failed to sign Jws Payload"); - Ok(sig.as_bytes().to_vec()) + Ok(sig) } fn algorithm(&self) -> Algorithm { diff --git a/src/oid4vp/verifier.rs b/src/oid4vp/verifier.rs index 744a51f..3751bc9 100644 --- a/src/oid4vp/verifier.rs +++ b/src/oid4vp/verifier.rs @@ -175,10 +175,6 @@ mod tests { .await .expect("authorization request failed"); - request.credentials().iter().for_each(|c| { - println!("Credential: {:?}", c); - }); - let response = request .create_permission_response(request.credentials()) .await @@ -197,7 +193,6 @@ mod tests { assert_eq!(status, DelegatedVerifierStatus::Success); assert!(oid4vp.is_some()); - println!("Presentation: {:?}", oid4vp); Ok(()) } }