nuts-foundation/nuts-node

View on GitHub
auth/services/selfsigned/signer.go

Summary

Maintainability
A
0 mins
Test Coverage
B
84%
/*
 * Nuts node
 * Copyright (C) 2023 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 selfsigned

import (
    "context"
    "crypto/rand"
    "encoding/hex"
    "encoding/json"
    "fmt"
    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/auth/contract"
    "github.com/nuts-foundation/nuts-node/auth/services"
    "github.com/nuts-foundation/nuts-node/auth/services/selfsigned/types"
    "github.com/nuts-foundation/nuts-node/auth/services/selfsigned/web"
    "github.com/nuts-foundation/nuts-node/core"
    "github.com/nuts-foundation/nuts-node/vcr"
    "github.com/nuts-foundation/nuts-node/vcr/credential"
    "github.com/nuts-foundation/nuts-node/vcr/holder"
    "github.com/nuts-foundation/nuts-node/vcr/issuer"
    "github.com/nuts-foundation/nuts-node/vcr/signature/proof"
    "net/url"
    "time"
)

const credentialType = "NutsEmployeeCredential"

// signer implements the contract.Signer interface
type signer struct {
    store     types.SessionStore
    vcr       vcr.VCR
    publicURL string
    // signingDuration is the time the user has to sign the contract
    signingDuration time.Duration
}

// NewSigner returns an initialized employee identity contract signer
func NewSigner(vcr vcr.VCR, publicURL string) contract.Signer {
    return &signer{
        // NewMemorySessionStore returns an initialized SessionStore
        store:           NewMemorySessionStore(),
        vcr:             vcr,
        publicURL:       publicURL,
        signingDuration: 10 * time.Minute,
    }
}

// SigningSessionStatus returns the status of a signing session
// If the session is completed, a VerifiablePresentation is created and added to the result
// The session is deleted after the VerifiablePresentation is created, so the completed result can only be retrieved once
func (v *signer) SigningSessionStatus(ctx context.Context, sessionID string) (contract.SigningSessionResult, error) {
    s, ok := v.store.Load(sessionID)
    if !ok {
        return nil, services.ErrSessionNotFound
    }

    var (
        vp  *vc.VerifiablePresentation
        err error
    )
    if s.Status == types.SessionCompleted {
        // Make sure no other VP will be created for this session
        if !v.store.CheckAndSetStatus(sessionID, types.SessionCompleted, types.SessionVPRequested) {
            // Another VP is already being created for this session
            // Make sure the session is deleted
            v.store.Delete(sessionID)
            return nil, services.ErrSessionNotFound
        }

        // Create the VerifiablePresentation
        vp, err = v.createVP(ctx, s, time.Now())
        if err != nil {
            return nil, fmt.Errorf("failed to create VerifiablePresentation: %w", err)
        }
    }

    // cleanup all sessions in a final state
    switch s.Status {
    case types.SessionVPRequested:
        fallthrough
    case types.SessionExpired:
        fallthrough
    case types.SessionCancelled:
        fallthrough
    case types.SessionErrored:
        v.store.Delete(sessionID)
    }

    return signingSessionResult{
        id:                     sessionID,
        status:                 s.Status,
        request:                s.Contract,
        verifiablePresentation: vp,
    }, nil
}

// createVP creates a VerifiablePresentation for the given session
func (v *signer) createVP(ctx context.Context, s types.Session, issuanceDate time.Time) (*vc.VerifiablePresentation, error) {
    issuerID, err := did.ParseDID(s.Employer)
    if err != nil {
        return nil, fmt.Errorf("invalid issuer DID: %w", err)
    }

    expirationDate := issuanceDate.Add(24 * time.Hour)
    template := vc.VerifiableCredential{
        Context:           []ssi.URI{credential.NutsV1ContextURI},
        Type:              []ssi.URI{ssi.MustParseURI(credentialType)},
        Issuer:            issuerID.URI(),
        IssuanceDate:      issuanceDate,
        ExpirationDate:    &expirationDate,
        CredentialSubject: s.CredentialSubject(),
    }
    verifiableCredential, err := v.vcr.Issuer().Issue(ctx, template, issuer.CredentialOptions{})
    if err != nil {
        return nil, fmt.Errorf("issue VC failed: %w", err)
    }
    presentationOptions := holder.PresentationOptions{
        AdditionalContexts: []ssi.URI{credential.NutsV1ContextURI},
        AdditionalTypes:    []ssi.URI{ssi.MustParseURI(VerifiablePresentationType)},
        ProofOptions: proof.ProofOptions{
            Created:      issuanceDate,
            Challenge:    &s.Contract,
            ProofPurpose: proof.AuthenticationProofPurpose,
        },
    }
    return v.vcr.Wallet().BuildPresentation(ctx, []vc.VerifiableCredential{*verifiableCredential}, presentationOptions, issuerID, true)
}

func (v *signer) Start(ctx context.Context) {
    v.store.Start(ctx)
}

func (v *signer) StartSigningSession(userContract contract.Contract, params map[string]interface{}) (contract.SessionPointer, error) {
    // check the session params first to provide the user with feedback if something is missing
    if err := checkSessionParams(params); err != nil {
        return nil, services.InvalidContractRequestError{Message: fmt.Errorf("invalid session params: %w", err)}
    }

    const randomByteCount = 16
    sessionBytes := make([]byte, randomByteCount)
    count, err := rand.Reader.Read(sessionBytes)
    if err != nil || count != randomByteCount {
        return nil, fmt.Errorf("failed to generate session ID: %w", err)
    }

    secret := make([]byte, randomByteCount)
    _, err = rand.Reader.Read(secret)
    if err != nil || count != randomByteCount {
        return nil, fmt.Errorf("failed to generate session secret: %w", err)
    }

    sessionID := hex.EncodeToString(sessionBytes)
    s := types.Session{
        Contract:  userContract.RawContractText,
        Status:    types.SessionCreated,
        Secret:    hex.EncodeToString(secret),
        ExpiresAt: time.Now().Add(v.signingDuration),
    }
    // load params directly into session
    marshalled, err := json.Marshal(params)
    // only functions or other weird constructions can cause an error here. No need for custom error handling.
    if err != nil {
        return nil, err
    }
    // impossible to get an error here since both the pointer and the data is under our control.
    _ = json.Unmarshal(marshalled, &s)

    // Parse the DID here so we can return an error
    employeeDID, err := did.ParseDID(params["employer"].(string))
    if err != nil {
        return nil, fmt.Errorf("failed to parse employer param as DID: %w", err)
    }
    s.Employer = employeeDID.String()
    v.store.Store(sessionID, s)

    pageURL, err := url.ParseRequestURI(core.JoinURLPaths(v.publicURL, "public/auth/v1/means/employeeid", sessionID))
    if err != nil {
        return nil, err
    }
    return sessionPointer{
        sessionID: sessionID,
        url:       pageURL.String(),
    }, nil
}

// checkSessionParams checks for the following structure:
//
//    {
//      "employer":"did:123",
//      "employee": {
//        "identifier": "481",
//        "roleName": "Verpleegkundige niveau 2",
//        "initials": "J",
//        "familyName": "van Dijk",
//        "email": "j.vandijk@example.com"
//      }
//    }
func checkSessionParams(params map[string]interface{}) error {
    _, ok := params["employer"]
    if !ok {
        return fmt.Errorf("missing employer")
    }
    employee, ok := params["employee"]
    if !ok {
        return fmt.Errorf("missing employee")
    }
    employeeMap, ok := employee.(map[string]interface{})
    if !ok {
        return fmt.Errorf("employee should be an object")
    }
    identifier, _ := employeeMap["identifier"].(string)
    if len(identifier) == 0 {
        return fmt.Errorf("missing/invalid employee identifier")
    }
    initials, _ := employeeMap["initials"].(string)
    if len(initials) == 0 {
        return fmt.Errorf("missing/invalid employee initials")
    }
    familyName, _ := employeeMap["familyName"].(string)
    if len(familyName) == 0 {
        return fmt.Errorf("missing/invalid employee familyName")
    }
    return nil
}

func (v *signer) Routes(router core.EchoRouter) {
    h := web.NewHandler(v.store)
    h.Routes(router)
}