Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implements SD-JWT #103

Open
wants to merge 25 commits into
base: main
Choose a base branch
from
Open

Implements SD-JWT #103

wants to merge 25 commits into from

Conversation

andresuribe87
Copy link
Contributor

@andresuribe87 andresuribe87 commented Oct 27, 2023

Overview

Implements a module that supports SD-JWT according to https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-05.htm
This PR will be a first step towards fully closing #56

Description

SD-JWT is becoming the de-factor standard. This PR implements the basic functionality in order to support SD-JWT from the perspective of the holder, the issuer, and the verifier. Simple documentation has been added in this iteration. It's published as a separate module, and it's dependencies are kept lean on purpose, since this module is meant to be used independently from DIDs and VCs.

Most of the design takes inspiration from the Nimbus library, where a JWT needs to be created first, and then signed.

Feedback would be much appreciated.

How Has This Been Tested?

Unit tests everywhere.

@codecov
Copy link

codecov bot commented Oct 27, 2023

Codecov Report

Merging #103 (4eb9bcb) into main (a0e4225) will increase coverage by 0.95%.
Report is 7 commits behind head on main.
The diff coverage is 67.36%.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #103      +/-   ##
==========================================
+ Coverage   78.96%   79.91%   +0.95%     
==========================================
  Files          33       41       +8     
  Lines        1930     2788     +858     
  Branches      265      477     +212     
==========================================
+ Hits         1524     2228     +704     
- Misses        274      378     +104     
- Partials      132      182      +50     
Components Coverage Δ
credentials 84.60% <ø> (+3.92%) ⬆️
crypto 38.07% <ø> (ø)
dids 90.98% <ø> (+1.38%) ⬆️
common 80.55% <ø> (+0.10%) ⬆️

}

// Check that the SD-JWT is valid using nbf, iat, and exp claims, if provided in the SD-JWT, and not selectively disclosed.
val claimsVerifier = DefaultJWTClaimsVerifier<SecurityContext>(null, null)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are these nulls in the constructor expected?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. Those provide fields that are required. passing null validated the nbf, iat, and exp claims only when they're present.


// Ensure that a signing algorithm was used that was deemed secure for the application. Refer to [RFC8725], Sections 3.1
// and 3.2 for details.
require(verificationOptions.supportedAlgorithms.contains(keyBindingJwt.header.algorithm)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

did similar supportedAlgorithms above, maybe pull out into a fuction

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't quite follow the suggestion, mind elaborating?

"supported algorithms (${verificationOptions.supportedAlgorithms})"
}
// The none algorithm MUST NOT be accepted.
require(keyBindingJwt.header.algorithm != JWSAlgorithm.NONE) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

verificationOptions.supportedAlgorithms are meant to be user specified, BUT the spec explicitly says that NONE cannot be part of it. Let me know if that makes sense. If not, mind making a concrete suggestion to improve?


// Ensure that the disclosure is object or array disclosure
when (disclosureElems.size) {
3 -> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fancy fancy

)
}

else -> throw IllegalArgumentException("Disclosure \"$encodedDisclosure\" must have exactly 3 elements")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this correct? Seems like there is a case to handle 2 elements

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed, thx!


