nuts-foundation/nuts-node

View on GitHub
vcr/revocation/statuslist2021_issuer.go

Summary

Maintainability
B
6 hrs
Test Coverage
B
80%
/*
 * Copyright (C) 2024 Nuts community
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 */

package revocation

import (
    "context"
    "errors"
    "fmt"
    "github.com/nuts-foundation/nuts-node/audit"
    "net/url"
    "path"
    "strconv"
    "strings"
    "time"

    "github.com/google/uuid"
    ssi "github.com/nuts-foundation/go-did"
    "github.com/nuts-foundation/go-did/did"
    "github.com/nuts-foundation/go-did/vc"
    "github.com/nuts-foundation/nuts-node/crypto"
    "github.com/nuts-foundation/nuts-node/vcr/log"
    "github.com/nuts-foundation/nuts-node/vdr/didweb"
    "gorm.io/gorm"
    "gorm.io/gorm/clause"
)

// statusListValidity is default validity of a StatusList2021Credential
const statusListValidity = 24 * time.Hour // TODO: make configurable, and set reasonable default.
// minTimeUntilExpired is the minimum time a StatusList2021Credential must be valid that is returned by the API
const minTimeUntilExpired = statusListValidity / 4

func (s credentialIssuerRecord) TableName() string {
    return "status_list"
}

// credentialIssuerRecord keeps track of a StatusList2021Credential issued by the Issuer, and what the LastIssuedIndex is for the StatusList2021Credential.
// Issuers can have multiple StatusList2021Credentials, the one with the highest page number is the most recent VC / VC currently being issued on.
type credentialIssuerRecord struct {
    // SubjectID is the VC.credentialSubject.ID for this StatusListCredential.
    // It is the URL where the StatusList2021Credential can be downloaded e.g., https://example.com/iam/id/statuslist/1.
    SubjectID string `gorm:"primaryKey"`
    // Issuer of the StatusListCredential.
    Issuer string
    // Page number corresponding to this SubjectID.
    Page int
    // LastIssuedIndex on this page. Range:  0 <= StatusListIndex < maxBitstringIndex
    LastIssuedIndex int
    // Revocations list all revocations for this SubjectID
    Revocations []revocationRecord `gorm:"foreignKey:StatusListCredential;references:SubjectID"`
}

func (c credentialRecord) TableName() string {
    return "status_list_credential"
}

// credentialRecord contains the latest known version of a StatusList2021Credential.
// For managed StatusList2021Credentials this always contains the most up-to-date information,
// for external StatusList2021Credentials it contains the status as received on CreatedAt.
type credentialRecord struct {
    // SubjectID is the URL (from StatusList2021Entry.StatusListCredential) that credential was downloaded from
    // it should match with CredentialSubject.ID
    SubjectID string `gorm:"primaryKey"`
    // StatusPurpose is the purpose listed in the StatusList2021Credential.credentialSubject
    StatusPurpose string
    // Expanded StatusList2021 bitstring
    Expanded bitstring
    // CreatedAt is the UNIX timestamp this credentialRecord was generated
    CreatedAt int64 `gorm:"autoCreateTime"`
    // Expires is the UNIX timestamp the StatusList2021Credential expires. May be missing in external StatusList2021Credentials
    Expires *int64
    // Raw contains the raw data of the Verifiable Credential
    Raw string
}

func (s revocationRecord) TableName() string {
    return "status_list_entry"
}

// revocationRecord is created when a statusList entry has been revoked.
type revocationRecord struct {
    // StatusListCredential is the credentialSubject.ID this revocation belongs to. Example https://example.com/iam/id/statuslist/1
    StatusListCredential string `gorm:"primaryKey"`
    // StatusListIndex of the revoked status list entry. Range: 0 <= StatusListIndex <= maxBitstringIndex
    StatusListIndex int `gorm:"primaryKey;autoIncrement:false"`
    // CredentialID is the VC.ID of the credential revoked by this status list entry.
    // The value is stored as convenience during revocation, but is not validated.
    // Example did:web:example.com:iam:id#unique-identifier
    CredentialID string
    // RevokedAt contains the UNIX timestamp the revocation was registered.
    RevokedAt int64 `gorm:"autoCreateTime;column:created_at"`
}

