Skip to content

Commit

Permalink
fix(castor): align DID document translation logic (#595)
Browse files Browse the repository at this point in the history
* feat(castor): make castor understand more key types

* fix(castor): update how DID doc is translated

* fix(pollux): support JsonWebKey2020 key type verification

* fix: use consistent EC name

* conditionally return canonicalId based on spec

* resolution metadata tests

* add versionId to metadata

* expose context to REST api

* tests: fix e2e test model

* chore: update OAS definition

* finish rebase to main

* dummy commit

* Add created and updated to resolution metadata

* expose timestamp resolution metadata

* update context validation rules

* add context validation tests

* update OAS spec manually

* tests: fix e2e model

* fix: fix context endpoints

* pr clean up

* revert oas manual update
  • Loading branch information
patlo-iog authored Jul 17, 2023
1 parent 3643de7 commit bb1f112
Show file tree
Hide file tree
Showing 28 changed files with 800 additions and 446 deletions.
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.iohk.atala.castor.core.model.did

import java.time.Instant
import scala.collection.immutable.ArraySeq

final case class DIDData(
Expand All @@ -12,5 +13,8 @@ final case class DIDData(

final case class DIDMetadata(
lastOperationHash: ArraySeq[Byte],
deactivated: Boolean
canonicalId: Option[CanonicalPrismDID],
deactivated: Boolean,
created: Option[Instant],
updated: Option[Instant]
)
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ package io.iohk.atala.castor.core.model.did
// in the "JSON Web Key Elliptic Curve" section
enum EllipticCurve(val name: String) {
case SECP256K1 extends EllipticCurve("secp256k1")
case ED25519 extends EllipticCurve("Ed25519")
case X25519 extends EllipticCurve("X25519")
}

object EllipticCurve {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,20 @@ object PublicKeyData {
crv: EllipticCurve,
data: Base64UrlString
) extends PublicKeyData {
def toUncompressedKeyData: ECKeyData = {
val prism14PublicKey = EC.INSTANCE.toPublicKeyFromCompressed(data.toByteArray)
val ecPoint = prism14PublicKey.getCurvePoint()
ECKeyData(
crv = crv,
x = Base64UrlString.fromByteArray(ecPoint.getX().bytes()),
y = Base64UrlString.fromByteArray(ecPoint.getY().bytes())
)
def toUncompressedKeyData: Option[ECKeyData] = {
crv match {
case EllipticCurve.SECP256K1 =>
val prism14PublicKey = EC.INSTANCE.toPublicKeyFromCompressed(data.toByteArray)
val ecPoint = prism14PublicKey.getCurvePoint()
Some(
ECKeyData(
crv = crv,
x = Base64UrlString.fromByteArray(ecPoint.getX().bytes()),
y = Base64UrlString.fromByteArray(ecPoint.getY().bytes())
)
)
case _ => None
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ type PublicKeyReprOrRef = PublicKeyRepr | String

final case class PublicKeyRepr(
id: String,
`type`: "EcdsaSecp256k1VerificationKey2019", // TODO: use JsonWebKey2020 (ATL-3788)
`type`: "JsonWebKey2020",
controller: String,
publicKeyJwk: PublicKeyJwk
)
Expand All @@ -31,4 +31,4 @@ final case class ServiceRepr(
serviceEndpoint: Json
)

final case class PublicKeyJwk(kty: "EC", crv: String, x: String, y: String)
final case class PublicKeyJwk(kty: String, crv: String, x: Option[String], y: Option[String])
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,10 @@ enum DIDResolutionErrorRepr(val value: String, val errorMessage: Option[String])
case UnsupportedPublicKeyType extends DIDResolutionErrorRepr("unsupportedPublicKeyType", None)
}

final case class DIDDocumentMetadataRepr(deactivated: Boolean, canonicalId: String, versionId: String)
final case class DIDDocumentMetadataRepr(
deactivated: Boolean,
canonicalId: Option[String],
versionId: String,
created: Option[String],
updated: Option[String]
)
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,29 @@ import io.iohk.atala.castor.core.model.did.ServiceType
import io.circe.Json
import io.iohk.atala.castor.core.model.did.ServiceEndpoint
import io.iohk.atala.castor.core.model.did.ServiceEndpoint.UriOrJsonEndpoint
import io.iohk.atala.castor.core.model.did.EllipticCurve
import java.time.format.DateTimeFormatter
import java.time.ZoneOffset
import java.time.Instant

object W3CModelHelper extends W3CModelHelper

private[castor] trait W3CModelHelper {

private val XML_DATETIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'")

private def toXmlDateTime(time: Instant): String = {
val zonedDateTime = time.atZone(ZoneOffset.UTC)
XML_DATETIME_FORMATTER.format(zonedDateTime)
}

extension (didMetadata: DIDMetadata) {
def toW3C(did: PrismDID): DIDDocumentMetadataRepr = DIDDocumentMetadataRepr(
def toW3C: DIDDocumentMetadataRepr = DIDDocumentMetadataRepr(
deactivated = didMetadata.deactivated,
canonicalId = did.asCanonical.toString,
versionId = HexString.fromByteArray(didMetadata.lastOperationHash.toArray).toString
canonicalId = didMetadata.canonicalId.map(_.toString),
versionId = HexString.fromByteArray(didMetadata.lastOperationHash.toArray).toString,
created = didMetadata.created.map(toXmlDateTime),
updated = didMetadata.updated.map(toXmlDateTime)
)
}

Expand Down Expand Up @@ -105,29 +118,62 @@ private[castor] trait W3CModelHelper {
}
}

// FIXME: do we need to support uncompress for OKP key types?
extension (publicKey: PublicKey) {
def toW3C(did: PrismDID, controller: PrismDID): PublicKeyRepr = PublicKeyRepr(
id = s"${did.toString}#${publicKey.id}",
`type` = "EcdsaSecp256k1VerificationKey2019",
controller = controller.toString,
publicKeyJwk = publicKey.publicKeyData match {
case PublicKeyData.ECKeyData(crv, x, y) =>
def toW3C(did: PrismDID, controller: PrismDID): PublicKeyRepr = {
val curve = publicKey.publicKeyData match {
case PublicKeyData.ECCompressedKeyData(crv, _) => crv
case PublicKeyData.ECKeyData(crv, _, _) => crv
}
val publicKeyJwk = curve match {
case EllipticCurve.SECP256K1 => secp256k1Repr(publicKey.publicKeyData)
case EllipticCurve.ED25519 => okpPublicKeyRepr(publicKey.publicKeyData)
case EllipticCurve.X25519 => okpPublicKeyRepr(publicKey.publicKeyData)
}
PublicKeyRepr(
id = s"${did.toString}#${publicKey.id}",
`type` = "JsonWebKey2020",
controller = controller.toString,
publicKeyJwk = publicKeyJwk
)
}

private def okpPublicKeyRepr(pk: PublicKeyData): PublicKeyJwk = {
pk match {
case PublicKeyData.ECCompressedKeyData(crv, data) =>
PublicKeyJwk(
kty = "EC",
kty = "OKP",
crv = crv.name,
x = x.toStringNoPadding,
y = y.toStringNoPadding
x = Some(data.toStringNoPadding),
y = None
)
case PublicKeyData.ECKeyData(crv, _, _) =>
throw Exception(s"Uncompressed key for curve ${crv.name} is not supported")
}
}

private def secp256k1Repr(pk: PublicKeyData): PublicKeyJwk = {
pk match {
case pk: PublicKeyData.ECCompressedKeyData =>
val uncompressed = pk.toUncompressedKeyData
val uncomporessed = pk.toUncompressedKeyData.getOrElse(
throw Exception(s"Conversion to uncompress key is not supported for curve ${pk.crv.name}")
)
PublicKeyJwk(
kty = "EC",
crv = uncompressed.crv.name,
x = uncompressed.x.toStringNoPadding,
y = uncompressed.y.toStringNoPadding
crv = uncomporessed.crv.name,
x = Some(uncomporessed.x.toStringNoPadding),
y = Some(uncomporessed.y.toStringNoPadding)
)
case PublicKeyData.ECKeyData(crv, x, y) =>
PublicKeyJwk(
kty = "EC",
crv = crv.name,
x = Some(x.toStringNoPadding),
y = Some(y.toStringNoPadding)
)

}
)
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ package object w3c {
} yield {
// https://www.w3.org/TR/did-core/#dfn-diddocument
// The value of id in the resolved DID document MUST match the DID that was resolved.
(didData._1.toW3C(prismDID), didData._2.toW3C(prismDID))
(didData._1.toW3C, didData._2.toW3C(prismDID))
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.iohk.atala.castor.core.service

import io.iohk.atala.castor.core.model.ProtoModelHelper
import io.iohk.atala.castor.core.model.did.{
CanonicalPrismDID,
DIDData,
Expand All @@ -12,17 +13,16 @@ import io.iohk.atala.castor.core.model.did.{
ScheduledDIDOperationDetail,
SignedPrismDIDOperation
}
import zio.*
import io.iohk.atala.castor.core.model.ProtoModelHelper
import io.iohk.atala.castor.core.model.error.OperationValidationError
import io.iohk.atala.castor.core.model.error.{DIDOperationError, DIDResolutionError}
import io.iohk.atala.castor.core.util.DIDOperationValidator
import io.iohk.atala.shared.models.HexString
import io.iohk.atala.prism.protos.{node_api, node_models}
import io.iohk.atala.prism.protos.node_api.NodeServiceGrpc.NodeService
import io.iohk.atala.prism.protos.node_models.OperationOutput.OperationMaybe

import io.iohk.atala.prism.protos.{node_api, node_models}
import io.iohk.atala.shared.models.HexString
import java.time.Instant
import scala.collection.immutable.ArraySeq
import io.iohk.atala.castor.core.model.error.OperationValidationError
import zio.*

trait DIDService {
def scheduleOperation(operation: SignedPrismDIDOperation): IO[DIDOperationError, ScheduleDIDOperationOutcome]
Expand Down Expand Up @@ -108,9 +108,14 @@ private class DIDServiceImpl(didOpValidator: DIDOperationValidator, nodeClient:
.flatMap(didData => ZIO.fromEither(didData.toDomain))
.mapError(DIDResolutionError.UnexpectedDLTResult.apply)
.map { didData =>
val (created, updated) = getMinMaxLedgerTime(didDataProto)
val metadata = DIDMetadata(
lastOperationHash = ArraySeq.from(result.lastUpdateOperation.toByteArray),
deactivated = didData.internalKeys.isEmpty && didData.publicKeys.isEmpty
canonicalId =
unpublishedDidData.map(_ => canonicalDID), // only shows canonicalId if long-form and published
deactivated = didData.internalKeys.isEmpty && didData.publicKeys.isEmpty,
created = created,
updated = updated
)
metadata -> didData
}
Expand All @@ -119,6 +124,17 @@ private class DIDServiceImpl(didOpValidator: DIDOperationValidator, nodeClient:
} yield publishedDidData.orElse(unpublishedDidData)
}

// FIXME: This doesn't play well detecting timestamp context and revoked service due to
// the response from Node missing the ledger data for those items.
private def getMinMaxLedgerTime(didData: node_models.DIDData): (Option[Instant], Option[Instant]) = {
val ledgerTimes = didData.publicKeys.flatMap(_.addedOn) ++
didData.publicKeys.flatMap(_.revokedOn) ++
didData.services.flatMap(_.addedOn) ++
didData.services.flatMap(_.deletedOn)
val instants = ledgerTimes.flatMap(_.toInstant)
(instants.minOption, instants.maxOption)
}

private def extractUnpublishedDIDData(did: LongFormPrismDID): IO[DIDResolutionError, (DIDMetadata, DIDData)] = {
ZIO
.fromEither(did.createOperation)
Expand All @@ -134,7 +150,10 @@ private class DIDServiceImpl(didOpValidator: DIDOperationValidator, nodeClient:
val metadata =
DIDMetadata(
lastOperationHash = ArraySeq.from(did.stateHash.toByteArray),
deactivated = false // unpublished DID cannot be deactivated
canonicalId = None, // unpublished DID must not contain canonicalId
deactivated = false, // unpublished DID cannot be deactivated
created = None, // unpublished DID cannot have timestamp
updated = None // unpublished DID cannot have timestamp
)
val didData = DIDData(
id = did.asCanonical,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ private object CreateOperationValidator extends BaseOperationValidator {
_ <- validateServiceEndpointLength(config)(operation, extractServiceEndpoint)
_ <- validateServiceTypeLength(config)(operation, extractServiceType)
_ <- validateUniqueContext(operation, _.context :: Nil)
_ <- validateContextLength(operation, _.context :: Nil)
_ <- validateContextIsUri(operation, _.context :: Nil)
_ <- validateMasterKeyExists(operation)
} yield ()
}
Expand Down Expand Up @@ -100,6 +102,8 @@ private object UpdateOperationValidator extends BaseOperationValidator {
_ <- validateServiceEndpointLength(config)(operation, extractServiceEndpoint)
_ <- validateServiceTypeLength(config)(operation, extractServiceType)
_ <- validateUniqueContext(operation, extractContexts)
_ <- validateContextLength(operation, extractContexts)
_ <- validateContextIsUri(operation, extractContexts)
_ <- validatePreviousOperationHash(operation, _.previousOperationHash)
_ <- validateNonEmptyUpdateAction(operation)
_ <- validateUpdateServiceNonEmpty(operation)
Expand Down Expand Up @@ -227,6 +231,34 @@ private trait BaseOperationValidator {
else Left(OperationValidationError.InvalidArgument("context is not unique"))
}

protected def validateContextIsUri[T <: PrismDIDOperation](
operation: T,
contextExtractor: ContextExtractor[T]
): Either[OperationValidationError, Unit] = {
val contexts = contextExtractor(operation)
val nonUriContexts = contexts.flatten.filterNot(UriUtils.isValidUriString)
if (nonUriContexts.isEmpty) Right(())
else
Left(
OperationValidationError.InvalidArgument(
s"context is not a valid URI: ${nonUriContexts.mkString("[", ", ", "]")}"
)
)
}

protected def validateContextLength[T <: PrismDIDOperation](
operation: T,
contextExtractor: ContextExtractor[T]
): Either[OperationValidationError, Unit] = {
val contexts = contextExtractor(operation)
val invalidContexts = contexts.flatten.filter(_.length > 100) // FIXME: confirm this value with the spec
if (invalidContexts.isEmpty) Right(())
else
Left(
OperationValidationError.InvalidArgument(s"context is too long: ${invalidContexts.mkString("[", ", ", "]")}")
)
}

protected def validateKeyIdIsUriFragment[T <: PrismDIDOperation](
operation: T,
keyIdExtractor: KeyIdExtractor[T]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ object W3CModelHelperSpec extends ZIOSpecDefault {
Seq(
"https://www.w3.org/ns/did/v1",
"https://identity.foundation/.well-known/did-configuration/v1",
// "https://w3id.org/security/suites/jws-2020/v1", // TODO: enable when align the key type (ATL-3788)
"https://w3id.org/security/suites/jws-2020/v1",
"user-defined-context"
)
)
Expand All @@ -133,7 +133,7 @@ object W3CModelHelperSpec extends ZIOSpecDefault {
hasSameElements(
Seq(
"https://www.w3.org/ns/did/v1",
// "https://w3id.org/security/suites/jws-2020/v1", // TODO: enable when align the key type (ATL-3788)
"https://w3id.org/security/suites/jws-2020/v1",
"user-defined-context"
)
)
Expand Down
Loading

0 comments on commit bb1f112

Please sign in to comment.