repeat(totalDigests(arrayDisclosures.size) - arrayDisclosures.size) {
val randBytes = ByteArray(32)
SecureRandom().nextBytes(randBytes)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We so need external audits on this stuff :hahaha:

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Curious what parts were red flags?



/** Interface for generating salt values as described in https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-05.html#disclosures_for_object_properties */
public interface ISaltGenerator {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there not a library for this? Is this a special case sd-jwt salt?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There isn't a library that does exactly what's needed. This module provides an implementation below, which is used by default. It can be overriden, which was super useful for tests.


}

private fun getNextPowerOfTwo(n: Int): Int {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wow some leetcode stuff

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More like CHAD stuff hahaha

"zip_code": "12345"
}
}""".trimIndent()
val claimsToBlind = mapOf(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why can't this be dynamic? Is it to give the option to the user?
If they provide null as claimsToBlind can it dynamically do default blinding to all?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The issuer of an SdJwt must, at some point, select which claims they'll allow the holder to selectively disclose. For those, they must also say how they'll be disclosable, since there are various mechanisms.

What do you think should be the way in which they are all blinded by default?

Copy link
Contributor

@nitro-neal nitro-neal left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wow mind blown. Great stuff! I need to reread the sd-jwt spec and look over one more time.

Will this only be used for credentials? Does it make sense to put it in a sub package of credentials or something?

There will be more changes in the credentials package to make it user friendly to get SD JWT creds along with the normal cred they made?

VerifiableCredential.create(sdJwt=ture) // something like this?

@andresuribe87
Copy link
Contributor Author

Will this only be used for credentials?

No, it can be used for other types credentials, that aren't w3c vcs.

There will be more changes in the credentials package to make it user friendly to get SD JWT creds along with the normal cred they made?

Yes, but that comes later. Once we have support for VC2.0, then we'll need to allow securing it using sd-jwt. That will likely be the piece that does belong inside the credentials module.

VerifiableCredential.create(sdJwt=ture) // something like this?

Something like that sounds reasonable.

* Verifies this SD-JWT according to [verificationOptions].
*/
@Throws(Exception::class)
public fun verify(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe add some more detailed explanations on these comments, similar to our other packages, and add example code if it is short and sweet

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added more documentation, and an example.

public fun unblind(
serializedSdJwt: String
): JWTClaimsSet {
// 2. Separate the Presentation into the SD-JWT, the Disclosures (if any), and the Holder Binding JWT (if provided).
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// 2. Separate the Presentation into the SD-JWT, the Disclosures (if any), and the Holder Binding JWT (if provided).
// Separate the Presentation into the SD-JWT, the Disclosures (if any), and the Holder Binding JWT (if provided).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

val disclosuresByDigest = sdJwt.disclosures.associateBy { it.digest(hashAlg) }

// Process the Disclosures and _sd keys in the SD-JWT as follows:
//
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
//

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

}

@Test
fun testExample1() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fix the test names in this file

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed, and added where they came from

Comment on lines 56 to 57
val keyManager = InMemoryKeyManager()
DidKey.create(keyManager)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are these lines needed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They aren't! Removed.

@@ -1,3 +1,4 @@
rootProject.name = "web5"
include("common", "crypto", "dids", "credentials")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
include("common", "crypto", "dids", "credentials")
include("common", "crypto", "dids", "credentials", "sdjwt")

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, separated everything into single lines and sorted alphabetically.

@nitro-neal
Copy link
Contributor

Why is this another package?

In the future should we abstract like a lot of this away and have something like:

data class StreetCredibility(val localRespect: String, val legit: Boolean)

val vc = VerifiableCredential.create(
  type = "StreetCred",
  issuer = "did:example:issuer",
  subject = "did:example:subject",
  data = StreetCredibility(localRespect = "high", legit = true)
)

val sdVcs = vc.toSdVcs()

Am I thinking about this the wrong way?

Copy link
Contributor

@nitro-neal nitro-neal left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Happy to get this in since it is isolated in another package, but I want to see how we fit this into the bigger picture of our VerifiableCredential packages.

Would love to the see the api interface easier to understand and/or integrated in our existing VC package if possible

return when (jwk) {
is ECKey -> jwk.toPublicKey()
is OctetKeyPair -> jwk.toPublicKey()
is OctetSequenceKey -> jwk.toSecretKey()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this secret but the others are public?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ooof, good catch. Removed this line. Should only be using asymmetric keys.

)
}

2 -> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: put 2 before 3

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Copy link
Member

@decentralgabe decentralgabe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm - would appreciate a review from @mistermoe or @phoebe-lew before merging

* Note: While [issuerJwt] and [keyBindingJwt] are of type [SignedJWT], they may or may not be signed.
*/
public class SdJwt(
public val issuerJwt: SignedJWT,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So in order to use SD-JWT, if for example if I have VC in JWT format created by the web5/credentials package, do I first need to convert it into the nimbusds SignedJWT object?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes you do.

Assuming you have an object of type String that was returned from the VerifiableCredential.sign function, you can do something like this:

val vcJwtString = ""
val issuerJwt = SignedJWT.parse(vcJwtString)

The motivation behind this design for a the SdJwt object was to:

  • Reduce cognitive overhead by only allowing specific types instead of generic primitives (i.e. SignedJWT vs String).
  • Allow for others to use the sd-jwt library by itself by decoupling it from other existing packages in web5. Later on, changes can be made to web5/credentials and web5/dids to make it easy to create an sd-jwt that contains a VC.

/**
* Represents a disclosure for an Object Property as defined in https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-05.html#name-disclosures-for-object-prop.
*/
public class ObjectDisclosure(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe move out disclosure classes into separate file?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

return Pair(blinded, allDisclosures)
}

fun toBlindedClaimsAndDisclosures(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@andresuribe87 can you add comments or doc for this function? For code readability rather than external documentation

}

@Test
fun `whole flow from issuer to holder to verifier`() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

super helpful to understand how the whole thing works!

.build()
val sdJwt = builder.build()

sdJwt.signAsIssuer(issuerSigner)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can I not sign this with my did?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No. As mentioned in #103 (comment), the motivation was to make this a lower level lib. What you're suggesting is an excellent idea, and I would encourage that to be done in a separate PR that changes the public APIs that exist in the web5/credentials module.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Support SD-JWT as format of VC 2.0 creds
4 participants