func (cs *StatusList2021) loadCredential(subjectID string) (*credentialRecord, error) {
    cr := new(credentialRecord)
    err := cs.db.First(cr, "subject_id = ?", subjectID).Error
    if err != nil {
        return nil, err
    }
    return cr, nil
}

// isManaged returns true if the StatusList2021Credential is issued by this node.
// returns false on db errors, or if the StatusList2021Credential does not exist.
func (cs *StatusList2021) isManaged(subjectID string) bool {
    var exists bool
    cs.db.Model(new(credentialIssuerRecord)).
        Select("count(*) > 0").
        Where("subject_id = ?", subjectID).
        First(&exists)
    return exists
}

func (cs *StatusList2021) Credential(ctx context.Context, issuerDID did.DID, page int) (*vc.VerifiableCredential, error) {
    statusListCredentialURL, err := toStatusListCredential(issuerDID, page)
    if err != nil {
        return nil, err
    }

    // only return StatusList2021Credential if it already exists, and we are the issuer
    if !cs.isManaged(statusListCredentialURL) {
        return nil, errNotFound
    }

    // return stored StatusList2021Credential if valid for long enough
    credRecord, err := cs.loadCredential(statusListCredentialURL)
    if err == nil && time.Now().Add(minTimeUntilExpired).Before(time.Unix(*credRecord.Expires, 0)) {
        cred, err := vc.ParseVerifiableCredential(credRecord.Raw)
        if err == nil {
            return cred, nil
        }
        // log broken StatusList2021Credential in DB and try to issue a new one
        log.Logger().WithError(err).WithField("StatusList2021Credential", statusListCredentialURL).Error("Failed to parse managed StatusList2021Credential in database")
    }

    // Rewrite audit context. This is a system action and should not be logged against an external party.
    info := audit.InfoFromContext(ctx)
    if info != nil {
        module, operation, ok := strings.Cut(info.Operation, ".")
        if ok {
            ctx = audit.Context(ctx, "_system_signing_expired_statuslist2021credential", module, operation)
        }
    }

    // resolve signing key outside of transaction
    key, err := cs.ResolveKey(ctx, issuerDID)
    if err != nil {
        // should never happen; credential confirmed to issued by this node
        return nil, err
    }

    // issue a new StatusList2021Credential if we can't load the existing, or it's about to expire
    var cred *vc.VerifiableCredential // is nil, so if this panics outside this method the var name is probably shadowed in the db.Transaction.
    err = cs.db.Transaction(func(tx *gorm.DB) error {
        // lock credentialRecord row for statusListCredentialURL since it will be updated.
        // Revoke does the same to guarantee the DB always contains all revocations.
        err = tx.Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate}).
            Find(new(credentialRecord), "subject_id = ?", statusListCredentialURL).
            Error
        if err != nil {
            return err
        }

        issuerRecord := new(credentialIssuerRecord)
        err = tx.Preload("Revocations").First(issuerRecord, "subject_id = ?", statusListCredentialURL).Error
        if err != nil {
            // gorm.ErrRecordNotFound can't happen, isManaged() confirmed it exists
            return err
        }
        cred, credRecord, err = cs.updateCredential(ctx, issuerRecord, key)
        if err != nil {
            return err
        }

        err = tx.Clauses(clause.OnConflict{UpdateAll: true}).Create(credRecord).Error
        if err != nil {
            // log error, but don't fail.
            log.Logger().
                WithError(err).
                WithField("Status list URL", statusListCredentialURL).
                Error("failed to store issued StatusList2021Credential")
        }
        return nil
    })
    if err != nil {
        return nil, err
    }

    return cred, nil
}

