Repository: TBD54566975/ssi-service
Pull request #280 (2023-02-07 18:33:11)

Credential suspension
# Overview
This change allows the ssi-service to have suspension credentials

# Description
Following the status list spec https://w3c.github.io/vc-status-list-2021/ we now have support for revocable and suspendable credentials. This change enforces and assumes that any one credential can only be revocable OR suspendable and not both. This fits the spec as in the example there is only room for one credentialStatus object and not an array

```
{
  "@context": [
    "https://www.w3.org/2018/credentials/v1",
    "https://w3id.org/vc/status-list/2021/v1"
  ],
  "id": "https://example.com/credentials/23894672394",
  "type": ["VerifiableCredential"],
  "issuer": "did:example:12345",
  "issued": "2021-04-05T14:27:42Z",
  "credentialStatus": {
    "id": "https://example.com/credentials/status/3#94567"
    "type": "StatusList2021Entry",
    "statusPurpose": "revocation",
    "statusListIndex": "94567",
    "statusListCredential": "https://example.com/credentials/status/3"
  },
  "credentialSubject": {
    "id": "did:example:6789",
    "type": "Person"
  },
  "proof": { ... }
}
```

 
The StatusListCredential are now unique to 3 fields <issuer,schema,statusPurpose>
issuer1,schema1,revocation = StatusList2021Credential1
issuer1,schema1,suspension = StatusList2021Credential2


andresuribe87: > This change enforces and assumes that any one credential can only be revocable OR suspendable and not both

I actually had this question recently, and it's in the process of being clarified in the spec. See https://github.com/w3c/vc-status-list-2021/issues/37#issuecomment-1416348418 for details. Turns out that `credentialStatus` can be an object, or an array.

