diff --git a/.env-ui.sample b/.env-ui.sample index 94b376b66..e6d839ca1 100644 --- a/.env-ui.sample +++ b/.env-ui.sample @@ -4,5 +4,5 @@ ISSUER_UI_BUILD_TAG= ISSUER_UI_WARNING_MESSAGE= ISSUER_UI_IPFS_GATEWAY_URL=https://ipfs-proxy-cache.privado.id ISSUER_UI_SCHEMA_EXPLORER_AND_BUILDER_URL=https://tools.privado.id -ISSUER_UI_SCHEMA_EXPLORER_AND_BUILDER_URL=https://display-method-dev.privado.id +ISSUER_UI_DISPLAY_METHOD_BUILDER_URL=https://display-method-dev.privado.id ISSUER_UI_INSECURE=false \ No newline at end of file diff --git a/.github/workflows/delete_testing_env.yml b/.github/workflows/delete_testing_env.yml index 21718f71b..68f220d18 100644 --- a/.github/workflows/delete_testing_env.yml +++ b/.github/workflows/delete_testing_env.yml @@ -1,9 +1,14 @@ name: Delete Helm Release -on: [delete] +on: + workflow_dispatch: + inputs: + branch: + description: 'Branch to target' + required: true env: - BRANCH_NAME: ${{ github.event.ref }} + BRANCH_NAME: ${{ github.event.inputs.branch }} jobs: delete: @@ -24,14 +29,14 @@ jobs: echo "Extracted URL: $url" echo "::set-output name=url::$url" - - name: Cambiar contexto de kubectl + - name: change k3s context run: | kubectl config use-context k3s - - name: Verificar conexión al clúster + - name: check if the cluster exists run: kubectl cluster-info - - name: Check if helm chart exists + - name: check if helm chart exists id: helm_check run: | result=$(helm list --namespace "${{ steps.extract-url.outputs.url }}" -q | grep "^${{ steps.extract-url.outputs.url }}$" || echo 'not_found') @@ -48,7 +53,7 @@ jobs: overrule_existing_kubeconfig: "true" if: steps.helm_check.outputs.result != 'not_found' - - name: "Delete namespace" + - name: "delete namespace" run: | kubectl delete namespace ${{ env.BRANCH_NAME }} if: steps.helm_check.outputs.result != 'not_found' diff --git a/.github/workflows/deploy_testing_env.yml b/.github/workflows/deploy_testing_env.yml index 499288f23..1a7c75060 100644 --- a/.github/workflows/deploy_testing_env.yml +++ b/.github/workflows/deploy_testing_env.yml @@ -114,7 +114,7 @@ jobs: exec: helm install "${{ steps.extract-url.outputs.url }}" --create-namespace ./k8s/helm --wait --atomic --timeout 5m --namespace="${{ steps.extract-url.outputs.url }}" --values=./k8s/helm/values.yaml --set apidomain="${{ env.API_DOMAIN }}" --set uidomain="${{ env.UI_DOMAIN }}" --set privateKey=${{ secrets.ISSUER_NODE_TESTING_PRIVATE_KEY }} --set ingressEnabled="true" --set vaultpwd="foo" --set issuerUiInsecure=true --set issuerResolverFile="${{ secrets.ISSUER_NODE_TESTING_ISSUER_RESOLVER_FILE }}" --set issuernode_repository_image="${{ env.ISSUER_NODE_API_IMAGE }}" --set issuernode_repository_tag="${{ steps.version.outputs.VERSION }}" --set issuernode_ui_repository_image="${{ env.ISSUER_NODE_UI_IMAGE }}" --set issuernode_ui_repository_tag="${{ steps.version.outputs.VERSION }}" kubeconfig: "${{ secrets.KUBECONFIG }}" overrule_existing_kubeconfig: "true" - if: steps.helm_check.outputs.result == 'not_found' + if: steps.helm_check.outputs.result == 'not_found' && steps.extract-url.outputs.url != '' - name: "Update helm chart" uses: WyriHaximus/github-action-helm3@v3.0 @@ -122,4 +122,4 @@ jobs: exec: helm upgrade "${{ steps.extract-url.outputs.url }}" ./k8s/helm/ --wait --atomic --timeout 5m --namespace="${{ steps.extract-url.outputs.url }}" --values=./k8s/helm/values.yaml --set apidomain="${{ env.API_DOMAIN }}" --set uidomain="${{ env.UI_DOMAIN }}" --set privateKey=${{ secrets.ISSUER_NODE_TESTING_PRIVATE_KEY }} --set ingressEnabled="true" --set vaultpwd="foo" --set issuerUiInsecure=true --set issuerResolverFile="${{ secrets.ISSUER_NODE_TESTING_ISSUER_RESOLVER_FILE }}" --set issuernode_repository_image="${{ env.ISSUER_NODE_API_IMAGE }}" --set issuernode_repository_tag="${{ steps.version.outputs.VERSION }}" --set issuernode_ui_repository_image="${{ env.ISSUER_NODE_UI_IMAGE }}" --set issuernode_ui_repository_tag="${{ steps.version.outputs.VERSION }}" kubeconfig: "${{ secrets.KUBECONFIG }}" overrule_existing_kubeconfig: "true" - if: steps.helm_check.outputs.result != 'not_found' \ No newline at end of file + if: steps.helm_check.outputs.result != 'not_found' && steps.extract-url.outputs.url != '' \ No newline at end of file diff --git a/api/api.yaml b/api/api.yaml index 7b293854b..b65a6fbcd 100644 --- a/api/api.yaml +++ b/api/api.yaml @@ -26,6 +26,8 @@ tags: description: Collection of endpoints related to Mobile - name: config description: Collection of endpoints related to Config + - name: Key Management + description: Collection of endpoints related to Key Management paths: @@ -425,6 +427,51 @@ paths: '500': $ref: '#/components/responses/500' + /v2/identities/{identifier}/create-auth-credential: + post: + summary: Create Auth Credential + operationId: CreateAuthCredential + description: | + Endpoint to create a new Auth Credential + * keyID - only babyjubjub keys supported + + security: + - basicAuth: [ ] + parameters: + - $ref: '#/components/parameters/pathIdentifier2' + tags: + - Identity + - Key Management + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateAuthCredentialRequest' + responses: + '201': + description: Key added successfully + content: + application/json: + schema: + type: object + required: + - id + - credentialStatusType + properties: + id: + type: string + description: The ID of the created Auth Credential + x-go-type: uuid.UUID + x-go-type-import: + name: uuid + path: github.com/google/uuid + example: 8edd8112-c415-11ed-b036-debe37e1cbd6 + '400': + $ref: '#/components/responses/400' + '500': + $ref: '#/components/responses/500' + #connections: /v2/identities/{identifier}/connections/{id}: get: @@ -1469,6 +1516,179 @@ paths: $ref: '#/components/responses/404' '500': $ref: '#/components/responses/500' + /v2/identities/{identifier}/keys: + post: + summary: Create a Key + operationId: CreateKey + description: Endpoint to create a new key. + tags: + - Key Management + security: + - basicAuth: [ ] + parameters: + - $ref: '#/components/parameters/pathIdentifier2' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateKeyRequest' + responses: + '201': + description: Crated Key + content: + application/json: + schema: + $ref: '#/components/schemas/CreateKeyResponse' + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' + + get: + summary: Get Keys + operationId: GetKeys + description: | + Returns a list of Keys for the provided identity. + security: + - basicAuth: [ ] + tags: + - Key Management + parameters: + - $ref: '#/components/parameters/pathIdentifier2' + - in: query + name: max_results + schema: + type: integer + format: uint + example: 50 + default: 50 + description: Number of items to fetch on each page. Minimum is 10. Default is 50. No maximum by the moment. + - in: query + name: page + schema: + type: integer + format: uint + minimum: 1 + example: 1 + description: Page to fetch. First is one. If omitted, page 1 will be returned. + - in: query + name: type + schema: + type: string + x-omitempty: false + example: "babyjubJub" + enum: [ babyjubJub, secp256k1 ] + description: If not provided, all keys will be returned. + responses: + '200': + description: Keys collection + content: + application/json: + schema: + $ref: '#/components/schemas/KeysPaginated' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' + + /v2/identities/{identifier}/keys/{id}: + get: + summary: Get a Key + operationId: GetKey + description: Get a specific key for the provided identity. + tags: + - Key Management + security: + - basicAuth: [ ] + parameters: + - $ref: '#/components/parameters/pathIdentifier2' + - $ref: '#/components/parameters/pathKeyID' + responses: + '200': + description: Key found + content: + application/json: + schema: + $ref: '#/components/schemas/Key' + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' + patch: + summary: Update a Key + operationId: UpdateKey + description: Update a specific key. + tags: + - Key Management + security: + - basicAuth: [ ] + parameters: + - $ref: '#/components/parameters/pathIdentifier2' + - $ref: '#/components/parameters/pathKeyID' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - name + properties: + name: + type: string + example: "New Key Name" + responses: + '200': + description: Key found + content: + application/json: + schema: + $ref: '#/components/schemas/GenericMessage' + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' + delete: + summary: Delete Key + operationId: DeleteKey + description: Remove a specific key for the provided identity. + tags: + - Identity + security: + - basicAuth: [ ] + parameters: + - $ref: '#/components/parameters/pathIdentifier2' + - $ref: '#/components/parameters/pathKeyID' + responses: + '200': + description: Key deleted + content: + application/json: + schema: + $ref: '#/components/schemas/GenericMessage' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' + + /v2/identities/{identifier}/payment-request: get: @@ -1853,6 +2073,7 @@ components: - status - keyType - credentialStatusType + - authCredentialsIDs properties: identifier: type: string @@ -1873,6 +2094,10 @@ components: type: string example: "Iden3ReverseSparseMerkleTreeProof" enum: [ Iden3commRevocationStatusV1.0, Iden3ReverseSparseMerkleTreeProof, Iden3OnchainSparseMerkleTreeProof2023 ] + authCredentialsIDs: + type: array + items: + type: string IdentityState: type: object @@ -2856,7 +3081,7 @@ components: issuerDoc: type: object format: byte - + SupportedNetworks: type: object x-omitempty: false @@ -2895,22 +3120,113 @@ components: path: github.com/iden3/iden3comm/v2/protocol CreateDisplayMethodRequest: - type: object - required: - - name - - url - properties: - name: - type: string - example: "My Display Method" - url: - type: string - example: "https://my-display-method.com" - type: - type: string - example: "Iden3BasicDisplayMethodV1" - description: "Display method type (Iden3BasicDisplayMethodV1 is default value)" + type: object + required: + - name + - url + properties: + name: + type: string + example: "My Display Method" + url: + type: string + example: "https://my-display-method.com" + type: + type: string + example: "Iden3BasicDisplayMethodV1" + description: "Display method type (Iden3BasicDisplayMethodV1 is default value)" + + CreateKeyRequest: + type: object + required: + - keyType + - name + properties: + keyType: + type: string + x-omitempty: false + example: "babyjubJub" + enum: [ babyjubJub, secp256k1 ] + name: + type: string + example: "my key" + + CreateKeyResponse: + type: object + required: + - id + properties: + id: + type: string + x-omitempty: false + description: base64 encoded keyID + example: a2V5cy9kaWQ6aWRlbjM6cG9seWdvbjphbW95OnhKQktvbkJ1dWdKbW1aMkdvS2gzOTM + + Key: + type: object + required: + - id + - keyType + - publicKey + - isAuthCredential + - name + properties: + id: + type: string + x-omitempty: false + example: ZGlkOnBvbHlnb25pZDpwb2x5Z29uOmFtb3k6MnFRNjhKa1JjZjN5cXBYanRqVVQ3WjdVeW1TV0hzYll + description: base64 encoded keyID + keyType: + type: string + x-omitempty: false + example: "babyjubJub" + enum: [ babyjubJub, secp256k1 ] + publicKey: + type: string + x-omitempty: false + example: "0x04e3e7e" + isAuthCredential: + type: boolean + x-omitempty: false + example: true + name: + type: string + x-omitempty: false + example: "my key" + KeysPaginated: + type: object + required: [ items, meta ] + properties: + items: + type: array + items: + $ref: '#/components/schemas/Key' + meta: + $ref: '#/components/schemas/PaginatedMetadata' + + CreateAuthCredentialRequest: + type: object + required: [keyID, credentialStatusType] + properties: + keyID: + type: string + x-omitempty: false + example: ZGlkOnBvbHlnb25pZDpwb2x5Z29uOmFtb3k6MnFRNjhKa1JjZjN5cXBYanRqVVQ3WjdVeW1TV0hzYll + expiration: + type: integer + format: int64 + version: + type: integer + format: uint32 + revNonce: + type: integer + format: uint64 + credentialStatusType: + type: string + x-omitempty: true + example: "Iden3ReverseSparseMerkleTreeProof" + enum: [ Iden3commRevocationStatusV1.0, Iden3ReverseSparseMerkleTreeProof, Iden3OnchainSparseMerkleTreeProof2023 ] parameters: credentialStatusType: @@ -2969,6 +3285,18 @@ components: schema: type: string + pathIdentifier2: + name: identifier + in: path + required: true + description: Issuer identifier + schema: + type: string + x-go-type: Identity + x-go-type-import: + name: customIdentity + path: github.com/polygonid/sh-id-platform/internal/api + pathClaim: name: id in: path @@ -2986,6 +3314,15 @@ components: type: integer format: int64 + pathKeyID: + name: id + in: path + required: true + description: Key ID in base64 + schema: + type: string + + responses: '400': description: 'Bad Request' @@ -3059,4 +3396,4 @@ components: code: type: integer error: - type: string + type: string \ No newline at end of file diff --git a/api/spec.html b/api/spec.html index f653c5747..ae3fa7a07 100644 --- a/api/spec.html +++ b/api/spec.html @@ -9,7 +9,7 @@ = len(keyIDs) { + return []*ports.KMSKey{}, 0, nil + } + + if end > len(keyIDs) { + end = len(keyIDs) + } + + keys := make([]*ports.KMSKey, len(keyIDs)) + for i, keyID := range keyIDs { + key, err := ks.Get(ctx, did, keyID.ID) + if err != nil { + log.Error(ctx, "failed to get key", "err", err) + return nil, 0, err + } + keys[i] = key + } + + sort.Slice(keys, func(i, j int) bool { + return keys[i].Name < keys[j].Name + }) + + keys = keys[start:end] + return keys, total, nil +} + +// Delete deletes the key with the given keyID +func (ks *Key) Delete(ctx context.Context, did *w3c.DID, keyID string) error { + keyType, err := getKeyType(keyID) + if err != nil { + log.Error(ctx, "failed to get key type", "err", err) + return err + } + + kmsKeyID := kms.KeyID{ + ID: keyID, + Type: keyType, + } + + exists, err := ks.kms.Exists(ctx, kmsKeyID) + if err != nil { + log.Error(ctx, "failed to check if key exists", "err", err) + return err + } + + if !exists { + return ErrKeyNotFound + } + + publicKey, err := ks.getPublicKey(ctx, keyID) + if err != nil { + log.Error(ctx, "failed to get public key", "err", err) + return err + } + + hasAssociatedAuthCoreCredential := false + var authCredential *domain.Claim + switch keyType { + case kms.KeyTypeBabyJubJub: + hasAssociatedAuthCoreCredential, authCredential, err = ks.hasAssociatedAuthCredential(ctx, did, publicKey) + if err != nil { + log.Error(ctx, "failed to check if key has associated auth credential", "err", err) + return err + } + + if hasAssociatedAuthCoreCredential { + log.Info(ctx, "can not be deleted because it has an associated auth credential. Have to check revocation status") + revStatus, err := ks.claimService.GetRevocationStatus(ctx, *did, uint64(authCredential.RevNonce)) + if err != nil { + log.Error(ctx, "failed to get revocation status", "err", err) + return err + } + + if revStatus != nil && !revStatus.MTP.Existence { + log.Info(ctx, "auth credential is non revoked. Can not be deleted") + return ErrAuthCredentialNotRevoked + } + } + case kms.KeyTypeEthereum: + hasAssociatedAuthCoreCredential, err = ks.isAssociatedWithIdentity(ctx, did, publicKey) + if err != nil { + log.Error(ctx, "failed to check if key has associated auth credential", "err", err) + return err + } + if hasAssociatedAuthCoreCredential { + log.Info(ctx, "can not be deleted because it is associated with the identity") + return ErrKeyAssociatedWithIdentity + } + default: + return ErrInvalidKeyType + } + + if err := ks.keyRepository.Delete(ctx, *did, hexutil.Encode(publicKey)); err != nil { + log.Error(ctx, "failed to delete key", "err", err) + return err + } + return ks.kms.Delete(ctx, kmsKeyID) +} + +// getPublicKey returns the public key for the given keyID +func (ks *Key) getPublicKey(ctx context.Context, keyID string) ([]byte, error) { + keyType, err := getKeyType(keyID) + if err != nil { + log.Error(ctx, "failed to get key type", "err", err) + return nil, err + } + kmsKeyID := kms.KeyID{ + ID: keyID, + Type: keyType, + } + + return ks.kms.PublicKey(kmsKeyID) +} + +// getKeyType returns the key type for the given keyID +func getKeyType(keyID string) (kms.KeyType, error) { + var keyType kms.KeyType + if strings.Contains(keyID, "BJJ") { + keyType = kms.KeyTypeBabyJubJub + } else if strings.Contains(keyID, "ETH") { + keyType = kms.KeyTypeEthereum + } else { + return keyType, ErrInvalidKeyType + } + + return keyType, nil +} + +// hasAssociatedAuthCredential checks if the bbj key has an associated auth credential +func (ks *Key) hasAssociatedAuthCredential(ctx context.Context, did *w3c.DID, publicKey []byte) (bool, *domain.Claim, error) { + hasAssociatedAuthCredential := false + authCredential, err := ks.claimService.GetAuthCredentialByPublicKey(ctx, did, publicKey) + if err != nil { + log.Error(ctx, "failed to check if key has associated auth credential", "err", err) + return false, nil, err + } + hasAssociatedAuthCredential = authCredential != nil + return hasAssociatedAuthCredential, authCredential, nil +} + +// isAssociatedWithIdentity checks if the eth key is associated with the identity +func (ks *Key) isAssociatedWithIdentity(ctx context.Context, did *w3c.DID, publicKey []byte) (bool, error) { + hasAssociatedAuthCredential := false + pubKey, err := crypto.DecompressPubkey(publicKey) + if err != nil { + log.Error(ctx, "failed to decompress public key", "err", err) + return false, err + } + + keyETHAddress := crypto.PubkeyToAddress(*pubKey) + isEthAddress, identityAddress, err := common.CheckEthIdentityByDID(did) + if err != nil { + log.Error(ctx, "failed to check if DID is ETH identity", "err", err) + return false, err + } + + identityAddressToBeChecked := strings.ToUpper("0x" + identityAddress) + if isEthAddress { + hasAssociatedAuthCredential = identityAddressToBeChecked == strings.ToUpper(keyETHAddress.Hex()) + } + + return hasAssociatedAuthCredential, nil +} diff --git a/internal/core/services/link_test.go b/internal/core/services/link_test.go index 4730975d2..1550e61fd 100644 --- a/internal/core/services/link_test.go +++ b/internal/core/services/link_test.go @@ -35,6 +35,7 @@ func Test_link_issueClaim(t *testing.T) { schemaRepository := repositories.NewSchema(*storage) mtService := NewIdentityMerkleTrees(mtRepo) connectionsRepository := repositories.NewConnection() + keyRepository := repositories.NewKey(*storage) reader := common.CreateFile(t) networkResolver, err := networkPkg.NewResolver(ctx, cfg, keyStore, reader) @@ -42,7 +43,7 @@ func Test_link_issueClaim(t *testing.T) { rhsFactory := reversehash.NewFactory(*networkResolver, reversehash.DefaultRHSTimeOut) revocationStatusResolver := revocationstatus.NewRevocationStatusResolver(*networkResolver) - identityService := NewIdentity(keyStore, identityRepo, mtRepo, identityStateRepo, mtService, nil, claimsRepo, revocationRepository, connectionsRepository, storage, nil, nil, pubsub.NewMock(), *networkResolver, rhsFactory, revocationStatusResolver) + identityService := NewIdentity(keyStore, identityRepo, mtRepo, identityStateRepo, mtService, nil, claimsRepo, revocationRepository, connectionsRepository, storage, nil, nil, pubsub.NewMock(), *networkResolver, rhsFactory, revocationStatusResolver, keyRepository) sessionRepository := repositories.NewSessionCached(cachex) schemaService := NewSchema(schemaRepository, docLoader) diff --git a/internal/core/services/notification_test.go b/internal/core/services/notification_test.go index cc9c99a06..77abc02cc 100644 --- a/internal/core/services/notification_test.go +++ b/internal/core/services/notification_test.go @@ -39,6 +39,7 @@ func TestNotification_SendNotification(t *testing.T) { mtService := NewIdentityMerkleTrees(mtRepo) revocationRepository := repositories.NewRevocation() connectionsRepository := repositories.NewConnection() + keyRepository := repositories.NewKey(*storage) reader := common.CreateFile(t) networkResolver, err := networkPkg.NewResolver(ctx, cfg, keyStore, reader) @@ -46,7 +47,7 @@ func TestNotification_SendNotification(t *testing.T) { rhsFactory := reversehash.NewFactory(*networkResolver, reversehash.DefaultRHSTimeOut) revocationStatusResolver := revocationstatus.NewRevocationStatusResolver(*networkResolver) - identityService := NewIdentity(keyStore, identityRepo, mtRepo, identityStateRepo, mtService, nil, claimsRepo, revocationRepository, connectionsRepository, storage, nil, nil, pubsub.NewMock(), *networkResolver, rhsFactory, revocationStatusResolver) + identityService := NewIdentity(keyStore, identityRepo, mtRepo, identityStateRepo, mtService, nil, claimsRepo, revocationRepository, connectionsRepository, storage, nil, nil, pubsub.NewMock(), *networkResolver, rhsFactory, revocationStatusResolver, keyRepository) mediaTypeManager := NewMediaTypeManager( map[iden3comm.ProtocolMessage][]string{ diff --git a/internal/db/schema/migrations/202412101722170_add_keys_table.sql b/internal/db/schema/migrations/202412101722170_add_keys_table.sql new file mode 100644 index 000000000..faf15b958 --- /dev/null +++ b/internal/db/schema/migrations/202412101722170_add_keys_table.sql @@ -0,0 +1,18 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE keys( + id UUID PRIMARY KEY NOT NULL, + issuer_did text NOT NULL, + name text NOT NULL, + public_key text NOT NULL, + created_at timestamptz NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT keys_unique_name UNIQUE (issuer_did, name), + CONSTRAINT keys_identities_id_key foreign key (issuer_did) references identities (identifier) +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS keys; +-- +goose StatementEnd \ No newline at end of file diff --git a/internal/db/schema/migrations/202411131724000_payment_options.sql b/internal/db/schema/migrations/202412231508172_payment_options.sql similarity index 100% rename from internal/db/schema/migrations/202411131724000_payment_options.sql rename to internal/db/schema/migrations/202412231508172_payment_options.sql diff --git a/internal/kms/aws_kms_eth_key_provider.go b/internal/kms/aws_kms_eth_key_provider.go index 3494c162f..39972e235 100644 --- a/internal/kms/aws_kms_eth_key_provider.go +++ b/internal/kms/aws_kms_eth_key_provider.go @@ -2,7 +2,6 @@ package kms import ( "context" - "encoding/hex" "fmt" "regexp" "strings" @@ -18,7 +17,11 @@ import ( "github.com/polygonid/sh-id-platform/internal/log" ) -const aliasPrefix = "alias/" +const ( + aliasPrefix = "alias/" + awsKmdKeyIDPrefix = "ETH/" + awsKmsKeyIDParts = 2 +) type awsKmsEthKeyProvider struct { keyType KeyType @@ -79,37 +82,26 @@ func (awsKeyProv *awsKmsEthKeyProvider) New(identity *w3c.DID) (KeyID, error) { keyArn, err := awsKeyProv.kmsClient.CreateKey(ctx, input) if err != nil { - log.Error(ctx, "failed to create key: %v", err) + log.Error(ctx, "failed to create key", "err", err) return KeyID{}, fmt.Errorf("failed to create key: %v", err) } - log.Info(ctx, "keyArn:", keyArn.KeyMetadata.Arn) - inputPublicKey := &kms.GetPublicKeyInput{ - KeyId: aws.String(*keyArn.KeyMetadata.Arn), - } - - publicKeyResult, err := awsKeyProv.kmsClient.GetPublicKey(ctx, inputPublicKey) - if err != nil { - return KeyID{}, fmt.Errorf("failed to get public key: %v", err) - } - pubKeyHex := hex.EncodeToString(publicKeyResult.PublicKey) - keyID.ID = keyPathForAws(identity, awsKeyProv.keyType, pubKeyHex) - - aliasName := aliasPrefix + keyID.ID - err = awsKeyProv.createAlias(ctx, aliasName, *keyArn.KeyMetadata.KeyId) - if err != nil { - log.Error(ctx, "failed to create alias: %v", err) - return KeyID{}, fmt.Errorf("failed to create alias: %v", err) - } - keyID.ID = aliasName + log.Info(ctx, "keyArn", "keyArn", keyArn.KeyMetadata.Arn) + keyID.ID = awsKmdKeyIDPrefix + *keyArn.KeyMetadata.KeyId return keyID, nil } // PublicKey returns public key for given keyID func (awsKeyProv *awsKmsEthKeyProvider) PublicKey(keyID KeyID) ([]byte, error) { + ctx := context.Background() if keyID.ID == awsKeyProv.issuerETHTransferKeyPath { keyID.ID = aliasPrefix + awsKeyProv.issuerETHTransferKeyPath + } else { + keyIDParts := strings.Split(keyID.ID, "ETH/") + if len(keyIDParts) == awsKmsKeyIDParts { + keyID.ID = keyIDParts[1] + } } - ctx := context.Background() + inputPublicKey := &kms.GetPublicKeyInput{ KeyId: aws.String(keyID.ID), } @@ -124,7 +116,14 @@ func (awsKeyProv *awsKmsEthKeyProvider) PublicKey(keyID KeyID) ([]byte, error) { func (awsKeyProv *awsKmsEthKeyProvider) Sign(ctx context.Context, keyID KeyID, data []byte) ([]byte, error) { if keyID.ID == awsKeyProv.issuerETHTransferKeyPath { keyID.ID = aliasPrefix + awsKeyProv.issuerETHTransferKeyPath + } else { + keyIDParts := strings.Split(keyID.ID, awsKmdKeyIDPrefix) + if len(keyIDParts) != awsKmsKeyIDParts { + return nil, fmt.Errorf("invalid keyID: %v", keyID.ID) + } + keyID.ID = keyIDParts[1] } + signInput := &kms.SignInput{ KeyId: aws.String(keyID.ID), Message: data, @@ -165,14 +164,13 @@ func (awsKeyProv *awsKmsEthKeyProvider) Sign(ctx context.Context, keyID KeyID, d // LinkToIdentity links key to identity func (awsKeyProv *awsKmsEthKeyProvider) LinkToIdentity(ctx context.Context, keyID KeyID, identity w3c.DID) (KeyID, error) { - keyMetadata, err := awsKeyProv.getKeyInfoByAlias(ctx, keyID.ID) + keyIDStr, err := getAwsKmsKeyID(keyID) if err != nil { - log.Error(ctx, "failed to get key metadata", "keyMetadata", keyMetadata, "err", err) - return KeyID{}, fmt.Errorf("failed to get key metadata: %v", err) + return KeyID{}, err } tagResourceInput := &kms.TagResourceInput{ - KeyId: keyMetadata.KeyId, + KeyId: aws.String(keyIDStr), Tags: []types.Tag{ { TagKey: aws.String("keyType"), @@ -187,18 +185,20 @@ func (awsKeyProv *awsKmsEthKeyProvider) LinkToIdentity(ctx context.Context, keyI resourceOutput, err := awsKeyProv.kmsClient.TagResource(ctx, tagResourceInput) if err != nil { - log.Error(ctx, "failed to tag resource: %v", err) + log.Error(ctx, "failed to tag resource", "err", err) return KeyID{}, fmt.Errorf("failed to tag resource: %v", err) } log.Info(ctx, "resource tagged:", "resourceOutput:", resourceOutput.ResultMetadata) - keyID.ID = identity.String() return keyID, nil } // ListByIdentity returns list of keyIDs for given identity func (awsKeyProv *awsKmsEthKeyProvider) ListByIdentity(ctx context.Context, identity w3c.DID) ([]KeyID, error) { - listKeysInput := &kms.ListKeysInput{} + const limit = 500 + listKeysInput := &kms.ListKeysInput{ + Limit: aws.Int32(limit), + } listKeysOutput, err := awsKeyProv.kmsClient.ListKeys(ctx, listKeysInput) if err != nil { return nil, fmt.Errorf("failed to list keys: %w", err) @@ -217,9 +217,10 @@ func (awsKeyProv *awsKmsEthKeyProvider) ListByIdentity(ctx context.Context, iden for _, tag := range tagOutput.Tags { if aws.ToString(tag.TagKey) == "did" && aws.ToString(tag.TagValue) == identity.String() { + id := "ETH/" + *key.KeyId keysToReturn = append(keysToReturn, KeyID{ Type: KeyTypeEthereum, - ID: aws.ToString(key.KeyId), + ID: aws.ToString(&id), }) } } @@ -228,25 +229,41 @@ func (awsKeyProv *awsKmsEthKeyProvider) ListByIdentity(ctx context.Context, iden return keysToReturn, nil } -// createAlias creates alias for key -func (awsKeyProv *awsKmsEthKeyProvider) createAlias(ctx context.Context, aliasName, targetKeyId string) error { - input := &kms.CreateAliasInput{ - AliasName: aws.String(aliasName), - TargetKeyId: aws.String(targetKeyId), +// Delete deletes key by keyID +func (awsKeyProv *awsKmsEthKeyProvider) Delete(ctx context.Context, keyID KeyID) error { + const pendingWindowInDays = 7 + keyIDStr, err := getAwsKmsKeyID(keyID) + if err != nil { + return err } - _, err := awsKeyProv.kmsClient.CreateAlias(ctx, input) + _, err = awsKeyProv.kmsClient.ScheduleKeyDeletion(ctx, &kms.ScheduleKeyDeletionInput{ + KeyId: aws.String(keyIDStr), + PendingWindowInDays: aws.Int32(pendingWindowInDays), + }) + return err +} + +// Exists checks if key exists +func (awsKeyProv *awsKmsEthKeyProvider) Exists(ctx context.Context, keyID KeyID) (bool, error) { + keyIDStr, err := getAwsKmsKeyID(keyID) if err != nil { - return fmt.Errorf("failed to create alias: %v", err) + return false, err + } + keyInfo, err := awsKeyProv.getKeyInfo(ctx, keyIDStr) + if err != nil { + return false, nil } - log.Info(ctx, "alias created:", "aliasName:", aliasName) - return nil + if keyInfo != nil && keyInfo.DeletionDate != nil { + return false, nil + } + return true, nil } -// getKeyInfoByAlias returns key metadata by alias -func (awsKeyProv *awsKmsEthKeyProvider) getKeyInfoByAlias(ctx context.Context, aliasName string) (*types.KeyMetadata, error) { +// getKeyInfo returns key metadata by key id +func (awsKeyProv *awsKmsEthKeyProvider) getKeyInfo(ctx context.Context, keyID string) (*types.KeyMetadata, error) { aliasInput := &kms.DescribeKeyInput{ - KeyId: aws.String(aliasName), + KeyId: aws.String(keyID), } aliasOutput, err := awsKeyProv.kmsClient.DescribeKey(ctx, aliasInput) if err != nil { @@ -255,3 +272,11 @@ func (awsKeyProv *awsKmsEthKeyProvider) getKeyInfoByAlias(ctx context.Context, a } return aliasOutput.KeyMetadata, nil } + +func getAwsKmsKeyID(keyID KeyID) (string, error) { + keyIDParts := strings.Split(keyID.ID, awsKmdKeyIDPrefix) + if len(keyIDParts) != awsKmsKeyIDParts { + return "", fmt.Errorf("invalid keyID: %v", keyID.ID) + } + return keyIDParts[1], nil +} diff --git a/internal/kms/aws_kms_eth_key_provider_test.go b/internal/kms/aws_kms_eth_key_provider_test.go index ac55ecc10..c4c4fbcf9 100644 --- a/internal/kms/aws_kms_eth_key_provider_test.go +++ b/internal/kms/aws_kms_eth_key_provider_test.go @@ -71,12 +71,10 @@ func Test_LinkToIdentityInAWSKMS(t *testing.T) { assert.NoError(t, err) assert.NotEmpty(t, keyID.ID) assert.Equal(t, ethereum, string(keyID.Type)) - identity := randomDID(t) - - keyID, err = awsStorageProvider.LinkToIdentity(ctx, keyID, identity) + newKeyID, err := awsStorageProvider.LinkToIdentity(ctx, keyID, identity) assert.NoError(t, err) - assert.Equal(t, identity.String(), keyID.ID) + assert.Equal(t, keyID.ID, newKeyID.ID) }) t.Run("should get an error", func(t *testing.T) { diff --git a/internal/kms/aws_secret_storage_provider.go b/internal/kms/aws_secret_storage_provider.go index 57bae4e1a..9cb6cbecd 100644 --- a/internal/kms/aws_secret_storage_provider.go +++ b/internal/kms/aws_secret_storage_provider.go @@ -166,3 +166,38 @@ func (a *awsSecretStorageProvider) searchPrivateKey(ctx context.Context, keyID K } return secretValue.PrivateKey, nil } + +func (a *awsSecretStorageProvider) deleteKeyMaterial(ctx context.Context, keyID KeyID) error { + encodedSecretName := base64.StdEncoding.EncodeToString([]byte(keyID.ID)) + input := &secretsmanager.DeleteSecretInput{ + SecretId: aws.String(encodedSecretName), + ForceDeleteWithoutRecovery: aws.Bool(true), + } + _, err := a.secretManager.DeleteSecret(ctx, input) + return err +} + +func (a *awsSecretStorageProvider) getKeyMaterial(ctx context.Context, keyID KeyID) (map[string]string, error) { + encodedSecretName := base64.StdEncoding.EncodeToString([]byte(keyID.ID)) + input := &secretsmanager.GetSecretValueInput{ + SecretId: aws.String(encodedSecretName), + } + result, err := a.secretManager.GetSecretValue(ctx, input) + if err != nil { + log.Error(ctx, "error getting secret value", "err", err) + if strings.Contains(err.Error(), "ResourceNotFoundException") { + return nil, ErrKeyNotFound + } + return nil, errors.New("error getting secret value from AWS") + } + + var secretValue secretStorageProviderKeyMaterial + if err := json.Unmarshal([]byte(aws.ToString(result.SecretString)), &secretValue); err != nil { + return nil, err + } + + return map[string]string{ + jsonKeyType: secretValue.KeyType, + jsonKeyData: secretValue.PrivateKey, + }, nil +} diff --git a/internal/kms/aws_secret_storage_provider_test.go b/internal/kms/aws_secret_storage_provider_test.go index 09db2e2b1..80941f0a0 100644 --- a/internal/kms/aws_secret_storage_provider_test.go +++ b/internal/kms/aws_secret_storage_provider_test.go @@ -221,3 +221,88 @@ func Test_searchPrivateKey(t *testing.T) { assert.Equal(t, privateKey, privateKeyFromStore) }) } + +func Test_getKeyMaterial(t *testing.T) { + ctx := context.Background() + awsStorageProvider, err := NewAwsSecretStorageProvider(ctx, AwsSecretStorageProviderConfig{ + AccessKey: "access_key", + SecretKey: "secret_key", + Region: "local", + URL: "http://localhost:4566", + }) + require.NoError(t, err) + + t.Run("should get key material for bjj", func(t *testing.T) { + did := randomDID(t) + privateKey := "9d7abdd5a43573ab9b623c50b9fc8f4357329d3009fe0fc22c8931161d98a03d" + id := getKeyID(&did, KeyTypeBabyJubJub, "BJJ:2290140c920a31a596937095f18a9ae15c1fe7091091be485f353968a4310380") + err := awsStorageProvider.SaveKeyMaterial(ctx, map[string]string{ + jsonKeyType: string(KeyTypeBabyJubJub), + jsonKeyData: privateKey, + }, id) + assert.NoError(t, err) + + keyMaterial, err := awsStorageProvider.getKeyMaterial(ctx, KeyID{ + Type: babyjubjub, + ID: id, + }) + require.NoError(t, err) + assert.Equal(t, map[string]string{ + jsonKeyType: string(babyjubjub), + jsonKeyData: privateKey, + }, keyMaterial) + }) + + t.Run("should get an error for bjj", func(t *testing.T) { + _, err := awsStorageProvider.getKeyMaterial(ctx, KeyID{ + Type: babyjubjub, + ID: "wrong_id", + }) + require.Error(t, err) + }) +} + +func Test_deleteKeyMaterial(t *testing.T) { + ctx := context.Background() + awsStorageProvider, err := NewAwsSecretStorageProvider(ctx, AwsSecretStorageProviderConfig{ + AccessKey: "access_key", + SecretKey: "secret_key", + Region: "local", + URL: "http://localhost:4566", + }) + require.NoError(t, err) + + t.Run("should delete key material for bjj", func(t *testing.T) { + did := randomDID(t) + privateKey := "9d7abdd5a43573ab9b623c50b9fc8f4357329d3009fe0fc22c8931161d98a03d" + id := getKeyID(&did, KeyTypeBabyJubJub, "BJJ:2290140c920a31a596937095f18a9ae15c1fe7091091be485f353968a4310380") + err := awsStorageProvider.SaveKeyMaterial(ctx, map[string]string{ + jsonKeyType: string(KeyTypeBabyJubJub), + jsonKeyData: privateKey, + }, id) + assert.NoError(t, err) + + keyMaterial, err := awsStorageProvider.getKeyMaterial(ctx, KeyID{ + Type: babyjubjub, + ID: id, + }) + require.NoError(t, err) + assert.Equal(t, map[string]string{ + jsonKeyType: string(babyjubjub), + jsonKeyData: privateKey, + }, keyMaterial) + + err = awsStorageProvider.deleteKeyMaterial(ctx, KeyID{ + Type: babyjubjub, + ID: id, + }) + + require.NoError(t, err) + + _, err = awsStorageProvider.getKeyMaterial(ctx, KeyID{ + Type: babyjubjub, + ID: id, + }) + require.Error(t, err) + }) +} diff --git a/internal/kms/file_storage_manager.go b/internal/kms/file_storage_manager.go index 36643108b..b6d55ae03 100644 --- a/internal/kms/file_storage_manager.go +++ b/internal/kms/file_storage_manager.go @@ -109,3 +109,44 @@ func readContentFile(ctx context.Context, file string) ([]localStorageProviderFi return localStorageFileContent, nil } + +func (ls *fileStorageManager) deleteKeyMaterial(ctx context.Context, keyID KeyID) error { + localStorageFileContent, err := readContentFile(ctx, ls.file) + if err != nil { + return err + } + for i, keyMaterial := range localStorageFileContent { + if keyMaterial.KeyPath == keyID.ID { + localStorageFileContent = append(localStorageFileContent[:i], localStorageFileContent[i+1:]...) + break + } + } + + newFileContent, err := json.Marshal(localStorageFileContent) + if err != nil { + log.Error(ctx, "cannot marshal file content", "err", err) + return err + } + // nolint: all + if err := os.WriteFile(ls.file, newFileContent, 0644); err != nil { + log.Error(ctx, "cannot write file", "err", err) + return err + } + return nil +} + +func (ls *fileStorageManager) getKeyMaterial(ctx context.Context, keyID KeyID) (map[string]string, error) { + localStorageFileContent, err := readContentFile(ctx, ls.file) + if err != nil { + return nil, err + } + for _, keyMaterial := range localStorageFileContent { + if keyMaterial.KeyPath == keyID.ID { + return map[string]string{ + jsonKeyType: keyMaterial.KeyType, + jsonKeyData: keyMaterial.PrivateKey, + }, nil + } + } + return nil, ErrKeyNotFound +} diff --git a/internal/kms/file_storage_manager_test.go b/internal/kms/file_storage_manager_test.go index 1125f94bd..28fad8a8f 100644 --- a/internal/kms/file_storage_manager_test.go +++ b/internal/kms/file_storage_manager_test.go @@ -164,6 +164,91 @@ func TestSearchPrivateKeyInFile_ReturnsErrorWhenKeyNotFound(t *testing.T) { assert.Error(t, err) } +func Test_GetKeyMaterial(t *testing.T) { + tmpFile, err := createTestFile(t) + assert.NoError(t, err) + //nolint:errcheck + defer os.Remove(tmpFile.Name()) + + ls := NewFileStorageManager(tmpFile.Name()) + ctx := context.Background() + + t.Run("should return key material", func(t *testing.T) { + did := randomDID(t) + privateKey := "9d7abdd5a43573ab9b623c50b9fc8f4357329d3009fe0fc22c8931161d98a03d" + id := getKeyID(&did, KeyTypeBabyJubJub, "BJJ:2290140c920a31a596937095f18a9ae15c1fe7091091be485f353968a4310380") + + err = ls.SaveKeyMaterial(ctx, map[string]string{ + jsonKeyType: string(KeyTypeBabyJubJub), + jsonKeyData: privateKey, + }, id) + assert.NoError(t, err) + + keyMaterial, err := ls.getKeyMaterial(ctx, KeyID{ + Type: babyjubjub, + ID: id, + }) + require.NoError(t, err) + assert.Equal(t, map[string]string{ + jsonKeyType: string(babyjubjub), + jsonKeyData: privateKey, + }, keyMaterial) + }) + + t.Run("should return an error", func(t *testing.T) { + _, err := ls.getKeyMaterial(ctx, KeyID{ + Type: babyjubjub, + ID: "wrong_id", + }) + require.Error(t, err) + }) +} + +func Test_DeleteKeyMaterial(t *testing.T) { + tmpFile, err := createTestFile(t) + require.NoError(t, err) + //nolint:errcheck + defer os.Remove(tmpFile.Name()) + + ls := NewFileStorageManager(tmpFile.Name()) + ctx := context.Background() + + t.Run("should delete key material", func(t *testing.T) { + did := randomDID(t) + privateKey := "9d7abdd5a43573ab9b623c50b9fc8f4357329d3009fe0fc22c8931161d98a03d" + id := getKeyID(&did, KeyTypeBabyJubJub, "BJJ:2290140c920a31a596937095f18a9ae15c1fe7091091be485f353968a4310380") + + err = ls.SaveKeyMaterial(ctx, map[string]string{ + jsonKeyType: string(KeyTypeBabyJubJub), + jsonKeyData: privateKey, + }, id) + assert.NoError(t, err) + + keyMaterial, err := ls.getKeyMaterial(ctx, KeyID{ + Type: babyjubjub, + ID: id, + }) + require.NoError(t, err) + assert.Equal(t, map[string]string{ + jsonKeyType: string(babyjubjub), + jsonKeyData: privateKey, + }, keyMaterial) + + err = ls.deleteKeyMaterial(ctx, KeyID{ + Type: babyjubjub, + ID: id, + }) + + require.NoError(t, err) + + _, err = ls.getKeyMaterial(ctx, KeyID{ + Type: babyjubjub, + ID: id, + }) + require.Error(t, err) + }) +} + func createTestFile(t *testing.T) (*os.File, error) { t.Helper() tmpFile, err := os.Create("./kms.json") diff --git a/internal/kms/kms.go b/internal/kms/kms.go index e3b7d2e57..2b921df46 100644 --- a/internal/kms/kms.go +++ b/internal/kms/kms.go @@ -20,6 +20,8 @@ type StorageManager interface { SaveKeyMaterial(ctx context.Context, keyMaterial map[string]string, id string) error searchByIdentity(ctx context.Context, identity w3c.DID, keyType KeyType) ([]KeyID, error) searchPrivateKey(ctx context.Context, keyID KeyID) (string, error) + deleteKeyMaterial(ctx context.Context, keyID KeyID) error + getKeyMaterial(ctx context.Context, keyID KeyID) (map[string]string, error) } // KMSType represents the KMS interface @@ -31,6 +33,8 @@ type KMSType interface { Sign(ctx context.Context, keyID KeyID, data []byte) ([]byte, error) KeysByIdentity(ctx context.Context, identity w3c.DID) ([]KeyID, error) LinkToIdentity(ctx context.Context, keyID KeyID, identity w3c.DID) (KeyID, error) + Delete(ctx context.Context, keyID KeyID) error + Exists(ctx context.Context, keyID KeyID) (bool, error) } // ConfigProvider is a key provider configuration @@ -82,6 +86,10 @@ type KeyProvider interface { // KeyID can be changed after linking. // Returning new KeyID. LinkToIdentity(ctx context.Context, keyID KeyID, identity w3c.DID) (KeyID, error) + // Delete removes key from storage + Delete(ctx context.Context, keyID KeyID) error + // Exists checks if key exists + Exists(ctx context.Context, keyID KeyID) (bool, error) } // KMS stores keys and secrets @@ -111,6 +119,9 @@ var ErrKeyTypeConflict = stderr.New("key type already registered") // ErrPermissionDenied raises when we register new key provider with key type var ErrPermissionDenied = stderr.New("permission denied") +// ErrKeyNotFound raises when key is not found +var ErrKeyNotFound = stderr.New("key not found") + // KeyID is a key unique identifier type KeyID struct { Type KeyType @@ -222,6 +233,24 @@ func (k *KMS) LinkToIdentity(ctx context.Context, keyID KeyID, identity w3c.DID) return kp.LinkToIdentity(ctx, keyID, identity) } +// Delete removes key from storage +func (k *KMS) Delete(ctx context.Context, keyID KeyID) error { + kp, ok := k.registry[keyID.Type] + if !ok { + return errors.WithStack(ErrUnknownKeyType) + } + return kp.Delete(ctx, keyID) +} + +// Exists checks if key exists +func (k *KMS) Exists(ctx context.Context, keyID KeyID) (bool, error) { + kp, ok := k.registry[keyID.Type] + if !ok { + return false, errors.WithStack(ErrUnknownKeyType) + } + return kp.Exists(ctx, keyID) +} + // Open returns an initialized KMS func Open(pluginIden3MountPath string, vault *api.Client) (*KMS, error) { bjjKeyProvider, err := NewVaultPluginIden3KeyProvider(vault, pluginIden3MountPath, KeyTypeBabyJubJub) diff --git a/internal/kms/local_bjj_key_provider.go b/internal/kms/local_bjj_key_provider.go index fc6e0ad00..728a2d126 100644 --- a/internal/kms/local_bjj_key_provider.go +++ b/internal/kms/local_bjj_key_provider.go @@ -125,6 +125,10 @@ func (ls *localBJJKeyProvider) LinkToIdentity(ctx context.Context, keyID KeyID, return keyID, nil } +func (ls *localBJJKeyProvider) Delete(ctx context.Context, keyID KeyID) error { + return ls.storageManager.deleteKeyMaterial(ctx, keyID) +} + func (ls *localBJJKeyProvider) privateKey(ctx context.Context, keyID KeyID) ([]byte, error) { if keyID.Type != ls.keyType { return nil, ErrIncorrectKeyType @@ -155,3 +159,13 @@ func (ls *localBJJKeyProvider) privateKey(ctx context.Context, keyID KeyID) ([]b return val, nil } + +func (ls *localBJJKeyProvider) Exists(ctx context.Context, keyID KeyID) (bool, error) { + _, err := ls.storageManager.getKeyMaterial(ctx, keyID) + if err != nil { + if errors.Is(err, ErrKeyNotFound) { + return false, nil + } + } + return true, nil +} diff --git a/internal/kms/local_bjj_key_provider_test.go b/internal/kms/local_bjj_key_provider_test.go index bf0de78a7..db378dba0 100644 --- a/internal/kms/local_bjj_key_provider_test.go +++ b/internal/kms/local_bjj_key_provider_test.go @@ -259,12 +259,14 @@ func Test_PublicKey_LocalBJJKeyProvider(t *testing.T) { AccessKey: "access_key", SecretKey: "secret_key", Region: "local", + URL: "http://localhost:4566", }) require.NoError(t, err) t.Run("should get public key using local storage manager", func(t *testing.T) { + did := randomDID(t) localbbjKeyProvider := NewLocalBJJKeyProvider(KeyTypeBabyJubJub, ls) - keyID, err := localbbjKeyProvider.New(nil) + keyID, err := localbbjKeyProvider.New(&did) assert.NoError(t, err) assert.NotEmpty(t, keyID.ID) @@ -274,8 +276,9 @@ func Test_PublicKey_LocalBJJKeyProvider(t *testing.T) { }) t.Run("should get public key using aws storage manager", func(t *testing.T) { + did := randomDID(t) localbbjKeyProvider := NewLocalBJJKeyProvider(KeyTypeBabyJubJub, awsStorageProvider) - keyID, err := localbbjKeyProvider.New(nil) + keyID, err := localbbjKeyProvider.New(&did) assert.NoError(t, err) assert.NotEmpty(t, keyID.ID) @@ -375,3 +378,42 @@ func Test_Sign_LocalBJJKeyProvider(t *testing.T) { assert.NotNil(t, signature) }) } + +func Test_DeleteKey_LocalBJJKeyProvider(t *testing.T) { + ctx := context.Background() + tmpFile, err := createTestFile(t) + assert.NoError(t, err) + //nolint:errcheck + defer os.Remove(tmpFile.Name()) + ls := NewFileStorageManager(tmpFile.Name()) + + awsStorageProvider, err := NewAwsSecretStorageProvider(ctx, AwsSecretStorageProviderConfig{ + AccessKey: "access_key", + SecretKey: "secret_key", + Region: "local", + URL: "http://localhost:4566", + }) + require.NoError(t, err) + + t.Run("should get public key using local storage manager", func(t *testing.T) { + did := randomDID(t) + localbbjKeyProvider := NewLocalBJJKeyProvider(KeyTypeBabyJubJub, ls) + keyID, err := localbbjKeyProvider.New(&did) + assert.NoError(t, err) + assert.NotEmpty(t, keyID.ID) + + err = localbbjKeyProvider.Delete(ctx, keyID) + assert.NoError(t, err) + }) + + t.Run("should get public key using aws storage manager", func(t *testing.T) { + did := randomDID(t) + localbbjKeyProvider := NewLocalBJJKeyProvider(KeyTypeBabyJubJub, awsStorageProvider) + keyID, err := localbbjKeyProvider.New(&did) + assert.NoError(t, err) + assert.NotEmpty(t, keyID.ID) + + err = localbbjKeyProvider.Delete(ctx, keyID) + assert.NoError(t, err) + }) +} diff --git a/internal/kms/local_eth_key_provider.go b/internal/kms/local_eth_key_provider.go index b384bbf02..b6bea84cc 100644 --- a/internal/kms/local_eth_key_provider.go +++ b/internal/kms/local_eth_key_provider.go @@ -119,7 +119,7 @@ func (ls *localEthKeyProvider) LinkToIdentity(ctx context.Context, keyID KeyID, return KeyID{}, err } - keyID.ID = identity.String() + keyID.ID = identity.String() + "/" + keyID.ID return keyID, nil } @@ -128,6 +128,20 @@ func (ls *localEthKeyProvider) ListByIdentity(ctx context.Context, identity w3c. return ls.storageManager.searchByIdentity(ctx, identity, ls.keyType) } +func (ls *localEthKeyProvider) Delete(ctx context.Context, keyID KeyID) error { + return ls.storageManager.deleteKeyMaterial(ctx, keyID) +} + +func (ls *localEthKeyProvider) Exists(ctx context.Context, keyID KeyID) (bool, error) { + _, err := ls.storageManager.getKeyMaterial(ctx, keyID) + if err != nil { + if errors.Is(err, ErrKeyNotFound) { + return false, nil + } + } + return true, nil +} + // nolint func (ls *localEthKeyProvider) privateKey(ctx context.Context, keyID KeyID) ([]byte, error) { if keyID.Type != ls.keyType { diff --git a/internal/kms/local_eth_key_provider_test.go b/internal/kms/local_eth_key_provider_test.go index db6b6e440..28b524526 100644 --- a/internal/kms/local_eth_key_provider_test.go +++ b/internal/kms/local_eth_key_provider_test.go @@ -96,10 +96,10 @@ func Test_LinkToIdentity_LocalETHKeyProvider(t *testing.T) { assert.NotEmpty(t, keyID.ID) did := randomDID(t) - keyID, err = localETHKeyProvider.LinkToIdentity(ctx, keyID, did) + newKeyID, err := localETHKeyProvider.LinkToIdentity(ctx, keyID, did) assert.NoError(t, err) assert.NotNil(t, keyID) - assert.Equal(t, did.String(), keyID.ID) + assert.Equal(t, did.String()+"/"+keyID.ID, newKeyID.ID) assert.Equal(t, KeyTypeEthereum, keyID.Type) }) @@ -110,10 +110,10 @@ func Test_LinkToIdentity_LocalETHKeyProvider(t *testing.T) { assert.NotEmpty(t, keyID.ID) did := randomDID(t) - keyID, err = localETHKeyProvider.LinkToIdentity(ctx, keyID, did) + newKeyID, err := localETHKeyProvider.LinkToIdentity(ctx, keyID, did) assert.NoError(t, err) assert.NotNil(t, keyID) - assert.Equal(t, did.String(), keyID.ID) + assert.Equal(t, did.String()+"/"+keyID.ID, newKeyID.ID) assert.Equal(t, KeyTypeEthereum, keyID.Type) }) } diff --git a/internal/kms/vaultPluginIden3KeyProvider.go b/internal/kms/vaultPluginIden3KeyProvider.go index f25ac181f..aea4b4b0f 100644 --- a/internal/kms/vaultPluginIden3KeyProvider.go +++ b/internal/kms/vaultPluginIden3KeyProvider.go @@ -128,6 +128,9 @@ func (v *vaultPluginIden3KeyProvider) PublicKey(keyID KeyID) ([]byte, error) { publicKeyStr, err := publicKey(v.vaultCli, v.keyPathFromID(keyID)) if err != nil { + if strings.Contains(err.Error(), "secret is nil") { + return nil, ErrKeyNotFound + } return nil, err } @@ -161,6 +164,22 @@ func (v *vaultPluginIden3KeyProvider) New(identity *w3c.DID) (KeyID, error) { return keyID, nil } +func (v *vaultPluginIden3KeyProvider) Delete(ctx context.Context, keyID KeyID) error { + _, err := v.vaultCli.Logical().Delete(v.keyPathFromID(keyID).keys()) + return err +} + +func (v *vaultPluginIden3KeyProvider) Exists(ctx context.Context, keyID KeyID) (bool, error) { + _, err := publicKey(v.vaultCli, v.keyPathFromID(keyID)) + if err != nil { + if strings.Contains(err.Error(), "secret is nil") { + return false, nil + } + return false, err + } + return true, nil +} + func (v *vaultPluginIden3KeyProvider) randomKeyPath() (keyPathT, error) { var rnd [16]byte _, err := rand.Read(rnd[:]) diff --git a/internal/kms/vault_bjj_key_provider.go b/internal/kms/vault_bjj_key_provider.go index c99a248ec..4ebdb28f2 100644 --- a/internal/kms/vault_bjj_key_provider.go +++ b/internal/kms/vault_bjj_key_provider.go @@ -134,6 +134,14 @@ func (v *vaultBJJKeyProvider) PublicKey(keyID KeyID) ([]byte, error) { return val, err } +func (v *vaultBJJKeyProvider) Delete(ctx context.Context, keyID KeyID) error { + return nil +} + +func (v *vaultBJJKeyProvider) Exists(ctx context.Context, keyID KeyID) (bool, error) { + return false, errors.New("not implemented") +} + func (v *vaultBJJKeyProvider) privateKey(keyID KeyID) ([]byte, error) { if keyID.Type != v.keyType { return nil, ErrIncorrectKeyType diff --git a/internal/kms/vault_eth_key_provider.go b/internal/kms/vault_eth_key_provider.go index c9433dad3..535a67ec5 100644 --- a/internal/kms/vault_eth_key_provider.go +++ b/internal/kms/vault_eth_key_provider.go @@ -193,6 +193,15 @@ func (v *vaultETHKeyProvider) New(identity *w3c.DID) (KeyID, error) { return keyID, saveKeyMaterial(v.vaultCli, keyID.ID, keyMaterial) } +func (v *vaultETHKeyProvider) Delete(ctx context.Context, keyID KeyID) error { + _, err := v.vaultCli.Logical().Delete(absVaultSecretPath(keyID.ID)) + return err +} + +func (v *vaultETHKeyProvider) Exists(ctx context.Context, keyID KeyID) (bool, error) { + return false, errors.New("not implemented") +} + // NewVaultEthProvider creates new provider for Ethereum keys stored in vault func NewVaultEthProvider(valutCli *api.Client, keyType KeyType) KeyProvider { reIdenKeyPathHex := regexp.MustCompile("^(?i).*/" + diff --git a/internal/kms/vault_providers_helpers.go b/internal/kms/vault_providers_helpers.go index 6306cbee4..2073fc591 100644 --- a/internal/kms/vault_providers_helpers.go +++ b/internal/kms/vault_providers_helpers.go @@ -73,14 +73,6 @@ func keyPath(identity *w3c.DID, keyType KeyType, keyID string) string { return basePath + string(keyType) + ":" + keyID } -func keyPathForAws(identity *w3c.DID, keyType KeyType, keyID string) string { - basePath := "" - if identity != nil { - basePath = identityPath(identity) + "/" - } - return basePath + string(keyType) + keyID -} - func absVaultSecretPath(path string) string { return kvStoragePath + "/data/" + strings.TrimPrefix(path, "/") } diff --git a/internal/repositories/claim.go b/internal/repositories/claim.go index 54cb6ffe6..0731b7a8c 100644 --- a/internal/repositories/claim.go +++ b/internal/repositories/claim.go @@ -24,8 +24,8 @@ import ( const duplicateViolationErrorCode = "23505" -// ErrClaimDuplication claim duplication error var ( + // ErrClaimDuplication claim duplication error ErrClaimDuplication = errors.New("claim duplication error") // ErrClaimDoesNotExist claim does not exist ErrClaimDoesNotExist = errors.New("claim does not exist") @@ -345,6 +345,8 @@ func (c *claim) GetByRevocationNonce(ctx context.Context, conn db.Querier, ident return claims, nil } +// FindOneClaimBySchemaHash returns a claim by schema hash +// The claim must have MTP proof and not be revoked. This means the claim is published. func (c *claim) FindOneClaimBySchemaHash(ctx context.Context, conn db.Querier, subject *w3c.DID, schemaHash string) (*domain.Claim, error) { var claim domain.Claim @@ -371,13 +373,14 @@ func (c *claim) FindOneClaimBySchemaHash(ctx context.Context, conn db.Querier, s WHERE claims.identifier=$1 AND ( claims.other_identifier = $1 or claims.other_identifier = '') AND claims.schema_hash = $2 - AND claims.revoked = false`, subject.String(), schemaHash) + AND claims.revoked = false + AND claims.mtp_proof IS NOT NULL `, subject.String(), schemaHash) err := row.Scan(&claim.ID, &claim.Issuer, &claim.SchemaHash, &claim.SchemaType, - &claim.SchemaHash, + &claim.SchemaURL, &claim.OtherIdentifier, &claim.Expiration, &claim.Updatable, @@ -399,6 +402,73 @@ func (c *claim) FindOneClaimBySchemaHash(ctx context.Context, conn db.Querier, s return &claim, err } +// FindClaimsBySchemaHash returns all claims by schema hash +// The claim must have MTP proof and not be revoked. +func (c *claim) FindClaimsBySchemaHash(ctx context.Context, conn db.Querier, subject *w3c.DID, schemaHash string) ([]*domain.Claim, error) { + rows, err := conn.Query(ctx, + `SELECT claims.id, + issuer, + schema_hash, + schema_type, + schema_url, + other_identifier, + expiration, + updatable, + claims.version, + rev_nonce, + mtp_proof, + signature_proof, + data, + claims.identifier, + identity_state, + credential_status, + core_claim, + revoked, + mtp, + claims.created_at + FROM claims + WHERE claims.identifier=$1 + AND ( claims.other_identifier = $1 or claims.other_identifier = '') + AND claims.schema_hash = $2 + AND claims.revoked = false + AND claims.mtp_proof IS NOT NULL `, subject.String(), schemaHash) + if err != nil { + return nil, err + } + defer rows.Close() + + credentials := make([]*domain.Claim, 0) + for rows.Next() { + var claim domain.Claim + err := rows.Scan(&claim.ID, + &claim.Issuer, + &claim.SchemaHash, + &claim.SchemaType, + &claim.SchemaURL, + &claim.OtherIdentifier, + &claim.Expiration, + &claim.Updatable, + &claim.Version, + &claim.RevNonce, + &claim.MTPProof, + &claim.SignatureProof, + &claim.Data, + &claim.Identifier, + &claim.IdentityState, + &claim.CredentialStatus, + &claim.CoreClaim, + &claim.Revoked, + &claim.MtProof, + &claim.CreatedAt) + if err != nil { + return nil, err + } + credentials = append(credentials, &claim) + } + + return credentials, nil +} + func (c *claim) RevokeNonce(ctx context.Context, conn db.Querier, revocation *domain.Revocation) error { _, err := conn.Exec(ctx, ` INSERT INTO revocation (identifier, nonce, version, status, description) @@ -730,8 +800,8 @@ func processClaims(rows pgx.Rows) ([]*domain.Claim, error) { err := rows.Scan(&claim.ID, &claim.Issuer, &claim.SchemaHash, - &claim.SchemaURL, &claim.SchemaType, + &claim.SchemaURL, &claim.OtherIdentifier, &claim.Expiration, &claim.Updatable, @@ -1098,3 +1168,62 @@ func (c *claim) GetByStateIDWithMTPProof(ctx context.Context, conn db.Querier, d return claims, nil } + +// GetAuthCoreClaims returns all the core claims for the given identifier and schema hash +// The auth core claims returned may not be published onchain. +func (c *claim) GetAuthCoreClaims(ctx context.Context, conn db.Querier, identifier *w3c.DID, schemaHash string) ([]*domain.Claim, error) { + rows, err := conn.Query(ctx, + `SELECT claims.id, + issuer, + schema_hash, + schema_type, + schema_url, + other_identifier, + expiration, + updatable, + claims.version, + rev_nonce, + mtp_proof, + signature_proof, + data, + claims.identifier, + identity_state, + credential_status, + revoked, + core_claim + FROM claims + WHERE claims.identifier=$1 + AND ( claims.other_identifier = $1 or claims.other_identifier = '') + AND claims.schema_hash = $2`, identifier.String(), schemaHash) + if err != nil { + return nil, err + } + + claims := make([]*domain.Claim, 0) + for rows.Next() { + var claim domain.Claim + err := rows.Scan(&claim.ID, + &claim.Issuer, + &claim.SchemaHash, + &claim.SchemaType, + &claim.SchemaURL, + &claim.OtherIdentifier, + &claim.Expiration, + &claim.Updatable, + &claim.Version, + &claim.RevNonce, + &claim.MTPProof, + &claim.SignatureProof, + &claim.Data, + &claim.Identifier, + &claim.IdentityState, + &claim.CredentialStatus, + &claim.Revoked, + &claim.CoreClaim) + if err != nil { + return nil, err + } + claims = append(claims, &claim) + } + return claims, nil +} diff --git a/internal/repositories/fixture.go b/internal/repositories/fixture.go index 26a223b98..0a631d836 100644 --- a/internal/repositories/fixture.go +++ b/internal/repositories/fixture.go @@ -13,7 +13,7 @@ import ( // Fixture - Handle testing fixture configuration type Fixture struct { storage *db.Storage - identityRepository ports.IndentityRepository + identityRepository ports.IdentityRepository claimRepository ports.ClaimRepository connectionsRepository ports.ConnectionRepository schemaRepository ports.SchemaRepository diff --git a/internal/repositories/identity.go b/internal/repositories/identity.go index e139add6b..2504c5d2a 100644 --- a/internal/repositories/identity.go +++ b/internal/repositories/identity.go @@ -22,8 +22,8 @@ var ErrDisplayNameDuplicated = errors.New("display name already exists") type identity struct{} -// NewIdentity TODO -func NewIdentity() ports.IndentityRepository { +// NewIdentity - Create new identity repository +func NewIdentity() ports.IdentityRepository { return &identity{} } @@ -70,7 +70,8 @@ func (i *identity) GetByID(ctx context.Context, conn db.Querier, identifier w3c. claims.credential_status FROM identities LEFT JOIN identity_states ON identities.identifier = identity_states.identifier - LEFT JOIN claims ON claims.identifier = identities.identifier and claims.schema_type = 'https://schema.iden3.io/core/jsonld/auth.jsonld#AuthBJJCredential' + LEFT JOIN claims ON claims.identifier = identities.identifier + AND claims.schema_type = 'https://schema.iden3.io/core/jsonld/auth.jsonld#AuthBJJCredential' WHERE identities.identifier=$1 AND ( status = 'transacted' OR status = 'confirmed') OR (identities.identifier=$1 AND status = 'created' AND previous_state is null diff --git a/internal/repositories/key.go b/internal/repositories/key.go new file mode 100644 index 000000000..ae4e3284a --- /dev/null +++ b/internal/repositories/key.go @@ -0,0 +1,95 @@ +package repositories + +import ( + "context" + "errors" + "strings" + + "github.com/google/uuid" + "github.com/iden3/go-iden3-core/v2/w3c" + "github.com/jackc/pgconn" + + "github.com/polygonid/sh-id-platform/internal/core/domain" + "github.com/polygonid/sh-id-platform/internal/db" + "github.com/polygonid/sh-id-platform/internal/log" +) + +var ( + // ErrKeyNotFound key not found error + ErrKeyNotFound = errors.New("key not found") + // ErrDuplicateKeyName duplicate key name error + ErrDuplicateKeyName = errors.New("key name already exists") +) + +type key struct { + conn db.Storage +} + +// NewKey returns a new key repository +func NewKey(conn db.Storage) *key { + return &key{ + conn, + } +} + +// Save saves a key +func (k *key) Save(ctx context.Context, conn db.Querier, key *domain.Key) (uuid.UUID, error) { + if conn == nil { + conn = k.conn.Pgx + } + sql := `INSERT INTO keys (id, issuer_did, public_key, name) + VALUES($1, $2, $3, $4) ON CONFLICT (id) DO + UPDATE SET name=$4` + _, err := conn.Exec(ctx, sql, key.ID, key.IssuerCoreDID().String(), key.PublicKey, key.Name) + if err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == duplicateViolationErrorCode { + return uuid.Nil, ErrDuplicateKeyName + } + return uuid.Nil, err + } + return key.ID, err +} + +// GetByPublicKey returns a key by its public key +func (k *key) GetByPublicKey(ctx context.Context, issuerDID w3c.DID, publicKey string) (*domain.Key, error) { + sql := `SELECT id, issuer_did, public_key, name + FROM keys WHERE issuer_did=$1 and public_key=$2` + row := k.conn.Pgx.QueryRow(ctx, sql, issuerDID.String(), publicKey) + + key := domain.Key{} + err := row.Scan(&key.ID, &key.IssuerDID, &key.PublicKey, &key.Name) + if err != nil { + log.Error(ctx, "error getting key by public key", "err", err) + if strings.Contains(err.Error(), "no rows in result set") { + return nil, ErrKeyNotFound + } + return nil, err + } + return &key, nil +} + +// Delete deletes a key by its public key +func (k *key) Delete(ctx context.Context, issuerDID w3c.DID, publicKey string) error { + sql := `DELETE FROM keys WHERE issuer_did=$1 AND public_key=$2` + _, err := k.conn.Pgx.Exec(ctx, sql, issuerDID.String(), publicKey) + return err +} + +// GetByName returns a key by its name +func (k *key) GetByName(ctx context.Context, issuerDID w3c.DID, name string) (*domain.Key, error) { + sql := `SELECT id, issuer_did, public_key, name + FROM keys WHERE issuer_did=$1 and name=$2` + row := k.conn.Pgx.QueryRow(ctx, sql, issuerDID.String(), name) + + key := domain.Key{} + err := row.Scan(&key.ID, &key.IssuerDID, &key.PublicKey, &key.Name) + if err != nil { + log.Error(ctx, "error getting key by name", "err", err) + if strings.Contains(err.Error(), "no rows in result set") { + return nil, ErrKeyNotFound + } + return nil, err + } + return &key, nil +} diff --git a/internal/repositories/key_test.go b/internal/repositories/key_test.go new file mode 100644 index 000000000..54810a829 --- /dev/null +++ b/internal/repositories/key_test.go @@ -0,0 +1,95 @@ +package repositories + +import ( + "context" + "errors" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/polygonid/sh-id-platform/internal/core/domain" +) + +func TestKey_Save(t *testing.T) { + keyRepository := NewKey(*storage) + + ctx := context.Background() + did := randomDID(t) + _, err := storage.Pgx.Exec(ctx, "INSERT INTO identities (identifier, keytype) VALUES ($1, $2)", did.String(), "BJJ") + assert.NoError(t, err) + + t.Run("should save a new key", func(t *testing.T) { + key := domain.Key{ + ID: uuid.New(), + IssuerDID: domain.KeyCoreDID(did), + PublicKey: "publicKey", + Name: "name", + } + id, err := keyRepository.Save(context.Background(), storage.Pgx, &key) + require.NoError(t, err) + require.NotEqual(t, uuid.Nil, id) + assert.Equal(t, key.ID, id) + }) + + t.Run("should get an error", func(t *testing.T) { + key := domain.Key{ + ID: uuid.New(), + IssuerDID: domain.KeyCoreDID(did), + PublicKey: "publicKey", + Name: "name_1", + } + + id, err := keyRepository.Save(context.Background(), storage.Pgx, &key) + require.NoError(t, err) + require.NotEqual(t, uuid.Nil, id) + assert.Equal(t, key.ID, id) + + key2 := domain.Key{ + ID: uuid.New(), + IssuerDID: domain.KeyCoreDID(did), + PublicKey: "publicKey2", + Name: "name_1", + } + + id, err = keyRepository.Save(context.Background(), storage.Pgx, &key2) + require.Error(t, err) + require.Equal(t, uuid.Nil, id) + }) +} + +func TestKey_GetByPublicKey(t *testing.T) { + keyRepository := NewKey(*storage) + ctx := context.Background() + did := randomDID(t) + _, err := storage.Pgx.Exec(ctx, "INSERT INTO identities (identifier, keytype) VALUES ($1, $2)", did.String(), "BJJ") + assert.NoError(t, err) + + key := domain.Key{ + ID: uuid.New(), + IssuerDID: domain.KeyCoreDID(did), + PublicKey: "publicKey", + Name: "name" + uuid.New().String(), + } + id, err := keyRepository.Save(context.Background(), storage.Pgx, &key) + require.NoError(t, err) + require.NotEqual(t, uuid.Nil, id) + assert.Equal(t, key.ID, id) + + t.Run("should get the key by public key", func(t *testing.T) { + keyFromDatabase, err := keyRepository.GetByPublicKey(ctx, did, key.PublicKey) + require.NoError(t, err) + assert.Equal(t, key.ID, keyFromDatabase.ID) + assert.Equal(t, key.IssuerDID, keyFromDatabase.IssuerDID) + assert.Equal(t, key.PublicKey, keyFromDatabase.PublicKey) + assert.Equal(t, key.Name, keyFromDatabase.Name) + }) + + t.Run("should get an error - ErrKeyNotFound", func(t *testing.T) { + keyFromDatabase, err := keyRepository.GetByPublicKey(ctx, did, "wrong public key") + assert.Error(t, err) + assert.Nil(t, keyFromDatabase) + assert.True(t, errors.Is(err, ErrKeyNotFound)) + }) +} diff --git a/internal/repositories/main_test.go b/internal/repositories/main_test.go index d58adcca7..d208d681e 100644 --- a/internal/repositories/main_test.go +++ b/internal/repositories/main_test.go @@ -2,9 +2,14 @@ package repositories import ( "context" + "crypto/rand" "os" "testing" + core "github.com/iden3/go-iden3-core/v2" + "github.com/iden3/go-iden3-core/v2/w3c" + "github.com/stretchr/testify/require" + "github.com/polygonid/sh-id-platform/internal/config" "github.com/polygonid/sh-id-platform/internal/db" "github.com/polygonid/sh-id-platform/internal/db/tests" @@ -47,3 +52,16 @@ func lookupPostgresURL() string { } return con } + +func randomDID(t *testing.T) w3c.DID { + t.Helper() + typ, err := core.BuildDIDType(core.DIDMethodIden3, core.Privado, core.Main) + var genesis [27]byte + require.NoError(t, err) + _, err = rand.Read(genesis[:]) + require.NoError(t, err) + id := core.NewID(typ, genesis) + did, err := core.ParseDIDFromID(id) + require.NoError(t, err) + return *did +} diff --git a/internal/utils/public_key.go b/internal/utils/public_key.go new file mode 100644 index 000000000..adf8ec793 --- /dev/null +++ b/internal/utils/public_key.go @@ -0,0 +1,58 @@ +package utils + +import ( + "bytes" + + "github.com/iden3/go-iden3-crypto/babyjub" + "github.com/iden3/go-schema-processor/v2/verifiable" + + "github.com/polygonid/sh-id-platform/internal/core/domain" +) + +// PublicKey - defines the interface for public keys +type PublicKey interface { + Equal([]byte) bool + String() string +} + +type bjjPublicKey struct { + publicKey babyjub.PublicKey +} + +// newBJJPublicKey creates a new PublicKey from a Claim +func newBJJPublicKey(claim domain.Claim) PublicKey { + entry := claim.CoreClaim.Get() + bjjClaim := entry.RawSlotsAsInts() + var authCoreClaimPublicKey babyjub.PublicKey + authCoreClaimPublicKey.X = bjjClaim[2] + authCoreClaimPublicKey.Y = bjjClaim[3] + return &bjjPublicKey{publicKey: authCoreClaimPublicKey} +} + +func (b *bjjPublicKey) Equal(pubKey []byte) bool { + compPubKey := b.publicKey.Compress() + return bytes.Equal(pubKey, compPubKey[:]) +} + +func (b *bjjPublicKey) String() string { + return "0x" + b.publicKey.String() +} + +type unSupportedPublicKeyType struct{} + +func (u *unSupportedPublicKeyType) Equal([]byte) bool { + return false +} + +func (u *unSupportedPublicKeyType) String() string { + return "" +} + +// GetPublicKeyFromClaim returns the public key of the claim +// If the schema is not supported, it returns an unSupportedPublicKeyType +func GetPublicKeyFromClaim(c *domain.Claim) PublicKey { + if c.SchemaURL == verifiable.JSONSchemaIden3AuthBJJCredential { + return newBJJPublicKey(*c) + } + return &unSupportedPublicKeyType{} +} diff --git a/k8s/helm/templates/issuer-node-ui-deployment.yaml b/k8s/helm/templates/issuer-node-ui-deployment.yaml index 2a8b1d1c6..1ca56222c 100644 --- a/k8s/helm/templates/issuer-node-ui-deployment.yaml +++ b/k8s/helm/templates/issuer-node-ui-deployment.yaml @@ -25,7 +25,7 @@ spec: image: {{ .Values.issuernode_ui_repository_image }}:{{ .Values.issuernode_ui_repository_tag }} imagePullPolicy: {{ .Values.uiIssuerNode.deployment.imagePullPolicy | quote }} ports: - - containerPort: {{ .Values.uiIssuerNode.deployment.containerPort }} + - containerPort: {{ .Values.uiIssuerNode.deployment.containerPort }} envFrom: - - configMapRef: - name: {{ .Values.uiIssuerNode.deployment.uiconfigMapRef }} \ No newline at end of file + - configMapRef: + name: {{ .Values.uiIssuerNode.deployment.uiconfigMapRef }} \ No newline at end of file diff --git a/ui/package-lock.json b/ui/package-lock.json index 6427b3cda..b76a8a5a0 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -8,6 +8,7 @@ "name": "issuer-node-ui", "version": "1.0.0", "dependencies": { + "@iden3/js-crypto": "^1.1.0", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "ajv-formats-draft2019": "^1.6.1", @@ -1240,6 +1241,12 @@ "typescript": ">=5" } }, + "node_modules/@iden3/js-crypto": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@iden3/js-crypto/-/js-crypto-1.1.0.tgz", + "integrity": "sha512-MbL7OpOxBoCybAPoorxrp+fwjDVESyDe6giIWxErjEIJy0Q2n1DU4VmKh4vDoCyhJx/RdVgT8Dkb59lKwISqsw==", + "license": "AGPL-3.0" + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", diff --git a/ui/package.json b/ui/package.json index ad9f459d1..6bf0d357e 100644 --- a/ui/package.json +++ b/ui/package.json @@ -2,6 +2,7 @@ "name": "issuer-node-ui", "version": "1.0.0", "dependencies": { + "@iden3/js-crypto": "^1.1.0", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "ajv-formats-draft2019": "^1.6.1", diff --git a/ui/src/adapters/api/credentials.ts b/ui/src/adapters/api/credentials.ts index 45ca7bb5a..1082dc0b5 100644 --- a/ui/src/adapters/api/credentials.ts +++ b/ui/src/adapters/api/credentials.ts @@ -12,14 +12,17 @@ import { serializeSorters, } from "src/adapters/api"; import { + buildAppError, datetimeParser, getListParser, getResourceParser, getStrictParser, } from "src/adapters/parsers"; import { + AuthCredential, Credential, CredentialDisplayMethod, + CredentialStatusType, DisplayMethodType, Env, IssuedMessage, @@ -42,12 +45,14 @@ type CredentialInput = Pick & { } & Record; credentialStatus: { revocationNonce: number; + type: CredentialStatusType; } & Record; credentialSubject: Record; displayMethod?: CredentialDisplayMethod | null; expirationDate?: string | null; issuanceDate: string; issuer: string; + proof?: Array<{ type: ProofType }> | null; refreshService?: RefreshService | null; type: [string, string]; }; @@ -69,6 +74,7 @@ export const credentialParser = getStrictParser()( credentialStatus: z .object({ revocationNonce: z.number(), + type: z.nativeEnum(CredentialStatusType), }) .and(z.record(z.unknown())), credentialSubject: z.record(z.unknown()), @@ -79,6 +85,14 @@ export const credentialParser = getStrictParser()( expirationDate: datetimeParser.nullable().default(null), issuanceDate: datetimeParser, issuer: z.string(), + proof: z + .array( + z.object({ + type: z.nativeEnum(ProofType), + }) + ) + .nullable() + .default(null), refreshService: z .object({ id: z.string(), type: z.literal("Iden3RefreshService2023") }) .nullable() @@ -100,6 +114,7 @@ export const credentialParser = getStrictParser()( expirationDate, issuanceDate, issuer, + proof, refreshService, type, }, @@ -108,12 +123,14 @@ export const credentialParser = getStrictParser()( const [, schemaType] = type; return { + credentialStatus, credentialSubject, displayMethod, expirationDate, expired, id, issuanceDate, + proof, proofTypes, refreshService, revNonce: credentialStatus.revocationNonce, @@ -133,6 +150,27 @@ export const credentialStatusParser = getStrictParser()( z.union([z.literal("all"), z.literal("revoked"), z.literal("expired")]) ); +export type AuthCredentialSubjectInput = { + x: string; + y: string; +}; +export type AuthCredentialSubject = { + x: bigint; + y: bigint; +}; + +export const authCredentialSubjectParser = getStrictParser< + AuthCredentialSubjectInput, + AuthCredentialSubject +>()( + z + .object({ + x: z.string().regex(/^\d+$/, "x must be a numeric string"), + y: z.string().regex(/^\d+$/, "y must be a numeric string"), + }) + .transform(({ x, y }) => ({ x: BigInt(x), y: BigInt(y) })) +); + export async function getCredential({ credentialID, env, @@ -202,6 +240,65 @@ export async function getCredentials({ } } +export async function getAuthCredentialsByIDs({ + env, + identifier, + IDs, + signal, +}: { + IDs: Array; + env: Env; + identifier: string; + signal?: AbortSignal; +}): Promise>> { + try { + const promises = IDs.map((id) => getCredential({ credentialID: id, env, identifier, signal })); + const credentials = await Promise.all(promises); + + const { failed, successful } = credentials.reduce>( + (acc, credential) => { + try { + if (credential.success) { + const parsedCredentialSubject = authCredentialSubjectParser.parse({ + x: credential.data.credentialSubject.x, + y: credential.data.credentialSubject.y, + }); + + const published = + credential.data.proof?.some( + ({ type }) => type === ProofType.Iden3SparseMerkleTreeProof + ) || false; + + return { + ...acc, + successful: [ + ...acc.successful, + { + ...credential.data, + credentialSubject: { ...parsedCredentialSubject }, + published, + }, + ], + }; + } else { + return { ...acc, failed: [...acc.failed, credential.error] }; + } + } catch (error) { + return { ...acc, failed: [...acc.failed, buildAppError(error)] }; + } + }, + { failed: [], successful: [] } + ); + + return buildSuccessResponse({ + failed, + successful, + }); + } catch (error) { + return buildErrorResponse(error); + } +} + export type CreateCredential = { credentialSchema: string; credentialSubject: Json; @@ -579,3 +676,33 @@ export async function getIssuedMessages({ return buildErrorResponse(error); } } + +export type CreateAuthCredential = { + credentialStatusType: CredentialStatusType; + keyID: string; +}; + +export async function createAuthCredential({ + env, + identifier, + payload, +}: { + env: Env; + identifier: string; + payload: CreateAuthCredential; +}): Promise> { + try { + const response = await axios({ + baseURL: env.api.url, + data: payload, + headers: { + Authorization: buildAuthorizationHeader(env), + }, + method: "POST", + url: `${API_VERSION}/identities/${identifier}/create-auth-credential`, + }); + return buildSuccessResponse(IDParser.parse(response.data)); + } catch (error) { + return buildErrorResponse(error); + } +} diff --git a/ui/src/adapters/api/identities.ts b/ui/src/adapters/api/identities.ts index 8e298c0d8..6303290d8 100644 --- a/ui/src/adapters/api/identities.ts +++ b/ui/src/adapters/api/identities.ts @@ -49,7 +49,7 @@ export async function getIdentities({ url: `${API_VERSION}/identities`, }); - return buildSuccessResponse(getListParser(identityParser).parse(response.data || [])); + return buildSuccessResponse(getListParser(identityParser).parse(response.data)); } catch (error) { return buildErrorResponse(error); } @@ -101,6 +101,7 @@ export async function createIdentity({ export const identityDetailsParser = getStrictParser()( z.object({ + authCredentialsIDs: z.array(z.string()), credentialStatusType: z.nativeEnum(CredentialStatusType), displayName: z.string().nullable(), identifier: z.string(), diff --git a/ui/src/adapters/api/keys.ts b/ui/src/adapters/api/keys.ts new file mode 100644 index 000000000..194bd9b3d --- /dev/null +++ b/ui/src/adapters/api/keys.ts @@ -0,0 +1,168 @@ +import axios from "axios"; +import { z } from "zod"; + +import { Response, buildErrorResponse, buildSuccessResponse } from "src/adapters"; +import { ID, IDParser, Message, buildAuthorizationHeader, messageParser } from "src/adapters/api"; +import { getResourceParser, getStrictParser } from "src/adapters/parsers"; +import { Env, Key, KeyType } from "src/domain"; +import { API_VERSION } from "src/utils/constants"; +import { Resource } from "src/utils/types"; + +const keyParser = getStrictParser()( + z.object({ + id: z.string(), + isAuthCredential: z.boolean(), + keyType: z.nativeEnum(KeyType), + name: z.string(), + publicKey: z.string(), + }) +); + +export async function getKeys({ + env, + identifier, + params: { maxResults, page, type }, + signal, +}: { + env: Env; + identifier: string; + params: { + maxResults?: number; + page?: number; + type?: KeyType; + }; + signal?: AbortSignal; +}): Promise>> { + try { + const response = await axios({ + baseURL: env.api.url, + headers: { + Authorization: buildAuthorizationHeader(env), + }, + method: "GET", + params: new URLSearchParams({ + ...(maxResults !== undefined ? { max_results: maxResults.toString() } : {}), + ...(page !== undefined ? { page: page.toString() } : {}), + ...(type !== undefined ? { type } : {}), + }), + signal, + url: `${API_VERSION}/identities/${identifier}/keys`, + }); + return buildSuccessResponse(getResourceParser(keyParser).parse(response.data)); + } catch (error) { + return buildErrorResponse(error); + } +} + +export async function getKey({ + env, + identifier, + keyID, + signal, +}: { + env: Env; + identifier: string; + keyID: string; + signal?: AbortSignal; +}): Promise> { + try { + const response = await axios({ + baseURL: env.api.url, + headers: { + Authorization: buildAuthorizationHeader(env), + }, + method: "GET", + signal, + url: `${API_VERSION}/identities/${identifier}/keys/${keyID}`, + }); + return buildSuccessResponse(keyParser.parse(response.data)); + } catch (error) { + return buildErrorResponse(error); + } +} + +export type CreateKey = { + keyType: KeyType; + name: string; +}; + +export async function createKey({ + env, + identifier, + payload, +}: { + env: Env; + identifier: string; + payload: CreateKey; +}): Promise> { + try { + const response = await axios({ + baseURL: env.api.url, + data: payload, + headers: { + Authorization: buildAuthorizationHeader(env), + }, + method: "POST", + url: `${API_VERSION}/identities/${identifier}/keys`, + }); + return buildSuccessResponse(IDParser.parse(response.data)); + } catch (error) { + return buildErrorResponse(error); + } +} + +export type UpdateKey = { + name: string; +}; + +export async function updateKeyName({ + env, + identifier, + keyID, + payload, +}: { + env: Env; + identifier: string; + keyID: string; + payload: UpdateKey; +}) { + try { + await axios({ + baseURL: env.api.url, + data: payload, + headers: { + Authorization: buildAuthorizationHeader(env), + }, + method: "PATCH", + url: `${API_VERSION}/identities/${identifier}/keys/${keyID}`, + }); + + return buildSuccessResponse(undefined); + } catch (error) { + return buildErrorResponse(error); + } +} + +export async function deleteKey({ + env, + identifier, + keyID, +}: { + env: Env; + identifier: string; + keyID: string; +}): Promise> { + try { + const response = await axios({ + baseURL: env.api.url, + headers: { + Authorization: buildAuthorizationHeader(env), + }, + method: "DELETE", + url: `${API_VERSION}/identities/${identifier}/keys/${keyID}`, + }); + return buildSuccessResponse(messageParser.parse(response.data)); + } catch (error) { + return buildErrorResponse(error); + } +} diff --git a/ui/src/adapters/index.ts b/ui/src/adapters/index.ts index e566ba8ed..1c683edad 100644 --- a/ui/src/adapters/index.ts +++ b/ui/src/adapters/index.ts @@ -1,5 +1,5 @@ +import { buildAppError } from "src/adapters/parsers"; import { AppError } from "src/domain"; -import { buildAppError } from "src/utils/error"; type SuccessResponse = { data: D; diff --git a/ui/src/adapters/jsonSchemas.ts b/ui/src/adapters/jsonSchemas.ts index f3ed5c401..1c75f89a8 100644 --- a/ui/src/adapters/jsonSchemas.ts +++ b/ui/src/adapters/jsonSchemas.ts @@ -1,8 +1,8 @@ import { Response } from "src/adapters"; import { getJsonFromUrl } from "src/adapters/json"; +import { buildAppError } from "src/adapters/parsers"; import { getJsonLdTypeParser, jsonSchemaParser } from "src/adapters/parsers/jsonSchemas"; import { Env, Json, JsonLdType, JsonSchema } from "src/domain"; -import { buildAppError } from "src/utils/error"; export async function getJsonSchemaFromUrl({ env, diff --git a/ui/src/adapters/parsers/index.ts b/ui/src/adapters/parsers/index.ts index ed71013e2..643d3f42a 100644 --- a/ui/src/adapters/parsers/index.ts +++ b/ui/src/adapters/parsers/index.ts @@ -1,5 +1,9 @@ +import { message } from "antd"; +import { MessageType } from "antd/es/message/interface"; +import { isAxiosError, isCancel } from "axios"; import { z } from "zod"; +import { AppError } from "src/domain"; import { List, ResourceMeta } from "src/utils/types"; export function getListParser( @@ -11,23 +15,30 @@ export function getListParser( (acc: List, curr: unknown, index): List => { const parsed = parser.safeParse(curr); - return parsed.success - ? { - ...acc, - successful: [...acc.successful, parsed.data], - } - : { - ...acc, - failed: [ - ...acc.failed, - new z.ZodError( - parsed.error.issues.map((issue) => ({ - ...issue, - path: [index, ...issue.path], - })) - ), - ], - }; + if (parsed.success) { + return { + ...acc, + successful: [...acc.successful, parsed.data], + }; + } else { + const error = new z.ZodError( + parsed.error.issues.map((issue) => ({ + ...issue, + path: [index, ...issue.path], + })) + ); + return { + ...acc, + failed: [ + ...acc.failed, + { + error, + message: processZodError(error).join("\n"), + type: "parse-error", + }, + ], + }; + } }, { failed: [], successful: [] } ) @@ -119,3 +130,132 @@ export function getStrictParser(): < ) => z.ZodSchema { return (parser: z.ZodSchema) => parser; } + +export function processZodError(error: z.ZodError, init: string[] = []) { + return error.errors.reduce((mainAcc, issue): string[] => { + switch (issue.code) { + case "invalid_union": { + return [ + ...mainAcc, + ...issue.unionErrors.reduce( + (innerAcc: string[], current: z.ZodError): string[] => [ + ...innerAcc, + ...processZodError(current), + ], + [] + ), + ]; + } + + default: { + const errorMsg = issue.path.length + ? `${issue.message} at ${issue.path.join(".")}` + : issue.message; + return [...mainAcc, errorMsg]; + } + } + }, init); +} + +export function notifyError(error: AppError, compact = false): MessageType[] { + if (!compact && error.type === "parse-error") { + return notifyParseError(error.error); + } else { + return [message.error(error.message)]; + } +} + +export function notifyParseError(error: z.ZodError): MessageType[] { + return processZodError(error).map((error) => message.error(error)); +} + +export function notifyErrors(errors: AppError[]): MessageType[] { + return errors.reduce( + (acc: MessageType[], curr) => [ + ...acc, + ...(curr.type === "parse-error" ? notifyParseError(curr.error) : notifyError(curr)), + ], + [] + ); +} + +const messageParser = getStrictParser<{ message: string }>()(z.object({ message: z.string() })); + +export function buildAppError(error: unknown): AppError { + if (typeof error === "string") { + return { + message: error, + type: "custom-error", + }; + } else if (isCancel(error)) { + return { + error, + message: error.message + ? `The request has been aborted. ${error.message}` + : "The request has been aborted.", + type: "cancel-error", + }; + } else if (isAxiosError(error)) { + const parsedMessage = messageParser.safeParse(error.response?.data); + + return { + error, + message: parsedMessage.success + ? `${error.message}: ${parsedMessage.data.message}` + : error.message, + type: "request-error", + }; + } else if (error instanceof z.ZodError) { + return { + error, + message: processZodError(error).join("\n"), + type: "parse-error", + }; + } else if (error instanceof Error) { + return { + error, + message: error.message, + type: "general-error", + }; + } else { + return { + error, + message: "Unknown error", + type: "unknown-error", + }; + } +} + +export const envErrorToString = (error: AppError) => + [ + "An error occurred while reading the environment variables:", + error.message, + "Please provide valid environment variables.", + ].join("\n"); + +export const credentialSubjectValueErrorToString = (error: AppError) => + [ + error.type === "parse-error" || error.type === "custom-error" + ? "An error occurred while parsing the value of the credentialSubject:" + : "An error occurred while processing the value of the credentialSubject", + error.message, + "Please try again.", + ].join("\n"); + +export const jsonSchemaErrorToString = (error: AppError) => + [ + error.type === "parse-error" || error.type === "custom-error" + ? "An error occurred while parsing the JSON Schema:" + : "An error occurred while downloading the JSON Schema:", + error.message, + "Please try again.", + ].join("\n"); + +export const jsonLdContextErrorToString = (error: AppError) => + [ + error.type === "parse-error" || error.type === "custom-error" + ? "An error occurred while parsing the JSON LD Type referenced in this schema:" + : "An error occurred while downloading the JSON LD Type referenced in this schema:", + error.message, + "Please try again.", + ].join("\n"); diff --git a/ui/src/components/connections/ConnectionsTable.tsx b/ui/src/components/connections/ConnectionsTable.tsx index ae66e8a82..6a9bbc3ce 100644 --- a/ui/src/components/connections/ConnectionsTable.tsx +++ b/ui/src/components/connections/ConnectionsTable.tsx @@ -16,7 +16,7 @@ import { generatePath, useNavigate, useSearchParams } from "react-router-dom"; import { Sorter, parseSorters, serializeSorters } from "src/adapters/api"; import { getConnections } from "src/adapters/api/connections"; -import { positiveIntegerFromStringParser } from "src/adapters/parsers"; +import { notifyErrors, positiveIntegerFromStringParser } from "src/adapters/parsers"; import { tableSorterParser } from "src/adapters/parsers/view"; import IconCreditCardPlus from "src/assets/icons/credit-card-plus.svg?react"; import IconDots from "src/assets/icons/dots-vertical.svg?react"; @@ -49,7 +49,6 @@ import { QUERY_SEARCH_PARAM, SORT_PARAM, } from "src/utils/constants"; -import { notifyParseErrors } from "src/utils/error"; export function ConnectionsTable() { const env = useEnvContext(); @@ -217,7 +216,7 @@ export function ConnectionsTable() { maxResults: response.data.meta.max_results, page: response.data.meta.page, }); - notifyParseErrors(response.data.items.failed); + void notifyErrors(response.data.items.failed); } else { if (!isAbortedError(response.error)) { setConnections({ error: response.error, status: "failed" }); diff --git a/ui/src/components/connections/CredentialsTable.tsx b/ui/src/components/connections/CredentialsTable.tsx index 2868d3453..23b41b537 100644 --- a/ui/src/components/connections/CredentialsTable.tsx +++ b/ui/src/components/connections/CredentialsTable.tsx @@ -21,6 +21,7 @@ import { credentialStatusParser, getCredentials, } from "src/adapters/api/credentials"; +import { notifyErrors, notifyParseError } from "src/adapters/parsers"; import IconCreditCardRefresh from "src/assets/icons/credit-card-refresh.svg?react"; import IconDots from "src/assets/icons/dots-vertical.svg?react"; import IconInfoCircle from "src/assets/icons/info-circle.svg?react"; @@ -49,7 +50,6 @@ import { REVOCATION, REVOKE, } from "src/utils/constants"; -import { notifyParseError, notifyParseErrors } from "src/utils/error"; import { formatDate } from "src/utils/forms"; export function CredentialsTable({ userID }: { userID: string }) { @@ -199,7 +199,7 @@ export function CredentialsTable({ userID }: { userID: string }) { data: response.data.items.successful, status: "successful", }); - notifyParseErrors(response.data.items.failed); + void notifyErrors(response.data.items.failed); } else { if (!isAbortedError(response.error)) { setCredentials({ error: response.error, status: "failed" }); @@ -215,7 +215,7 @@ export function CredentialsTable({ userID }: { userID: string }) { if (parsedCredentialStatus.success) { setCredentialStatus(parsedCredentialStatus.data); } else { - notifyParseError(parsedCredentialStatus.error); + void notifyParseError(parsedCredentialStatus.error); } }; diff --git a/ui/src/components/credentials/CreateAuthCredential.tsx b/ui/src/components/credentials/CreateAuthCredential.tsx new file mode 100644 index 000000000..9b1b4eb4e --- /dev/null +++ b/ui/src/components/credentials/CreateAuthCredential.tsx @@ -0,0 +1,210 @@ +import { App, Button, Card, Divider, Flex, Form, Select, Space } from "antd"; +import { useCallback, useEffect, useState } from "react"; +import { generatePath, useNavigate } from "react-router-dom"; +import { + CreateAuthCredential as CreateAuthCredentialType, + createAuthCredential, +} from "src/adapters/api/credentials"; +import { getSupportedBlockchains } from "src/adapters/api/identities"; +import { getKeys } from "src/adapters/api/keys"; +import { notifyErrors } from "src/adapters/parsers"; +import { ErrorResult } from "src/components/shared/ErrorResult"; +import { LoadingResult } from "src/components/shared/LoadingResult"; + +import { SiderLayoutContent } from "src/components/shared/SiderLayoutContent"; +import { useEnvContext } from "src/contexts/Env"; +import { useIdentityContext } from "src/contexts/Identity"; +import { AppError, CredentialStatusType, Key, KeyType } from "src/domain"; +import { ROUTES } from "src/routes"; +import { + AsyncTask, + hasAsyncTaskFailed, + isAsyncTaskDataAvailable, + isAsyncTaskStarting, +} from "src/utils/async"; +import { isAbortedError, makeRequestAbortable } from "src/utils/browser"; +import { VALUE_REQUIRED } from "src/utils/constants"; + +export function CreateAuthCredential() { + const env = useEnvContext(); + const { identifier } = useIdentityContext(); + const [form] = Form.useForm(); + const navigate = useNavigate(); + const { message } = App.useApp(); + + const [keys, setKeys] = useState>({ + status: "pending", + }); + + const [credentialStatusTypes, setCredentialStatusTypes] = useState< + AsyncTask + >({ + status: "pending", + }); + + const handleSubmit = (formValues: CreateAuthCredentialType) => { + return void createAuthCredential({ + env, + identifier, + payload: formValues, + }).then((response) => { + if (response.success) { + void message.success("Auth credential added successfully"); + navigate(generatePath(ROUTES.identityDetails.path, { identityID: identifier })); + } else { + void message.error(response.error.message); + } + }); + }; + + const fetchKeys = useCallback( + async (signal?: AbortSignal) => { + setKeys((previousKeys) => + isAsyncTaskDataAvailable(previousKeys) + ? { data: previousKeys.data, status: "reloading" } + : { status: "loading" } + ); + + const response = await getKeys({ + env, + identifier, + params: { + type: KeyType.babyjubJub, + }, + signal, + }); + if (response.success) { + setKeys({ + data: response.data.items.successful, + status: "successful", + }); + + void notifyErrors(response.data.items.failed); + } else { + if (!isAbortedError(response.error)) { + setKeys({ error: response.error, status: "failed" }); + } + } + }, + [env, identifier] + ); + + const fetchBlockChains = useCallback( + async (signal: AbortSignal) => { + setCredentialStatusTypes((previousState) => + isAsyncTaskDataAvailable(previousState) + ? { data: previousState.data, status: "reloading" } + : { status: "loading" } + ); + + const response = await getSupportedBlockchains({ + env, + signal, + }); + + if (response.success) { + const [, , blockchain = "", network = ""] = identifier.split(":"); + const identityBlockchainNetworks = + response.data.successful.find(({ name }) => name === blockchain)?.networks || []; + const identityNetworkCredentialStatusTypes = + identityBlockchainNetworks.find(({ name }) => name === network)?.credentialStatus || []; + + setCredentialStatusTypes({ + data: identityNetworkCredentialStatusTypes, + status: "successful", + }); + } else { + if (!isAbortedError(response.error)) { + setCredentialStatusTypes({ error: response.error, status: "failed" }); + void message.error(response.error.message); + } + } + }, + [env, message, identifier] + ); + + useEffect(() => { + const { aborter } = makeRequestAbortable(fetchKeys); + + return aborter; + }, [fetchKeys]); + + useEffect(() => { + const { aborter } = makeRequestAbortable(fetchBlockChains); + + return aborter; + }, [fetchBlockChains]); + + return ( + + {(() => { + if (hasAsyncTaskFailed(keys) || hasAsyncTaskFailed(credentialStatusTypes)) { + return ( + + {hasAsyncTaskFailed(keys) && }; + {hasAsyncTaskFailed(credentialStatusTypes) && ( + + )} + ; + + ); + } else if (isAsyncTaskStarting(keys) || isAsyncTaskStarting(credentialStatusTypes)) { + return ( + + + + ); + } else { + return ( + + +
+ + + + + + + + + + + + + + +
+
+ ); + } + })()} +
+ ); +} diff --git a/ui/src/components/credentials/CredentialDetails.tsx b/ui/src/components/credentials/CredentialDetails.tsx index 18b0fde64..41d32de85 100644 --- a/ui/src/components/credentials/CredentialDetails.tsx +++ b/ui/src/components/credentials/CredentialDetails.tsx @@ -4,6 +4,7 @@ import { generatePath, useNavigate, useParams } from "react-router-dom"; import { getCredential, getIssuedMessages } from "src/adapters/api/credentials"; import { getJsonSchemaFromUrl } from "src/adapters/jsonSchemas"; +import { buildAppError, credentialSubjectValueErrorToString } from "src/adapters/parsers"; import { getAttributeValueParser } from "src/adapters/parsers/jsonSchemas"; import IconTrash from "src/assets/icons/trash-01.svg?react"; import IconClose from "src/assets/icons/x.svg?react"; @@ -26,7 +27,6 @@ import { } from "src/utils/async"; import { isAbortedError, makeRequestAbortable } from "src/utils/browser"; import { CREDENTIALS_TABS, DELETE, REVOKE } from "src/utils/constants"; -import { buildAppError, credentialSubjectValueErrorToString } from "src/utils/error"; import { formatDate } from "src/utils/forms"; import { extractCredentialSubjectAttribute } from "src/utils/jsonSchemas"; diff --git a/ui/src/components/credentials/CredentialsTable.tsx b/ui/src/components/credentials/CredentialsTable.tsx index a6e5964e1..a61baf69e 100644 --- a/ui/src/components/credentials/CredentialsTable.tsx +++ b/ui/src/components/credentials/CredentialsTable.tsx @@ -19,7 +19,11 @@ import { Link, generatePath, useNavigate, useSearchParams } from "react-router-d import { Sorter, parseSorters, serializeSorters } from "src/adapters/api"; import { credentialStatusParser, getCredentials } from "src/adapters/api/credentials"; -import { positiveIntegerFromStringParser } from "src/adapters/parsers"; +import { + notifyErrors, + notifyParseError, + positiveIntegerFromStringParser, +} from "src/adapters/parsers"; import { tableSorterParser } from "src/adapters/parsers/view"; import IconCreditCardPlus from "src/assets/icons/credit-card-plus.svg?react"; import IconCreditCardRefresh from "src/assets/icons/credit-card-refresh.svg?react"; @@ -57,7 +61,6 @@ import { SORT_PARAM, STATUS_SEARCH_PARAM, } from "src/utils/constants"; -import { notifyParseError, notifyParseErrors } from "src/utils/error"; import { formatDate } from "src/utils/forms"; export function CredentialsTable() { @@ -270,7 +273,7 @@ export function CredentialsTable() { maxResults: response.data.meta.max_results, page: response.data.meta.page, }); - notifyParseErrors(response.data.items.failed); + void notifyErrors(response.data.items.failed); } else { if (!isAbortedError(response.error)) { setCredentials({ error: response.error, status: "failed" }); @@ -321,7 +324,7 @@ export function CredentialsTable() { setSearchParams(params); } else { - notifyParseError(parsedCredentialStatus.error); + void notifyParseError(parsedCredentialStatus.error); } }; diff --git a/ui/src/components/credentials/IssuanceMethodForm.tsx b/ui/src/components/credentials/IssuanceMethodForm.tsx index ae5633b92..433f11320 100644 --- a/ui/src/components/credentials/IssuanceMethodForm.tsx +++ b/ui/src/components/credentials/IssuanceMethodForm.tsx @@ -17,6 +17,7 @@ import dayjs from "dayjs"; import { useCallback, useEffect, useState } from "react"; import { getConnections } from "src/adapters/api/connections"; +import { notifyErrors } from "src/adapters/parsers"; import { IssuanceMethodFormData, issuanceMethodFormDataParser } from "src/adapters/parsers/view"; import IconRight from "src/assets/icons/arrow-narrow-right.svg?react"; import { useEnvContext } from "src/contexts/Env"; @@ -25,7 +26,6 @@ import { AppError, Connection } from "src/domain"; import { AsyncTask, isAsyncTaskDataAvailable } from "src/utils/async"; import { makeRequestAbortable } from "src/utils/browser"; import { ACCESSIBLE_UNTIL, CREDENTIAL_LINK, VALUE_REQUIRED } from "src/utils/constants"; -import { notifyParseErrors } from "src/utils/error"; export function IssuanceMethodForm({ initialValues, @@ -64,7 +64,7 @@ export function IssuanceMethodForm({ if (response.success) { setConnections({ data: response.data.items.successful, status: "successful" }); - notifyParseErrors(response.data.items.failed); + void notifyErrors(response.data.items.failed); } else { setConnections({ error: response.error, status: "failed" }); } diff --git a/ui/src/components/credentials/IssueCredential.tsx b/ui/src/components/credentials/IssueCredential.tsx index a74e30193..83a28897d 100644 --- a/ui/src/components/credentials/IssueCredential.tsx +++ b/ui/src/components/credentials/IssueCredential.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from "react"; import { generatePath, useNavigate, useSearchParams } from "react-router-dom"; import { createCredential, createLink } from "src/adapters/api/credentials"; +import { notifyParseError } from "src/adapters/parsers"; import { CredentialDirectIssuance, CredentialFormInput, @@ -23,7 +24,6 @@ import { ApiSchema, JsonSchema, ProofType } from "src/domain"; import { ROUTES } from "src/routes"; import { AsyncTask, isAsyncTaskDataAvailable } from "src/utils/async"; import { DID_SEARCH_PARAM, ISSUE_CREDENTIAL, SCHEMA_SEARCH_PARAM } from "src/utils/constants"; -import { notifyParseError } from "src/utils/error"; import { extractCredentialSubjectAttribute, extractCredentialSubjectAttributeWithoutId, @@ -140,7 +140,7 @@ export function IssueCredential() { void message.error(response.error.message); } } else { - notifyParseError(serializedCredentialForm.error); + void notifyParseError(serializedCredentialForm.error); } setIsLoading(false); } @@ -188,7 +188,7 @@ export function IssueCredential() { void message.error(response.error.message); } } else { - notifyParseError(serializedCredentialForm.error); + void notifyParseError(serializedCredentialForm.error); } setIsLoading(false); @@ -270,7 +270,7 @@ export function IssueCredential() { }); } } else { - notifyParseError(parsedForm.error); + void notifyParseError(parsedForm.error); } }} type={credentialFormInput.issuanceMethod.type} diff --git a/ui/src/components/credentials/IssueCredentialForm.tsx b/ui/src/components/credentials/IssueCredentialForm.tsx index cf4e2fe0a..85aa8fa1c 100644 --- a/ui/src/components/credentials/IssueCredentialForm.tsx +++ b/ui/src/components/credentials/IssueCredentialForm.tsx @@ -26,6 +26,7 @@ import { z } from "zod"; import { getDisplayMethods } from "src/adapters/api/display-method"; import { getApiSchemas } from "src/adapters/api/schemas"; import { getJsonSchemaFromUrl } from "src/adapters/jsonSchemas"; +import { buildAppError, jsonSchemaErrorToString, notifyError } from "src/adapters/parsers"; import { IssueCredentialFormData, dayjsInstanceParser, @@ -58,7 +59,6 @@ import { URL_FIELD_ERROR_MESSAGE, VALUE_REQUIRED, } from "src/utils/constants"; -import { buildAppError, jsonSchemaErrorToString, notifyError } from "src/utils/error"; import { extractCredentialSubjectAttributeWithoutId, makeAttributeOptional, @@ -215,10 +215,10 @@ export function IssueCredentialForm({ ); } } catch (error) { - notifyError(buildAppError(error)); + void notifyError(buildAppError(error)); } } else { - notifyError(buildAppError(serializedSchemaForm.error)); + void notifyError(buildAppError(serializedSchemaForm.error)); } } return false; diff --git a/ui/src/components/credentials/LinkDetails.tsx b/ui/src/components/credentials/LinkDetails.tsx index a10595d83..ed89d3c35 100644 --- a/ui/src/components/credentials/LinkDetails.tsx +++ b/ui/src/components/credentials/LinkDetails.tsx @@ -4,6 +4,7 @@ import { generatePath, useNavigate, useParams } from "react-router-dom"; import { getLink } from "src/adapters/api/credentials"; import { getJsonSchemaFromUrl } from "src/adapters/jsonSchemas"; +import { buildAppError, credentialSubjectValueErrorToString } from "src/adapters/parsers"; import { getAttributeValueParser } from "src/adapters/parsers/jsonSchemas"; import IconTrash from "src/assets/icons/trash-01.svg?react"; import { LinkDeleteModal } from "src/components/credentials/LinkDeleteModal"; @@ -24,7 +25,6 @@ import { } from "src/utils/async"; import { isAbortedError, makeRequestAbortable } from "src/utils/browser"; import { CREDENTIALS_TABS, DELETE } from "src/utils/constants"; -import { buildAppError, credentialSubjectValueErrorToString } from "src/utils/error"; import { formatDate } from "src/utils/forms"; import { extractCredentialSubjectAttributeWithoutId } from "src/utils/jsonSchemas"; diff --git a/ui/src/components/credentials/LinksTable.tsx b/ui/src/components/credentials/LinksTable.tsx index 301e1c336..d9a682527 100644 --- a/ui/src/components/credentials/LinksTable.tsx +++ b/ui/src/components/credentials/LinksTable.tsx @@ -22,6 +22,7 @@ import { useCallback, useEffect, useState } from "react"; import { generatePath, useNavigate, useSearchParams } from "react-router-dom"; import { getLinks, linkStatusParser, updateLink } from "src/adapters/api/credentials"; +import { notifyErrors } from "src/adapters/parsers"; import IconCreditCardPlus from "src/assets/icons/credit-card-plus.svg?react"; import IconDots from "src/assets/icons/dots-vertical.svg?react"; import IconInfoCircle from "src/assets/icons/info-circle.svg?react"; @@ -46,7 +47,6 @@ import { STATUS, STATUS_SEARCH_PARAM, } from "src/utils/constants"; -import { notifyParseErrors } from "src/utils/error"; import { formatDate } from "src/utils/forms"; export function LinksTable() { @@ -231,7 +231,7 @@ export function LinksTable() { if (response.success) { setLinks({ data: response.data.successful, status: "successful" }); - notifyParseErrors(response.data.failed); + void notifyErrors(response.data.failed); } else { if (!isAbortedError(response.error)) { setLinks({ error: response.error, status: "failed" }); diff --git a/ui/src/components/display-methods/DisplayMethodDetails.tsx b/ui/src/components/display-methods/DisplayMethodDetails.tsx index 9174a944d..1f13e8fd1 100644 --- a/ui/src/components/display-methods/DisplayMethodDetails.tsx +++ b/ui/src/components/display-methods/DisplayMethodDetails.tsx @@ -24,6 +24,7 @@ import { updateDisplayMethod, } from "src/adapters/api/display-method"; import { processUrl } from "src/adapters/api/schemas"; +import { buildAppError, notifyError } from "src/adapters/parsers"; import IconDots from "src/assets/icons/dots-vertical.svg?react"; import EditIcon from "src/assets/icons/edit-02.svg?react"; import { DisplayMethodCard } from "src/components/display-methods/DisplayMethodCard"; @@ -45,7 +46,6 @@ import { isAsyncTaskStarting, } from "src/utils/async"; import { isAbortedError, makeRequestAbortable } from "src/utils/browser"; -import { buildAppError, notifyError } from "src/utils/error"; function Details({ data }: { data: DisplayMethod }) { const env = useEnvContext(); @@ -170,11 +170,11 @@ export function DisplayMethodDetails() { void fetchDisplayMethod(); setIsEditModalOpen(false); } else { - notifyError(buildAppError(response.error.message)); + void notifyError(buildAppError(response.error.message)); } }); } else { - notifyError(buildAppError(`"${url}" is not a valid URL`)); + void notifyError(buildAppError(`"${url}" is not a valid URL`)); } }; diff --git a/ui/src/components/display-methods/DisplayMethodForm.tsx b/ui/src/components/display-methods/DisplayMethodForm.tsx index 7497d21ea..ce0e1e2bc 100644 --- a/ui/src/components/display-methods/DisplayMethodForm.tsx +++ b/ui/src/components/display-methods/DisplayMethodForm.tsx @@ -3,12 +3,12 @@ import { useState } from "react"; import { z } from "zod"; import { UpsertDisplayMethod, getDisplayMethodMetadata } from "src/adapters/api/display-method"; +import { buildAppError, notifyError } from "src/adapters/parsers"; import { DisplayMethodErrorResult } from "src/components/display-methods/DisplayMethodErrorResult"; import { useEnvContext } from "src/contexts/Env"; import { AppError, DisplayMethodMetadata } from "src/domain"; import { AsyncTask, hasAsyncTaskFailed, isAsyncTaskStarting } from "src/utils/async"; import { VALUE_REQUIRED } from "src/utils/constants"; -import { buildAppError, notifyError } from "src/utils/error"; export function DisplayMethodForm({ initialValues, @@ -39,7 +39,7 @@ export function DisplayMethodForm({ } }); } else { - notifyError(buildAppError(`"${url}" is not a valid URL`)); + void notifyError(buildAppError(`"${url}" is not a valid URL`)); } }; diff --git a/ui/src/components/display-methods/DisplayMethodsTable.tsx b/ui/src/components/display-methods/DisplayMethodsTable.tsx index 1dd58558a..86f52558f 100644 --- a/ui/src/components/display-methods/DisplayMethodsTable.tsx +++ b/ui/src/components/display-methods/DisplayMethodsTable.tsx @@ -16,7 +16,7 @@ import { Link, generatePath, useNavigate, useSearchParams } from "react-router-d import { Sorter, parseSorters, serializeSorters } from "src/adapters/api"; import { deleteDisplayMethod, getDisplayMethods } from "src/adapters/api/display-method"; -import { positiveIntegerFromStringParser } from "src/adapters/parsers"; +import { notifyErrors, positiveIntegerFromStringParser } from "src/adapters/parsers"; import { tableSorterParser } from "src/adapters/parsers/view"; import IconIssuers from "src/assets/icons/building-08.svg?react"; import IconCheckMark from "src/assets/icons/check.svg?react"; @@ -46,7 +46,6 @@ import { QUERY_SEARCH_PARAM, SORT_PARAM, } from "src/utils/constants"; -import { notifyParseErrors } from "src/utils/error"; export function DisplayMethodsTable() { const env = useEnvContext(); @@ -240,7 +239,7 @@ export function DisplayMethodsTable() { maxResults: response.data.meta.max_results, page: response.data.meta.page, }); - notifyParseErrors(response.data.items.failed); + void notifyErrors(response.data.items.failed); } else { if (!isAbortedError(response.error)) { setDisplayMethods({ error: response.error, status: "failed" }); diff --git a/ui/src/components/identities/Identity.tsx b/ui/src/components/identities/Identity.tsx index 1557698be..808b019dd 100644 --- a/ui/src/components/identities/Identity.tsx +++ b/ui/src/components/identities/Identity.tsx @@ -1,4 +1,4 @@ -import { App, Button, Card, Flex, Form, Input, Space } from "antd"; +import { App, Button, Card, Divider, Flex, Form, Input, Space } from "antd"; import { useCallback, useEffect, useState } from "react"; import { useParams } from "react-router-dom"; import { useIdentityContext } from "../../contexts/Identity"; @@ -7,6 +7,7 @@ import { IdentityDetailsFormData } from "src/adapters/parsers/view"; import CheckIcon from "src/assets/icons/check.svg?react"; import EditIcon from "src/assets/icons/edit-02.svg?react"; import CloseIcon from "src/assets/icons/x-close.svg?react"; +import { IdentityAuthCredentials } from "src/components/identities/IdentityAuthCredentials"; import { Detail } from "src/components/shared/Detail"; import { ErrorResult } from "src/components/shared/ErrorResult"; import { LoadingResult } from "src/components/shared/LoadingResult"; @@ -126,69 +127,77 @@ export function Identity() { } else { const [, method = "", blockchain = "", network = ""] = identifier.split(":"); return ( - - {displayNameEditable ? ( -
- - - - - - + + } + > + + } size={48} /> + + No auth credentials + + + Auth credentials will be listed here. + + + } + isLoading={isAsyncTaskStarting(credentials)} + searchPlaceholder="Search credentials, attributes, identifiers..." + showDefaultContents={showDefaultContent} + table={ + ({ + title: ( + + <>{title} + + ), + ...column, + }))} + dataSource={credentialsList} + loading={credentials.status === "reloading"} + pagination={false} + rowKey="id" + showSorterTooltip + sortDirections={["ascend", "descend"]} + /> + } + title={ + + + + + {credentialsList.length} + + + } + /> + {credentialToRevoke && ( + setCredentialToRevoke(undefined)} + onRevoke={() => void fetchAuthCredentials()} + /> + )} + + ); +} diff --git a/ui/src/components/identities/IdentityForm.tsx b/ui/src/components/identities/IdentityForm.tsx index a266d095b..af355851f 100644 --- a/ui/src/components/identities/IdentityForm.tsx +++ b/ui/src/components/identities/IdentityForm.tsx @@ -2,6 +2,7 @@ import { App, Button, Col, Divider, Form, Input, Row, Select, Typography } from import { useCallback, useEffect, useState } from "react"; import { getSupportedBlockchains } from "src/adapters/api/identities"; +import { buildAppError } from "src/adapters/parsers"; import { IdentityFormData, identityFormDataParser } from "src/adapters/parsers/view"; import { ErrorResult } from "src/components/shared/ErrorResult"; import { LoadingResult } from "src/components/shared/LoadingResult"; @@ -10,7 +11,6 @@ import { AppError, Blockchain, CredentialStatusType, IdentityType, Method } from import { AsyncTask, isAsyncTaskDataAvailable } from "src/utils/async"; import { isAbortedError, makeRequestAbortable } from "src/utils/browser"; import { VALUE_REQUIRED } from "src/utils/constants"; -import { buildAppError } from "src/utils/error"; const initialValues: IdentityFormData = { blockchain: "", diff --git a/ui/src/components/issuer-state/IssuerState.tsx b/ui/src/components/issuer-state/IssuerState.tsx index a4fc8df4f..f438260bc 100644 --- a/ui/src/components/issuer-state/IssuerState.tsx +++ b/ui/src/components/issuer-state/IssuerState.tsx @@ -18,7 +18,7 @@ import { useSearchParams } from "react-router-dom"; import { Sorter, parseSorters, serializeSorters } from "src/adapters/api"; import { getTransactions, publishState, retryPublishState } from "src/adapters/api/issuer-state"; -import { positiveIntegerFromStringParser } from "src/adapters/parsers"; +import { notifyErrors, positiveIntegerFromStringParser } from "src/adapters/parsers"; import { tableSorterParser } from "src/adapters/parsers/view"; import IconAlert from "src/assets/icons/alert-circle.svg?react"; import IconSwitch from "src/assets/icons/switch-horizontal.svg?react"; @@ -44,7 +44,6 @@ import { SORT_PARAM, STATUS, } from "src/utils/constants"; -import { notifyParseErrors } from "src/utils/error"; import { formatDate } from "src/utils/forms"; const PUBLISHED_MESSAGE = "Issuer state is being published"; @@ -160,7 +159,7 @@ export function IssuerState() { maxResults: response.data.meta.max_results, page: response.data.meta.page, }); - notifyParseErrors(response.data.items.failed); + void notifyErrors(response.data.items.failed); } else { if (!isAbortedError(response.error)) { setTransactions({ error: response.error, status: "failed" }); diff --git a/ui/src/components/keys/CreateKey.tsx b/ui/src/components/keys/CreateKey.tsx new file mode 100644 index 000000000..ff3947b27 --- /dev/null +++ b/ui/src/components/keys/CreateKey.tsx @@ -0,0 +1,86 @@ +import { App, Button, Card, Divider, Flex, Form, Input, Select, Space } from "antd"; +import { useNavigate } from "react-router-dom"; + +import { CreateKey as CreateKeyType, createKey } from "src/adapters/api/keys"; +import { SiderLayoutContent } from "src/components/shared/SiderLayoutContent"; +import { useEnvContext } from "src/contexts/Env"; +import { useIdentityContext } from "src/contexts/Identity"; +import { KeyType } from "src/domain"; +import { ROUTES } from "src/routes"; +import { KEY_ADD_NEW, VALUE_REQUIRED } from "src/utils/constants"; + +export function CreateKey() { + const env = useEnvContext(); + const { identifier } = useIdentityContext(); + const [form] = Form.useForm(); + const navigate = useNavigate(); + const { message } = App.useApp(); + + const handleSubmit = (formValues: CreateKeyType) => { + return void createKey({ + env, + identifier, + payload: formValues, + }).then((response) => { + if (response.success) { + void message.success("Key added successfully"); + navigate(ROUTES.keys.path); + } else { + void message.error(response.error.message); + } + }); + }; + + return ( + + + +
+ + + + + + + + + + + + + + +
+
+
+ ); +} diff --git a/ui/src/components/keys/Key.tsx b/ui/src/components/keys/Key.tsx new file mode 100644 index 000000000..b2b61c47a --- /dev/null +++ b/ui/src/components/keys/Key.tsx @@ -0,0 +1,206 @@ +import { App, Button, Card, Dropdown, Flex, Form, Input, Row, Space, Typography } from "antd"; +import { useCallback, useEffect, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; + +import { useIdentityContext } from "../../contexts/Identity"; +import { UpdateKey, deleteKey, getKey, updateKeyName } from "src/adapters/api/keys"; +import IconDots from "src/assets/icons/dots-vertical.svg?react"; +import EditIcon from "src/assets/icons/edit-02.svg?react"; +import { DeleteItem } from "src/components/schemas/DeleteItem"; +import { Detail } from "src/components/shared/Detail"; +import { EditModal } from "src/components/shared/EditModal"; +import { ErrorResult } from "src/components/shared/ErrorResult"; +import { LoadingResult } from "src/components/shared/LoadingResult"; +import { SiderLayoutContent } from "src/components/shared/SiderLayoutContent"; +import { useEnvContext } from "src/contexts/Env"; +import { AppError, Key as KeyType } from "src/domain"; +import { ROUTES } from "src/routes"; +import { AsyncTask, hasAsyncTaskFailed, isAsyncTaskStarting } from "src/utils/async"; +import { isAbortedError, makeRequestAbortable } from "src/utils/browser"; +import { KEY_DETAILS, VALUE_REQUIRED } from "src/utils/constants"; + +export function Key() { + const env = useEnvContext(); + const { identifier } = useIdentityContext(); + const { message } = App.useApp(); + const navigate = useNavigate(); + const [form] = Form.useForm(); + + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + + const [key, setKey] = useState>({ + status: "pending", + }); + + const { keyID } = useParams(); + + const fetchKey = useCallback( + async (signal?: AbortSignal) => { + if (keyID) { + setKey({ status: "loading" }); + + const response = await getKey({ + env, + identifier, + keyID, + signal, + }); + + if (response.success) { + setKey({ data: response.data, status: "successful" }); + } else { + if (!isAbortedError(response.error)) { + setKey({ error: response.error, status: "failed" }); + } + } + } + }, + [env, keyID, identifier] + ); + + useEffect(() => { + const { aborter } = makeRequestAbortable(fetchKey); + + return aborter; + }, [fetchKey]); + + if (!keyID) { + return ; + } + + const handleEdit = () => { + const { name } = form.getFieldsValue(); + void updateKeyName({ + env, + identifier, + keyID, + payload: { name: name.trim() }, + }).then((response) => { + setIsEditModalOpen(false); + if (response.success) { + void fetchKey().then(() => { + void message.success("Key edited successfully"); + }); + } else { + void message.error(response.error.message); + } + }); + }; + + const handleDeleteKey = () => { + void deleteKey({ env, identifier, keyID }).then((response) => { + if (response.success) { + navigate(ROUTES.keys.path); + void message.success(response.data.message); + } else { + void message.error(response.error.message); + } + }); + }; + + return ( + + {(() => { + if (hasAsyncTaskFailed(key)) { + return ( + + + + ); + } else if (isAsyncTaskStarting(key)) { + return ( + + + + ); + } else { + return ( + <> + + {key.data.name} + + + } + title={KEYS} + > + + + + + ); +} diff --git a/ui/src/components/keys/KeysTable.tsx b/ui/src/components/keys/KeysTable.tsx new file mode 100644 index 000000000..a6d8161a9 --- /dev/null +++ b/ui/src/components/keys/KeysTable.tsx @@ -0,0 +1,321 @@ +import { + App, + Avatar, + Button, + Card, + Dropdown, + Row, + Space, + Table, + TableColumnsType, + Tag, + Tooltip, + Typography, +} from "antd"; +import { ItemType } from "antd/es/menu/interface"; +import { useCallback, useEffect, useState } from "react"; +import { Link, generatePath, useNavigate, useSearchParams } from "react-router-dom"; + +import { deleteKey, getKeys } from "src/adapters/api/keys"; +import { notifyErrors, positiveIntegerFromStringParser } from "src/adapters/parsers"; +import IconIssuers from "src/assets/icons/building-08.svg?react"; +import IconCheckMark from "src/assets/icons/check.svg?react"; +import IconCopy from "src/assets/icons/copy-01.svg?react"; +import IconDots from "src/assets/icons/dots-vertical.svg?react"; +import IconInfoCircle from "src/assets/icons/info-circle.svg?react"; +import IconPlus from "src/assets/icons/plus.svg?react"; +import { DeleteItem } from "src/components/schemas/DeleteItem"; +import { ErrorResult } from "src/components/shared/ErrorResult"; +import { NoResults } from "src/components/shared/NoResults"; +import { TableCard } from "src/components/shared/TableCard"; +import { useEnvContext } from "src/contexts/Env"; +import { useIdentityContext } from "src/contexts/Identity"; +import { AppError, Key } from "src/domain"; +import { ROUTES } from "src/routes"; +import { AsyncTask, isAsyncTaskDataAvailable, isAsyncTaskStarting } from "src/utils/async"; +import { isAbortedError, makeRequestAbortable } from "src/utils/browser"; +import { + DEFAULT_PAGINATION_MAX_RESULTS, + DEFAULT_PAGINATION_PAGE, + DEFAULT_PAGINATION_TOTAL, + DETAILS, + DOTS_DROPDOWN_WIDTH, + KEY_ADD_NEW, + PAGINATION_MAX_RESULTS_PARAM, + PAGINATION_PAGE_PARAM, + QUERY_SEARCH_PARAM, +} from "src/utils/constants"; + +export function KeysTable() { + const env = useEnvContext(); + const { identifier } = useIdentityContext(); + const { message } = App.useApp(); + const navigate = useNavigate(); + + const [keys, setKeys] = useState>({ + status: "pending", + }); + + const [searchParams, setSearchParams] = useSearchParams(); + const queryParam = searchParams.get(QUERY_SEARCH_PARAM); + const paginationPageParam = searchParams.get(PAGINATION_PAGE_PARAM); + const paginationMaxResultsParam = searchParams.get(PAGINATION_MAX_RESULTS_PARAM); + + const paginationPageParsed = positiveIntegerFromStringParser.safeParse(paginationPageParam); + const paginationMaxResultsParsed = + positiveIntegerFromStringParser.safeParse(paginationMaxResultsParam); + + const [paginationTotal, setPaginationTotal] = useState(DEFAULT_PAGINATION_TOTAL); + const paginationPage = paginationPageParsed.success + ? paginationPageParsed.data + : DEFAULT_PAGINATION_PAGE; + const paginationMaxResults = paginationMaxResultsParsed.success + ? paginationMaxResultsParsed.data + : DEFAULT_PAGINATION_MAX_RESULTS; + + const keysList = isAsyncTaskDataAvailable(keys) ? keys.data : []; + const showDefaultContent = keys.status === "successful" && keysList.length === 0; + + const tableColumns: TableColumnsType = [ + { + dataIndex: "name", + key: "name", + render: (name: Key["name"], { id }: Key) => ( + + navigate( + generatePath(ROUTES.keyDetails.path, { + keyID: id, + }) + ) + } + strong + > + {name} + + ), + title: "Name", + }, + { + dataIndex: "keyType", + key: "keyType", + render: (keyType: Key["keyType"]) => {keyType}, + title: "Type", + }, + { + dataIndex: "publicKey", + key: "publicKey", + render: (publicKey: Key["publicKey"]) => ( + + , ], + }} + ellipsis={{ + suffix: publicKey.slice(-5), + }} + > + {publicKey} + + + ), + title: "Public key", + }, + { + dataIndex: "id", + key: "id", + render: (id: Key["id"], { isAuthCredential }: Key) => { + const items: Array = [ + { + icon: , + key: "details", + label: DETAILS, + onClick: () => + navigate( + generatePath(ROUTES.keyDetails.path, { + keyID: id, + }) + ), + }, + ]; + + if (!isAuthCredential) { + items.push( + { + key: "divider1", + type: "divider", + }, + { + danger: true, + key: "delete", + label: ( + handleDeleteKey(id)} + title="Are you sure you want to delete this key?" + /> + ), + } + ); + } + + return ( + + + + + + ); + }, + + width: DOTS_DROPDOWN_WIDTH, + }, + ]; + + const updateUrlParams = useCallback( + ({ maxResults, page }: { maxResults?: number; page?: number }) => { + setSearchParams((previousParams) => { + const params = new URLSearchParams(previousParams); + params.set( + PAGINATION_PAGE_PARAM, + page !== undefined ? page.toString() : DEFAULT_PAGINATION_PAGE.toString() + ); + params.set( + PAGINATION_MAX_RESULTS_PARAM, + maxResults !== undefined + ? maxResults.toString() + : DEFAULT_PAGINATION_MAX_RESULTS.toString() + ); + + return params; + }); + }, + [setSearchParams] + ); + + const fetchKeys = useCallback( + async (signal?: AbortSignal) => { + setKeys((previousKeys) => + isAsyncTaskDataAvailable(previousKeys) + ? { data: previousKeys.data, status: "reloading" } + : { status: "loading" } + ); + + const response = await getKeys({ + env, + identifier, + params: { + maxResults: paginationMaxResults, + page: paginationPage, + }, + signal, + }); + if (response.success) { + setKeys({ + data: response.data.items.successful, + status: "successful", + }); + setPaginationTotal(response.data.meta.total); + updateUrlParams({ + maxResults: response.data.meta.max_results, + page: response.data.meta.page, + }); + void notifyErrors(response.data.items.failed); + } else { + if (!isAbortedError(response.error)) { + setKeys({ error: response.error, status: "failed" }); + } + } + }, + [env, paginationMaxResults, paginationPage, identifier, updateUrlParams] + ); + + const handleDeleteKey = (keyID: string) => { + void deleteKey({ env, identifier, keyID }).then((response) => { + if (response.success) { + void fetchKeys(); + void message.success(response.data.message); + } else { + void message.error(response.error.message); + } + }); + }; + + useEffect(() => { + const { aborter } = makeRequestAbortable(fetchKeys); + + return aborter; + }, [fetchKeys]); + + return ( + + } size={48} /> + + No keys + + Your keys will be listed here. + + + + + + } + isLoading={isAsyncTaskStarting(keys)} + query={queryParam} + showDefaultContents={showDefaultContent} + table={ +
({ + title: ( + + <>{title} + + ), + ...column, + }))} + dataSource={keysList} + locale={{ + emptyText: + keys.status === "failed" ? ( + + ) : ( + + ), + }} + onChange={({ current, pageSize, total }) => { + setPaginationTotal(total || DEFAULT_PAGINATION_TOTAL); + updateUrlParams({ + maxResults: pageSize, + page: current, + }); + }} + pagination={{ + current: paginationPage, + hideOnSinglePage: true, + pageSize: paginationMaxResults, + position: ["bottomRight"], + total: paginationTotal, + }} + rowKey="id" + showSorterTooltip + sortDirections={["ascend", "descend"]} + /> + } + title={ + + + + {paginationTotal} + + + } + /> + ); +} diff --git a/ui/src/components/schemas/DeleteItem.tsx b/ui/src/components/schemas/DeleteItem.tsx new file mode 100644 index 000000000..0eb0f6ceb --- /dev/null +++ b/ui/src/components/schemas/DeleteItem.tsx @@ -0,0 +1,35 @@ +import { Flex, Modal, Typography } from "antd"; +import { useState } from "react"; +import IconTrash from "src/assets/icons/trash-01.svg?react"; +import IconClose from "src/assets/icons/x.svg?react"; +import { CLOSE, DELETE } from "src/utils/constants"; + +export function DeleteItem({ onOk, title }: { onOk: () => void; title: string }) { + const [isModalOpen, setIsModalOpen] = useState(false); + + return ( + <> + setIsModalOpen(true)}> + + + {DELETE} + + + } + maskClosable + okButtonProps={{ danger: true }} + okText={DELETE} + onCancel={() => setIsModalOpen(false)} + onOk={onOk} + open={isModalOpen} + title={title} + > + This action cannot be undone. + + + ); +} diff --git a/ui/src/components/schemas/ImportSchemaForm.tsx b/ui/src/components/schemas/ImportSchemaForm.tsx index caeaa30fa..20fee09a1 100644 --- a/ui/src/components/schemas/ImportSchemaForm.tsx +++ b/ui/src/components/schemas/ImportSchemaForm.tsx @@ -3,16 +3,16 @@ import { useState } from "react"; import { z } from "zod"; import { getJsonSchemaFromUrl, getSchemaJsonLdTypes } from "src/adapters/jsonSchemas"; +import { + buildAppError, + jsonLdContextErrorToString, + jsonSchemaErrorToString, +} from "src/adapters/parsers"; import { ErrorResult } from "src/components/shared/ErrorResult"; import { LoadingResult } from "src/components/shared/LoadingResult"; import { useEnvContext } from "src/contexts/Env"; import { AppError, Env, Json, JsonLdType, JsonSchema } from "src/domain"; import { AsyncTask, isAsyncTaskDataAvailable } from "src/utils/async"; -import { - buildAppError, - jsonLdContextErrorToString, - jsonSchemaErrorToString, -} from "src/utils/error"; export type FormData = { jsonLdContextObject: Json; diff --git a/ui/src/components/schemas/SchemaDetails.tsx b/ui/src/components/schemas/SchemaDetails.tsx index 872f337ab..5f32ddbd2 100644 --- a/ui/src/components/schemas/SchemaDetails.tsx +++ b/ui/src/components/schemas/SchemaDetails.tsx @@ -4,6 +4,11 @@ import { generatePath, useNavigate, useParams } from "react-router-dom"; import { getApiSchema, processUrl } from "src/adapters/api/schemas"; import { getJsonSchemaFromUrl, getSchemaJsonLdTypes } from "src/adapters/jsonSchemas"; +import { + buildAppError, + jsonLdContextErrorToString, + jsonSchemaErrorToString, +} from "src/adapters/parsers"; import CreditCardIcon from "src/assets/icons/credit-card-plus.svg?react"; import { DownloadSchema } from "src/components/schemas/DownloadSchema"; import { SchemaViewer } from "src/components/schemas/SchemaViewer"; @@ -18,11 +23,6 @@ import { ROUTES } from "src/routes"; import { AsyncTask, hasAsyncTaskFailed, isAsyncTaskStarting } from "src/utils/async"; import { isAbortedError, makeRequestAbortable } from "src/utils/browser"; import { SCHEMA_SEARCH_PARAM } from "src/utils/constants"; -import { - buildAppError, - jsonLdContextErrorToString, - jsonSchemaErrorToString, -} from "src/utils/error"; import { formatDate } from "src/utils/forms"; export function SchemaDetails() { diff --git a/ui/src/components/schemas/SchemasTable.tsx b/ui/src/components/schemas/SchemasTable.tsx index 1d0f78ee1..ce181a3d1 100644 --- a/ui/src/components/schemas/SchemasTable.tsx +++ b/ui/src/components/schemas/SchemasTable.tsx @@ -16,6 +16,7 @@ import { useCallback, useEffect, useState } from "react"; import { Link, generatePath, useSearchParams } from "react-router-dom"; import { getApiSchemas } from "src/adapters/api/schemas"; +import { notifyErrors } from "src/adapters/parsers"; import IconSchema from "src/assets/icons/file-search-02.svg?react"; import IconUpload from "src/assets/icons/upload-01.svg?react"; import { ErrorResult } from "src/components/shared/ErrorResult"; @@ -34,7 +35,6 @@ import { SCHEMA_SEARCH_PARAM, SCHEMA_TYPE, } from "src/utils/constants"; -import { notifyParseErrors } from "src/utils/error"; import { formatDate } from "src/utils/forms"; export function SchemasTable() { @@ -138,7 +138,7 @@ export function SchemasTable() { }); if (response.success) { setApiSchemas({ data: response.data.successful, status: "successful" }); - notifyParseErrors(response.data.failed); + void notifyErrors(response.data.failed); } else { if (!isAbortedError(response.error)) { setApiSchemas({ error: response.error, status: "failed" }); diff --git a/ui/src/components/shared/Detail.tsx b/ui/src/components/shared/Detail.tsx index 37d91ad28..eae3faa61 100644 --- a/ui/src/components/shared/Detail.tsx +++ b/ui/src/components/shared/Detail.tsx @@ -1,7 +1,7 @@ import { App, Button, Col, Flex, Grid, Row, Tag, TagProps, Typography } from "antd"; - import copy from "copy-to-clipboard"; import { useRef } from "react"; + import IconCheckMark from "src/assets/icons/check.svg?react"; import IconCopy from "src/assets/icons/copy-01.svg?react"; import IconDownload from "src/assets/icons/download-01.svg?react"; @@ -47,11 +47,13 @@ export function Detail({ const componentProps = Component === Typography.Link ? { + className: "detail", ellipsis: true, href, target: "_blank", } : { + className: "detail", ellipsis: ellipsisPosition ? { suffix: text.slice(-ellipsisPosition) } : true, }; diff --git a/ui/src/components/shared/Router.tsx b/ui/src/components/shared/Router.tsx index 3533f64de..be894e50a 100644 --- a/ui/src/components/shared/Router.tsx +++ b/ui/src/components/shared/Router.tsx @@ -3,6 +3,7 @@ import { Navigate, Route, Routes } from "react-router-dom"; import { ConnectionDetails } from "src/components/connections/ConnectionDetails"; import { ConnectionsTable } from "src/components/connections/ConnectionsTable"; +import { CreateAuthCredential } from "src/components/credentials/CreateAuthCredential"; import { CredentialDetails } from "src/components/credentials/CredentialDetails"; import { Credentials } from "src/components/credentials/Credentials"; import { IssueCredential } from "src/components/credentials/IssueCredential"; @@ -15,6 +16,9 @@ import { Identities } from "src/components/identities/Identities"; import { Identity } from "src/components/identities/Identity"; import { Onboarding } from "src/components/identities/Onboarding"; import { IssuerState } from "src/components/issuer-state/IssuerState"; +import { CreateKey } from "src/components/keys/CreateKey"; +import { Key } from "src/components/keys/Key"; +import { Keys } from "src/components/keys/Keys"; import { FullWidthLayout } from "src/components/layouts/FullWidthLayout"; import { SiderLayout } from "src/components/layouts/SiderLayout"; import { ImportSchema } from "src/components/schemas/ImportSchema"; @@ -28,8 +32,10 @@ import { ROOT_PATH } from "src/utils/constants"; const COMPONENTS: Record = { connectionDetails: ConnectionDetails, connections: ConnectionsTable, + createAuthCredential: CreateAuthCredential, createDisplayMethod: CreateDisplayMethod, createIdentity: CreateIdentity, + createKey: CreateKey, credentialDetails: CredentialDetails, credentials: Credentials, displayMethodDetails: DisplayMethodDetails, @@ -39,6 +45,8 @@ const COMPONENTS: Record = { importSchema: ImportSchema, issueCredential: IssueCredential, issuerState: IssuerState, + keyDetails: Key, + keys: Keys, linkDetails: LinkDetails, notFound: NotFound, onboarding: Onboarding, diff --git a/ui/src/components/shared/SiderMenu.tsx b/ui/src/components/shared/SiderMenu.tsx index 2dc575041..d046adbcb 100644 --- a/ui/src/components/shared/SiderMenu.tsx +++ b/ui/src/components/shared/SiderMenu.tsx @@ -25,6 +25,7 @@ import { DOCS_URL, IDENTITIES, ISSUER_STATE, + KEYS, SCHEMAS, } from "src/utils/constants"; @@ -50,6 +51,7 @@ export function SiderMenu({ const schemasPath = ROUTES.schemas.path; const identitiesPath = ROUTES.identities.path; const displayMethodsPath = ROUTES.displayMethods.path; + const keysPath = ROUTES.keys.path; const getSelectedKey = (): string[] => { if ( @@ -103,6 +105,13 @@ export function SiderMenu({ ) ) { return [displayMethodsPath]; + } else if ( + matchRoutes( + [{ path: keysPath }, { path: ROUTES.keyDetails.path }, { path: ROUTES.createKey.path }], + pathname + ) + ) { + return [keysPath]; } return []; @@ -182,6 +191,13 @@ export function SiderMenu({ onClick: () => onMenuClick(identitiesPath), title: "", }, + { + icon: , + key: keysPath, + label: KEYS, + onClick: () => onMenuClick(keysPath), + title: "", + }, ]} selectedKeys={getSelectedKey()} style={{ marginTop: 16 }} diff --git a/ui/src/contexts/Env.tsx b/ui/src/contexts/Env.tsx index cfdde2d67..3e2d0d79d 100644 --- a/ui/src/contexts/Env.tsx +++ b/ui/src/contexts/Env.tsx @@ -2,9 +2,9 @@ import { PropsWithChildren, createContext, useContext, useEffect, useMemo, useSt import { z } from "zod"; import { EnvInput, envParser } from "src/adapters/env"; +import { buildAppError, envErrorToString } from "src/adapters/parsers"; import { ErrorResult } from "src/components/shared/ErrorResult"; import { Env } from "src/domain"; -import { buildAppError, envErrorToString } from "src/utils/error"; const defaultEnvContext: Env = { api: { diff --git a/ui/src/contexts/Identity.tsx b/ui/src/contexts/Identity.tsx index 129318fa9..c04a6f2c1 100644 --- a/ui/src/contexts/Identity.tsx +++ b/ui/src/contexts/Identity.tsx @@ -1,4 +1,4 @@ -import { App, Space } from "antd"; +import { Space } from "antd"; import { PropsWithChildren, createContext, @@ -11,6 +11,7 @@ import { import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; import { getIdentities, identifierParser } from "src/adapters/api/identities"; +import { notifyErrors } from "src/adapters/parsers"; import { ErrorResult } from "src/components/shared/ErrorResult"; import { LoadingResult } from "src/components/shared/LoadingResult"; import { useEnvContext } from "src/contexts/Env"; @@ -28,7 +29,6 @@ import { IDENTIFIER_SEARCH_PARAM, ROOT_PATH, } from "src/utils/constants"; -import { buildAppError } from "src/utils/error"; type IdentityState = { fetchIdentities: (signal: AbortSignal) => void; @@ -50,7 +50,6 @@ const IdentityContext = createContext(defaultIdentityState); export function IdentityProvider(props: PropsWithChildren) { const env = useEnvContext(); - const { message } = App.useApp(); const navigate = useNavigate(); const location = useLocation(); const [identityList, setIdentityList] = useState>({ @@ -78,9 +77,7 @@ export function IdentityProvider(props: PropsWithChildren) { const identities = response.data.successful; if (response.data.failed.length) { - void message.error( - response.data.failed.map((error) => buildAppError(error).message).join("\n") - ); + void notifyErrors(response.data.failed); } const savedIdentifier = getStorageByKey({ @@ -106,7 +103,7 @@ export function IdentityProvider(props: PropsWithChildren) { } } }, - [env, message, identifierParam] + [env, identifierParam] ); const selectIdentity = useCallback( diff --git a/ui/src/domain/credential.ts b/ui/src/domain/credential.ts index b7466b8de..cd5deae4a 100644 --- a/ui/src/domain/credential.ts +++ b/ui/src/domain/credential.ts @@ -1,5 +1,5 @@ import { DisplayMethodType } from "src/domain/display-method"; - +import { CredentialStatusType } from "src/domain/identity"; export type CredentialsTabIDs = "issued" | "links"; export enum ProofType { @@ -17,13 +17,22 @@ export type CredentialDisplayMethod = { type: DisplayMethodType; }; +export type CredentialStatus = { + revocationNonce: number; + type: CredentialStatusType; +}; + export type Credential = { + credentialStatus: CredentialStatus; credentialSubject: Record; displayMethod: CredentialDisplayMethod | null; expirationDate: Date | null; expired: boolean; id: string; issuanceDate: Date; + proof: Array<{ + type: ProofType; + }> | null; proofTypes: ProofType[]; refreshService: RefreshService | null; revNonce: number; @@ -34,6 +43,14 @@ export type Credential = { userID: string; }; +export type AuthCredential = Omit & { + credentialSubject: { + x: bigint; + y: bigint; + }; + published: boolean; +}; + export type IssuedMessage = { schemaType: string; universalLink: string; diff --git a/ui/src/domain/identity.ts b/ui/src/domain/identity.ts index 48ac61b92..4355a9134 100644 --- a/ui/src/domain/identity.ts +++ b/ui/src/domain/identity.ts @@ -35,6 +35,7 @@ export type Identity = { }; export type IdentityDetails = { + authCredentialsIDs: string[]; credentialStatusType: CredentialStatusType; displayName: string | null; identifier: string; diff --git a/ui/src/domain/index.ts b/ui/src/domain/index.ts index 3b1cd554d..5be033699 100644 --- a/ui/src/domain/index.ts +++ b/ui/src/domain/index.ts @@ -3,6 +3,7 @@ export type { AppError } from "src/domain/error"; export type { Connection } from "src/domain/connection"; export type { + AuthCredential, Credential, CredentialsTabIDs, IssuedMessage, @@ -66,3 +67,6 @@ export { IdentityType, Method, CredentialStatusType } from "src/domain/identity" export type { DisplayMethod, DisplayMethodMetadata } from "src/domain/display-method"; export { DisplayMethodType } from "./display-method"; + +export type { Key } from "src/domain/key"; +export { KeyType } from "src/domain/key"; diff --git a/ui/src/domain/key.ts b/ui/src/domain/key.ts new file mode 100644 index 000000000..735209bae --- /dev/null +++ b/ui/src/domain/key.ts @@ -0,0 +1,12 @@ +export enum KeyType { + babyjubJub = "babyjubJub", + secp256k1 = "secp256k1", +} + +export type Key = { + id: string; + isAuthCredential: boolean; + keyType: KeyType; + name: string; + publicKey: string; +}; diff --git a/ui/src/routes.ts b/ui/src/routes.ts index 462770a09..af716b8b3 100644 --- a/ui/src/routes.ts +++ b/ui/src/routes.ts @@ -5,6 +5,7 @@ export type RouteID = | "credentials" | "importSchema" | "issueCredential" + | "createAuthCredential" | "issuerState" | "linkDetails" | "notFound" @@ -16,7 +17,10 @@ export type RouteID = | "onboarding" | "displayMethods" | "displayMethodDetails" - | "createDisplayMethod"; + | "createDisplayMethod" + | "keys" + | "keyDetails" + | "createKey"; export type Layout = "fullWidth" | "fullWidthGrey" | "sider"; @@ -37,6 +41,10 @@ export const ROUTES: Routes = { layout: "sider", path: "/connections", }, + createAuthCredential: { + layout: "sider", + path: "/credentials/auth", + }, createDisplayMethod: { layout: "sider", path: "/display-methods/create", @@ -45,6 +53,10 @@ export const ROUTES: Routes = { layout: "sider", path: "/identities/create", }, + createKey: { + layout: "sider", + path: "/keys/create", + }, credentialDetails: { layout: "sider", path: "/credentials/issued/:credentialID", @@ -81,6 +93,14 @@ export const ROUTES: Routes = { layout: "sider", path: "/issuer-state", }, + keyDetails: { + layout: "sider", + path: "/keys/:keyID", + }, + keys: { + layout: "sider", + path: "/keys", + }, linkDetails: { layout: "sider", path: "/credentials/links/:linkID", diff --git a/ui/src/utils/constants.ts b/ui/src/utils/constants.ts index fb2a83be3..55b8c380d 100644 --- a/ui/src/utils/constants.ts +++ b/ui/src/utils/constants.ts @@ -29,6 +29,10 @@ export const DISPLAY_METHOD_ADD_NEW = "Add new display method"; export const DISPLAY_METHOD_EDIT = "Edit display method"; export const DISPLAY_METHOD_ADD = "Add display method"; export const DISPLAY_METHOD_DETAILS = "Display method details"; +export const KEYS = "Keys"; +export const KEY_ADD = "Add key"; +export const KEY_ADD_NEW = "Add new key"; +export const KEY_DETAILS = "Key details"; export const LINKS = "Links"; export const REVOCATION = "Revocation"; export const REVOKE = "Revoke"; diff --git a/ui/src/utils/error.ts b/ui/src/utils/error.ts deleted file mode 100644 index 248849151..000000000 --- a/ui/src/utils/error.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { message } from "antd"; -import { isAxiosError, isCancel } from "axios"; -import { z } from "zod"; - -import { getStrictParser } from "src/adapters/parsers"; -import { AppError } from "src/domain"; - -function processZodError(error: z.ZodError, init: string[] = []) { - return error.errors.reduce((mainAcc, issue): string[] => { - switch (issue.code) { - case "invalid_union": { - return [ - ...mainAcc, - ...issue.unionErrors.reduce( - (innerAcc: string[], current: z.ZodError): string[] => [ - ...innerAcc, - ...processZodError(current), - ], - [] - ), - ]; - } - - default: { - const errorMsg = issue.path.length - ? `${issue.message} at ${issue.path.join(".")}` - : issue.message; - return [...mainAcc, errorMsg]; - } - } - }, init); -} - -export function notifyError(error: AppError, compact = false): void { - if (!compact && error.type === "parse-error") { - notifyParseError(error.error); - } else { - void message.error(error.message); - } -} - -export function notifyParseError(error: z.ZodError): void { - processZodError(error).forEach((error) => void message.error(error)); -} - -export function notifyParseErrors(errors: z.ZodError[]): void { - errors.forEach(notifyParseError); -} - -const messageParser = getStrictParser<{ message: string }>()(z.object({ message: z.string() })); - -export function buildAppError(error: unknown): AppError { - if (typeof error === "string") { - return { - message: error, - type: "custom-error", - }; - } else if (isCancel(error)) { - return { - error, - message: error.message - ? `The request has been aborted. ${error.message}` - : "The request has been aborted.", - type: "cancel-error", - }; - } else if (isAxiosError(error)) { - const parsedMessage = messageParser.safeParse(error.response?.data); - - return { - error, - message: parsedMessage.success - ? `${error.message}: ${parsedMessage.data.message}` - : error.message, - type: "request-error", - }; - } else if (error instanceof z.ZodError) { - return { - error, - message: processZodError(error).join("\n"), - type: "parse-error", - }; - } else if (error instanceof Error) { - return { - error, - message: error.message, - type: "general-error", - }; - } else { - return { - error, - message: "Unknown error", - type: "unknown-error", - }; - } -} - -export const envErrorToString = (error: AppError) => - [ - "An error occurred while reading the environment variables:", - error.message, - "Please provide valid environment variables.", - ].join("\n"); - -export const credentialSubjectValueErrorToString = (error: AppError) => - [ - error.type === "parse-error" || error.type === "custom-error" - ? "An error occurred while parsing the value of the credentialSubject:" - : "An error occurred while processing the value of the credentialSubject", - error.message, - "Please try again.", - ].join("\n"); - -export const jsonSchemaErrorToString = (error: AppError) => - [ - error.type === "parse-error" || error.type === "custom-error" - ? "An error occurred while parsing the JSON Schema:" - : "An error occurred while downloading the JSON Schema:", - error.message, - "Please try again.", - ].join("\n"); - -export const jsonLdContextErrorToString = (error: AppError) => - [ - error.type === "parse-error" || error.type === "custom-error" - ? "An error occurred while parsing the JSON LD Type referenced in this schema:" - : "An error occurred while downloading the JSON LD Type referenced in this schema:", - error.message, - "Please try again.", - ].join("\n"); diff --git a/ui/src/utils/iden3.ts b/ui/src/utils/iden3.ts index 760795114..2bfd8b7ae 100644 --- a/ui/src/utils/iden3.ts +++ b/ui/src/utils/iden3.ts @@ -1,8 +1,8 @@ import { keccak256 } from "js-sha3"; import { Response, buildErrorResponse, buildSuccessResponse } from "src/adapters"; +import { buildAppError } from "src/adapters/parsers"; import { JsonLdType } from "src/domain"; -import { buildAppError } from "src/utils/error"; const HEX_TABLE = "0123456789abcdef"; diff --git a/ui/src/utils/types.ts b/ui/src/utils/types.ts index 5a1cac2bf..eaabf62a9 100644 --- a/ui/src/utils/types.ts +++ b/ui/src/utils/types.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { AppError } from "src/domain/error"; export type ResourceMeta = { max_results: number; @@ -7,7 +7,7 @@ export type ResourceMeta = { }; export type List = { - failed: z.ZodError[]; + failed: AppError[]; successful: T[]; };