// updateCredential creates a signed StatusList2021Credential and a credentialRecord from the credentialIssuerRecord.
// All revocations must be present in the issuerRecord. The caller is responsible for writing the credentialRecord to the db.
func (cs *StatusList2021) updateCredential(ctx context.Context, issuerRecord *credentialIssuerRecord, key crypto.Key) (*vc.VerifiableCredential, *credentialRecord, error) {
    issuerDID, err := did.ParseDID(issuerRecord.Issuer)
    if err != nil {
        return nil, nil, err
    }

    // bit string
    expanded := newBitstring()
    for _, rev := range issuerRecord.Revocations {
        if err = expanded.setBit(rev.StatusListIndex, true); err != nil {
            // can't happen
            return nil, nil, err
        }
    }
    encodedList, err := compress(*expanded)
    if err != nil {
        // can't happen
        return nil, nil, err
    }

    // credential subject
    credSubject := &StatusList2021CredentialSubject{
        ID:            issuerRecord.SubjectID,
        Type:          StatusList2021CredentialSubjectType,
        StatusPurpose: StatusPurposeRevocation,
        EncodedList:   encodedList,
    }
    // create and sign a new StatusList2021Credential
    statusListCredential, err := cs.buildAndSignVC(ctx, *issuerDID, *credSubject, key)
    if err != nil {
        return nil, nil, err
    }

    // create new credentialRecord
    expires := statusListCredential.ExpirationDate.Unix()
    credRecord := &credentialRecord{
        SubjectID:     credSubject.ID,
        StatusPurpose: credSubject.StatusPurpose,
        Expanded:      *expanded,
        Expires:       &expires,
        Raw:           statusListCredential.Raw(),
    }
    return statusListCredential, credRecord, nil
}

// buildAndSignVC intends to do the same as vcr.issuer.buildAndSignVC
func (cs *StatusList2021) buildAndSignVC(ctx context.Context, issuerDID did.DID, credSubject StatusList2021CredentialSubject, key crypto.Key) (*vc.VerifiableCredential, error) {
    iss := time.Now()
    exp := iss.Add(statusListValidity)
    credentialID := ssi.MustParseURI(fmt.Sprintf("%s#%s", issuerDID.String(), uuid.New().String()))
    template := vc.VerifiableCredential{
        Context: []ssi.URI{
            vc.VCContextV1URI(),
            StatusList2021ContextURI,
        },
        Type: []ssi.URI{
            vc.VerifiableCredentialTypeV1URI(),
            statusList2021CredentialTypeURI,
        },
        ID:                &credentialID,
        CredentialSubject: []any{credSubject},
        Issuer:            issuerDID.URI(),
        IssuanceDate:      iss,
        ExpirationDate:    &exp,
    }

    // sign the StatusList2021Credential
    return cs.Sign(ctx, template, key)
}

func (cs *StatusList2021) Entry(ctx context.Context, issuer did.DID, purpose StatusPurpose) (*StatusList2021Entry, error) {
    if purpose != StatusPurposeRevocation {
        return nil, errUnsupportedPurpose
    }

    // resolve signing key outside of transaction
    key, err := cs.ResolveKey(ctx, issuer)
    if err != nil {
        return nil, err
    }

    credentialIssuer := new(credentialIssuerRecord)
    for {
        err := cs.db.Transaction(func(tx *gorm.DB) error {
            // lock issuer's last page; iff it exists
            err := tx.Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate}).
                Order("page").
                Last(credentialIssuer, "issuer = ?", issuer.String()).
                Error
            if err != nil {
                if !errors.Is(err, gorm.ErrRecordNotFound) {
                    return err
                }

                // first time issuer; prepare to create a new Page / StatusListCredential
                credentialIssuer = &credentialIssuerRecord{
                    Issuer:          issuer.String(),
                    LastIssuedIndex: maxBitstringIndex, // this will be incremented to move to page 1
                    Page:            0,
                }
            }

            // next index
            credentialIssuer.LastIssuedIndex++

            // create new page (statusListCredential) if current is full and release lock
            // write actions here are not protected by the SELECT FOR UPDATE clause, so can fail with gorm.ErrDuplicatedKey
            if credentialIssuer.LastIssuedIndex > maxBitstringIndex {
                credentialIssuer.LastIssuedIndex = 0
                credentialIssuer.Page++
                credentialIssuer.SubjectID, err = toStatusListCredential(issuer, credentialIssuer.Page)
                if err != nil {
                    return err
                }
                // add new credentialIssuerRecord
                if err = tx.Create(credentialIssuer).Error; err != nil {
                    return err
                }

                _, credRecord, err := cs.updateCredential(ctx, credentialIssuer, key)
                if err != nil {
                    return err
                }
                return tx.Create(credRecord).Error
            }

            // update last_issued_index and release lock
            return tx.Model(&credentialIssuerRecord{}).
                Where("subject_id = ?", credentialIssuer.SubjectID).
                UpdateColumn("last_issued_index", credentialIssuer.LastIssuedIndex).Error // only then update
        })
        if err != nil {
            if errors.Is(err, gorm.ErrDuplicatedKey) {
                // gorm.ErrDuplicatedKey means that a race condition occurred while trying to add a new credentialRecord
                // or credentialIssuerRecord. We just have to try again.
                continue
            }
            return nil, err
        }
        break
    }

    return &StatusList2021Entry{
        ID:                   fmt.Sprintf("%s#%d", credentialIssuer.SubjectID, credentialIssuer.LastIssuedIndex),
        Type:                 StatusList2021EntryType,
        StatusPurpose:        StatusPurposeRevocation,
        StatusListIndex:      strconv.Itoa(credentialIssuer.LastIssuedIndex),
        StatusListCredential: credentialIssuer.SubjectID,
    }, nil
}

