diff --git a/bindings/web5_uniffi/src/lib.rs b/bindings/web5_uniffi/src/lib.rs index b6a4e787..98fb38e7 100644 --- a/bindings/web5_uniffi/src/lib.rs +++ b/bindings/web5_uniffi/src/lib.rs @@ -1,6 +1,7 @@ use web5_uniffi_wrapper::{ credentials::{ presentation_definition::PresentationDefinition, + status_list_credential::StatusListCredential, verifiable_credential_1_1::{ VerifiableCredential, VerifiableCredentialCreateOptions as VerifiableCredentialCreateOptionsData, @@ -33,6 +34,7 @@ use web5_uniffi_wrapper::{ }; use web5::{ + credentials::verifiable_credential_1_1::CredentialStatus as CredentialStatusData, credentials::CredentialSchema as CredentialSchemaData, crypto::{dsa::Dsa, jwk::Jwk as JwkData}, dids::{ diff --git a/bindings/web5_uniffi/src/web5.udl b/bindings/web5_uniffi/src/web5.udl index fca37de0..40533579 100644 --- a/bindings/web5_uniffi/src/web5.udl +++ b/bindings/web5_uniffi/src/web5.udl @@ -257,6 +257,7 @@ dictionary VerifiableCredentialCreateOptionsData { sequence? type; timestamp? issuance_date; timestamp? expiration_date; + CredentialStatusData? credential_status; CredentialSchemaData? credential_schema; string? json_serialized_evidence; }; @@ -284,6 +285,7 @@ dictionary VerifiableCredentialData { string json_serialized_credential_subject; timestamp issuance_date; timestamp? expiration_date; + CredentialStatusData? credential_status; CredentialSchemaData? credential_schema; string? json_serialized_evidence; }; @@ -291,4 +293,26 @@ dictionary VerifiableCredentialData { dictionary CredentialSchemaData { string id; string type; +}; + +dictionary CredentialStatusData { + string id; + string type; + string status_purpose; + string status_list_index; + string status_list_credential; +}; + +interface StatusListCredential { + [Throws=Web5Error, Name=create] + constructor( + string json_serialized_issuer, + string status_purpose, + sequence? disabled_credentials + ); + + [Throws=Web5Error] + VerifiableCredential get_base(); + [Throws=Web5Error] + boolean is_disabled(VerifiableCredential credential); }; \ No newline at end of file diff --git a/bindings/web5_uniffi_wrapper/src/credentials/mod.rs b/bindings/web5_uniffi_wrapper/src/credentials/mod.rs index e2356aca..00344386 100644 --- a/bindings/web5_uniffi_wrapper/src/credentials/mod.rs +++ b/bindings/web5_uniffi_wrapper/src/credentials/mod.rs @@ -1,2 +1,3 @@ pub mod presentation_definition; +pub mod status_list_credential; pub mod verifiable_credential_1_1; diff --git a/bindings/web5_uniffi_wrapper/src/credentials/status_list_credential.rs b/bindings/web5_uniffi_wrapper/src/credentials/status_list_credential.rs new file mode 100644 index 00000000..ad71a5ae --- /dev/null +++ b/bindings/web5_uniffi_wrapper/src/credentials/status_list_credential.rs @@ -0,0 +1,43 @@ +use crate::credentials::verifiable_credential_1_1::VerifiableCredential; +use crate::errors::Result; +use std::sync::Arc; +use web5::credentials::Issuer; +use web5::{ + credentials::verifiable_credential_1_1::VerifiableCredential as InnerVerifiableCredential, + credentials::StatusListCredential as InnerStatusListCredential, json::FromJson, +}; + +pub struct StatusListCredential(pub InnerStatusListCredential); + +impl StatusListCredential { + pub fn create( + json_serialized_issuer: String, + status_purpose: String, + credentials_to_disable: Option>>, + ) -> Result { + let issuer = Issuer::from_json_string(&json_serialized_issuer)?; + + let inner_vcs: Option> = + credentials_to_disable.map(|credentials| { + credentials + .into_iter() + .map(|vc| vc.inner_vc.clone()) + .collect() + }); + + Ok(Self(InnerStatusListCredential::create( + issuer, + status_purpose, + inner_vcs, + )?)) + } + + pub fn get_base(&self) -> Result> { + let vc = VerifiableCredential::from_inner(&self.0.base)?; + Ok(Arc::new(vc)) + } + + pub fn is_disabled(&self, credential: Arc) -> Result { + Ok(self.0.is_disabled(&credential.inner_vc.clone())?) + } +} diff --git a/bindings/web5_uniffi_wrapper/src/credentials/verifiable_credential_1_1.rs b/bindings/web5_uniffi_wrapper/src/credentials/verifiable_credential_1_1.rs index bf2649cd..f254ea20 100644 --- a/bindings/web5_uniffi_wrapper/src/credentials/verifiable_credential_1_1.rs +++ b/bindings/web5_uniffi_wrapper/src/credentials/verifiable_credential_1_1.rs @@ -1,12 +1,15 @@ use crate::{dids::bearer_did::BearerDid, errors::Result}; use std::{sync::Arc, time::SystemTime}; +use web5::credentials::verifiable_credential_1_1::CredentialStatus; +use web5::credentials::Issuer; +use web5::json::ToJson; use web5::{ credentials::{ verifiable_credential_1_1::{ VerifiableCredential as InnerVerifiableCredential, VerifiableCredentialCreateOptions as InnerVerifiableCredentialCreateOptions, }, - CredentialSchema, CredentialSubject, Issuer, + CredentialSchema, CredentialSubject, }, json::{FromJson as _, JsonObject}, }; @@ -18,12 +21,13 @@ pub struct VerifiableCredentialCreateOptions { pub r#type: Option>, pub issuance_date: Option, pub expiration_date: Option, + pub credential_status: Option, pub credential_schema: Option, pub json_serialized_evidence: Option, } pub struct VerifiableCredential { - inner_vc: InnerVerifiableCredential, + pub inner_vc: InnerVerifiableCredential, json_serialized_issuer: String, json_serialized_credential_subject: String, } @@ -51,6 +55,7 @@ impl VerifiableCredential { r#type: options.r#type, issuance_date: options.issuance_date, expiration_date: options.expiration_date, + credential_status: options.credential_status, credential_schema: options.credential_schema, evidence, }; @@ -79,6 +84,7 @@ impl VerifiableCredential { json_serialized_credential_subject: self.json_serialized_credential_subject.clone(), issuance_date: self.inner_vc.issuance_date, expiration_date: self.inner_vc.expiration_date, + credential_status: self.inner_vc.credential_status.clone(), credential_schema: self.inner_vc.credential_schema.clone(), json_serialized_evidence, }) @@ -105,6 +111,16 @@ impl VerifiableCredential { let vc_jwt = self.inner_vc.sign(&bearer_did.0, verification_method_id)?; Ok(vc_jwt) } + + pub(crate) fn from_inner(inner_vc: &InnerVerifiableCredential) -> Result { + let json_serialized_issuer = inner_vc.issuer.to_json_string()?; + let json_serialized_credential_subject = inner_vc.credential_subject.to_json_string()?; + Ok(Self { + inner_vc: inner_vc.clone(), + json_serialized_issuer, + json_serialized_credential_subject, + }) + } } #[derive(Clone)] @@ -116,6 +132,7 @@ pub struct VerifiableCredentialData { pub json_serialized_credential_subject: String, pub issuance_date: SystemTime, pub expiration_date: Option, + pub credential_status: Option, pub credential_schema: Option, pub json_serialized_evidence: Option, } diff --git a/bound/kt/src/main/kotlin/web5/sdk/rust/UniFFI.kt b/bound/kt/src/main/kotlin/web5/sdk/rust/UniFFI.kt index c744941f..4573c97a 100644 --- a/bound/kt/src/main/kotlin/web5/sdk/rust/UniFFI.kt +++ b/bound/kt/src/main/kotlin/web5/sdk/rust/UniFFI.kt @@ -896,6 +896,14 @@ internal open class UniffiVTableCallbackInterfaceVerifier( + + + + + + + + @@ -1058,6 +1066,16 @@ internal interface UniffiLib : Library { ): Unit fun uniffi_web5_uniffi_fn_method_signer_sign(`ptr`: Pointer,`payload`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue + fun uniffi_web5_uniffi_fn_clone_statuslistcredential(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, + ): Pointer + fun uniffi_web5_uniffi_fn_free_statuslistcredential(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, + ): Unit + fun uniffi_web5_uniffi_fn_constructor_statuslistcredential_create(`jsonSerializedIssuer`: RustBuffer.ByValue,`statusPurpose`: RustBuffer.ByValue,`disabledCredentials`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): Pointer + fun uniffi_web5_uniffi_fn_method_statuslistcredential_get_base(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, + ): Pointer + fun uniffi_web5_uniffi_fn_method_statuslistcredential_is_disabled(`ptr`: Pointer,`credential`: Pointer,uniffi_out_err: UniffiRustCallStatus, + ): Byte fun uniffi_web5_uniffi_fn_clone_verifiablecredential(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Pointer fun uniffi_web5_uniffi_fn_free_verifiablecredential(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, @@ -1268,6 +1286,10 @@ internal interface UniffiLib : Library { ): Short fun uniffi_web5_uniffi_checksum_method_signer_sign( ): Short + fun uniffi_web5_uniffi_checksum_method_statuslistcredential_get_base( + ): Short + fun uniffi_web5_uniffi_checksum_method_statuslistcredential_is_disabled( + ): Short fun uniffi_web5_uniffi_checksum_method_verifiablecredential_get_data( ): Short fun uniffi_web5_uniffi_checksum_method_verifiablecredential_sign( @@ -1300,6 +1322,8 @@ internal interface UniffiLib : Library { ): Short fun uniffi_web5_uniffi_checksum_constructor_resolutionresult_resolve( ): Short + fun uniffi_web5_uniffi_checksum_constructor_statuslistcredential_create( + ): Short fun uniffi_web5_uniffi_checksum_constructor_verifiablecredential_create( ): Short fun uniffi_web5_uniffi_checksum_constructor_verifiablecredential_from_vc_jwt( @@ -1414,6 +1438,12 @@ private fun uniffiCheckApiChecksums(lib: UniffiLib) { if (lib.uniffi_web5_uniffi_checksum_method_signer_sign() != 5738.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_web5_uniffi_checksum_method_statuslistcredential_get_base() != 15197.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_web5_uniffi_checksum_method_statuslistcredential_is_disabled() != 23900.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_web5_uniffi_checksum_method_verifiablecredential_get_data() != 24514.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -1462,6 +1492,9 @@ private fun uniffiCheckApiChecksums(lib: UniffiLib) { if (lib.uniffi_web5_uniffi_checksum_constructor_resolutionresult_resolve() != 11404.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_web5_uniffi_checksum_constructor_statuslistcredential_create() != 49374.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_web5_uniffi_checksum_constructor_verifiablecredential_create() != 31236.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -5242,6 +5275,266 @@ public object FfiConverterTypeSigner: FfiConverter { // +public interface StatusListCredentialInterface { + + fun `getBase`(): VerifiableCredential + + fun `isDisabled`(`credential`: VerifiableCredential): kotlin.Boolean + + companion object +} + +open class StatusListCredential: Disposable, AutoCloseable, StatusListCredentialInterface { + + constructor(pointer: Pointer) { + this.pointer = pointer + this.cleanable = UniffiLib.CLEANER.register(this, UniffiCleanAction(pointer)) + } + + /** + * This constructor can be used to instantiate a fake object. Only used for tests. Any + * attempt to actually use an object constructed this way will fail as there is no + * connected Rust object. + */ + @Suppress("UNUSED_PARAMETER") + constructor(noPointer: NoPointer) { + this.pointer = null + this.cleanable = UniffiLib.CLEANER.register(this, UniffiCleanAction(pointer)) + } + + protected val pointer: Pointer? + protected val cleanable: UniffiCleaner.Cleanable + + private val wasDestroyed = AtomicBoolean(false) + private val callCounter = AtomicLong(1) + + override fun destroy() { + // Only allow a single call to this method. + // TODO: maybe we should log a warning if called more than once? + if (this.wasDestroyed.compareAndSet(false, true)) { + // This decrement always matches the initial count of 1 given at creation time. + if (this.callCounter.decrementAndGet() == 0L) { + cleanable.clean() + } + } + } + + @Synchronized + override fun close() { + this.destroy() + } + + internal inline fun callWithPointer(block: (ptr: Pointer) -> R): R { + // Check and increment the call counter, to keep the object alive. + // This needs a compare-and-set retry loop in case of concurrent updates. + do { + val c = this.callCounter.get() + if (c == 0L) { + throw IllegalStateException("${this.javaClass.simpleName} object has already been destroyed") + } + if (c == Long.MAX_VALUE) { + throw IllegalStateException("${this.javaClass.simpleName} call counter would overflow") + } + } while (! this.callCounter.compareAndSet(c, c + 1L)) + // Now we can safely do the method call without the pointer being freed concurrently. + try { + return block(this.uniffiClonePointer()) + } finally { + // This decrement always matches the increment we performed above. + if (this.callCounter.decrementAndGet() == 0L) { + cleanable.clean() + } + } + } + + // Use a static inner class instead of a closure so as not to accidentally + // capture `this` as part of the cleanable's action. + private class UniffiCleanAction(private val pointer: Pointer?) : Runnable { + override fun run() { + pointer?.let { ptr -> + uniffiRustCall { status -> + UniffiLib.INSTANCE.uniffi_web5_uniffi_fn_free_statuslistcredential(ptr, status) + } + } + } + } + + fun uniffiClonePointer(): Pointer { + return uniffiRustCall() { status -> + UniffiLib.INSTANCE.uniffi_web5_uniffi_fn_clone_statuslistcredential(pointer!!, status) + } + } + + + @Throws(Web5Exception::class)override fun `getBase`(): VerifiableCredential { + return FfiConverterTypeVerifiableCredential.lift( + callWithPointer { + uniffiRustCallWithError(Web5Exception) { _status -> + UniffiLib.INSTANCE.uniffi_web5_uniffi_fn_method_statuslistcredential_get_base( + it, _status) +} + } + ) + } + + + + @Throws(Web5Exception::class)override fun `isDisabled`(`credential`: VerifiableCredential): kotlin.Boolean { + return FfiConverterBoolean.lift( + callWithPointer { + uniffiRustCallWithError(Web5Exception) { _status -> + UniffiLib.INSTANCE.uniffi_web5_uniffi_fn_method_statuslistcredential_is_disabled( + it, FfiConverterTypeVerifiableCredential.lower(`credential`),_status) +} + } + ) + } + + + + + + companion object { + + @Throws(Web5Exception::class) fun `create`(`jsonSerializedIssuer`: kotlin.String, `statusPurpose`: kotlin.String, `disabledCredentials`: List?): StatusListCredential { + return FfiConverterTypeStatusListCredential.lift( + uniffiRustCallWithError(Web5Exception) { _status -> + UniffiLib.INSTANCE.uniffi_web5_uniffi_fn_constructor_statuslistcredential_create( + FfiConverterString.lower(`jsonSerializedIssuer`),FfiConverterString.lower(`statusPurpose`),FfiConverterOptionalSequenceTypeVerifiableCredential.lower(`disabledCredentials`),_status) +} + ) + } + + + + } + +} + +public object FfiConverterTypeStatusListCredential: FfiConverter { + + override fun lower(value: StatusListCredential): Pointer { + return value.uniffiClonePointer() + } + + override fun lift(value: Pointer): StatusListCredential { + return StatusListCredential(value) + } + + override fun read(buf: ByteBuffer): StatusListCredential { + // The Rust code always writes pointers as 8 bytes, and will + // fail to compile if they don't fit. + return lift(Pointer(buf.getLong())) + } + + override fun allocationSize(value: StatusListCredential) = 8UL + + override fun write(value: StatusListCredential, buf: ByteBuffer) { + // The Rust code always expects pointers written as 8 bytes, + // and will fail to compile if they don't fit. + buf.putLong(Pointer.nativeValue(lower(value))) + } +} + + +// This template implements a class for working with a Rust struct via a Pointer/Arc +// to the live Rust struct on the other side of the FFI. +// +// Each instance implements core operations for working with the Rust `Arc` and the +// Kotlin Pointer to work with the live Rust struct on the other side of the FFI. +// +// There's some subtlety here, because we have to be careful not to operate on a Rust +// struct after it has been dropped, and because we must expose a public API for freeing +// theq Kotlin wrapper object in lieu of reliable finalizers. The core requirements are: +// +// * Each instance holds an opaque pointer to the underlying Rust struct. +// Method calls need to read this pointer from the object's state and pass it in to +// the Rust FFI. +// +// * When an instance is no longer needed, its pointer should be passed to a +// special destructor function provided by the Rust FFI, which will drop the +// underlying Rust struct. +// +// * Given an instance, calling code is expected to call the special +// `destroy` method in order to free it after use, either by calling it explicitly +// or by using a higher-level helper like the `use` method. Failing to do so risks +// leaking the underlying Rust struct. +// +// * We can't assume that calling code will do the right thing, and must be prepared +// to handle Kotlin method calls executing concurrently with or even after a call to +// `destroy`, and to handle multiple (possibly concurrent!) calls to `destroy`. +// +// * We must never allow Rust code to operate on the underlying Rust struct after +// the destructor has been called, and must never call the destructor more than once. +// Doing so may trigger memory unsafety. +// +// * To mitigate many of the risks of leaking memory and use-after-free unsafety, a `Cleaner` +// is implemented to call the destructor when the Kotlin object becomes unreachable. +// This is done in a background thread. This is not a panacea, and client code should be aware that +// 1. the thread may starve if some there are objects that have poorly performing +// `drop` methods or do significant work in their `drop` methods. +// 2. the thread is shared across the whole library. This can be tuned by using `android_cleaner = true`, +// or `android = true` in the [`kotlin` section of the `uniffi.toml` file](https://mozilla.github.io/uniffi-rs/kotlin/configuration.html). +// +// If we try to implement this with mutual exclusion on access to the pointer, there is the +// possibility of a race between a method call and a concurrent call to `destroy`: +// +// * Thread A starts a method call, reads the value of the pointer, but is interrupted +// before it can pass the pointer over the FFI to Rust. +// * Thread B calls `destroy` and frees the underlying Rust struct. +// * Thread A resumes, passing the already-read pointer value to Rust and triggering +// a use-after-free. +// +// One possible solution would be to use a `ReadWriteLock`, with each method call taking +// a read lock (and thus allowed to run concurrently) and the special `destroy` method +// taking a write lock (and thus blocking on live method calls). However, we aim not to +// generate methods with any hidden blocking semantics, and a `destroy` method that might +// block if called incorrectly seems to meet that bar. +// +// So, we achieve our goals by giving each instance an associated `AtomicLong` counter to track +// the number of in-flight method calls, and an `AtomicBoolean` flag to indicate whether `destroy` +// has been called. These are updated according to the following rules: +// +// * The initial value of the counter is 1, indicating a live object with no in-flight calls. +// The initial value for the flag is false. +// +// * At the start of each method call, we atomically check the counter. +// If it is 0 then the underlying Rust struct has already been destroyed and the call is aborted. +// If it is nonzero them we atomically increment it by 1 and proceed with the method call. +// +// * At the end of each method call, we atomically decrement and check the counter. +// If it has reached zero then we destroy the underlying Rust struct. +// +// * When `destroy` is called, we atomically flip the flag from false to true. +// If the flag was already true we silently fail. +// Otherwise we atomically decrement and check the counter. +// If it has reached zero then we destroy the underlying Rust struct. +// +// Astute readers may observe that this all sounds very similar to the way that Rust's `Arc` works, +// and indeed it is, with the addition of a flag to guard against multiple calls to `destroy`. +// +// The overall effect is that the underlying Rust struct is destroyed only when `destroy` has been +// called *and* all in-flight method calls have completed, avoiding violating any of the expectations +// of the underlying Rust code. +// +// This makes a cleaner a better alternative to _not_ calling `destroy()` as +// and when the object is finished with, but the abstraction is not perfect: if the Rust object's `drop` +// method is slow, and/or there are many objects to cleanup, and it's on a low end Android device, then the cleaner +// thread may be starved, and the app will leak memory. +// +// In this case, `destroy`ing manually may be a better solution. +// +// The cleaner can live side by side with the manual calling of `destroy`. In the order of responsiveness, uniffi objects +// with Rust peers are reclaimed: +// +// 1. By calling the `destroy` method of the object, which calls `rustObject.free()`. If that doesn't happen: +// 2. When the object becomes unreachable, AND the Cleaner thread gets to call `rustObject.free()`. If the thread is starved then: +// 3. The memory is reclaimed when the process terminates. +// +// [1] https://stackoverflow.com/questions/24376768/can-java-finalize-an-object-when-it-is-still-in-scope/24380219 +// + + public interface VerifiableCredentialInterface { fun `getData`(): VerifiableCredentialData @@ -5760,6 +6053,47 @@ public object FfiConverterTypeCredentialSchemaData: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): CredentialStatusData { + return CredentialStatusData( + FfiConverterString.read(buf), + FfiConverterString.read(buf), + FfiConverterString.read(buf), + FfiConverterString.read(buf), + FfiConverterString.read(buf), + ) + } + + override fun allocationSize(value: CredentialStatusData) = ( + FfiConverterString.allocationSize(value.`id`) + + FfiConverterString.allocationSize(value.`type`) + + FfiConverterString.allocationSize(value.`statusPurpose`) + + FfiConverterString.allocationSize(value.`statusListIndex`) + + FfiConverterString.allocationSize(value.`statusListCredential`) + ) + + override fun write(value: CredentialStatusData, buf: ByteBuffer) { + FfiConverterString.write(value.`id`, buf) + FfiConverterString.write(value.`type`, buf) + FfiConverterString.write(value.`statusPurpose`, buf) + FfiConverterString.write(value.`statusListIndex`, buf) + FfiConverterString.write(value.`statusListCredential`, buf) + } +} + + + data class DidData ( var `uri`: kotlin.String, var `url`: kotlin.String, @@ -6262,6 +6596,7 @@ data class VerifiableCredentialCreateOptionsData ( var `type`: List?, var `issuanceDate`: java.time.Instant?, var `expirationDate`: java.time.Instant?, + var `credentialStatus`: CredentialStatusData?, var `credentialSchema`: CredentialSchemaData?, var `jsonSerializedEvidence`: kotlin.String? ) { @@ -6277,6 +6612,7 @@ public object FfiConverterTypeVerifiableCredentialCreateOptionsData: FfiConverte FfiConverterOptionalSequenceString.read(buf), FfiConverterOptionalTimestamp.read(buf), FfiConverterOptionalTimestamp.read(buf), + FfiConverterOptionalTypeCredentialStatusData.read(buf), FfiConverterOptionalTypeCredentialSchemaData.read(buf), FfiConverterOptionalString.read(buf), ) @@ -6288,6 +6624,7 @@ public object FfiConverterTypeVerifiableCredentialCreateOptionsData: FfiConverte FfiConverterOptionalSequenceString.allocationSize(value.`type`) + FfiConverterOptionalTimestamp.allocationSize(value.`issuanceDate`) + FfiConverterOptionalTimestamp.allocationSize(value.`expirationDate`) + + FfiConverterOptionalTypeCredentialStatusData.allocationSize(value.`credentialStatus`) + FfiConverterOptionalTypeCredentialSchemaData.allocationSize(value.`credentialSchema`) + FfiConverterOptionalString.allocationSize(value.`jsonSerializedEvidence`) ) @@ -6298,6 +6635,7 @@ public object FfiConverterTypeVerifiableCredentialCreateOptionsData: FfiConverte FfiConverterOptionalSequenceString.write(value.`type`, buf) FfiConverterOptionalTimestamp.write(value.`issuanceDate`, buf) FfiConverterOptionalTimestamp.write(value.`expirationDate`, buf) + FfiConverterOptionalTypeCredentialStatusData.write(value.`credentialStatus`, buf) FfiConverterOptionalTypeCredentialSchemaData.write(value.`credentialSchema`, buf) FfiConverterOptionalString.write(value.`jsonSerializedEvidence`, buf) } @@ -6313,6 +6651,7 @@ data class VerifiableCredentialData ( var `jsonSerializedCredentialSubject`: kotlin.String, var `issuanceDate`: java.time.Instant, var `expirationDate`: java.time.Instant?, + var `credentialStatus`: CredentialStatusData?, var `credentialSchema`: CredentialSchemaData?, var `jsonSerializedEvidence`: kotlin.String? ) { @@ -6330,6 +6669,7 @@ public object FfiConverterTypeVerifiableCredentialData: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): CredentialStatusData? { + if (buf.get().toInt() == 0) { + return null + } + return FfiConverterTypeCredentialStatusData.read(buf) + } + + override fun allocationSize(value: CredentialStatusData?): ULong { + if (value == null) { + return 1UL + } else { + return 1UL + FfiConverterTypeCredentialStatusData.allocationSize(value) + } + } + + override fun write(value: CredentialStatusData?, buf: ByteBuffer) { + if (value == null) { + buf.put(0) + } else { + buf.put(1) + FfiConverterTypeCredentialStatusData.write(value, buf) + } + } +} + + + + public object FfiConverterOptionalTypeDidDhtCreateOptions: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): DidDhtCreateOptions? { if (buf.get().toInt() == 0) { @@ -6932,6 +7303,35 @@ public object FfiConverterOptionalSequenceString: FfiConverterRustBuffer?> { + override fun read(buf: ByteBuffer): List? { + if (buf.get().toInt() == 0) { + return null + } + return FfiConverterSequenceTypeVerifiableCredential.read(buf) + } + + override fun allocationSize(value: List?): ULong { + if (value == null) { + return 1UL + } else { + return 1UL + FfiConverterSequenceTypeVerifiableCredential.allocationSize(value) + } + } + + override fun write(value: List?, buf: ByteBuffer) { + if (value == null) { + buf.put(0) + } else { + buf.put(1) + FfiConverterSequenceTypeVerifiableCredential.write(value, buf) + } + } +} + + + + public object FfiConverterOptionalSequenceTypeServiceData: FfiConverterRustBuffer?> { override fun read(buf: ByteBuffer): List? { if (buf.get().toInt() == 0) { @@ -7044,6 +7444,31 @@ public object FfiConverterSequenceString: FfiConverterRustBuffer> { + override fun read(buf: ByteBuffer): List { + val len = buf.getInt() + return List(len) { + FfiConverterTypeVerifiableCredential.read(buf) + } + } + + override fun allocationSize(value: List): ULong { + val sizeForLength = 4UL + val sizeForItems = value.map { FfiConverterTypeVerifiableCredential.allocationSize(it) }.sum() + return sizeForLength + sizeForItems + } + + override fun write(value: List, buf: ByteBuffer) { + buf.putInt(value.size) + value.iterator().forEach { + FfiConverterTypeVerifiableCredential.write(it, buf) + } + } +} + + + + public object FfiConverterSequenceTypeJwkData: FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() diff --git a/bound/kt/src/main/kotlin/web5/sdk/vc/StatusListCredential.kt b/bound/kt/src/main/kotlin/web5/sdk/vc/StatusListCredential.kt new file mode 100644 index 00000000..f5792aca --- /dev/null +++ b/bound/kt/src/main/kotlin/web5/sdk/vc/StatusListCredential.kt @@ -0,0 +1,30 @@ +package web5.sdk.vc + +import web5.sdk.Json +import web5.sdk.rust.StatusListCredential as RustCoreStatusListCredential + +data class StatusListCredential( + val base: VerifiableCredential, + internal val rustCoreStatusListCredential: RustCoreStatusListCredential +) { + companion object { + fun create( + issuer: Issuer, + statusPurpose: String, + credentialsToDisable: List? = null + ): StatusListCredential { + val jsonSerializedIssuer = Json.stringify(issuer) + val rustCoreCredentials = credentialsToDisable?.map { it.rustCoreVerifiableCredential } + + val rustCoreStatusListCredential = RustCoreStatusListCredential.create(jsonSerializedIssuer, statusPurpose, rustCoreCredentials) + + val baseVerifiableCredential = VerifiableCredential.fromRustCore(rustCoreStatusListCredential.getBase()) + + return StatusListCredential(baseVerifiableCredential, rustCoreStatusListCredential) + } + } + + fun isDisabled(credential: VerifiableCredential): Boolean { + return rustCoreStatusListCredential.isDisabled(credential.rustCoreVerifiableCredential) + } +} \ No newline at end of file diff --git a/bound/kt/src/main/kotlin/web5/sdk/vc/VerifiableCredential.kt b/bound/kt/src/main/kotlin/web5/sdk/vc/VerifiableCredential.kt index 67af1d0b..db41afd7 100644 --- a/bound/kt/src/main/kotlin/web5/sdk/vc/VerifiableCredential.kt +++ b/bound/kt/src/main/kotlin/web5/sdk/vc/VerifiableCredential.kt @@ -13,16 +13,26 @@ import com.fasterxml.jackson.module.kotlin.readValue import web5.sdk.Json import web5.sdk.dids.BearerDid import java.util.Date +import web5.sdk.rust.CredentialStatusData as RustCoreCredentialStatus import web5.sdk.rust.VerifiableCredential as RustCoreVerifiableCredential import web5.sdk.rust.VerifiableCredentialCreateOptionsData as RustCoreVerifiableCredentialCreateOptions import web5.sdk.rust.CredentialSchemaData as RustCoreCredentialSchema +data class CredentialStatus( + var id: String, + var type: String, + var statusPurpose: String, + var statusListIndex: String, + var statusListCredential: String +) + data class VerifiableCredentialCreateOptions( val id: String? = null, val context: List? = null, val type: List? = null, val issuanceDate: Date? = null, val expirationDate: Date? = null, + var credentialStatus: CredentialStatus? = null, val credentialSchema: CredentialSchema? = null, val evidence: List>? = null ) @@ -35,11 +45,34 @@ data class VerifiableCredential private constructor( val credentialSubject: CredentialSubject, val issuanceDate: Date, val expirationDate: Date? = null, + val credentialStatus: CredentialStatus? = null, val credentialSchema: CredentialSchema? = null, val evidence: List>? = null, internal val rustCoreVerifiableCredential: RustCoreVerifiableCredential, ) { companion object { + internal fun fromRustCore(rustCoreVerifiableCredential: RustCoreVerifiableCredential): VerifiableCredential { + val data = rustCoreVerifiableCredential.getData() + + val issuer = Json.jsonMapper.readValue(data.jsonSerializedIssuer, Issuer::class.java) + val credentialSubject = Json.jsonMapper.readValue(data.jsonSerializedCredentialSubject, CredentialSubject::class.java) + val evidence = data.jsonSerializedEvidence?.let { Json.jsonMapper.readValue>>(it) } + + return VerifiableCredential( + data.context, + data.type, + data.id, + issuer, + credentialSubject, + Date.from(data.issuanceDate), + data.expirationDate?.let { Date.from(it) }, + data.credentialStatus?.let { CredentialStatus(it.id, it.type, it.statusPurpose, it.statusListIndex, it.statusListCredential) }, + data.credentialSchema?.let { CredentialSchema(it.id, it.type) }, + evidence, + rustCoreVerifiableCredential + ) + } + fun create( issuer: Issuer, credentialSubject: CredentialSubject, @@ -58,6 +91,7 @@ data class VerifiableCredential private constructor( options?.type, options?.issuanceDate?.toInstant(), options?.expirationDate?.toInstant(), + options?.credentialStatus?.let { RustCoreCredentialStatus(it.id, it.type, it.statusPurpose, it.statusListIndex, it.statusListCredential) }, options?.credentialSchema?.let { RustCoreCredentialSchema(it.id, it.type) }, jsonSerializedEvidence ) @@ -74,6 +108,7 @@ data class VerifiableCredential private constructor( credentialSubject, Date.from(data.issuanceDate), data.expirationDate?.let { Date.from(it) }, + data.credentialStatus?.let { CredentialStatus(it.id, it.type, it.statusPurpose, it.statusListIndex, it.statusListCredential) }, data.credentialSchema?.let { CredentialSchema(it.id, it.type) }, evidence, rustCoreVerifiableCredential @@ -96,6 +131,7 @@ data class VerifiableCredential private constructor( credentialSubject, Date.from(data.issuanceDate), data.expirationDate?.let { Date.from(it) }, + data.credentialStatus?.let { CredentialStatus(it.id, it.type, it.statusPurpose, it.statusListIndex, it.statusListCredential) }, data.credentialSchema?.let { CredentialSchema(it.id, it.type) }, evidence, rustCoreVerifiableCredential diff --git a/bound/kt/src/test/kotlin/web5/sdk/vc/StatusListCredentialTest.kt b/bound/kt/src/test/kotlin/web5/sdk/vc/StatusListCredentialTest.kt new file mode 100644 index 00000000..11064e7b --- /dev/null +++ b/bound/kt/src/test/kotlin/web5/sdk/vc/StatusListCredentialTest.kt @@ -0,0 +1,41 @@ +package web5.sdk.vc + +import org.junit.jupiter.api.* +import org.junit.jupiter.api.Assertions.* + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class StatusListCredentialTest { + companion object { + const val ISSUER_DID_URI = "did:web:tbd.website" + const val SUBJECT_DID_URI = "did:dht:qgmmpyjw5hwnqfgzn7wmrm33ady8gb8z9ideib6m9gj4ys6wny8y" + + val ISSUER = Issuer.StringIssuer(ISSUER_DID_URI) + val CREDENTIAL_SUBJECT = CredentialSubject(SUBJECT_DID_URI) + } + + @Test + fun test_create_status_list_credential() { + val statusPurpose = "revocation" + + val optionsVc1 = VerifiableCredentialCreateOptions( + credentialStatus = CredentialStatus("vc-cred-status-id-1", "StatusList2021Entry", statusPurpose, "123", "status-list-credential-id"), + ) + val vc1 = VerifiableCredential.create(ISSUER, CREDENTIAL_SUBJECT, optionsVc1) + + val optionsVc2 = VerifiableCredentialCreateOptions( + credentialStatus = CredentialStatus("vc-cred-status-id-2", "StatusList2021Entry", statusPurpose, "9999", "status-list-credential-id"), + ) + val vc2 = VerifiableCredential.create(ISSUER, CREDENTIAL_SUBJECT, optionsVc2) + + val optionsVc3 = VerifiableCredentialCreateOptions( + credentialStatus = CredentialStatus("vc-cred-status-id-3", "StatusList2021Entry", statusPurpose, "876", "status-list-credential-id"), + ) + val vc3 = VerifiableCredential.create(ISSUER, CREDENTIAL_SUBJECT, optionsVc3) + + val statusListCredential = StatusListCredential.create(ISSUER, statusPurpose, listOf(vc1, vc2)) + + assertTrue(statusListCredential.isDisabled(vc1)) + assertTrue(statusListCredential.isDisabled(vc2)) + assertFalse(statusListCredential.isDisabled(vc3)) + } +} \ No newline at end of file diff --git a/crates/web5/Cargo.toml b/crates/web5/Cargo.toml index d6c5d483..ba9594bf 100644 --- a/crates/web5/Cargo.toml +++ b/crates/web5/Cargo.toml @@ -30,6 +30,7 @@ uuid = { workspace = true } x25519-dalek = { version = "2.0.1", features = ["getrandom", "static_secrets"] } zbase32 = "0.1.2" lazy_static = "1.5.0" +flate2 = "1.0.33" [dev-dependencies] mockito = "1.5.0" diff --git a/crates/web5/src/credentials/create.rs b/crates/web5/src/credentials/create.rs index 30c7851d..91ef5c93 100644 --- a/crates/web5/src/credentials/create.rs +++ b/crates/web5/src/credentials/create.rs @@ -36,6 +36,7 @@ pub fn create_vc( issuer, issuance_date: options.issuance_date.unwrap_or_else(SystemTime::now), expiration_date: options.expiration_date, + credential_status: options.credential_status, credential_subject, credential_schema: options.credential_schema, evidence: options.evidence, @@ -73,9 +74,11 @@ fn validate_credential_subject(credential_subject: &CredentialSubject) -> Result return Err(Web5Error::Parameter("subject id must not be empty".into())); } - if Did::parse(&credential_subject.to_string()).is_err() { + if Did::parse(&credential_subject.to_string()).is_err() + && !credential_subject.to_string().starts_with("urn:uuid:") + { return Err(Web5Error::Parameter( - "credential subject must be a valid DID URI".into(), + "credential subject must be a valid DID URI or start with 'urn:uuid:'".into(), )); } @@ -379,7 +382,10 @@ mod tests { match result { Err(Web5Error::Parameter(err_msg)) => { - assert_eq!(err_msg, "credential subject must be a valid DID URI") + assert_eq!( + err_msg, + "credential subject must be a valid DID URI or start with 'urn:uuid:'" + ) } _ => panic!("Expected Web5Error::Parameter, but got: {:?}", result), }; diff --git a/crates/web5/src/credentials/decode.rs b/crates/web5/src/credentials/decode.rs index 96b35cd4..dd41d155 100644 --- a/crates/web5/src/credentials/decode.rs +++ b/crates/web5/src/credentials/decode.rs @@ -148,6 +148,7 @@ pub fn decode(vc_jwt: &str, verify_signature: bool) -> Result, + #[serde(rename = "credentialStatus")] + pub credential_status: Option, #[serde(skip_serializing_if = "Option::is_none", rename = "credentialSubject")] pub credential_subject: Option, #[serde(rename = "credentialSchema", skip_serializing_if = "Option::is_none")] diff --git a/crates/web5/src/credentials/mod.rs b/crates/web5/src/credentials/mod.rs index 749978db..e0fbaa87 100644 --- a/crates/web5/src/credentials/mod.rs +++ b/crates/web5/src/credentials/mod.rs @@ -8,6 +8,11 @@ mod josekit; mod jwt_payload_vc; pub mod presentation_definition; mod sign; +mod status_list_credential; +pub use status_list_credential::{ + StatusListCredential, STATUS_LIST_2021, STATUS_LIST_2021_ENTRY, STATUS_LIST_CREDENTIAL_CONTEXT, + STATUS_LIST_CREDENTIAL_TYPE, +}; pub mod verifiable_credential_1_1; pub use credential_schema::CredentialSchema; diff --git a/crates/web5/src/credentials/sign.rs b/crates/web5/src/credentials/sign.rs index 5a1de919..1b3d69c6 100644 --- a/crates/web5/src/credentials/sign.rs +++ b/crates/web5/src/credentials/sign.rs @@ -25,6 +25,7 @@ pub fn sign_with_signer( issuer: Some(vc.issuer.clone()), issuance_date: Some(vc.issuance_date), expiration_date: vc.expiration_date, + credential_status: vc.credential_status.clone(), credential_subject: Some(vc.credential_subject.clone()), credential_schema: vc.credential_schema.clone(), evidence: vc.evidence.clone(), diff --git a/crates/web5/src/credentials/status_list_credential.rs b/crates/web5/src/credentials/status_list_credential.rs new file mode 100644 index 00000000..9a4908e6 --- /dev/null +++ b/crates/web5/src/credentials/status_list_credential.rs @@ -0,0 +1,533 @@ +use std::io::{Read, Write}; + +use super::verifiable_credential_1_1::{VerifiableCredential, VerifiableCredentialCreateOptions}; +use crate::credentials::{CredentialSubject, Issuer}; +use crate::errors::{Result, Web5Error}; +use crate::json::{JsonObject, JsonValue}; +use base64::Engine; +use flate2::{read::GzDecoder, write::GzEncoder, Compression}; + +pub const STATUS_LIST_CREDENTIAL_CONTEXT: &str = "https://w3id.org/vc/status-list/2021/v1"; +pub const STATUS_LIST_CREDENTIAL_TYPE: &str = "StatusList2021Credential"; +pub const STATUS_LIST_2021: &str = "StatusList2021"; + +pub const STATUS_LIST_2021_ENTRY: &str = "StatusList2021Entry"; + +pub struct StatusListCredential { + pub base: VerifiableCredential, +} + +impl StatusListCredential { + pub fn create( + issuer: Issuer, + status_purpose: String, + disabled_credentials: Option>, + ) -> Result { + // Determine the status list indexes based on the provided credentials to disable. + let status_list_indexes = match disabled_credentials { + Some(credentials) => Self::get_status_list_indexes(&status_purpose, credentials)?, + None => Vec::new(), + }; + + // Generate the base64 bitstring from the status list indexes. + let base64_bitstring = Self::bitstring_generation(status_list_indexes)?; + + // Construct the properties for the credential subject. + let additional_properties = JsonObject { + properties: [ + ( + "statusPurpose".to_string(), + JsonValue::String(status_purpose), + ), + ( + "type".to_string(), + JsonValue::String(STATUS_LIST_2021.to_string()), + ), + ( + "encodedList".to_string(), + JsonValue::String(base64_bitstring), + ), + ] + .into_iter() + .collect(), + }; + + let credential_subject = CredentialSubject { + id: format!("urn:uuid:{}", uuid::Uuid::new_v4()), + additional_properties: Some(additional_properties), + }; + + let vc_options = VerifiableCredentialCreateOptions { + id: Some(format!("urn:uuid:{}", uuid::Uuid::new_v4())), + context: Some(vec![STATUS_LIST_CREDENTIAL_CONTEXT.to_string()]), + r#type: Some(vec![STATUS_LIST_CREDENTIAL_TYPE.to_string()]), + ..Default::default() + }; + + let verifiable_credential = + VerifiableCredential::create(issuer, credential_subject, Some(vc_options))?; + + Ok(Self { + base: verifiable_credential, + }) + } + + /// Checks if a given credential is disabled according to this Status List Credential. + /// + /// # Arguments + /// + /// * `credential` - The `VerifiableCredential` to check. + /// + /// # Returns + /// + /// * `Ok(true)` if the credential is disabled, `Ok(false)` otherwise. + /// * `Err` if the credential status is invalid or incompatible. + /// + /// # Example + /// + /// let is_disabled = status_list_credential.is_disabled(&credential_to_check)?; + /// println!("Credential is disabled: {}", is_disabled); + pub fn is_disabled(&self, credential: &VerifiableCredential) -> Result { + let status = credential.credential_status.as_ref().ok_or_else(|| { + Web5Error::Parameter("no credential status found in credential".to_string()) + })?; + + // Check if the status type matches + if status.r#type != STATUS_LIST_2021_ENTRY { + return Err(Web5Error::Parameter(format!( + "unsupported status type: {}", + status.r#type + ))); + } + + // Check if the status purpose matches + let status_purpose = Self::get_additional_property( + &self.base.credential_subject.additional_properties, + "statusPurpose", + )?; + + if status_purpose != status.status_purpose { + return Err(Web5Error::Parameter("status purpose mismatch".to_string())); + } + + // Get the bit index + let index = status.status_list_index.parse::().map_err(|_| { + Web5Error::Parameter(format!( + "invalid status list index: {}", + status.status_list_index + )) + })?; + + let encoded_list = Self::get_additional_property( + &self.base.credential_subject.additional_properties, + "encodedList", + )?; + + // Check the bit in the encoded list + Self::get_bit(encoded_list, index) + } + + /// Extracts status list indexes from a vector of verifiable credentials that match the specified status purpose. + /// + /// # Arguments + /// * `status_purpose` - The status purpose to match. + /// * `credentials` - A vector of `VerifiableCredential` objects. + /// + /// # Returns + /// A `Result` containing a vector of `usize` indexes, or an error if a credential is missing + fn get_status_list_indexes( + status_purpose: &str, + credentials: Vec, + ) -> Result> { + let mut status_list_indexes = Vec::new(); + + for vc in credentials { + let status_list_entry = vc.credential_status.as_ref().ok_or_else(|| { + Web5Error::Parameter("no credential status found in credential".to_string()) + })?; + + if status_list_entry.status_purpose != *status_purpose { + return Err(Web5Error::Parameter(format!( + "status purpose mismatch: expected '{}', found '{}'", + status_purpose, status_list_entry.status_purpose + ))); + } + + let index = status_list_entry + .status_list_index + .parse::() + .map_err(|_| { + Web5Error::Parameter(format!( + "invalid status list index: {}", + status_list_entry.status_list_index + )) + })?; + + status_list_indexes.push(index); + } + + Ok(status_list_indexes) + } + + /// Generates a compressed, base64-encoded bitstring from a list of status list indexes. + /// + /// # Arguments + /// * `status_list_indexes` - A vector of indexes to set in the bitstring. + /// + /// # Returns + /// A `Result` containing the compressed, base64-encoded bitstring, or an error if an index is out of range. + fn bitstring_generation(status_list_indexes: Vec) -> Result { + const BITSET_SIZE: usize = 16 * 1024 * 8; + let mut bit_vec = vec![0u8; BITSET_SIZE / 8]; + + for index in status_list_indexes { + if index >= BITSET_SIZE { + return Err(Web5Error::Parameter(format!( + "invalid status list index: {}, index is larger than the bitset size", + index + ))); + } + let byte_index = index / 8; + let bit_index = 7 - (index % 8); + bit_vec[byte_index] |= 1 << bit_index; + } + + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(&bit_vec).map_err(|e| { + Web5Error::Parameter(format!( + "encoder write_all issue while creating bitstring: {}", + e + )) + })?; + let compressed = encoder.finish().map_err(|e| { + Web5Error::Parameter(format!( + "encoder finish issue while creating bitstring: {}", + e + )) + })?; + + Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(compressed)) + } + + /// Retrieves the value of a specific bit from a compressed base64 URL-encoded bitstring + /// by decoding and decompressing a bitstring, then extracting a bit's value by its index. + /// + /// # Arguments + /// * `compressed_bitstring` - A base64 URL-encoded string representing the compressed bitstring. + /// * `bit_index` - The zero-based index of the bit to retrieve from the decompressed bitstream. + /// + /// # Returns + /// `true` if the bit at the specified index is 1, `false` if it is 0. + fn get_bit(compressed_bitstring: &str, bit_index: usize) -> Result { + // Base64-decode the compressed bitstring + let compressed_data = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(compressed_bitstring) + .map_err(|e| Web5Error::Parameter(format!("failed to decode base64: {}", e)))?; + + // Decompress the data using GZIP + let mut decoder = GzDecoder::new(&compressed_data[..]); + let mut decompressed_data = Vec::new(); + decoder + .read_to_end(&mut decompressed_data) + .map_err(|e| Web5Error::Parameter(format!("failed to decompress data: {}", e)))?; + + // Find the byte index, and bit index within the byte + let byte_index = bit_index / 8; + let bit_index_within_byte = 7 - (bit_index % 8); + let byte = decompressed_data.get(byte_index).ok_or_else(|| { + Web5Error::Parameter("bit index out of range in decompressed data".into()) + })?; + + // Extract the targeted bit + let bit_integer = (byte >> bit_index_within_byte) & 1; + + Ok(bit_integer == 1) + } + + /// Helper function to extract a string property from the additional_properties + fn get_additional_property<'a>(props: &'a Option, key: &str) -> Result<&'a str> { + props + .as_ref() + .and_then(|p| p.properties.get(key)) + .and_then(|value| { + if let JsonValue::String(s) = value { + Some(s.as_str()) + } else { + None + } + }) + .ok_or_else(|| Web5Error::Parameter(format!("no valid {} found", key))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::credentials::verifiable_credential_1_1::{ + CredentialStatus, BASE_CONTEXT, BASE_TYPE, + }; + + const ISSUER_DID_URI: &str = "did:web:tbd.website"; + const SUBJECT_DID_URI: &str = "did:dht:qgmmpyjw5hwnqfgzn7wmrm33ady8gb8z9ideib6m9gj4ys6wny8y"; + + fn issuer() -> Issuer { + Issuer::from(ISSUER_DID_URI) + } + + fn credential_subject() -> CredentialSubject { + CredentialSubject::from(SUBJECT_DID_URI) + } + + fn create_test_credential(index: &str, purpose: &str) -> VerifiableCredential { + let credential_status = CredentialStatus { + id: format!("https://example.com/status/{}", index), + r#type: STATUS_LIST_2021_ENTRY.to_string(), + status_purpose: purpose.to_string(), + status_list_index: index.to_string(), + status_list_credential: "https://example.com/status/1".to_string(), + }; + + VerifiableCredential::create( + Issuer::from("did:example:issuer"), + CredentialSubject::from("did:example:subject"), + Some(VerifiableCredentialCreateOptions { + credential_status: Some(credential_status), + ..Default::default() + }), + ) + .unwrap() + } + + fn create_test_credential_with_type( + index: &str, + status_type: &str, + purpose: &str, + ) -> VerifiableCredential { + let mut credential = create_test_credential(index, purpose); + if let Some(status) = &mut credential.credential_status { + status.r#type = status_type.to_string(); + } + credential + } + + #[test] + fn test_create_status_list_credential2() { + let issuer = Issuer::from("did:example:123".to_string()); + let status_purpose = "revocation".to_string(); + let credentials_to_disable = None; + + let result = StatusListCredential::create(issuer, status_purpose, credentials_to_disable); + + assert!(result.is_ok()); + let status_list_credential = result.unwrap(); + + assert_eq!( + status_list_credential.base.r#type, + vec![ + BASE_TYPE.to_string(), + STATUS_LIST_CREDENTIAL_TYPE.to_string() + ] + ); + assert_eq!( + status_list_credential.base.context, + vec![ + BASE_CONTEXT.to_string(), + STATUS_LIST_CREDENTIAL_CONTEXT.to_string() + ] + ); + + let additional_properties = status_list_credential + .base + .credential_subject + .additional_properties + .unwrap(); + + assert_eq!( + additional_properties + .properties + .get("statusPurpose") + .unwrap(), + &JsonValue::String("revocation".to_string()) + ); + assert_eq!( + additional_properties.properties.get("type").unwrap(), + &JsonValue::String(STATUS_LIST_2021.to_string()) + ); + assert!(additional_properties + .properties + .get("encodedList") + .is_some()); + } + + #[test] + fn test_get_bit() { + let bit_indices = vec![3, 1023]; + + let bitstring = StatusListCredential::bitstring_generation(bit_indices).unwrap(); + + assert_eq!(StatusListCredential::get_bit(&bitstring, 3).unwrap(), true); + assert_eq!( + StatusListCredential::get_bit(&bitstring, 1023).unwrap(), + true + ); + assert_eq!(StatusListCredential::get_bit(&bitstring, 0).unwrap(), false); + assert_eq!( + StatusListCredential::get_bit(&bitstring, 1024).unwrap(), + false + ); + + let result = StatusListCredential::get_bit(&bitstring, 16 * 1024 * 8 + 1); + assert!(result.is_err()); + } + + #[test] + fn test_is_disabled() -> Result<()> { + // Create a StatusListCredential with some disabled credentials + let issuer = Issuer::from("did:example:issuer"); + let status_purpose = "revocation".to_string(); + let credentials_to_disable = Some(vec![ + create_test_credential("3", &status_purpose), + create_test_credential("1023", &status_purpose), + ]); + let status_list_credential = + StatusListCredential::create(issuer, status_purpose.clone(), credentials_to_disable)?; + + // Test 1: Check a disabled credential (index 3) + let disabled_credential = create_test_credential("3", &status_purpose); + assert!(status_list_credential.is_disabled(&disabled_credential)?); + + // Test 2: Check another disabled credential (index 1023) + let another_disabled_credential = create_test_credential("1023", &status_purpose); + assert!(status_list_credential.is_disabled(&another_disabled_credential)?); + + // Test 3: Check an enabled credential (index 5) + let enabled_credential = create_test_credential("5", &status_purpose); + assert!(!status_list_credential.is_disabled(&enabled_credential)?); + + // Test 4: Check a credential with mismatched status type + let mismatched_type_credential = + create_test_credential_with_type("7", "InvalidType", &status_purpose); + assert!(status_list_credential + .is_disabled(&mismatched_type_credential) + .is_err()); + + // Test 5: Check a credential with mismatched status purpose + let mismatched_purpose_credential = create_test_credential("9", "suspension"); + assert!(status_list_credential + .is_disabled(&mismatched_purpose_credential) + .is_err()); + + // Test 6: Check a credential without a status + let no_status_credential = VerifiableCredential::create( + Issuer::from("did:example:issuer"), + CredentialSubject::from("did:example:subject"), + None, + )?; + assert!(status_list_credential + .is_disabled(&no_status_credential) + .is_err()); + + Ok(()) + } + + #[test] + fn test_full_flow() { + let status_purpose = "revocation".to_string(); + let credentials_to_disable = None; + let status_list_credential = + StatusListCredential::create(issuer(), status_purpose, credentials_to_disable).unwrap(); + + let encoded_list = StatusListCredential::get_additional_property( + &status_list_credential + .base + .credential_subject + .additional_properties, + "encodedList", + ) + .unwrap(); + + // Test various bit positions + assert_eq!( + StatusListCredential::get_bit(encoded_list, 0).unwrap(), + false + ); + assert_eq!( + StatusListCredential::get_bit(encoded_list, 100).unwrap(), + false + ); + assert_eq!( + StatusListCredential::get_bit(encoded_list, 1000).unwrap(), + false + ); + + let vc1_options = Some(VerifiableCredentialCreateOptions { + credential_status: Some(CredentialStatus { + id: "https://example.com/status/1".to_string(), + r#type: STATUS_LIST_2021_ENTRY.to_string(), + status_purpose: "revocation".to_string(), + status_list_index: "3".to_string(), + status_list_credential: "https://example.com/status/1".to_string(), + }), + ..Default::default() + }); + + let vc1 = + VerifiableCredential::create(issuer(), credential_subject(), vc1_options).unwrap(); + + let vc2_options = Some(VerifiableCredentialCreateOptions { + credential_status: Some(CredentialStatus { + id: "https://example.com/status/2".to_string(), + r#type: STATUS_LIST_2021_ENTRY.to_string(), + status_purpose: "revocation".to_string(), + status_list_index: "1023".to_string(), + status_list_credential: "https://example.com/status/1".to_string(), + }), + ..Default::default() + }); + + let vc2 = + VerifiableCredential::create(issuer(), credential_subject(), vc2_options).unwrap(); + + let credentials_to_disable = Some(vec![vc1, vc2]); + + let updated_status_list_credential = StatusListCredential::create( + Issuer::from("did:example:123".to_string()), + "revocation".to_string(), + credentials_to_disable, + ) + .unwrap(); + + let updated_encoded_list = StatusListCredential::get_additional_property( + &updated_status_list_credential + .base + .credential_subject + .additional_properties, + "encodedList", + ) + .unwrap(); + + // Test the bits corresponding to the disabled credentials + assert_eq!( + StatusListCredential::get_bit(updated_encoded_list, 3).unwrap(), + true + ); + assert_eq!( + StatusListCredential::get_bit(updated_encoded_list, 1023).unwrap(), + true + ); + + // Test other bits are still false + assert_eq!( + StatusListCredential::get_bit(updated_encoded_list, 0).unwrap(), + false + ); + assert_eq!( + StatusListCredential::get_bit(updated_encoded_list, 100).unwrap(), + false + ); + assert_eq!( + StatusListCredential::get_bit(updated_encoded_list, 1000).unwrap(), + false + ); + } +} diff --git a/crates/web5/src/credentials/verifiable_credential_1_1.rs b/crates/web5/src/credentials/verifiable_credential_1_1.rs index b54148c8..dcd6e7b7 100644 --- a/crates/web5/src/credentials/verifiable_credential_1_1.rs +++ b/crates/web5/src/credentials/verifiable_credential_1_1.rs @@ -42,6 +42,8 @@ pub struct VerifiableCredential { deserialize_with = "deserialize_optional_system_time" )] pub expiration_date: Option, + #[serde(rename = "credentialStatus")] + pub credential_status: Option, #[serde(rename = "credentialSchema", skip_serializing_if = "Option::is_none")] pub credential_schema: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -58,10 +60,24 @@ pub struct VerifiableCredentialCreateOptions { pub r#type: Option>, pub issuance_date: Option, pub expiration_date: Option, + pub credential_status: Option, pub credential_schema: Option, pub evidence: Option>, } +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq)] +pub struct CredentialStatus { + pub id: String, + #[serde(rename = "type")] + pub r#type: String, + #[serde(rename = "statusPurpose")] + pub status_purpose: String, + #[serde(rename = "statusListIndex")] + pub status_list_index: String, + #[serde(rename = "statusListCredential")] + pub status_list_credential: String, +} + impl VerifiableCredential { pub fn create( issuer: Issuer, @@ -267,9 +283,9 @@ mod tests { let vc = VerifiableCredential::from_vc_jwt(vc_jwt_valid, true) .expect("vc_jwt should be valid"); assert_eq!( - "did:jwk:eyJhbGciOiJFZDI1NTE5Iiwia3R5IjoiT0tQIiwiY3J2IjoiRWQyNTUxOSIsIngiOiJHVzZFTDlITTltdHlycGlYdFFUMGpxZk52aWNnQTlBVDg0MHY1Y08yb1RrIn0#0", - vc.issuer.to_string() - ); + "did:jwk:eyJhbGciOiJFZDI1NTE5Iiwia3R5IjoiT0tQIiwiY3J2IjoiRWQyNTUxOSIsIngiOiJHVzZFTDlITTltdHlycGlYdFFUMGpxZk52aWNnQTlBVDg0MHY1Y08yb1RrIn0#0", + vc.issuer.to_string() + ); } #[test] @@ -318,9 +334,9 @@ mod tests { match vc.issuer { Issuer::String(issuer) => { assert_eq!( - "did:jwk:eyJhbGciOiJFZDI1NTE5Iiwia3R5IjoiT0tQIiwiY3J2IjoiRWQyNTUxOSIsIngiOiJyd2hXSUNYclJ3b0ZaRmR1M0lsNi1BNGUtdjk3QlMxRkZRaVE4aWNmWktrIn0", - issuer - ) + "did:jwk:eyJhbGciOiJFZDI1NTE5Iiwia3R5IjoiT0tQIiwiY3J2IjoiRWQyNTUxOSIsIngiOiJyd2hXSUNYclJ3b0ZaRmR1M0lsNi1BNGUtdjk3QlMxRkZRaVE4aWNmWktrIn0", + issuer + ) } Issuer::Object(_) => panic!("issuer should be string"), } @@ -338,9 +354,9 @@ mod tests { Issuer::String(_) => panic!("issuer should be object"), Issuer::Object(issuer) => { assert_eq!( - "did:jwk:eyJhbGciOiJFZDI1NTE5Iiwia3R5IjoiT0tQIiwiY3J2IjoiRWQyNTUxOSIsIngiOiI1Uk1yaUM1VlhubzZSVDhMWVVrbnpJZnNjaTQyYmxBaWlLWkpCZGhnVnVBIn0", - issuer.id - ); + "did:jwk:eyJhbGciOiJFZDI1NTE5Iiwia3R5IjoiT0tQIiwiY3J2IjoiRWQyNTUxOSIsIngiOiI1Uk1yaUM1VlhubzZSVDhMWVVrbnpJZnNjaTQyYmxBaWlLWkpCZGhnVnVBIn0", + issuer.id + ); assert_eq!("some name", issuer.name) } } diff --git a/docs/API_DESIGN.md b/docs/API_DESIGN.md index 84dc286c..4c799cd6 100644 --- a/docs/API_DESIGN.md +++ b/docs/API_DESIGN.md @@ -140,13 +140,11 @@ CLASS VerifiableCredentialCreateOptions #### `StatusListCredential` ```pseudocode! -CLASS StatusListCredential IMPLEMENTS VerifiableCredential - PUBLIC DATA status_purpose: string - PUBLIC DATA credentials_to_disable: []VerifiableCredential +CLASS StatusListCredential + PUBLIC DATA base: VerifiableCredential - CONSTRUCTOR create(issuer: Issuer, status_purpose: string, credentials_to_disable: []VerifiableCredential, options: CreateOptions?) + CONSTRUCTOR create(issuer: Issuer, status_purpose: string, disabled_credentials: []VerifiableCredential) - METHOD update_credentials_to_disable(credentials_to_disable: []VerifiableCredential): StatusListCredential METHOD is_disabled(credential VerifiableCredential): bool ```