diff --git a/bindings/web5_uniffi/src/lib.rs b/bindings/web5_uniffi/src/lib.rs index 6fdbdc61..4b48b1d4 100644 --- a/bindings/web5_uniffi/src/lib.rs +++ b/bindings/web5_uniffi/src/lib.rs @@ -16,6 +16,7 @@ use web5_uniffi_wrapper::{ crypto::{ dsa::{ ed25519::{ed25519_generator_generate, Ed25519Signer, Ed25519Verifier}, + secp256k1::{secp256k1_generator_generate, Secp256k1Signer, Secp256k1Verifier}, Signer, Verifier, }, in_memory_key_manager::InMemoryKeyManager, diff --git a/bindings/web5_uniffi/src/web5.udl b/bindings/web5_uniffi/src/web5.udl index 94916227..33a835b9 100644 --- a/bindings/web5_uniffi/src/web5.udl +++ b/bindings/web5_uniffi/src/web5.udl @@ -1,5 +1,6 @@ namespace web5 { JwkData ed25519_generator_generate(); + JwkData secp256k1_generator_generate(); [Throws=Web5Error] BearerDid did_jwk_create(DidJwkCreateOptions? options); @@ -91,6 +92,18 @@ interface Ed25519Verifier { void verify(bytes message, bytes signature); }; +interface Secp256k1Signer { + constructor(JwkData private_key); + [Throws=Web5Error] + bytes sign(bytes payload); +}; + +interface Secp256k1Verifier { + constructor(JwkData public_jwk); + [Throws=Web5Error] + void verify(bytes message, bytes signature); +}; + dictionary DidData { string uri; string url; diff --git a/bindings/web5_uniffi_wrapper/src/crypto/dsa/mod.rs b/bindings/web5_uniffi_wrapper/src/crypto/dsa/mod.rs index 8a8e9a8d..0f427a6b 100644 --- a/bindings/web5_uniffi_wrapper/src/crypto/dsa/mod.rs +++ b/bindings/web5_uniffi_wrapper/src/crypto/dsa/mod.rs @@ -1,4 +1,5 @@ pub mod ed25519; +pub mod secp256k1; use crate::errors::Result; use std::sync::Arc; diff --git a/bindings/web5_uniffi_wrapper/src/crypto/dsa/secp256k1.rs b/bindings/web5_uniffi_wrapper/src/crypto/dsa/secp256k1.rs new file mode 100644 index 00000000..e578a17a --- /dev/null +++ b/bindings/web5_uniffi_wrapper/src/crypto/dsa/secp256k1.rs @@ -0,0 +1,44 @@ +use super::{Signer, Verifier}; +use crate::errors::Result; +use web5::crypto::{ + dsa::{ + secp256k1::{ + Secp256k1Generator as InnerSecp256k1Generator, Secp256k1Signer as InnerSecp256k1Signer, + Secp256k1Verifier as InnerSecp256k1Verifier, + }, + Signer as InnerSigner, Verifier as InnerVerifier, + }, + jwk::Jwk, +}; + +pub fn secp256k1_generator_generate() -> Jwk { + InnerSecp256k1Generator::generate() +} + +pub struct Secp256k1Signer(pub InnerSecp256k1Signer); + +impl Secp256k1Signer { + pub fn new(private_jwk: Jwk) -> Self { + Self(InnerSecp256k1Signer::new(private_jwk)) + } +} + +impl Signer for Secp256k1Signer { + fn sign(&self, payload: Vec) -> Result> { + Ok(self.0.sign(&payload)?) + } +} + +pub struct Secp256k1Verifier(pub InnerSecp256k1Verifier); + +impl Secp256k1Verifier { + pub fn new(public_jwk: Jwk) -> Self { + Self(InnerSecp256k1Verifier::new(public_jwk)) + } +} + +impl Verifier for Secp256k1Verifier { + fn verify(&self, payload: Vec, signature: Vec) -> Result<()> { + Ok(self.0.verify(&payload, &signature)?) + } +} diff --git a/bound/kt/src/main/kotlin/web5/sdk/crypto/Secp256k1Generator.kt b/bound/kt/src/main/kotlin/web5/sdk/crypto/Secp256k1Generator.kt new file mode 100644 index 00000000..99fd7629 --- /dev/null +++ b/bound/kt/src/main/kotlin/web5/sdk/crypto/Secp256k1Generator.kt @@ -0,0 +1,27 @@ +package web5.sdk.crypto + +import web5.sdk.Web5Exception +import web5.sdk.crypto.keys.Jwk +import web5.sdk.rust.secp256k1GeneratorGenerate +import web5.sdk.rust.Web5Exception.Exception as RustCoreException + +/** + * Generates private key material for secp256k1. + */ +class Secp256k1Generator { + companion object { + /** + * Generate the private key material; return Jwk includes private key material. + * + * @return Jwk the JWK with private key material included. + */ + fun generate(): Jwk { + try { + val rustCoreJwkData = secp256k1GeneratorGenerate() + return Jwk.fromRustCoreJwkData(rustCoreJwkData) + } catch (e: RustCoreException) { + throw Web5Exception.fromRustCore(e) + } + } + } +} \ No newline at end of file diff --git a/bound/kt/src/main/kotlin/web5/sdk/crypto/signers/Secp256k1Signer.kt b/bound/kt/src/main/kotlin/web5/sdk/crypto/signers/Secp256k1Signer.kt new file mode 100644 index 00000000..f340ea97 --- /dev/null +++ b/bound/kt/src/main/kotlin/web5/sdk/crypto/signers/Secp256k1Signer.kt @@ -0,0 +1,27 @@ +package web5.sdk.crypto.signers + +import web5.sdk.Web5Exception +import web5.sdk.crypto.keys.Jwk +import web5.sdk.rust.Secp256k1Signer as RustCoreSecp256k1Signer +import web5.sdk.rust.Web5Exception.Exception as RustCoreException + +/** + * Implementation of Signer for secp256k1. + */ +class Secp256k1Signer(privateJwk: Jwk) : Signer { + private val rustCoreSigner = RustCoreSecp256k1Signer(privateJwk.rustCoreJwkData) + + /** + * Implementation of Signer's sign instance method for secp256k1. + * + * @param payload the data to be signed. + * @return ByteArray the signature. + */ + override fun sign(payload: ByteArray): ByteArray { + try { + return rustCoreSigner.sign(payload) + } catch (e: RustCoreException) { + throw Web5Exception.fromRustCore(e) + } + } +} diff --git a/bound/kt/src/main/kotlin/web5/sdk/crypto/verifiers/Secp256k1Verifier.kt b/bound/kt/src/main/kotlin/web5/sdk/crypto/verifiers/Secp256k1Verifier.kt new file mode 100644 index 00000000..65dd25b6 --- /dev/null +++ b/bound/kt/src/main/kotlin/web5/sdk/crypto/verifiers/Secp256k1Verifier.kt @@ -0,0 +1,28 @@ +package web5.sdk.crypto.verifiers + +import web5.sdk.Web5Exception +import web5.sdk.crypto.keys.Jwk +import web5.sdk.rust.Secp256k1Verifier as RustCoreSecp256k1Verifier +import web5.sdk.rust.Web5Exception.Exception as RustCoreException + +/** + * Implementation of Verifier for secp256k1. + */ +class Secp256k1Verifier(publicJwk: Jwk) : Verifier { + private val rustCoreVerifier = RustCoreSecp256k1Verifier(publicJwk.rustCoreJwkData) + + /** + * Implementation of Signer's verify instance method for secp256k1. + * + * @param message the data to be verified. + * @param signature the signature to be verified. + * @throws Web5Exception in the case of a failed verification + */ + override fun verify(message: ByteArray, signature: ByteArray) { + try { + rustCoreVerifier.verify(message, signature) + } catch (e: RustCoreException) { + throw Web5Exception.fromRustCore(e) + } + } +} \ No newline at end of file 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 1d93b3e7..5dc9078b 100644 --- a/bound/kt/src/main/kotlin/web5/sdk/rust/UniFFI.kt +++ b/bound/kt/src/main/kotlin/web5/sdk/rust/UniFFI.kt @@ -910,6 +910,20 @@ internal open class UniffiVTableCallbackInterfaceVerifier( + + + + + + + + + + + + + + @@ -1072,6 +1086,22 @@ internal interface UniffiLib : Library { ): Pointer fun uniffi_web5_uniffi_fn_method_resolutionresult_get_data(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue + fun uniffi_web5_uniffi_fn_clone_secp256k1signer(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, + ): Pointer + fun uniffi_web5_uniffi_fn_free_secp256k1signer(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, + ): Unit + fun uniffi_web5_uniffi_fn_constructor_secp256k1signer_new(`privateKey`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): Pointer + fun uniffi_web5_uniffi_fn_method_secp256k1signer_sign(`ptr`: Pointer,`payload`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_web5_uniffi_fn_clone_secp256k1verifier(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, + ): Pointer + fun uniffi_web5_uniffi_fn_free_secp256k1verifier(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, + ): Unit + fun uniffi_web5_uniffi_fn_constructor_secp256k1verifier_new(`publicJwk`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): Pointer + fun uniffi_web5_uniffi_fn_method_secp256k1verifier_verify(`ptr`: Pointer,`message`: RustBuffer.ByValue,`signature`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): Unit fun uniffi_web5_uniffi_fn_clone_signer(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Pointer fun uniffi_web5_uniffi_fn_free_signer(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, @@ -1138,6 +1168,8 @@ internal interface UniffiLib : Library { ): Pointer fun uniffi_web5_uniffi_fn_func_ed25519_generator_generate(uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue + fun uniffi_web5_uniffi_fn_func_secp256k1_generator_generate(uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue fun ffi_web5_uniffi_rustbuffer_alloc(`size`: Long,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue fun ffi_web5_uniffi_rustbuffer_from_bytes(`bytes`: ForeignBytes.ByValue,uniffi_out_err: UniffiRustCallStatus, @@ -1266,6 +1298,8 @@ internal interface UniffiLib : Library { ): Short fun uniffi_web5_uniffi_checksum_func_ed25519_generator_generate( ): Short + fun uniffi_web5_uniffi_checksum_func_secp256k1_generator_generate( + ): Short fun uniffi_web5_uniffi_checksum_method_bearerdid_get_data( ): Short fun uniffi_web5_uniffi_checksum_method_bearerdid_get_signer( @@ -1312,6 +1346,10 @@ internal interface UniffiLib : Library { ): Short fun uniffi_web5_uniffi_checksum_method_resolutionresult_get_data( ): Short + fun uniffi_web5_uniffi_checksum_method_secp256k1signer_sign( + ): Short + fun uniffi_web5_uniffi_checksum_method_secp256k1verifier_verify( + ): Short fun uniffi_web5_uniffi_checksum_method_signer_sign( ): Short fun uniffi_web5_uniffi_checksum_method_statuslistcredential_get_base( @@ -1354,6 +1392,10 @@ internal interface UniffiLib : Library { ): Short fun uniffi_web5_uniffi_checksum_constructor_resolutionresult_resolve( ): Short + fun uniffi_web5_uniffi_checksum_constructor_secp256k1signer_new( + ): Short + fun uniffi_web5_uniffi_checksum_constructor_secp256k1verifier_new( + ): Short fun uniffi_web5_uniffi_checksum_constructor_statuslistcredential_create( ): Short fun uniffi_web5_uniffi_checksum_constructor_verifiablecredential_create( @@ -1405,6 +1447,9 @@ private fun uniffiCheckApiChecksums(lib: UniffiLib) { if (lib.uniffi_web5_uniffi_checksum_func_ed25519_generator_generate() != 57849.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_web5_uniffi_checksum_func_secp256k1_generator_generate() != 50489.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_web5_uniffi_checksum_method_bearerdid_get_data() != 23985.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -1474,6 +1519,12 @@ private fun uniffiCheckApiChecksums(lib: UniffiLib) { if (lib.uniffi_web5_uniffi_checksum_method_resolutionresult_get_data() != 57220.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_web5_uniffi_checksum_method_secp256k1signer_sign() != 17108.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_web5_uniffi_checksum_method_secp256k1verifier_verify() != 38282.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_web5_uniffi_checksum_method_signer_sign() != 5738.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -1537,6 +1588,12 @@ 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_secp256k1signer_new() != 58975.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_web5_uniffi_checksum_constructor_secp256k1verifier_new() != 20759.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") } @@ -5068,6 +5125,485 @@ public object FfiConverterTypeResolutionResult: FfiConverter + UniffiLib.INSTANCE.uniffi_web5_uniffi_fn_constructor_secp256k1signer_new( + FfiConverterTypeJwkData.lower(`privateKey`),_status) +} + ) + + 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_secp256k1signer(ptr, status) + } + } + } + } + + fun uniffiClonePointer(): Pointer { + return uniffiRustCall() { status -> + UniffiLib.INSTANCE.uniffi_web5_uniffi_fn_clone_secp256k1signer(pointer!!, status) + } + } + + + @Throws(Web5Exception::class)override fun `sign`(`payload`: kotlin.ByteArray): kotlin.ByteArray { + return FfiConverterByteArray.lift( + callWithPointer { + uniffiRustCallWithError(Web5Exception) { _status -> + UniffiLib.INSTANCE.uniffi_web5_uniffi_fn_method_secp256k1signer_sign( + it, FfiConverterByteArray.lower(`payload`),_status) +} + } + ) + } + + + + + + + companion object + +} + +public object FfiConverterTypeSecp256k1Signer: FfiConverter { + + override fun lower(value: Secp256k1Signer): Pointer { + return value.uniffiClonePointer() + } + + override fun lift(value: Pointer): Secp256k1Signer { + return Secp256k1Signer(value) + } + + override fun read(buf: ByteBuffer): Secp256k1Signer { + // 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: Secp256k1Signer) = 8UL + + override fun write(value: Secp256k1Signer, 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 Secp256k1VerifierInterface { + + fun `verify`(`message`: kotlin.ByteArray, `signature`: kotlin.ByteArray) + + companion object +} + +open class Secp256k1Verifier: Disposable, AutoCloseable, Secp256k1VerifierInterface { + + 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)) + } + constructor(`publicJwk`: JwkData) : + this( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_web5_uniffi_fn_constructor_secp256k1verifier_new( + FfiConverterTypeJwkData.lower(`publicJwk`),_status) +} + ) + + 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_secp256k1verifier(ptr, status) + } + } + } + } + + fun uniffiClonePointer(): Pointer { + return uniffiRustCall() { status -> + UniffiLib.INSTANCE.uniffi_web5_uniffi_fn_clone_secp256k1verifier(pointer!!, status) + } + } + + + @Throws(Web5Exception::class)override fun `verify`(`message`: kotlin.ByteArray, `signature`: kotlin.ByteArray) + = + callWithPointer { + uniffiRustCallWithError(Web5Exception) { _status -> + UniffiLib.INSTANCE.uniffi_web5_uniffi_fn_method_secp256k1verifier_verify( + it, FfiConverterByteArray.lower(`message`),FfiConverterByteArray.lower(`signature`),_status) +} + } + + + + + + + + companion object + +} + +public object FfiConverterTypeSecp256k1Verifier: FfiConverter { + + override fun lower(value: Secp256k1Verifier): Pointer { + return value.uniffiClonePointer() + } + + override fun lift(value: Pointer): Secp256k1Verifier { + return Secp256k1Verifier(value) + } + + override fun read(buf: ByteBuffer): Secp256k1Verifier { + // 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: Secp256k1Verifier) = 8UL + + override fun write(value: Secp256k1Verifier, 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 Signer { fun `sign`(`payload`: kotlin.ByteArray): kotlin.ByteArray @@ -8108,5 +8644,14 @@ public object FfiConverterMapStringString: FfiConverterRustBuffer + UniffiLib.INSTANCE.uniffi_web5_uniffi_fn_func_secp256k1_generator_generate( + _status) +} + ) + } + diff --git a/bound/kt/src/test/kotlin/web5/sdk/crypto/Secp256k1GeneratorTest.kt b/bound/kt/src/test/kotlin/web5/sdk/crypto/Secp256k1GeneratorTest.kt new file mode 100644 index 00000000..8ceeeb8b --- /dev/null +++ b/bound/kt/src/test/kotlin/web5/sdk/crypto/Secp256k1GeneratorTest.kt @@ -0,0 +1,67 @@ +package web5.sdk.crypto + +import org.junit.jupiter.api.* +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.fail +import java.util.Base64 +import web5.sdk.UnitTestSuite + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class Secp256k1GeneratorTest { + + private val testSuite = UnitTestSuite("secp256k1_generate") + + @AfterAll + fun verifyAllTestsIncluded() { + if (testSuite.tests.isNotEmpty()) { + println("The following tests were not included or executed:") + testSuite.tests.forEach { println(it) } + fail("Not all tests were executed! ${testSuite.tests}") + } + } + + @Test + fun test_must_set_alg() { + testSuite.include() + + val jwk = Secp256k1Generator.generate() + assertEquals("ES256K", jwk.alg) + } + + @Test + fun test_must_set_kty() { + testSuite.include() + + val jwk = Secp256k1Generator.generate() + assertEquals("EC", jwk.kty) + } + + @Test + fun test_must_set_crv() { + testSuite.include() + + val jwk = Secp256k1Generator.generate() + assertEquals("secp256k1", jwk.crv) + } + + @Test + fun test_must_set_public_key_with_correct_length() { + testSuite.include() + + val jwk = Secp256k1Generator.generate() + val xBytes = Base64.getUrlDecoder().decode(jwk.x) + val yBytes = jwk.y?.let { Base64.getUrlDecoder().decode(it) } ?: fail("y coordinate is missing") + assertEquals(32, xBytes.size) + assertEquals(32, yBytes.size) + } + + @Test + fun test_must_set_private_key_with_correct_length() { + testSuite.include() + + val jwk = Secp256k1Generator.generate() + val privateKeyBytes = jwk.d ?: fail("Private key is missing") + val decodedPrivateKeyBytes = Base64.getUrlDecoder().decode(privateKeyBytes) + assertEquals(32, decodedPrivateKeyBytes.size) + } +} \ No newline at end of file diff --git a/bound/kt/src/test/kotlin/web5/sdk/crypto/signers/Secp256k1SignerTest.kt b/bound/kt/src/test/kotlin/web5/sdk/crypto/signers/Secp256k1SignerTest.kt new file mode 100644 index 00000000..a0d2e0f3 --- /dev/null +++ b/bound/kt/src/test/kotlin/web5/sdk/crypto/signers/Secp256k1SignerTest.kt @@ -0,0 +1,77 @@ +package web5.sdk.crypto.signers + +import org.junit.jupiter.api.* +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.fail +import web5.sdk.UnitTestSuite +import web5.sdk.crypto.Secp256k1Generator +import web5.sdk.Web5Exception +import java.util.Base64 + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class Secp256k1SignerTest { + private val testSuite = UnitTestSuite("secp256k1_sign") + + @AfterAll + fun verifyAllTestsIncluded() { + if (testSuite.tests.isNotEmpty()) { + println("The following tests were not included or executed:") + testSuite.tests.forEach { println(it) } + fail("Not all tests were executed! ${testSuite.tests}") + } + } + + @Test + fun test_with_valid_key() { + testSuite.include() + + val jwk = Secp256k1Generator.generate() + val signer = Secp256k1Signer(jwk) + + val message = "Test message".toByteArray() + + assertDoesNotThrow { + val signature = signer.sign(message) + assertEquals(SIGNATURE_LENGTH, signature.size, "Signature length should match the expected Secp256k1 signature length") + } + } + + @Test + fun test_with_invalid_private_key() { + testSuite.include() + + val jwk = Secp256k1Generator.generate() + val invalidJwk = jwk.copy(d = Base64.getUrlEncoder().withoutPadding().encodeToString(ByteArray(SECRET_KEY_LENGTH - 1))) + + val signer = Secp256k1Signer(invalidJwk) + val message = "Test message".toByteArray() + val exception = assertThrows { + signer.sign(message) + } + + assertEquals("cryptography error invalid private key", exception.message) + assertEquals("Crypto", exception.variant) + } + + @Test + fun test_with_missing_private_key() { + testSuite.include() + + val jwk = Secp256k1Generator.generate() + val missingKeyJwk = jwk.copy(d = null) + + val signer = Secp256k1Signer(missingKeyJwk) + val message = "Test message".toByteArray() + val exception = assertThrows { + signer.sign(message) + } + + assertEquals("cryptography error private key material must be set", exception.message) + assertEquals("Crypto", exception.variant) + } + + companion object { + const val SIGNATURE_LENGTH = 64 // Expected length for Secp256k1 signature (r + s, each 32 bytes) + const val SECRET_KEY_LENGTH = 32 // Secp256k1 private key length in bytes + } +} \ No newline at end of file diff --git a/bound/kt/src/test/kotlin/web5/sdk/crypto/verifiers/Secp256k1Verifier.kt b/bound/kt/src/test/kotlin/web5/sdk/crypto/verifiers/Secp256k1Verifier.kt new file mode 100644 index 00000000..e91b4169 --- /dev/null +++ b/bound/kt/src/test/kotlin/web5/sdk/crypto/verifiers/Secp256k1Verifier.kt @@ -0,0 +1,133 @@ +package web5.sdk.crypto.verifiers + +import org.junit.jupiter.api.* +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.fail +import web5.sdk.UnitTestSuite +import web5.sdk.crypto.Secp256k1Generator +import web5.sdk.crypto.keys.Jwk +import web5.sdk.crypto.signers.Secp256k1Signer +import web5.sdk.Web5Exception +import java.util.Base64 + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class Secp256k1VerifierTest { + private val testSuite = UnitTestSuite("secp256k1_verify") + + @AfterAll + fun verifyAllTestsIncluded() { + if (testSuite.tests.isNotEmpty()) { + println("The following tests were not included or executed:") + testSuite.tests.forEach { println(it) } + fail("Not all tests were executed! ${testSuite.tests}") + } + } + + private fun generateKeys(): Pair { + val privateJwk = Secp256k1Generator.generate() + val publicJwk = privateJwk.copy(d = null) + return Pair(publicJwk, privateJwk) + } + + @Test + fun test_with_valid_signature() { + testSuite.include() + + val (publicJwk, privateJwk) = generateKeys() + val signer = Secp256k1Signer(privateJwk) + val verifier = Secp256k1Verifier(publicJwk) + + val message = "Test message".toByteArray() + val signature = signer.sign(message) + + val verifyResult = runCatching { verifier.verify(message, signature) } + + assertTrue(verifyResult.isSuccess, "Verification should succeed with a valid signature") + } + + @Test + fun test_with_private_key() { + testSuite.include() + + val (_, privateJwk) = generateKeys() + val verifier = Secp256k1Verifier(privateJwk) // this is not allowed + + val message = "Test message".toByteArray() + val invalidSignature = ByteArray(SIGNATURE_LENGTH) // Invalid length, but valid shape + + val exception = assertThrows { + verifier.verify(message, invalidSignature) + } + + assertEquals("cryptography error provided verification key cannot contain private key material", exception.message) + assertEquals("Crypto", exception.variant) + } + + @Test + fun test_with_invalid_signature() { + testSuite.include() + + val (publicJwk, privateJwk) = generateKeys() + val signer = Secp256k1Signer(privateJwk) + val verifier = Secp256k1Verifier(publicJwk) + + val message = "Test message".toByteArray() + + // Create a valid signature and mutate the last byte + val validSignature = signer.sign(message).toMutableList() + validSignature[validSignature.size - 1] = (validSignature.last().toInt() xor 0x01).toByte() // Flip the last bit + + val exception = assertThrows { + verifier.verify(message, validSignature.toByteArray()) + } + + assertEquals("cryptography error cryptographic verification failure", exception.message) + assertEquals("Crypto", exception.variant) + } + + @Test + fun test_with_invalid_public_key() { + testSuite.include() + + val (publicJwk, privateJwk) = generateKeys() + val invalidPublicJwk = publicJwk.copy( + x = Base64.getUrlEncoder().withoutPadding().encodeToString(ByteArray(PUBLIC_KEY_LENGTH - 1)) + ) + + val signer = Secp256k1Signer(privateJwk) + val verifier = Secp256k1Verifier(invalidPublicJwk) + + val message = "Test message".toByteArray() + val signature = signer.sign(message) + + val exception = assertThrows { + verifier.verify(message, signature) + } + + assertEquals("cryptography error unable to instantiate verifying key", exception.message) + assertEquals("Crypto", exception.variant) + } + + @Test + fun test_with_invalid_signature_length() { + testSuite.include() + + val (publicJwk, _) = generateKeys() + val verifier = Secp256k1Verifier(publicJwk) + + val message = "Test message".toByteArray() + val invalidSignature = ByteArray(SIGNATURE_LENGTH - 1) // Invalid length + + val exception = assertThrows { + verifier.verify(message, invalidSignature) + } + + assertEquals("cryptography error invalid signature", exception.message) + assertEquals("Crypto", exception.variant) + } + + companion object { + const val SIGNATURE_LENGTH = 64 // Secp256k1 signature length (r + s, each 32 bytes) + const val PUBLIC_KEY_LENGTH = 32 // Secp256k1 public key length in bytes + } +} \ No newline at end of file diff --git a/crates/web5/src/credentials/decode.rs b/crates/web5/src/credentials/decode.rs index a58eca7b..8bf92311 100644 --- a/crates/web5/src/credentials/decode.rs +++ b/crates/web5/src/credentials/decode.rs @@ -1,7 +1,7 @@ -use std::sync::Arc; +use std::{str::FromStr, sync::Arc}; use crate::{ - crypto::dsa::ed25519::Ed25519Verifier, + crypto::dsa::{ed25519::Ed25519Verifier, secp256k1::Secp256k1Verifier, Dsa, Verifier}, dids::{ data_model::document::FindVerificationMethodOptions, did::Did, @@ -42,7 +42,7 @@ pub fn decode(vc_jwt: &str, verify_signature: bool) -> Result Result = match dsa { + Dsa::Ed25519 => Arc::new(Ed25519Verifier::new(public_jwk)), + Dsa::Secp256k1 => Arc::new(Secp256k1Verifier::new(public_jwk)), + }; + let jose_verifier = &JoseVerifier { kid: kid.to_string(), - verifier: Arc::new(Ed25519Verifier::new(public_key_jwk)), + dsa, + verifier, }; let (jwt_payload, _) = diff --git a/crates/web5/src/credentials/josekit.rs b/crates/web5/src/credentials/josekit.rs index 32f4063a..f505ffee 100644 --- a/crates/web5/src/credentials/josekit.rs +++ b/crates/web5/src/credentials/josekit.rs @@ -1,20 +1,27 @@ use std::{fmt::Formatter, sync::Arc}; -use crate::crypto::dsa::{Signer, Verifier}; +use crate::crypto::dsa::{Dsa, Signer, Verifier}; use josekit::{ - jws::{alg::eddsa::EddsaJwsAlgorithm, JwsAlgorithm, JwsSigner, JwsVerifier}, + jws::{ + alg::{ecdsa::EcdsaJwsAlgorithm, eddsa::EddsaJwsAlgorithm}, + JwsAlgorithm, JwsSigner, JwsVerifier, + }, JoseError, }; #[derive(Clone)] pub struct JoseSigner { pub kid: String, + pub dsa: Dsa, pub signer: Arc, } impl JwsSigner for JoseSigner { fn algorithm(&self) -> &dyn JwsAlgorithm { - &EddsaJwsAlgorithm::Eddsa + match self.dsa { + Dsa::Ed25519 => &EddsaJwsAlgorithm::Eddsa, + Dsa::Secp256k1 => &EcdsaJwsAlgorithm::Es256k, + } } fn key_id(&self) -> Option<&str> { @@ -46,12 +53,16 @@ impl core::fmt::Debug for JoseSigner { #[derive(Clone)] pub struct JoseVerifier { pub kid: String, + pub dsa: Dsa, pub verifier: Arc, } impl JwsVerifier for JoseVerifier { fn algorithm(&self) -> &dyn JwsAlgorithm { - &EddsaJwsAlgorithm::Eddsa + match self.dsa { + Dsa::Ed25519 => &EddsaJwsAlgorithm::Eddsa, + Dsa::Secp256k1 => &EcdsaJwsAlgorithm::Es256k, + } } fn key_id(&self) -> Option<&str> { diff --git a/crates/web5/src/credentials/jwt_payload_vc.rs b/crates/web5/src/credentials/jwt_payload_vc.rs index 6afcdac4..9487b406 100644 --- a/crates/web5/src/credentials/jwt_payload_vc.rs +++ b/crates/web5/src/credentials/jwt_payload_vc.rs @@ -22,13 +22,15 @@ pub struct JwtPayloadVerifiableCredential { #[serde( rename = "issuanceDate", serialize_with = "serialize_optional_system_time", - deserialize_with = "deserialize_optional_system_time" + deserialize_with = "deserialize_optional_system_time", + default )] pub issuance_date: Option, #[serde( rename = "expirationDate", serialize_with = "serialize_optional_system_time", - deserialize_with = "deserialize_optional_system_time" + deserialize_with = "deserialize_optional_system_time", + default )] pub expiration_date: Option, #[serde(rename = "credentialStatus", skip_serializing_if = "Option::is_none")] diff --git a/crates/web5/src/credentials/sign.rs b/crates/web5/src/credentials/sign.rs index 1b3d69c6..54ccf9e8 100644 --- a/crates/web5/src/credentials/sign.rs +++ b/crates/web5/src/credentials/sign.rs @@ -1,8 +1,8 @@ -use std::{sync::Arc, time::SystemTime}; +use std::{str::FromStr, sync::Arc, time::SystemTime}; use crate::{ - crypto::dsa::Signer, - dids::bearer_did::BearerDid, + crypto::dsa::{Dsa, Signer}, + dids::{bearer_did::BearerDid, data_model::document::FindVerificationMethodOptions}, errors::{Result, Web5Error}, }; use josekit::{jws::JwsHeader, jwt::JwtPayload}; @@ -15,6 +15,7 @@ use super::{ pub fn sign_with_signer( vc: &VerifiableCredential, key_id: &str, + dsa: Dsa, signer: Arc, ) -> Result { let mut payload = JwtPayload::new(); @@ -44,6 +45,7 @@ pub fn sign_with_signer( let jose_signer = JoseSigner { kid: key_id.to_string(), + dsa, signer, }; @@ -84,8 +86,20 @@ pub fn sign_with_did( ))); } + let public_jwk = bearer_did + .document + .find_verification_method(FindVerificationMethodOptions { + verification_method_id: Some(verification_method_id.clone()), + })? + .public_key_jwk; + + let dsa = Dsa::from_str(&public_jwk.alg.clone().ok_or(Web5Error::Crypto(format!( + "did document vm {} publicKeyJwk must have alg", + verification_method_id + )))?)?; + let signer = bearer_did.get_signer(&verification_method_id)?; - sign_with_signer(vc, &verification_method_id, signer) + sign_with_signer(vc, &verification_method_id, dsa, signer) } #[cfg(test)] diff --git a/crates/web5/src/credentials/verifiable_presentation_1_1.rs b/crates/web5/src/credentials/verifiable_presentation_1_1.rs index f012c5a6..c1585d22 100644 --- a/crates/web5/src/credentials/verifiable_presentation_1_1.rs +++ b/crates/web5/src/credentials/verifiable_presentation_1_1.rs @@ -2,7 +2,8 @@ use crate::credentials::josekit::{JoseSigner, JoseVerifier, JoseVerifierAlwaysTr use crate::credentials::verifiable_credential_1_1::VerifiableCredential; use crate::credentials::VerificationError; use crate::crypto::dsa::ed25519::Ed25519Verifier; -use crate::crypto::dsa::Signer; +use crate::crypto::dsa::secp256k1::Secp256k1Verifier; +use crate::crypto::dsa::{Dsa, Signer, Verifier}; use crate::dids::bearer_did::BearerDid; use crate::dids::data_model::document::FindVerificationMethodOptions; use crate::dids::did::Did; @@ -18,6 +19,7 @@ use josekit::jwt::JwtPayload; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; +use std::str::FromStr; use std::sync::Arc; use std::time::SystemTime; use uuid::Uuid; @@ -74,13 +76,15 @@ pub struct JwtPayloadVerifiablePresentation { #[serde( rename = "issuanceDate", serialize_with = "serialize_optional_system_time", - deserialize_with = "deserialize_optional_system_time" + deserialize_with = "deserialize_optional_system_time", + default )] pub issuance_date: Option, #[serde( rename = "expirationDate", serialize_with = "serialize_optional_system_time", - deserialize_with = "deserialize_optional_system_time" + deserialize_with = "deserialize_optional_system_time", + default )] pub expiration_date: Option, #[serde(rename = "verifiableCredential", skip_serializing_if = "Vec::is_empty")] @@ -150,6 +154,7 @@ impl VerifiablePresentation { pub fn sign_presentation_with_signer( vp: &VerifiablePresentation, key_id: &str, + dsa: Dsa, signer: Arc, ) -> Result { let mut payload = JwtPayload::new(); @@ -177,6 +182,7 @@ pub fn sign_presentation_with_signer( let jose_signer = JoseSigner { kid: key_id.to_string(), + dsa: dsa.clone(), signer, }; @@ -217,8 +223,20 @@ pub fn sign_presentation_with_did( ))); } + let public_jwk = bearer_did + .document + .find_verification_method(FindVerificationMethodOptions { + verification_method_id: Some(verification_method_id.clone()), + })? + .public_key_jwk; + + let dsa = Dsa::from_str(&public_jwk.alg.clone().ok_or(Web5Error::Crypto(format!( + "did document vm {} publicKeyJwk must have alg", + verification_method_id + )))?)?; + let signer = bearer_did.get_signer(&verification_method_id)?; - sign_presentation_with_signer(vp, &verification_method_id, signer) + sign_presentation_with_signer(vp, &verification_method_id, dsa, signer) } fn build_vp_context(context: Option>) -> Vec { @@ -258,7 +276,7 @@ pub fn decode_vp_jwt(vp_jwt: &str, verify_signature: bool) -> Result Result = match dsa { + Dsa::Ed25519 => Arc::new(Ed25519Verifier::new(public_jwk)), + Dsa::Secp256k1 => Arc::new(Secp256k1Verifier::new(public_jwk)), + }; + let jose_verifier = &JoseVerifier { kid: kid.to_string(), - verifier: Arc::new(Ed25519Verifier::new(public_key_jwk)), + dsa, + verifier, }; let (jwt_payload, _) = diff --git a/crates/web5/src/crypto/dsa/mod.rs b/crates/web5/src/crypto/dsa/mod.rs index 6e593ca4..054675f7 100644 --- a/crates/web5/src/crypto/dsa/mod.rs +++ b/crates/web5/src/crypto/dsa/mod.rs @@ -3,6 +3,7 @@ use crate::errors::{Result, Web5Error}; pub mod ed25519; pub mod secp256k1; +#[derive(Clone)] pub enum Dsa { Ed25519, Secp256k1, @@ -15,6 +16,7 @@ impl std::str::FromStr for Dsa { match input.to_ascii_lowercase().as_str() { "ed25519" => Ok(Dsa::Ed25519), "secp256k1" => Ok(Dsa::Secp256k1), + "es256k" => Ok(Dsa::Secp256k1), _ => Err(Web5Error::Parameter(format!("unsupported dsa {}", input))), } } diff --git a/crates/web5/src/crypto/dsa/secp256k1.rs b/crates/web5/src/crypto/dsa/secp256k1.rs index 844576f0..b337a664 100644 --- a/crates/web5/src/crypto/dsa/secp256k1.rs +++ b/crates/web5/src/crypto/dsa/secp256k1.rs @@ -1,7 +1,11 @@ +use super::Signer; +use super::Verifier; use crate::crypto::jwk::Jwk; use crate::errors::Result; use crate::errors::Web5Error; use base64::{engine::general_purpose, Engine as _}; +use k256::ecdsa::signature::{Signer as K256Signer, Verifier as K256Verifier}; +use k256::ecdsa::Signature; pub struct Secp256k1Generator; @@ -66,33 +70,123 @@ pub fn public_jwk_from_bytes(public_key: &[u8]) -> Result { }) } +#[derive(Clone)] +pub struct Secp256k1Signer { + private_jwk: Jwk, +} + +impl Secp256k1Signer { + pub fn new(private_jwk: Jwk) -> Self { + Self { private_jwk } + } +} + +impl Signer for Secp256k1Signer { + fn sign(&self, payload: &[u8]) -> Result> { + let d = self.private_jwk.d.as_ref().ok_or(Web5Error::Crypto( + "private key material must be set".to_string(), + ))?; + + let decoded_d = general_purpose::URL_SAFE_NO_PAD.decode(d)?; + + let signing_key = k256::ecdsa::SigningKey::from_slice(&decoded_d) + .map_err(|_| Web5Error::Crypto("invalid private key".to_string()))?; + + let signature: Signature = signing_key.sign(payload); + + Ok(signature.to_vec()) + } +} + +#[derive(Clone)] +pub struct Secp256k1Verifier { + public_jwk: Jwk, +} + +impl Secp256k1Verifier { + pub fn new(public_jwk: Jwk) -> Self { + Self { public_jwk } + } +} + +impl Verifier for Secp256k1Verifier { + fn verify(&self, payload: &[u8], signature: &[u8]) -> Result<()> { + if let Some(d) = &self.public_jwk.d { + if !d.is_empty() { + return Err(Web5Error::Crypto( + "provided verification key cannot contain private key material".to_string(), + )); + } + } + + let public_key_bytes = public_jwk_extract_bytes(&self.public_jwk)?; + + let verifying_key = k256::ecdsa::VerifyingKey::from_sec1_bytes(&public_key_bytes) + .map_err(|_| Web5Error::Crypto("unable to instantiate verifying key".to_string()))?; + + let signature = k256::ecdsa::Signature::from_slice(signature) + .map_err(|_| Web5Error::Crypto("invalid signature".to_string()))?; + + verifying_key + .verify(payload, &signature) + .map_err(|_| Web5Error::Crypto("cryptographic verification failure".to_string())) + } +} + #[cfg(test)] mod tests { use super::*; + use crate::test_helpers::UnitTestSuite; + use crate::test_name; + use lazy_static::lazy_static; mod generate { use super::*; + lazy_static! { + static ref TEST_SUITE: UnitTestSuite = UnitTestSuite::new("secp256k1_generate"); + } + + #[test] + fn z_assert_all_suite_cases_covered() { + // fn name prefixed with `z_*` b/c rust test harness executes in alphabetical order, + // unless intentionally executed with "shuffle" https://doc.rust-lang.org/rustc/tests/index.html#--shuffle + // this may not work if shuffled or if test list grows to the extent of 100ms being insufficient wait time + + // wait 100ms to be last-in-queue of mutex lock + std::thread::sleep(std::time::Duration::from_millis(100)); + + TEST_SUITE.assert_coverage() + } + #[test] fn test_must_set_alg() { + TEST_SUITE.include(test_name!()); + let jwk = Secp256k1Generator::generate(); assert_eq!(jwk.alg, Some("ES256K".to_string())); } #[test] fn test_must_set_kty() { + TEST_SUITE.include(test_name!()); + let jwk = Secp256k1Generator::generate(); assert_eq!(jwk.kty, "EC".to_string()); } #[test] fn test_must_set_crv() { + TEST_SUITE.include(test_name!()); + let jwk = Secp256k1Generator::generate(); assert_eq!(jwk.crv, "secp256k1"); } #[test] fn test_must_set_public_key_with_correct_length() { + TEST_SUITE.include(test_name!()); + let jwk = Secp256k1Generator::generate(); let x_bytes = general_purpose::URL_SAFE_NO_PAD .decode(&jwk.x) @@ -106,6 +200,8 @@ mod tests { #[test] fn test_must_set_private_key_with_correct_length() { + TEST_SUITE.include(test_name!()); + let jwk = Secp256k1Generator::generate(); let private_key_bytes = jwk.d.expect("Private key is missing"); let decoded_private_key_bytes = general_purpose::URL_SAFE_NO_PAD @@ -114,4 +210,235 @@ mod tests { assert_eq!(decoded_private_key_bytes.len(), 32); } } + + mod sign { + use super::*; + + lazy_static! { + static ref TEST_SUITE: UnitTestSuite = UnitTestSuite::new("secp256k1_sign"); + } + + #[test] + fn z_assert_all_suite_cases_covered() { + // fn name prefixed with `z_*` b/c rust test harness executes in alphabetical order, + // unless intentionally executed with "shuffle" https://doc.rust-lang.org/rustc/tests/index.html#--shuffle + // this may not work if shuffled or if test list grows to the extent of 100ms being insufficient wait time + + // wait 100ms to be last-in-queue of mutex lock + std::thread::sleep(std::time::Duration::from_millis(100)); + + TEST_SUITE.assert_coverage() + } + + #[test] + fn test_with_valid_key() { + TEST_SUITE.include(test_name!()); + + let jwk = Secp256k1Generator::generate(); + let signer = Secp256k1Signer::new(jwk); + + let message = b"Test message"; + let signature_result = signer.sign(message); + + assert!( + signature_result.is_ok(), + "Signing should succeed with a valid key" + ); + + let signature = signature_result.unwrap(); + assert_eq!( + signature.len(), + 64, // Expected length for Secp256k1 signature (r + s, each 32 bytes) + "Signature length should match the expected Secp256k1 signature length" + ); + } + + #[test] + fn test_with_invalid_private_key() { + TEST_SUITE.include(test_name!()); + + let mut jwk = Secp256k1Generator::generate(); + + // Set an invalid private key (wrong length) + jwk.d = Some(general_purpose::URL_SAFE_NO_PAD.encode(&[0u8; 31])); // One byte too short + + let signer = Secp256k1Signer::new(jwk); + let message = b"Test message"; + let signature_result = signer.sign(message); + + assert!( + signature_result.is_err(), + "Signing should fail with an invalid private key" + ); + assert_eq!( + signature_result.unwrap_err(), + Web5Error::Crypto("invalid private key".to_string()) + ); + } + + #[test] + fn test_with_missing_private_key() { + TEST_SUITE.include(test_name!()); + + let mut jwk = Secp256k1Generator::generate(); + + // Remove the private key + jwk.d = None; + + let signer = Secp256k1Signer::new(jwk); + let message = b"Test message"; + let signature_result = signer.sign(message); + + assert!( + signature_result.is_err(), + "Signing should fail if the private key is missing" + ); + assert_eq!( + signature_result.unwrap_err(), + Web5Error::Crypto("private key material must be set".to_string()) + ); + } + } + + mod verify { + use super::*; + + lazy_static! { + static ref TEST_SUITE: UnitTestSuite = UnitTestSuite::new("secp256k1_verify"); + } + + #[test] + fn z_assert_all_suite_cases_covered() { + // fn name prefixed with `z_*` b/c rust test harness executes in alphabetical order, + // unless intentionally executed with "shuffle" https://doc.rust-lang.org/rustc/tests/index.html#--shuffle + // this may not work if shuffled or if test list grows to the extent of 100ms being insufficient wait time + + // wait 100ms to be last-in-queue of mutex lock + std::thread::sleep(std::time::Duration::from_millis(100)); + + TEST_SUITE.assert_coverage() + } + + fn generate_keys() -> (Jwk, Jwk) { + let private_jwk = Secp256k1Generator::generate(); + let public_jwk = to_public_jwk(&private_jwk); + (public_jwk, private_jwk) + } + + #[test] + fn test_with_valid_signature() { + TEST_SUITE.include(test_name!()); + + let (public_jwk, private_jwk) = generate_keys(); + let signer = Secp256k1Signer::new(private_jwk); + let verifier = Secp256k1Verifier::new(public_jwk); + + let message = b"Test message"; + let signature = signer.sign(message).expect("Signing failed"); + + let verify_result = verifier.verify(message, &signature); + + assert!( + verify_result.is_ok(), + "Verification should succeed with a valid signature" + ); + } + + #[test] + fn test_with_private_key() { + TEST_SUITE.include(test_name!()); + + let (_, private_jwk) = generate_keys(); + let verifier = Secp256k1Verifier::new(private_jwk); // Should not use a private key for verification + + let message = b"Test message"; + let invalid_signature = vec![0u8; 64]; // Invalid length + + let verify_result = verifier.verify(message, &invalid_signature); + + assert!( + verify_result.is_err(), + "Verification should fail when a private key is used" + ); + assert_eq!( + verify_result.unwrap_err(), + Web5Error::Crypto( + "provided verification key cannot contain private key material".to_string() + ) + ); + } + + #[test] + fn test_with_invalid_signature() { + TEST_SUITE.include(test_name!()); + + let (public_jwk, private_jwk) = generate_keys(); + let signer = Secp256k1Signer::new(private_jwk); + let verifier = Secp256k1Verifier::new(public_jwk); + + let message = b"Test message"; + + let mut valid_signature = signer.sign(message).expect("Signing failed"); + let last_bit = valid_signature.len() - 1; + valid_signature[last_bit] ^= 0x01; // Flip the last bit + + let verify_result = verifier.verify(message, &valid_signature); + + assert!( + verify_result.is_err(), + "Verification should fail with an invalid signature" + ); + assert_eq!( + verify_result.unwrap_err(), + Web5Error::Crypto("cryptographic verification failure".to_string()) + ); + } + + #[test] + fn test_with_invalid_public_key() { + TEST_SUITE.include(test_name!()); + + let (mut public_jwk, private_jwk) = generate_keys(); + public_jwk.x = general_purpose::URL_SAFE_NO_PAD.encode(&[0u8; 31]); // Invalid length + + let signer = Secp256k1Signer::new(private_jwk); + let verifier = Secp256k1Verifier::new(public_jwk); + + let message = b"Test message"; + let signature = signer.sign(message).expect("Signing failed"); + + let verify_result = verifier.verify(message, &signature); + + assert!( + verify_result.is_err(), + "Verification should fail with an invalid public key" + ); + assert_eq!( + verify_result.unwrap_err(), + Web5Error::Crypto("unable to instantiate verifying key".to_string()) + ); + } + + #[test] + fn test_with_invalid_signature_length() { + TEST_SUITE.include(test_name!()); + + let (public_jwk, _) = generate_keys(); + let verifier = Secp256k1Verifier::new(public_jwk); + + let message = b"Test message"; + let invalid_signature = vec![0u8; 63]; // Invalid length (should be 64 bytes) + + let verify_result = verifier.verify(message, &invalid_signature); + + assert!( + verify_result.is_err(), + "Verification should fail with a signature of incorrect length" + ); + assert_eq!( + verify_result.unwrap_err(), + Web5Error::Crypto("invalid signature".to_string()) + ); + } + } } diff --git a/crates/web5/src/crypto/key_managers/in_memory_key_manager.rs b/crates/web5/src/crypto/key_managers/in_memory_key_manager.rs index 48052a12..dc849727 100644 --- a/crates/web5/src/crypto/key_managers/in_memory_key_manager.rs +++ b/crates/web5/src/crypto/key_managers/in_memory_key_manager.rs @@ -1,13 +1,14 @@ use super::{KeyExporter, KeyManager}; use crate::{ crypto::{ - dsa::{ed25519::Ed25519Signer, Signer}, + dsa::{ed25519::Ed25519Signer, secp256k1::Secp256k1Signer, Dsa, Signer}, jwk::Jwk, }, errors::{Result, Web5Error}, }; use std::{ collections::HashMap, + str::FromStr, sync::{Arc, RwLock}, }; @@ -64,7 +65,18 @@ impl KeyManager for InMemoryKeyManager { "signer not found for public_jwk with thumbprint {}", thumbprint )))?; - Ok(Arc::new(Ed25519Signer::new(private_jwk.clone()))) + + let dsa = Dsa::from_str( + &public_jwk + .alg + .clone() + .ok_or(Web5Error::Crypto("public jwk must have alg".to_string()))?, + )?; + let signer: Arc = match dsa { + Dsa::Ed25519 => Arc::new(Ed25519Signer::new(private_jwk.clone())), + Dsa::Secp256k1 => Arc::new(Secp256k1Signer::new(private_jwk.clone())), + }; + Ok(signer) } } diff --git a/crates/web5/src/test_vectors.rs b/crates/web5/src/test_vectors.rs index ab45e135..977f29bc 100644 --- a/crates/web5/src/test_vectors.rs +++ b/crates/web5/src/test_vectors.rs @@ -324,4 +324,46 @@ mod test_vectors { .collect::() } } + + mod credentials { + use super::*; + use crate::credentials::VerifiableCredential; + + #[derive(Debug, serde::Deserialize)] + struct VerifyVectorInput { + #[serde(rename = "vcJwt")] + pub vc_jwt: String, + } + + #[test] + fn verify() { + let path = "credentials/verify.json"; + let vectors: TestVectorFile> = + TestVectorFile::load_from_path(path); + + for vector in vectors.vectors { + if vector.description.contains("from web5-") { + continue; + } + + let vc_jwt = vector.input.vc_jwt; + let result = VerifiableCredential::from_vc_jwt(&vc_jwt, true); + + if matches!(vector.errors, Some(true)) { + assert!( + result.is_err(), + "Expected an error, but verification succeeded. Result: {:?}, Description: {}", + result, vector.description + ); + } else { + assert!( + result.is_ok(), + "Verification failed. Result: {:?}, Description: {}", + result, + vector.description + ); + } + } + } + } } diff --git a/tests/unit_test_cases/secp256k1_generate.json b/tests/unit_test_cases/secp256k1_generate.json new file mode 100644 index 00000000..76513941 --- /dev/null +++ b/tests/unit_test_cases/secp256k1_generate.json @@ -0,0 +1,7 @@ +[ + "test_must_set_alg", + "test_must_set_kty", + "test_must_set_crv", + "test_must_set_public_key_with_correct_length", + "test_must_set_private_key_with_correct_length" +] diff --git a/tests/unit_test_cases/secp256k1_sign.json b/tests/unit_test_cases/secp256k1_sign.json new file mode 100644 index 00000000..55edacad --- /dev/null +++ b/tests/unit_test_cases/secp256k1_sign.json @@ -0,0 +1,5 @@ +[ + "test_with_valid_key", + "test_with_invalid_private_key", + "test_with_missing_private_key" +] diff --git a/tests/unit_test_cases/secp256k1_verify.json b/tests/unit_test_cases/secp256k1_verify.json new file mode 100644 index 00000000..4e31615f --- /dev/null +++ b/tests/unit_test_cases/secp256k1_verify.json @@ -0,0 +1,7 @@ +[ + "test_with_valid_signature", + "test_with_private_key", + "test_with_invalid_signature", + "test_with_invalid_public_key", + "test_with_invalid_signature_length" +] diff --git a/web5-spec b/web5-spec index 10f3f2b4..f08247aa 160000 --- a/web5-spec +++ b/web5-spec @@ -1 +1 @@ -Subproject commit 10f3f2b4d571b41a3598c4bcc23d081fffd7f1ef +Subproject commit f08247aa1fa03dfab146bb2073b5240580865810