Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

fix(castor): align DID document translation logic #595

Merged
merged 24 commits into from
Jul 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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