func (cs *StatusList2021) Revoke(ctx context.Context, credentialID ssi.URI, entry StatusList2021Entry) error {
    // parse StatusListIndex
    statusListIndex, err := strconv.Atoi(entry.StatusListIndex)
    if err != nil {
        return err
    }

    // validate StatusPurpose
    if entry.StatusPurpose != StatusPurposeRevocation {
        return errUnsupportedPurpose
    }

    // check if StatusList2021Credential is managed by this node
    if !cs.isManaged(entry.StatusListCredential) {
        return errNotFound
    }

    // resolve signing key outside of transaction
    var issuerStr string
    err = cs.db.Model(&credentialIssuerRecord{}).Select("issuer").First(&issuerStr, "subject_id = ?", entry.StatusListCredential).Error
    if err != nil {
        // can't happen; confirmed isManaged
        return err
    }
    issuerDID, err := did.ParseDID(issuerStr)
    if err != nil {
        // can't happen; own DB
        return err
    }
    key, err := cs.ResolveKey(ctx, *issuerDID)
    if err != nil {
        // can't happen; credential confirmed to issued by this node
        return err
    }

    return cs.db.Transaction(func(tx *gorm.DB) error {
        // lock relevant credentialRecord. It was created when the first entry was issued for this StatusList2021Credential.
        err = tx.Model(new(credentialRecord)).
            Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate}).
            Select("count(*) > 0").
            Where("subject_id = ?", entry.StatusListCredential).
            First(new(bool)).
            Error
        if err != nil {
            return err
        }

        // revoke
        revocation := revocationRecord{
            StatusListCredential: entry.StatusListCredential,
            StatusListIndex:      statusListIndex,
            CredentialID:         credentialID.String(),
        }

        // fail fast, immediately fail if revocation already exists
        err = tx.Create(&revocation).Error
        if err != nil {
            if errors.Is(err, gorm.ErrDuplicatedKey) {
                return errRevoked // already revoked
            }
            return err
        }

        // load all revocations
        issuerRecord := new(credentialIssuerRecord)
        err = tx.Preload("Revocations").First(issuerRecord, "subject_id = ?", entry.StatusListCredential).Error
        if err != nil {
            if errors.Is(err, gorm.ErrRecordNotFound) {
                // can't happen, already checked
                return errNotFound
            }
            return err
        }

        // validate StatusListIndex; triggers a rollback after the fact, but this should never happen.
        if statusListIndex < 0 || statusListIndex > issuerRecord.LastIssuedIndex {
            return ErrIndexNotInBitstring
        }

        // append new revocation and re-issue the StatusList2021Credential.
        _, credRecord, err := cs.updateCredential(ctx, issuerRecord, key)
        if err != nil {
            return err
        }
        return tx.Clauses(clause.OnConflict{UpdateAll: true}).Create(credRecord).Error
    })
}

func toStatusListCredential(issuer did.DID, page int) (string, error) {
    switch issuer.Method {
    case "web":
        issuerAsURL, err := didweb.DIDToURL(issuer)
        if err != nil {
            return "", err
        }
        result := new(url.URL)
        result.Scheme = issuerAsURL.Scheme
        result.Host = issuerAsURL.Host
        result.Path = path.Join("statuslist", issuer.String(), strconv.Itoa(page))
        return result.String(), nil // https://example.com/statuslist/<did>/page
    }
    return "", fmt.Errorf("status list: unsupported DID method: %s", issuer.Method)
}