Patch:
diff --git a/internal/credential/model.go b/internal/credential/model.go
index 67da303..4730a24 100644
--- a/internal/credential/model.go
+++ b/internal/credential/model.go
@@ -20,6 +20,7 @@ type Container struct {
 	Credential    *credential.VerifiableCredential
 	CredentialJWT *keyaccess.JWT
 	Revoked       bool
+	Suspended     bool
 }
 
 func (c Container) JWTString() string {
diff --git a/pkg/server/router/credential.go b/pkg/server/router/credential.go
index d8b6be7..3b36bf6 100644
--- a/pkg/server/router/credential.go
+++ b/pkg/server/router/credential.go
@@ -61,18 +61,23 @@ type CreateCredentialRequest struct {
 	// Whether this credential can be revoked. When true, the created VC will have the "credentialStatus"
 	// property set.
 	Revocable bool `json:"revocable"`
+
+	// Whether this credential can be suspended. When true, the created VC will have the "credentialStatus"
+	// property set.
+	Suspendable bool `json:"suspendable"`
 	// TODO(gabe) support more capabilities like signature type, format, status, and more.
 }
 
 func (c CreateCredentialRequest) ToServiceRequest() credential.CreateCredentialRequest {
 	return credential.CreateCredentialRequest{
-		Issuer:     c.Issuer,
-		Subject:    c.Subject,
-		Context:    c.Context,
-		JSONSchema: c.Schema,
-		Data:       c.Data,
-		Expiry:     c.Expiry,
-		Revocable:  c.Revocable,
+		Issuer:      c.Issuer,
+		Subject:     c.Subject,
+		Context:     c.Context,
+		JSONSchema:  c.Schema,
+		Data:        c.Data,
+		Expiry:      c.Expiry,
+		Revocable:   c.Revocable,
+		Suspendable: c.Suspendable,
 	}
 }
 
@@ -164,6 +169,8 @@ func (cr CredentialRouter) GetCredential(ctx context.Context, w http.ResponseWri
 type GetCredentialStatusResponse struct {
 	// Whether the credential has been revoked.
 	Revoked bool `json:"revoked"`
+	// Whether the credential has been suspended.
+	Suspended bool `json:"suspended"`
 }
 
 // GetCredentialStatus godoc
@@ -194,7 +201,8 @@ func (cr CredentialRouter) GetCredentialStatus(ctx context.Context, w http.Respo
 	}
 
 	resp := GetCredentialStatusResponse{
-		Revoked: getCredentialStatusResponse.Revoked,
+		Revoked:   getCredentialStatusResponse.Revoked,
+		Suspended: getCredentialStatusResponse.Suspended,
 	}
 
 	return framework.Respond(ctx, w, resp, http.StatusOK)
@@ -248,19 +256,22 @@ func (cr CredentialRouter) GetCredentialStatusList(ctx context.Context, w http.R
 type UpdateCredentialStatusRequest struct {
 	// The new revoked status of this credential. The status will be saved in the encodedList of the StatusList2021
 	// credential associated with this VC.
-	Revoked bool `json:"revoked" validate:"required"`
+	Revoked   bool `json:"revoked,omitempty"`
+	Suspended bool `json:"suspended,omitempty"`
 }
 
 func (c UpdateCredentialStatusRequest) ToServiceRequest(id string) credential.UpdateCredentialStatusRequest {
 	return credential.UpdateCredentialStatusRequest{
-		ID:      id,
-		Revoked: c.Revoked,
+		ID:        id,
+		Revoked:   c.Revoked,
+		Suspended: c.Suspended,
 	}
 }
 
 type UpdateCredentialStatusResponse struct {
 	// The updated status of this credential.
-	Revoked bool `json:"revoked"`
+	Revoked   bool `json:"revoked"`
+	Suspended bool `json:"suspended"`
 }
 
 // UpdateCredentialStatus godoc
@@ -307,7 +318,8 @@ func (cr CredentialRouter) UpdateCredentialStatus(ctx context.Context, w http.Re
 	}
 
 	resp := UpdateCredentialStatusResponse{
-		Revoked: gotCredential.Revoked,
+		Revoked:   gotCredential.Revoked,
+		Suspended: gotCredential.Suspended,
 	}
 
 	return framework.Respond(ctx, w, resp, http.StatusOK)
diff --git a/pkg/service/credential/model.go b/pkg/service/credential/model.go
index 41c2d32..6974701 100644
--- a/pkg/service/credential/model.go
+++ b/pkg/service/credential/model.go
@@ -14,10 +14,11 @@ type CreateCredentialRequest struct {
 	// A context is optional. If not present, we'll apply default, required context values.
 	Context string `json:"context,omitempty"`
 	// A schema is optional. If present, we'll attempt to look it up and validate the data against it.
-	JSONSchema string         `json:"jsonSchema,omitempty"`
-	Data       map[string]any `json:"data,omitempty"`
-	Expiry     string         `json:"expiry,omitempty"`
-	Revocable  bool           `json:"revocable,omitempty"`
+	JSONSchema  string         `json:"jsonSchema,omitempty"`
+	Data        map[string]any `json:"data,omitempty"`
+	Expiry      string         `json:"expiry,omitempty"`
+	Revocable   bool           `json:"revocable,omitempty"`
+	Suspendable bool           `json:"suspendable,omitempty"`
 	// TODO(gabe) support more capabilities like signature type, format, status, and more.
 }
 
@@ -60,16 +61,19 @@ type GetCredentialStatusRequest struct {
 }
 
 type GetCredentialStatusResponse struct {
-	Revoked bool `json:"revoked" validate:"required"`
+	Revoked   bool `json:"revoked" validate:"required"`
+	Suspended bool `json:"suspended" validate:"required"`
 }
 
 type UpdateCredentialStatusRequest struct {
-	ID      string `json:"id" validate:"required"`
-	Revoked bool   `json:"revoked" validate:"required"`
+	ID        string `json:"id" validate:"required"`
+	Revoked   bool   `json:"revoked" validate:"required"`
+	Suspended bool   `json:"suspended" validate:"required"`
 }
 
 type UpdateCredentialStatusResponse struct {
-	Revoked bool `json:"revoked" validate:"required"`
+	Revoked   bool `json:"revoked" validate:"required"`
+	Suspended bool `json:"suspended" validate:"required"`
 }
 
 type GetCredentialStatusListRequest struct {
@@ -79,3 +83,10 @@ type GetCredentialStatusListRequest struct {
 type GetCredentialStatusListResponse struct {
 	credential.Container `json:"credential,omitempty"`
 }
+
+func (csr CreateCredentialRequest) isStatusValid() bool {
+	if csr.Revocable && csr.Suspendable {
+		return false
+	}
+	return true
+}
diff --git a/pkg/service/credential/service.go b/pkg/service/credential/service.go
index ec110df..9cacfb8 100644
--- a/pkg/service/credential/service.go
+++ b/pkg/service/credential/service.go
@@ -116,6 +116,10 @@ func (s Service) createCredentialFunc(request CreateCredentialRequest) storage.B
 func (s Service) createCredentialBusinessLogic(ctx context.Context, request CreateCredentialRequest, tx storage.Tx) (*CreateCredentialResponse, error) {
 	logrus.Debugf("creating credential: %+v", request)
 
+	if !request.isStatusValid() {
+		return nil, util.LoggingNewError("credential may have at most one status")
+	}
+
 	builder := credential.NewVerifiableCredentialBuilder()
 
 	if err := builder.SetIssuer(request.Issuer); err != nil {
@@ -173,12 +177,18 @@ func (s Service) createCredentialBusinessLogic(ctx context.Context, request Crea
 		return nil, util.LoggingErrorMsg(err, errMsg)
 	}
 
-	if request.Revocable {
+	if request.Revocable || request.Suspendable {
 		credID := builder.ID
 		issuerID := request.Issuer
 		schemaID := request.JSONSchema
 
-		statusListCredential, err := getOrCreateStatusListCredential(ctx, s, issuerID, schemaID, tx)
+		statusPurpose := statussdk.StatusRevocation
+
+		if request.Suspendable {
+			statusPurpose = statussdk.StatusSuspension
+		}
+
+		statusListCredential, err := getOrCreateStatusListCredential(ctx, s, statusPurpose, issuerID, schemaID, tx)
 		if err != nil {
 			return nil, util.LoggingErrorMsgf(err, "problem with getting status list credential")
 		}
@@ -195,7 +205,7 @@ func (s Service) createCredentialBusinessLogic(ctx context.Context, request Crea
 		status := statussdk.StatusList2021Entry{
 			ID:                   fmt.Sprintf(`%s/v1/credentials/%s/status`, s.config.ServiceEndpoint, credID),
 			Type:                 statussdk.StatusList2021EntryType,
-			StatusPurpose:        statussdk.StatusRevocation,
+			StatusPurpose:        statusPurpose,
 			StatusListIndex:      strconv.Itoa(statusListIndex),
 			StatusListCredential: statusListCredential.ID,
 		}
@@ -228,6 +238,7 @@ func (s Service) createCredentialBusinessLogic(ctx context.Context, request Crea
 		Credential:    cred,
 		CredentialJWT: credJWT,
 		Revoked:       false,
+		Suspended:     false,
 	}
 
 	credentialStorageRequest := StoreCredentialRequest{
@@ -242,8 +253,8 @@ func (s Service) createCredentialBusinessLogic(ctx context.Context, request Crea
 	return &response, nil
 }
 
-func getOrCreateStatusListCredential(ctx context.Context, s Service, issuerID string, schemaID string, tx storage.Tx) (*credential.VerifiableCredential, error) {
-	storedStatusListCreds, err := s.storage.GetStatusListCredentialsByIssuerAndSchema(ctx, issuerID, schemaID)
+func getOrCreateStatusListCredential(ctx context.Context, s Service, statusPurpose statussdk.StatusPurpose, issuerID string, schemaID string, tx storage.Tx) (*credential.VerifiableCredential, error) {
+	storedStatusListCreds, err := s.storage.GetStatusListCredentialsByIssuerAndSchema(ctx, statusPurpose, issuerID, schemaID)
 	if err != nil {
 		return nil, util.LoggingNewErrorf("getting status list credential for issuer: %s schema: %s", issuerID, schemaID)
 	}
@@ -257,8 +268,9 @@ func getOrCreateStatusListCredential(ctx context.Context, s Service, issuerID st
 
 	// First time that this <issuer,schema> pair has a revocation or suspension credential issued
 	if len(storedStatusListCreds) == 0 {
-		statusListID := fmt.Sprintf("%s/v1/credentials/status/%s", s.config.ServiceEndpoint, uuid.New().String())
-		generatedStatusListCredential, err := statussdk.GenerateStatusList2021Credential(statusListID, issuerID, statussdk.StatusRevocation, []credential.VerifiableCredential{})
+		statusListUUID := uuid.New().String()
+		statusListID := fmt.Sprintf("%s/v1/credentials/status/%s", s.config.ServiceEndpoint, statusListUUID)
+		generatedStatusListCredential, err := statussdk.GenerateStatusList2021Credential(statusListID, issuerID, statusPurpose, []credential.VerifiableCredential{})
 		if err != nil {
 			return nil, util.LoggingErrorMsg(err, "could not generate status list")
 		}
@@ -288,7 +300,7 @@ func getOrCreateStatusListCredential(ctx context.Context, s Service, issuerID st
 			Container: statusListContainer,
 		}
 
-		if err := s.storage.StoreStatusListCredentialTx(ctx, statusListStorageRequest, tx); err != nil {
+		if err := s.storage.StoreStatusListCredentialTx(ctx, tx, statusListStorageRequest, statusPurpose); err != nil {
 			return nil, errors.Wrap(err, "storing status list credential")
 		}
 	} else {
@@ -516,6 +528,10 @@ func (s Service) updateCredentialStatusFunc(request UpdateCredentialStatusReques
 func (s Service) updateCredentialStatusBusinessLogic(ctx context.Context, request UpdateCredentialStatusRequest, tx storage.Tx) (*UpdateCredentialStatusResponse, error) {
 	logrus.Debugf("updating credential status: %s to Revoked: %v", request.ID, request.Revoked)
 
+	if request.Suspended && request.Revoked {
+		return nil, util.LoggingNewErrorf("cannot update both suspended and revoked status")
+	}
+
 	gotCred, err := s.storage.GetCredential(ctx, request.ID)
 	if err != nil {
 		return nil, util.LoggingErrorMsgf(err, "could not get credential: %s", request.ID)
@@ -525,8 +541,8 @@ func (s Service) updateCredentialStatusBusinessLogic(ctx context.Context, reques
 	}
 
 	// if the request is the same as what the current credential is there is no action
-	if gotCred.Revoked == request.Revoked {
-		response := UpdateCredentialStatusResponse{Revoked: gotCred.Revoked}
+	if gotCred.Revoked == request.Revoked && gotCred.Suspended == request.Suspended {
+		response := UpdateCredentialStatusResponse{Revoked: gotCred.Revoked, Suspended: gotCred.Suspended}
 		return &response, nil
 	}
 
@@ -535,7 +551,7 @@ func (s Service) updateCredentialStatusBusinessLogic(ctx context.Context, reques
 		return nil, util.LoggingErrorMsg(err, "updating credential")
 	}
 
-	response := UpdateCredentialStatusResponse{Revoked: container.Revoked}
+	response := UpdateCredentialStatusResponse{Revoked: container.Revoked, Suspended: container.Suspended}
 	return &response, nil
 }
 
@@ -546,6 +562,7 @@ func updateCredentialStatus(ctx context.Context, s Service, gotCred *StoredCrede
 		Credential:    gotCred.Credential,
 		CredentialJWT: gotCred.CredentialJWT,
 		Revoked:       request.Revoked,
+		Suspended:     request.Suspended,
 	}
 
 	storageRequest := StoreCredentialRequest{
@@ -567,14 +584,22 @@ func updateCredentialStatus(ctx context.Context, s Service, gotCred *StoredCrede
 		return nil, util.LoggingNewErrorf("problem with getting status list credential for issuer: %s schema: %s", gotCred.Issuer, gotCred.Schema)
 	}
 
-	var revokedStatusCreds []credential.VerifiableCredential
+	var revokedOrSuspendedStatusCreds []credential.VerifiableCredential
 	for _, cred := range creds {
-		if cred.Credential.CredentialStatus != nil && cred.Revoked {
-			revokedStatusCreds = append(revokedStatusCreds, *cred.Credential)
+		if request.Revoked && cred.Credential.CredentialStatus != nil && cred.Revoked {
+			revokedOrSuspendedStatusCreds = append(revokedOrSuspendedStatusCreds, *cred.Credential)
+		} else if request.Suspended && cred.Credential.CredentialStatus != nil && cred.Suspended {
+			revokedOrSuspendedStatusCreds = append(revokedOrSuspendedStatusCreds, *cred.Credential)
 		}
 	}
 
-	generatedStatusListCredential, err := statussdk.GenerateStatusList2021Credential(statusListCredentialID, gotCred.Issuer, statussdk.StatusRevocation, revokedStatusCreds)
+	statusPurpose := statussdk.StatusRevocation
+
+	if request.Suspended {
+		statusPurpose = statussdk.StatusSuspension
+	}
+
+	generatedStatusListCredential, err := statussdk.GenerateStatusList2021Credential(statusListCredentialID, gotCred.Issuer, statusPurpose, revokedOrSuspendedStatusCreds)
 	if err != nil {
 		return nil, util.LoggingErrorMsg(err, "could not generate status list")
 	}
@@ -597,7 +622,7 @@ func updateCredentialStatus(ctx context.Context, s Service, gotCred *StoredCrede
 		Container: statusListContainer,
 	}
 
-	if err = s.storage.StoreStatusListCredentialTx(ctx, storageRequest, tx); err != nil {
+	if err = s.storage.StoreStatusListCredentialTx(ctx, tx, storageRequest, statusPurpose); err != nil {
 		return nil, util.LoggingErrorMsg(err, "could not store credential status list")
 	}
 
diff --git a/pkg/service/credential/storage.go b/pkg/service/credential/storage.go
index 15e9175..bb32f83 100644
--- a/pkg/service/credential/storage.go
+++ b/pkg/service/credential/storage.go
@@ -8,6 +8,7 @@ import (
 
 	"github.com/TBD54566975/ssi-sdk/credential"
 	"github.com/TBD54566975/ssi-sdk/credential/signing"
+	statussdk "github.com/TBD54566975/ssi-sdk/credential/status"
 	"github.com/goccy/go-json"
 	"github.com/pkg/errors"
 	"github.com/sirupsen/logrus"
@@ -36,6 +37,7 @@ type StoredCredential struct {
 	Schema       string `json:"schema"`
 	IssuanceDate string `json:"issuanceDate"`
 	Revoked      bool   `json:"revoked"`
+	Suspended    bool   `json:"suspended"`
 }
 
 type WriteContext struct {
@@ -97,7 +99,9 @@ func NewCredentialStorage(db storage.ServiceStorage) (*Storage, error) {
 		return nil, util.LoggingNewError("list index and status list index not in the same state")
 	}
 
-	if !listIndexKeyExists && !statusListIndexesKeyExists {
+	indexKeysDontExist := !listIndexKeyExists && !statusListIndexesKeyExists
+
+	if indexKeysDontExist {
 		randUniqueList := randomUniqueNum(bitStringLength)
 		uniqueNumBytes, err := json.Marshal(randUniqueList)
 		if err != nil {
@@ -134,7 +138,7 @@ func (cs *Storage) GetNextStatusListRandomIndex(ctx context.Context) (int, error
 
 	var uniqueNums []int
 	if err = json.Unmarshal(gotUniqueNumBytes, &uniqueNums); err != nil {
-		return -1, util.LoggingErrorMsgf(err, "could not unmarshal unique numbers")
+		return -1, util.LoggingErrorMsgf(err, "unmarshalling unique numbers")
 	}
 
 	gotCurrentListIndexBytes, err := cs.db.Read(ctx, statusListIndexNamespace, currentListIndexKey)
@@ -144,7 +148,7 @@ func (cs *Storage) GetNextStatusListRandomIndex(ctx context.Context) (int, error
 
 	var statusListIndex StatusListIndex
 	if err = json.Unmarshal(gotCurrentListIndexBytes, &statusListIndex); err != nil {
-		return -1, util.LoggingErrorMsgf(err, "could not unmarshal unique numbers")
+		return -1, util.LoggingErrorMsgf(err, "unmarshalling unique numbers")
 	}
 
 	return uniqueNums[statusListIndex.Index], nil
@@ -198,7 +202,7 @@ func (cs *Storage) GetIncrementStatusListIndexWriteContext(ctx context.Context)
 
 	var statusListIndex StatusListIndex
 	if err = json.Unmarshal(gotCurrentListIndexBytes, &statusListIndex); err != nil {
-		return nil, util.LoggingErrorMsg(err, "could not unmarshal unique numbers")
+		return nil, util.LoggingErrorMsg(err, "unmarshalling unique numbers")
 	}
 
 	if statusListIndex.Index >= bitStringLength-1 {
@@ -223,23 +227,63 @@ func (cs *Storage) StoreCredential(ctx context.Context, request StoreCredentialR
 	return cs.storeCredential(ctx, request, credentialNamespace)
 }
 
-func (cs *Storage) StoreStatusListCredentialTx(ctx context.Context, request StoreCredentialRequest, tx storage.Tx) error {
-	wc, err := cs.getStoreCredentialWriteContext(request, statusListCredentialNamespace)
+func (cs *Storage) StoreStatusListCredentialTx(ctx context.Context, tx storage.Tx, request StoreCredentialRequest, statusPurpose statussdk.StatusPurpose) error {
+	if !request.IsValid() {
+		return util.LoggingNewError("store request request is not valid")
+	}
+
+	// transform the credential into its denormalized form for storage
+	storedCredential, err := buildStoredCredential(request)
+	if err != nil {
+		return errors.Wrap(err, "building stored credential")
+	}
+
+	storedCredBytes, err := json.Marshal(storedCredential)
 	if err != nil {
-		return errors.Wrap(err, "could not get stored credential write context")
+		return util.LoggingErrorMsgf(err, "could not store request: %s", storedCredential.CredentialID)
 	}
-	return tx.Write(ctx, wc.namespace, wc.key, wc.value)
+
+	schemaID := ""
+	if request.Credential.CredentialSchema != nil {
+		schemaID = request.Credential.CredentialSchema.ID
+	}
+
+	statusListCredentialKey := createStatusListCredentialPrefixKey(storedCredential.CredentialID, request.Credential.Issuer.(string), request.Credential.CredentialSubject.GetID(), schemaID, string(statusPurpose))
+	return tx.Write(ctx, statusListCredentialNamespace, statusListCredentialKey, storedCredBytes)
 }
 
-func (cs *Storage) StoreStatusListCredential(ctx context.Context, request StoreCredentialRequest) error {
-	return cs.storeCredential(ctx, request, statusListCredentialNamespace)
+func (cs *Storage) GetStatusListCredential(ctx context.Context, id string) (*StoredCredential, error) {
+	prefixValues, err := cs.db.ReadPrefix(ctx, statusListCredentialNamespace, id)
+	if err != nil {
+		return nil, util.LoggingErrorMsgf(err, "could not get credential from storage: %s", id)
+	}
+	if len(prefixValues) > 1 {
+		return nil, util.LoggingNewErrorf("could not get credential from storage; multiple prefix values matched credential id: %s", id)
+	}
+
+	// since we know the map now only has a single value, we break after the first element
+	var credBytes []byte
+	for _, v := range prefixValues {
+		credBytes = v
+		break
+	}
+	if len(credBytes) == 0 {
+		return nil, util.LoggingNewErrorf("could not get credential from storage %s with id: %s", credentialNotFoundErrMsg, id)
+	}
+
+	var stored StoredCredential
+	if err = json.Unmarshal(credBytes, &stored); err != nil {
+		return nil, util.LoggingErrorMsgf(err, "unmarshalling stored credential: %s", id)
+	}
+	return &stored, nil
 }
 
 func (cs *Storage) storeCredential(ctx context.Context, request StoreCredentialRequest, namespace string) error {
 
 	wc, err := cs.getStoreCredentialWriteContext(request, namespace)
 	if err != nil {
-		return errors.Wrap(err, "could not get stored credential write context")
+		return errors.Wrap(err, "building stored credential")
+
 	}
 	// TODO(gabe) conflict checking?
 	return cs.db.Write(ctx, wc.namespace, wc.key, wc.value)
@@ -249,10 +293,6 @@ func (cs *Storage) GetStoreCredentialWriteContext(request StoreCredentialRequest
 	return cs.getStoreCredentialWriteContext(request, credentialNamespace)
 }
 
-func (cs *Storage) GetStoreStatusListCredentialWriteContext(request StoreCredentialRequest) (*WriteContext, error) {
-	return cs.getStoreCredentialWriteContext(request, statusListCredentialNamespace)
-}
-
 func (cs *Storage) getStoreCredentialWriteContext(request StoreCredentialRequest, namespace string) (*WriteContext, error) {
 	if !request.IsValid() {
 		return nil, util.LoggingNewError("store request request is not valid")
@@ -261,7 +301,7 @@ func (cs *Storage) getStoreCredentialWriteContext(request StoreCredentialRequest
 	// transform the credential into its denormalized form for storage
 	storedCredential, err := buildStoredCredential(request)
 	if err != nil {
-		return nil, errors.Wrap(err, "could not build stored credential")
+		return nil, errors.Wrap(err, "building stored credential")
 	}
 
 	storedCredBytes, err := json.Marshal(storedCredential)
@@ -312,6 +352,7 @@ func buildStoredCredential(request StoreCredentialRequest) (*StoredCredential, e
 		Schema:        schema,
 		IssuanceDate:  cred.IssuanceDate,
 		Revoked:       request.Revoked,
+		Suspended:     request.Suspended,
 	}, nil
 }
 
@@ -319,10 +360,6 @@ func (cs *Storage) GetCredential(ctx context.Context, id string) (*StoredCredent
 	return cs.getCredential(ctx, id, credentialNamespace)
 }
 
-func (cs *Storage) GetStatusListCredential(ctx context.Context, id string) (*StoredCredential, error) {
-	return cs.getCredential(ctx, id, statusListCredentialNamespace)
-}
-
 func (cs *Storage) getCredential(ctx context.Context, id string, namespace string) (*StoredCredential, error) {
 	prefixValues, err := cs.db.ReadPrefix(ctx, namespace, id)
 	if err != nil {
@@ -344,7 +381,7 @@ func (cs *Storage) getCredential(ctx context.Context, id string, namespace strin
 
 	var stored StoredCredential
 	if err = json.Unmarshal(credBytes, &stored); err != nil {
-		return nil, util.LoggingErrorMsgf(err, "could not unmarshal stored credential: %s", id)
+		return nil, util.LoggingErrorMsgf(err, "unmarshalling stored credential: %s", id)
 	}
 	return &stored, nil
 }
@@ -382,7 +419,7 @@ func (cs *Storage) GetCredentialsByIssuer(ctx context.Context, issuer string) ([
 		} else {
 			var cred StoredCredential
 			if err = json.Unmarshal(credBytes, &cred); err != nil {
-				logrus.WithError(err).Errorf("could not unmarshal credential with key: %s", key)
+				logrus.WithError(err).Errorf("unmarshalling credential with key: %s", key)
 			}
 			storedCreds = append(storedCreds, cred)
 		}
@@ -425,7 +462,7 @@ func (cs *Storage) GetCredentialsBySubject(ctx context.Context, subject string)
 		} else {
 			var cred StoredCredential
 			if err := json.Unmarshal(credBytes, &cred); err != nil {
-				logrus.WithError(err).Errorf("could not unmarshal credential with key: %s", key)
+				logrus.WithError(err).Errorf("unmarshalling credential with key: %s", key)
 			}
 			storedCreds = append(storedCreds, cred)
 		}
@@ -469,7 +506,7 @@ func (cs *Storage) GetCredentialsBySchema(ctx context.Context, schema string) ([
 		} else {
 			var cred StoredCredential
 			if err := json.Unmarshal(credBytes, &cred); err != nil {
-				logrus.WithError(err).Errorf("could not unmarshal credential with key: %s", key)
+				logrus.WithError(err).Errorf("unmarshalling credential with key: %s", key)
 			}
 			storedCreds = append(storedCreds, cred)
 		}
@@ -489,8 +526,46 @@ func (cs *Storage) GetCredentialsByIssuerAndSchema(ctx context.Context, issuer s
 	return cs.getCredentialsByIssuerAndSchema(ctx, issuer, schema, credentialNamespace)
 }
 
-func (cs *Storage) GetStatusListCredentialsByIssuerAndSchema(ctx context.Context, issuer string, schema string) ([]StoredCredential, error) {
-	return cs.getCredentialsByIssuerAndSchema(ctx, issuer, schema, statusListCredentialNamespace)
+func (cs *Storage) GetStatusListCredentialsByIssuerAndSchema(ctx context.Context, statusPurpose statussdk.StatusPurpose, issuer string, schema string) ([]StoredCredential, error) {
+	keys, err := cs.db.ReadAllKeys(ctx, statusListCredentialNamespace)
+	if err != nil {
+		return nil, util.LoggingErrorMsgf(err, "could not read credential storage while searching for creds for issuer: %s", issuer)
+	}
+
+	query := "sc:" + schema + "-sp:" + string(statusPurpose)
+	var issuerSchemaKeys []string
+	for _, k := range keys {
+		if strings.Contains(k, issuer) && strings.HasSuffix(k, query) {
+			issuerSchemaKeys = append(issuerSchemaKeys, k)
+		}
+	}
+
+	if len(issuerSchemaKeys) == 0 {
+		logrus.Warnf("no credentials found for issuer: %s and schema %s", util.SanitizeLog(issuer), util.SanitizeLog(schema))
+		return nil, nil
+	}
+
+	// now get each credential by key
+	var storedCreds []StoredCredential
+	for _, key := range issuerSchemaKeys {
+		credBytes, err := cs.db.Read(ctx, statusListCredentialNamespace, key)
+		if err != nil {
+			logrus.WithError(err).Errorf("could not read credential with key: %s", key)
+		} else {
+			var cred StoredCredential
+			if err = json.Unmarshal(credBytes, &cred); err != nil {
+				logrus.WithError(err).Errorf("unmarshalling credential with key: %s", key)
+			}
+
+			storedCreds = append(storedCreds, cred)
+		}
+	}
+
+	if len(storedCreds) == 0 {
+		logrus.Warnf("no credentials able to be retrieved for issuer: %s", issuerSchemaKeys)
+	}
+
+	return storedCreds, nil
 }
 
 func (cs *Storage) getCredentialsByIssuerAndSchema(ctx context.Context, issuer string, schema string, namespace string) ([]StoredCredential, error) {
@@ -521,7 +596,7 @@ func (cs *Storage) getCredentialsByIssuerAndSchema(ctx context.Context, issuer s
 		} else {
 			var cred StoredCredential
 			if err = json.Unmarshal(credBytes, &cred); err != nil {
-				logrus.WithError(err).Errorf("could not unmarshal credential with key: %s", key)
+				logrus.WithError(err).Errorf("unmarshalling credential with key: %s", key)
 			}
 			storedCreds = append(storedCreds, cred)
 		}
@@ -584,6 +659,10 @@ func createPrefixKey(id, issuer, subject, schema string) string {
 	return strings.Join([]string{id, "is:" + issuer, "su:" + subject, "sc:" + schema}, "-")
 }
 
+func createStatusListCredentialPrefixKey(id, issuer, subject, schema, statusPurpose string) string {
+	return strings.Join([]string{id, "is:" + issuer, "su:" + subject, "sc:" + schema, "sp:" + statusPurpose}, "-")
+}
+
 func randomUniqueNum(count int) []int {
 	randomNumbers := make([]int, 0, count)
 


Test patch:
diff --git a/pkg/server/router/credential_test.go b/pkg/server/router/credential_test.go
index 18d8270..a5b9488 100644
--- a/pkg/server/router/credential_test.go
+++ b/pkg/server/router/credential_test.go
@@ -12,6 +12,7 @@ import (
 	"github.com/TBD54566975/ssi-sdk/crypto"
 	didsdk "github.com/TBD54566975/ssi-sdk/did"
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 
 	"github.com/goccy/go-json"
 	"github.com/tbd54566975/ssi-service/config"
@@ -492,7 +493,8 @@ func TestCredentialRouter(t *testing.T) {
 
 		credStatusListStr := statusEntry.StatusListCredential
 
-		credStatusListID := strings.Split(credStatusListStr, "/")[len(strings.Split(credStatusListStr, "/"))-1]
+		_, credStatusListID, ok := strings.Cut(credStatusListStr, "/v1/credentials/status/")
+		assert.True(tt, ok)
 		credStatusList, err := credService.GetCredentialStatusList(context.Background(), credential.GetCredentialStatusListRequest{ID: credStatusListID})
 		assert.NoError(tt, err)
 		assert.Equal(tt, credStatusList.Credential.ID, statusEntry.StatusListCredential)
@@ -503,7 +505,7 @@ func TestCredentialRouter(t *testing.T) {
 		encodedList := credentialSubject["encodedList"]
 		assert.NotEmpty(tt, encodedList)
 
-		// Validate the StatusListIndex in not flipped in the credStatusList
+		// Validate the StatusListIndex is not flipped in the credStatusList
 		valid, err := status.ValidateCredentialInStatusList(*createdCred.Credential, *credStatusList.Credential)
 		assert.NoError(tt, err)
 		assert.False(tt, valid)
@@ -601,4 +603,246 @@ func TestCredentialRouter(t *testing.T) {
 
 		assert.Equal(tt, statusListIndexes, statusListIndexesAfterRestart)
 	})
+
+	t.Run("Create Suspendable Credential", func(tt *testing.T) {
+		issuer, schema, credService := createCredServicePrereqs(tt)
+		subject := "did:test:345"
+
+		createdCred, err := credService.CreateCredential(context.Background(), credential.CreateCredentialRequest{
+			Issuer:     issuer,
+			Subject:    subject,
+			JSONSchema: schema,
+			Data: map[string]any{
+				"email": "Satoshi@Nakamoto.btc",
+			},
+			Expiry:      time.Now().Add(24 * time.Hour).Format(time.RFC3339),
+			Suspendable: true,
+		})
+
+		assert.NoError(tt, err)
+		assert.NotEmpty(tt, createdCred)
+		assert.NotEmpty(tt, createdCred.CredentialJWT)
+
+		statusBytes, err := json.Marshal(createdCred.Credential.CredentialStatus)
+		assert.NoError(tt, err)
+
+		var statusEntry status.StatusList2021Entry
+		err = json.Unmarshal(statusBytes, &statusEntry)
+		assert.NoError(tt, err)
+
+		assert.Contains(tt, statusEntry.ID, fmt.Sprintf("http://localhost:1234/v1/credentials/%s/status", createdCred.ID))
+		assert.Contains(tt, statusEntry.StatusListCredential, "http://localhost:1234/v1/credentials/status")
+		assert.NotEmpty(tt, statusEntry.StatusListIndex)
+
+		credStatus, err := credService.GetCredentialStatus(context.Background(), credential.GetCredentialStatusRequest{ID: createdCred.ID})
+		assert.NoError(tt, err)
+		assert.Equal(tt, credStatus.Revoked, false)
+		assert.Equal(tt, credStatus.Suspended, false)
+
+		credStatusListStr := statusEntry.StatusListCredential
+
+		_, credStatusListID, ok := strings.Cut(credStatusListStr, "/v1/credentials/status/")
+		assert.True(tt, ok)
+		credStatusList, err := credService.GetCredentialStatusList(context.Background(), credential.GetCredentialStatusListRequest{ID: credStatusListID})
+		assert.NoError(tt, err)
+		assert.Equal(tt, credStatusList.Credential.ID, statusEntry.StatusListCredential)
+
+		credentialSubject := credStatusList.Container.Credential.CredentialSubject
+		assert.NotEmpty(tt, credentialSubject)
+
+		encodedList := credentialSubject["encodedList"]
+		assert.NotEmpty(tt, encodedList)
+
+		// Validate the StatusListIndex is not flipped in the credStatusList
+		valid, err := status.ValidateCredentialInStatusList(*createdCred.Credential, *credStatusList.Credential)
+		assert.NoError(tt, err)
+		assert.False(tt, valid)
+	})
+
+	t.Run("Update Suspendable Credential To Suspended", func(tt *testing.T) {
+		issuer, schema, credService := createCredServicePrereqs(tt)
+		subject := "did:test:345"
+
+		createdCred, err := credService.CreateCredential(context.Background(), credential.CreateCredentialRequest{
+			Issuer:     issuer,
+			Subject:    subject,
+			JSONSchema: schema,
+			Data: map[string]any{
+				"email": "Satoshi@Nakamoto.btc",
+			},
+			Expiry:      time.Now().Add(24 * time.Hour).Format(time.RFC3339),
+			Suspendable: true,
+		})
+
+		assert.NoError(tt, err)
+		assert.NotEmpty(tt, createdCred)
+		assert.NotEmpty(tt, createdCred.CredentialJWT)
+
+		statusBytes, err := json.Marshal(createdCred.Credential.CredentialStatus)
+		assert.NoError(tt, err)
+
+		var statusEntry status.StatusList2021Entry
+		err = json.Unmarshal(statusBytes, &statusEntry)
+		assert.NoError(tt, err)
+
+		assert.Contains(tt, statusEntry.ID, fmt.Sprintf("http://localhost:1234/v1/credentials/%s/status", createdCred.ID))
+		assert.Contains(tt, statusEntry.StatusListCredential, "http://localhost:1234/v1/credentials/status")
+		assert.NotEmpty(tt, statusEntry.StatusListIndex)
+
+		credStatus, err := credService.GetCredentialStatus(context.Background(), credential.GetCredentialStatusRequest{ID: createdCred.ID})
+		assert.NoError(tt, err)
+		assert.Equal(tt, credStatus.Revoked, false)
+		assert.Equal(tt, credStatus.Suspended, false)
+
+		credStatusListStr := statusEntry.StatusListCredential
+
+		_, credStatusListID, ok := strings.Cut(credStatusListStr, "/v1/credentials/status/")
+		assert.True(tt, ok)
+		credStatusList, err := credService.GetCredentialStatusList(context.Background(), credential.GetCredentialStatusListRequest{ID: credStatusListID})
+		assert.NoError(tt, err)
+		assert.Equal(tt, credStatusList.Credential.ID, statusEntry.StatusListCredential)
+
+		credentialSubject := credStatusList.Container.Credential.CredentialSubject
+		assert.NotEmpty(tt, credentialSubject)
+
+		encodedList := credentialSubject["encodedList"]
+		assert.NotEmpty(tt, encodedList)
+
+		// Validate the StatusListIndex is not flipped in the credStatusList
+		valid, err := status.ValidateCredentialInStatusList(*createdCred.Credential, *credStatusList.Credential)
+		assert.NoError(tt, err)
+		assert.False(tt, valid)
+
+		updatedStatus, err := credService.UpdateCredentialStatus(context.Background(), credential.UpdateCredentialStatusRequest{ID: createdCred.ID, Suspended: true})
+		assert.NoError(tt, err)
+		assert.Equal(tt, updatedStatus.Suspended, true)
+		assert.Equal(tt, updatedStatus.Revoked, false)
+
+		credStatusListAfterRevoke, err := credService.GetCredentialStatusList(context.Background(), credential.GetCredentialStatusListRequest{ID: credStatusListID})
+		assert.NoError(tt, err)
+		assert.Equal(tt, credStatusListAfterRevoke.Credential.ID, statusEntry.StatusListCredential)
+
+		// Validate the StatusListIndex in flipped in the credStatusList
+		valid, err = status.ValidateCredentialInStatusList(*createdCred.Credential, *credStatusListAfterRevoke.Credential)
+		assert.NoError(tt, err)
+		assert.True(tt, valid)
+
+		credentialSubjectAfterRevoke := credStatusListAfterRevoke.Container.Credential.CredentialSubject
+		assert.NotEmpty(tt, credentialSubjectAfterRevoke)
+
+		encodedListAfterSuspended := credentialSubjectAfterRevoke["encodedList"]
+		assert.NotEmpty(tt, encodedListAfterSuspended)
+
+		assert.NotEqualValues(tt, encodedListAfterSuspended, encodedList)
+	})
+
+	t.Run("Create Suspendable and Revocable Credential Should Be Error", func(tt *testing.T) {
+		issuer, schema, credService := createCredServicePrereqs(tt)
+		subject := "did:test:345"
+
+		createdCred, err := credService.CreateCredential(context.Background(), credential.CreateCredentialRequest{
+			Issuer:     issuer,
+			Subject:    subject,
+			JSONSchema: schema,
+			Data: map[string]any{
+				"email": "Satoshi@Nakamoto.btc",
+			},
+			Expiry:      time.Now().Add(24 * time.Hour).Format(time.RFC3339),
+			Revocable:   true,
+			Suspendable: true,
+		})
+
+		assert.Error(tt, err)
+		assert.ErrorContains(tt, err, "credential may have at most one status")
+		assert.Empty(tt, createdCred)
+	})
+
+	t.Run("Update Suspendable and Revocable Credential Should Be Error", func(tt *testing.T) {
+		issuer, schema, credService := createCredServicePrereqs(tt)
+		subject := "did:test:345"
+
+		createdCred, err := credService.CreateCredential(context.Background(), credential.CreateCredentialRequest{
+			Issuer:     issuer,
+			Subject:    subject,
+			JSONSchema: schema,
+			Data: map[string]any{
+				"email": "Satoshi@Nakamoto.btc",
+			},
+			Expiry:      time.Now().Add(24 * time.Hour).Format(time.RFC3339),
+			Suspendable: true,
+		})
+
+		assert.NoError(tt, err)
+		assert.NotEmpty(tt, createdCred)
+
+		updatedStatus, err := credService.UpdateCredentialStatus(context.Background(), credential.UpdateCredentialStatusRequest{ID: createdCred.ID, Revoked: true, Suspended: true})
+		assert.Nil(tt, updatedStatus)
+		assert.Error(tt, err)
+		assert.ErrorContains(tt, err, "cannot update both suspended and revoked status")
+	})
+
+	t.Run("Update Suspended On Revoked Credential Should Be Error", func(tt *testing.T) {
+		issuer, schema, credService := createCredServicePrereqs(tt)
+		subject := "did:test:345"
+
+		createdCred, err := credService.CreateCredential(context.Background(), credential.CreateCredentialRequest{
+			Issuer:     issuer,
+			Subject:    subject,
+			JSONSchema: schema,
+			Data: map[string]any{
+				"email": "Satoshi@Nakamoto.btc",
+			},
+			Expiry:    time.Now().Add(24 * time.Hour).Format(time.RFC3339),
+			Revocable: true,
+		})
+
+		assert.NoError(tt, err)
+		assert.NotEmpty(tt, createdCred)
+
+		updatedStatus, err := credService.UpdateCredentialStatus(context.Background(), credential.UpdateCredentialStatusRequest{ID: createdCred.ID, Suspended: true})
+		assert.Nil(tt, updatedStatus)
+		assert.Error(tt, err)
+		assert.ErrorContains(tt, err, "has a different status purpose<revocation> value than the status credential<suspension>")
+	})
+
+}
+
+func createCredServicePrereqs(tt *testing.T) (string, string, credential.Service) {
+	bolt := setupTestDB(tt)
+	require.NotNil(tt, bolt)
+
+	serviceConfig := config.CredentialServiceConfig{BaseServiceConfig: &config.BaseServiceConfig{Name: "credential", ServiceEndpoint: "http://localhost:1234"}}
+	keyStoreService := testKeyStoreService(tt, bolt)
+	didService := testDIDService(tt, bolt, keyStoreService)
+	schemaService := testSchemaService(tt, bolt, keyStoreService, didService)
+	credService, err := credential.NewCredentialService(serviceConfig, bolt, keyStoreService, didService.GetResolver(), schemaService)
+	require.NoError(tt, err)
+	require.NotEmpty(tt, credService)
+
+	// check type and status
+	require.Equal(tt, framework.Credential, credService.Type())
+	require.Equal(tt, framework.StatusReady, credService.Status().Status)
+
+	// create a did
+	issuerDID, err := didService.CreateDIDByMethod(context.Background(), did.CreateDIDRequest{Method: didsdk.KeyMethod, KeyType: crypto.Ed25519})
+	require.NoError(tt, err)
+	require.NotEmpty(tt, issuerDID)
+
+	// create a schema
+	emailSchema := map[string]any{
+		"type": "object",
+		"properties": map[string]any{
+			"email": map[string]any{
+				"type": "string",
+			},
+		},
+		"required":             []any{"email"},
+		"additionalProperties": false,
+	}
+
+	createdSchema, err := schemaService.CreateSchema(context.Background(), schema.CreateSchemaRequest{Author: "me", Name: "simple schema", Schema: emailSchema})
+	require.NoError(tt, err)
+	require.NotEmpty(tt, createdSchema)
+
+	return issuerDID.DID.ID, createdSchema.ID, *credService
 }