diff --git a/CODEOWNERS b/CODEOWNERS index 9cd62f39c..72bdf13b9 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -9,7 +9,7 @@ # The format is described: https://github.blog/2017-07-06-introducing-code-owners/ # These owners will be the default owners for everything in the repo. -* @amika-sq @mistermoe @nitro-neal @tomdaffurn @phoebe-lew @diehuxx @kirahsapong @jiyoontbd @frankhinek +* @mistermoe @nitro-neal @tomdaffurn @phoebe-lew @diehuxx @kirahsapong @jiyoontbd @frankhinek /crypto/src/main/kotlin/web5/sdk/crypto/AwsKeyManager.kt @tomdaffurn # ----------------------------------------------- diff --git a/build.gradle.kts b/build.gradle.kts index 23eb9bafc..80efcd1e2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -63,6 +63,7 @@ dependencies { api(project(":credentials")) api(project(":crypto")) api(project(":dids")) + api(project(":jose")) detektPlugins("io.gitlab.arturbosch.detekt:detekt-formatting:1.23.+") } diff --git a/common/src/main/kotlin/web5/sdk/common/Convert.kt b/common/src/main/kotlin/web5/sdk/common/Convert.kt index 698b52839..19b0dfeeb 100644 --- a/common/src/main/kotlin/web5/sdk/common/Convert.kt +++ b/common/src/main/kotlin/web5/sdk/common/Convert.kt @@ -46,7 +46,7 @@ public val B64URL_DECODER: Base64.Decoder = Base64.getUrlDecoder() * ``` * // Example 1: Convert a ByteArray to a Base64Url encoded string without padding * val byteArray = byteArrayOf(1, 2, 3) - * val base64Url = Convert(byteArray).toBase64Url(padding = false) + * val base64Url = Convert(byteArray).toBase64Url() // same as .toBase64Url(padding = false) * println(base64Url) // Output should be a Base64Url encoded string without padding * * // Example 2: Convert a Base64Url encoded string to a ByteArray @@ -68,13 +68,14 @@ public class Convert(private val value: T, private val kind: EncodingFormat? /** * Converts the [value] to a Base64Url-encoded string. * - * @param padding Determines whether the resulting Base64 string should be padded or not. Default is true. + * @param padding Determines whether the resulting Base64 string should be padded or not. + * Default is false. * @return The Base64Url-encoded string. * * Note: If the value type is unsupported for this conversion, the method will throw an exception. */ @JvmOverloads - public fun toBase64Url(padding: Boolean = true): String { + public fun toBase64Url(padding: Boolean = false): String { val encoder = if (padding) B64URL_ENCODER else B64URL_ENCODER.withoutPadding() return when (this.value) { diff --git a/common/src/main/kotlin/web5/sdk/common/Json.kt b/common/src/main/kotlin/web5/sdk/common/Json.kt index 60b4e0e3d..3ac1ccd10 100644 --- a/common/src/main/kotlin/web5/sdk/common/Json.kt +++ b/common/src/main/kotlin/web5/sdk/common/Json.kt @@ -2,8 +2,11 @@ package web5.sdk.common import com.fasterxml.jackson.annotation.JsonInclude import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.ObjectReader import com.fasterxml.jackson.databind.ObjectWriter import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.databind.module.SimpleModule +import com.fasterxml.jackson.module.kotlin.readValue import com.fasterxml.jackson.module.kotlin.registerKotlinModule /** @@ -21,6 +24,7 @@ import com.fasterxml.jackson.module.kotlin.registerKotlinModule * ``` */ public object Json { + /** * The Jackson object mapper instance, shared across the lib. * @@ -32,7 +36,10 @@ public object Json { .setSerializationInclusion(JsonInclude.Include.NON_NULL) .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + + private val objectWriter: ObjectWriter = jsonMapper.writer() + public val objectReader: ObjectReader = jsonMapper.reader() /** * Converts a kotlin object to a json string. @@ -43,4 +50,24 @@ public object Json { public fun stringify(obj: Any): String { return objectWriter.writeValueAsString(obj) } -} \ No newline at end of file + + /** + * Parse a json string into a kotlin object. + * + * @param T type of the object to parse. + * @param payload JSON string to parse + * @return parsed type T + */ + public inline fun parse(payload: String): T { + return objectReader.readValue(payload, T::class.java) + } + + /** + * Parse a JSON string into a Map. + * + * @return String parsed into a Map + */ + public fun String.toMap(): Map { + return jsonMapper.readValue(this) + } +} diff --git a/config/detekt.yml b/config/detekt.yml index 855c8123a..0eb0aad62 100644 --- a/config/detekt.yml +++ b/config/detekt.yml @@ -169,7 +169,7 @@ style: UselessCallOnNotNull: active: true UtilityClassWithPublicConstructor: - active: true + active: false TbdRuleset: JvmOverloadsAnnotationRule: diff --git a/credentials/build.gradle.kts b/credentials/build.gradle.kts index b7671ea46..877383c4a 100644 --- a/credentials/build.gradle.kts +++ b/credentials/build.gradle.kts @@ -38,6 +38,8 @@ dependencies { implementation(project(":dids")) implementation(project(":common")) implementation(project(":crypto")) + implementation(project(":jose")) + // Implementation implementation(libs.comFasterXmlJacksonModuleKotlin) diff --git a/credentials/src/main/kotlin/web5/sdk/credentials/PresentationExchange.kt b/credentials/src/main/kotlin/web5/sdk/credentials/PresentationExchange.kt index 0954adc5b..cc93992e9 100644 --- a/credentials/src/main/kotlin/web5/sdk/credentials/PresentationExchange.kt +++ b/credentials/src/main/kotlin/web5/sdk/credentials/PresentationExchange.kt @@ -1,18 +1,21 @@ package web5.sdk.credentials import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.module.SimpleModule import com.fasterxml.jackson.databind.node.ObjectNode import com.networknt.schema.JsonSchema import com.nfeld.jsonpathkt.JsonPath import com.nfeld.jsonpathkt.extension.read -import com.nimbusds.jwt.JWTParser -import com.nimbusds.jwt.SignedJWT +import web5.sdk.common.Json import web5.sdk.credentials.model.InputDescriptorMapping import web5.sdk.credentials.model.InputDescriptorV2 import web5.sdk.credentials.model.PresentationDefinitionV2 import web5.sdk.credentials.model.PresentationDefinitionV2Validator import web5.sdk.credentials.model.PresentationSubmission import web5.sdk.credentials.model.PresentationSubmissionValidator +import web5.sdk.jose.JwtClaimsSetSerializer +import web5.sdk.jose.jwt.Jwt +import web5.sdk.jose.jwt.JwtClaimsSet import java.util.UUID /** @@ -161,12 +164,17 @@ public object PresentationExchange { vcJwtList: Iterable, presentationDefinition: PresentationDefinitionV2 ): Map> { - val vcJwtListWithNodes = vcJwtList.zip(vcJwtList.map { vcJwt -> - val vc = JWTParser.parse(vcJwt) as SignedJWT - JsonPath.parse(vc.payload.toString()) - ?: throw JsonPathParseException() - }) + val jwtModule = SimpleModule().addSerializer(JwtClaimsSet::class.java, JwtClaimsSetSerializer()) + Json.jsonMapper.registerModule(jwtModule) + + val vcJwtListWithNodes = vcJwtList.zip( + vcJwtList.map { vcJwt -> + val vc = Jwt.decode(vcJwt) + val jsonString = Json.jsonMapper.writeValueAsString(vc.claims) + Json.jsonMapper.readTree(jsonString) + ?: throw JsonPathParseException() + }) return presentationDefinition.inputDescriptors.associateWith { inputDescriptor -> vcJwtListWithNodes.filter { (_, node) -> vcSatisfiesInputDescriptor(node, inputDescriptor) diff --git a/credentials/src/main/kotlin/web5/sdk/credentials/VerifiableCredential.kt b/credentials/src/main/kotlin/web5/sdk/credentials/VerifiableCredential.kt index f987bd374..7ccfea232 100644 --- a/credentials/src/main/kotlin/web5/sdk/credentials/VerifiableCredential.kt +++ b/credentials/src/main/kotlin/web5/sdk/credentials/VerifiableCredential.kt @@ -10,11 +10,10 @@ import com.fasterxml.jackson.module.kotlin.KotlinModule import com.fasterxml.jackson.module.kotlin.convertValue import com.nfeld.jsonpathkt.JsonPath import com.nfeld.jsonpathkt.extension.read -import com.nimbusds.jwt.JWTClaimsSet -import com.nimbusds.jwt.JWTParser -import com.nimbusds.jwt.SignedJWT -import web5.sdk.credentials.util.JwtUtil -import web5.sdk.dids.Did +import web5.sdk.common.Json +import web5.sdk.dids.did.BearerDid +import web5.sdk.jose.jwt.Jwt +import web5.sdk.jose.jwt.JwtClaimsSet import java.net.URI import java.security.SignatureException import java.util.Date @@ -53,7 +52,7 @@ public class VerifiableCredential internal constructor(public val vcDataModel: V * If the [assertionMethodId] is null, the function will attempt to use the first available verification method from * the [did]. The result is a String in a JWT format. * - * @param did The [Did] used to sign the credential. + * @param did The [BearerDid] used to sign the credential. * @param assertionMethodId An optional identifier for the assertion method that will be used for verification of the * produced signature. * @return The JWT representing the signed verifiable credential. @@ -64,15 +63,15 @@ public class VerifiableCredential internal constructor(public val vcDataModel: V * ``` */ @JvmOverloads - public fun sign(did: Did, assertionMethodId: String? = null): String { - val payload = JWTClaimsSet.Builder() + public fun sign(did: BearerDid, assertionMethodId: String? = null): String { + val payload = JwtClaimsSet.Builder() .issuer(vcDataModel.issuer.toString()) - .issueTime(vcDataModel.issuanceDate) + .issueTime(vcDataModel.issuanceDate.time) .subject(vcDataModel.credentialSubject.id.toString()) - .claim("vc", vcDataModel.toMap()) + .misc("vc", vcDataModel.toMap()) .build() - return JwtUtil.sign(did, assertionMethodId, payload) + return Jwt.sign(did, payload) } /** @@ -188,7 +187,8 @@ public class VerifiableCredential internal constructor(public val vcDataModel: V * ``` */ public fun verify(vcJwt: String) { - JwtUtil.verify(vcJwt) + val decodedJwt = Jwt.decode(vcJwt) + decodedJwt.verify() } /** @@ -203,15 +203,12 @@ public class VerifiableCredential internal constructor(public val vcDataModel: V * ``` */ public fun parseJwt(vcJwt: String): VerifiableCredential { - val jwt = JWTParser.parse(vcJwt) as SignedJWT - val jwtPayload = jwt.payload.toJSONObject() - val vcDataModelValue = jwtPayload.getOrElse("vc") { + val jwt = Jwt.decode(vcJwt) + val jwtPayload = jwt.claims + val vcDataModelValue = jwtPayload.misc["vc"] ?: throw IllegalArgumentException("jwt payload missing vc property") - } - @Suppress("UNCHECKED_CAST") // only partially unchecked. can only safely cast to Map<*, *> - val vcDataModelMap = vcDataModelValue as? Map - ?: throw IllegalArgumentException("expected vc property in JWT payload to be an object") + val vcDataModelMap = Json.parse>(Json.stringify(vcDataModelValue)) val vcDataModel = VcDataModel.fromMap(vcDataModelMap) diff --git a/credentials/src/main/kotlin/web5/sdk/credentials/VerifiablePresentation.kt b/credentials/src/main/kotlin/web5/sdk/credentials/VerifiablePresentation.kt index ffd4bc14d..81e418824 100644 --- a/credentials/src/main/kotlin/web5/sdk/credentials/VerifiablePresentation.kt +++ b/credentials/src/main/kotlin/web5/sdk/credentials/VerifiablePresentation.kt @@ -3,11 +3,10 @@ package web5.sdk.credentials import com.fasterxml.jackson.annotation.JsonInclude import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.KotlinModule -import com.nimbusds.jwt.JWTClaimsSet -import com.nimbusds.jwt.JWTParser -import com.nimbusds.jwt.SignedJWT -import web5.sdk.credentials.util.JwtUtil -import web5.sdk.dids.Did +import web5.sdk.common.Json +import web5.sdk.dids.did.BearerDid +import web5.sdk.jose.jwt.Jwt +import web5.sdk.jose.jwt.JwtClaimsSet import java.net.URI import java.security.SignatureException import java.util.Date @@ -37,17 +36,17 @@ public class VerifiablePresentation internal constructor(public val vpDataModel: public val verifiableCredential: List get() = vpDataModel.toMap()["verifiableCredential"] as List - public val holder: String + public val holder: String get() = vpDataModel.holder.toString() /** - * Sign a verifiable presentation using a specified decentralized identifier ([did]) with the private key that pairs + * Sign a verifiable presentation using a specified decentralized identifier ([bearerDid]) with the private key that pairs * with the public key identified by [assertionMethodId]. * * If the [assertionMethodId] is null, the function will attempt to use the first available verification method from - * the [did]. The result is a String in a JWT format. + * the [bearerDid]. The result is a String in a JWT format. * - * @param did The [Did] used to sign the credential. + * @param bearerDid The [BearerDid] used to sign the credential. * @param assertionMethodId An optional identifier for the assertion method that will be used for verification of the * produced signature. * @return The JWT representing the signed verifiable credential. @@ -58,14 +57,14 @@ public class VerifiablePresentation internal constructor(public val vpDataModel: * ``` */ @JvmOverloads - public fun sign(did: Did, assertionMethodId: String? = null): String { - val payload = JWTClaimsSet.Builder() - .issuer(did.uri) - .issueTime(Date()) - .claim("vp", vpDataModel.toMap()) + public fun sign(bearerDid: BearerDid, assertionMethodId: String? = null): String { + val payload = JwtClaimsSet.Builder() + .issuer(bearerDid.uri) + .issueTime(Date().time / 1000) + .misc("vp", vpDataModel.toMap()) .build() - return JwtUtil.sign(did, assertionMethodId, payload) + return Jwt.sign(bearerDid, payload) } /** @@ -153,7 +152,8 @@ public class VerifiablePresentation internal constructor(public val vpDataModel: * ``` */ public fun verify(vpJwt: String) { - JwtUtil.verify(vpJwt) + val decodedJwt = Jwt.decode(vpJwt) + decodedJwt.verify() val vp = this.parseJwt(vpJwt) vp.verifiableCredential.forEach { @@ -177,15 +177,12 @@ public class VerifiablePresentation internal constructor(public val vpDataModel: * ``` */ public fun parseJwt(vpJwt: String): VerifiablePresentation { - val jwt = JWTParser.parse(vpJwt) as SignedJWT - val jwtPayload = jwt.payload.toJSONObject() - val vpDataModelValue = jwtPayload.getOrElse("vp") { - throw IllegalArgumentException("jwt payload missing vp property") - } + val jwt = Jwt.decode(vpJwt) + val jwtPayload = jwt.claims + val vpDataModelValue = jwtPayload.misc["vp"] + ?: throw IllegalArgumentException("jwt payload missing vp property") - @Suppress("UNCHECKED_CAST") // only partially unchecked. can only safely cast to Map<*, *> - val vpDataModelMap = vpDataModelValue as? Map - ?: throw IllegalArgumentException("expected vp property in JWT payload to be an object") + val vpDataModelMap = Json.parse>(Json.stringify(vpDataModelValue)) val vpDataModel = VpDataModel.fromMap(vpDataModelMap) diff --git a/credentials/src/main/kotlin/web5/sdk/credentials/util/JwtUtil.kt b/credentials/src/main/kotlin/web5/sdk/credentials/util/JwtUtil.kt index 5dbb2b921..e69de29bb 100644 --- a/credentials/src/main/kotlin/web5/sdk/credentials/util/JwtUtil.kt +++ b/credentials/src/main/kotlin/web5/sdk/credentials/util/JwtUtil.kt @@ -1,174 +0,0 @@ -package web5.sdk.credentials.util - -import com.nimbusds.jose.JOSEObjectType -import com.nimbusds.jose.JWSAlgorithm -import com.nimbusds.jose.JWSHeader -import com.nimbusds.jose.util.Base64URL -import com.nimbusds.jwt.JWTClaimsSet -import com.nimbusds.jwt.JWTParser -import com.nimbusds.jwt.SignedJWT -import web5.sdk.common.Convert -import web5.sdk.crypto.Crypto -import web5.sdk.dids.Did -import web5.sdk.dids.DidResolvers -import web5.sdk.dids.didcore.DidUri -import web5.sdk.dids.exceptions.DidResolutionException -import web5.sdk.dids.exceptions.PublicKeyJwkMissingException -import java.net.URI -import java.security.SignatureException - -private const val JSON_WEB_KEY_2020 = "JsonWebKey2020" -private const val JSON_WEB_KEY = "JsonWebKey" - -/** - * Util class for common shared JWT methods. - */ -public object JwtUtil { - /** - * Sign a jwt payload using a specified decentralized identifier ([did]) with the private key that pairs - * with the public key identified by [assertionMethodId]. - * - * If the [assertionMethodId] is null, the function will attempt to use the first available verification method from - * the [did]. The result is a String in a JWT format. - * - * @param did The [Did] used to sign the credential. - * @param assertionMethodId An optional identifier for the assertion method - * that will be used for verification of the produced signature. - * @param jwtPayload the payload that is getting signed by the [Did] - * @return The JWT representing the signed verifiable credential. - * - * Example: - * ``` - * val signedVc = verifiableCredential.sign(myDid) - * ``` - */ - public fun sign(did: Did, assertionMethodId: String?, jwtPayload: JWTClaimsSet): String { - val didResolutionResult = DidResolvers.resolve(did.uri) - val didDocument = didResolutionResult.didDocument - if (didResolutionResult.didResolutionMetadata.error != null || didDocument == null) { - throw DidResolutionException( - "Signature verification failed: " + - "Failed to resolve DID ${did.uri}. " + - "Error: ${didResolutionResult.didResolutionMetadata.error}" - ) - } - - val assertionMethod = didDocument.findAssertionMethodById(assertionMethodId) - - val publicKeyJwk = assertionMethod.publicKeyJwk ?: throw PublicKeyJwkMissingException("publicKeyJwk is null.") - val keyAlias = did.keyManager.getDeterministicAlias(publicKeyJwk) - - // TODO: figure out how to make more reliable since algorithm is technically not a required property of a JWK - val algorithm = publicKeyJwk.algorithm - val jwsAlgorithm = JWSAlgorithm.parse(algorithm.toString()) - - val kid = when (URI.create(assertionMethod.id).isAbsolute) { - true -> assertionMethod.id - false -> "${did.uri}${assertionMethod.id}" - } - - val jwtHeader = JWSHeader.Builder(jwsAlgorithm) - .type(JOSEObjectType.JWT) - .keyID(kid) - .build() - - val jwtObject = SignedJWT(jwtHeader, jwtPayload) - val toSign = jwtObject.signingInput - val signatureBytes = did.keyManager.sign(keyAlias, toSign) - - val base64UrlEncodedHeader = jwtHeader.toBase64URL() - val base64UrlEncodedPayload = jwtPayload.toPayload().toBase64URL() - val base64UrlEncodedSignature = Base64URL(Convert(signatureBytes).toBase64Url(padding = false)) - - return "$base64UrlEncodedHeader.$base64UrlEncodedPayload.$base64UrlEncodedSignature" - } - - /** - * Verifies the integrity and authenticity of a JSON Web Token (JWT). - * - * This function performs several crucial validation steps to ensure the trustworthiness of the provided VC: - * - Parses and validates the structure of the JWT. - * - Ensures the presence of critical header elements `alg` and `kid` in the JWT header. - * - Resolves the Decentralized Identifier (DID) and retrieves the associated DID Document. - * - Validates the DID and establishes a set of valid verification method IDs. - * - Identifies the correct Verification Method from the DID Document based on the `kid` parameter. - * - Verifies the JWT's signature using the public key associated with the Verification Method. - * - * If any of these steps fail, the function will throw a [SignatureException] with a message indicating the nature of the failure. - * - * @param jwtString The JWT as a [String]. - * @throws SignatureException if the verification fails at any step, providing a message with failure details. - * @throws IllegalArgumentException if critical JWT header elements are absent. - */ - public fun verify(jwtString: String) { - val jwt = JWTParser.parse(jwtString) as SignedJWT // validates JWT - - require(jwt.header.algorithm != null && jwt.header.keyID != null) { - "Signature verification failed: Expected JWS header to contain alg and kid" - } - - val verificationMethodId = jwt.header.keyID - val didUri = DidUri.Parser.parse(verificationMethodId) - - val didResolutionResult = DidResolvers.resolve(didUri.uri) - - if (didResolutionResult.didResolutionMetadata.error != null) { - throw SignatureException( - "Signature verification failed: " + - "Failed to resolve DID ${didUri.url}. " + - "Error: ${didResolutionResult.didResolutionMetadata.error}" - ) - } - - // create a set of possible id matches. the DID spec allows for an id to be the entire `did#fragment` - // or just `#fragment`. See: https://www.w3.org/TR/did-core/#relative-did-urls. - // using a set for fast string comparison. DIDs can be lonnng. - val verificationMethodIds = setOf( - didUri.url, - "#${didUri.fragment}" - ) - - didResolutionResult.didDocument?.assertionMethod?.firstOrNull { - verificationMethodIds.contains(it) - } ?: throw SignatureException( - "Signature verification failed: Expected kid in JWS header to dereference " + - "a DID Document Verification Method with an Assertion verification relationship" - ) - - // TODO: this will be cleaned up as part of BearerDid PR - val assertionVerificationMethod = didResolutionResult - .didDocument - ?.verificationMethod - ?.find { verificationMethodIds.contains(it.id) } - - if (assertionVerificationMethod == null) { - throw SignatureException( - "Signature verification failed: Expected kid in JWS header to dereference " + - "a DID Document Verification Method with an Assertion verification relationship" - ) - } - - require( - (assertionVerificationMethod.isType(JSON_WEB_KEY_2020) || assertionVerificationMethod.isType(JSON_WEB_KEY)) && - assertionVerificationMethod.publicKeyJwk != null - ) { - throw SignatureException( - "Signature verification failed: Expected kid in JWS header to dereference " + - "a DID Document Verification Method of type $JSON_WEB_KEY_2020 or $JSON_WEB_KEY with a publicKeyJwk" - ) - } - - val publicKeyJwk = - assertionVerificationMethod.publicKeyJwk ?: throw PublicKeyJwkMissingException("publicKeyJwk is null") - val toVerifyBytes = jwt.signingInput - val signatureBytes = jwt.signature.decode() - - Crypto.verify( - publicKeyJwk, - toVerifyBytes, - signatureBytes - ) - } - - -} diff --git a/credentials/src/test/kotlin/web5/sdk/credentials/PresentationExchangeTest.kt b/credentials/src/test/kotlin/web5/sdk/credentials/PresentationExchangeTest.kt index 6c67601e8..f88204a56 100644 --- a/credentials/src/test/kotlin/web5/sdk/credentials/PresentationExchangeTest.kt +++ b/credentials/src/test/kotlin/web5/sdk/credentials/PresentationExchangeTest.kt @@ -16,6 +16,7 @@ import web5.sdk.crypto.InMemoryKeyManager import web5.sdk.dids.methods.key.DidKey import web5.sdk.testing.TestVectors import java.io.File +import java.security.SignatureException import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFails @@ -246,10 +247,9 @@ class PresentationExchangeTest { val vcJwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" - assertThrows { + assertThrows { PresentationExchange.satisfiesPresentationDefinition(listOf(vcJwt), pd) } - } @Test diff --git a/credentials/src/test/kotlin/web5/sdk/credentials/VerifiableCredentialTest.kt b/credentials/src/test/kotlin/web5/sdk/credentials/VerifiableCredentialTest.kt index a4da9e9e5..be5bf5cab 100644 --- a/credentials/src/test/kotlin/web5/sdk/credentials/VerifiableCredentialTest.kt +++ b/credentials/src/test/kotlin/web5/sdk/credentials/VerifiableCredentialTest.kt @@ -1,34 +1,35 @@ package web5.sdk.credentials import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.exc.MismatchedInputException import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.nimbusds.jose.JWSAlgorithm -import com.nimbusds.jose.JWSHeader -import com.nimbusds.jose.JWSSigner -import com.nimbusds.jose.crypto.Ed25519Signer -import com.nimbusds.jose.jwk.Curve -import com.nimbusds.jose.jwk.gen.OctetKeyPairGenerator -import com.nimbusds.jwt.JWTClaimsSet -import com.nimbusds.jwt.SignedJWT import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertDoesNotThrow +import web5.sdk.common.Convert +import web5.sdk.common.Json import web5.sdk.crypto.AlgorithmId import web5.sdk.crypto.AwsKeyManager import web5.sdk.crypto.InMemoryKeyManager -import web5.sdk.dids.Did +import web5.sdk.crypto.Jwa +import web5.sdk.crypto.jwk.Jwk +import web5.sdk.dids.did.BearerDid +import web5.sdk.dids.did.PortableDid +import web5.sdk.dids.didcore.DidDocument import web5.sdk.dids.didcore.Purpose -import web5.sdk.dids.extensions.load -import web5.sdk.dids.methods.jwk.DidJwk +import web5.sdk.jose.jws.JwsHeader +import web5.sdk.jose.jwt.Jwt +import web5.sdk.jose.jwt.JwtClaimsSet import web5.sdk.dids.methods.dht.CreateDidDhtOptions import web5.sdk.dids.methods.dht.DidDht +import web5.sdk.dids.methods.jwk.DidJwk import web5.sdk.dids.methods.key.DidKey import web5.sdk.testing.TestVectors import java.io.File import java.security.SignatureException -import java.text.ParseException import java.util.Date import kotlin.test.Ignore +import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertFails import kotlin.test.assertNotNull @@ -39,17 +40,7 @@ class VerifiableCredentialTest { @Ignore("Testing with a prev created ion did") fun `create a vc with a previously created DID in the key manager`() { val keyManager = AwsKeyManager() - val didUri = - "did:ion:EiCTb6TakNEaBkYK0ZVtCC26mdv8mGZ8Z7YnbsSf-kiMyg" + - ":eyJkZWx0YSI6eyJwYXRjaGVzIjpbeyJhY3Rpb24iOiJyZXBsYWNlIiwiZG9jdW1lbnQiOnsicHVibGljS2V5cyI6W3siaWQiOiIwMzlhZTc" + - "xYy04OTZjLTQ2MzgtYjA3My0zYTQyM2IwMjhiMDEiLCJwdWJsaWNLZXlKd2siOnsiYWxnIjoiRVMyNTZLIiwiY3J2Ijoic2VjcDI1NmsxIiw" + - "ia2lkIjoiYWxpYXMvTzNmZUVhSDlaTVFmdkg3cTFkSUw3OFNxUmRJWkhnVUJlcFU3c1RtbHY1OCIsImt0eSI6IkVDIiwidXNlIjoic2lnIiw" + - "ieCI6IllwbTNZWS1oVnNqWjV2ME83aGRhZS1WVi1DRm1Ib0hldWFZODAtV08wS0UiLCJ5IjoiUnU5QlA2RzctU0lxU3E0MFdUenk5MnpiWXd" + - "aRHBuVmlDUWxRSHpNWVQzVSJ9LCJwdXJwb3NlcyI6WyJhc3NlcnRpb25NZXRob2QiXSwidHlwZSI6Ikpzb25XZWJLZXkyMDIwIn1dLCJzZXJ" + - "2aWNlcyI6W119fV0sInVwZGF0ZUNvbW1pdG1lbnQiOiJFaUNsaVVIbHBQQjE0VVpkVzk4S250aG8zV2YxRjQxOU83cFhSMGhPeFAzRkNnIn0" + - "sInN1ZmZpeERhdGEiOnsiZGVsdGFIYXNoIjoiRWlEU2FMNHZVNElzNmxDalp4YVp6Zl9lWFFMU3V5T3E5T0pNbVJHa2FFTzRCQSIsInJlY29" + - "2ZXJ5Q29tbWl0bWVudCI6IkVpQzI0TFljVEdRN1JzaDdIRUl2TXQ0MGNGbmNhZGZReTdibDNoa3k0RkxUQ2cifX0" - val issuerDid = DidDht.load(didUri, keyManager) + val issuerDid = DidDht.create(keyManager) val holderDid = DidKey.create(keyManager) val vc = VerifiableCredential.create( @@ -179,46 +170,41 @@ class VerifiableCredentialTest { CreateDidDhtOptions(verificationMethods = verificationMethodsToAdd) ) - val header = JWSHeader.Builder(JWSAlgorithm.ES256K) - .keyID(issuerDid.uri) + val header = JwsHeader.Builder() + .type("JWT") + .algorithm(Jwa.ES256K.name) + .keyId(issuerDid.uri) .build() // A detached payload JWT - val vcJwt = "${header.toBase64URL()}..fakeSig" + val vcJwt = "${Convert(Json.stringify(header)).toBase64Url()}..fakeSig" val exception = assertThrows(SignatureException::class.java) { VerifiableCredential.verify(vcJwt) } - assertEquals( - "Signature verification failed: Expected kid in JWS header to dereference a DID Document " + - "Verification Method with an Assertion verification relationship", exception.message + assertContains( + exception.message!!, "Malformed JWT. Invalid base64url encoding for JWT payload.", ) } @Test - fun `parseJwt throws ParseException if argument is not a valid JWT`() { - assertThrows(ParseException::class.java) { + fun `parseJwt throws IllegalStateException if argument is not a valid JWT`() { + assertThrows(IllegalStateException::class.java) { VerifiableCredential.parseJwt("hi") } } @Test fun `parseJwt throws if vc property is missing in JWT`() { - val jwk = OctetKeyPairGenerator(Curve.Ed25519).generate() - val signer: JWSSigner = Ed25519Signer(jwk) + val signerDid = DidDht.create(InMemoryKeyManager()) - val claimsSet = JWTClaimsSet.Builder() + val claimsSet = JwtClaimsSet.Builder() .subject("alice") .build() - val signedJWT = SignedJWT( - JWSHeader.Builder(JWSAlgorithm.EdDSA).keyID(jwk.keyID).build(), - claimsSet - ) + val signedJWT = Jwt.sign(signerDid, claimsSet) - signedJWT.sign(signer) - val randomJwt = signedJWT.serialize() val exception = assertThrows(IllegalArgumentException::class.java) { - VerifiableCredential.parseJwt(randomJwt) + VerifiableCredential.parseJwt(signedJWT) } assertEquals("jwt payload missing vc property", exception.message) @@ -226,27 +212,18 @@ class VerifiableCredentialTest { @Test fun `parseJwt throws if vc property in JWT payload is not an object`() { - val jwk = OctetKeyPairGenerator(Curve.Ed25519).generate() - val signer: JWSSigner = Ed25519Signer(jwk) + val signerDid = DidDht.create(InMemoryKeyManager()) - val claimsSet = JWTClaimsSet.Builder() + val claimsSet = JwtClaimsSet.Builder() .subject("alice") - .claim("vc", "hehe troll") + .misc("vc", "hehe troll") .build() - val signedJWT = SignedJWT( - JWSHeader.Builder(JWSAlgorithm.EdDSA).keyID(jwk.keyID).build(), - claimsSet - ) - - signedJWT.sign(signer) - val randomJwt = signedJWT.serialize() - - val exception = assertThrows(IllegalArgumentException::class.java) { - VerifiableCredential.parseJwt(randomJwt) + val signedJWT = Jwt.sign(signerDid, claimsSet) + assertThrows(MismatchedInputException::class.java) { + VerifiableCredential.parseJwt(signedJWT) } - assertEquals("expected vc property in JWT payload to be an object", exception.message) } @Test @@ -276,8 +253,7 @@ class VerifiableCredentialTest { class Web5TestVectorsCredentials { data class CreateTestInput( - val signerDidUri: String?, - val signerPrivateJwk: Map?, + val signerPortableDid: PortableDid?, val credential: Map?, ) @@ -292,18 +268,18 @@ class Web5TestVectorsCredentials { val typeRef = object : TypeReference>() {} val testVectors = mapper.readValue(File("../web5-spec/test-vectors/credentials/create.json"), typeRef) - testVectors.vectors.filterNot { it.errors ?: false }.forEach { vector -> + testVectors.vectors.filter { it.errors == false }.forEach { vector -> val vc = VerifiableCredential.fromJson(mapper.writeValueAsString(vector.input.credential)) + val portableDid = Json.parse(Json.stringify(vector.input.signerPortableDid!!)) val keyManager = InMemoryKeyManager() - keyManager.import(listOf(vector.input.signerPrivateJwk!!)) - val issuerDid = Did.load(vector.input.signerDidUri!!, keyManager) - val vcJwt = vc.sign(issuerDid) + val bearerDid = BearerDid.import(portableDid, keyManager) + val vcJwt = vc.sign(bearerDid) assertEquals(vector.output, vcJwt, vector.description) } - testVectors.vectors.filter { it.errors ?: false }.forEach { vector -> + testVectors.vectors.filter { it.errors == true }.forEach { vector -> assertFails(vector.description) { VerifiableCredential.fromJson(mapper.writeValueAsString(vector.input.credential)) } diff --git a/credentials/src/test/kotlin/web5/sdk/credentials/VerifiablePresentationTest.kt b/credentials/src/test/kotlin/web5/sdk/credentials/VerifiablePresentationTest.kt index 5c325b332..cad6c648e 100644 --- a/credentials/src/test/kotlin/web5/sdk/credentials/VerifiablePresentationTest.kt +++ b/credentials/src/test/kotlin/web5/sdk/credentials/VerifiablePresentationTest.kt @@ -1,16 +1,10 @@ package web5.sdk.credentials -import com.nimbusds.jose.JWSAlgorithm -import com.nimbusds.jose.JWSHeader -import com.nimbusds.jose.JWSSigner -import com.nimbusds.jose.crypto.Ed25519Signer -import com.nimbusds.jose.jwk.Curve -import com.nimbusds.jose.jwk.gen.OctetKeyPairGenerator -import com.nimbusds.jwt.JWTClaimsSet -import com.nimbusds.jwt.SignedJWT import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertDoesNotThrow +import web5.sdk.common.Convert +import web5.sdk.common.Json import web5.sdk.credentials.model.ConstraintsV2 import web5.sdk.credentials.model.FieldV2 import web5.sdk.credentials.model.InputDescriptorMapping @@ -19,13 +13,18 @@ import web5.sdk.credentials.model.PresentationDefinitionV2 import web5.sdk.credentials.model.PresentationSubmission import web5.sdk.crypto.AlgorithmId import web5.sdk.crypto.InMemoryKeyManager +import web5.sdk.crypto.Jwa import web5.sdk.dids.didcore.Purpose import web5.sdk.dids.methods.dht.CreateDidDhtOptions import web5.sdk.dids.methods.dht.DidDht import web5.sdk.dids.methods.jwk.DidJwk import web5.sdk.dids.methods.key.DidKey +import web5.sdk.jose.jws.JwsHeader +import web5.sdk.jose.jwt.Jwt +import web5.sdk.jose.jwt.JwtClaimsSet import java.security.SignatureException import java.text.ParseException +import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertNotNull @@ -42,6 +41,7 @@ class VerifiablePresentationTest { "M1oiLCJjcmVkZW50aWFsU3ViamVjdCI6eyJpZCI6ImRpZDprZXk6elEzc2h3ZDR5VUFmV2dmR0VScVVrNDd4Rzk1cU5Vc2lzRDc3NkpMdVo3" + "eXo5blFpaiIsImxvY2FsUmVzcGVjdCI6ImhpZ2giLCJsZWdpdCI6dHJ1ZX19fQ.Bx0JrQERWRLpYeg3TnfrOIo4zexo3q1exPZ-Ej6j0T0YO" + "BVZaZ9-RqpiAM-fHKrdGUzVyXr77pOl7yGgwIO90g" + @Test fun `create simple vp`() { val vcJwts: Iterable = listOf("vcjwt1") @@ -161,19 +161,21 @@ class VerifiablePresentationTest { val keyManager = InMemoryKeyManager() val holderDid = DidKey.create(keyManager) - val header = JWSHeader.Builder(JWSAlgorithm.ES256K) - .keyID(holderDid.uri) + val header = JwsHeader.Builder() + .type("JWT") + .algorithm(Jwa.ES256K.name) + // todo does fragment always start with a # ? if not need to add # in the middle + .keyId("${holderDid.uri}${holderDid.did.fragment}") .build() - val vpJwt = "${header.toBase64URL()}..fakeSig" + val vpJwt = "${Convert(Json.stringify(header)).toBase64Url()}..fakeSig" val exception = assertThrows(SignatureException::class.java) { VerifiablePresentation.verify(vpJwt) } - assertEquals( - "Signature verification failed: Expected kid in JWS header to dereference a DID Document " + - "Verification Method with an Assertion verification relationship", exception.message + assertContains( + exception.message!!, "Malformed JWT. Invalid base64url encoding for JWT payload.", ) } @@ -199,30 +201,24 @@ class VerifiablePresentationTest { } @Test - fun `parseJwt throws ParseException if argument is not a valid JWT`() { - assertThrows(ParseException::class.java) { + fun `parseJwt throws IllegalStateException if argument is not a valid JWT`() { + assertThrows(IllegalStateException::class.java) { VerifiablePresentation.parseJwt("hi") } } @Test fun `parseJwt throws if vp property is missing in JWT`() { - val jwk = OctetKeyPairGenerator(Curve.Ed25519).generate() - val signer: JWSSigner = Ed25519Signer(jwk) + val signerDid = DidDht.create(InMemoryKeyManager()) - val claimsSet = JWTClaimsSet.Builder() + val claimsSet = JwtClaimsSet.Builder() .subject("alice") .build() - val signedJWT = SignedJWT( - JWSHeader.Builder(JWSAlgorithm.EdDSA).keyID(jwk.keyID).build(), - claimsSet - ) + val signedJWT = Jwt.sign(signerDid, claimsSet) - signedJWT.sign(signer) - val randomJwt = signedJWT.serialize() val exception = assertThrows(IllegalArgumentException::class.java) { - VerifiablePresentation.parseJwt(randomJwt) + VerifiablePresentation.parseJwt(signedJWT) } assertEquals("jwt payload missing vp property", exception.message) @@ -235,34 +231,37 @@ class VerifiablePresentationTest { //Create a DHT DID without an assertionMethod val alias = keyManager.generatePrivateKey(AlgorithmId.secp256k1) val verificationJwk = keyManager.getPublicKey(alias) - val verificationMethodsToAdd = listOf(Triple( - verificationJwk, - emptyList(), - "did:web:tbd.website" - )) + val verificationMethodsToAdd = listOf( + Triple( + verificationJwk, + emptyList(), + "did:web:tbd.website" + ) + ) val issuerDid = DidDht.create( InMemoryKeyManager(), CreateDidDhtOptions(verificationMethods = verificationMethodsToAdd) ) - val header = JWSHeader.Builder(JWSAlgorithm.ES256K) - .keyID(issuerDid.uri) + val header = JwsHeader.Builder() + .type("JWT") + .algorithm(Jwa.ES256K.name) + .keyId(issuerDid.uri) .build() //A detached payload JWT - val vpJwt = "${header.toBase64URL()}..fakeSig" + val vpJwt = "${Convert(Json.stringify(header)).toBase64Url()}..fakeSig" val exception = assertThrows(SignatureException::class.java) { VerifiablePresentation.verify(vpJwt) } - assertEquals( - "Signature verification failed: Expected kid in JWS header to dereference a DID Document " + - "Verification Method with an Assertion verification relationship", exception.message + assertContains( + exception.message!!, "Malformed JWT. Invalid base64url encoding for JWT payload.", ) } data class EmploymentStatus(val employmentStatus: String) data class PIICredential(val name: String, val dateOfBirth: String) - + @Test fun `full flow with did dht`() { val keyManager = InMemoryKeyManager() diff --git a/crypto/build.gradle.kts b/crypto/build.gradle.kts index 303871d87..83333f6bb 100644 --- a/crypto/build.gradle.kts +++ b/crypto/build.gradle.kts @@ -16,13 +16,6 @@ dependencies { * Deps are declared in alphabetical order. */ - // API - /* - * API Leak: https://github.com/TBD54566975/web5-kt/issues/229 - * - * Change and move to "implementation" when completed - */ - api(libs.comNimbusdsJoseJwt) /* * API Leak: https://github.com/TBD54566975/web5-kt/issues/230 * @@ -37,6 +30,7 @@ dependencies { implementation(libs.comGoogleCryptoTink) implementation(libs.bundles.orgBouncycastle) implementation(libs.comFasterXmlJacksonModuleKotlin) + implementation(libs.comNimbusdsJoseJwt) // Test /** diff --git a/crypto/src/main/kotlin/web5/sdk/crypto/AwsKeyManager.kt b/crypto/src/main/kotlin/web5/sdk/crypto/AwsKeyManager.kt index 1451db28c..f9506c956 100644 --- a/crypto/src/main/kotlin/web5/sdk/crypto/AwsKeyManager.kt +++ b/crypto/src/main/kotlin/web5/sdk/crypto/AwsKeyManager.kt @@ -15,12 +15,11 @@ import com.amazonaws.services.kms.model.SigningAlgorithmSpec import com.nimbusds.jose.JWSAlgorithm import com.nimbusds.jose.crypto.impl.ECDSA import com.nimbusds.jose.jwk.ECKey -import com.nimbusds.jose.jwk.JWK -import com.nimbusds.jose.jwk.KeyUse import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo import org.bouncycastle.crypto.ExtendedDigest import org.bouncycastle.crypto.digests.SHA256Digest import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter +import web5.sdk.crypto.jwk.Jwk import java.nio.ByteBuffer import java.security.PublicKey import java.security.interfaces.ECPublicKey @@ -30,7 +29,7 @@ import java.security.interfaces.ECPublicKey * connection details for [AWSKMS] client as per * [Configure the AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-configure.html) * - * Key aliases are generated from the key's JWK thumbprint, and stored in AWS KMS. + * Key aliases are generated from the key's Jwk thumbprint, and stored in AWS KMS. * e.g. alias/6uNnyj7xZUgtKTEOFV2mz0f7Hd3cxIH1o5VXsOo4u1M * * AWSKeyManager supports a limited set ECDSA curves for signing: @@ -118,24 +117,25 @@ public class AwsKeyManager @JvmOverloads constructor( * Retrieves the public key associated with a previously stored private key, identified by the provided alias. * * @param keyAlias The alias referencing the stored private key. - * @return The associated public key in JWK (JSON Web Key) format. + * @return The associated public key in Jwk (JSON Web Key) format. * @throws [AWSKMSException] for any error originating from the [AWSKMS] client */ - override fun getPublicKey(keyAlias: String): JWK { + override fun getPublicKey(keyAlias: String): Jwk { val getPublicKeyRequest = GetPublicKeyRequest().withKeyId(keyAlias) val publicKeyResponse = kmsClient.getPublicKey(getPublicKeyRequest) val publicKey = convertToJavaPublicKey(publicKeyResponse.publicKey) val algorithmDetails = getAlgorithmDetails(publicKeyResponse.keySpec.enum()) val jwkBuilder = when (publicKey) { - is ECPublicKey -> ECKey.Builder(JwaCurve.toJwkCurve(algorithmDetails.curve), publicKey) + is ECPublicKey -> { + val key = ECKey.Builder(JwaCurve.toNimbusCurve(algorithmDetails.curve), publicKey).build() + Jwk.Builder("EC", key.curve.name) + .x(key.x.toString()) + .y(key.y.toString()) + } else -> throw IllegalArgumentException("Unknown key type $publicKey") } - return jwkBuilder - .algorithm(Jwa.toJwsAlgorithm(algorithmDetails.algorithm)) - .keyID(keyAlias) - .keyUse(KeyUse.SIGNATURE) - .build() + return jwkBuilder.build() } /** @@ -165,10 +165,10 @@ public class AwsKeyManager @JvmOverloads constructor( /** * Return the alias of [publicKey], as was originally returned by [generatePrivateKey]. * - * @param publicKey A public key in JWK (JSON Web Key) format + * @param publicKey A public key in Jwk (JSON Web Key) format * @return The alias belonging to [publicKey] */ - override fun getDeterministicAlias(publicKey: JWK): String { + override fun getDeterministicAlias(publicKey: Jwk): String { val jwkThumbprint = publicKey.computeThumbprint() return "alias/$jwkThumbprint" } diff --git a/crypto/src/main/kotlin/web5/sdk/crypto/Crypto.kt b/crypto/src/main/kotlin/web5/sdk/crypto/Crypto.kt index 52bc833b6..8baa4d1be 100644 --- a/crypto/src/main/kotlin/web5/sdk/crypto/Crypto.kt +++ b/crypto/src/main/kotlin/web5/sdk/crypto/Crypto.kt @@ -1,9 +1,9 @@ package web5.sdk.crypto -import com.nimbusds.jose.jwk.JWK import web5.sdk.crypto.Crypto.generatePrivateKey import web5.sdk.crypto.Crypto.publicKeyToBytes import web5.sdk.crypto.Crypto.sign +import web5.sdk.crypto.jwk.Jwk /** * Cryptography utility object providing key generation, signature creation, and other crypto-related functionalities. @@ -13,7 +13,7 @@ import web5.sdk.crypto.Crypto.sign * It offers convenience methods to: * - Generate private keys ([generatePrivateKey]) * - Create digital signatures ([sign]) - * - conversion from JWK <-> bytes ([publicKeyToBytes]) + * - conversion from Jwk <-> bytes ([publicKeyToBytes]) * - Get relevant key generators and signers based on algorithmId. * * Internally, it utilizes predefined mappings to pair algorithms and curve types with their respective [KeyGenerator] @@ -23,7 +23,7 @@ import web5.sdk.crypto.Crypto.sign * * ### Example Usage: * ``` - * val privateKey: JWK = Crypto.generatePrivateKey(JWSAlgorithm.EdDSA, Curve.Ed25519) + * val privateKey: Jwk = Crypto.generatePrivateKey(JWSAlgorithm.EdDSA, Curve.Ed25519) * ``` * * ### Key Points: @@ -61,11 +61,11 @@ public object Crypto { * * @param algorithmId The algorithmId [AlgorithmId]. * @param options Options for key generation, may include specific parameters relevant to the algorithm. - * @return The generated private key as a JWK object. + * @return The generated private key as a Jwk object. * @throws IllegalArgumentException if the provided algorithm or curve is not supported. */ @JvmOverloads - public fun generatePrivateKey(algorithmId: AlgorithmId, options: KeyGenOptions? = null): JWK { + public fun generatePrivateKey(algorithmId: AlgorithmId, options: KeyGenOptions? = null): Jwk { val keyGenerator = getKeyGenerator(algorithmId) return keyGenerator.generatePrivateKey(options) } @@ -74,11 +74,11 @@ public object Crypto { * Computes a public key from the given private key, utilizing relevant [KeyGenerator]. * * @param privateKey The private key used to compute the public key. - * @return The computed public key as a JWK object. + * @return The computed public key as a Jwk object. */ - public fun computePublicKey(privateKey: JWK): JWK { - val rawCurve = privateKey.toJSONObject()["crv"] - val curve = rawCurve?.let { JwaCurve.parse(it.toString()) } + public fun computePublicKey(privateKey: Jwk): Jwk { + val rawCurve = privateKey.crv + val curve = JwaCurve.parse(rawCurve) val generator = getKeyGenerator(AlgorithmId.from(curve)) return generator.computePublicKey(privateKey) @@ -90,17 +90,16 @@ public object Crypto { * This function utilizes the appropriate [Signer] to generate a digital signature * of the provided payload using the provided private key. * - * @param privateKey The JWK private key to be used for generating the signature. + * @param privateKey The Jwk private key to be used for generating the signature. * @param payload The byte array data to be signed. * @param options Options for the signing operation, may include specific parameters relevant to the algorithm. * @return The digital signature as a byte array. */ @JvmOverloads - public fun sign(privateKey: JWK, payload: ByteArray, options: SignOptions? = null): ByteArray { - val rawCurve = privateKey.toJSONObject()["crv"] - val jwaCurve = rawCurve?.let { JwaCurve.parse(it.toString()) } + public fun sign(privateKey: Jwk, payload: ByteArray, options: SignOptions? = null): ByteArray { + val curve = getJwkCurve(privateKey) - val signer = getSigner(AlgorithmId.from(jwaCurve)) + val signer = getSigner(AlgorithmId.from(curve)) return signer.sign(privateKey, payload, options) } @@ -109,24 +108,24 @@ public object Crypto { * Verifies a signature against a signed payload using a public key. * * This function utilizes the relevant verifier, determined by the algorithm and curve - * used in the JWK, to ensure the provided signature is valid for the signed payload + * used in the Jwk, to ensure the provided signature is valid for the signed payload * using the provided public key. The algorithm used can either be specified in the - * public key JWK or passed explicitly as a parameter. If it is not found in either, + * public key Jwk or passed explicitly as a parameter. If it is not found in either, * an exception will be thrown. * * ## Note - * Algorithm **MUST** either be present on the [JWK] or be provided explicitly + * Algorithm **MUST** either be present on the [Jwk] or be provided explicitly * - * @param publicKey The JWK public key to be used for verifying the signature. + * @param publicKey The Jwk public key to be used for verifying the signature. * @param signedPayload The byte array data that was signed. * @param signature The signature that will be verified. - * if not provided in the JWK. Default is null. + * if not provided in the Jwk. Default is null. * - * @throws IllegalArgumentException if neither the JWK nor the explicit algorithm parameter + * @throws IllegalArgumentException if neither the Jwk nor the explicit algorithm parameter * provides an algorithm. * */ - public fun verify(publicKey: JWK, signedPayload: ByteArray, signature: ByteArray) { + public fun verify(publicKey: Jwk, signedPayload: ByteArray, signature: ByteArray) { val curve = getJwkCurve(publicKey) val verifier = getVerifier(curve) @@ -135,9 +134,9 @@ public object Crypto { /** - * Converts a [JWK] public key into its byte array representation. + * Converts a [Jwk] public key into its byte array representation. * - * @param publicKey A [JWK] object representing the public key to be converted. + * @param publicKey A [Jwk] object representing the public key to be converted. * @return A [ByteArray] representing the byte-level information of the provided public key. * * ### Example @@ -146,14 +145,14 @@ public object Crypto { * ``` * * ### Note - * This function assumes that the provided [JWK] contains valid curve and algorithm - * information. Malformed or invalid [JWK] objects may result in exceptions or + * This function assumes that the provided [Jwk] contains valid curve and algorithm + * information. Malformed or invalid [Jwk] objects may result in exceptions or * unexpected behavior. * * ### Throws - * - [IllegalArgumentException] If the algorithm or curve in [JWK] is not supported or invalid. + * - [IllegalArgumentException] If the algorithm or curve in [Jwk] is not supported or invalid. */ - public fun publicKeyToBytes(publicKey: JWK): ByteArray { + public fun publicKeyToBytes(publicKey: Jwk): ByteArray { val curve = getJwkCurve(publicKey) val generator = getKeyGenerator(AlgorithmId.from(curve)) @@ -162,6 +161,7 @@ public object Crypto { /** * Retrieves a [KeyGenerator] based on the provided algorithmId. + * Currently, we provide key generators for keys that use ECC (see [AlgorithmId] enum) * * This function looks up and retrieves the relevant [KeyGenerator] based on the provided * algorithmId. @@ -225,19 +225,16 @@ public object Crypto { } /** - * Extracts the cryptographic curve information from a [JWK] object. + * Extracts the cryptographic curve information from a [Jwk] object. * - * This function parses and returns the curve type used in a JWK. + * This function parses and returns the curve type used in a Jwk. * May return `null` if the curve information is not present or unsupported. * - * @param jwk The JWK object from which to extract curve information. - * @return The [JwaCurve] used in the JWK, or `null` if the curve is not defined or recognized. + * @param jwk The Jwk object from which to extract curve information. + * @return The [JwaCurve] used in the Jwk, or `null` if the curve is not defined or recognized. */ - public fun getJwkCurve(jwk: JWK): JwaCurve? { - val rawCurve = jwk.toJSONObject()["crv"] - - // todo since crv is required in JWK, shouldn't we throw an error if rawCurve is null? - return rawCurve?.let { JwaCurve.parse(it.toString()) } + public fun getJwkCurve(jwk: Jwk): JwaCurve? { + return JwaCurve.parse(jwk.crv) } /** diff --git a/crypto/src/main/kotlin/web5/sdk/crypto/Dsa.kt b/crypto/src/main/kotlin/web5/sdk/crypto/Dsa.kt index 05cc7013b..e06c589ed 100644 --- a/crypto/src/main/kotlin/web5/sdk/crypto/Dsa.kt +++ b/crypto/src/main/kotlin/web5/sdk/crypto/Dsa.kt @@ -12,6 +12,21 @@ public enum class JwaCurve { Ed25519; public companion object { + + /** + * Convert JwaCurve to nimbusds JWK curve. + * Used to temporarily bridge the gap between moving from nimbusds JWK methods + * to rolling our own JWK methods + * @param curve + * @return nimbus JWK Curve + */ + public fun toNimbusCurve(curve: JwaCurve): Curve { + return when (curve) { + secp256k1 -> Curve.SECP256K1 + Ed25519 -> Curve.Ed25519 + } + } + /** * Parse name of a curve into JwaCurve. * @@ -30,25 +45,11 @@ public enum class JwaCurve { null } } - - /** - * Convert JwaCurve nimbusds JWK curve. - * Used to temporarily bridge the gap between moving from nimbusds JWK methods - * to rolling our own JWK methods - * @param curve - * @return nimbus JWK Curve - */ - public fun toJwkCurve(curve: JwaCurve): Curve { - return when (curve) { - secp256k1 -> Curve.SECP256K1 - Ed25519 -> Curve.Ed25519 - } - } } } /** - * JSON Web Algorithm Curve. + * JSON Web Algorithm. */ public enum class Jwa { EdDSA, @@ -76,8 +77,8 @@ public enum class Jwa { /** * Convert Jwa to nimbusds JWSAlgorithm. - * Used to temporarily bridge the gap between moving from nimbusds JWK methods - * to rolling our own JWK methods + * Used to temporarily bridge the gap between moving from nimbusds Jwk methods + * to rolling our own Jwk methods * * @param algorithm Jwa * @return JWSAlgorithm nimbusds JWSAlgorithm diff --git a/crypto/src/main/kotlin/web5/sdk/crypto/Ed25519.kt b/crypto/src/main/kotlin/web5/sdk/crypto/Ed25519.kt index cdd7202b0..2847ef852 100644 --- a/crypto/src/main/kotlin/web5/sdk/crypto/Ed25519.kt +++ b/crypto/src/main/kotlin/web5/sdk/crypto/Ed25519.kt @@ -2,17 +2,16 @@ package web5.sdk.crypto import com.google.crypto.tink.subtle.Ed25519Sign import com.google.crypto.tink.subtle.Ed25519Verify -import com.nimbusds.jose.JWSAlgorithm -import com.nimbusds.jose.jwk.JWK -import com.nimbusds.jose.jwk.KeyUse +import com.nimbusds.jose.jwk.Curve import com.nimbusds.jose.jwk.OctetKeyPair import com.nimbusds.jose.jwk.gen.OctetKeyPairGenerator -import com.nimbusds.jose.util.Base64URL import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters import web5.sdk.common.Convert +import web5.sdk.common.EncodingFormat import web5.sdk.crypto.Ed25519.PRIV_MULTICODEC import web5.sdk.crypto.Ed25519.PUB_MULTICODEC import web5.sdk.crypto.Ed25519.algorithm +import web5.sdk.crypto.jwk.Jwk import java.security.GeneralSecurityException import java.security.SignatureException @@ -43,67 +42,80 @@ public object Ed25519 : KeyGenerator, Signer { * Generates a private key utilizing the Ed25519 algorithm. * * @param options (Optional) Additional options to control the key generation process. - * @return The generated private key in JWK format. + * @return The generated private key in Jwk format. */ - override fun generatePrivateKey(options: KeyGenOptions?): JWK { - return OctetKeyPairGenerator(com.nimbusds.jose.jwk.Curve.Ed25519) - .algorithm(JWSAlgorithm.EdDSA) - .keyIDFromThumbprint(true) - .keyUse(KeyUse.SIGNATURE) - .generate() - .toOctetKeyPair() + override fun generatePrivateKey(options: KeyGenOptions?): Jwk { + // TODO use tink to generate private key https://github.com/TBD54566975/web5-kt/issues/273 + val privateKey = OctetKeyPairGenerator(Curve.Ed25519).generate() + + val jwk = Jwk.Builder("OKP", privateKey.curve.name) + .privateKey(privateKey.d.toString()) + .x(privateKey.x.toString()) + .build() + + return jwk + } /** * Derives the public key corresponding to a given private key. * - * @param privateKey The private key in JWK format. - * @return The corresponding public key in JWK format. + * @param privateKey The private key in Jwk format. + * @return The corresponding public key in Jwk format. */ - override fun computePublicKey(privateKey: JWK): JWK { - require(privateKey is OctetKeyPair) { "private key must be an Octet Key Pair (kty: OKP)" } + override fun computePublicKey(privateKey: Jwk): Jwk { + validateKey(privateKey) + + val jwk = Jwk.Builder(privateKey.kty, curve.name) + .algorithm(algorithm.name) + .apply { + privateKey.use?.let { keyUse(it) } + privateKey.alg?.let { algorithm(it) } + privateKey.x?.let { x(it) } + } + .build() - return privateKey.toOctetKeyPair().toPublicJWK() + return jwk } - override fun privateKeyToBytes(privateKey: JWK): ByteArray { + override fun privateKeyToBytes(privateKey: Jwk): ByteArray { validatePrivateKey(privateKey) - return privateKey.toOctetKeyPair().decodedD + return Convert(privateKey.d, EncodingFormat.Base64Url).toByteArray() } - override fun publicKeyToBytes(publicKey: JWK): ByteArray { + override fun publicKeyToBytes(publicKey: Jwk): ByteArray { validatePublicKey(publicKey) - return publicKey.toOctetKeyPair().decodedX + return Convert(publicKey.x, EncodingFormat.Base64Url).toByteArray() } - override fun bytesToPrivateKey(privateKeyBytes: ByteArray): JWK { + override fun bytesToPrivateKey(privateKeyBytes: ByteArray): Jwk { val privateKeyParameters = Ed25519PrivateKeyParameters(privateKeyBytes, 0) val publicKeyBytes = privateKeyParameters.generatePublicKey().encoded - val base64UrlEncodedPrivateKey = Convert(privateKeyBytes).toBase64Url(padding = false) - val base64UrlEncodedPublicKey = Convert(publicKeyBytes).toBase64Url(padding = false) + val base64UrlEncodedPrivateKey = Convert(privateKeyBytes).toBase64Url() + val base64UrlEncodedPublicKey = Convert(publicKeyBytes).toBase64Url() - return OctetKeyPair.Builder(com.nimbusds.jose.jwk.Curve.Ed25519, Base64URL(base64UrlEncodedPublicKey)) - .algorithm(Jwa.toJwsAlgorithm(algorithm)) - .keyIDFromThumbprint() - .d(Base64URL(base64UrlEncodedPrivateKey)) - .keyUse(KeyUse.SIGNATURE) + return Jwk.Builder("OKP", curve.name) + .algorithm(algorithm.name) + .privateKey(base64UrlEncodedPrivateKey) + .x(base64UrlEncodedPublicKey) .build() } - override fun bytesToPublicKey(publicKeyBytes: ByteArray): JWK { - val base64UrlEncodedPublicKey = Convert(publicKeyBytes).toBase64Url(padding = false) + override fun bytesToPublicKey(publicKeyBytes: ByteArray): Jwk { + val base64UrlEncodedPublicKey = Convert(publicKeyBytes).toBase64Url() - return OctetKeyPair.Builder(com.nimbusds.jose.jwk.Curve.Ed25519, Base64URL(base64UrlEncodedPublicKey)) - .algorithm(Jwa.toJwsAlgorithm(algorithm)) - .keyIDFromThumbprint() - .keyUse(KeyUse.SIGNATURE) + val jwk = Jwk.Builder("OKP", curve.name) + .algorithm(algorithm.name) + .x(base64UrlEncodedPublicKey) .build() + + return jwk } - override fun sign(privateKey: JWK, payload: ByteArray, options: SignOptions?): ByteArray { + override fun sign(privateKey: Jwk, payload: ByteArray, options: SignOptions?): ByteArray { validatePrivateKey(privateKey) val privateKeyBytes = privateKeyToBytes(privateKey) @@ -112,7 +124,7 @@ public object Ed25519 : KeyGenerator, Signer { return signer.sign(payload) } - override fun verify(publicKey: JWK, signedPayload: ByteArray, signature: ByteArray, options: VerifyOptions?) { + override fun verify(publicKey: Jwk, signedPayload: ByteArray, signature: ByteArray, options: VerifyOptions?) { validatePublicKey(publicKey) val publicKeyBytes = publicKeyToBytes(publicKey) @@ -126,7 +138,7 @@ public object Ed25519 : KeyGenerator, Signer { } /** - * Validates the provided [JWK] (JSON Web Key) is a public key + * Validates the provided [Jwk] (JSON Web Key) is a public key * * This function checks the following: * - The key must be a public key @@ -135,16 +147,16 @@ public object Ed25519 : KeyGenerator, Signer { * If any of these checks fail, this function throws an [IllegalArgumentException] with * a descriptive error message. * - * @param key The [JWK] to validate. + * @param key The [Jwk] to validate. * @throws IllegalArgumentException if the key is not a public key */ - public fun validatePublicKey(key: JWK) { - require(!key.isPrivate) { "key must be public" } + public fun validatePublicKey(key: Jwk) { + require(key.d == null) { "key must be public" } validateKey(key) } /** - * Validates the provided [JWK] (JSON Web Key) to ensure it conforms to the expected key type and format. + * Validates the provided [Jwk] (JSON Web Key) to ensure it conforms to the expected key type and format. * * This function checks the following: * - The key must be a private key @@ -153,16 +165,16 @@ public object Ed25519 : KeyGenerator, Signer { * If any of these checks fail, this function throws an [IllegalArgumentException] with * a descriptive error message. * - * @param key The [JWK] to validate. + * @param key The [Jwk] to validate. * @throws IllegalArgumentException if the key is not a private key */ - public fun validatePrivateKey(key: JWK) { - require(key.isPrivate) { "key must be private" } + public fun validatePrivateKey(key: Jwk) { + require(key.d != null) { "key must be private" } validateKey(key) } /** - * Validates the provided [JWK] (JSON Web Key) to ensure it conforms to the expected key type and format. + * Validates the provided [Jwk] (JSON Web Key) to ensure it conforms to the expected key type and format. * * This function checks the following: * - The key must be an instance of [OctetKeyPair]. @@ -172,7 +184,7 @@ public object Ed25519 : KeyGenerator, Signer { * * ### Usage Example: * ``` - * val jwk: JWK = //...obtain or generate a JWK + * val jwk: Jwk = //...obtain or generate a Jwk * try { * Ed25519.validateKey(jwk) * // Key is valid, proceed with further operations... @@ -182,13 +194,13 @@ public object Ed25519 : KeyGenerator, Signer { * ``` * * ### Important: - * Ensure to call this function before using a [JWK] in cryptographic operations + * Ensure to call this function before using a [Jwk] in cryptographic operations * to safeguard against invalid key usage and potential vulnerabilities. * - * @param key The [JWK] to validate. + * @param key The [Jwk] to validate. * @throws IllegalArgumentException if the key is not of type [OctetKeyPair]. */ - private fun validateKey(key: JWK) { - require(key is OctetKeyPair) { "key must be an Octet Key Pair (kty: OKP)" } + private fun validateKey(key: Jwk) { + require(key.kty == "OKP") { "key must be an Octet Key Pair (kty: OKP)" } } } \ No newline at end of file diff --git a/crypto/src/main/kotlin/web5/sdk/crypto/InMemoryKeyManager.kt b/crypto/src/main/kotlin/web5/sdk/crypto/InMemoryKeyManager.kt index c4f6c5032..24317b7fa 100644 --- a/crypto/src/main/kotlin/web5/sdk/crypto/InMemoryKeyManager.kt +++ b/crypto/src/main/kotlin/web5/sdk/crypto/InMemoryKeyManager.kt @@ -1,6 +1,8 @@ package web5.sdk.crypto -import com.nimbusds.jose.jwk.JWK +import web5.sdk.common.Json +import web5.sdk.common.Json.toMap +import web5.sdk.crypto.jwk.Jwk /** * A class for managing cryptographic keys in-memory. @@ -21,12 +23,12 @@ import com.nimbusds.jose.jwk.JWK * - Keys are stored in an in-memory mutable map and will be lost once the application is terminated or the object is garbage-collected. * - It is suitable for testing or scenarios where persistent storage of keys is not necessary. */ -public class InMemoryKeyManager : KeyManager { +public class InMemoryKeyManager : KeyManager, KeyExporter, KeyImporter { /** * An in-memory keystore represented as a flat key-value map, where the key is a key ID. */ - private val keyStore: MutableMap = HashMap() + private val keyStore: MutableMap = HashMap() /** * Generates a private key using specified algorithmId, and stores it in the in-memory keyStore. @@ -37,19 +39,22 @@ public class InMemoryKeyManager : KeyManager { */ override fun generatePrivateKey(algorithmId: AlgorithmId, options: KeyGenOptions?): String { val jwk = Crypto.generatePrivateKey(algorithmId, options) - keyStore[jwk.keyID] = jwk + if (jwk.kid.isNullOrEmpty()) { + jwk.kid = jwk.computeThumbprint() + } - return jwk.keyID + keyStore[jwk.kid!!] = jwk + return jwk.kid!! } /** * Computes and returns a public key corresponding to the private key identified by the provided keyAlias. * * @param keyAlias The alias (key ID) of the private key stored in the keyStore. - * @return The computed public key as a JWK object. + * @return The computed public key as a Jwk object. * @throws Exception if a key with the provided alias is not found in the keyStore. */ - override fun getPublicKey(keyAlias: String): JWK { + override fun getPublicKey(keyAlias: String): Jwk { // TODO: decide whether to return null or throw an exception val privateKey = getPrivateKey(keyAlias) return Crypto.computePublicKey(privateKey) @@ -72,12 +77,12 @@ public class InMemoryKeyManager : KeyManager { /** * Return the alias of [publicKey], as was originally returned by [generatePrivateKey]. * - * @param publicKey A public key in JWK (JSON Web Key) format + * @param publicKey A public key in Jwk (JSON Web Key) format * @return The alias belonging to [publicKey] * @throws IllegalArgumentException if the key is not known to the [KeyManager] */ - override fun getDeterministicAlias(publicKey: JWK): String { - val kid = publicKey.keyID ?: publicKey.computeThumbprint().toString() + override fun getDeterministicAlias(publicKey: Jwk): String { + val kid = publicKey.computeThumbprint() require(keyStore.containsKey(kid)) { "key with alias $kid not found" } @@ -87,36 +92,14 @@ public class InMemoryKeyManager : KeyManager { private fun getPrivateKey(keyAlias: String) = keyStore[keyAlias] ?: throw IllegalArgumentException("key with alias $keyAlias not found") - /** - * Imports a list of keys represented as a list of maps and returns a list of key aliases referring to them. - * - * @param keySet A list of key representations in map format. - * @return A list of key aliases belonging to the imported keys. - */ - public fun import(keySet: Iterable>): List = keySet.map { - val jwk = JWK.parse(it) - import(jwk) + override fun exportKey(keyId: String): Jwk { + return this.getPrivateKey(keyId) } - /** - * Imports a single key and returns the alias that refers to it. - * - * @param jwk A JWK object representing the key to be imported. - * @return The alias belonging to the imported key. - */ - public fun import(jwk: JWK): String { - var kid = jwk.keyID - if (kid.isNullOrEmpty()) { - kid = jwk.computeThumbprint().toString() - } - keyStore.putIfAbsent(kid, jwk) - return kid + override fun importKey(jwk: Jwk): String { + val keyAlias = jwk.computeThumbprint() + keyStore[keyAlias] = jwk + return keyAlias } - /** - * Exports all stored keys as a list of maps. - * - * @return A list of key representations in map format. - */ - public fun export(): List> = keyStore.map { it.value.toJSONObject() } } diff --git a/crypto/src/main/kotlin/web5/sdk/crypto/KeyGenerator.kt b/crypto/src/main/kotlin/web5/sdk/crypto/KeyGenerator.kt index 948c3e070..b836d18d5 100644 --- a/crypto/src/main/kotlin/web5/sdk/crypto/KeyGenerator.kt +++ b/crypto/src/main/kotlin/web5/sdk/crypto/KeyGenerator.kt @@ -1,6 +1,6 @@ package web5.sdk.crypto -import com.nimbusds.jose.jwk.JWK +import web5.sdk.crypto.jwk.Jwk /** * `KeyGenOptions` serves as an interface defining options or parameters that influence @@ -21,7 +21,7 @@ import com.nimbusds.jose.jwk.JWK * ```kotlin * val keyGenOptions: KeyGenOptions = ... * val keyGenerator: KeyGenerator = ... - * val privateKey: JWK = keyGenerator.generatePrivateKey(keyGenOptions) + * val privateKey: Jwk = keyGenerator.generatePrivateKey(keyGenOptions) * ``` * * ### Note: @@ -50,10 +50,10 @@ public interface KeyGenOptions * * ``` * val keyGenerator: KeyGenerator = ... - * val privateKey: JWK = keyGenerator.generatePrivateKey() - * val publicKey: JWK = keyGenerator.getPublicKey(privateKey) + * val privateKey: Jwk = keyGenerator.generatePrivateKey() + * val publicKey: Jwk = keyGenerator.getPublicKey(privateKey) * val privateKeyBytes: ByteArray = keyGenerator.privateKeyToBytes(privateKey) - * val restoredPrivateKey: JWK = keyGenerator.bytesToPrivateKey(privateKeyBytes) + * val restoredPrivateKey: Jwk = keyGenerator.bytesToPrivateKey(privateKeyBytes) * ``` * * ### Note: @@ -71,33 +71,33 @@ public interface KeyGenerator { public val curve: JwaCurve /** Generates a private key. */ - public fun generatePrivateKey(options: KeyGenOptions? = null): JWK + public fun generatePrivateKey(options: KeyGenOptions? = null): Jwk /** * Derives a public key from the private key provided. Applicable for asymmetric Key Generators only. * Implementers of symmetric key generators should throw an UnsupportedOperation Exception */ - public fun computePublicKey(privateKey: JWK): JWK + public fun computePublicKey(privateKey: Jwk): Jwk /** * Converts a private key to bytes. */ - public fun privateKeyToBytes(privateKey: JWK): ByteArray + public fun privateKeyToBytes(privateKey: Jwk): ByteArray /** * Converts a public key to bytes. Applicable for asymmetric [KeyGenerator] implementations only. * Implementers of symmetric key generators should throw an UnsupportedOperation Exception */ - public fun publicKeyToBytes(publicKey: JWK): ByteArray + public fun publicKeyToBytes(publicKey: Jwk): ByteArray /** - * Converts a private key as bytes into a JWK. + * Converts a private key as bytes into a Jwk. */ - public fun bytesToPrivateKey(privateKeyBytes: ByteArray): JWK + public fun bytesToPrivateKey(privateKeyBytes: ByteArray): Jwk /** - * Converts a public key as bytes into a JWK. Applicable for asymmetric Key Generators only. + * Converts a public key as bytes into a Jwk. Applicable for asymmetric Key Generators only. * Implementers of symmetric key generators should throw an UnsupportedOperation Exception */ - public fun bytesToPublicKey(publicKeyBytes: ByteArray): JWK + public fun bytesToPublicKey(publicKeyBytes: ByteArray): Jwk } \ No newline at end of file diff --git a/crypto/src/main/kotlin/web5/sdk/crypto/KeyManager.kt b/crypto/src/main/kotlin/web5/sdk/crypto/KeyManager.kt index af8269bcc..f6f71af8c 100644 --- a/crypto/src/main/kotlin/web5/sdk/crypto/KeyManager.kt +++ b/crypto/src/main/kotlin/web5/sdk/crypto/KeyManager.kt @@ -1,6 +1,6 @@ package web5.sdk.crypto -import com.nimbusds.jose.jwk.JWK +import web5.sdk.crypto.jwk.Jwk /** * A key management interface that provides functionality for generating, storing, and utilizing @@ -31,12 +31,12 @@ public interface KeyManager { * Retrieves the public key associated with a previously stored private key, identified by the provided alias. * * @param keyAlias The alias referencing the stored private key. - * @return The associated public key in JWK (JSON Web Key) format. + * @return The associated public key in Jwk (JSON Web Key) format. * * The function should provide the public key in a format suitable for external sharing and usage, * enabling others to perform operations like verifying signatures or encrypting data for the private key holder. */ - public fun getPublicKey(keyAlias: String): JWK + public fun getPublicKey(keyAlias: String): Jwk /** * Signs the provided payload using the private key identified by the provided alias. @@ -54,9 +54,42 @@ public interface KeyManager { /** * Return the alias of [publicKey], as was originally returned by [generatePrivateKey]. * - * @param publicKey A public key in JWK (JSON Web Key) format + * @param publicKey A public key in Jwk (JSON Web Key) format * @return The alias belonging to [publicKey] * @throws IllegalArgumentException if the key is not known to the [KeyManager] */ - public fun getDeterministicAlias(publicKey: JWK): String + public fun getDeterministicAlias(publicKey: Jwk): String +} + +/** + * KeyExporter is an abstraction that can be leveraged to + * implement types which intend to export keys. + * + */ +public interface KeyExporter { + + /** + * ExportKey exports the key specific by the key ID from the KeyManager. + * + * @param keyId the keyId whose corresponding key to export + * @return the Jwk representation of the key + */ + public fun exportKey(keyId: String): Jwk +} + +/** + * KeyImporter is an abstraction that can be leveraged to + * implement types which intend to import keys. + * + */ +public interface KeyImporter { + + /** + * ImportKey imports the key into the KeyManager + * and returns the key alias. + * + * @param jwk the Jwk representation of the key to import + * @return the key alias of the imported key + */ + public fun importKey(jwk: Jwk): String } \ No newline at end of file diff --git a/crypto/src/main/kotlin/web5/sdk/crypto/Secp256k1.kt b/crypto/src/main/kotlin/web5/sdk/crypto/Secp256k1.kt index 7321897f1..602af6eca 100644 --- a/crypto/src/main/kotlin/web5/sdk/crypto/Secp256k1.kt +++ b/crypto/src/main/kotlin/web5/sdk/crypto/Secp256k1.kt @@ -2,11 +2,10 @@ package web5.sdk.crypto import com.nimbusds.jose.JWSAlgorithm import com.nimbusds.jose.crypto.bc.BouncyCastleProviderSingleton +import com.nimbusds.jose.jwk.Curve import com.nimbusds.jose.jwk.ECKey -import com.nimbusds.jose.jwk.JWK import com.nimbusds.jose.jwk.KeyUse import com.nimbusds.jose.jwk.gen.ECKeyGenerator -import com.nimbusds.jose.util.Base64URL import org.bouncycastle.crypto.digests.SHA256Digest import org.bouncycastle.crypto.params.ECDomainParameters import org.bouncycastle.crypto.params.ECPrivateKeyParameters @@ -16,8 +15,11 @@ import org.bouncycastle.crypto.signers.HMacDSAKCalculator import org.bouncycastle.jce.ECNamedCurveTable import org.bouncycastle.jce.spec.ECNamedCurveParameterSpec import org.bouncycastle.math.ec.ECPoint +import web5.sdk.common.Convert +import web5.sdk.common.EncodingFormat import web5.sdk.crypto.Secp256k1.PRIV_MULTICODEC import web5.sdk.crypto.Secp256k1.PUB_MULTICODEC +import web5.sdk.crypto.jwk.Jwk import java.math.BigInteger import java.security.MessageDigest import java.security.Security @@ -135,62 +137,80 @@ public object Secp256k1 : KeyGenerator, Signer { * be intended for signature use. * * @param options Options for key generation (currently unused, provided for possible future expansion). - * @return A JWK representing the generated private key. + * @return A Jwk representing the generated private key. */ - override fun generatePrivateKey(options: KeyGenOptions?): JWK { - return ECKeyGenerator(com.nimbusds.jose.jwk.Curve.SECP256K1) - .algorithm(JWSAlgorithm.ES256K) + override fun generatePrivateKey(options: KeyGenOptions?): Jwk { + // TODO use tink to generate private key https://github.com/TBD54566975/web5-kt/issues/273 + val privateKey = ECKeyGenerator(Curve.SECP256K1) .provider(BouncyCastleProviderSingleton.getInstance()) - .keyIDFromThumbprint(true) - .keyUse(KeyUse.SIGNATURE) .generate() + + val jwk = Jwk.Builder("EC", privateKey.curve.name) + .privateKey(privateKey.d.toString()) + .x(privateKey.x.toString()) + .y(privateKey.y.toString()) + .build() + + return jwk } - override fun computePublicKey(privateKey: JWK): JWK { + override fun computePublicKey(privateKey: Jwk): Jwk { validateKey(privateKey) - return privateKey.toECKey().toPublicJWK() + val jwk = Jwk.Builder(privateKey.kty, curve.name) + .algorithm(algorithm.name) + .apply { + privateKey.use?.let { keyUse(it) } + privateKey.alg?.let { algorithm(it) } + privateKey.x?.let { x(it) } + privateKey.y?.let { y(it) } + } + .build() + + return jwk } - override fun privateKeyToBytes(privateKey: JWK): ByteArray { + override fun privateKeyToBytes(privateKey: Jwk): ByteArray { validateKey(privateKey) - return privateKey.toECKey().d.decode() + return Convert(privateKey.d, EncodingFormat.Base64Url).toByteArray() } - override fun publicKeyToBytes(publicKey: JWK): ByteArray { + override fun publicKeyToBytes(publicKey: Jwk): ByteArray { validateKey(publicKey) - val ecKey = publicKey.toECKey() - val xBytes = ecKey.x.decode() - val yBytes = ecKey.y.decode() + val xBytes = Convert(publicKey.x, EncodingFormat.Base64Url).toByteArray() + val yBytes = Convert(publicKey.y, EncodingFormat.Base64Url).toByteArray() return byteArrayOf(UNCOMPRESSED_KEY_ID) + xBytes + yBytes } - override fun bytesToPrivateKey(privateKeyBytes: ByteArray): JWK { + override fun bytesToPrivateKey(privateKeyBytes: ByteArray): Jwk { var pointQ: ECPoint = spec.g.multiply(BigInteger(1, privateKeyBytes)) pointQ = pointQ.normalize() val rawX = pointQ.rawXCoord.encoded val rawY = pointQ.rawYCoord.encoded - return ECKey.Builder(com.nimbusds.jose.jwk.Curve.SECP256K1, Base64URL.encode(rawX), Base64URL.encode(rawY)) - .algorithm(JWSAlgorithm.ES256K) - .keyIDFromThumbprint() - .keyUse(KeyUse.SIGNATURE) + return Jwk.Builder("EC", curve.name) + .algorithm(algorithm.name) + .x(Convert(rawX).toBase64Url()) + .y(Convert(rawY).toBase64Url()) + .privateKey(Convert(privateKeyBytes).toBase64Url()) .build() } - override fun bytesToPublicKey(publicKeyBytes: ByteArray): JWK { + override fun bytesToPublicKey(publicKeyBytes: ByteArray): Jwk { val xBytes = publicKeyBytes.sliceArray(1..32) val yBytes = publicKeyBytes.sliceArray(33..64) - return ECKey.Builder(com.nimbusds.jose.jwk.Curve.SECP256K1, Base64URL.encode(xBytes), Base64URL.encode(yBytes)) - .algorithm(JWSAlgorithm.ES256K) - .keyIDFromThumbprint() - .keyUse(KeyUse.SIGNATURE) + val jwk = Jwk.Builder("EC", curve.name) + .algorithm(algorithm.name) + .x(Convert(xBytes).toBase64Url()) + .y(Convert(yBytes).toBase64Url()) .build() + + return jwk } /** @@ -200,7 +220,7 @@ public object Secp256k1 : KeyGenerator, Signer { * This function is designed to generate deterministic signatures, meaning that signing the * same payload with the same private key will always produce the same signature. * - * @param privateKey The private key used for signing, provided as a `JWK` (JSON Web Key). + * @param privateKey The private key used for signing, provided as a `Jwk` (JSON Web Key). * @param payload The byte array containing the data to be signed. * Ensure that the payload is prepared appropriately, considering any necessary * hashing or formatting relevant to the application's security requirements. @@ -211,8 +231,8 @@ public object Secp256k1 : KeyGenerator, Signer { * @throws IllegalArgumentException If the provided key, payload, or options are invalid or inappropriate * for the signing process. */ - override fun sign(privateKey: JWK, payload: ByteArray, options: SignOptions?): ByteArray { - val privateKeyBigInt = privateKey.toECKey().d.decodeToBigInteger() + override fun sign(privateKey: Jwk, payload: ByteArray, options: SignOptions?): ByteArray { + val privateKeyBigInt = BigInteger(1, Convert(privateKey.d, EncodingFormat.Base64Url).toByteArray()) val privateKeyParams = ECPrivateKeyParameters(privateKeyBigInt, curveParams) // generates k value deterministically using the private key and message hash, ensuring that signing the same @@ -251,7 +271,7 @@ public object Secp256k1 : KeyGenerator, Signer { * through HMAC and SHA-256, ensuring consistent verification outcomes for identical payloads * and signatures. * - * @param publicKey The public key used for verification, provided as a `JWK` (JSON Web Key). + * @param publicKey The public key used for verification, provided as a `Jwk` (JSON Web Key). * @param signedPayload The byte array containing the data that was signed. * @param signature The byte array representing the signature to be verified against the payload. * @param options Optional parameter to provide additional configuration for the verification process. @@ -260,7 +280,7 @@ public object Secp256k1 : KeyGenerator, Signer { * @throws IllegalArgumentException If the provided public key or signature format is invalid or not * supported by the implementation. */ - override fun verify(publicKey: JWK, signedPayload: ByteArray, signature: ByteArray, options: VerifyOptions?) { + override fun verify(publicKey: Jwk, signedPayload: ByteArray, signature: ByteArray, options: VerifyOptions?) { val publicKeyBytes = publicKeyToBytes(publicKey) val publicKeyPoint = spec.curve.decodePoint(publicKeyBytes) @@ -290,7 +310,7 @@ public object Secp256k1 : KeyGenerator, Signer { } /** - * Validates the provided [JWK] (JSON Web Key) to ensure it conforms to the expected key type and format. + * Validates the provided [Jwk] (JSON Web Key) to ensure it conforms to the expected key type and format. * * This function checks the following: * - The key must be an instance of [ECKey]. @@ -300,7 +320,7 @@ public object Secp256k1 : KeyGenerator, Signer { * * ### Usage Example: * ``` - * val jwk: JWK = //...obtain or generate a JWK + * val jwk: Jwk = //...obtain or generate a Jwk * try { * Secp256k1.validateKey(jwk) * // Key is valid, proceed with further operations... @@ -310,14 +330,14 @@ public object Secp256k1 : KeyGenerator, Signer { * ``` * * ### Important: - * Ensure to call this function before using a [JWK] in cryptographic operations + * Ensure to call this function before using a [Jwk] in cryptographic operations * to safeguard against invalid key usage and potential vulnerabilities. * - * @param key The [JWK] to validate. + * @param key The [Jwk] to validate. * @throws IllegalArgumentException if the key is not of type [ECKey]. */ - public fun validateKey(key: JWK) { - require(key is ECKey) { "private key must be an ECKey (kty: EC)" } + public fun validateKey(key: Jwk) { + require(key.kty == "EC") { "private key must be an ECKey (kty: EC)" } } /** diff --git a/crypto/src/main/kotlin/web5/sdk/crypto/Signer.kt b/crypto/src/main/kotlin/web5/sdk/crypto/Signer.kt index f06e1f85e..29c007bb9 100644 --- a/crypto/src/main/kotlin/web5/sdk/crypto/Signer.kt +++ b/crypto/src/main/kotlin/web5/sdk/crypto/Signer.kt @@ -1,6 +1,6 @@ package web5.sdk.crypto -import com.nimbusds.jose.jwk.JWK +import web5.sdk.crypto.jwk.Jwk import java.security.SignatureException /** @@ -59,11 +59,11 @@ public interface VerifyOptions * ### Usage Example: * ``` * class MySigner : Signer { - * override fun sign(privateKey: JWK, payload: Payload, options: SignOptions?): String { + * override fun sign(privateKey: Jwk , payload: Payload, options: SignOptions?): String { * // Implementation-specific signing logic. * } * - * override fun verify(publicKey: JWK, jws: String, options: VerifyOptions?) { + * override fun verify(publicKey: Jwk , jws: String, options: VerifyOptions?) { * // Implementation-specific verification logic. * } * } @@ -80,28 +80,28 @@ public interface Signer { /** * Sign a given payload using a private key. * - * This function takes a payload and a private key in JWK (JSON Web Key) format, + * This function takes a payload and a private key in Jwk (JSON Web Key) format, * and returns a signature as a byte array. Additional options for the signing * process can be provided via the `options` parameter. * - * @param privateKey The private key in JWK format to be used for signing. + * @param privateKey The private key in Jwk format to be used for signing. * Must not be null. * @param payload The payload/data to be signed. Must not be null. * @param options Optional parameter containing additional options to control * the signing process. Default is null. * @return A [ByteArray] representing the signature. */ - public fun sign(privateKey: JWK, payload: ByteArray, options: SignOptions? = null): ByteArray + public fun sign(privateKey: Jwk, payload: ByteArray, options: SignOptions? = null): ByteArray /** * Verify the signature of a given payload using a public key. * * This function attempts to verify the signature of a provided payload using a public key, - * supplied in JWK (JSON Web Key) format, and a signature. The verification process checks + * supplied in Jwk (JSON Web Key) format, and a signature. The verification process checks * the validity of the signature against the provided payload, respecting any optional * verification options provided via [VerifyOptions]. * - * @param publicKey The public key in JWK format used for verifying the signature. + * @param publicKey The public key in Jwk format used for verifying the signature. * Must not be null. * @param signedPayload The original payload/data that was signed, to be verified * against its signature. Must not be null. @@ -112,5 +112,5 @@ public interface Signer { * * @throws [SignatureException] if the verification fails. */ - public fun verify(publicKey: JWK, signedPayload: ByteArray, signature: ByteArray, options: VerifyOptions? = null) + public fun verify(publicKey: Jwk, signedPayload: ByteArray, signature: ByteArray, options: VerifyOptions? = null) } \ No newline at end of file diff --git a/crypto/src/main/kotlin/web5/sdk/crypto/jwk/Jwk.kt b/crypto/src/main/kotlin/web5/sdk/crypto/jwk/Jwk.kt new file mode 100644 index 000000000..c4f13b6ff --- /dev/null +++ b/crypto/src/main/kotlin/web5/sdk/crypto/jwk/Jwk.kt @@ -0,0 +1,180 @@ +package web5.sdk.crypto.jwk + +import web5.sdk.common.Convert +import web5.sdk.common.Json +import web5.sdk.crypto.Ed25519 +import java.security.MessageDigest + +/** + * Represents a [JSON Web Key (Jwk )](https://datatracker.ietf.org/doc/html/rfc7517). + * A Jwk is a JSON object that represents a cryptographic key. This class + * provides functionalities to manage a Jwk including its creation, conversion + * to and from JSON, and computing a thumbprint. + * + * Example: + * ``` + * var jwk = Jwk( + * kty: 'RSA', + * alg: 'RS256', + * use: 'sig', + * ... // other parameters + * ); + * ``` + * @property kty Represents the key type. + * @property use Represents the intended use of the public key. + * @property alg Identifies the algorithm intended for use with the key. + * @property kid Key ID, unique identifier for the key. + * @property crv Elliptic curve name for EC keys. + * @property d Private key component for EC or OKP keys. + * @property x X coordinate for EC keys, or the public key for OKP. + * @property y Y coordinate for EC keys. + * + */ +public class Jwk( + public val kty: String, + public val crv: String, + public val use: String?, + public val alg: String?, + public var kid: String?, + public val d: String?, + public val x: String?, + public val y: String? +) { + + /** + * Computes the thumbprint of the Jwk. + * [Specification](https://www.rfc-editor.org/rfc/rfc7638.html). + * + * Generates a thumbprint of the Jwk using SHA-256 hash function. + * The thumbprint is computed based on the key's [kty], [crv], [x], + * and [y] values. + * + * @return a Base64URL-encoded string representing the thumbprint. + */ + public fun computeThumbprint(): String { + val thumbprintPayload = Json.jsonMapper.createObjectNode().apply { + put("crv", crv) + put("kty", kty) + put("x", x) + if (y != null) { + put("y", y) + } + } + + val thumbprintPayloadString = Json.stringify(thumbprintPayload) + val thumbprintPayloadBytes = Convert(thumbprintPayloadString).toByteArray() + + val messageDigest = MessageDigest.getInstance("SHA-256") + val thumbprintPayloadDigest = messageDigest.digest(thumbprintPayloadBytes) + + return Convert(thumbprintPayloadDigest).toBase64Url() + + } + + override fun toString(): String { + return "Jwk(kty='$kty', use=$use, alg=$alg, kid=$kid, crv=$crv, d=$d, x=$x, y=$y)" + } + + /** + * Builder for Jwk type. + * + * @property keyType: Type of key (EC or OKP) + * @property curve: Type of curve + */ + public class Builder(keyType: String, curve: String) { + private var kty: String = keyType + private var crv: String = curve + private var use: String? = null + private var alg: String? = null + private var kid: String? = null + private var d: String? = null + private var x: String? = null + private var y: String? = null + + /** + * Sets key use. + * + * @param use + * @return Builder object + */ + public fun keyUse(use: String): Builder { + this.use = use + return this + } + + /** + * Sets algorithm. + * + * @param alg + * @return Builder object + */ + public fun algorithm(alg: String): Builder { + this.alg = alg + return this + } + + /** + * Sets key ID. + * + * @param kid + * @return Builder object + */ + public fun keyId(kid: String): Builder { + this.kid = kid + return this + } + + + /** + * Sets private key component. Must be base64 encoded string. + * + * @param d + * @return Builder object + */ + public fun privateKey(d: String): Builder { + this.d = d + return this + } + + /** + * Sets x coordinate. Must be base64 encoded string. + * + * @param x + * @return Builder object + */ + public fun x(x: String): Builder { + this.x = x + return this + } + + /** + * Sets y coordinate. Must be base64 encoded string. + * + * @param y + * @return Builder object + */ + public fun y(y: String): Builder { + this.y = y + return this + } + + /** + * Builds a Jwk object. + * + * @return Jwk object + */ + public fun build(): Jwk { + // TODO move these checks out to Ed25519 or Secp256k1 classes? + // https://github.com/TBD54566975/web5-kt/issues/276 + if (kty == "EC") { + check(x != null) { "x is required for EC keys" } + check(y != null) { "y is required for EC keys" } + } + if (kty == "OKP") { + check(x != null) { "x is required for OKP keys" } + } + + return Jwk(kty, crv, use, alg, kid, d, x, y) + } + } +} diff --git a/crypto/src/test/kotlin/web5/sdk/crypto/AwsKeyManagerTest.kt b/crypto/src/test/kotlin/web5/sdk/crypto/AwsKeyManagerTest.kt index 18f430b81..6bba7243f 100644 --- a/crypto/src/test/kotlin/web5/sdk/crypto/AwsKeyManagerTest.kt +++ b/crypto/src/test/kotlin/web5/sdk/crypto/AwsKeyManagerTest.kt @@ -4,9 +4,6 @@ import com.amazonaws.AmazonServiceException import com.amazonaws.auth.AWSStaticCredentialsProvider import com.amazonaws.auth.BasicAWSCredentials import com.amazonaws.services.kms.AWSKMSClient -import com.nimbusds.jose.JWSAlgorithm -import com.nimbusds.jose.jwk.ECKey -import com.nimbusds.jose.jwk.KeyUse import org.junit.jupiter.api.Disabled import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows @@ -24,10 +21,9 @@ class AwsKeyManagerTest { val alias = awsKeyManager.generatePrivateKey(AlgorithmId.secp256k1) val publicKey = awsKeyManager.getPublicKey(alias) - assertEquals(alias, publicKey.keyID) - assertTrue(publicKey is ECKey) - assertEquals(KeyUse.SIGNATURE, publicKey.keyUse) - assertEquals(JWSAlgorithm.ES256K, publicKey.algorithm) + assertEquals(alias, publicKey.kid) + assertTrue(publicKey.kty == "EC") + assertEquals(Jwa.ES256K.name, publicKey.alg) } @Test diff --git a/crypto/src/test/kotlin/web5/sdk/crypto/Ed25519Test.kt b/crypto/src/test/kotlin/web5/sdk/crypto/Ed25519Test.kt index 5edbaf3c4..39d868d69 100644 --- a/crypto/src/test/kotlin/web5/sdk/crypto/Ed25519Test.kt +++ b/crypto/src/test/kotlin/web5/sdk/crypto/Ed25519Test.kt @@ -2,9 +2,10 @@ package web5.sdk.crypto import com.fasterxml.jackson.core.type.TypeReference import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.nimbusds.jose.jwk.OctetKeyPair import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertDoesNotThrow +import web5.sdk.common.Json +import web5.sdk.crypto.jwk.Jwk import web5.sdk.testing.TestVectors import java.io.File import kotlin.test.assertEquals @@ -34,7 +35,7 @@ class Web5TestVectorsCryptoEd25519 { val inputByteArray: ByteArray = hexStringToByteArray(vector.input.data) val jwkMap = vector.input.key - val ed25519Jwk = OctetKeyPair.parse(jwkMap.toString()) + val ed25519Jwk = Json.parse(Json.stringify(jwkMap)) val signedByteArray: ByteArray = Ed25519.sign(ed25519Jwk, inputByteArray) @@ -50,7 +51,7 @@ class Web5TestVectorsCryptoEd25519 { val inputByteArray: ByteArray = hexStringToByteArray(vector.input.data) val jwkMap = vector.input.key - val ed25519Jwk = OctetKeyPair.parse(jwkMap.toString()) + val ed25519Jwk = Json.parse(jwkMap.toString()) Ed25519.sign(ed25519Jwk, inputByteArray) } @@ -63,7 +64,7 @@ class Web5TestVectorsCryptoEd25519 { val testVectors = mapper.readValue(File("../web5-spec/test-vectors/crypto_ed25519/verify.json"), typeRef) testVectors.vectors.filter { it.errors == false }.forEach { vector -> - val key = OctetKeyPair.parse(vector.input.key) + val key = Json.parse(Json.stringify(vector.input.key)) val data = hexStringToByteArray(vector.input.data) val signature = hexStringToByteArray(vector.input.signature) if (vector.output == true) { @@ -79,7 +80,7 @@ class Web5TestVectorsCryptoEd25519 { testVectors.vectors.filter { it.errors == true }.forEach { vector -> assertFails { - val key = OctetKeyPair.parse(vector.input.key) + val key = Json.parse(vector.input.key.toString()) val data = hexStringToByteArray(vector.input.data) val signature = hexStringToByteArray(vector.input.signature) Ed25519.verify(key, data, signature) diff --git a/crypto/src/test/kotlin/web5/sdk/crypto/InMemoryKeyManagerTest.kt b/crypto/src/test/kotlin/web5/sdk/crypto/InMemoryKeyManagerTest.kt index 18ec400c8..c48629577 100644 --- a/crypto/src/test/kotlin/web5/sdk/crypto/InMemoryKeyManagerTest.kt +++ b/crypto/src/test/kotlin/web5/sdk/crypto/InMemoryKeyManagerTest.kt @@ -1,17 +1,12 @@ package web5.sdk.crypto -import com.fasterxml.jackson.annotation.JsonInclude -import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.MissingKotlinParameterException import com.fasterxml.jackson.module.kotlin.readValue -import com.nimbusds.jose.crypto.bc.BouncyCastleProviderSingleton -import com.nimbusds.jose.jwk.Curve -import com.nimbusds.jose.jwk.JWK -import com.nimbusds.jose.jwk.gen.ECKeyGenerator import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertDoesNotThrow import org.junit.jupiter.api.assertThrows -import java.text.ParseException +import web5.sdk.common.Json import kotlin.test.assertEquals class InMemoryKeyManagerTest { @@ -30,85 +25,47 @@ class InMemoryKeyManagerTest { val keyManager = InMemoryKeyManager() val jwk = Crypto.generatePrivateKey(AlgorithmId.secp256k1) val exception = assertThrows { - keyManager.getDeterministicAlias(jwk.toPublicJWK()) + keyManager.getDeterministicAlias(jwk) } assertTrue(exception.message!!.matches("key with alias .* not found".toRegex())) } @Test fun `public key is available after import`() { - val jwk = Crypto.generatePrivateKey(AlgorithmId.secp256k1) + val privateKey = Crypto.generatePrivateKey(AlgorithmId.secp256k1) val keyManager = InMemoryKeyManager() - val alias = keyManager.import(jwk) + val alias = keyManager.importKey(privateKey) val publicKey = keyManager.getPublicKey(alias) - assertEquals(jwk.toPublicJWK(), publicKey) + assertEquals(privateKey.kty, publicKey.kty) + assertEquals(privateKey.crv, publicKey.crv) + assertEquals(privateKey.x, publicKey.x) } @Test fun `public keys can be imported`() { - val jwk = Crypto.generatePrivateKey(AlgorithmId.secp256k1) + val privateKey = Crypto.generatePrivateKey(AlgorithmId.secp256k1) val keyManager = InMemoryKeyManager() - val alias = keyManager.import(jwk.toPublicJWK()) - - assertEquals(jwk.toPublicJWK(), keyManager.getPublicKey(alias)) + val alias = keyManager.importKey(privateKey) + val publicKey = keyManager.getPublicKey(alias) + assertEquals(privateKey.kty, publicKey.kty) + assertEquals(privateKey.crv, publicKey.crv) + assertEquals(privateKey.x, publicKey.x) } @Test fun `key without kid can be imported`() { - val jwk = ECKeyGenerator(Curve.SECP256K1) - .provider( - BouncyCastleProviderSingleton.getInstance() - ) - .generate() + val privateKey = Ed25519.generatePrivateKey() val keyManager = InMemoryKeyManager() - val alias = keyManager.import(jwk) - + val alias = keyManager.importKey(privateKey) val publicKey = keyManager.getPublicKey(alias) - assertEquals(jwk.toPublicJWK(), publicKey) - } - - @Test - fun `export returns all keys`() { - val keyManager = InMemoryKeyManager() - keyManager.generatePrivateKey(AlgorithmId.Ed25519) - - val keySet = keyManager.export() - assertEquals(1, keySet.size) - - assertDoesNotThrow { - JWK.parse(keySet[0]) - } - } - - @Test - fun `import throws an exception if key isnt a JWK`() { - val keyManager = InMemoryKeyManager() - val kakaKeySet = listOf(mapOf("hehe" to "troll")) + assertEquals(privateKey.kty, publicKey.kty) + assertEquals(privateKey.crv, publicKey.crv) + assertEquals(privateKey.x, publicKey.x) - assertThrows { - keyManager.import(kakaKeySet) - } } - @Test - fun `import loads all keys provided`() { - @Suppress("MaxLineLength") - val serializedKeySet = - """[{"kty":"OKP","d":"DTwtf9i7M4Vj8vSg0iJAQ_n2gSNEUTNLIq30CJ4d9BE","use":"sig","crv":"Ed25519","kid":"hKTpA-TQPNAX9zXtuxPIyTNpoyd4j1Pq1Y_txo2Hm3I","x":"_CrbbGuhpHFs3KVGg2bbNgd2SikmT4L5rIE_zQQjKq0","alg":"EdDSA"}]""" - - val jsonMapper: ObjectMapper = ObjectMapper() - .findAndRegisterModules() - .setSerializationInclusion(JsonInclude.Include.NON_NULL) - - val jsonKeySet: List> = jsonMapper.readValue(serializedKeySet) - val keyManager = InMemoryKeyManager() - - assertDoesNotThrow { - keyManager.import(jsonKeySet) - } - } } diff --git a/crypto/src/test/kotlin/web5/sdk/crypto/Secp256k1Test.kt b/crypto/src/test/kotlin/web5/sdk/crypto/Secp256k1Test.kt index d4b346d25..0a97dd12d 100644 --- a/crypto/src/test/kotlin/web5/sdk/crypto/Secp256k1Test.kt +++ b/crypto/src/test/kotlin/web5/sdk/crypto/Secp256k1Test.kt @@ -2,21 +2,20 @@ package web5.sdk.crypto import com.fasterxml.jackson.core.type.TypeReference import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.nimbusds.jose.JWSAlgorithm -import com.nimbusds.jose.jwk.ECKey -import com.nimbusds.jose.jwk.KeyUse import org.apache.commons.codec.binary.Hex import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertDoesNotThrow import web5.sdk.common.Convert +import web5.sdk.common.Json +import web5.sdk.crypto.jwk.Jwk import web5.sdk.testing.TestVectors import java.io.File import java.security.SignatureException import java.util.Random import kotlin.test.assertEquals import kotlin.test.assertFails -import kotlin.test.assertFalse import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue class Secp256k1Test { @@ -25,11 +24,9 @@ class Secp256k1Test { val privateKey = Secp256k1.generatePrivateKey() Secp256k1.validateKey(privateKey) - assertEquals(JWSAlgorithm.ES256K, privateKey.algorithm) - assertEquals(KeyUse.SIGNATURE, privateKey.keyUse) - assertNotNull(privateKey.keyID) - assertTrue(privateKey is ECKey) - assertTrue(privateKey.isPrivate) + assertEquals(JwaCurve.secp256k1.name, privateKey.crv) + assertTrue(privateKey.kty == "EC") + assertNotNull(privateKey.d) } @Test @@ -39,11 +36,10 @@ class Secp256k1Test { val publicKey = Secp256k1.computePublicKey(privateKey) Secp256k1.validateKey(publicKey) - assertEquals(publicKey.keyID, privateKey.keyID) - assertEquals(JWSAlgorithm.ES256K, publicKey.algorithm) - assertEquals(KeyUse.SIGNATURE, publicKey.keyUse) - assertTrue(publicKey is ECKey) - assertFalse(publicKey.isPrivate) + assertEquals(publicKey.kid, privateKey.kid) + assertEquals(Jwa.ES256K.name, publicKey.alg) + assertTrue(publicKey.kty == "EC") + assertNull(publicKey.d) } @Test @@ -58,8 +54,8 @@ class Secp256k1Test { val sig2 = Secp256k1.sign(privateKey, payload) Secp256k1.verify(publicKey, payload, sig2) - val base64UrlEncodedSig1 = Convert(sig1).toBase64Url(padding = false) - val base64UrlEncodedSig2 = Convert(sig2).toBase64Url(padding = false) + val base64UrlEncodedSig1 = Convert(sig1).toBase64Url() + val base64UrlEncodedSig2 = Convert(sig2).toBase64Url() assertEquals(base64UrlEncodedSig1, base64UrlEncodedSig2) } @@ -80,7 +76,7 @@ class Secp256k1Test { val sig1 = Secp256k1.sign(privateKey, payload) Secp256k1.verify(publicKey, payload, sig1) } catch (e: SignatureException) { - val payloadString = Convert(payload).toBase64Url(false) + val payloadString = Convert(payload).toBase64Url() println("($it) $e. Payload (base64url encoded): $payloadString") throw e } @@ -109,8 +105,8 @@ class Web5TestVectorsCryptoEs256k { testVectors.vectors.filter { it.errors == false }.forEach { vector -> val inputByteArray: ByteArray = Hex.decodeHex(vector.input.data.toCharArray()) - val jwkMap = vector.input.key - val ecJwk = ECKey.parse(jwkMap.toString()) + val jwkMap = vector.input.key!! + val ecJwk = Json.parse(Json.stringify(jwkMap)) val signedByteArray: ByteArray = Secp256k1.sign(ecJwk, inputByteArray) val signedHex = Hex.encodeHexString(signedByteArray) @@ -123,7 +119,7 @@ class Web5TestVectorsCryptoEs256k { val inputByteArray: ByteArray = Hex.decodeHex(vector.input.data.toCharArray()) val jwkMap = vector.input.key - val ecJwk = ECKey.parse(jwkMap.toString()) + val ecJwk = Json.parse(jwkMap.toString()) Secp256k1.sign(ecJwk, inputByteArray) } @@ -137,25 +133,25 @@ class Web5TestVectorsCryptoEs256k { testVectors.vectors.filter { it.errors == false }.forEach { vector -> val inputByteArray: ByteArray = Hex.decodeHex(vector.input.data.toCharArray()) - val jwkMap = vector.input.key + val jwkMap = vector.input.key!! val signatureByteArray = Hex.decodeHex(vector.input.signature.toCharArray()) - val ecJwk = ECKey.parse(jwkMap.toString()) + val jwk = Json.parse(Json.stringify(jwkMap)) if (vector.output == true) { - assertDoesNotThrow { Secp256k1.verify(ecJwk, inputByteArray, signatureByteArray) } + assertDoesNotThrow { Secp256k1.verify(jwk, inputByteArray, signatureByteArray) } } else { - assertFails { Secp256k1.verify(ecJwk, inputByteArray, signatureByteArray) } + assertFails { Secp256k1.verify(jwk, inputByteArray, signatureByteArray) } } } testVectors.vectors.filter { it.errors == true }.forEach { vector -> assertFails { val inputByteArray: ByteArray = Hex.decodeHex(vector.input.data.toCharArray()) - val jwkMap = vector.input.key + val jwkMap = vector.input.key!! val signatureByteArray = Hex.decodeHex(vector.input.signature.toCharArray()) - val ecJwk = ECKey.parse(jwkMap.toString()) + val ecJwk = Json.parse(Json.stringify(jwkMap)) Secp256k1.verify(ecJwk, inputByteArray, signatureByteArray) } } diff --git a/crypto/src/test/kotlin/web5/sdk/crypto/jwk/JwkTest.kt b/crypto/src/test/kotlin/web5/sdk/crypto/jwk/JwkTest.kt new file mode 100644 index 000000000..d43d290c1 --- /dev/null +++ b/crypto/src/test/kotlin/web5/sdk/crypto/jwk/JwkTest.kt @@ -0,0 +1,35 @@ +package web5.sdk.crypto.jwk + +import org.junit.jupiter.api.Test +import web5.sdk.crypto.JwaCurve +import kotlin.test.assertEquals + +class JwkTest { + + @Test + fun `computeThumbPrint works with secp256k1 jwk`() { + val jwk = Jwk.Builder(keyType = "EC", curve = JwaCurve.secp256k1.name) + .x("vdrbz2EOzvbLDV_-kL4eJt7VI-8TFZNmA9YgWzvhh7U") + .y("VLFqQMZP_AspucXoWX2-bGXpAO1fQ5Ln19V5RAxrgvU") + .algorithm("ES256K") + .build() + + val thumbprint = jwk.computeThumbprint() + + assertEquals("i3SPRBtJKovHFsBaqM92ti6xQCJLX3E7YCewiHV2CSg", thumbprint) + + } + + @Test + fun `computeThumbprint works with Ed25519 jwk`() { + val jwk = Jwk.Builder(keyType = "OKP", curve = JwaCurve.Ed25519.name) + .x("DzpSEyU0w1Myn3lA_piHAI6OrFAnZuEsTwMUPCTwMc8") + .algorithm("EdDSA") + .build() + + val thumbprint = jwk.computeThumbprint() + + assertEquals("c4IOrQdnehPwQZ6SyNLp9J942VCXrxgWw4zUxAHQXQE", thumbprint) + } + +} \ No newline at end of file diff --git a/dids/src/main/kotlin/web5/sdk/dids/DidMethod.kt b/dids/src/main/kotlin/web5/sdk/dids/DidMethod.kt index 42d534c64..283b07562 100644 --- a/dids/src/main/kotlin/web5/sdk/dids/DidMethod.kt +++ b/dids/src/main/kotlin/web5/sdk/dids/DidMethod.kt @@ -1,37 +1,5 @@ package web5.sdk.dids -import web5.sdk.crypto.KeyManager -import web5.sdk.dids.didcore.DidUri -import web5.sdk.dids.exceptions.PublicKeyJwkMissingException - -/** - * A base abstraction for Decentralized Identifiers (DID) compliant with the W3C DID standard. - * - * This abstract class serves as a foundational structure upon which specific DID methods - * can be implemented. Subclasses should furnish particular method and data models adherent - * to various DID methods. - * - * @property uri The Uniform Resource Identifier (URI) string of the DID. - * @property keyManager An [KeyManager] instance managing the cryptographic keys linked to the DID. - * - * ### Example Implementation - * Implementing subclasses should provide concrete method and data models specific to a DID method: - * ``` - * class SpecificDid(uri: String, keyManager: KeyManager): Did(uri, keyManager) { - * // Implementation-specific details here. - * } - * ``` - * - * ### Additional Notes - * Implementers should adhere to the respective DID method specifications ensuring both compliance - * and interoperability across different DID networks. - */ -public abstract class Did(public val uri: String, public val keyManager: KeyManager) { - public companion object { - // static helper methods here - } -} - /** * Represents options during the creation of a Decentralized Identifier (DID). * @@ -50,135 +18,3 @@ public abstract class Did(public val uri: String, public val keyManager: KeyMana * ``` */ public interface CreateDidOptions - -/** - * Represents metadata that results from the creation of a Decentralized Identifier (DID). - * - * Implementers can include information that would be considered useful for callers. - * - * ### Usage Example - * ``` - * class MyDidMethodCreatedMetadata : CreationMetadata { - * // implementation-specific metadata about the created did - * } - * ``` - */ -public interface CreationMetadata - -/** - * Represents options during the resolution of a Decentralized Identifier (DID). - * - * Implementations of this interface may contain properties and methods that provide - * specific options or metadata during the DID resolution processes following specific - * DID method specifications. - * - * ### Usage Example: - * Implement this interface in classes where specific creation options are needed - * for different DID methods. - * - * ``` - * class ResolveDidKeyOptions : ResolveDidOptions { - * // Implementation-specific options for DID creation. - * } - * ``` - */ -public interface ResolveDidOptions - -/** - * An interface defining operations for DID methods in accordance with the W3C DID standard. - * - * A DID method is a specific set of rules for creating, updating, and revoking DIDs, - * specified in a DID method specification. Different DID methods utilize different - * consensus mechanisms, cryptographic algorithms, and registries (or none at all). - * The purpose of `DidMethod` implementations is to provide logic tailored to a - * particular method while adhering to the broader operations outlined in the W3C DID standard. - * - * Implementations of this interface should provide method-specific logic for - * creating and resolving DIDs under a particular method. - * - * @param T The type of DID that this method can create and resolve, extending [Did]. - * - * ### Example of a Custom DID Method Implementation: - * ``` - * class ExampleDidMethod : DidMethod { - * override val methodName: String = "example" - * - * override fun create(keyManager: KeyManager, options: ExampleCreateDidOptions?): ExampleDid { - * // Implementation-specific logic for creating DIDs. - * } - * - * override fun resolve(didUrl: String, opts: ResolveDidOpts?): DidResolutionResult { - * // Implementation-specific logic for resolving DIDs. - * } - * } - * ``` - * - * ### Notes: - * - Ensure conformance with the relevant DID method specification for accurate and - * interoperable functionality. - * - Ensure that cryptographic operations utilize secure and tested libraries, ensuring - * the reliability and security of DIDs managed by this method. - */ -public interface DidMethod { - /** - * A string that specifies the name of the DID method. - * - * For instance, in the DID `did:example:123456`, "example" would be the method name. - */ - public val methodName: String - - /** - * Creates a new DID. - * - * This function should generate a new DID according to the rules of the specific - * method being implemented, using the provided [KeyManager] and optionally considering - * any provided [CreateDidOptions]. - * - * @param keyManager An instance of [KeyManager] responsible for cryptographic operations. - * @param options Optionally, an instance of [CreateDidOptions] providing additional options - * or requirements for DID creation. - * @return A new instance of type [T], representing the created DID. - */ - public fun create(keyManager: KeyManager, options: O? = null): T - - /** - * Resolves a DID to its associated DID Document. - * - * This function should retrieve and return the DID Document associated with the provided - * DID URI, in accordance with the rules and mechanisms of the specific DID method being - * implemented, and optionally considering any provided [ResolveDidOptions]. - * - * @param did A string containing the DID URI to be resolved. - * @param options Optionally, an instance of [ResolveDidOptions] providing additional options - * or requirements for DID resolution. - * @return An instance of [DidResolutionResult] containing the resolved DID Document and - * any associated metadata. - */ - public fun resolve(did: String, options: ResolveDidOptions? = null): DidResolutionResult - - /** - * Returns an instance of [T] for [uri]. This function validates that all the key material needed for signing and - * managing the passed in [uri] exists within the provided [keyManager]. - * - * @param uri A string containing the DID URI to load. - * @param keyManager An instance of [KeyManager] that should contain all the key material needed for signing and - * managing the passed in [did]. - * @return An instance of [T] representing the loaded DID. - */ - public fun load(uri: String, keyManager: KeyManager): T -} - - -internal fun DidMethod.validateKeyMaterialInsideKeyManager( - did: String, keyManager: KeyManager) { - require(DidUri.parse(did).method == methodName) { - "did must start with the prefix \"did:$methodName\", but got $did" - } - val didResolutionResult = resolve(did) - - didResolutionResult.didDocument!!.verificationMethod?.forEach { - val publicKeyJwk = it.publicKeyJwk ?: throw PublicKeyJwkMissingException("publicKeyJwk is null") - val keyAlias = keyManager.getDeterministicAlias(publicKeyJwk) - keyManager.getPublicKey(keyAlias) - } -} \ No newline at end of file diff --git a/dids/src/main/kotlin/web5/sdk/dids/DidResolutionResult.kt b/dids/src/main/kotlin/web5/sdk/dids/DidResolutionResult.kt index 6b7a9e53b..aa0119ef6 100644 --- a/dids/src/main/kotlin/web5/sdk/dids/DidResolutionResult.kt +++ b/dids/src/main/kotlin/web5/sdk/dids/DidResolutionResult.kt @@ -4,8 +4,8 @@ import com.fasterxml.jackson.annotation.JsonInclude import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.KotlinModule -import web5.sdk.dids.didcore.DIDDocument -import web5.sdk.dids.didcore.DIDDocumentMetadata +import web5.sdk.dids.didcore.DidDocument +import web5.sdk.dids.didcore.DidDocumentMetadata import java.util.Objects.hash /** @@ -20,8 +20,8 @@ import java.util.Objects.hash public class DidResolutionResult( @JsonProperty("@context") public val context: String? = null, - public val didDocument: DIDDocument? = null, - public val didDocumentMetadata: DIDDocumentMetadata = DIDDocumentMetadata(), + public val didDocument: DidDocument? = null, + public val didDocumentMetadata: DidDocumentMetadata = DidDocumentMetadata(), public val didResolutionMetadata: DidResolutionMetadata = DidResolutionMetadata(), ) { override fun toString(): String { @@ -35,7 +35,13 @@ public class DidResolutionResult( return false } - override fun hashCode(): Int = hash(context, didDocument, didDocumentMetadata, didResolutionMetadata) + override fun hashCode(): Int = + hash( + context, + didDocument, + didDocumentMetadata, + didResolutionMetadata + ) public companion object { private val objectMapper: ObjectMapper = ObjectMapper().apply { diff --git a/dids/src/main/kotlin/web5/sdk/dids/DidResolvers.kt b/dids/src/main/kotlin/web5/sdk/dids/DidResolvers.kt index 274384a53..400153c93 100644 --- a/dids/src/main/kotlin/web5/sdk/dids/DidResolvers.kt +++ b/dids/src/main/kotlin/web5/sdk/dids/DidResolvers.kt @@ -1,43 +1,46 @@ package web5.sdk.dids -import web5.sdk.dids.didcore.DidUri -import web5.sdk.dids.extensions.supportedMethods +import web5.sdk.dids.didcore.Did +import web5.sdk.dids.methods.dht.DidDht +import web5.sdk.dids.methods.jwk.DidJwk +import web5.sdk.dids.methods.key.DidKey +import web5.sdk.dids.methods.web.DidWeb /** * Type alias for a DID resolver function. * A DID resolver takes a DID URL as input and returns a [DidResolutionResult]. * * @param didUrl The DID URL to resolve. - * @param options (Optional) Options for resolving the DID. * @return A [DidResolutionResult] containing the resolved DID document or an error message. */ -public typealias DidResolver = (String, ResolveDidOptions?) -> DidResolutionResult +public typealias DidResolver = (String) -> DidResolutionResult /** * Singleton object representing a collection of DID resolvers. */ public object DidResolvers { - // A mutable map to store method-specific DID resolvers. - private val methodResolvers = supportedMethods.entries.associate { - it.key to it.value::resolve as DidResolver - }.toMutableMap() + private val methodResolvers = mutableMapOf( + DidKey.methodName to DidKey.Companion::resolve, + DidJwk.methodName to DidJwk::resolve, + DidDht.methodName to DidDht.Default::resolve, + DidWeb.methodName to DidWeb.Default::resolve + ) /** * Resolves a DID URL using an appropriate resolver based on the DID method. * * @param didUrl The DID URL to resolve. - * @param options (Optional) Options for resolving the DID. * @return A [DidResolutionResult] containing the resolved DID document or an error message. * @throws IllegalArgumentException if resolving the specified DID method is not supported. */ - public fun resolve(didUrl: String, options: ResolveDidOptions? = null): DidResolutionResult { - val parsedDidUri = DidUri.parse(didUrl) - val resolver = methodResolvers.getOrElse(parsedDidUri.method) { - throw IllegalArgumentException("Resolving did:${parsedDidUri.method} not supported") + public fun resolve(didUrl: String): DidResolutionResult { + val did = Did.parse(didUrl) + val resolver = this.methodResolvers.getOrElse(did.method) { + throw IllegalArgumentException("Resolving did:${did.method} not supported") } - return resolver(didUrl, options) + return resolver(didUrl) } /** @@ -47,6 +50,6 @@ public object DidResolvers { * @param resolver The resolver function for the specified DID method. */ public fun addResolver(methodName: String, resolver: DidResolver) { - methodResolvers[methodName] = resolver + this.methodResolvers[methodName] = resolver } } diff --git a/dids/src/main/kotlin/web5/sdk/dids/Serialization.kt b/dids/src/main/kotlin/web5/sdk/dids/Serialization.kt index 1de8ae552..5f249ee4f 100644 --- a/dids/src/main/kotlin/web5/sdk/dids/Serialization.kt +++ b/dids/src/main/kotlin/web5/sdk/dids/Serialization.kt @@ -1,41 +1,13 @@ package web5.sdk.dids -import com.fasterxml.jackson.core.JsonGenerator import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.core.JsonProcessingException import com.fasterxml.jackson.databind.DeserializationContext import com.fasterxml.jackson.databind.JsonDeserializer import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.JsonSerializer -import com.fasterxml.jackson.databind.SerializerProvider -import com.nimbusds.jose.jwk.JWK import web5.sdk.dids.didcore.Purpose import java.io.IOException -/** - * Serialize JWK into String. - */ -public class JWKSerializer : JsonSerializer() { - public override fun serialize(jwk: JWK?, gen: JsonGenerator, serializers: SerializerProvider?) { - val jwkString = jwk?.toJSONString() - - gen.writeRawValue(jwkString) - } - -} - -/** - * Deserialize String into JWK. - * - */ -public class JwkDeserializer : JsonDeserializer() { - override fun deserialize(p: JsonParser, ctxt: DeserializationContext): JWK { - val node = p.codec.readTree(p) - val jwkJson = node.toString() - return JWK.parse(jwkJson) - } -} - /** * Deserialize String into List of Purpose enums. * diff --git a/dids/src/main/kotlin/web5/sdk/dids/did/BearerDid.kt b/dids/src/main/kotlin/web5/sdk/dids/did/BearerDid.kt new file mode 100644 index 000000000..7a5658c70 --- /dev/null +++ b/dids/src/main/kotlin/web5/sdk/dids/did/BearerDid.kt @@ -0,0 +1,153 @@ +package web5.sdk.dids.did + +import web5.sdk.crypto.InMemoryKeyManager +import web5.sdk.crypto.KeyExporter +import web5.sdk.crypto.KeyImporter +import web5.sdk.crypto.KeyManager +import web5.sdk.crypto.jwk.Jwk +import web5.sdk.dids.didcore.DidDocument +import web5.sdk.dids.didcore.Did +import web5.sdk.dids.didcore.VMSelector +import web5.sdk.dids.didcore.VerificationMethod + +public typealias DidSigner = (payload: ByteArray) -> ByteArray + +/** + * Represents a Decentralized Identifier (DID) along with its DID document, key manager, metadata, + * and convenience functions. + * + * @param did The Decentralized Identifier (DID) to represent. + * @param keyManager The KeyManager instance used to manage the cryptographic keys associated with the DID. + * @param document The DID Document associated with the DID. + */ +public class BearerDid( + public val uri: String, + public val did: Did, + public val keyManager: KeyManager, + public val document: DidDocument +) { + + /** + * GetSigner returns a sign method that can be used to sign a payload using a key associated to the DID. + * This function also returns the verification method needed to verify the signature. + * + * Providing the verification method allows the caller to provide the signature's recipient + * with a reference to the verification method needed to verify the payload. This is often done + * by including the verification method id either alongside the signature or as part of the header + * in the case of JSON Web Signatures. + * + * The verifier can dereference the verification method id to obtain the public key needed to verify the signature. + * + * This function takes a Verification Method selector that can be used to select a specific verification method + * from the DID Document if desired. If no selector is provided, the payload will be signed with the key associated + * to the first verification method in the DID Document. + * + * The selector can either be a Verification Method ID or a Purpose. If a Purpose is provided, the first verification + * method in the DID Document that has the provided purpose will be used to sign the payload. + * + * The returned signer is a function that takes a byte payload and returns a byte signature. + */ + @JvmOverloads + public fun getSigner(selector: VMSelector? = null): Pair { + val verificationMethod = document.selectVerificationMethod(selector) + + val kid = verificationMethod.publicKeyJwk?.computeThumbprint() + ?: throw Exception("Failed to compute key alias") + + val signer: DidSigner = { payload -> + keyManager.sign(kid, payload) + } + + return Pair(signer, verificationMethod) + } + + /** + * Converts a `BearerDid` object to a portable format containing the URI and verification methods + * associated with the DID. + * + * This method is useful when you need to represent the key material and metadata associated with + * a DID in format that can be used independently of the specific DID method implementation. It + * extracts both public and private keys from the DID's key manager and organizes them into a + * `PortableDid` structure. + * + * @returns A `PortableDid` containing the URI, DID document, metadata, and optionally private + * keys associated with the `BearerDid`. + */ + public fun export(): PortableDid { + + check(keyManager is KeyExporter) { + "KeyManager must implement KeyExporter to export keys" + } + + val keyExporter = keyManager as KeyExporter + val privateKeys = mutableListOf() + + document.verificationMethod?.forEach { vm -> + val keyAliasResult = runCatching { vm.publicKeyJwk?.computeThumbprint() } + if (keyAliasResult.isSuccess) { + val keyAlias = keyAliasResult.getOrNull() + keyExporter.exportKey(keyAlias!!.toString()).let { key -> + privateKeys.add(key) + } + } + } + + return PortableDid( + uri = this.uri, + document = this.document, + privateKeys = privateKeys, + metadata = mapOf() + ) + } + + public companion object { + + + /** + * Instantiates a [BearerDid] object from a given [PortableDid]. + * + * This method allows for the creation of a `BearerDid` object using a previously created DID's + * key material, DID document, and metadata. + * + * @param portableDid - The PortableDid object to import. + * @param keyManager - Optionally specify an external Key Management System (KMS) used to + * generate keys and sign data. If not given, a new + * [LocalKeyManager] instance will be created and used. + * @returns [BearerDid] object representing the DID formed from the + * provided PortableDid. + */ + @JvmOverloads + public fun import( + portableDid: PortableDid, + keyManager: KeyManager = InMemoryKeyManager() + ): BearerDid { + + check(keyManager is KeyImporter) { + "KeyManager must implement KeyImporter to import keys" + } + + check(portableDid.document.verificationMethod?.size != 0) { + "PortableDID must contain at least one verification method" + } + + val allVerificationMethodsHavePublicKey = + portableDid.document.verificationMethod + ?.all { vm -> vm.publicKeyJwk != null } + ?: false + check(allVerificationMethodsHavePublicKey) { + "Each VerificationMethod must contain a public key in Jwk format." + } + + val did = Did.parse(portableDid.uri) + + for (key in portableDid.privateKeys) { + val keyImporter = keyManager as KeyImporter + keyImporter.importKey(key) + } + + return BearerDid(portableDid.uri, did, keyManager, portableDid.document) + } + } + +} + diff --git a/dids/src/main/kotlin/web5/sdk/dids/did/PortableDid.kt b/dids/src/main/kotlin/web5/sdk/dids/did/PortableDid.kt new file mode 100644 index 000000000..c337f3e68 --- /dev/null +++ b/dids/src/main/kotlin/web5/sdk/dids/did/PortableDid.kt @@ -0,0 +1,23 @@ +package web5.sdk.dids.did + +import web5.sdk.crypto.jwk.Jwk +import web5.sdk.dids.didcore.DidDocument + +/** + * PortableDid is a serializable BearerDid that documents the key material and metadata + * of a Decentralized Identifier (DID) to enable usage of the DID in different contexts. + * + * This format is useful for exporting, saving to a file, or importing a DID across process + * boundaries or between different DID method implementations. + * + * @property uri The URI of the DID. + * @property privateKeys The private keys associated with the PortableDid. + * @property document The DID Document associated with the PortableDid. + * @property metadata Additional metadata associated with the PortableDid. + */ +public class PortableDid( + public val uri: String, + public val privateKeys: List, + public val document: DidDocument, + public val metadata: Map + ) \ No newline at end of file diff --git a/dids/src/main/kotlin/web5/sdk/dids/didcore/DidUri.kt b/dids/src/main/kotlin/web5/sdk/dids/didcore/Did.kt similarity index 96% rename from dids/src/main/kotlin/web5/sdk/dids/didcore/DidUri.kt rename to dids/src/main/kotlin/web5/sdk/dids/didcore/Did.kt index 7b13cdc2f..5249134cd 100644 --- a/dids/src/main/kotlin/web5/sdk/dids/didcore/DidUri.kt +++ b/dids/src/main/kotlin/web5/sdk/dids/didcore/Did.kt @@ -28,7 +28,7 @@ import java.util.regex.Pattern * a specific part of a DID document. * Spec: https://www.w3.org/TR/did-core/#fragment */ -public class DidUri( +public class Did( public val uri: String, public val url: String, public val method: String, @@ -57,7 +57,7 @@ public class DidUri( * @param byteArray * @return DID object */ - public fun unmarshalText(byteArray: ByteArray): DidUri { + public fun unmarshalText(byteArray: ByteArray): Did { return parse(byteArray.toString(Charsets.UTF_8)) } @@ -85,7 +85,7 @@ public class DidUri( * @param didUri The DID URI to parse. * @return DID object */ - public fun parse(didUri: String): DidUri { + public fun parse(didUri: String): Did { val matcher = DID_URI_PATTERN.matcher(didUri) matcher.find() if (!matcher.matches()) { @@ -111,7 +111,7 @@ public class DidUri( val query = matcher.group(7)?.drop(1) val fragment = matcher.group(8)?.drop(1) - return DidUri( + return Did( uri = "did:$method:$id", url = didUri, method = method, diff --git a/dids/src/main/kotlin/web5/sdk/dids/didcore/DIDDocument.kt b/dids/src/main/kotlin/web5/sdk/dids/didcore/DidDocument.kt similarity index 83% rename from dids/src/main/kotlin/web5/sdk/dids/didcore/DIDDocument.kt rename to dids/src/main/kotlin/web5/sdk/dids/didcore/DidDocument.kt index 6c1f5c2cf..e85d10291 100644 --- a/dids/src/main/kotlin/web5/sdk/dids/didcore/DIDDocument.kt +++ b/dids/src/main/kotlin/web5/sdk/dids/didcore/DidDocument.kt @@ -1,10 +1,11 @@ package web5.sdk.dids.didcore import com.fasterxml.jackson.annotation.JsonProperty +import web5.sdk.dids.DidResolvers import java.security.SignatureException /** - * DIDDocument represents a set of data describing the DID subject including mechanisms such as: + * DidDocument represents a set of data describing the DID subject including mechanisms such as: * - cryptographic public keys - used to authenticate itself and prove association with the DID * - services - means of communicating or interacting with the DID subject or * associated entities via one or more service endpoints. @@ -36,7 +37,7 @@ import java.security.SignatureException * @property capabilityInvocation specifies a verification method used by the DID subject to invoke a * cryptographic capability, such as the authorization to update the DID Document. */ -public class DIDDocument( +public class DidDocument( public val id: String, @JsonProperty("@context") public val context: List? = null, @@ -102,7 +103,7 @@ public class DIDDocument( } /** - * Finds the first available assertion method from the [DIDDocument]. When [assertionMethodId] + * Finds the first available assertion method from the [DidDocument]. When [assertionMethodId] * is null, the function will return the first available assertion method. * * @param assertionMethodId The ID of the assertion method to be found @@ -115,23 +116,58 @@ public class DIDDocument( } if (assertionMethodId != null) { + require(assertionMethod.contains(assertionMethodId)) { throw SignatureException("assertion method \"$assertionMethodId\" not found in list of assertion methods") } - } - val assertionMethod: VerificationMethod = - verificationMethod - ?.find { - it.id == (assertionMethodId ?: assertionMethod.first()) - } - ?: throw SignatureException("assertion method \"$assertionMethodId\" not found") + val verificationMethodIds = mutableSetOf( + assertionMethodId + ) + + if (assertionMethodId.startsWith("#")) { + verificationMethodIds.add("${this.id}${assertionMethodId}") + } else if (assertionMethodId.startsWith("did:")) { + val fragment = assertionMethodId.split("#").last() + verificationMethodIds.add("#$fragment") + } else { + throw IllegalArgumentException( + "Invalid assertionMethodId. " + + "Expected assertionMethodId to be a DID URL or fragment, but was $assertionMethodId" + ) + } + + assertionMethod.firstOrNull { + verificationMethodIds.contains(it) + } ?: throw SignatureException( + "Signature verification failed: Expected kid in JWS header to dereference " + + "a DID Document Verification Method with an Assertion verification relationship" + ) - return assertionMethod + val assertionMethod: VerificationMethod = + verificationMethod + ?.find { + it.id == assertionMethodId + } + ?: throw SignatureException("assertion method \"$assertionMethodId\" not found") + + return assertionMethod + + } else { + val assertionMethod: VerificationMethod = + verificationMethod + ?.find { + it.id == assertionMethod.first() + } + ?: throw SignatureException("assertion method not found") + + return assertionMethod + } } + /** - * Builder object to build a DIDDocument. + * Builder object to build a DidDocument. */ public class Builder { @@ -150,17 +186,17 @@ public class DIDDocument( private var capabilityInvocationMethod: MutableList? = null /** - * Adds Id to the DIDDocument. + * Adds Id to the DidDocument. * - * @param id of the DIDDocument + * @param id of the DidDocument * @return Builder object */ public fun id(id: String): Builder = apply { this.id = id } /** - * Sets Context to the DIDDocument. + * Sets Context to the DidDocument. * - * @param context of the DIDDocument + * @param context of the DidDocument * @return Builder object */ public fun context(context: List): Builder = apply { @@ -170,7 +206,7 @@ public class DIDDocument( /** * Sets Controllers. * - * @param controllers to be set on the DIDDocument + * @param controllers to be set on the DidDocument * @return Builder object */ public fun controllers(controllers: List): Builder = apply { this.controller = controllers } @@ -178,7 +214,7 @@ public class DIDDocument( /** * Sets AlsoknownAses. * - * @param alsoKnownAses to be set on the DIDDocument + * @param alsoKnownAses to be set on the DidDocument * @return Builder object */ public fun alsoKnownAses(alsoKnownAses: List): Builder = apply { this.alsoKnownAs = alsoKnownAses } @@ -186,7 +222,7 @@ public class DIDDocument( /** * Sets Services. * - * @param services to be set on the DIDDocument + * @param services to be set on the DidDocument * @return Builder object */ public fun services(services: List?): Builder = apply { this.service = services } @@ -227,7 +263,7 @@ public class DIDDocument( /** * Adds VerificationMethods for a single purpose. * - * @param methodIds a list of VerificationMethodIds to be added to the DIDDocument + * @param methodIds a list of VerificationMethodIds to be added to the DidDocument * @param purpose a single purpose to be associated with the list of VerificationMethods * @return Builder object */ @@ -256,13 +292,13 @@ public class DIDDocument( } /** - * Builds DIDDocument after validating the required fields. + * Builds DidDocument after validating the required fields. * - * @return DIDDocument + * @return DidDocument */ - public fun build(): DIDDocument { + public fun build(): DidDocument { check(id != null) { "ID is required" } - return DIDDocument( + return DidDocument( id!!, context, alsoKnownAs, @@ -279,7 +315,7 @@ public class DIDDocument( } override fun toString(): String { - return "DIDDocument(" + + return "DidDocument(" + "id='$id', " + "context='$context', " + "alsoKnownAs=$alsoKnownAs, " + diff --git a/dids/src/main/kotlin/web5/sdk/dids/didcore/DIDDocumentMetadata.kt b/dids/src/main/kotlin/web5/sdk/dids/didcore/DidDocumentMetadata.kt similarity index 96% rename from dids/src/main/kotlin/web5/sdk/dids/didcore/DIDDocumentMetadata.kt rename to dids/src/main/kotlin/web5/sdk/dids/didcore/DidDocumentMetadata.kt index 301812f26..c2358c819 100644 --- a/dids/src/main/kotlin/web5/sdk/dids/didcore/DIDDocumentMetadata.kt +++ b/dids/src/main/kotlin/web5/sdk/dids/didcore/DidDocumentMetadata.kt @@ -12,7 +12,7 @@ package web5.sdk.dids.didcore * @property equivalentId Alternative ID that can be used interchangeably with the canonical DID. * @property canonicalId The canonical ID of the DID as per method-specific rules. */ -public open class DIDDocumentMetadata( +public open class DidDocumentMetadata( public var created: String? = null, public var updated: String? = null, public var deactivated: Boolean? = null, diff --git a/dids/src/main/kotlin/web5/sdk/dids/didcore/VerificationMethod.kt b/dids/src/main/kotlin/web5/sdk/dids/didcore/VerificationMethod.kt index d022b1a70..95abf716b 100644 --- a/dids/src/main/kotlin/web5/sdk/dids/didcore/VerificationMethod.kt +++ b/dids/src/main/kotlin/web5/sdk/dids/didcore/VerificationMethod.kt @@ -1,10 +1,6 @@ package web5.sdk.dids.didcore -import com.fasterxml.jackson.databind.annotation.JsonDeserialize -import com.fasterxml.jackson.databind.annotation.JsonSerialize -import com.nimbusds.jose.jwk.JWK -import web5.sdk.dids.JWKSerializer -import web5.sdk.dids.JwkDeserializer +import web5.sdk.crypto.jwk.Jwk /** * VerificationMethod expresses verification methods, such as cryptographic @@ -26,9 +22,7 @@ public class VerificationMethod( public val id: String, public val type: String, public val controller: String, - @JsonSerialize(using = JWKSerializer::class) - @JsonDeserialize(using = JwkDeserializer::class) - public val publicKeyJwk: JWK? = null + public val publicKeyJwk: Jwk? = null ) { /** * Checks type of VerificationMethod. @@ -55,7 +49,7 @@ public class VerificationMethod( private var id: String? = null private var type: String? = null private var controller: String? = null - private var publicKeyJwk: JWK? = null + private var publicKeyJwk: Jwk? = null /** @@ -88,7 +82,7 @@ public class VerificationMethod( * @param publicKeyJwk of the VerificationMethod * @return Builder object */ - public fun publicKeyJwk(publicKeyJwk: JWK): Builder = apply { this.publicKeyJwk = publicKeyJwk } + public fun publicKeyJwk(publicKeyJwk: Jwk): Builder = apply { this.publicKeyJwk = publicKeyJwk } /** diff --git a/dids/src/main/kotlin/web5/sdk/dids/extensions/DidExtensions.kt b/dids/src/main/kotlin/web5/sdk/dids/extensions/DidExtensions.kt deleted file mode 100644 index 36440b331..000000000 --- a/dids/src/main/kotlin/web5/sdk/dids/extensions/DidExtensions.kt +++ /dev/null @@ -1,25 +0,0 @@ -package web5.sdk.dids.extensions - -import web5.sdk.crypto.KeyManager -import web5.sdk.dids.Did -import web5.sdk.dids.didcore.DidUri -import web5.sdk.dids.methods.dht.DidDht -import web5.sdk.dids.methods.jwk.DidJwk -import web5.sdk.dids.methods.key.DidKey -import web5.sdk.dids.methods.web.DidWeb - -internal val supportedMethods = mapOf( - DidKey.methodName to DidKey.Companion, - DidJwk.methodName to DidJwk.Companion, - DidDht.methodName to DidDht.Default, - DidWeb.methodName to DidWeb.Default -) - -/** - * Creates the appropriate instance for [didUri]. This function validates that all the key material needed for - * signing and managing the passed in [didUri] exists within the provided [keyManager]. This function is meant - * to be used when the method of the DID is unknown. - */ -public fun Did.Companion.load(didUri: String, keyManager: KeyManager): Did { - return supportedMethods.getValue(DidUri.parse(didUri).method).load(didUri, keyManager) -} \ No newline at end of file diff --git a/dids/src/main/kotlin/web5/sdk/dids/methods/dht/DhtClient.kt b/dids/src/main/kotlin/web5/sdk/dids/methods/dht/DhtClient.kt index a63331370..1fafc1281 100644 --- a/dids/src/main/kotlin/web5/sdk/dids/methods/dht/DhtClient.kt +++ b/dids/src/main/kotlin/web5/sdk/dids/methods/dht/DhtClient.kt @@ -1,6 +1,5 @@ package web5.sdk.dids.methods.dht -import com.nimbusds.jose.jwk.JWK import io.ktor.client.HttpClient import io.ktor.client.engine.HttpClientEngine import io.ktor.client.engine.okhttp.OkHttp @@ -122,7 +121,7 @@ internal class DhtClient( * the current time in milliseconds. * * @param manager The key manager to use to sign the message [KeyManager]. - * @param keyAlias The alias of an Ed25519 key to sign the message with [JWK]. + * @param keyAlias The alias of an Ed25519 key to sign the message with [String]. * @param message The message to publish (a DNS packet) [Message]. * @return A BEP44 signed message [Bep44Message]. * @throws IllegalArgumentException if the private key is not an Ed25519 key. @@ -131,7 +130,7 @@ internal class DhtClient( fun createBep44PutRequest(manager: KeyManager, keyAlias: String, message: Message): Bep44Message { // get the public key to verify it is an Ed25519 key val pubKey = manager.getPublicKey(keyAlias) - val curve = pubKey.toJSONObject()["crv"] + val curve = pubKey.crv require(curve == Ed25519.curve.name) { "Must supply an Ed25519 key" } @@ -171,7 +170,7 @@ internal class DhtClient( * https://www.bittorrent.org/beps/bep_0044.html * * @param manager The key manager to use to sign the message [KeyManager]. - * @param keyAlias The alias of an Ed25519 key to sign the message with [JWK]. + * @param keyAlias The alias of an Ed25519 key to sign the message with [String]. * @param seq The sequence number of the message. * @param v The value to be written to the DHT. * @return A BEP44 signed message [Bep44Message]. @@ -183,7 +182,7 @@ internal class DhtClient( // get the public key to verify it is an Ed25519 key val pubKey = manager.getPublicKey(keyAlias) - val curve = pubKey.toJSONObject()["crv"] + val curve = pubKey.crv require(curve == Ed25519.curve.name) { "Must supply an Ed25519 key" } @@ -234,7 +233,7 @@ internal class DhtClient( // prepare buffer and verify val bytesToVerify = "3:seqi${message.seq}e1:v".toByteArray() + vEncoded - // create a JWK representation of the public key + // create a Jwk representation of the public key val ed25519PublicKey = Ed25519.bytesToPublicKey(message.k) // verify the signature diff --git a/dids/src/main/kotlin/web5/sdk/dids/methods/dht/DidDht.kt b/dids/src/main/kotlin/web5/sdk/dids/methods/dht/DidDht.kt index 6e24f38cf..5130be645 100644 --- a/dids/src/main/kotlin/web5/sdk/dids/methods/dht/DidDht.kt +++ b/dids/src/main/kotlin/web5/sdk/dids/methods/dht/DidDht.kt @@ -1,7 +1,5 @@ package web5.sdk.dids.methods.dht -import com.nimbusds.jose.JWSAlgorithm -import com.nimbusds.jose.jwk.JWK import io.github.oshai.kotlinlogging.KotlinLogging import io.ktor.client.engine.HttpClientEngine import io.ktor.client.engine.okhttp.OkHttp @@ -16,17 +14,19 @@ import web5.sdk.common.ZBase32 import web5.sdk.crypto.AlgorithmId import web5.sdk.crypto.Crypto import web5.sdk.crypto.Ed25519 +import web5.sdk.crypto.InMemoryKeyManager +import web5.sdk.crypto.Jwa import web5.sdk.crypto.KeyManager import web5.sdk.crypto.Secp256k1 +import web5.sdk.crypto.jwk.Jwk import web5.sdk.dids.CreateDidOptions -import web5.sdk.dids.Did -import web5.sdk.dids.DidMethod import web5.sdk.dids.DidResolutionResult import web5.sdk.dids.ResolutionError -import web5.sdk.dids.ResolveDidOptions -import web5.sdk.dids.didcore.DidUri -import web5.sdk.dids.didcore.DIDDocument -import web5.sdk.dids.didcore.DIDDocumentMetadata +import web5.sdk.dids.did.BearerDid +import web5.sdk.dids.did.PortableDid +import web5.sdk.dids.didcore.Did +import web5.sdk.dids.didcore.DidDocument +import web5.sdk.dids.didcore.DidDocumentMetadata import web5.sdk.dids.didcore.Purpose import web5.sdk.dids.didcore.Service import web5.sdk.dids.didcore.VerificationMethod @@ -36,18 +36,6 @@ import web5.sdk.dids.exceptions.InvalidMethodNameException import web5.sdk.dids.exceptions.ParserException import web5.sdk.dids.exceptions.PkarrRecordNotFoundException import web5.sdk.dids.exceptions.PublicKeyJwkMissingException -import web5.sdk.dids.validateKeyMaterialInsideKeyManager - -/** - * Configuration for the [DidDhtApi]. - * - * @property gateway The DID DHT gateway URL. - * @property engine The engine to use. When absent, a new one will be created from the [OkHttp] factory. - */ -public class DidDhtConfiguration internal constructor( - public val gateway: String = "https://diddht.tbddev.org", - public var engine: HttpClientEngine = OkHttp.create {}, -) /** * Type indexing types as per https://tbd54566975.github.io/did-dht-method/#type-indexing @@ -70,6 +58,17 @@ public enum class DidDhtTypeIndexing(public val index: Int) { } } +/** + * Configuration for the [DidDhtApi]. + * + * @property gateway The DID DHT gateway URL. + * @property engine The engine to use. When absent, a new one will be created from the [OkHttp] factory. + */ +public class DidDhtConfiguration internal constructor( + public val gateway: String = "https://diddht.tbddev.org", + public var engine: HttpClientEngine = OkHttp.create {}, +) + /** * Returns a [DidDhtApi] after applying [configurationBlock] on the default [DidDhtConfiguration]. */ @@ -83,7 +82,7 @@ private class DidDhtApiImpl(configuration: DidDhtConfiguration) : DidDhtApi(conf /** * Specifies options for creating a new "did:dht" Decentralized Identifier (DID). - * @property verificationMethods A list of [JWK]s to add to the DID Document mapped to their purposes + * @property verificationMethods A list of [Jwk]s to add to the DID Document mapped to their purposes * as verification methods, and an optional controller for the verification method. * @property services A list of [Service]s to add to the DID Document. * @property publish Whether to publish the DID Document to the DHT after creation. @@ -91,7 +90,7 @@ private class DidDhtApiImpl(configuration: DidDhtConfiguration) : DidDhtApi(conf * @property alsoKnownAses A list of also known as identifiers to add to the DID Document. */ public class CreateDidDhtOptions( - public val verificationMethods: Iterable, String?>>? = null, + public val verificationMethods: Iterable, String?>>? = null, public val services: Iterable? = null, public val publish: Boolean = true, public val controllers: Iterable? = null, @@ -99,21 +98,20 @@ public class CreateDidDhtOptions( ) : CreateDidOptions private const val PROPERTY_SEPARATOR = ";" - private const val ARRAY_SEPARATOR = "," - private val logger = KotlinLogging.logger {} + /** * Base class for managing DID DHT operations. Uses the given [DidDhtConfiguration]. */ -public sealed class DidDhtApi(configuration: DidDhtConfiguration) : DidMethod { +public sealed class DidDhtApi(configuration: DidDhtConfiguration) { private val engine: HttpClientEngine = configuration.engine private val dhtClient = DhtClient(configuration.gateway, engine) private val ttl: Long = 7200 - override val methodName: String = "dht" + public val methodName: String = "dht" /** * Creates a new "did:dht" DID, derived from an initial identity key, and stores the associated private key in the @@ -128,7 +126,8 @@ public sealed class DidDhtApi(configuration: DidDhtConfiguration) : DidMethod @@ -146,15 +145,15 @@ public sealed class DidDhtApi(configuration: DidDhtConfiguration) : DidMethod + opts.verificationMethods?.map { (publicKey, purposes, controller) -> VerificationMethod.Builder() - .id("$id#${key.keyID}") + .id("$didUri#${publicKey.kid ?: publicKey.computeThumbprint()}") .type("JsonWebKey") - .controller(controller ?: id) - .publicKeyJwk(key.toPublicJWK()) + .controller(controller ?: didUri) + .publicKeyJwk(publicKey) .build().also { verificationMethod -> didDocumentBuilder.verificationMethodForPurposes(verificationMethod, purposes.toList()) } } - val didDocument = didDocumentBuilder.build() // publish to DHT if requested @@ -199,7 +197,9 @@ public sealed class DidDhtApi(configuration: DidDhtConfiguration) : DidMethod + vm.id.split("#").last() == "0" + } ?: false + + check(containsOneVmWithFragmentId0) { + "DidDht DID document must contain at least one verification method with fragment of 0" + } + return bearerDid + } + private fun resolveInternal(did: String): DidResolutionResult { try { validate(did) @@ -245,22 +282,22 @@ public sealed class DidDhtApi(configuration: DidDhtConfiguration) : DidMethod return DidResolutionResult( didDocument = didDocument, - didDocumentMetadata = DIDDhtDocumentMetadata(types = types.map { it.index }) + didDocumentMetadata = DidDhtDocumentMetadata(types = types.map { it.index }) ) } } /** - * Publishes a [DIDDocument] to the DHT. + * Publishes a [DidDocument] to the DHT. * * @param manager The [KeyManager] instance to use for signing the message. - * @param didDocument The [DIDDocument] to publish. + * @param didDocument The [DidDocument] to publish. * @param types A list of types to include in the packet. * @throws IllegalArgumentException if the provided DID does not conform to the "did:dht" method. * @throws Exception if the message is not successfully put to the DHT. */ @JvmOverloads - public fun publish(manager: KeyManager, didDocument: DIDDocument, types: List? = null) { + public fun publish(manager: KeyManager, didDocument: DidDocument, types: List? = null) { validate(didDocument.id) val publishId = DidDht.suffix(didDocument.id) @@ -272,11 +309,11 @@ public sealed class DidDhtApi(configuration: DidDhtConfiguration) : DidMethod? = null): Message { + internal fun toDnsPacket(didDocument: DidDocument, types: List? = null): Message { val message = Message(0).apply { header.setFlag(5) } // Set authoritative answer flag // Add Resource Records for each Verification Method @@ -434,23 +477,23 @@ public sealed class DidDhtApi(configuration: DidDhtConfiguration) : DidMethod, Map> { val verificationMethodsById = mutableMapOf() val verificationMethods = buildList { didDocument.verificationMethod?.forEachIndexed { i, verificationMethod -> val publicKeyJwk = verificationMethod.publicKeyJwk ?: throw PublicKeyJwkMissingException("publicKeyJwk is null") val publicKeyBytes = Crypto.publicKeyToBytes(publicKeyJwk) - val base64UrlEncodedKey = Convert(publicKeyBytes).toBase64Url(padding = false) + val base64UrlEncodedKey = Convert(publicKeyBytes).toBase64Url() val verificationMethodId = "k$i" verificationMethodsById[verificationMethod.id] = verificationMethodId - val keyType = when (publicKeyJwk.algorithm) { - JWSAlgorithm.EdDSA -> 0 - JWSAlgorithm.ES256K -> 1 - JWSAlgorithm.ES256 -> 2 - else -> throw IllegalArgumentException("unsupported algorithm: ${publicKeyJwk.algorithm}") + // TODO support Jwa.ES256 alg https://github.com/TBD54566975/web5-kt/issues/272 + val keyType = when (publicKeyJwk.alg) { + Jwa.EdDSA.name -> 0 + Jwa.ES256K.name -> 1 + else -> throw IllegalArgumentException("unsupported algorithm: ${publicKeyJwk.alg}") } message.addRecord( @@ -459,7 +502,7 @@ public sealed class DidDhtApi(configuration: DidDhtConfiguration) : DidMethod> { - val doc = DIDDocument.Builder().id(did) + internal fun fromDnsPacket(did: String, msg: Message): Pair> { + val doc = DidDocument.Builder().id(did) val services = mutableListOf() val types = mutableListOf() @@ -568,12 +611,12 @@ public sealed class DidDhtApi(configuration: DidDhtConfiguration) : DidMethod, name: String, - didDocBuilder: DIDDocument.Builder + didDocBuilder: DidDocument.Builder ) { val data = parseTxtData(rr.strings.joinToString("")) val verificationMethodId = data["id"]!! val keyBytes = Convert(data["k"]!!, EncodingFormat.Base64Url).toByteArray() - // TODO(gabe): support other key types + // TODO(gabe): support other key types https://github.com/TBD54566975/web5-kt/issues/272 val publicKeyJwk = when (data["t"]!!) { "0" -> Ed25519.bytesToPublicKey(keyBytes) "1" -> Secp256k1.bytesToPublicKey(keyBytes) @@ -599,7 +642,7 @@ public sealed class DidDhtApi(configuration: DidDhtConfiguration) : DidMethod, - doc: DIDDocument.Builder, + doc: DidDocument.Builder, ) { val rootData = rr.strings.joinToString(PROPERTY_SEPARATOR).split(PROPERTY_SEPARATOR) @@ -667,41 +710,20 @@ public sealed class DidDhtApi(configuration: DidDhtConfiguration) : DidMethod? = emptyList()): Message { + public fun toDnsPacket(didDocument: DidDocument, types: List? = emptyList()): Message { return DidDht.toDnsPacket(didDocument, types) } @@ -724,7 +746,7 @@ public class DidDht( * Calls [DidDht.fromDnsPacket] with the provided [did] and [msg] and returns the result. */ @JvmOverloads - public fun fromDnsPacket(did: String = this.uri, msg: Message): Pair> { + public fun fromDnsPacket(did: String = this.uri, msg: Message): Pair> { return DidDht.fromDnsPacket(did, msg) } diff --git a/dids/src/main/kotlin/web5/sdk/dids/methods/dht/DIDDhtDocumentMetadata.kt b/dids/src/main/kotlin/web5/sdk/dids/methods/dht/DidDhtDocumentMetadata.kt similarity index 69% rename from dids/src/main/kotlin/web5/sdk/dids/methods/dht/DIDDhtDocumentMetadata.kt rename to dids/src/main/kotlin/web5/sdk/dids/methods/dht/DidDhtDocumentMetadata.kt index fb163ace5..45ed6d1c2 100644 --- a/dids/src/main/kotlin/web5/sdk/dids/methods/dht/DIDDhtDocumentMetadata.kt +++ b/dids/src/main/kotlin/web5/sdk/dids/methods/dht/DidDhtDocumentMetadata.kt @@ -1,6 +1,6 @@ package web5.sdk.dids.methods.dht -import web5.sdk.dids.didcore.DIDDocumentMetadata +import web5.sdk.dids.didcore.DidDocumentMetadata /** * Did document metadata for did:dht that extends the base did document metadata. @@ -8,6 +8,6 @@ import web5.sdk.dids.didcore.DIDDocumentMetadata * @property types list of types * @constructor Create empty Did dht document metadata */ -public class DIDDhtDocumentMetadata( +public class DidDhtDocumentMetadata( public val types: List? = null -) : DIDDocumentMetadata() +) : DidDocumentMetadata() diff --git a/dids/src/main/kotlin/web5/sdk/dids/methods/jwk/DidJwk.kt b/dids/src/main/kotlin/web5/sdk/dids/methods/jwk/DidJwk.kt index ecdd614b9..ff24505ef 100644 --- a/dids/src/main/kotlin/web5/sdk/dids/methods/jwk/DidJwk.kt +++ b/dids/src/main/kotlin/web5/sdk/dids/methods/jwk/DidJwk.kt @@ -1,43 +1,25 @@ package web5.sdk.dids.methods.jwk -import com.nimbusds.jose.jwk.JWK -import com.nimbusds.jose.jwk.KeyUse import web5.sdk.common.Convert import web5.sdk.common.EncodingFormat +import web5.sdk.common.Json import web5.sdk.crypto.AlgorithmId +import web5.sdk.crypto.InMemoryKeyManager import web5.sdk.crypto.KeyManager -import web5.sdk.dids.CreateDidOptions -import web5.sdk.dids.Did -import web5.sdk.dids.DidMethod +import web5.sdk.crypto.jwk.Jwk import web5.sdk.dids.DidResolutionMetadata import web5.sdk.dids.DidResolutionResult import web5.sdk.dids.ResolutionError -import web5.sdk.dids.ResolveDidOptions -import web5.sdk.dids.didcore.DidUri -import web5.sdk.dids.didcore.DIDDocument +import web5.sdk.dids.did.BearerDid +import web5.sdk.dids.did.PortableDid +import web5.sdk.dids.didcore.Did +import web5.sdk.dids.didcore.DidDocument import web5.sdk.dids.didcore.Purpose import web5.sdk.dids.didcore.VerificationMethod +import web5.sdk.dids.exceptions.InvalidMethodNameException import web5.sdk.dids.exceptions.ParserException -import web5.sdk.dids.validateKeyMaterialInsideKeyManager import java.text.ParseException -/** - * Specifies options for creating a new "did:jwk" Decentralized Identifier (DID). - * - * @property algorithmId Specifies the algorithmId to be used for key creation. - * Defaults to ES256K (Elliptic Curve Digital Signature Algorithm with SHA-256 and secp256k1 curve). - * @constructor Creates an instance of [CreateDidJwkOptions] with the provided [algorithmId] - * - * ### Usage Example: - * ``` - * val options = CreateDidJwkOptions(algorithm = JWSAlgorithm.ES256K, curve = null) - * val didJwk = DidJwk.create(keyManager, options) - * ``` - */ -public class CreateDidJwkOptions( - public val algorithmId: AlgorithmId = AlgorithmId.secp256k1, -) : CreateDidOptions - /** * Provides a specific implementation for creating and resolving "did:jwk" method Decentralized Identifiers (DIDs). * @@ -46,151 +28,154 @@ public class CreateDidJwkOptions( * to be self-verifiable by third parties. This eradicates the necessity for a separate blockchain or ledger. * Further specifics and technical details are outlined in [the DID Jwk Spec](https://example.org/did-method-jwk/). * - * @property uri The URI of the "did:jwk" which conforms to the DID standard. - * @property keyManager A [KeyManager] instance utilized to manage the cryptographic keys associated with the DID. - * - * @constructor Initializes a new instance of [DidJwk] with the provided [uri] and [keyManager]. */ -public class DidJwk(uri: String, keyManager: KeyManager) : Did(uri, keyManager) { +public object DidJwk { + + public const val methodName: String = "jwk" + + /** + * Creates a new "did:jwk" DID, derived from a public key, and stores the associated private key in the + * provided [keyManager]. + * + * The method-specific identifier of a "did:jwk" DID is a base64url encoded json web key serialized as a UTF-8 + * string. + * + * **Note**: Defaults to ES256K if no options are provided + * + * @param keyManager A [keyManager] instance where the new key will be stored. + * @return A [DidJwk] instance representing the newly created "did:jwk" DID. + * + * @throws UnsupportedOperationException if the specified curve is not supported. + */ + @JvmOverloads + public fun create( + keyManager: KeyManager = InMemoryKeyManager(), + algorithmId: AlgorithmId = AlgorithmId.Ed25519): BearerDid { + + val keyAlias = keyManager.generatePrivateKey(algorithmId) + val publicKeyJwk = keyManager.getPublicKey(keyAlias) + + val base64Encoded = Convert(Json.stringify(publicKeyJwk)).toBase64Url() + + val didUri = "did:jwk:$base64Encoded" + + val did = Did(method = methodName, uri = didUri, url = didUri, id = base64Encoded) + + return BearerDid(didUri, did, keyManager, createDocument(did, publicKeyJwk)) + + } + /** - * Resolves the current instance's [uri] to a [DidResolutionResult], which contains the DID Document - * and possible related metadata. + * Instantiates a [BearerDid] object for the DID JWK method from a given [PortableDid]. + * + * This method allows for the creation of a `BearerDid` object using a previously created DID's + * key material, DID document, and metadata. * + * @param portableDid - The PortableDid object to import. + * @param keyManager - Optionally specify an external Key Management System (KMS) used to + * generate keys and sign data. If not given, a new + * [InMemoryKeyManager] instance will be created and + * used. + * @returns a BearerDid object representing the DID formed from the + * provided PortableDid. + * @throws InvalidMethodNameException if importing incorrect DID method + */ + public fun import(portableDid: PortableDid, keyManager: KeyManager = InMemoryKeyManager()): BearerDid { + val parsedDid = Did.parse(portableDid.uri) + if (parsedDid.method != methodName) { + throw InvalidMethodNameException("Method not supported") + } + val bearerDid = BearerDid.import(portableDid, keyManager) + + check(bearerDid.document.verificationMethod?.size != 0) { + "DidJwk DID document must contain exactly one verification method" + } + return bearerDid + } + + /** + * Resolves a "did:jwk" DID into a [DidResolutionResult], which contains the DID Document and possible related metadata. + * + * This implementation primarily constructs a DID Document with a single verification method derived + * from the DID's method-specific identifier (the public key). + * + * @param did The "did:jwk" DID that needs to be resolved. * @return A [DidResolutionResult] instance containing the DID Document and related context. * * @throws IllegalArgumentException if the provided DID does not conform to the "did:jwk" method. */ - public fun resolve(): DidResolutionResult { - return resolve(this.uri) - } + public fun resolve(did: String): DidResolutionResult { + val parsedDid = try { + Did.parse(did) + } catch (_: ParserException) { + return DidResolutionResult( + context = "https://w3id.org/did-resolution/v1", + didResolutionMetadata = DidResolutionMetadata( + error = ResolutionError.INVALID_DID.value, + ), + ) + } - public companion object : DidMethod { - override val methodName: String = "jwk" - - /** - * Creates a new "did:jwk" DID, derived from a public key, and stores the associated private key in the - * provided [KeyManager]. - * - * The method-specific identifier of a "did:jwk" DID is a base64url encoded json web key serialized as a UTF-8 - * string. - * - * **Note**: Defaults to ES256K if no options are provided - * - * @param keyManager A [KeyManager] instance where the new key will be stored. - * @param options Optional parameters ([CreateDidJwkOptions]) to specify algorithmId during key creation. - * @return A [DidJwk] instance representing the newly created "did:jwk" DID. - * - * @throws UnsupportedOperationException if the specified curve is not supported. - */ - override fun create(keyManager: KeyManager, options: CreateDidJwkOptions?): DidJwk { - val opts = options ?: CreateDidJwkOptions() - - val keyAlias = keyManager.generatePrivateKey(opts.algorithmId) - val publicKey = keyManager.getPublicKey(keyAlias) - - val base64Encoded = Convert(publicKey.toJSONString()).toBase64Url(padding = false) - - val did = "did:jwk:$base64Encoded" - - return DidJwk(did, keyManager) + if (parsedDid.method != methodName) { + return DidResolutionResult( + context = "https://w3id.org/did-resolution/v1", + didResolutionMetadata = DidResolutionMetadata( + error = ResolutionError.METHOD_NOT_SUPPORTED.value, + ), + ) } - /** - * Resolves a "did:jwk" DID into a [DidResolutionResult], which contains the DID Document and possible related metadata. - * - * This implementation primarily constructs a DID Document with a single verification method derived - * from the DID's method-specific identifier (the public key). - * - * @param did The "did:jwk" DID that needs to be resolved. - * @return A [DidResolutionResult] instance containing the DID Document and related context. - * - * @throws IllegalArgumentException if the provided DID does not conform to the "did:jwk" method. - */ - override fun resolve(did: String, options: ResolveDidOptions?): DidResolutionResult { - val parsedDidUri = try { - DidUri.parse(did) - } catch (_: ParserException) { - return DidResolutionResult( - context = "https://w3id.org/did-resolution/v1", - didResolutionMetadata = DidResolutionMetadata( - error = ResolutionError.INVALID_DID.value, - ), - ) - } - - if (parsedDidUri.method != methodName) { - return DidResolutionResult( - context = "https://w3id.org/did-resolution/v1", - didResolutionMetadata = DidResolutionMetadata( - error = ResolutionError.METHOD_NOT_SUPPORTED.value, - ), + val id = parsedDid.id + val decodedKey = Convert(id, EncodingFormat.Base64Url).toStr() + val publicKeyJwk = try { + Json.parse(decodedKey) + } catch (_: Exception) { + return DidResolutionResult( + context = "https://w3id.org/did-resolution/v1", + didResolutionMetadata = DidResolutionMetadata( + error = ResolutionError.INVALID_DID.value ) - } - - val id = parsedDidUri.id - val decodedKey = Convert(id, EncodingFormat.Base64Url).toStr() - val publicKeyJwk = try { - JWK.parse(decodedKey) - } catch (_: ParseException) { - return DidResolutionResult( - context = "https://w3id.org/did-resolution/v1", - didResolutionMetadata = DidResolutionMetadata( - error = ResolutionError.INVALID_DID.value - ) - ) - } - - require(!publicKeyJwk.isPrivate) { - throw IllegalArgumentException("decoded jwk value cannot be a private key") - } - - val verificationMethodId = "${parsedDidUri.uri}#0" - val verificationMethod = VerificationMethod.Builder() - .id(verificationMethodId) - .publicKeyJwk(publicKeyJwk) - .controller(did) - .type("JsonWebKey") - .build() - - val didDocumentBuilder = DIDDocument.Builder() - .context(listOf("https://www.w3.org/ns/did/v1")) - .id(did) - - if (publicKeyJwk.keyUse != KeyUse.ENCRYPTION) { - didDocumentBuilder - .verificationMethodForPurposes( - verificationMethod, - listOf( - Purpose.AssertionMethod, - Purpose.Authentication, - Purpose.CapabilityDelegation, - Purpose.CapabilityInvocation - ) - ) - } - - if (publicKeyJwk.keyUse != KeyUse.SIGNATURE) { - didDocumentBuilder.verificationMethodForPurposes(verificationMethod, listOf(Purpose.KeyAgreement)) - } - val didDocument = didDocumentBuilder.build() + ) + } - return DidResolutionResult(didDocument = didDocument, context = "https://w3id.org/did-resolution/v1") + require(publicKeyJwk.d == null) { + "decoded jwk value cannot be a private key" } + val didDocument = createDocument(parsedDid, publicKeyJwk) + + return DidResolutionResult(didDocument = didDocument, context = "https://w3id.org/did-resolution/v1") + } - /** - * Instantiates a [DidJwk] instance from [uri] (which has to start with "did:jwk:"), and validates that the - * associated key material exists in the provided [keyManager]. - * - * ### Usage Example: - * ```kotlin - * val keyManager = InMemoryKeyManager() - * val did = DidJwk.load("did:jwk:example", keyManager) - * ``` - */ - override fun load(uri: String, keyManager: KeyManager): DidJwk { - validateKeyMaterialInsideKeyManager(uri, keyManager) - return DidJwk(uri, keyManager) + private fun createDocument(did: Did, publicKeyJwk: Jwk): DidDocument { + val verificationMethodId = "${did.uri}#0" + val verificationMethod = VerificationMethod.Builder() + .id(verificationMethodId) + .publicKeyJwk(publicKeyJwk) + .controller(did.url) + .type("JsonWebKey") + .build() + + val didDocumentBuilder = DidDocument.Builder() + .context(listOf("https://www.w3.org/ns/did/v1")) + .id(did.url) + if (publicKeyJwk.use != "enc") { + didDocumentBuilder + .verificationMethodForPurposes( + verificationMethod, + listOf( + Purpose.AssertionMethod, + Purpose.Authentication, + Purpose.CapabilityDelegation, + Purpose.CapabilityInvocation + ) + ) } + + if (publicKeyJwk.use != "sig") { + didDocumentBuilder.verificationMethodForPurposes(verificationMethod, listOf(Purpose.KeyAgreement)) + } + return didDocumentBuilder.build() } -} + +} \ No newline at end of file diff --git a/dids/src/main/kotlin/web5/sdk/dids/methods/key/DidKey.kt b/dids/src/main/kotlin/web5/sdk/dids/methods/key/DidKey.kt index c2e78532e..2876c27b0 100644 --- a/dids/src/main/kotlin/web5/sdk/dids/methods/key/DidKey.kt +++ b/dids/src/main/kotlin/web5/sdk/dids/methods/key/DidKey.kt @@ -1,21 +1,22 @@ package web5.sdk.dids.methods.key import io.ipfs.multibase.Multibase +import org.apache.http.MethodNotSupportedException import web5.sdk.common.Varint import web5.sdk.crypto.AlgorithmId import web5.sdk.crypto.Crypto +import web5.sdk.crypto.InMemoryKeyManager import web5.sdk.crypto.KeyManager import web5.sdk.crypto.Secp256k1 import web5.sdk.dids.CreateDidOptions -import web5.sdk.dids.Did -import web5.sdk.dids.DidMethod import web5.sdk.dids.DidResolutionResult -import web5.sdk.dids.ResolveDidOptions -import web5.sdk.dids.didcore.DidUri -import web5.sdk.dids.didcore.DIDDocument +import web5.sdk.dids.did.BearerDid +import web5.sdk.dids.did.PortableDid +import web5.sdk.dids.didcore.Did +import web5.sdk.dids.didcore.DidDocument import web5.sdk.dids.didcore.Purpose import web5.sdk.dids.didcore.VerificationMethod -import web5.sdk.dids.validateKeyMaterialInsideKeyManager +import web5.sdk.dids.exceptions.InvalidMethodNameException /** * Specifies options for creating a new "did:key" Decentralized Identifier (DID). @@ -31,7 +32,7 @@ import web5.sdk.dids.validateKeyMaterialInsideKeyManager * ``` */ public class CreateDidKeyOptions( - public val algorithmId: AlgorithmId = AlgorithmId.secp256k1, + public val algorithmId: AlgorithmId = AlgorithmId.Ed25519, ) : CreateDidOptions /** @@ -47,7 +48,7 @@ public class CreateDidKeyOptions( * * @constructor Initializes a new instance of [DidKey] with the provided [uri] and [keyManager]. */ -public class DidKey(uri: String, keyManager: KeyManager) : Did(uri, keyManager) { +public class DidKey(public val uri: String, public val keyManager: KeyManager) { /** * Resolves the current instance's [uri] to a [DidResolutionResult], which contains the DID Document * and possible related metadata. @@ -60,8 +61,8 @@ public class DidKey(uri: String, keyManager: KeyManager) : Did(uri, keyManager) return resolve(this.uri) } - public companion object : DidMethod { - override val methodName: String = "key" + public companion object { + public const val methodName: String = "key" /** * Creates a new "did:key" DID, derived from a public key, and stores the associated private key in the @@ -77,7 +78,8 @@ public class DidKey(uri: String, keyManager: KeyManager) : Did(uri, keyManager) * * @throws UnsupportedOperationException if the specified curve is not supported. */ - override fun create(keyManager: KeyManager, options: CreateDidKeyOptions?): DidKey { + @JvmOverloads + public fun create(keyManager: KeyManager, options: CreateDidKeyOptions? = null): BearerDid { val opts = options ?: CreateDidKeyOptions() val keyAlias = keyManager.generatePrivateKey(opts.algorithmId) @@ -95,24 +97,14 @@ public class DidKey(uri: String, keyManager: KeyManager) : Did(uri, keyManager) val idBytes = multiCodecBytes + publicKeyBytes val multibaseEncodedId = Multibase.encode(Multibase.Base.Base58BTC, idBytes) - val did = "did:key:$multibaseEncodedId" + val didUrl = "did:key:$multibaseEncodedId" - return DidKey(did, keyManager) - } - - /** - * Instantiates a [DidKey] instance from [uri] (which has to start with "did:key:"), and validates that the - * associated key material exists in the provided [keyManager]. - * - * ### Usage Example: - * ```kotlin - * val keyManager = InMemoryKeyManager() - * val did = DidKey.load("did:key:example", keyManager) - * ``` - */ - override fun load(uri: String, keyManager: KeyManager): DidKey { - validateKeyMaterialInsideKeyManager(uri, keyManager) - return DidKey(uri, keyManager) + val did = Did(method = methodName, uri = didUrl, url = didUrl, id = multibaseEncodedId) + val resolutionResult = resolve(didUrl) + check(resolutionResult.didDocument != null) { + "DidDocument not found" + } + return BearerDid(didUrl, did, keyManager, resolutionResult.didDocument) } /** @@ -126,12 +118,12 @@ public class DidKey(uri: String, keyManager: KeyManager) : Did(uri, keyManager) * * @throws IllegalArgumentException if the provided DID does not conform to the "did:key" method. */ - override fun resolve(did: String, options: ResolveDidOptions?): DidResolutionResult { - val parsedDidUri = DidUri.parse(did) + public fun resolve(did: String): DidResolutionResult { + val parsedDid = Did.parse(did) - require(parsedDidUri.method == methodName) { throw IllegalArgumentException("expected did:key") } + require(parsedDid.method == methodName) { throw IllegalArgumentException("expected did:key") } - val id = parsedDidUri.id + val id = parsedDid.id val idBytes = Multibase.decode(id) val (multiCodec, numBytes) = Varint.decode(idBytes) @@ -144,15 +136,15 @@ public class DidKey(uri: String, keyManager: KeyManager) : Did(uri, keyManager) val publicKeyJwk = keyGenerator.bytesToPublicKey(publicKeyBytes) - val verificationMethodId = "${parsedDidUri.uri}#$id" + val verificationMethodId = "${parsedDid.uri}#$id" val verificationMethod = VerificationMethod.Builder() .id(verificationMethodId) .publicKeyJwk(publicKeyJwk) .controller(did) - .type("JsonWebKey2020") + .type("JsonWebKey") .build() - val didDocument = DIDDocument.Builder() + val didDocument = DidDocument.Builder() .id(did) .verificationMethodForPurposes( verificationMethod, @@ -167,6 +159,37 @@ public class DidKey(uri: String, keyManager: KeyManager) : Did(uri, keyManager) return DidResolutionResult(didDocument = didDocument, context = "https://w3id.org/did-resolution/v1") } + + /** + * Instantiates a [BearerDid] object for the DID KEY method from a given [PortableDid]. + * + * This method allows for the creation of a `BearerDid` object using a previously created DID's + * key material, DID document, and metadata. + * + * @param portableDid - The PortableDid object to import. + * @param keyManager - Optionally specify an external Key Management System (KMS) used to + * generate keys and sign data. If not given, a new + * [InMemoryKeyManager] instance will be created and + * used. + * @returns a BearerDid object representing the DID formed from the + * provided PortableDid. + * @throws InvalidMethodNameException if importing incorrect DID method + */ + @JvmOverloads + public fun import(portableDid: PortableDid, keyManager: KeyManager = InMemoryKeyManager()): BearerDid { + val parsedDid = Did.parse(portableDid.uri) + if (parsedDid.method != methodName) { + throw InvalidMethodNameException("Method not supported") + } + + val bearerDid = BearerDid.import(portableDid, keyManager) + + check(bearerDid.document.verificationMethod?.size == 1) { + "DidKey DID document must contain exactly one verification method" + } + + return bearerDid + } } } diff --git a/dids/src/main/kotlin/web5/sdk/dids/methods/web/DidWeb.kt b/dids/src/main/kotlin/web5/sdk/dids/methods/web/DidWeb.kt index 8d6521078..7751bedfe 100644 --- a/dids/src/main/kotlin/web5/sdk/dids/methods/web/DidWeb.kt +++ b/dids/src/main/kotlin/web5/sdk/dids/methods/web/DidWeb.kt @@ -1,6 +1,5 @@ package web5.sdk.dids.methods.web -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import io.github.oshai.kotlinlogging.KotlinLogging import io.ktor.client.engine.HttpClientEngine import io.ktor.client.engine.okhttp.OkHttp @@ -19,17 +18,16 @@ import okhttp3.Cache import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.OkHttpClient import okhttp3.dnsoverhttps.DnsOverHttps +import web5.sdk.common.Json +import web5.sdk.crypto.InMemoryKeyManager import web5.sdk.crypto.KeyManager -import web5.sdk.dids.CreateDidOptions -import web5.sdk.dids.didcore.DidUri -import web5.sdk.dids.didcore.DIDDocument -import web5.sdk.dids.Did -import web5.sdk.dids.DidMethod +import web5.sdk.dids.didcore.Did +import web5.sdk.dids.didcore.DidDocument import web5.sdk.dids.DidResolutionResult import web5.sdk.dids.exceptions.ParserException import web5.sdk.dids.ResolutionError -import web5.sdk.dids.ResolveDidOptions -import web5.sdk.dids.validateKeyMaterialInsideKeyManager +import web5.sdk.dids.did.BearerDid +import web5.sdk.dids.did.PortableDid import java.io.File import java.net.InetAddress import java.net.URL @@ -38,29 +36,20 @@ import java.net.UnknownHostException import kotlin.text.Charsets.UTF_8 /** - * Provides a specific implementation for creating and resolving "did:web" method Decentralized Identifiers (DIDs). + * Provides a specific implementation for creating "did:web" method Decentralized Identifiers (DIDs). * * A "did:web" DID is an implementation that uses the web domains existing reputation system. More details can be * read in https://w3c-ccg.github.io/did-method-web/ * - * @property uri The URI of the "did:web" which conforms to the DID standard. - * @property keyManager A [KeyManager] instance utilized to manage the cryptographic keys associated with the DID. + * DidWeb API does not support creating DIDs, only resolving them. * * ### Usage Example: * ```kotlin * val keyManager = InMemoryKeyManager() - * val did = StatefulWebDid("did:web:tbd.website", keyManager) + * val did = DidWeb.resolve("did:web:tbd.website") * ``` */ -public class DidWeb( - uri: String, - keyManager: KeyManager, - private val didWebApi: DidWebApi -) : Did(uri, keyManager) { - /** - * Calls [DidWebApi.resolve] for this DID. - */ - public fun resolve(options: ResolveDidOptions?): DidResolutionResult = didWebApi.resolve(uri, options) +public class DidWeb { /** * Default companion object for creating a [DidWebApi] with a default configuration. @@ -81,8 +70,8 @@ public class DidWebApiConfiguration internal constructor( * Returns a [DidWebApi] instance after applying [blockConfiguration]. */ public fun DidWebApi(blockConfiguration: DidWebApiConfiguration.() -> Unit): DidWebApi { - val conf = DidWebApiConfiguration().apply(blockConfiguration) - return DidWebApiImpl(conf) + val config = DidWebApiConfiguration().apply(blockConfiguration) + return DidWebApiImpl(config) } private class DidWebApiImpl(configuration: DidWebApiConfiguration) : DidWebApi(configuration) @@ -91,15 +80,16 @@ private const val WELL_KNOWN_URL_PATH = "/.well-known" private const val DID_DOC_FILE_NAME = "/did.json" /** - * Implements [resolve] and [create] according to https://w3c-ccg.github.io/did-method-web/ + * Implements [resolve] according to https://w3c-ccg.github.io/did-method-web/ */ public sealed class DidWebApi( configuration: DidWebApiConfiguration -) : DidMethod { +) { + public val methodName: String = "web" private val logger = KotlinLogging.logger {} - private val mapper = jacksonObjectMapper() + private val mapper = Json.jsonMapper private val engine: HttpClientEngine = configuration.engine ?: OkHttp.create { val appCache = Cache(File("cacheDir", "okhttpcache"), 10 * 1024 * 1024) @@ -120,28 +110,63 @@ public sealed class DidWebApi( } } - override val methodName: String = "web" - - override fun resolve(did: String, options: ResolveDidOptions?): DidResolutionResult { + /** + * Resolves a `did:jwk` URI into a [DidResolutionResult]. + * + * This method parses the provided `did` URI to extract the Jwk information. + * It validates the method of the DID URI and then attempts to parse the + * Jwk from the URI. If successful, it constructs a [DidDocument] with the + * resolved Jwk, generating a [DidResolutionResult]. + * + * The method ensures that the DID URI adheres to the `did:jwk` method + * specification and handles exceptions that may arise during the parsing + * and resolution process. + * + * @param did did URI to parse + * @return [DidResolutionResult] containing the resolved DID document. + * If the DID URI is invalid, not conforming to the `did:jwk` method, or + * if any other error occurs during the resolution process, it returns + * an invalid [DidResolutionResult]. + */ + public fun resolve(did: String): DidResolutionResult { return try { - resolveInternal(did, options) + resolveInternal(did) } catch (e: Exception) { - logger.warn(e) { "resolving DID $did failed" } + logger.warn(e) { "resolving DID $did failed, ${e.message}" } DidResolutionResult.fromResolutionError(ResolutionError.INTERNAL_ERROR) } } - private fun resolveInternal(did: String, options: ResolveDidOptions?): DidResolutionResult { - val parsedDidUri = try { - DidUri.parse(did) + /** + * Instantiates a [BearerDid] object for the DID WEB method from a given [PortableDid]. + * + * This method allows for the creation of a `BearerDid` object using a previously created DID's + * key material, DID document, and metadata. + * + * @param portableDid - The PortableDid object to import. + * @param keyManager - Optionally specify an external Key Management System (KMS) used to + * generate keys and sign data. If not given, a new + * [InMemoryKeyManager] instance will be created and + * used. + * @returns a BearerDid object representing the DID formed from the + * provided PortableDid. + */ + @JvmOverloads + public fun import(portableDid: PortableDid, keyManager: KeyManager = InMemoryKeyManager()): BearerDid { + return BearerDid.import(portableDid, keyManager) + } + + private fun resolveInternal(did: String): DidResolutionResult { + val parsedDid = try { + Did.parse(did) } catch (_: ParserException) { return DidResolutionResult.fromResolutionError(ResolutionError.INVALID_DID) } - if (parsedDidUri.method != methodName) { + if (parsedDid.method != methodName) { return DidResolutionResult.fromResolutionError(ResolutionError.METHOD_NOT_SUPPORTED) } - val docURL = getDocURL(parsedDidUri) + val docURL = decodeId(parsedDid) val resp: HttpResponse = try { runBlocking { @@ -149,7 +174,8 @@ public sealed class DidWebApi( contentType(ContentType.Application.Json) } } - } catch (_: UnknownHostException) { + } catch (e: UnknownHostException) { + logger.warn(e) { "failed to make GET request to $did doc URL, ${e.message}" } return DidResolutionResult.fromResolutionError(ResolutionError.NOT_FOUND) } @@ -159,17 +185,12 @@ public sealed class DidWebApi( throw ResponseException(resp, "resolution error response: '$body'") } return DidResolutionResult( - didDocument = mapper.readValue(body, DIDDocument::class.java), + didDocument = mapper.readValue(body, DidDocument::class.java), ) } - override fun load(uri: String, keyManager: KeyManager): DidWeb { - validateKeyMaterialInsideKeyManager(uri, keyManager) - return DidWeb(uri, keyManager, this) - } - - private fun getDocURL(parsedDidUri: DidUri): String { - val domainNameWithPath = parsedDidUri.id.replace(":", "/") + private fun decodeId(parsedDid: Did): String { + val domainNameWithPath = parsedDid.id.replace(":", "/") val decodedDomain = URLDecoder.decode(domainNameWithPath, UTF_8) val targetUrl = StringBuilder("https://$decodedDomain") @@ -182,7 +203,4 @@ public sealed class DidWebApi( return targetUrl.toString() } - public override fun create(keyManager: KeyManager, options: CreateDidOptions?): DidWeb { - throw UnsupportedOperationException("Create operation is not supported for did:web") - } } \ No newline at end of file diff --git a/dids/src/test/kotlin/web5/sdk/dids/DidMethodTest.kt b/dids/src/test/kotlin/web5/sdk/dids/DidMethodTest.kt deleted file mode 100644 index 27b31b2ff..000000000 --- a/dids/src/test/kotlin/web5/sdk/dids/DidMethodTest.kt +++ /dev/null @@ -1,76 +0,0 @@ -package web5.sdk.dids - -import io.ktor.client.engine.mock.MockEngine -import io.ktor.client.engine.mock.respond -import io.ktor.http.HttpHeaders -import io.ktor.http.HttpStatusCode -import io.ktor.http.headersOf -import io.ktor.utils.io.ByteReadChannel -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows -import web5.sdk.crypto.InMemoryKeyManager -import web5.sdk.dids.didcore.DidUri -import web5.sdk.dids.methods.key.DidKey -import web5.sdk.dids.methods.web.DidWebApi -import java.security.SignatureException -import kotlin.test.assertContains -import kotlin.test.assertEquals - -class DidMethodTest { - @Test - fun `findAssertionMethodById works with default`() { - val manager = InMemoryKeyManager() - val did = DidKey.create(manager) - - val verificationMethod = did.resolve().didDocument!!.findAssertionMethodById() - assertEquals("${did.uri}#${DidUri.parse(did.uri).id}", verificationMethod.id) - } - - @Test - fun `findAssertionMethodById finds with id`() { - val manager = InMemoryKeyManager() - val did = DidKey.create(manager) - - val assertionMethodId = "${did.uri}#${DidUri.parse(did.uri).id}" - val verificationMethod = did.resolve().didDocument!!.findAssertionMethodById(assertionMethodId) - assertEquals(assertionMethodId, verificationMethod.id) - } - - @Test - fun `findAssertionMethodById throws exception`() { - val manager = InMemoryKeyManager() - val did = DidKey.create(manager) - - val exception = assertThrows { - did.resolve().didDocument!!.findAssertionMethodById("made up assertion method id") - } - assertContains(exception.message!!, "assertion method \"made up assertion method id\" not found") - } - - @Test - fun `findAssertionMethodById throws exception when no assertion methods are found`() { - val manager = InMemoryKeyManager() - val did = DidWebApi { - engine = mockEngine() - }.load("did:web:example.com", manager) - - val exception = assertThrows { - did.resolve(null).didDocument!!.findAssertionMethodById("made up assertion method id") - } - assertEquals("No assertion methods found in DID document", exception.message) - } - - private fun mockEngine() = MockEngine { request -> - when (request.url.toString()) { - "https://example.com/.well-known/did.json" -> { - respond( - content = ByteReadChannel("""{"id": "did:web:example.com"}"""), - status = HttpStatusCode.OK, - headers = headersOf(HttpHeaders.ContentType, "application/json") - ) - } - - else -> throw Exception("") - } - } -} \ No newline at end of file diff --git a/dids/src/test/kotlin/web5/sdk/dids/DidResolversTest.kt b/dids/src/test/kotlin/web5/sdk/dids/DidResolversTest.kt index d83c376bf..168b79418 100644 --- a/dids/src/test/kotlin/web5/sdk/dids/DidResolversTest.kt +++ b/dids/src/test/kotlin/web5/sdk/dids/DidResolversTest.kt @@ -12,12 +12,12 @@ class DidResolversTest { // TODO: use all relevant test vectors from https://github.com/w3c-ccg/did-method-key/blob/main/test-vectors/ @Test fun `it works`() { - DidResolvers.resolve("did:key:z6MkiTBz1ymuepAQ4HEHYSF1H8quG5GLVVQR3djdX3mDooWp", null) + DidResolvers.resolve("did:key:z6MkiTBz1ymuepAQ4HEHYSF1H8quG5GLVVQR3djdX3mDooWp") } @Test fun `resolving a default dht did contains assertion method`() { - val dhtDid = DidDht.create(InMemoryKeyManager()) + val dhtDid = DidDht.create(InMemoryKeyManager(), null) val resolutionResult = DidResolvers.resolve(dhtDid.uri) assertNotNull(resolutionResult.didDocument!!.assertionMethod) @@ -33,7 +33,7 @@ class DidResolversTest { @Test fun `addResolver adds a custom resolver`() { - val resolver: DidResolver = { _, _ -> DidResolutionResult(null, null) } + val resolver: DidResolver = { _ -> DidResolutionResult(null, null) } DidResolvers.addResolver("test", resolver) assertNotNull(DidResolvers.resolve("did:test:123")) } diff --git a/dids/src/test/kotlin/web5/sdk/dids/did/BearerDidTest.kt b/dids/src/test/kotlin/web5/sdk/dids/did/BearerDidTest.kt new file mode 100644 index 000000000..d0965d2f5 --- /dev/null +++ b/dids/src/test/kotlin/web5/sdk/dids/did/BearerDidTest.kt @@ -0,0 +1,62 @@ +package web5.sdk.dids.did + +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.spy +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import web5.sdk.crypto.InMemoryKeyManager +import web5.sdk.dids.methods.jwk.DidJwk +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +class BearerDidTest { + + @Test + fun `getSigner should return a signer and verification method`() { + val keyManager = spy(InMemoryKeyManager()) + + val did = DidJwk.create(keyManager) + val expectedVm = did.document.verificationMethod?.first() + val testPayload = "testPayload".toByteArray() + val expectedSignature = "signature".toByteArray() + doReturn(expectedSignature).whenever(keyManager).sign(any(), eq(testPayload)) + + val (signer, vm) = did.getSigner() + + val signature = signer(testPayload) + + assertEquals(expectedVm, vm) + verify(keyManager).sign(any(), eq(testPayload)) + assertArrayEquals(expectedSignature, signature) + + } + + @Test + fun `export returns a portable did with correct attributes`() { + val bearerDid = DidJwk.create(InMemoryKeyManager()) + val portableDid = bearerDid.export() + + assertEquals(portableDid.uri, bearerDid.uri) + assertEquals(portableDid.document, bearerDid.document) + assertEquals(1, portableDid.privateKeys.size) + } + + @Test + fun `import should return a BearerDid object`() { + val portableDid = DidJwk.create(InMemoryKeyManager()).export() + val bearerDid = DidJwk.import(portableDid) + + assertEquals(portableDid.uri, bearerDid.uri) + assertEquals(portableDid.document, bearerDid.document) + val portableDidKid = portableDid.privateKeys[0].kid ?: portableDid.privateKeys[0].computeThumbprint() + assertNotNull(bearerDid.keyManager.getPublicKey(portableDidKid)) + val bearerDidKid = bearerDid.document.verificationMethod?.first()?.publicKeyJwk?.kid + ?: bearerDid.document.verificationMethod?.first()?.publicKeyJwk?.computeThumbprint() + assertEquals(portableDidKid, bearerDidKid) + } + +} \ No newline at end of file diff --git a/dids/src/test/kotlin/web5/sdk/dids/did/PortableDidTest.kt b/dids/src/test/kotlin/web5/sdk/dids/did/PortableDidTest.kt new file mode 100644 index 000000000..ac6ae88e1 --- /dev/null +++ b/dids/src/test/kotlin/web5/sdk/dids/did/PortableDidTest.kt @@ -0,0 +1,64 @@ +package web5.sdk.dids.did + +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.junit.jupiter.api.Test +import web5.sdk.common.Json +import web5.sdk.crypto.InMemoryKeyManager +import web5.sdk.crypto.jwk.Jwk +import web5.sdk.dids.didcore.Did +import web5.sdk.dids.didcore.DidDocument +import web5.sdk.testing.TestVectors +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertFails + +class Web5TestVectorsPortableDid { + + data class CreateTestInput( + val uri: String?, + val privateKeys: List?, + val document: DidDocument?, + val metadata: Map?, + ) + + + private val mapper = jacksonObjectMapper() + + @Test + fun create() { + val typeRef = object : TypeReference>() {} + val testVectors = mapper.readValue(File("../web5-spec/test-vectors/portable_did/parse.json"), typeRef) + + testVectors.vectors.filter { it.errors == false }.forEach { vector -> + + val portableDid = Json.parse(Json.stringify(mapOf( + "uri" to vector.input.uri, + "privateKeys" to vector.input.privateKeys, + "document" to vector.input.document, + "metadata" to vector.input.metadata + ))) + + val bearerDid = BearerDid.import(portableDid, InMemoryKeyManager()) + val did = Did.parse(vector.input.uri!!) + assertEquals(bearerDid.uri, vector.input.uri) + assertEquals(bearerDid.document.toString(), vector.input.document.toString()) + assertEquals(bearerDid.did.url, did.url) + } + + testVectors.vectors.filter { it.errors == true }.forEach { vector -> + assertFails(vector.description) { + Json.parse(Json.stringify(mapOf( + "uri" to vector.input.uri, + "privateKeys" to vector.input.privateKeys, + "document" to vector.input.document, + "metadata" to vector.input.metadata + ))) + } + } + } + +} + +class PortableDidTest { +} \ No newline at end of file diff --git a/dids/src/test/kotlin/web5/sdk/dids/didcore/DIDDocumentTest.kt b/dids/src/test/kotlin/web5/sdk/dids/didcore/DidDocumentTest.kt similarity index 75% rename from dids/src/test/kotlin/web5/sdk/dids/didcore/DIDDocumentTest.kt rename to dids/src/test/kotlin/web5/sdk/dids/didcore/DidDocumentTest.kt index 0d1dd0bb1..505ba8c6a 100644 --- a/dids/src/test/kotlin/web5/sdk/dids/didcore/DIDDocumentTest.kt +++ b/dids/src/test/kotlin/web5/sdk/dids/didcore/DidDocumentTest.kt @@ -4,12 +4,15 @@ import org.junit.jupiter.api.Nested import org.junit.jupiter.api.assertThrows import web5.sdk.crypto.AlgorithmId import web5.sdk.crypto.InMemoryKeyManager +import web5.sdk.dids.methods.jwk.DidJwk +import web5.sdk.dids.methods.key.DidKey import java.security.SignatureException import kotlin.test.Test +import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertNull -class DIDDocumentTest { +class DidDocumentTest { @Nested inner class SelectVerificationMethodTest { @@ -17,7 +20,7 @@ class DIDDocumentTest { @Test fun `selectVerificationMethod throws exception if vmMethod is empty`() { - val doc = DIDDocument("did:example:123") + val doc = DidDocument("did:example:123") assertThrows { doc.selectVerificationMethod(Purpose.AssertionMethod) @@ -34,7 +37,7 @@ class DIDDocumentTest { val vmList = listOf( VerificationMethod("id", "type", "controller", publicKeyJwk) ) - val doc = DIDDocument(id = "did:example:123", verificationMethod = vmList) + val doc = DidDocument(id = "did:example:123", verificationMethod = vmList) val vm = doc.selectVerificationMethod(null) assertEquals("id", vm.id) @@ -54,7 +57,7 @@ class DIDDocumentTest { VerificationMethod("id", "type", "controller", publicKeyJwk) ) val assertionMethods = listOf("id") - val doc = DIDDocument( + val doc = DidDocument( id = "did:example:123", verificationMethod = vmList, assertionMethod = assertionMethods @@ -78,7 +81,7 @@ class DIDDocumentTest { VerificationMethod("id", "type", "controller", publicKeyJwk) ) val assertionMethods = listOf("id") - val doc = DIDDocument( + val doc = DidDocument( id = "did:example:123", verificationMethod = vmList, assertionMethod = assertionMethods @@ -101,7 +104,7 @@ class DIDDocumentTest { val vmList = listOf( VerificationMethod("id", "type", "controller", publicKeyJwk) ) - val doc = DIDDocument( + val doc = DidDocument( id = "did:example:123", verificationMethod = vmList ) @@ -117,14 +120,14 @@ class DIDDocumentTest { inner class GetAbsoluteResourceIDTest { @Test fun `getAbsoluteResourceID returns absolute resource id if passed in fragment`() { - val doc = DIDDocument("did:example:123") + val doc = DidDocument("did:example:123") val resourceID = doc.getAbsoluteResourceID("#0") assertEquals("did:example:123#0", resourceID) } @Test fun `getAbsoluteResourceID returns absolute resource id if passed in full id`() { - val doc = DIDDocument("did:example:123") + val doc = DidDocument("did:example:123") val resourceID = doc.getAbsoluteResourceID("did:example:123#1") assertEquals("did:example:123#1", resourceID) } @@ -135,7 +138,7 @@ class DIDDocumentTest { inner class FindAssertionMethodByIdTest { @Test fun `findAssertionMethodById throws exception if assertionMethod list is empty`() { - val doc = DIDDocument("did:example:123") + val doc = DidDocument("did:example:123") assertThrows { doc.findAssertionMethodById() @@ -146,7 +149,7 @@ class DIDDocumentTest { fun `findAssertionMethodById throws exception if assertionMethod does not have provided id`() { val assertionMethods = listOf("foo") - val doc = DIDDocument(id = "did:example:123", assertionMethod = assertionMethods) + val doc = DidDocument(id = "did:example:123", assertionMethod = assertionMethods) assertThrows { doc.findAssertionMethodById("bar") @@ -164,7 +167,7 @@ class DIDDocumentTest { VerificationMethod("foo", "type", "controller", publicKeyJwk) ) - val doc = DIDDocument(id = "did:example:123", verificationMethod = vmList, assertionMethod = assertionMethods) + val doc = DidDocument(id = "did:example:123", verificationMethod = vmList, assertionMethod = assertionMethods) assertThrows { doc.findAssertionMethodById() @@ -173,28 +176,75 @@ class DIDDocumentTest { @Test fun `findAssertionMethodById returns assertion verification method if id is found`() { - val assertionMethods = listOf("foo") + val assertionMethods = listOf("#foo") val manager = InMemoryKeyManager() val keyAlias = manager.generatePrivateKey(AlgorithmId.secp256k1) val publicKeyJwk = manager.getPublicKey(keyAlias) val vmList = listOf( - VerificationMethod("foo", "type", "controller", publicKeyJwk) + VerificationMethod("#foo", "type", "controller", publicKeyJwk) ) - val doc = DIDDocument(id = "did:example:123", verificationMethod = vmList, assertionMethod = assertionMethods) + val doc = DidDocument(id = "did:example:123", verificationMethod = vmList, assertionMethod = assertionMethods) - val assertionMethod = doc.findAssertionMethodById("foo") - assertEquals("foo", assertionMethod.id) + val assertionMethod = doc.findAssertionMethodById("#foo") + assertEquals("#foo", assertionMethod.id) assertEquals("type", assertionMethod.type) assertEquals("controller", assertionMethod.controller) } + + @Test + fun `findAssertionMethodById works with default`() { + val manager = InMemoryKeyManager() + val bearerDid = DidKey.create(manager) + + val verificationMethod = DidKey.resolve(bearerDid.uri) + .didDocument!! + .findAssertionMethodById() + assertEquals("${bearerDid.uri}#${Did.parse(bearerDid.uri).id}", verificationMethod.id) + } + + @Test + fun `findAssertionMethodById finds with id`() { + val manager = InMemoryKeyManager() + val bearerDid = DidKey.create(manager) + + val assertionMethodId = "${bearerDid.uri}#${Did.parse(bearerDid.uri).id}" + val verificationMethod = DidKey.resolve(bearerDid.uri) + .didDocument!! + .findAssertionMethodById(assertionMethodId) + assertEquals(assertionMethodId, verificationMethod.id) + } + + @Test + fun `findAssertionMethodById throws exception`() { + val manager = InMemoryKeyManager() + val bearerDid = DidKey.create(manager) + + val exception = assertThrows { + DidKey.resolve(bearerDid.uri) + .didDocument!! + .findAssertionMethodById("made up assertion method id") + } + assertContains(exception.message!!, "assertion method \"made up assertion method id\" not found") + } + + @Test + fun `findAssertionMethodById throws exception when no assertion methods are found`() { + val manager = InMemoryKeyManager() + val did = DidJwk.create(manager) + val exception = assertThrows { + did.document.findAssertionMethodById("made up assertion method id") + } + assertEquals("assertion method \"made up assertion method id\" " + + "not found in list of assertion methods", exception.message) + } } @Nested inner class BuilderTest { @Test - fun `builder creates a DIDDocument with the provided id`() { + fun `builder creates a DidDocument with the provided id`() { val svc = Service.Builder() .id("service_id") @@ -202,7 +252,7 @@ class DIDDocumentTest { .serviceEndpoint(listOf("https://example.com")) .build() - val doc = DIDDocument.Builder() + val doc = DidDocument.Builder() .id("did:ex:foo") .context(listOf("https://www.w3.org/ns/did/v1")) .controllers(listOf("did:ex:foo")) @@ -224,7 +274,7 @@ class DIDDocumentTest { val publicKeyJwk = manager.getPublicKey(keyAlias) val vm = VerificationMethod("foo", "type", "controller", publicKeyJwk) - val doc = DIDDocument.Builder() + val doc = DidDocument.Builder() .id("did:ex:foo") .context(listOf("https://www.w3.org/ns/did/v1")) .verificationMethodForPurposes(vm, @@ -253,7 +303,7 @@ class DIDDocumentTest { val publicKeyJwk = manager.getPublicKey(keyAlias) val vm = VerificationMethod("foo", "type", "controller", publicKeyJwk) - val doc = DIDDocument.Builder() + val doc = DidDocument.Builder() .id("did:ex:foo") .context(listOf("https://www.w3.org/ns/did/v1")) .verificationMethodForPurposes(vm,listOf(Purpose.Authentication)) @@ -274,7 +324,7 @@ class DIDDocumentTest { val publicKeyJwk = manager.getPublicKey(keyAlias) val vm = VerificationMethod("foo", "type", "controller", publicKeyJwk) - val doc = DIDDocument.Builder() + val doc = DidDocument.Builder() .id("did:ex:foo") .context(listOf("https://www.w3.org/ns/did/v1")) .verificationMethodForPurposes(vm) @@ -290,7 +340,7 @@ class DIDDocumentTest { @Test fun `verificationMethodIdsForPurpose builds list for one purpose`() { - val doc = DIDDocument.Builder() + val doc = DidDocument.Builder() .id("did:ex:foo") .context(listOf("https://www.w3.org/ns/did/v1")) .verificationMethodIdsForPurpose(mutableListOf("keyagreementId"), Purpose.KeyAgreement) diff --git a/dids/src/test/kotlin/web5/sdk/dids/didcore/DidUriTest.kt b/dids/src/test/kotlin/web5/sdk/dids/didcore/DidTest.kt similarity index 53% rename from dids/src/test/kotlin/web5/sdk/dids/didcore/DidUriTest.kt rename to dids/src/test/kotlin/web5/sdk/dids/didcore/DidTest.kt index 5cb2ee7c1..b99898515 100644 --- a/dids/src/test/kotlin/web5/sdk/dids/didcore/DidUriTest.kt +++ b/dids/src/test/kotlin/web5/sdk/dids/didcore/DidTest.kt @@ -4,20 +4,18 @@ import org.junit.jupiter.api.assertThrows import web5.sdk.dids.exceptions.ParserException import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertNotEquals -import kotlin.test.assertNotNull -class DidUriTest { +class DidTest { @Test fun `toString() returns url`() { - val didUri = DidUri( + val did = Did( uri = "did:example:123", url = "did:example:123#0", method = "example", id = "123", ) - assertEquals("did:example:123#0", didUri.toString()) + assertEquals("did:example:123#0", did.toString()) } @Test @@ -35,7 +33,7 @@ class DidUriTest { "did:method:id::anotherid%r9") for (did in invalidDids) { val exception = assertThrows { - DidUri.Parser.parse(did) + Did.Parser.parse(did) } assertEquals("Invalid DID URI", exception.message) } @@ -43,17 +41,17 @@ class DidUriTest { @Test fun `Parser parses a valid did`() { - // todo adding /path after abcdefghi messes up the parsing of params (comes in null) - // to be addressed via gh issue https://github.com/TBD54566975/web5-spec/issues/120 - val didUri = DidUri.Parser.parse("did:example:123456789abcdefghi;foo=bar;baz=qux?foo=bar&baz=qux#keys-1") - assertEquals("did:example:123456789abcdefghi", didUri.uri) - assertEquals("123456789abcdefghi", didUri.id) - assertEquals("did:example:123456789abcdefghi;foo=bar;baz=qux?foo=bar&baz=qux#keys-1", didUri.url) - assertEquals("example", didUri.method) - assertEquals("123456789abcdefghi", didUri.id) - assertEquals("foo=bar&baz=qux", didUri.query) - assertEquals("keys-1", didUri.fragment) - assertEquals(mapOf("foo" to "bar", "baz" to "qux"), didUri.params) + // TODO adding /path after abcdefghi messes up the parsing of params (comes in null) + // https://github.com/TBD54566975/web5-spec/issues/120 + val did = Did.Parser.parse("did:example:123456789abcdefghi;foo=bar;baz=qux?foo=bar&baz=qux#keys-1") + assertEquals("did:example:123456789abcdefghi", did.uri) + assertEquals("123456789abcdefghi", did.id) + assertEquals("did:example:123456789abcdefghi;foo=bar;baz=qux?foo=bar&baz=qux#keys-1", did.url) + assertEquals("example", did.method) + assertEquals("123456789abcdefghi", did.id) + assertEquals("foo=bar&baz=qux", did.query) + assertEquals("keys-1", did.fragment) + assertEquals(mapOf("foo" to "bar", "baz" to "qux"), did.params) } } \ No newline at end of file diff --git a/dids/src/test/kotlin/web5/sdk/dids/didcore/VerificationMethodTest.kt b/dids/src/test/kotlin/web5/sdk/dids/didcore/VerificationMethodTest.kt index d5a6ce972..e39567e6b 100644 --- a/dids/src/test/kotlin/web5/sdk/dids/didcore/VerificationMethodTest.kt +++ b/dids/src/test/kotlin/web5/sdk/dids/didcore/VerificationMethodTest.kt @@ -1,16 +1,16 @@ package web5.sdk.dids.didcore -import com.nimbusds.jose.jwk.JWK import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.assertThrows import web5.sdk.crypto.AlgorithmId import web5.sdk.crypto.InMemoryKeyManager +import web5.sdk.crypto.jwk.Jwk import kotlin.test.Test import kotlin.test.assertEquals class VerificationMethodTest { - var publicKey: JWK? = null + var publicKey: Jwk? = null @BeforeEach fun setUp() { diff --git a/dids/src/test/kotlin/web5/sdk/dids/methods/dht/DhtTest.kt b/dids/src/test/kotlin/web5/sdk/dids/methods/dht/DhtTest.kt index f68844d41..0e27571c2 100644 --- a/dids/src/test/kotlin/web5/sdk/dids/methods/dht/DhtTest.kt +++ b/dids/src/test/kotlin/web5/sdk/dids/methods/dht/DhtTest.kt @@ -1,9 +1,5 @@ package web5.sdk.dids.methods.dht -import io.ktor.client.engine.mock.MockEngine -import io.ktor.client.engine.mock.respond -import io.ktor.http.HttpMethod -import io.ktor.http.HttpStatusCode import org.junit.jupiter.api.Assertions.assertArrayEquals import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test @@ -13,6 +9,7 @@ import web5.sdk.crypto.AlgorithmId import web5.sdk.crypto.Ed25519 import web5.sdk.crypto.InMemoryKeyManager import web5.sdk.dids.methods.dht.DhtClient.Companion.bencode +import web5.sdk.dids.methods.dht.DidDht.Default.suffix import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -76,9 +73,14 @@ class DhtTest { val v = "Hello World!".toByteArray() val manager = InMemoryKeyManager() - manager.import(privateKey) + manager.importKey(privateKey) - val bep44SignedMessage = DhtClient.signBep44Message(manager, privateKey.keyID, seq, v) + val bep44SignedMessage = DhtClient.signBep44Message( + manager, + privateKey.kid ?: privateKey.computeThumbprint(), + seq, + v + ) assertNotNull(bep44SignedMessage) assertEquals("48656c6c6f20576f726c6421", bep44SignedMessage.v.toHexString()) @@ -140,12 +142,11 @@ class DhtTest { val diddht = DidDhtApi {} val did = diddht.create(manager) - require(did.didDocument != null) - - val kid = did.didDocument!!.verificationMethod?.first()?.publicKeyJwk?.keyID?.toString() + val kid = did.document.verificationMethod?.first()?.publicKeyJwk?.kid + ?: did.document.verificationMethod?.first()?.publicKeyJwk?.computeThumbprint() assertNotNull(kid) - val message = did.didDocument?.let { diddht.toDnsPacket(it) } + val message = did.document.let { diddht.toDnsPacket(it) } assertNotNull(message) val bep44Message = DhtClient.createBep44PutRequest(manager, kid, message) @@ -162,22 +163,21 @@ class DhtTest { val dhtClient = DhtClient() val manager = InMemoryKeyManager() val diddht = DidDhtApi {} - val did = diddht.create(manager) - - require(did.didDocument != null) + val bearerDid = diddht.create(manager) - val kid = did.didDocument!!.verificationMethod?.first()?.publicKeyJwk?.keyID?.toString() + val kid = bearerDid.document.verificationMethod?.first()?.publicKeyJwk?.kid + ?: bearerDid.document.verificationMethod?.first()?.publicKeyJwk?.computeThumbprint() assertNotNull(kid) - val message = did.didDocument?.let { diddht.toDnsPacket(it) } + val message = bearerDid.document.let { diddht.toDnsPacket(it) } assertNotNull(message) val bep44Message = DhtClient.createBep44PutRequest(manager, kid, message) assertNotNull(bep44Message) - assertDoesNotThrow { dhtClient.pkarrPut(did.suffix(), bep44Message) } + assertDoesNotThrow { dhtClient.pkarrPut(suffix(bearerDid.uri), bep44Message) } - val retrievedMessage = assertDoesNotThrow { dhtClient.pkarrGet(did.suffix()) } + val retrievedMessage = assertDoesNotThrow { dhtClient.pkarrGet(suffix(bearerDid.uri)) } assertNotNull(retrievedMessage) } diff --git a/dids/src/test/kotlin/web5/sdk/dids/methods/dht/DidDhtTest.kt b/dids/src/test/kotlin/web5/sdk/dids/methods/dht/DidDhtTest.kt index 1907a0708..b8c174435 100644 --- a/dids/src/test/kotlin/web5/sdk/dids/methods/dht/DidDhtTest.kt +++ b/dids/src/test/kotlin/web5/sdk/dids/methods/dht/DidDhtTest.kt @@ -2,11 +2,6 @@ package web5.sdk.dids.methods.dht import com.fasterxml.jackson.core.type.TypeReference import com.fasterxml.jackson.databind.annotation.JsonDeserialize -import com.fasterxml.jackson.databind.annotation.JsonSerialize -import com.fasterxml.jackson.databind.node.ObjectNode -import com.nimbusds.jose.jwk.Curve -import com.nimbusds.jose.jwk.JWK -import com.nimbusds.jose.jwk.gen.ECKeyGenerator import io.ktor.client.engine.mock.MockEngine import io.ktor.client.engine.mock.respond import io.ktor.http.HttpMethod @@ -16,6 +11,7 @@ import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertDoesNotThrow import org.junit.jupiter.api.assertThrows +import org.mockito.kotlin.any import org.mockito.kotlin.doReturn import org.mockito.kotlin.spy import org.mockito.kotlin.whenever @@ -23,10 +19,11 @@ import web5.sdk.common.Json import web5.sdk.common.ZBase32 import web5.sdk.crypto.AlgorithmId import web5.sdk.crypto.InMemoryKeyManager +import web5.sdk.crypto.JwaCurve +import web5.sdk.crypto.jwk.Jwk import web5.sdk.dids.DidResolutionResult -import web5.sdk.dids.JwkDeserializer import web5.sdk.dids.PurposesDeserializer -import web5.sdk.dids.didcore.DIDDocument +import web5.sdk.dids.didcore.DidDocument import web5.sdk.dids.didcore.Purpose import web5.sdk.dids.didcore.Service import web5.sdk.dids.exceptions.InvalidIdentifierException @@ -99,84 +96,39 @@ class DidDhtTest { @Test fun `create with no options`() { val manager = InMemoryKeyManager() - val did = DidDht.create(manager, CreateDidDhtOptions(publish = false)) - - assertDoesNotThrow { did.validate() } - assertNotNull(did) - assertNotNull(did.didDocument) - assertEquals(1, did.didDocument!!.verificationMethod?.size) - assertContains(did.didDocument!!.verificationMethod?.get(0)?.id!!, "#0") - assertEquals(1, did.didDocument!!.assertionMethod?.size) - assertEquals(1, did.didDocument!!.authentication?.size) - assertEquals(1, did.didDocument!!.capabilityDelegation?.size) - assertEquals(1, did.didDocument!!.capabilityInvocation?.size) - assertNull(did.didDocument!!.keyAgreement) - assertNull(did.didDocument!!.service) - } - - @Test - fun `create with another key and service`() { - val manager = InMemoryKeyManager() - - val otherKey = manager.generatePrivateKey(AlgorithmId.secp256k1) - val publicKeyJwk = manager.getPublicKey(otherKey).toPublicJWK() - val publicKeyJwk2 = ECKeyGenerator(Curve.P_256).generate().toPublicJWK() - val verificationMethodsToAdd: Iterable, String?>> = listOf( - Triple( - publicKeyJwk, - listOf(Purpose.Authentication, Purpose.AssertionMethod), - "did:web:tbd.website" - ), - Triple( - publicKeyJwk2, - listOf(Purpose.Authentication, Purpose.AssertionMethod), - "did:web:tbd.website" - ) - ) - - val serviceToAdd = - Service.Builder() - .id("test-service") - .type("HubService") - .serviceEndpoint(listOf("https://example.com/service)")) - .build() - - val opts = CreateDidDhtOptions( - verificationMethods = verificationMethodsToAdd, services = listOf(serviceToAdd), publish = false - ) - val did = DidDht.create(manager, opts) - - assertNotNull(did) - assertNotNull(did.didDocument) - assertEquals(3, did.didDocument!!.verificationMethod?.size) - assertEquals(3, did.didDocument!!.assertionMethod?.size) - assertEquals(3, did.didDocument!!.authentication?.size) - assertEquals(1, did.didDocument!!.capabilityDelegation?.size) - assertEquals(1, did.didDocument!!.capabilityInvocation?.size) - assertNull(did.didDocument!!.keyAgreement) - assertNotNull(did.didDocument!!.service) - assertEquals(1, did.didDocument!!.service?.size) - assertContains(did.didDocument!!.service?.get(0)?.id!!, "test-service") + val bearerDid = DidDht.create(manager, CreateDidDhtOptions(publish = false)) + + assertDoesNotThrow { DidDht.validate(bearerDid.did.url) } + assertNotNull(bearerDid) + assertNotNull(bearerDid.document) + assertEquals(1, bearerDid.document.verificationMethod?.size) + assertContains(bearerDid.document.verificationMethod?.get(0)?.id!!, "#0") + assertEquals(1, bearerDid.document.assertionMethod?.size) + assertEquals(1, bearerDid.document.authentication?.size) + assertEquals(1, bearerDid.document.capabilityDelegation?.size) + assertEquals(1, bearerDid.document.capabilityInvocation?.size) + assertNull(bearerDid.document.keyAgreement) + assertNull(bearerDid.document.service) } @Test fun `create and transform to packet with types`() { val manager = InMemoryKeyManager() - val did = DidDht.create(manager, CreateDidDhtOptions(publish = false)) + val bearerDid = DidDht.create(manager, CreateDidDhtOptions(publish = false)) - assertDoesNotThrow { did.validate() } - assertNotNull(did) - assertNotNull(did.didDocument) + assertDoesNotThrow { DidDht.validate(bearerDid.did.url) } + assertNotNull(bearerDid) + assertNotNull(bearerDid.document) val indexes = listOf(DidDhtTypeIndexing.Corporation, DidDhtTypeIndexing.SoftwarePackage) - val packet = did.toDnsPacket(did.didDocument!!, indexes) + val packet = DidDht.toDnsPacket(bearerDid.document, indexes) assertNotNull(packet) - val docTypesPair = did.fromDnsPacket(msg = packet) + val docTypesPair = DidDht.fromDnsPacket(bearerDid.did.url, packet) assertNotNull(docTypesPair) assertNotNull(docTypesPair.first) assertNotNull(docTypesPair.second) - assertEquals(did.didDocument.toString(), docTypesPair.first.toString()) + assertEquals(bearerDid.document.toString(), docTypesPair.first.toString()) assertEquals(indexes, docTypesPair.second) } @@ -187,15 +139,15 @@ class DidDhtTest { val did = api.create(manager, CreateDidDhtOptions(publish = true)) assertNotNull(did) - assertNotNull(did.didDocument) - assertEquals(1, did.didDocument!!.verificationMethod?.size) - assertContains(did.didDocument!!.verificationMethod?.get(0)?.id!!, "#0") - assertEquals(1, did.didDocument!!.assertionMethod?.size) - assertEquals(1, did.didDocument!!.authentication?.size) - assertEquals(1, did.didDocument!!.capabilityDelegation?.size) - assertEquals(1, did.didDocument!!.capabilityInvocation?.size) - assertNull(did.didDocument!!.keyAgreement) - assertNull(did.didDocument!!.service) + assertNotNull(did.document) + assertEquals(1, did.document.verificationMethod?.size) + assertContains(did.document.verificationMethod?.get(0)?.id!!, "#0") + assertEquals(1, did.document.assertionMethod?.size) + assertEquals(1, did.document.authentication?.size) + assertEquals(1, did.document.capabilityDelegation?.size) + assertEquals(1, did.document.capabilityInvocation?.size) + assertNull(did.document.keyAgreement) + assertNull(did.document.service) } @Test @@ -241,16 +193,14 @@ class DidDhtTest { val manager = InMemoryKeyManager() val did = DidDht.create(manager, CreateDidDhtOptions(publish = false)) - require(did.didDocument != null) - - val packet = DidDht.toDnsPacket(did.didDocument!!) + val packet = DidDht.toDnsPacket(did.document) assertNotNull(packet) - val didFromPacket = DidDht.fromDnsPacket(did.didDocument!!.id, packet) + val didFromPacket = DidDht.fromDnsPacket(did.document.id, packet) assertNotNull(didFromPacket) assertNotNull(didFromPacket.first) - assertEquals(did.didDocument.toString(), didFromPacket.first.toString()) + assertEquals(did.document.toString(), didFromPacket.first.toString()) } @Test @@ -258,18 +208,16 @@ class DidDhtTest { val manager = InMemoryKeyManager() val did = DidDht.create(manager, CreateDidDhtOptions(publish = false)) - require(did.didDocument != null) - val indexes = listOf(DidDhtTypeIndexing.Corporation, DidDhtTypeIndexing.SoftwarePackage) - val packet = DidDht.toDnsPacket(did.didDocument!!, indexes) + val packet = DidDht.toDnsPacket(did.document, indexes) assertNotNull(packet) - val didFromPacket = DidDht.fromDnsPacket(did.didDocument!!.id, packet) + val didFromPacket = DidDht.fromDnsPacket(did.document.id, packet) assertNotNull(didFromPacket) assertNotNull(didFromPacket.first) assertNotNull(didFromPacket.second) - assertEquals(did.didDocument.toString(), didFromPacket.first.toString()) + assertEquals(did.document.toString(), didFromPacket.first.toString()) assertEquals(indexes, didFromPacket.second) } @@ -278,8 +226,8 @@ class DidDhtTest { val manager = InMemoryKeyManager() val otherKey = manager.generatePrivateKey(AlgorithmId.secp256k1) - val publicKeyJwk = manager.getPublicKey(otherKey).toPublicJWK() - val verificationMethodsToAdd: Iterable, String?>> = listOf( + val publicKeyJwk = manager.getPublicKey(otherKey) + val verificationMethodsToAdd: Iterable, String?>> = listOf( Triple(publicKeyJwk, listOf(Purpose.Authentication, Purpose.AssertionMethod), null) ) @@ -298,16 +246,14 @@ class DidDhtTest { ) val did = DidDht.create(manager, opts) - require(did.didDocument != null) - - val packet = DidDht.toDnsPacket(did.didDocument!!) + val packet = DidDht.toDnsPacket(did.document) assertNotNull(packet) - val didFromPacket = DidDht.fromDnsPacket(did.didDocument!!.id, packet) + val didFromPacket = DidDht.fromDnsPacket(did.document.id, packet) assertNotNull(didFromPacket) assertNotNull(didFromPacket.first) - assertEquals(did.didDocument.toString(), didFromPacket.first.toString()) + assertEquals(did.document.toString(), didFromPacket.first.toString()) } } @@ -358,8 +304,7 @@ class Web5TestVectorsDidDht { ) data class VerificationMethodInput( - @JsonDeserialize(using = JwkDeserializer::class) - val jwk: JWK, + val jwk: Jwk, @JsonDeserialize(using = PurposesDeserializer::class) val purposes: List ) @@ -367,12 +312,13 @@ class Web5TestVectorsDidDht { @Test fun create() { - val typeRef = object : TypeReference>() {} + val typeRef = object : TypeReference>() {} val testVectors = mapper.readValue(File("../web5-spec/test-vectors/did_dht/create.json"), typeRef) testVectors.vectors.forEach { vector -> val keyManager = spy(InMemoryKeyManager()) - val identityKeyId = keyManager.import(listOf(vector.input.identityPublicJwk!!)).first() + val identityJwk = Json.parse(Json.stringify(vector.input.identityPublicJwk!!)) + val identityKeyId = keyManager.importKey(identityJwk) doReturn(identityKeyId).whenever(keyManager).generatePrivateKey(AlgorithmId.Ed25519) val verificationMethods = vector.input.additionalVerificationMethods?.map { verificationMethodInput -> @@ -389,7 +335,7 @@ class Web5TestVectorsDidDht { val didDht = DidDht.create(keyManager, options) assertEquals( JsonCanonicalizer(Json.stringify(vector.output!!)).encodedString, - JsonCanonicalizer(Json.stringify(didDht.didDocument!!)).encodedString, + JsonCanonicalizer(Json.stringify(didDht.document)).encodedString, vector.description ) } diff --git a/dids/src/test/kotlin/web5/sdk/dids/methods/jwk/DidJwkTest.kt b/dids/src/test/kotlin/web5/sdk/dids/methods/jwk/DidJwkTest.kt index d6cac26c5..004ae46ce 100644 --- a/dids/src/test/kotlin/web5/sdk/dids/methods/jwk/DidJwkTest.kt +++ b/dids/src/test/kotlin/web5/sdk/dids/methods/jwk/DidJwkTest.kt @@ -2,8 +2,6 @@ package web5.sdk.dids.methods.jwk import com.fasterxml.jackson.core.type.TypeReference import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.nimbusds.jose.JWSAlgorithm -import com.nimbusds.jose.jwk.JWK import org.erdtman.jcs.JsonCanonicalizer import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test @@ -12,19 +10,22 @@ import web5.sdk.common.Convert import web5.sdk.common.Json import web5.sdk.crypto.AlgorithmId import web5.sdk.crypto.InMemoryKeyManager +import web5.sdk.crypto.JwaCurve +import web5.sdk.crypto.jwk.Jwk import web5.sdk.dids.DidResolutionResult import web5.sdk.dids.DidResolvers +import web5.sdk.dids.exceptions.InvalidMethodNameException +import web5.sdk.dids.methods.dht.DidDht import web5.sdk.testing.TestVectors import java.io.File import kotlin.test.assertEquals import kotlin.test.assertNotNull -import kotlin.test.assertTrue class DidJwkTest { @Nested inner class CreateTest { @Test - fun `creates an ES256K key when no options are passed`() { + fun `creates an Ed25519 key when no options are passed`() { val manager = InMemoryKeyManager() val did = DidJwk.create(manager) @@ -37,42 +38,29 @@ class DidJwkTest { assertNotNull(jwk) val keyAlias = did.keyManager.getDeterministicAlias(jwk) val publicKey = did.keyManager.getPublicKey(keyAlias) - - assertEquals(JWSAlgorithm.ES256K, publicKey.algorithm) + assertEquals(JwaCurve.Ed25519.name, publicKey.crv) } } @Nested - inner class LoadTest { - @Test - fun `throws exception when key manager does not contain private key`() { - val manager = InMemoryKeyManager() - val exception = assertThrows { - @Suppress("MaxLineLength") - DidJwk.load( - "did:jwk:eyJjcnYiOiJQLTI1NiIsImt0eSI6IkVDIiwieCI6ImFjYklRaXVNczNpOF91c3pFakoydHBUdFJNNEVVM3l6OTFQSDZDZEgyVjAiLCJ5IjoiX0tjeUxqOXZXTXB0bm1LdG00NkdxRHo4d2Y3NEk1TEtncmwyR3pIM25TRSJ9", - manager - ) - } - assertEquals("key with alias wKIg-QPOd75_AJLdvvo-EACSpCPE5IOJu-MUpQVk1c4 not found", exception.message) - } - + inner class ImportTest { @Test - fun `returns instance when key manager contains private key`() { + fun `importing a portable did jwk did works`() { val manager = InMemoryKeyManager() - val did = DidJwk.create(manager) - val didKey = DidJwk.load(did.uri, manager) - assertEquals(did.uri, didKey.uri) + val bearerDid = DidJwk.create(manager) + val portableDid = bearerDid.export() + val importedDid = DidJwk.import(portableDid, manager) + assertEquals(bearerDid.uri, importedDid.uri) } @Test - fun `throws exception when loading a different type of did`() { + fun `importing a did with wrong method name throws exception`() { val manager = InMemoryKeyManager() - val did = DidJwk.create(manager) - val exception = assertThrows { - DidJwk.load(did.uri.replace("jwk", "ion"), manager) + val did = DidDht.create(manager) + val portableDid = did.export() + assertThrows { + DidJwk.import(portableDid, manager) } - assertTrue(exception.message!!.startsWith("did must start with the prefix \"did:jwk\"")) } } @@ -91,19 +79,6 @@ class DidJwkTest { assertEquals("methodNotSupported", result.didResolutionMetadata.error) } - @Test - fun `private key throws exception`() { - val manager = InMemoryKeyManager() - manager.generatePrivateKey(AlgorithmId.secp256k1) - val privateJwk = JWK.parse(manager.export().first()) - val encodedPrivateJwk = Convert(privateJwk.toJSONString()).toBase64Url(padding = false) - - val did = "did:jwk:$encodedPrivateJwk" - assertThrows( - "decoded jwk value cannot be a private key" - ) { DidJwk.resolve(did) } - } - @Test fun `test vector 1`() { // test vector taken from: https://github.com/quartzjer/did-jwk/blob/main/spec.md#p-256 diff --git a/dids/src/test/kotlin/web5/sdk/dids/methods/key/DidKeyTest.kt b/dids/src/test/kotlin/web5/sdk/dids/methods/key/DidKeyTest.kt index a9567755e..1ddaac2f1 100644 --- a/dids/src/test/kotlin/web5/sdk/dids/methods/key/DidKeyTest.kt +++ b/dids/src/test/kotlin/web5/sdk/dids/methods/key/DidKeyTest.kt @@ -1,18 +1,15 @@ package web5.sdk.dids.methods.key -import com.fasterxml.jackson.annotation.JsonInclude -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.module.kotlin.readValue -import com.fasterxml.jackson.module.kotlin.registerKotlinModule -import com.nimbusds.jose.JWSAlgorithm -import com.nimbusds.jose.jwk.Curve -import com.nimbusds.jose.jwk.ECKey import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertDoesNotThrow import org.junit.jupiter.api.assertThrows import web5.sdk.crypto.InMemoryKeyManager +import web5.sdk.crypto.Jwa +import web5.sdk.crypto.JwaCurve import web5.sdk.dids.DidResolvers +import web5.sdk.dids.exceptions.InvalidMethodNameException +import web5.sdk.dids.methods.dht.DidDht import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -22,11 +19,11 @@ class DidKeyTest { @Nested inner class CreateTest { @Test - fun `it works`() { + fun `creates and resolves did key`() { val manager = InMemoryKeyManager() - val did = DidKey.create(manager) + val bearerDid = DidKey.create(manager) - val didResolutionResult = DidResolvers.resolve(did.uri) + val didResolutionResult = DidResolvers.resolve(bearerDid.uri) assertNotNull(didResolutionResult.didDocument) val verificationMethod = didResolutionResult.didDocument!!.verificationMethod?.get(0) @@ -34,8 +31,8 @@ class DidKeyTest { val jwk = verificationMethod?.publicKeyJwk assertNotNull(jwk) - val keyAlias = did.keyManager.getDeterministicAlias(jwk) - val publicKey = did.keyManager.getPublicKey(keyAlias) + val keyAlias = bearerDid.keyManager.getDeterministicAlias(jwk) + val publicKey = bearerDid.keyManager.getPublicKey(keyAlias) assertNotNull(jwk) assertNotNull(keyAlias) assertNotNull(publicKey) @@ -43,33 +40,6 @@ class DidKeyTest { } } - @Test - fun `load fails when key manager does not contain private key`() { - val manager = InMemoryKeyManager() - val exception = assertThrows { - DidKey.load("did:key:z6MkiTBz1ymuepAQ4HEHYSF1H8quG5GLVVQR3djdX3mDooWp", manager) - } - assertEquals("key with alias 9ZP03Nu8GrXPAUkbKNxHOKBzxPX83SShgFkRNK-f2lw not found", exception.message) - } - - @Test - fun `load returns instance when key manager contains private key`() { - val manager = InMemoryKeyManager() - val did = DidKey.create(manager) - val didKey = DidKey.load(did.uri, manager) - assertEquals(did.uri, didKey.uri) - } - - @Test - fun `throws exception when loading a different type of did`() { - val manager = InMemoryKeyManager() - val did = DidKey.create(manager) - val exception = assertThrows { - DidKey.load(did.uri.replace("key", "ion"), manager) - } - assertTrue(exception.message!!.startsWith("did must start with the prefix \"did:key\"")) - } - @Nested inner class ResolveTest { @Test @@ -100,41 +70,50 @@ class DidKeyTest { // Note: cannot run the controller assertion because underlying lib enforces JSON-LD @context // despite it not being a required field // assertEquals(did, verificationMethod.controller.toString()) - assertEquals("JsonWebKey2020", verificationMethod.type) + assertEquals("JsonWebKey", verificationMethod.type) assertNotNull(verificationMethod.publicKeyJwk) val publicKeyJwk = verificationMethod.publicKeyJwk // validates - assertTrue(publicKeyJwk is ECKey) + assertTrue(publicKeyJwk?.kty == "EC") - assertEquals(publicKeyJwk.algorithm, JWSAlgorithm.ES256K) - assertEquals(Curve.SECP256K1, publicKeyJwk.curve) - assertEquals("TEIJN9vnTq1EXMkqzo7yN_867-foKc2pREv45Fw_QA8", publicKeyJwk.x.toString()) - assertEquals("9yiymlzdxKCiRbYq7p-ArRB-C1ytjHE-eb7RDTi6rVc", publicKeyJwk.y.toString()) + assertEquals(publicKeyJwk?.alg, Jwa.ES256K.name) + assertEquals(JwaCurve.secp256k1.name, publicKeyJwk?.crv) + assertEquals("TEIJN9vnTq1EXMkqzo7yN_867-foKc2pREv45Fw_QA8", publicKeyJwk?.x.toString()) + assertEquals("9yiymlzdxKCiRbYq7p-ArRB-C1ytjHE-eb7RDTi6rVc", publicKeyJwk?.y.toString()) } } @Nested - inner class ImportExportTest { + inner class ImportTest { @Test - fun `InMemoryKeyManager export then re-import doesn't throw exception`() { - val jsonMapper = ObjectMapper() - .registerKotlinModule() - .setSerializationInclusion(JsonInclude.Include.NON_NULL) - + fun `BearerDid export then re-import doesn't throw exception`() { assertDoesNotThrow { val km = InMemoryKeyManager() - val did = DidKey.create(km) + val bearerDid = DidKey.create(km) - val keySet = km.export() - val serializedKeySet = jsonMapper.writeValueAsString(keySet) - val didUri = did.uri - - val jsonKeySet: List> = jsonMapper.readValue(serializedKeySet) + val portableDid = bearerDid.export() val km2 = InMemoryKeyManager() - km2.import(jsonKeySet) + DidKey.import(portableDid, km2) + } + } - DidKey.load(uri = didUri, keyManager = km2) + @Test + fun `importing a portable did key did works`() { + val manager = InMemoryKeyManager() + val bearerDid = DidKey.create(manager) + val portableDid = bearerDid.export() + val importedDid = DidKey.import(portableDid, manager) + assertEquals(bearerDid.uri, importedDid.uri) + } + + @Test + fun `importing a did with wrong method name throws exception`() { + val manager = InMemoryKeyManager() + val did = DidDht.create(manager) + val portableDid = did.export() + assertThrows { + DidKey.import(portableDid, manager) } } } diff --git a/dids/src/test/kotlin/web5/sdk/dids/methods/util/TestUtils.kt b/dids/src/test/kotlin/web5/sdk/dids/methods/util/TestUtils.kt index c5addfc54..b23afb62e 100644 --- a/dids/src/test/kotlin/web5/sdk/dids/methods/util/TestUtils.kt +++ b/dids/src/test/kotlin/web5/sdk/dids/methods/util/TestUtils.kt @@ -1,11 +1,12 @@ package web5.sdk.dids.methods.util -import com.nimbusds.jose.jwk.JWK +import web5.sdk.common.Json +import web5.sdk.crypto.jwk.Jwk import java.io.File -fun readKey(pathname: String): JWK { - return JWK.parse( +fun readKey(pathname: String): Jwk { + return Json.parse( File(pathname).readText() ) } \ No newline at end of file diff --git a/dids/src/test/kotlin/web5/sdk/dids/methods/web/DidWebTest.kt b/dids/src/test/kotlin/web5/sdk/dids/methods/web/DidWebTest.kt index 7c513187f..38ba26e8d 100644 --- a/dids/src/test/kotlin/web5/sdk/dids/methods/web/DidWebTest.kt +++ b/dids/src/test/kotlin/web5/sdk/dids/methods/web/DidWebTest.kt @@ -71,37 +71,6 @@ class DidWebTest { assertEquals("internalError", result.didResolutionMetadata.error) } - @Test - fun `load returns instance when key manager contains private key`() { - val manager = InMemoryKeyManager() - val privateJwk = readKey("src/test/resources/jwkEs256k1Private.json") - manager.import(privateJwk) - DidWebApi { - engine = mockEngine() - }.load("did:web:example-with-verification-method.com", manager) - } - - @Test - fun `load throws exception when key manager does not contain private key`() { - val manager = InMemoryKeyManager() - val exception = assertThrows { - DidWebApi { - engine = mockEngine() - }.load("did:web:example-with-verification-method.com", manager) - } - assertEquals("key with alias CfveyLOfYrOhSgD66MA6PO9J5sAnj_J-Z0URcD6VGVU not found", exception.message) - } - - @Test - fun `create throws exception`() { - val exception = assertThrows { - DidWebApi { - engine = mockEngine() - }.create(InMemoryKeyManager()) - } - assertEquals("Create operation is not supported for did:web", exception.message) - } - private fun mockEngine() = MockEngine { request -> when (request.url.toString()) { "https://example.com/.well-known/did.json" -> { diff --git a/jose/build.gradle.kts b/jose/build.gradle.kts new file mode 100644 index 000000000..20e3bd530 --- /dev/null +++ b/jose/build.gradle.kts @@ -0,0 +1,21 @@ +plugins { + id("org.jetbrains.kotlin.jvm") + id("java-library") +} + +repositories { + mavenCentral() +} + +dependencies { + + // Project + implementation(project(":common")) + implementation(project(":crypto")) + implementation(project(":dids")) + + // Test + testImplementation(libs.comWillowtreeappsAssertk) + testImplementation(kotlin("test")) + testImplementation(project(":testing")) +} \ No newline at end of file diff --git a/jose/src/main/kotlin/web5/sdk/jose/JwtClaimsSetSerializer.kt b/jose/src/main/kotlin/web5/sdk/jose/JwtClaimsSetSerializer.kt new file mode 100644 index 000000000..f377736b9 --- /dev/null +++ b/jose/src/main/kotlin/web5/sdk/jose/JwtClaimsSetSerializer.kt @@ -0,0 +1,85 @@ +package web5.sdk.jose + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JsonDeserializer +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.JsonSerializer +import com.fasterxml.jackson.databind.SerializerProvider +import web5.sdk.common.Json +import web5.sdk.jose.jwt.JwtClaimsSet + +/** + * JwtClaimsSet serializer. + * + * Used to serialize JwtClaimsSet into a JSON object that flattens the misc claims + * + */ +public class JwtClaimsSetSerializer : JsonSerializer() { + + override fun serialize(jwtClaimsSet: JwtClaimsSet, gen: JsonGenerator, serializers: SerializerProvider?) { + gen.writeStartObject() + + jwtClaimsSet.iss?.let { gen.writeStringField("iss", it) } + jwtClaimsSet.sub?.let { gen.writeStringField("sub", it) } + jwtClaimsSet.aud?.let { gen.writeStringField("aud", it) } + jwtClaimsSet.exp?.let { gen.writeNumberField("exp", it) } + jwtClaimsSet.nbf?.let { gen.writeNumberField("nbf", it) } + jwtClaimsSet.iat?.let { gen.writeNumberField("iat", it) } + jwtClaimsSet.jti?.let { gen.writeStringField("jti", it) } + + for ((key, value) in jwtClaimsSet.misc) { + gen.writeObjectField(key, value) + } + + gen.writeEndObject() + } + +} + +/** + * JwtClaimsSet deserializer. + * + * Used to deserialize JSON object JwtClaimsSet + * that takes miscellaneous claims and puts them as values inside misc key + */ +public class JwtClaimsSetDeserializer : JsonDeserializer() { + public override fun deserialize(p: JsonParser, ctxt: DeserializationContext): JwtClaimsSet { + val jsonNode = p.codec.readTree(p) + val reservedClaims = setOf( + "iss", + "sub", + "aud", + "exp", + "nbf", + "iat", + "jti" + ) + + // extract misc nodes + val miscClaims = Json.jsonMapper.createObjectNode() + val fields = jsonNode.fields() + + while (fields.hasNext()) { + val (key, value) = fields.next() + + if (!reservedClaims.contains(key)) { + miscClaims.set(key, value) + } + } + + val miscClaimsMap = Json.jsonMapper.convertValue(miscClaims, Map::class.java) + + return JwtClaimsSet( + iss = jsonNode.get("iss")?.asText(), + sub = jsonNode.get("sub")?.asText(), + aud = jsonNode.get("aud")?.asText(), + exp = jsonNode.get("exp")?.asLong(), + nbf = jsonNode.get("nbf")?.asLong(), + iat = jsonNode.get("iat")?.asLong(), + jti = jsonNode.get("jti")?.asText(), + misc = miscClaimsMap as Map + ) + } +} \ No newline at end of file diff --git a/jose/src/main/kotlin/web5/sdk/jose/jws/Jws.kt b/jose/src/main/kotlin/web5/sdk/jose/jws/Jws.kt new file mode 100644 index 000000000..0db089f89 --- /dev/null +++ b/jose/src/main/kotlin/web5/sdk/jose/jws/Jws.kt @@ -0,0 +1,275 @@ +package web5.sdk.jose.jws + +import web5.sdk.common.Convert +import web5.sdk.common.EncodingFormat +import web5.sdk.common.Json +import web5.sdk.crypto.AlgorithmId +import web5.sdk.crypto.Crypto +import web5.sdk.crypto.JwaCurve +import web5.sdk.dids.DidResolvers +import web5.sdk.dids.did.BearerDid +import web5.sdk.dids.exceptions.PublicKeyJwkMissingException +import java.security.SignatureException + +/** + * Json Web Signature (JWS) is a compact signature format that is used to secure messages. + * Spec: https://datatracker.ietf.org/doc/html/rfc7515 + */ +public object Jws { + + /** + * Decode a JWS into its parts. + * + * @param jws The JWS to decode + * @return DecodedJws + */ + @Suppress("SwallowedException") + public fun decode(jws: String): DecodedJws { + val parts = jws.split(".") + check(parts.size == 3) { + "Malformed JWT. Expected 3 parts, got ${parts.size}" + } + + val header: JwsHeader + try { + header = JwsHeader.fromBase64Url(parts[0]) + } catch (e: Exception) { + throw SignatureException("Malformed JWT. Failed to decode header: ${e.message}") + } + + val payload: ByteArray + try { + payload = Convert(parts[1], EncodingFormat.Base64Url).toByteArray() + } catch (e: Exception) { + throw SignatureException("Malformed JWT. Failed to decode payload: ${e.message}") + } + + val signature: ByteArray + try { + signature = Convert(parts[2], EncodingFormat.Base64Url).toByteArray() + } catch (e: Exception) { + throw SignatureException("Malformed JWT. Failed to decode signature: ${e.message}") + } + + return DecodedJws(header, payload, signature, parts) + } + + /** + * Sign a payload using a Bearer DID. + * + * @param bearerDid The Bearer DID to sign with + * @param payload The payload to sign + * @param detached Whether to include the payload in the JWS string output + * @return + */ + public fun sign( + bearerDid: BearerDid, + payload: ByteArray, + detached: Boolean = false + ): String { + val (signer, verificationMethod) = bearerDid.getSigner() + + check(verificationMethod.publicKeyJwk != null) { + throw PublicKeyJwkMissingException("publicKeyJwk is null.") + } + + val kid = if (verificationMethod.id.startsWith("#")) { + "${bearerDid.uri}${verificationMethod.id}" + } else { + verificationMethod.id + } + + val curve = JwaCurve.parse(verificationMethod.publicKeyJwk!!.crv) + val alg = AlgorithmId.from(curve).name + + val jwsHeader = JwsHeader.Builder() + .type("JWT") + .algorithm(alg) + .keyId(kid) + .build() + + val headerBase64Url = Convert(Json.stringify(jwsHeader)).toBase64Url() + val payloadBase64Url = Convert(payload).toBase64Url() + + val toSignBase64Url = "$headerBase64Url.$payloadBase64Url" + val toSignBytes = Convert(toSignBase64Url).toByteArray() + + val signatureBytes = signer.invoke(toSignBytes) + val signatureBase64Url = Convert(signatureBytes).toBase64Url() + + return if (detached) { + "$headerBase64Url..$signatureBase64Url" + } else { + "$headerBase64Url.$payloadBase64Url.$signatureBase64Url" + } + + } + + /** + * Verify a JWS. + * + * @param jws The JWS to verify + * @return DecodedJws + */ + public fun verify(jws: String): DecodedJws { + val decodedJws = decode(jws) + decodedJws.verify() + return decodedJws + } +} + +/** + * JSON Web Signature (JWS) Header Parameters + * + * The Header Parameter names for use in JWSs are registered in the IANA "JSON Web Signature and + * Encryption Header Parameters" registry. + * + * Spec: https://datatracker.ietf.org/doc/html/rfc7515 + * @param typ The "typ" (type) Header Parameter is used by JWS applications to declare the media type + * @param alg The "alg" (algorithm) Header Parameter identifies the cryptographic algorithm used to + * @param kid The "kid" (key ID) Header Parameter is a hint indicating which key was used to secure + */ +public class JwsHeader( + public val typ: String? = null, + public val alg: String? = null, + public val kid: String? = null +) { + + /** + * Builder for JwsHeader. + * + */ + public class Builder { + private var typ: String? = null + private var alg: String? = null + private var kid: String? = null + + /** + * Sets the typ field of the JWS header. + * + * @param typ The type of the JWS + * @return Builder object + */ + public fun type(typ: String): Builder { + this.typ = typ + return this + } + + /** + * Sets the alg field of the JWS header. + * + * @param alg The algorithm used to sign the JWS + * @return Builder object + */ + public fun algorithm(alg: String): Builder { + this.alg = alg + return this + } + + /** + * Sets the kid field of the JWS header. + * + * @param kid The key ID used to sign the JWS + * @return Builder object + */ + public fun keyId(kid: String): Builder { + this.kid = kid + return this + } + + /** + * Builds the JwsHeader object. + * + * @return JwsHeader + */ + public fun build(): JwsHeader { + check(typ != null) { "typ is required" } + check(alg != null) { "alg is required" } + check(kid != null) { "kid is required" } + return JwsHeader(typ, alg, kid) + } + } + + public companion object { + /** + * Decodes a base64 encoded JWS header. + * + * @param base64EncodedHeader The base64 encoded JWS header + * @return JwsHeader + */ + public fun fromBase64Url(base64EncodedHeader: String): JwsHeader { + val jsonHeaderDecoded = Convert(base64EncodedHeader, EncodingFormat.Base64Url).toStr() + return Json.parse(jsonHeaderDecoded) + } + + /** + * Encodes a JWS header to base64url string. + * + * @param header The JWS header to encode + * @return String base64url encoded JWS header + */ + public fun toBase64Url(header: JwsHeader): String { + val jsonHeader = Json.stringify(header) + return Convert(jsonHeader, EncodingFormat.Base64Url).toBase64Url() + } + } +} + +/** + * DecodedJws is a compact JWS decoded into its parts. + * + * @property header The JWS header + * @property payload The JWS payload + * @property signature The JWS signature + * @property parts All parts that make up JWS. Each part is a base64url encoded string + */ +public class DecodedJws( + public val header: JwsHeader, + public val payload: ByteArray, + public val signature: ByteArray, + public val parts: List +) { + + /** + * Verify the JWS signature is valid. + */ + public fun verify() { + check(header.kid != null || header.alg != null) { + "Malformed JWS. Expected header to contain kid and alg." + } + + // todo header.kid needs to be broken down into didUri and fragment and use the didUri + val didUri = header.kid!!.split("#")[0] + val resolutionResult = DidResolvers.resolve(didUri) + + check(resolutionResult.didResolutionMetadata.error == null) { + "Verification failed. Failed to resolve kid. " + + "Error: ${resolutionResult.didResolutionMetadata.error}" + } + + check(resolutionResult.didDocument != null) { + "Verification failed. Expected header kid to dereference a DID document" + } + + check(resolutionResult.didDocument!!.verificationMethod?.size != 0) { + "Verification failed. Expected header kid to dereference a verification method" + } + + val verificationMethod = resolutionResult.didDocument!!.findAssertionMethodById(header.kid) + check(verificationMethod.publicKeyJwk != null) { + "Verification failed. Expected headeder kid to dereference" + + " a verification method with a publicKeyJwk" + } + + check(verificationMethod.type == "JsonWebKey2020" || verificationMethod.type == "JsonWebKey") { + "Verification failed. Expected header kid to dereference " + + "a verification method of type JsonWebKey2020 or JsonWebKey" + } + + val toSign = "${parts[0]}.${parts[1]}" + val toSignBytes = Convert(toSign).toByteArray() + + Crypto.verify(verificationMethod.publicKeyJwk!!, toSignBytes, signature) + + } +} \ No newline at end of file diff --git a/jose/src/main/kotlin/web5/sdk/jose/jwt/Jwt.kt b/jose/src/main/kotlin/web5/sdk/jose/jwt/Jwt.kt new file mode 100644 index 000000000..c338e4e68 --- /dev/null +++ b/jose/src/main/kotlin/web5/sdk/jose/jwt/Jwt.kt @@ -0,0 +1,240 @@ +package web5.sdk.jose.jwt + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize +import com.fasterxml.jackson.databind.annotation.JsonSerialize +import com.fasterxml.jackson.databind.module.SimpleModule +import web5.sdk.common.Convert +import web5.sdk.common.EncodingFormat +import web5.sdk.common.Json +import web5.sdk.dids.did.BearerDid +import web5.sdk.jose.JwtClaimsSetDeserializer +import web5.sdk.jose.JwtClaimsSetSerializer +import web5.sdk.jose.jws.DecodedJws +import web5.sdk.jose.jws.Jws +import web5.sdk.jose.jws.JwsHeader +import java.security.SignatureException + +/** + * Json Web Token (JWT) is a compact, URL-safe means of representing claims to be transferred between two parties. + * Spec: https://datatracker.ietf.org/doc/html/rfc7519 + */ +public object Jwt { + + /** + * Decode a JWT into its parts. + * + * @param jwt The JWT string to decode + * @return DecodedJwt + */ + @Suppress("SwallowedException") + public fun decode(jwt: String): DecodedJwt { + val decodedJws = Jws.decode(jwt) + + val claims: JwtClaimsSet + try { + val payload = Convert(decodedJws.payload).toStr() + val jwtModule = SimpleModule() + .addDeserializer( + JwtClaimsSet::class.java, + JwtClaimsSetDeserializer() + ) + Json.jsonMapper.registerModule(jwtModule) + claims = Json.jsonMapper.readValue(payload, JwtClaimsSet::class.java) + } catch (e: Exception) { + throw SignatureException( + "Malformed JWT. " + + "Invalid base64url encoding for JWT payload. ${e.message}" + ) + } + + return DecodedJwt( + header = decodedJws.header, + claims = claims, + signature = decodedJws.signature, + parts = decodedJws.parts + ) + + } + + /** + * Sign a JwtClaimsSet using a Bearer DID. + * + * @param did The Bearer DID to sign with + * @param payload The JwtClaimsSet payload to sign + * @return The signed JWT + */ + public fun sign(did: BearerDid, payload: JwtClaimsSet): String { + val jwtModule = SimpleModule() + .addSerializer( + JwtClaimsSet::class.java, + JwtClaimsSetSerializer() + ) + Json.jsonMapper.registerModule(jwtModule) + val payloadJsonString = Json.jsonMapper.writeValueAsString(payload) + val payloadBytes = Convert(payloadJsonString).toByteArray() + + return Jws.sign(did, payloadBytes) + } + + /** + * Verify a JWT. + * + * @param jwt The JWT to verify + * @return DecodedJwt + */ + public fun verify(jwt: String): DecodedJwt { + val decodedJwt = decode(jwt) + decodedJwt.verify() + return decodedJwt + } +} + +/** + * DecodedJwt is a compact JWT decoded into its parts. + * + * @property header The JWT header + * @property claims The JWT claims + * @property signature The JWT signature + * @property parts The JWT parts + */ +public class DecodedJwt( + public val header: JwtHeader, + public val claims: JwtClaimsSet, + public val signature: ByteArray, + public val parts: List +) { + /** + * Verifies the JWT. + * + */ + public fun verify() { + val decodedJws = DecodedJws( + header = header, + payload = Convert(parts[1], EncodingFormat.Base64Url).toByteArray(), + signature = signature, + parts = parts + ) + decodedJws.verify() + } +} + +public typealias JwtHeader = JwsHeader + +/** + * Claims represents JWT (JSON Web Token) Claims + * Spec: https://datatracker.ietf.org/doc/html/rfc7519#section-4 + * + * @property iss identifies the principal that issued the + * @property sub the principal that is the subject of the JWT. + * @property aud the recipients that the JWT is intended for. + * @property exp the expiration time on or after which the JWT must not be accepted for processing. + * @property nbf the time before which the JWT must not be accepted for processing. + * @property iat the time at which the JWT was issued. + * @property jti provides a unique identifier for the JWT. + * @property misc additional claims (i.e. VerifiableCredential, VerifiablePresentation) + */ +@JsonSerialize(using = JwtClaimsSetSerializer::class) +@JsonDeserialize(using = JwtClaimsSetDeserializer::class) +public class JwtClaimsSet( + public val iss: String? = null, + public val sub: String? = null, + public val aud: String? = null, + public val exp: Long? = null, + public val nbf: Long? = null, + public val iat: Long? = null, + public val jti: String? = null, + public val misc: Map = emptyMap() +) { + + override fun toString(): String { + return "JwtClaimsSet(iss=$iss, sub=$sub, aud=$aud, exp=$exp, nbf=$nbf, iat=$iat, jti=$jti, misc=$misc)" + } + + /** + * Builder for JwtClaimsSet. + * + */ + public class Builder { + private var iss: String? = null + private var sub: String? = null + private var aud: String? = null + private var exp: Long? = null + private var nbf: Long? = null + private var iat: Long? = null + private var jti: String? = null + private var misc: MutableMap = mutableMapOf() + + /** + * Sets Issuer (iss) claim. + * + * @param iss The principal that issued the JWT + * @return Builder object + */ + public fun issuer(iss: String): Builder = apply { this.iss = iss } + + /** + * Sets Subject (sub) claim. + * + * @param sub The principal that is the subject of the JWT + * @return Builder object + */ + public fun subject(sub: String): Builder = apply { this.sub = sub } + + /** + * Sets Audience (aud) claim. + * + * @param aud The recipients that the JWT is intended for + * @return Builder object + */ + public fun audience(aud: String): Builder = apply { this.aud = aud } + + /** + * Sets Expiration Time (exp) claim. + * + * @param exp The expiration time on or after which the JWT must not be accepted for processing + * @return Builder object + */ + public fun expirationTime(exp: Long): Builder = apply { this.exp = exp } + + /** + * Sets Not Before (nbf) claim. + * + * @param nbf The time before which the JWT must not be accepted for processing + * @return Builder object + */ + public fun notBeforeTime(nbf: Long): Builder = apply { this.nbf = nbf } + + /** + * Sets Issued At (iat) claim. Denominated in seconds. + * + * @param iat The time at which the JWT was issued + * @return Builder object + */ + public fun issueTime(iat: Long): Builder = apply { this.iat = iat } + + /** + * Sets JWT ID (jti) claim. + * + * @param jti The unique identifier for the JWT + * @return Builder object + */ + public fun jwtId(jti: String): Builder = apply { this.jti = jti } + + /** + * Sets a custom claim. + * + * @param key The key of the custom claim + * @param value The value of the custom claim + * @return Builder object + */ + public fun misc(key: String, value: Any): Builder = apply { this.misc[key] = value } + + /**P + * Builds the JwtClaimsSet object. + * + * @return JwtClaimsSet + */ + public fun build(): JwtClaimsSet = JwtClaimsSet(iss, sub, aud, exp, nbf, iat, jti, misc) + } + +} diff --git a/jose/src/test/kotlin/web5/sdk/jose/jws/JwsTest.kt b/jose/src/test/kotlin/web5/sdk/jose/jws/JwsTest.kt new file mode 100644 index 000000000..247242ce8 --- /dev/null +++ b/jose/src/test/kotlin/web5/sdk/jose/jws/JwsTest.kt @@ -0,0 +1,125 @@ +package web5.sdk.jose.jws + +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.assertThrows +import web5.sdk.common.Convert +import web5.sdk.common.EncodingFormat +import web5.sdk.common.Json +import web5.sdk.crypto.InMemoryKeyManager +import web5.sdk.dids.methods.jwk.DidJwk +import java.security.SignatureException +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class JwsTest { + + @Nested + inner class DecodeTest { + + @Test + fun `decode fails if part size less than 3`() { + + assertThrows { + Jws.decode("a.b.c.d") + } + } + + @Test + fun `decode fails if header is not base64url`() { + val jwsString = "lol." + + "eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ." + + "SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" + + val exception = assertThrows { + Jws.decode(jwsString) + } + assertContains(exception.message!!, "Failed to decode header") + } + + @Test + fun `decode fails if payload is not base64url`() { + val jwsString = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." + + "{woohoo}." + + "SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" + val exception = assertThrows { + Jws.decode(jwsString) + } + + assertContains(exception.message!!, "Failed to decode payload") + + } + + @Test + fun `decode fails if signature is not base64url`() { + val jwsString = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." + + "eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ." + + "{woot}" + + val exception = assertThrows { + Jws.decode(jwsString) + } + + assertContains(exception.message!!, "Failed to decode signature") + } + + @Test + fun `decode succeeds with test jwt from jwtio`() { + val jwsString = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." + + "eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ." + + "SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" + + val decodedJws = Jws.decode(jwsString) + + assertEquals("HS256", decodedJws.header.alg) + assertEquals("JWT", decodedJws.header.typ) + val payloadStr = Convert(decodedJws.payload).toStr() + val payload = Json.parse>(payloadStr) + assertEquals("1234567890", payload["sub"]) + assertEquals(3, decodedJws.parts.size) + + } + } + + @Nested + inner class SignTest { + + @Test + fun `sign successfully creates signedJws`() { + val bearerDid = DidJwk.create(InMemoryKeyManager()) + val payload = "hello".toByteArray() + + val signedJws = Jws.sign(bearerDid, payload) + + val parts = signedJws.split(".") + assertEquals(3, parts.size) + val headerString = Convert(parts[0], EncodingFormat.Base64Url).toStr() + val header = Json.parse(headerString) + assertEquals("JWT", header.typ) + assertEquals("Ed25519", header.alg) + assertEquals(bearerDid.document.verificationMethod?.first()?.id, header.kid) + val decodedPayload = Convert(parts[1], EncodingFormat.Base64Url).toStr() + assertEquals("hello", decodedPayload) + + } + + @Test + fun `sign successfully creates detached signedJws`() { + val bearerDid = DidJwk.create(InMemoryKeyManager()) + val payload = "hello".toByteArray() + + val signedJws = Jws.sign(bearerDid, payload, detached = true) + + val parts = signedJws.split(".") + assertEquals(3, parts.size) + val headerString = Convert(parts[0], EncodingFormat.Base64Url).toStr() + val header = Json.parse(headerString) + assertEquals("JWT", header.typ) + assertEquals("Ed25519", header.alg) + assertEquals(bearerDid.document.verificationMethod?.first()?.id, header.kid) + assertEquals("", parts[1]) + } + + } +} \ No newline at end of file diff --git a/jose/src/test/kotlin/web5/sdk/jose/jwt/JwtTest.kt b/jose/src/test/kotlin/web5/sdk/jose/jwt/JwtTest.kt new file mode 100644 index 000000000..832e524f5 --- /dev/null +++ b/jose/src/test/kotlin/web5/sdk/jose/jwt/JwtTest.kt @@ -0,0 +1,64 @@ +package web5.sdk.jose.jwt + +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.assertDoesNotThrow +import org.junit.jupiter.api.assertThrows +import web5.sdk.crypto.InMemoryKeyManager +import web5.sdk.dids.methods.jwk.DidJwk +import java.security.SignatureException +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals + +class JwtTest { + + @Nested + inner class DecodeTest { + + @Test + fun `decode succeeds`() { + val bearerDid = DidJwk.create(InMemoryKeyManager()) + val claims = JwtClaimsSet.Builder() + .issuer("me") + .subject("you") + .misc("vc", mapOf("type" to listOf("VerifiableCredential"))) + .build() + val vcJwt = Jwt.sign(bearerDid, claims) + + val decodedJwt = Jwt.decode(vcJwt) + assertEquals(bearerDid.document.verificationMethod?.first()?.id, decodedJwt.header.kid) + assertEquals(claims.toString(), decodedJwt.claims.toString()) + } + + @Test + fun `decode fails due to payload failing and throws exception`() { + val vcJwt = + "eyJraWQiOiJkaWQ6a2V5OnpRM3NoTkx0MWFNV1BiV1JHYThWb2VFYkpvZko3" + + "eEplNEZDUHBES3hxMU5aeWdwaXkjelEzc2hOTHQxYU1XUGJXUkdhOFZvZU" + + "ViSm9mSjd4SmU0RkNQcERLeHExTlp5Z3BpeSIsInR5cCI6IkpXVCIsImFsZ" + + "yI6IkVTMjU2SyJ9.hehe.qoqF4-FinFsQ2J-NFSO46xCE8kUTZqZCU5fYr6t" + + "S0TQ6VP8y-ZnyR6R3oAqLs_Yo_CqQi23yi38uDjLjksiD2w" + + val exception = assertThrows { Jwt.decode(vcJwt) } + + assertContains(exception.message!!, "Malformed JWT. Invalid base64url encoding for JWT payload.") + } + } + + @Nested + inner class SignTest { + + @Test + fun `sign succeeds`() { + val bearerDid = DidJwk.create(InMemoryKeyManager()) + val claims = JwtClaimsSet.Builder() + .issuer("me") + .subject("you") + .misc("vc", mapOf("type" to listOf("VerifiableCredential"))) + .build() + + assertDoesNotThrow { Jwt.sign(bearerDid, claims) } + } + } + +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index dbfd46180..4bffd5ea4 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,3 +1,7 @@ +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "0.5.0" +} rootProject.name = "web5" include("common", "crypto", "dids", "credentials") include("testing") +include("jose") diff --git a/testing/src/main/kotlin/web5/sdk/testing/TestVectors.kt b/testing/src/main/kotlin/web5/sdk/testing/TestVectors.kt index a07d5b15d..b9be41b36 100644 --- a/testing/src/main/kotlin/web5/sdk/testing/TestVectors.kt +++ b/testing/src/main/kotlin/web5/sdk/testing/TestVectors.kt @@ -19,5 +19,5 @@ public class TestVector( public val description: String, public val input: I, public val output: O?, - public val errors: Boolean?, + public val errors: Boolean? = false, ) \ No newline at end of file diff --git a/web5-spec b/web5-spec index 7b292cb27..1f0c51f52 160000 --- a/web5-spec +++ b/web5-spec @@ -1 +1 @@ -Subproject commit 7b292cb276ce2ef4444fd034d89590a39005c8b8 +Subproject commit 1f0c51f52e27bde2947aadadd5d6e10238c8698e