diff --git a/CHANGELOG.md b/CHANGELOG.md index cf4f2a4e27..a3c9380081 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,8 @@ Changelog for NeoFS Node - `neofs-adm balance container-status` command (#3693) - Container IDs to JSON output of ADM `fschain dump-containers` command (#3789) - Support for IDs from JSON to ADM `fschain restore-containers` command (#3789) +- IR supports new session token v2 (#3671) +- SN supports new session token v2 for container and object operations (#3671) ### Fixed - IR panics at graceful shutdown (#3706) @@ -59,7 +61,7 @@ Changelog for NeoFS Node ### Updated - `github.com/nspcc-dev/neofs-contract` module to `v0.26.0` (#3670, #3746, #3733, #3780, #3782) -- `github.com/nspcc-dev/neofs-sdk-go` module to `v1.0.0-rc.16.0.20260126114348-87674e46ef14` (#3711, #3750, #3733, #3775, #3772, #3787, #3784) +- `github.com/nspcc-dev/neofs-sdk-go` module to `v1.0.0-rc.16.0.20260127152410-12dbac67e506` (#3711, #3750, #3733, #3775, #3772, #3787, #3784, #3671) - `github.com/nspcc-dev/locode-db` module to `v0.8.2` (#3729) - `github.com/nspcc-dev/neo-go` module to `v0.116.0` (#3733, #3769, #3779) diff --git a/cmd/neofs-cli/internal/commonflags/flags.go b/cmd/neofs-cli/internal/commonflags/flags.go index c1e7d75c7e..7c63f7756c 100644 --- a/cmd/neofs-cli/internal/commonflags/flags.go +++ b/cmd/neofs-cli/internal/commonflags/flags.go @@ -48,6 +48,12 @@ const ( OIDFlag = "oid" OIDFlagUsage = "Object ID." + + SessionSubjectFlag = "session-subjects" + SessionSubjectFlagUsage = "Session subject user IDs (optional, defaults to current node)" + + SessionSubjectNNSFlag = "session-subjects-nns" + SessionSubjectNNSFlagUsage = "Session subject NNS names (optional, defaults to current node)" ) // Init adds common flags to the command: diff --git a/cmd/neofs-cli/modules/container/create.go b/cmd/neofs-cli/modules/container/create.go index bc4e3bf245..ead6c91b04 100644 --- a/cmd/neofs-cli/modules/container/create.go +++ b/cmd/neofs-cli/modules/container/create.go @@ -124,7 +124,7 @@ It will be stored in FS chain when inner ring will accepts it.`, if tokAny != nil { switch tok := tokAny.(type) { case *sessionv2.Token: - cnr.SetOwner(tok.Issuer()) + cnr.SetOwner(tok.OriginalIssuer()) case *session.Container: cnr.SetOwner(tok.Issuer()) default: diff --git a/cmd/neofs-cli/modules/container/delete.go b/cmd/neofs-cli/modules/container/delete.go index 2ad4c2bc6d..912c3e16f5 100644 --- a/cmd/neofs-cli/modules/container/delete.go +++ b/cmd/neofs-cli/modules/container/delete.go @@ -63,8 +63,8 @@ Only owner of the container has a permission to remove container.`, switch tok := tokAny.(type) { case *sessionv2.Token: - if tok.Issuer() != owner { - return fmt.Errorf("session issuer differs with the container owner: expected %s, has %s", owner, tok.Issuer()) + if tok.OriginalIssuer() != owner { + return fmt.Errorf("session original issuer differs with the container owner: expected %s, has %s", owner, tok.OriginalIssuer()) } case *session.Container: if tok.Issuer() != owner { diff --git a/cmd/neofs-cli/modules/container/set_eacl.go b/cmd/neofs-cli/modules/container/set_eacl.go index ac5c75f80b..d01a742d6d 100644 --- a/cmd/neofs-cli/modules/container/set_eacl.go +++ b/cmd/neofs-cli/modules/container/set_eacl.go @@ -80,8 +80,8 @@ Container ID in EACL table will be substituted with ID from the CLI.`, switch tok := tokAny.(type) { case *sessionv2.Token: - if tok.Issuer() != owner { - return fmt.Errorf("session issuer differs with the container owner: expected %s, has %s", owner, tok.Issuer()) + if tok.OriginalIssuer() != owner { + return fmt.Errorf("session original issuer differs with the container owner: expected %s, has %s", owner, tok.OriginalIssuer()) } case *session.Container: if tok.Issuer() != owner { diff --git a/cmd/neofs-cli/modules/container/util_session_v2.go b/cmd/neofs-cli/modules/container/util_session_v2.go index 97a3d7506d..5342e5f913 100644 --- a/cmd/neofs-cli/modules/container/util_session_v2.go +++ b/cmd/neofs-cli/modules/container/util_session_v2.go @@ -51,10 +51,20 @@ func getSessionAnyVersion(cmd *cobra.Command) (any, error) { return nil, nil } +// noopNNSResolver is a no-operation NNS name resolver that +// always returns that the user exists for any NNS name. +// We don't have NNS resolution in the CLI, so this resolver +// is used to skip issuer validation for NNS subjects. +type noopNNSResolver struct{} + +func (r noopNNSResolver) HasUser(string, user.ID) (bool, error) { + return true, nil +} + func validateSessionV2ForContainer(cmd *cobra.Command, tok *session.Token, key *ecdsa.PrivateKey, cnrID cid.ID, verb session.Verb) error { common.PrintVerbose(cmd, "Validating V2 session token...") - if err := tok.Validate(); err != nil { + if err := tok.Validate(noopNNSResolver{}); err != nil { return fmt.Errorf("invalid V2 session token: %w", err) } diff --git a/cmd/neofs-cli/modules/object/delete.go b/cmd/neofs-cli/modules/object/delete.go index c0b5a28357..8987a0b611 100644 --- a/cmd/neofs-cli/modules/object/delete.go +++ b/cmd/neofs-cli/modules/object/delete.go @@ -34,6 +34,8 @@ func initObjectDeleteCmd() { flags.StringSlice(commonflags.OIDFlag, nil, commonflags.OIDFlagUsage) flags.Bool(binaryFlag, false, "Deserialize object structure from given file.") flags.String(fileFlag, "", "File with object payload") + flags.StringSlice(commonflags.SessionSubjectFlag, nil, commonflags.SessionSubjectFlagUsage) + flags.StringSlice(commonflags.SessionSubjectNNSFlag, nil, commonflags.SessionSubjectNNSFlagUsage) _ = objectDelCmd.MarkFlagRequired(commonflags.CIDFlag) _ = objectDelCmd.MarkFlagRequired(commonflags.OIDFlag) @@ -104,7 +106,12 @@ func deleteObject(cmd *cobra.Command, _ []string) error { var statusErr error for _, addr := range objAddrs { id := addr.Object() - err := ReadOrOpenSessionViaClient(ctx, cmd, &prm, cli, pk, cnr, id) + subjects, err := parseSessionSubjects(cmd, ctx, cli) + if err != nil { + return err + } + + err = ReadOrOpenSessionViaClient(ctx, cmd, &prm, cli, pk, subjects, cnr, id) if err != nil { return err } diff --git a/cmd/neofs-cli/modules/object/lock.go b/cmd/neofs-cli/modules/object/lock.go index c2efa0abae..0cb16b3a3f 100644 --- a/cmd/neofs-cli/modules/object/lock.go +++ b/cmd/neofs-cli/modules/object/lock.go @@ -82,7 +82,11 @@ var objectLockCmd = &cobra.Command{ } defer cli.Close() - err = ReadOrOpenSessionViaClient(ctx, cmd, &prm, cli, key, cnr) + subjects, err := parseSessionSubjects(cmd, ctx, cli) + if err != nil { + return err + } + err = ReadOrOpenSessionViaClient(ctx, cmd, &prm, cli, key, subjects, cnr) if err != nil { return err } @@ -132,4 +136,7 @@ func initCommandObjectLock() { ff.Uint64(commonflags.Lifetime, 0, "Lock lifetime") objectLockCmd.MarkFlagsOneRequired(commonflags.ExpireAt, commonflags.Lifetime) + + ff.StringSlice(commonflags.SessionSubjectFlag, nil, commonflags.SessionSubjectFlagUsage) + ff.StringSlice(commonflags.SessionSubjectNNSFlag, nil, commonflags.SessionSubjectNNSFlagUsage) } diff --git a/cmd/neofs-cli/modules/object/put.go b/cmd/neofs-cli/modules/object/put.go index 76564926bb..e0454b4580 100644 --- a/cmd/neofs-cli/modules/object/put.go +++ b/cmd/neofs-cli/modules/object/put.go @@ -59,6 +59,8 @@ func initObjectPutCmd() { flags.Bool(noProgressFlag, false, "Do not show progress bar") flags.Bool(binaryFlag, false, "Deserialize object structure from given file.") + flags.StringSlice(commonflags.SessionSubjectFlag, nil, commonflags.SessionSubjectFlagUsage) + flags.StringSlice(commonflags.SessionSubjectNNSFlag, nil, commonflags.SessionSubjectNNSFlagUsage) objectPutCmd.MarkFlagsMutuallyExclusive(commonflags.ExpireAt, commonflags.Lifetime) } @@ -129,7 +131,11 @@ func putObject(cmd *cobra.Command, _ []string) error { } defer func() { _ = cli.Close() }() - err = ReadOrOpenSessionViaClient(ctx, cmd, &prm, cli, pk, cnr) + subjects, err := parseSessionSubjects(cmd, ctx, cli) + if err != nil { + return err + } + err = ReadOrOpenSessionViaClient(ctx, cmd, &prm, cli, pk, subjects, cnr) if err != nil { return err } diff --git a/cmd/neofs-cli/modules/object/util.go b/cmd/neofs-cli/modules/object/util.go index f043d5ebb9..0b14577ee1 100644 --- a/cmd/neofs-cli/modules/object/util.go +++ b/cmd/neofs-cli/modules/object/util.go @@ -3,6 +3,7 @@ package object import ( "context" "crypto/ecdsa" + "crypto/elliptic" "errors" "fmt" "io" @@ -10,10 +11,9 @@ import ( "strings" "time" - internal "github.com/nspcc-dev/neofs-node/cmd/neofs-cli/internal/client" + "github.com/nspcc-dev/neo-go/pkg/crypto/keys" "github.com/nspcc-dev/neofs-node/cmd/neofs-cli/internal/common" "github.com/nspcc-dev/neofs-node/cmd/neofs-cli/internal/commonflags" - sessionCli "github.com/nspcc-dev/neofs-node/cmd/neofs-cli/modules/session" icrypto "github.com/nspcc-dev/neofs-node/internal/crypto" "github.com/nspcc-dev/neofs-sdk-go/bearer" "github.com/nspcc-dev/neofs-sdk-go/client" @@ -205,7 +205,7 @@ func getSession(cmd *cobra.Command) (*session.Object, error) { // *internal.PayloadRangePrm // *internal.HashPayloadRangesPrm func _readVerifiedSession(cmd *cobra.Command, dst SessionPrm, key *ecdsa.PrivateKey, cnr cid.ID, obj *oid.ID) error { - isV2, err := tryReadSessionV2(cmd, dst, key, cnr, obj) + isV2, err := tryReadSessionV2(cmd, dst, key, cnr) if err != nil { return fmt.Errorf("v2 session validation failed: %w", err) } @@ -276,8 +276,8 @@ func getVerifiedSession(cmd *cobra.Command, cmdVerb session.ObjectVerb, key *ecd // ReadOrOpenSessionViaClient tries to read session from the file (V2 first, then V1), // specified in commonflags.SessionToken flag, finalizes structures of the decoded token // and write the result into provided SessionPrm. -// If file is missing, ReadOrOpenSessionViaClient calls OpenSessionViaClient. -func ReadOrOpenSessionViaClient(ctx context.Context, cmd *cobra.Command, dst SessionPrm, cli *client.Client, key *ecdsa.PrivateKey, cnr cid.ID, objs ...oid.ID) error { +// If file is missing, CreateSessionV2 is called to create V2 token. +func ReadOrOpenSessionViaClient(ctx context.Context, cmd *cobra.Command, dst SessionPrm, cli *client.Client, key *ecdsa.PrivateKey, subjects []sessionv2.Target, cnr cid.ID, objs ...oid.ID) error { path, _ := cmd.Flags().GetString(commonflags.SessionToken) if path != "" { @@ -296,47 +296,13 @@ func ReadOrOpenSessionViaClient(ctx context.Context, cmd *cobra.Command, dst Ses } } - err := OpenSessionViaClient(ctx, cmd, dst, cli, key, cnr, objs...) + err := CreateSessionV2(ctx, cmd, dst, cli, key, subjects, cnr) if err != nil { return err } return nil } -// OpenSessionViaClient opens object session with the remote node, finalizes -// structure of the session token and writes the result into the provided -// SessionPrm. Also writes provided client connection to the SessionPrm. -// -// SessionPrm MUST be one of: -// -// *internal.PutObjectPrm -// *internal.DeleteObjectPrm -func OpenSessionViaClient(ctx context.Context, cmd *cobra.Command, dst SessionPrm, cli *client.Client, key *ecdsa.PrivateKey, cnr cid.ID, objs ...oid.ID) error { - var tok session.Object - - const sessionLifetime = 10 // in NeoFS epochs - - common.PrintVerbose(cmd, "Opening remote session with the node...") - currEpoch, err := internal.GetCurrentEpoch(ctx, viper.GetString(commonflags.RPC)) - if err != nil { - return fmt.Errorf("can't fetch current epoch: %w", err) - } - exp := currEpoch + sessionLifetime - err = sessionCli.CreateSession(ctx, &tok, cli, *key, exp, currEpoch) - if err != nil { - return fmt.Errorf("open remote session: %w", err) - } - - common.PrintVerbose(cmd, "Session successfully opened.") - - err = finalizeSession(cmd, dst, &tok, key, cnr, objs...) - if err != nil { - return err - } - - return nil -} - // specifies session verb, binds the session to the given container and limits // the session by the given objects (if specified). After all data is written, // signs session using provided private key and writes the session into the @@ -381,14 +347,27 @@ func finalizeSession(cmd *cobra.Command, dst SessionPrm, tok *session.Object, ke return nil } -// CreateSessionV2 creates V2 token locally and -// writes the result into the provided SessionPrm. +// noopNNSResolver is a no-operation NNS name resolver that +// always returns that the user exists for any NNS name. +// We don't have NNS resolution in the CLI, so this resolver +// is used to skip issuer validation for NNS subjects. +type noopNNSResolver struct{} + +func (r noopNNSResolver) HasUser(string, user.ID) (bool, error) { + return true, nil +} + +// CreateSessionV2 opens object session with the remote node, finalizes +// structure of the session token v2 and writes the result into the provided +// SessionPrm. Also writes provided client connection to the SessionPrm. // // SessionPrm MUST be one of: // // *internal.PutObjectPrm // *internal.DeleteObjectPrm -func CreateSessionV2(cmd *cobra.Command, dst SessionPrm, key *ecdsa.PrivateKey, subjects []sessionv2.Target, cnr cid.ID, objs ...oid.ID) error { +func CreateSessionV2(ctx context.Context, cmd *cobra.Command, dst SessionPrm, cli *client.Client, key *ecdsa.PrivateKey, subjects []sessionv2.Target, cnr cid.ID) error { + const defaultTokenExp = 10 * time.Hour + common.PrintVerbose(cmd, "Creating V2 session token locally...") var tok sessionv2.Token signer := user.NewAutoIDSigner(*key) @@ -396,15 +375,52 @@ func CreateSessionV2(cmd *cobra.Command, dst SessionPrm, key *ecdsa.PrivateKey, currentTime := time.Now() tok.SetVersion(sessionv2.TokenCurrentVersion) tok.SetNonce(sessionv2.RandomNonce()) - tok.SetIat(currentTime) + // allow 10s clock skew, because time isn't synchronous over the network + tok.SetIat(currentTime.Add(-10 * time.Second)) tok.SetNbf(currentTime) - tok.SetExp(currentTime.Add(10 * time.Hour)) + tok.SetExp(currentTime.Add(defaultTokenExp)) tok.SetFinal(true) err := tok.SetSubjects(subjects) if err != nil { return fmt.Errorf("set subjects: %w", err) } + common.PrintVerbose(cmd, "Creating server-side session key via RPC...") + + ni, err := cli.NetworkInfo(ctx, client.PrmNetworkInfo{}) + if err != nil { + return fmt.Errorf("get network info: %w", err) + } + epochInMs := int64(ni.EpochDuration()) * ni.MsPerBlock() + if epochInMs == 0 { + return errors.New("invalid network configuration: epoch duration is zero") + } + epochLifetime := (defaultTokenExp.Milliseconds() + epochInMs - 1) / epochInMs + epochExp := ni.CurrentEpoch() + uint64(epochLifetime) + common.PrintVerbose(cmd, "Current epoch: %d", ni.CurrentEpoch()) + common.PrintVerbose(cmd, "Token expiration epoch: %d", epochExp) + + var sessionPrm client.PrmSessionCreate + sessionPrm.SetExp(epochExp) + + sessionRes, err := cli.SessionCreate(ctx, signer, sessionPrm) + if err != nil { + return fmt.Errorf("create server-side session key: %w", err) + } + + var keySession neofsecdsa.PublicKey + err = keySession.Decode(sessionRes.PublicKey()) + if err != nil { + return fmt.Errorf("decode public session key: %w", err) + } + + serverUserID := user.NewFromECDSAPublicKey((ecdsa.PublicKey)(keySession)) + err = tok.AddSubject(sessionv2.NewTargetUser(serverUserID)) + if err != nil { + return fmt.Errorf("add server-side session key as subject: %w", err) + } + common.PrintVerbose(cmd, "Server-side session key created as last subject: %s", serverUserID) + var verb sessionv2.Verb switch dst.(type) { case *client.PrmObjectPutInit: @@ -428,7 +444,7 @@ func CreateSessionV2(cmd *cobra.Command, dst SessionPrm, key *ecdsa.PrivateKey, return fmt.Errorf("sign V2 session: %w", err) } - if err := tok.Validate(); err != nil { + if err := tok.Validate(noopNNSResolver{}); err != nil { return fmt.Errorf("invalid V2 session token after creation: %w", err) } @@ -442,7 +458,7 @@ func CreateSessionV2(cmd *cobra.Command, dst SessionPrm, key *ecdsa.PrivateKey, func finalizeSessionV2(cmd *cobra.Command, dst SessionPrm, tok *sessionv2.Token, key *ecdsa.PrivateKey, cnr cid.ID) error { common.PrintVerbose(cmd, "Finalizing V2 session token...") - if err := tok.Validate(); err != nil { + if err := tok.Validate(noopNNSResolver{}); err != nil { return fmt.Errorf("invalid V2 session token: %w", err) } @@ -487,3 +503,47 @@ func openFileForPayload(name string) (io.WriteCloser, error) { } return f, nil } + +func parseSessionSubjects(cmd *cobra.Command, ctx context.Context, cli *client.Client) ([]sessionv2.Target, error) { + sessionSubjects, _ := cmd.Flags().GetStringSlice(commonflags.SessionSubjectFlag) + sessionSubjectsNNS, _ := cmd.Flags().GetStringSlice(commonflags.SessionSubjectNNSFlag) + + if len(sessionSubjects) > 0 || len(sessionSubjectsNNS) > 0 { + common.PrintVerbose(cmd, "Using session subjects from command line flags") + subjects := make([]sessionv2.Target, 0, len(sessionSubjects)+len(sessionSubjectsNNS)) + + // Parse user IDs + for _, subj := range sessionSubjects { + userID, err := user.DecodeString(subj) + if err != nil { + return nil, fmt.Errorf("failed to decode user ID '%s': %w", subj, err) + } + subjects = append(subjects, sessionv2.NewTargetUser(userID)) + } + + // Parse NNS names + for _, nnsName := range sessionSubjectsNNS { + if nnsName == "" { + return nil, fmt.Errorf("NNS name cannot be empty") + } + subjects = append(subjects, sessionv2.NewTargetNamed(nnsName)) + } + + return subjects, nil + } + + common.PrintVerbose(cmd, "Using default session subjects (only target node)") + res, err := cli.EndpointInfo(ctx, client.PrmEndpointInfo{}) + if err != nil { + return nil, fmt.Errorf("get endpoint info: %w", err) + } + + neoPubKey, err := keys.NewPublicKeyFromBytes(res.NodeInfo().PublicKey(), elliptic.P256()) + if err != nil { + return nil, fmt.Errorf("parse node public key: %w", err) + } + + ecdsaPubKey := (*ecdsa.PublicKey)(neoPubKey) + userID := user.NewFromECDSAPublicKey(*ecdsaPubKey) + return []sessionv2.Target{sessionv2.NewTargetUser(userID)}, nil +} diff --git a/cmd/neofs-cli/modules/object/util_session_v2.go b/cmd/neofs-cli/modules/object/util_session_v2.go index b3d215fc92..2554783c5c 100644 --- a/cmd/neofs-cli/modules/object/util_session_v2.go +++ b/cmd/neofs-cli/modules/object/util_session_v2.go @@ -2,13 +2,15 @@ package object import ( "crypto/ecdsa" + "errors" "fmt" "github.com/nspcc-dev/neofs-node/cmd/neofs-cli/internal/common" "github.com/nspcc-dev/neofs-node/cmd/neofs-cli/internal/commonflags" + icrypto "github.com/nspcc-dev/neofs-node/internal/crypto" "github.com/nspcc-dev/neofs-sdk-go/client" cid "github.com/nspcc-dev/neofs-sdk-go/container/id" - oid "github.com/nspcc-dev/neofs-sdk-go/object/id" + neofscrypto "github.com/nspcc-dev/neofs-sdk-go/crypto" "github.com/nspcc-dev/neofs-sdk-go/session/v2" "github.com/nspcc-dev/neofs-sdk-go/user" "github.com/spf13/cobra" @@ -43,7 +45,7 @@ func getVerifiedSessionV2(cmd *cobra.Command, cmdVerb session.Verb, key *ecdsa.P common.PrintVerbose(cmd, "Validating V2 session token...") - if err := tok.Validate(); err != nil { + if err := tok.Validate(noopNNSResolver{}); err != nil { return nil, fmt.Errorf("invalid V2 session token: %w", err) } @@ -56,11 +58,19 @@ func getVerifiedSessionV2(cmd *cobra.Command, cmdVerb session.Verb, key *ecdsa.P return nil, fmt.Errorf("v2 session token issuer %v does not match provided key/wallet %s", tok.Issuer(), signer.UserID()) } + if err := icrypto.AuthenticateTokenV2(tok, nil); err != nil { + // CLI has no tool to verify N3 signature, so check is delegated to the server + var errScheme icrypto.ErrUnsupportedScheme + if !errors.As(err, &errScheme) || neofscrypto.Scheme(errScheme) != neofscrypto.N3 { + return nil, fmt.Errorf("verify session token signature: %w", err) + } + } + common.PrintVerbose(cmd, "V2 session token validated successfully") return tok, nil } -func _readVerifiedSessionV2(cmd *cobra.Command, dst SessionPrm, key *ecdsa.PrivateKey, cnr cid.ID, obj *oid.ID) error { +func _readVerifiedSessionV2(cmd *cobra.Command, dst SessionPrm, key *ecdsa.PrivateKey, cnr cid.ID) error { var cmdVerb session.Verb switch dst.(type) { @@ -89,7 +99,7 @@ func _readVerifiedSessionV2(cmd *cobra.Command, dst SessionPrm, key *ecdsa.Priva return nil } -func tryReadSessionV2(cmd *cobra.Command, dst SessionPrm, key *ecdsa.PrivateKey, cnr cid.ID, obj *oid.ID) (bool, error) { +func tryReadSessionV2(cmd *cobra.Command, dst SessionPrm, key *ecdsa.PrivateKey, cnr cid.ID) (bool, error) { path, _ := cmd.Flags().GetString(commonflags.SessionToken) if path == "" { return false, nil @@ -103,7 +113,7 @@ func tryReadSessionV2(cmd *cobra.Command, dst SessionPrm, key *ecdsa.PrivateKey, return false, nil } - err = _readVerifiedSessionV2(cmd, dst, key, cnr, obj) + err = _readVerifiedSessionV2(cmd, dst, key, cnr) if err != nil { return true, err } diff --git a/cmd/neofs-cli/modules/session/create_v2.go b/cmd/neofs-cli/modules/session/create_v2.go index 79516ba7d9..a8139f5a27 100644 --- a/cmd/neofs-cli/modules/session/create_v2.go +++ b/cmd/neofs-cli/modules/session/create_v2.go @@ -1,17 +1,24 @@ package session import ( + "context" + "crypto/ecdsa" "errors" "fmt" "os" + "slices" "strconv" "strings" "time" + internalclient "github.com/nspcc-dev/neofs-node/cmd/neofs-cli/internal/client" "github.com/nspcc-dev/neofs-node/cmd/neofs-cli/internal/common" "github.com/nspcc-dev/neofs-node/cmd/neofs-cli/internal/commonflags" "github.com/nspcc-dev/neofs-node/cmd/neofs-cli/internal/key" + "github.com/nspcc-dev/neofs-node/pkg/network" + "github.com/nspcc-dev/neofs-sdk-go/client" cid "github.com/nspcc-dev/neofs-sdk-go/container/id" + neofsecdsa "github.com/nspcc-dev/neofs-sdk-go/crypto/ecdsa" "github.com/nspcc-dev/neofs-sdk-go/session/v2" "github.com/nspcc-dev/neofs-sdk-go/user" "github.com/spf13/cobra" @@ -33,14 +40,14 @@ var createV2Cmd = &cobra.Command{ Short: "Create V2 session token", Long: `Create V2 session token with subjects and multiple contexts. +V2 tokens always create a server-side key via SessionCreate RPC +and include it as the last subject in the token. + V2 tokens support: - Multiple subjects (accounts authorized to use the token) - Multiple contexts (container + object operations) -- No server-side session key storage (no SessionCreate RPC needed) - Token delegation chains via --origin flag -IMPORTANT: Contexts and verbs must be specified in sorted order for proper token validation. - Context format: containerID:verbs - containerID: Container ID or "0" for wildcard (any container) - verbs: Comma-separated list of operations (e.g., DELETE,GET,HEAD,PUT,SEARCH) @@ -48,6 +55,7 @@ Context format: containerID:verbs Example usage: neofs-cli session create-v2 \ --wallet wallet.json \ + --rpc node.neofs.devenv:8080 \ --lifetime 10000 \ --out token.json \ --json \ @@ -74,16 +82,19 @@ func init() { createV2Cmd.Flags().String(outFlag, "", "File to write session token to") createV2Cmd.Flags().Bool(jsonFlag, false, "Output token in JSON") createV2Cmd.Flags().Uint64P(commonflags.ExpireAt, "e", 0, "Expiration time in seconds for token to stay valid") + createV2Cmd.Flags().StringP(commonflags.RPC, commonflags.RPCShorthand, commonflags.RPCDefault, commonflags.RPCUsage) // V2-specific flags createV2Cmd.Flags().StringArray(v2SubjectsFlag, nil, "Subject user IDs (can be specified multiple times)") createV2Cmd.Flags().StringArray(v2SubjectsNNSFlag, nil, "Subject NNS names (can be specified multiple times)") createV2Cmd.Flags().Bool(v2FinalFlag, false, "Set the final flag in the token, disallowing further delegation") - createV2Cmd.Flags().StringArray(v2ContextFlag, nil, "Context spec (repeatable): containerID:verbs. Use '0' for wildcard container. Contexts and verbs should be sorted.") + createV2Cmd.Flags().StringArray(v2ContextFlag, nil, "Context spec (repeatable): containerID:verbs. Use '0' for wildcard container.") createV2Cmd.Flags().String(v2OriginFlag, "", "Path to origin token file for token delegation chain") + createV2Cmd.Flags().BoolP(commonflags.ForceFlag, commonflags.ForceFlagShorthand, false, "Skip token validation (use with caution)") _ = cobra.MarkFlagRequired(createV2Cmd.Flags(), commonflags.WalletPath) _ = cobra.MarkFlagRequired(createV2Cmd.Flags(), outFlag) + _ = cobra.MarkFlagRequired(createV2Cmd.Flags(), commonflags.RPC) createV2Cmd.MarkFlagsOneRequired(commonflags.ExpireAt, commonflags.Lifetime) createV2Cmd.MarkFlagsOneRequired(v2SubjectsFlag, v2SubjectsNNSFlag) _ = cobra.MarkFlagRequired(createV2Cmd.Flags(), v2ContextFlag) @@ -114,7 +125,8 @@ func createSessionV2(cmd *cobra.Command, _ []string) error { tokV2.SetVersion(session.TokenCurrentVersion) tokV2.SetNonce(session.RandomNonce()) tokV2.SetNbf(currentTime) - tokV2.SetIat(currentTime) + // allow 10s clock skew, because time isn't synchronous over the network + tokV2.SetIat(currentTime.Add(-10 * time.Second)) tokV2.SetExp(expTime) final, _ := cmd.Flags().GetBool(v2FinalFlag) @@ -133,6 +145,55 @@ func createSessionV2(cmd *cobra.Command, _ []string) error { if err != nil { return err } + + common.PrintVerbose(cmd, "Creating server-side session key via RPC...") + + rpcEndpoint, _ := cmd.Flags().GetString(commonflags.RPC) + var netAddr network.Address + if err := netAddr.FromString(rpcEndpoint); err != nil { + return fmt.Errorf("parse endpoint: %w", err) + } + + ctx := context.Background() + c, err := internalclient.GetSDKClient(ctx, netAddr) + if err != nil { + return fmt.Errorf("create client: %w", err) + } + defer c.Close() + + ni, err := c.NetworkInfo(ctx, client.PrmNetworkInfo{}) + if err != nil { + return fmt.Errorf("get network info: %w", err) + } + lifetime := uint64(expTime.Sub(currentTime).Milliseconds()) + epochInMs := ni.EpochDuration() * uint64(ni.MsPerBlock()) + if epochInMs == 0 { + return errors.New("invalid network configuration: epoch duration is zero") + } + epochLifetime := (lifetime + epochInMs - 1) / epochInMs + epochExp := ni.CurrentEpoch() + epochLifetime + common.PrintVerbose(cmd, "Current epoch: %d", ni.CurrentEpoch()) + common.PrintVerbose(cmd, "Token expiration epoch: %d", epochExp) + + var sessionPrm client.PrmSessionCreate + sessionPrm.SetExp(epochExp) + + sessionRes, err := c.SessionCreate(ctx, signer, sessionPrm) + if err != nil { + return fmt.Errorf("create server-side session key: %w", err) + } + + var keySession neofsecdsa.PublicKey + + err = keySession.Decode(sessionRes.PublicKey()) + if err != nil { + return fmt.Errorf("decode public session key: %w", err) + } + + serverUserID := user.NewFromECDSAPublicKey((ecdsa.PublicKey)(keySession)) + subjects = append(subjects, session.NewTargetUser(serverUserID)) + common.PrintVerbose(cmd, "Server-side session key created as last subject: %s", serverUserID) + err = tokV2.SetSubjects(subjects) if err != nil { return fmt.Errorf("can't set subjects: %w", err) @@ -173,10 +234,15 @@ func createSessionV2(cmd *cobra.Command, _ []string) error { common.PrintVerbose(cmd, "Token signed successfully") - if err := tokV2.Validate(); err != nil { - return fmt.Errorf("created token validation failed: %w", err) + force, _ := cmd.Flags().GetBool(commonflags.ForceFlag) + if !force { + if err := tokV2.Validate(noopNNSResolver{}); err != nil { + return fmt.Errorf("created token validation failed: %w", err) + } + common.PrintVerbose(cmd, "Created token validated successfully") + } else { + common.PrintVerbose(cmd, "Token validation skipped (--force flag)") } - common.PrintVerbose(cmd, "Created token validated successfully") var data []byte if toJSON, _ := cmd.Flags().GetBool(jsonFlag); toJSON { @@ -209,7 +275,7 @@ func parseSubjects(cmd *cobra.Command) ([]session.Target, error) { return nil, errors.New("at least one subject (--subject or --subject-nns) must be specified") } - subjects := make([]session.Target, 0, len(subjectIDs)+len(subjectNNS)) + subjects := make([]session.Target, 0, len(subjectIDs)+len(subjectNNS)+1) for _, idStr := range subjectIDs { var userID user.ID @@ -267,6 +333,11 @@ func parseContexts(cmd *cobra.Command) ([]session.Context, error) { contexts = append(contexts, ctx) } + + slices.SortFunc(contexts, func(a, b session.Context) int { + return a.Container().Compare(b.Container()) + }) + return contexts, nil } @@ -310,6 +381,8 @@ func parseVerbs(verbsStr string) ([]session.Verb, error) { verbs = append(verbs, verb) } + slices.Sort(verbs) + return verbs, nil } @@ -331,9 +404,24 @@ func loadOriginToken(cmd *cobra.Command) (*session.Token, error) { } } - if err := originTok.Validate(); err != nil { - return nil, fmt.Errorf("origin token validation failed: %w", err) + force, _ := cmd.Flags().GetBool(commonflags.ForceFlag) + if !force { + if err := originTok.Validate(noopNNSResolver{}); err != nil { + return nil, fmt.Errorf("origin token validation failed: %w", err) + } + } else { + common.PrintVerbose(cmd, "Origin token validation skipped (--force flag)") } return &originTok, nil } + +// noopNNSResolver is a no-operation NNS name resolver that +// always returns that the user exists for any NNS name. +// We don't have NNS resolution in the CLI, so this resolver +// is used to skip issuer validation for NNS subjects. +type noopNNSResolver struct{} + +func (r noopNNSResolver) HasUser(string, user.ID) (bool, error) { + return true, nil +} diff --git a/cmd/neofs-node/config.go b/cmd/neofs-node/config.go index adf42f3413..62539e8db0 100644 --- a/cmd/neofs-node/config.go +++ b/cmd/neofs-node/config.go @@ -19,6 +19,7 @@ import ( neogoutil "github.com/nspcc-dev/neo-go/pkg/util" netmaprpc "github.com/nspcc-dev/neofs-contract/rpc/netmap" "github.com/nspcc-dev/neofs-node/cmd/neofs-node/config" + "github.com/nspcc-dev/neofs-node/internal/chaintime" "github.com/nspcc-dev/neofs-node/misc" "github.com/nspcc-dev/neofs-node/pkg/core/container" netmapCore "github.com/nspcc-dev/neofs-node/pkg/core/netmap" @@ -209,6 +210,9 @@ type cfg struct { cfgControlService cfgControlService cfgReputation cfgReputation cfgObject cfgObject + + // chainTime is a global chain time provider updated from FS headers. + chainTime chaintime.AtomicChainTimeProvider } // GetNetworkMap reads network map which has been cached at the latest epoch. diff --git a/cmd/neofs-node/container.go b/cmd/neofs-node/container.go index 1a5f5665f3..2ed8676fd4 100644 --- a/cmd/neofs-node/container.go +++ b/cmd/neofs-node/container.go @@ -28,7 +28,6 @@ import ( "github.com/nspcc-dev/neofs-sdk-go/eacl" netmapsdk "github.com/nspcc-dev/neofs-sdk-go/netmap" protocontainer "github.com/nspcc-dev/neofs-sdk-go/proto/container" - "github.com/nspcc-dev/neofs-sdk-go/session" "github.com/nspcc-dev/neofs-sdk-go/user" "go.uber.org/zap" ) @@ -107,7 +106,7 @@ func initContainerService(c *cfg) { initSizeLoadReports(c) - cnrSrv := containerService.New(&c.key.PrivateKey, c.networkState, c.cli, (*containersInChain)(&c.basics), c.nCli) + cnrSrv := containerService.New(&c.key.PrivateKey, c.networkState, c.cli, (*containersInChain)(&c.basics), c.nCli, &c.chainTime) addNewEpochAsyncNotificationHandler(c, func(event.Event) { cnrSrv.ResetSessionTokenCheckCache() }) @@ -543,14 +542,12 @@ func (x *containersInChain) List(id user.ID) ([]cid.ID, error) { return x.cnrLst.List(&id) } -func (x *containersInChain) Put(ctx context.Context, cnr containerSDK.Container, pub, sig []byte, st *session.Container) (cid.ID, error) { +func (x *containersInChain) Put(ctx context.Context, cnr containerSDK.Container, pub, sig []byte, st []byte) (cid.ID, error) { var prm cntClient.PutPrm prm.SetContainer(cnr) prm.SetKey(pub) prm.SetSignature(sig) - if st != nil { - prm.SetToken(st.Marshal()) - } + prm.SetToken(st) id, err := x.cCli.Put(ctx, prm) if errors.Is(err, client.ErrTxAwaitTimeout) { @@ -560,14 +557,12 @@ func (x *containersInChain) Put(ctx context.Context, cnr containerSDK.Container, return id, err } -func (x *containersInChain) Delete(ctx context.Context, id cid.ID, pub, sig []byte, st *session.Container) error { +func (x *containersInChain) Delete(ctx context.Context, id cid.ID, pub, sig []byte, st []byte) error { var prm cntClient.DeletePrm prm.SetCID(id[:]) prm.SetSignature(sig) prm.SetKey(pub) - if st != nil { - prm.SetToken(st.Marshal()) - } + prm.SetToken(st) err := x.cCli.Delete(ctx, prm) if errors.Is(err, client.ErrTxAwaitTimeout) { @@ -577,14 +572,12 @@ func (x *containersInChain) Delete(ctx context.Context, id cid.ID, pub, sig []by return err } -func (x *containersInChain) PutEACL(ctx context.Context, eACL eacl.Table, pub, sig []byte, st *session.Container) error { +func (x *containersInChain) PutEACL(ctx context.Context, eACL eacl.Table, pub, sig []byte, st []byte) error { var prm cntClient.PutEACLPrm prm.SetTable(eACL.Marshal()) prm.SetKey(pub) prm.SetSignature(sig) - if st != nil { - prm.SetToken(st.Marshal()) - } + prm.SetToken(st) err := x.cCli.PutEACL(ctx, prm) if errors.Is(err, client.ErrTxAwaitTimeout) { diff --git a/cmd/neofs-node/morph.go b/cmd/neofs-node/morph.go index 1f236d6f24..a24a20e2fd 100644 --- a/cmd/neofs-node/morph.go +++ b/cmd/neofs-node/morph.go @@ -91,6 +91,7 @@ func listenMorphNotifications(c *cfg) { c.cfgMorph.epochTimers.UpdateTime(block.Timestamp) c.networkState.block.Store(block.Index) + c.chainTime.Set(block.Timestamp) err = c.persistate.SetUInt32(persistateFSChainLastBlockKey, block.Index) if err != nil { diff --git a/cmd/neofs-node/object.go b/cmd/neofs-node/object.go index aec78b3b30..66f5230251 100644 --- a/cmd/neofs-node/object.go +++ b/cmd/neofs-node/object.go @@ -17,6 +17,7 @@ import ( coreclient "github.com/nspcc-dev/neofs-node/pkg/core/client" containercore "github.com/nspcc-dev/neofs-node/pkg/core/container" "github.com/nspcc-dev/neofs-node/pkg/core/netmap" + "github.com/nspcc-dev/neofs-node/pkg/core/nns" objectcore "github.com/nspcc-dev/neofs-node/pkg/core/object" "github.com/nspcc-dev/neofs-node/pkg/local_object_storage/engine" morphClient "github.com/nspcc-dev/neofs-node/pkg/morph/client" @@ -49,6 +50,7 @@ import ( protoobject "github.com/nspcc-dev/neofs-sdk-go/proto/object" protosession "github.com/nspcc-dev/neofs-sdk-go/proto/session" apireputation "github.com/nspcc-dev/neofs-sdk-go/reputation" + sessionv2 "github.com/nspcc-dev/neofs-sdk-go/session/v2" "github.com/nspcc-dev/neofs-sdk-go/user" "github.com/nspcc-dev/neofs-sdk-go/version" "go.uber.org/zap" @@ -217,11 +219,14 @@ func initObjectService(c *cfg) { c.workers = append(c.workers, c.policer) + nnsResolver := nns.NewResolver(c.cli) + sGet := getsvc.New(c, getsvc.WithLogger(c.log), getsvc.WithLocalStorageEngine(ls), getsvc.WithClientConstructor(coreConstructor), getsvc.WithKeyStorage(keyStorage), + getsvc.WithNNSResolver(nnsResolver), ) *c.cfgObject.getSvc = *sGet // need smth better @@ -235,6 +240,7 @@ func initObjectService(c *cfg) { searchsvc.WithLocalStorageEngine(ls), searchsvc.WithClientConstructor(coreConstructor), searchsvc.WithKeyStorage(keyStorage), + searchsvc.WithNNSResolver(nnsResolver), ) mNumber, err := c.cli.MagicNumber() @@ -256,6 +262,7 @@ func initObjectService(c *cfg) { putsvc.WithLogger(c.log), putsvc.WithSplitChainVerifier(split.NewVerifier(sGet)), putsvc.WithTombstoneVerifier(tombstone.NewVerifier(os)), + putsvc.WithNNSResolver(nnsResolver), ) sDelete := deletesvc.New( @@ -268,6 +275,7 @@ func initObjectService(c *cfg) { cfg: c, }), deletesvc.WithKeyStorage(keyStorage), + deletesvc.WithNNSResolver(nnsResolver), ) objSvc := &objectSvc{ @@ -294,6 +302,7 @@ func initObjectService(c *cfg) { netmapContract: c.nCli, }), v2.WithContainerSource(c.cnrSrc), + v2.WithTimeProvider(&c.chainTime), ) addNewEpochAsyncNotificationHandler(c, func(event.Event) { aclSvc.ResetTokenCheckCache() @@ -625,6 +634,14 @@ func (x storageForObjectService) GetSessionPrivateKey(usr user.ID, uid uuid.UUID return *k, nil } +func (x storageForObjectService) GetSessionV2PrivateKey(issuer user.ID, subjects []sessionv2.Target) (ecdsa.PrivateKey, error) { + k, err := x.keys.GetKeyBySubjects(issuer, subjects) + if err != nil { + return ecdsa.PrivateKey{}, err + } + return *k, nil +} + type objectSource struct { get *getsvc.Service signer neofscrypto.Signer @@ -730,10 +747,18 @@ func (n netmapSourceWithNodes) GetEpochBlock(epoch uint64) (uint32, error) { return n.netmapContract.GetEpochBlock(epoch) } +func (n netmapSourceWithNodes) GetEpochBlockByTime(t uint32) (uint32, error) { + return n.netmapContract.GetEpochBlockByTime(t) +} + func (c *cfg) GetEpochBlock(epoch uint64) (uint32, error) { return c.nCli.GetEpochBlock(epoch) } +func (c *cfg) GetEpochBlockByTime(t uint32) (uint32, error) { + return c.nCli.GetEpochBlockByTime(t) +} + // GetContainerNodes reads storage policy of the referenced container from the // underlying container storage, reads current network map from the underlying // storage, applies the storage policy to it, gathers storage nodes matching the diff --git a/cmd/neofs-node/session.go b/cmd/neofs-node/session.go index 3cb774e521..68d1243e26 100644 --- a/cmd/neofs-node/session.go +++ b/cmd/neofs-node/session.go @@ -6,12 +6,14 @@ import ( sessionSvc "github.com/nspcc-dev/neofs-node/pkg/services/session" "github.com/nspcc-dev/neofs-node/pkg/util/state/session" protosession "github.com/nspcc-dev/neofs-sdk-go/proto/session" + sessionv2 "github.com/nspcc-dev/neofs-sdk-go/session/v2" "github.com/nspcc-dev/neofs-sdk-go/user" ) type sessionStorage interface { sessionSvc.KeyStorage GetToken(ownerID user.ID, tokenID []byte) *session.PrivateToken + FindTokenBySubjects(owner user.ID, subjects []sessionv2.Target) *session.PrivateToken RemoveOldTokens(epoch uint64) Close() error diff --git a/docs/cli-commands/neofs-cli_object_delete.md b/docs/cli-commands/neofs-cli_object_delete.md index 5005381651..1b55e060a0 100644 --- a/docs/cli-commands/neofs-cli_object_delete.md +++ b/docs/cli-commands/neofs-cli_object_delete.md @@ -13,20 +13,22 @@ neofs-cli object delete [flags] ### Options ``` - --address string Address of wallet account - --bearer string File with signed JSON or binary encoded bearer token - --binary Deserialize object structure from given file. - --cid string Container ID. - --file string File with object payload - -g, --generate-key Generate new private key - -h, --help help for delete - --oid strings Object ID. - -r, --rpc-endpoint string Remote node address (as 'multiaddr' or ':') - --session string Filepath to a JSON- or binary-encoded token of the object DELETE session - -t, --timeout duration Timeout for the operation (default 15s) - --ttl uint32 TTL value in request meta header (default 2) - -w, --wallet string Path to the wallet - -x, --xhdr strings Request X-Headers in form of Key=Value + --address string Address of wallet account + --bearer string File with signed JSON or binary encoded bearer token + --binary Deserialize object structure from given file. + --cid string Container ID. + --file string File with object payload + -g, --generate-key Generate new private key + -h, --help help for delete + --oid strings Object ID. + -r, --rpc-endpoint string Remote node address (as 'multiaddr' or ':') + --session string Filepath to a JSON- or binary-encoded token of the object DELETE session + --session-subjects strings Session subject user IDs (optional, defaults to current node) + --session-subjects-nns strings Session subject NNS names (optional, defaults to current node) + -t, --timeout duration Timeout for the operation (default 15s) + --ttl uint32 TTL value in request meta header (default 2) + -w, --wallet string Path to the wallet + -x, --xhdr strings Request X-Headers in form of Key=Value ``` ### Options inherited from parent commands diff --git a/docs/cli-commands/neofs-cli_object_lock.md b/docs/cli-commands/neofs-cli_object_lock.md index 95dfac835b..9dc791ed62 100644 --- a/docs/cli-commands/neofs-cli_object_lock.md +++ b/docs/cli-commands/neofs-cli_object_lock.md @@ -13,20 +13,22 @@ neofs-cli object lock [flags] ### Options ``` - --address string Address of wallet account - --bearer string File with signed JSON or binary encoded bearer token - --cid string Container ID. - -e, --expire-at uint The last active epoch for the lock - -g, --generate-key Generate new private key - -h, --help help for lock - --lifetime uint Lock lifetime - --oid strings Object ID. - -r, --rpc-endpoint string Remote node address (as 'multiaddr' or ':') - --session string Filepath to a JSON- or binary-encoded token of the object PUT session - -t, --timeout duration Timeout for the operation (default 15s) - --ttl uint32 TTL value in request meta header (default 2) - -w, --wallet string Path to the wallet - -x, --xhdr strings Request X-Headers in form of Key=Value + --address string Address of wallet account + --bearer string File with signed JSON or binary encoded bearer token + --cid string Container ID. + -e, --expire-at uint The last active epoch for the lock + -g, --generate-key Generate new private key + -h, --help help for lock + --lifetime uint Lock lifetime + --oid strings Object ID. + -r, --rpc-endpoint string Remote node address (as 'multiaddr' or ':') + --session string Filepath to a JSON- or binary-encoded token of the object PUT session + --session-subjects strings Session subject user IDs (optional, defaults to current node) + --session-subjects-nns strings Session subject NNS names (optional, defaults to current node) + -t, --timeout duration Timeout for the operation (default 15s) + --ttl uint32 TTL value in request meta header (default 2) + -w, --wallet string Path to the wallet + -x, --xhdr strings Request X-Headers in form of Key=Value ``` ### Options inherited from parent commands diff --git a/docs/cli-commands/neofs-cli_object_put.md b/docs/cli-commands/neofs-cli_object_put.md index 1caaa19f8f..2717cb6350 100644 --- a/docs/cli-commands/neofs-cli_object_put.md +++ b/docs/cli-commands/neofs-cli_object_put.md @@ -13,25 +13,27 @@ neofs-cli object put [flags] ### Options ``` - --address string Address of wallet account - --attributes strings User attributes in form of Key1=Value1,Key2=Value2 - --bearer string File with signed JSON or binary encoded bearer token - --binary Deserialize object structure from given file. - --cid string Container ID. - --disable-filename Do not set well-known filename attribute - --disable-timestamp Do not set well-known timestamp attribute - -e, --expire-at uint The last active epoch in the life of the object - --file string File with object payload - -g, --generate-key Generate new private key - -h, --help help for put - -l, --lifetime uint Number of epochs for object to stay valid - --no-progress Do not show progress bar - -r, --rpc-endpoint string Remote node address (as 'multiaddr' or ':') - --session string Filepath to a JSON- or binary-encoded token of the object PUT session - -t, --timeout duration Timeout for the operation (default 15s) - --ttl uint32 TTL value in request meta header (default 2) - -w, --wallet string Path to the wallet - -x, --xhdr strings Request X-Headers in form of Key=Value + --address string Address of wallet account + --attributes strings User attributes in form of Key1=Value1,Key2=Value2 + --bearer string File with signed JSON or binary encoded bearer token + --binary Deserialize object structure from given file. + --cid string Container ID. + --disable-filename Do not set well-known filename attribute + --disable-timestamp Do not set well-known timestamp attribute + -e, --expire-at uint The last active epoch in the life of the object + --file string File with object payload + -g, --generate-key Generate new private key + -h, --help help for put + -l, --lifetime uint Number of epochs for object to stay valid + --no-progress Do not show progress bar + -r, --rpc-endpoint string Remote node address (as 'multiaddr' or ':') + --session string Filepath to a JSON- or binary-encoded token of the object PUT session + --session-subjects strings Session subject user IDs (optional, defaults to current node) + --session-subjects-nns strings Session subject NNS names (optional, defaults to current node) + -t, --timeout duration Timeout for the operation (default 15s) + --ttl uint32 TTL value in request meta header (default 2) + -w, --wallet string Path to the wallet + -x, --xhdr strings Request X-Headers in form of Key=Value ``` ### Options inherited from parent commands diff --git a/docs/cli-commands/neofs-cli_session_create-v2.md b/docs/cli-commands/neofs-cli_session_create-v2.md index 45224f7c5b..8763a3a44e 100644 --- a/docs/cli-commands/neofs-cli_session_create-v2.md +++ b/docs/cli-commands/neofs-cli_session_create-v2.md @@ -6,14 +6,14 @@ Create V2 session token Create V2 session token with subjects and multiple contexts. +V2 tokens always create a server-side key via SessionCreate RPC +and include it as the last subject in the token. + V2 tokens support: - Multiple subjects (accounts authorized to use the token) - Multiple contexts (container + object operations) -- No server-side session key storage (no SessionCreate RPC needed) - Token delegation chains via --origin flag -IMPORTANT: Contexts and verbs must be specified in sorted order for proper token validation. - Context format: containerID:verbs - containerID: Container ID or "0" for wildcard (any container) - verbs: Comma-separated list of operations (e.g., DELETE,GET,HEAD,PUT,SEARCH) @@ -21,6 +21,7 @@ Context format: containerID:verbs Example usage: neofs-cli session create-v2 \ --wallet wallet.json \ + --rpc node.neofs.devenv:8080 \ --lifetime 10000 \ --out token.json \ --json \ @@ -41,14 +42,16 @@ neofs-cli session create-v2 [flags] ``` --address string Address of wallet account - --context stringArray Context spec (repeatable): containerID:verbs. Use '0' for wildcard container. Contexts and verbs should be sorted. + --context stringArray Context spec (repeatable): containerID:verbs. Use '0' for wildcard container. -e, --expire-at uint Expiration time in seconds for token to stay valid --final Set the final flag in the token, disallowing further delegation + -f, --force Skip token validation (use with caution) -h, --help help for create-v2 --json Output token in JSON -l, --lifetime uint Duration in seconds for token to stay valid (default 36000) --origin string Path to origin token file for token delegation chain --out string File to write session token to + -r, --rpc-endpoint string Remote node address (as 'multiaddr' or ':') --subject stringArray Subject user IDs (can be specified multiple times) --subject-nns stringArray Subject NNS names (can be specified multiple times) -w, --wallet string Path to the wallet diff --git a/go.mod b/go.mod index 844f2ae557..46f96bf5c7 100644 --- a/go.mod +++ b/go.mod @@ -22,7 +22,7 @@ require ( github.com/nspcc-dev/neo-go v0.116.0 github.com/nspcc-dev/neofs-api-go/v2 v2.14.1-0.20240827150555-5ce597aa14ea github.com/nspcc-dev/neofs-contract v0.26.0 - github.com/nspcc-dev/neofs-sdk-go v1.0.0-rc.16.0.20260126114348-87674e46ef14 + github.com/nspcc-dev/neofs-sdk-go v1.0.0-rc.16.0.20260127152410-12dbac67e506 github.com/nspcc-dev/tzhash v1.8.3 github.com/panjf2000/ants/v2 v2.11.3 github.com/prometheus/client_golang v1.23.2 diff --git a/go.sum b/go.sum index dee30a8fdb..b4eb393468 100644 --- a/go.sum +++ b/go.sum @@ -199,8 +199,8 @@ github.com/nspcc-dev/neofs-api-go/v2 v2.14.1-0.20240827150555-5ce597aa14ea h1:mK github.com/nspcc-dev/neofs-api-go/v2 v2.14.1-0.20240827150555-5ce597aa14ea/go.mod h1:YzhD4EZmC9Z/PNyd7ysC7WXgIgURc9uCG1UWDeV027Y= github.com/nspcc-dev/neofs-contract v0.26.0 h1:HoYsJN3shTB8uHZn/FP1Ce2N6mnG5lpDKQXvEvzsAQA= github.com/nspcc-dev/neofs-contract v0.26.0/go.mod h1:pevVF9OWdEN5bweKxOu6ryZv9muCEtS1ppzYM4RfBIo= -github.com/nspcc-dev/neofs-sdk-go v1.0.0-rc.16.0.20260126114348-87674e46ef14 h1:Xz3JVv7wyMlJfr2Y0tpcoJNYasLBTuM1iMBQwnXAbuY= -github.com/nspcc-dev/neofs-sdk-go v1.0.0-rc.16.0.20260126114348-87674e46ef14/go.mod h1:IrM1JG/klBtecZEApIf8USgLonNcarv32R1O0dj4kQI= +github.com/nspcc-dev/neofs-sdk-go v1.0.0-rc.16.0.20260127152410-12dbac67e506 h1:aQPJ2OnJyVsYYom3nXYt1TlP4zcLa+2ypMXEGC5wkIY= +github.com/nspcc-dev/neofs-sdk-go v1.0.0-rc.16.0.20260127152410-12dbac67e506/go.mod h1:IrM1JG/klBtecZEApIf8USgLonNcarv32R1O0dj4kQI= github.com/nspcc-dev/rfc6979 v0.2.4 h1:NBgsdCjhLpEPJZqmC9rciMZDcSY297po2smeaRjw57k= github.com/nspcc-dev/rfc6979 v0.2.4/go.mod h1:86ylDw6Kss+P6v4QAJqo1Sp3mC0/Zr9G97xSjQ9TuFg= github.com/nspcc-dev/tzhash v1.8.3 h1:EWJMOL/ppdqNBvkKjHECljusopcsNu4i4kH8KctTv10= diff --git a/internal/chaintime/chaintime.go b/internal/chaintime/chaintime.go new file mode 100644 index 0000000000..7ae624da82 --- /dev/null +++ b/internal/chaintime/chaintime.go @@ -0,0 +1,25 @@ +package chaintime + +import ( + "sync/atomic" + "time" +) + +// AtomicChainTimeProvider is a simple provider backed by atomic value. +// It stores the latest chain timestamp (milliseconds precision) and returns it as [time.Time]. +type AtomicChainTimeProvider struct { + ms atomic.Uint64 +} + +// Set updates current chain time from header timestamp. +func (p *AtomicChainTimeProvider) Set(ms uint64) { p.ms.Store(ms) } + +// Now returns last set time as [time.Time] in milliseconds precision. +// If no time was set yet, it returns local system time. +func (p *AtomicChainTimeProvider) Now() time.Time { + v := p.ms.Load() + if v == 0 { + return time.Now() + } + return time.UnixMilli(int64(v)) +} diff --git a/internal/crypto/n3.go b/internal/crypto/n3.go index f84ba998cd..06ba7fce44 100644 --- a/internal/crypto/n3.go +++ b/internal/crypto/n3.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "slices" + "time" "github.com/nspcc-dev/neo-go/pkg/core/block" "github.com/nspcc-dev/neo-go/pkg/core/transaction" @@ -24,6 +25,9 @@ type HistoricN3ScriptRunner interface { N3ScriptRunner // GetEpochBlock returns FS chain height when given NeoFS epoch was ticked. GetEpochBlock(epoch uint64) (uint32, error) + // GetEpochBlockByTime returns FS chain height of block index when the latest epoch that + // started not later than the provided block time came. + GetEpochBlockByTime(t uint32) (uint32, error) } func verifyN3ScriptsNow(nsr N3ScriptRunner, acc util.Uint160, invocScript, verifScript []byte, hashData func() [sha256.Size]byte) error { @@ -38,6 +42,14 @@ func verifyN3ScriptsAtEpoch(fsChain HistoricN3ScriptRunner, epoch uint64, acc ut return verifyN3Scripts(fsChain, height, acc, invocScript, verifScript, hashData()) } +func verifyN3ScriptsAtTime(fsChain HistoricN3ScriptRunner, t time.Time, acc util.Uint160, invocScript, verifScript []byte, hashData func() [sha256.Size]byte) error { + height, err := fsChain.GetEpochBlockByTime(uint32(t.UnixMilli())) + if err != nil { + return fmt.Errorf("get FS chain height at time %s: %w", t, err) + } + return verifyN3Scripts(fsChain, height, acc, invocScript, verifScript, hashData()) +} + func verifyN3Scripts(nsr N3ScriptRunner, height uint32, acc util.Uint160, invocScript, verifScript []byte, dataHash [sha256.Size]byte) error { fullScript := slices.Concat(invocScript, verifScript) signer := transaction.Signer{ diff --git a/internal/crypto/object.go b/internal/crypto/object.go index 9fe5680331..717fa1ea85 100644 --- a/internal/crypto/object.go +++ b/internal/crypto/object.go @@ -9,11 +9,12 @@ import ( neofscrypto "github.com/nspcc-dev/neofs-sdk-go/crypto" neofsecdsa "github.com/nspcc-dev/neofs-sdk-go/crypto/ecdsa" "github.com/nspcc-dev/neofs-sdk-go/object" + "github.com/nspcc-dev/neofs-sdk-go/session/v2" "github.com/nspcc-dev/neofs-sdk-go/user" ) // AuthenticateObject checks whether obj is signed correctly by its owner. -func AuthenticateObject(obj object.Object, fsChain HistoricN3ScriptRunner, ecPart bool) error { +func AuthenticateObject(obj object.Object, fsChain HistoricN3ScriptRunner, ecPart bool, resolver session.NNSResolver) error { sig := obj.Signature() if sig == nil { return errMissingSignature @@ -22,6 +23,7 @@ func AuthenticateObject(obj object.Object, fsChain HistoricN3ScriptRunner, ecPar var ecdsaPub *ecdsa.PublicKey scheme := sig.Scheme() sessionToken := obj.SessionToken() + sessionTokenV2 := obj.SessionTokenV2() switch scheme { default: return fmt.Errorf("unsupported scheme %v", scheme) @@ -49,6 +51,25 @@ func AuthenticateObject(obj object.Object, fsChain HistoricN3ScriptRunner, ecPar return errors.New("different object owner and session issuer") } } + if sessionTokenV2 != nil { + // NOTE: update this place for non-ECDSA schemes + if ecdsaPub != nil { + nodeUser := user.NewFromECDSAPublicKey(*ecdsaPub) + ok, err := sessionTokenV2.AssertAuthority(nodeUser, resolver) + if err != nil { + return fmt.Errorf("assert session v2 authority: %w", err) + } + if !ok { // same format for all ECDSA schemes + return errors.New("session v2 token is not for object's signer") + } + } + if err := AuthenticateTokenV2(sessionTokenV2, fsChain); err != nil { + return fmt.Errorf("session token v2: %w", err) + } + if sessionTokenV2.OriginalIssuer() != obj.Owner() { + return errors.New("different object owner and session v2 issuer") + } + } switch scheme { default: @@ -57,7 +78,7 @@ func AuthenticateObject(obj object.Object, fsChain HistoricN3ScriptRunner, ecPar if !verifyECDSAFns[scheme](*ecdsaPub, sig.Value(), obj.GetID().Marshal()) { return schemeError(scheme, errSignatureMismatch) } - if sessionToken == nil && !ecPart && user.NewFromECDSAPublicKey(*ecdsaPub) != obj.Owner() { + if sessionToken == nil && sessionTokenV2 == nil && !ecPart && user.NewFromECDSAPublicKey(*ecdsaPub) != obj.Owner() { return errors.New("owner mismatches signature") } case neofscrypto.N3: diff --git a/internal/crypto/object_test.go b/internal/crypto/object_test.go index a9e4ce436c..d63396dc11 100644 --- a/internal/crypto/object_test.go +++ b/internal/crypto/object_test.go @@ -20,14 +20,14 @@ import ( func TestAuthenticateObject(t *testing.T) { t.Run("without signature", func(t *testing.T) { obj := getUnsignedObject() - require.EqualError(t, icrypto.AuthenticateObject(obj, nil, false), "missing signature") + require.EqualError(t, icrypto.AuthenticateObject(obj, nil, false, nil), "missing signature") }) t.Run("unsupported scheme", func(t *testing.T) { obj := objectECDSASHA512 sig := *obj.Signature() sig.SetScheme(4) obj.SetSignature(&sig) - require.EqualError(t, icrypto.AuthenticateObject(obj, nil, false), "unsupported scheme 4") + require.EqualError(t, icrypto.AuthenticateObject(obj, nil, false, nil), "unsupported scheme 4") }) t.Run("invalid public key", func(t *testing.T) { for _, tc := range []struct { @@ -48,7 +48,7 @@ func TestAuthenticateObject(t *testing.T) { sig := *obj.Signature() sig.SetPublicKeyBytes(tc.changePub(sig.PublicKeyBytes())) obj.SetSignature(&sig) - err := icrypto.AuthenticateObject(obj, nil, false) + err := icrypto.AuthenticateObject(obj, nil, false, nil) require.EqualError(t, err, "scheme ECDSA_SHA512: decode public key: "+tc.err) }) } @@ -70,7 +70,7 @@ func TestAuthenticateObject(t *testing.T) { cp[i]++ sig.SetValue(cp) tc.obj.SetSignature(&sig) - err := icrypto.AuthenticateObject(tc.obj, nil, false) + err := icrypto.AuthenticateObject(tc.obj, nil, false, nil) require.EqualError(t, err, fmt.Sprintf("scheme %s: signature mismatch", tc.scheme)) } }) @@ -86,7 +86,7 @@ func TestAuthenticateObject(t *testing.T) { {scheme: neofscrypto.ECDSA_WALLETCONNECT, object: wrongOwnerObjectECDSAWalletConnect}, } { t.Run(tc.scheme.String(), func(t *testing.T) { - require.EqualError(t, icrypto.AuthenticateObject(tc.object, nil, false), "owner mismatches signature") + require.EqualError(t, icrypto.AuthenticateObject(tc.object, nil, false, nil), "owner mismatches signature") }) } }) @@ -101,7 +101,7 @@ func TestAuthenticateObject(t *testing.T) { {scheme: neofscrypto.ECDSA_WALLETCONNECT, object: objectWithNoIssuerSessionECDSAWalletConnect}, } { t.Run(tc.scheme.String(), func(t *testing.T) { - require.EqualError(t, icrypto.AuthenticateObject(tc.object, nil, false), "session token: missing issuer") + require.EqualError(t, icrypto.AuthenticateObject(tc.object, nil, false, nil), "session token: missing issuer") }) } }) @@ -115,7 +115,7 @@ func TestAuthenticateObject(t *testing.T) { {scheme: neofscrypto.ECDSA_WALLETCONNECT, object: objectWithWrongIssuerSessionECDSAWalletConnect}, } { t.Run(tc.scheme.String(), func(t *testing.T) { - require.EqualError(t, icrypto.AuthenticateObject(tc.object, nil, false), "session token: issuer mismatches signature") + require.EqualError(t, icrypto.AuthenticateObject(tc.object, nil, false, nil), "session token: issuer mismatches signature") }) } }) @@ -129,7 +129,7 @@ func TestAuthenticateObject(t *testing.T) { {scheme: neofscrypto.ECDSA_WALLETCONNECT, object: objectWithWrongSessionSubjectECDSAWalletConnect}, } { t.Run(tc.scheme.String(), func(t *testing.T) { - require.EqualError(t, icrypto.AuthenticateObject(tc.object, nil, false), "session token is not for object's signer") + require.EqualError(t, icrypto.AuthenticateObject(tc.object, nil, false, nil), "session token is not for object's signer") }) } }) @@ -143,7 +143,7 @@ func TestAuthenticateObject(t *testing.T) { {scheme: neofscrypto.ECDSA_WALLETCONNECT, object: objectWithWrongOwnerSessionECDSAWalletConnect}, } { t.Run(tc.scheme.String(), func(t *testing.T) { - require.EqualError(t, icrypto.AuthenticateObject(tc.object, nil, false), "different object owner and session issuer") + require.EqualError(t, icrypto.AuthenticateObject(tc.object, nil, false, nil), "different object owner and session issuer") }) } }) @@ -160,7 +160,7 @@ func TestAuthenticateObject(t *testing.T) { {name: neofscrypto.ECDSA_WALLETCONNECT.String() + " with session", object: objectWithSessionECDSAWalletConnect}, } { t.Run(tc.name, func(t *testing.T) { - require.NoError(t, icrypto.AuthenticateObject(tc.object, nil, false)) + require.NoError(t, icrypto.AuthenticateObject(tc.object, nil, false, nil)) }) } } diff --git a/internal/crypto/tokens.go b/internal/crypto/tokens.go index 723771232c..76a882b3a0 100644 --- a/internal/crypto/tokens.go +++ b/internal/crypto/tokens.go @@ -4,6 +4,7 @@ import ( "crypto/sha256" "errors" "fmt" + "time" neofscrypto "github.com/nspcc-dev/neofs-sdk-go/crypto" "github.com/nspcc-dev/neofs-sdk-go/user" @@ -55,3 +56,48 @@ func AuthenticateToken[T interface { } return nil } + +// AuthenticateTokenV2 checks whether a V2 token is signed correctly by its issuer. +// +// If signature scheme is unsupported, [ErrUnsupportedScheme] returns. It also +// returns when [neofscrypto.N3] scheme is used but fsChain is not provided. +func AuthenticateTokenV2[T interface { + SignedData() []byte + Signature() (neofscrypto.Signature, bool) + Issuer() user.ID + Iat() time.Time +}](token T, fsChain HistoricN3ScriptRunner) error { + issuer := token.Issuer() + if issuer.IsZero() { + return errors.New("missing issuer") + } + sig, ok := token.Signature() + if !ok { + return errMissingSignature + } + switch scheme := sig.Scheme(); scheme { + default: + return ErrUnsupportedScheme(scheme) + case neofscrypto.ECDSA_SHA512, neofscrypto.ECDSA_DETERMINISTIC_SHA256, neofscrypto.ECDSA_WALLETCONNECT: + pub, err := decodeECDSAPublicKey(sig.PublicKeyBytes()) + if err != nil { + return schemeError(scheme, fmt.Errorf("decode public key: %w", err)) + } + if !verifyECDSAFns[scheme](*pub, sig.Value(), token.SignedData()) { + return schemeError(scheme, errSignatureMismatch) + } + if user.NewFromECDSAPublicKey(*pub) != issuer { + return errIssuerMismatch + } + case neofscrypto.N3: + if fsChain == nil { + return ErrUnsupportedScheme(neofscrypto.N3) + } + if err := verifyN3ScriptsAtTime(fsChain, token.Iat(), issuer.ScriptHash(), sig.Value(), sig.PublicKeyBytes(), func() [sha256.Size]byte { + return sha256.Sum256(token.SignedData()) + }); err != nil { + return err + } + } + return nil +} diff --git a/pkg/core/nns/resolver.go b/pkg/core/nns/resolver.go new file mode 100644 index 0000000000..dcf4426d62 --- /dev/null +++ b/pkg/core/nns/resolver.go @@ -0,0 +1,51 @@ +package nns + +import ( + "fmt" + + lru "github.com/hashicorp/golang-lru/v2" + "github.com/nspcc-dev/neo-go/pkg/util" + "github.com/nspcc-dev/neofs-sdk-go/user" +) + +// FSChain provides base non-contract functionality of the FS chain required for +// NNS name resolution. +type FSChain interface { + HasUserInNNS(name string, addr util.Uint160) (bool, error) +} + +// Resolver is an NNS name resolver. +type Resolver struct { + fsChain FSChain + nnsCache *lru.Cache[string, bool] +} + +// NewResolver creates a new NNS Resolver with given FSChain. +func NewResolver(fs FSChain) *Resolver { + cache, err := lru.New[string, bool](1000) + if err != nil { + panic(fmt.Errorf("unexpected error in lru.New for nns resolver: %w", err)) + } + return &Resolver{fsChain: fs, nnsCache: cache} +} + +// HasUser checks whether the user with given userID is registered +// in NNS under the specified name. +func (r Resolver) HasUser(name string, userID user.ID) (bool, error) { + cacheKey := name + string(userID[:]) + hasUser, ok := r.nnsCache.Get(cacheKey) + if ok { + return hasUser, nil + } + hasUser, err := r.fsChain.HasUserInNNS(name, userID.ScriptHash()) + if err != nil { + return false, err + } + r.nnsCache.Add(cacheKey, hasUser) + return hasUser, nil +} + +// PurgeCache clears the internal cache of resolved names. +func (r Resolver) PurgeCache() { + r.nnsCache.Purge() +} diff --git a/pkg/core/object/fmt.go b/pkg/core/object/fmt.go index d29aff2437..ecbbf506c9 100644 --- a/pkg/core/object/fmt.go +++ b/pkg/core/object/fmt.go @@ -10,13 +10,16 @@ import ( "github.com/nspcc-dev/neo-go/pkg/core/transaction" "github.com/nspcc-dev/neo-go/pkg/neorpc/result" "github.com/nspcc-dev/neo-go/pkg/smartcontract/trigger" + "github.com/nspcc-dev/neo-go/pkg/util" icrypto "github.com/nspcc-dev/neofs-node/internal/crypto" "github.com/nspcc-dev/neofs-node/pkg/core/container" "github.com/nspcc-dev/neofs-node/pkg/core/netmap" + "github.com/nspcc-dev/neofs-node/pkg/core/nns" "github.com/nspcc-dev/neofs-node/pkg/core/version" cid "github.com/nspcc-dev/neofs-sdk-go/container/id" "github.com/nspcc-dev/neofs-sdk-go/object" oid "github.com/nspcc-dev/neofs-sdk-go/object/id" + "github.com/nspcc-dev/neofs-sdk-go/session/v2" ) // FormatValidator represents an object format validator. @@ -25,6 +28,7 @@ type FormatValidator struct { fsChain FSChain netmapContract NetmapContract containers container.Source + resolver session.NNSResolver } // FormatValidatorOption represents a FormatValidator constructor option. @@ -85,6 +89,7 @@ type TombVerifier interface { // [FormatValidator] to work. type FSChain interface { InvokeContainedScript(tx *transaction.Transaction, header *block.Header, _ *trigger.Type, _ *bool) (*result.Invoke, error) + HasUserInNNS(name string, addr util.Uint160) (bool, error) } // NetmapContract represents Netmap contract deployed in the FS chain required @@ -92,11 +97,15 @@ type FSChain interface { type NetmapContract interface { // GetEpochBlock returns FS chain height when given NeoFS epoch was ticked. GetEpochBlock(epoch uint64) (uint32, error) + // GetEpochBlockByTime returns FS chain height of block index when the latest epoch that + // started not later than the provided block time came. + GetEpochBlockByTime(t uint32) (uint32, error) } type historicN3ScriptRunner struct { FSChain NetmapContract + session.NNSResolver } var errNilObject = errors.New("object is nil") @@ -122,6 +131,7 @@ func NewFormatValidator(fsChain FSChain, netmapContract NetmapContract, containe fsChain: fsChain, netmapContract: netmapContract, containers: containers, + resolver: nns.NewResolver(fsChain), } } @@ -189,6 +199,10 @@ func (v *FormatValidator) validate(obj *object.Object, unprepared, isParent bool return err } + if obj.SessionToken() != nil && obj.SessionTokenV2() != nil { + return errors.New("both V1 and V2 session tokens are set") + } + _, firstSet := obj.FirstID() splitID := obj.SplitID() par := obj.Parent() @@ -238,7 +252,7 @@ func (v *FormatValidator) validate(obj *object.Object, unprepared, isParent bool if err := icrypto.AuthenticateObject(*obj, historicN3ScriptRunner{ FSChain: v.fsChain, NetmapContract: v.netmapContract, - }, isEC); err != nil { + }, isEC, v.resolver); err != nil { return fmt.Errorf("authenticate: %w", err) } } diff --git a/pkg/innerring/innerring.go b/pkg/innerring/innerring.go index 541f24304f..a9229f39c0 100644 --- a/pkg/innerring/innerring.go +++ b/pkg/innerring/innerring.go @@ -13,6 +13,7 @@ import ( "github.com/nspcc-dev/neo-go/pkg/rpcclient/invoker" "github.com/nspcc-dev/neo-go/pkg/wallet" "github.com/nspcc-dev/neofs-contract/deploy" + "github.com/nspcc-dev/neofs-node/internal/chaintime" "github.com/nspcc-dev/neofs-node/misc" "github.com/nspcc-dev/neofs-node/pkg/innerring/config" "github.com/nspcc-dev/neofs-node/pkg/innerring/internal/blockchain" @@ -87,6 +88,7 @@ type ( contracts *contracts predefinedValidators keys.PublicKeys withoutMainNet bool + chainTime chaintime.AtomicChainTimeProvider // runtime processors netmapProcessor *netmap.Processor @@ -202,6 +204,7 @@ func (s *Server) Start(ctx context.Context, intError chan<- error) (err error) { ) s.epochTimers.UpdateTime(b.Timestamp) + s.chainTime.Set(b.Timestamp) err = s.persistate.SetUInt32(persistateFSChainLastBlockKey, b.Index) if err != nil { @@ -786,6 +789,7 @@ func New(ctx context.Context, log *zap.Logger, cfg *config.Config, errChan chan< NetworkState: server.netmapClient, MetaEnabled: cfg.Experimental.ChainMetaData, AllowEC: cfg.Experimental.AllowEC, + ChainTime: &server.chainTime, }) if err != nil { return nil, err diff --git a/pkg/innerring/processors/container/common.go b/pkg/innerring/processors/container/common.go index 9a245f6673..1456b373b4 100644 --- a/pkg/innerring/processors/container/common.go +++ b/pkg/innerring/processors/container/common.go @@ -3,6 +3,7 @@ package container import ( "errors" "fmt" + "time" icrypto "github.com/nspcc-dev/neofs-node/internal/crypto" "github.com/nspcc-dev/neofs-node/pkg/morph/client" @@ -44,7 +45,7 @@ type historicN3ScriptRunner struct { // - operation data is witnessed by container owner or trusted party // // (*) includes: -// - session token decodes correctly +// - session token decodes correctly (V2 or V1) // - session issued and witnessed by the container owner // - session context corresponds to the container and verb in v // - session is "alive" @@ -55,10 +56,10 @@ func (cp *Processor) verifySignature(v signatureVerificationData) error { var tokV2 sessionv2.Token err = tokV2.Unmarshal(v.binTokenSession) if err == nil { - // TODO - return errors.New("sessions V2 are not supported yet") + return cp.verifySessionV2(tokV2, v) } + // Fall back to V1 token var tok session.Container err = tok.Unmarshal(v.binTokenSession) @@ -112,3 +113,34 @@ func (cp *Processor) checkTokenLifetime(token session.Container) error { return nil } + +// verifySessionV2 validates V2 session token for container operations. +func (cp *Processor) verifySessionV2(tok sessionv2.Token, v signatureVerificationData) error { + if err := tok.Validate(cp.resolver); err != nil { + return fmt.Errorf("invalid V2 session token: %w", err) + } + + if err := icrypto.AuthenticateTokenV2(&tok, historicN3ScriptRunner{ + Client: cp.cnrClient.Morph(), + NetworkState: cp.netState, + }); err != nil { + return fmt.Errorf("authenticate session token: %w", err) + } + + if v.idContainerSet { + if !tok.AssertContainer(v.verbV2, v.idContainer) { + return errWrongCID + } + } + + if tok.OriginalIssuer() != v.ownerContainer { + return errors.New("owner differs with original token issuer") + } + + currentTime := cp.chainTime.Now().Round(time.Second) + if !tok.ValidAt(currentTime) { + return fmt.Errorf("v2 token is not valid at %s", currentTime) + } + + return nil +} diff --git a/pkg/innerring/processors/container/process_container.go b/pkg/innerring/processors/container/process_container.go index 2180d131ca..46d4783948 100644 --- a/pkg/innerring/processors/container/process_container.go +++ b/pkg/innerring/processors/container/process_container.go @@ -119,6 +119,7 @@ func (cp *Processor) checkPutContainer(cnr containerSDK.Container, cnrBytes, ses err := cp.verifySignature(signatureVerificationData{ ownerContainer: cnr.Owner(), verb: session.VerbContainerPut, + verbV2: sessionv2.VerbContainerPut, binTokenSession: sessionToken, verifScript: verifScript, invocScript: invocScript, @@ -231,6 +232,7 @@ func (cp *Processor) checkDeleteContainer(req containerEvent.RemoveContainerRequ err = cp.verifySignature(signatureVerificationData{ ownerContainer: cnr.Owner(), verb: session.VerbContainerDelete, + verbV2: sessionv2.VerbContainerDelete, idContainerSet: true, idContainer: idCnr, verifScript: req.VerificationScript, diff --git a/pkg/innerring/processors/container/process_eacl.go b/pkg/innerring/processors/container/process_eacl.go index 673d7f851c..2af48166cd 100644 --- a/pkg/innerring/processors/container/process_eacl.go +++ b/pkg/innerring/processors/container/process_eacl.go @@ -9,6 +9,7 @@ import ( "github.com/nspcc-dev/neofs-node/pkg/morph/event/container" "github.com/nspcc-dev/neofs-sdk-go/eacl" "github.com/nspcc-dev/neofs-sdk-go/session" + sessionv2 "github.com/nspcc-dev/neofs-sdk-go/session/v2" "go.uber.org/zap" ) @@ -61,6 +62,7 @@ func (cp *Processor) checkSetEACL(req container.PutContainerEACLRequest) error { err = cp.verifySignature(signatureVerificationData{ ownerContainer: cnr.Owner(), verb: session.VerbContainerSetEACL, + verbV2: sessionv2.VerbContainerSetEACL, idContainerSet: true, idContainer: idCnr, binTokenSession: req.SessionToken, diff --git a/pkg/innerring/processors/container/processor.go b/pkg/innerring/processors/container/processor.go index 38f20553a7..8c703133f3 100644 --- a/pkg/innerring/processors/container/processor.go +++ b/pkg/innerring/processors/container/processor.go @@ -8,11 +8,13 @@ import ( "github.com/nspcc-dev/neo-go/pkg/neorpc" "github.com/nspcc-dev/neo-go/pkg/vm/vmstate" + "github.com/nspcc-dev/neofs-node/pkg/core/nns" "github.com/nspcc-dev/neofs-node/pkg/morph/client/container" fschaincontracts "github.com/nspcc-dev/neofs-node/pkg/morph/contracts" "github.com/nspcc-dev/neofs-node/pkg/morph/event" containerEvent "github.com/nspcc-dev/neofs-node/pkg/morph/event/container" "github.com/nspcc-dev/neofs-sdk-go/netmap" + "github.com/nspcc-dev/neofs-sdk-go/session/v2" "github.com/panjf2000/ants/v2" "go.uber.org/zap" ) @@ -33,6 +35,8 @@ type ( netState NetworkState metaEnabled bool allowEC bool + chainTime TimeProvider + resolver session.NNSResolver } // Params of the processor constructor. @@ -44,6 +48,7 @@ type ( NetworkState NetworkState MetaEnabled bool AllowEC bool + ChainTime TimeProvider } ) @@ -69,6 +74,17 @@ type NetworkState interface { // GetEpochBlock returns FS chain height when given NeoFS epoch was ticked. GetEpochBlock(epoch uint64) (uint32, error) + + // GetEpochBlockByTime returns FS chain height of block index when the latest epoch that + // started not later than the provided block time came. + GetEpochBlockByTime(t uint32) (uint32, error) +} + +// TimeProvider supplies current FS chain time without calling the chain. +// It should be updated from block header subscriptions and return time +// based on the latest observed header timestamp. +type TimeProvider interface { + Now() time.Time } // New creates a container contract processor instance. @@ -82,6 +98,8 @@ func New(p *Params) (*Processor, error) { return nil, errors.New("ir/container: Container client is not set") case p.NetworkState == nil: return nil, errors.New("ir/container: network state is not set") + case p.ChainTime == nil: + return nil, errors.New("ir/container: chain time provider is not set") } p.Log.Debug("container worker pool", zap.Int("size", p.PoolSize)) @@ -103,6 +121,8 @@ func New(p *Params) (*Processor, error) { netState: p.NetworkState, metaEnabled: p.MetaEnabled, allowEC: p.AllowEC, + chainTime: p.ChainTime, + resolver: nns.NewResolver(p.ContainerClient.Morph()), }, nil } diff --git a/pkg/morph/client/netmap/client.go b/pkg/morph/client/netmap/client.go index 3618df9115..36e0b0b78c 100644 --- a/pkg/morph/client/netmap/client.go +++ b/pkg/morph/client/netmap/client.go @@ -24,16 +24,17 @@ type Client struct { } const ( - addNodeMethod = "addNode" - configMethod = "config" - epochMethod = "epoch" - lastEpochBlockMethod = "lastEpochBlock" - epochBlockMethod = "getEpochBlock" - innerRingListMethod = "innerRingList" - newEpochMethod = "newEpoch" - setConfigMethod = "setConfig" - updateInnerRingMethod = "updateInnerRing" - updateStateMethod = "updateState" + addNodeMethod = "addNode" + configMethod = "config" + epochMethod = "epoch" + lastEpochBlockMethod = "lastEpochBlock" + epochBlockMethod = "getEpochBlock" + epochBlockByTimeMethod = "getEpochBlockByTime" + innerRingListMethod = "innerRingList" + newEpochMethod = "newEpoch" + setConfigMethod = "setConfig" + updateInnerRingMethod = "updateInnerRing" + updateStateMethod = "updateState" configListMethod = "listConfig" ) diff --git a/pkg/morph/client/netmap/epoch.go b/pkg/morph/client/netmap/epoch.go index 5a3914fa21..197082f1f6 100644 --- a/pkg/morph/client/netmap/epoch.go +++ b/pkg/morph/client/netmap/epoch.go @@ -91,3 +91,34 @@ func (c *Client) GetEpochBlock(epoch uint64) (uint32, error) { return uint32(bn.Uint64()), nil } + +// GetEpochBlockByTime returns FS chain height of block index when the latest epoch that +// started not later than the provided block time came using 'getEpochBlockByTime' method. +func (c *Client) GetEpochBlockByTime(t uint32) (uint32, error) { + var prm client.TestInvokePrm + prm.SetMethod(epochBlockByTimeMethod) + prm.SetArgs(t) + + items, err := c.client.TestInvoke(prm) + if err != nil { + return 0, fmt.Errorf("call %s method: %w", epochBlockByTimeMethod, err) + } + + if ln := len(items); ln != 1 { + return 0, fmt.Errorf("unexpected stack item count (%s): %d", epochBlockByTimeMethod, ln) + } + + bn, err := items[0].TryInteger() + if err != nil { + return 0, fmt.Errorf("convert 1st stack item from %s method result to integer: %w", epochBlockByTimeMethod, err) + } + if !bn.IsUint64() { + return 0, fmt.Errorf("%s method result %v cannot be represented as uint64", epochBlockByTimeMethod, bn) + } + n64 := bn.Uint64() + if n64 > math.MaxUint32 { + return 0, fmt.Errorf("%s method result %v overflows uint32", epochBlockByTimeMethod, bn) + } + + return uint32(n64), nil +} diff --git a/pkg/services/container/server.go b/pkg/services/container/server.go index 5fd174014a..68331825ef 100644 --- a/pkg/services/container/server.go +++ b/pkg/services/container/server.go @@ -14,10 +14,12 @@ import ( "github.com/nspcc-dev/neo-go/pkg/core/transaction" "github.com/nspcc-dev/neo-go/pkg/neorpc/result" "github.com/nspcc-dev/neo-go/pkg/smartcontract/trigger" + neoutil "github.com/nspcc-dev/neo-go/pkg/util" icrypto "github.com/nspcc-dev/neofs-node/internal/crypto" iprotobuf "github.com/nspcc-dev/neofs-node/internal/protobuf" islices "github.com/nspcc-dev/neofs-node/internal/slices" "github.com/nspcc-dev/neofs-node/pkg/core/netmap" + "github.com/nspcc-dev/neofs-node/pkg/core/nns" "github.com/nspcc-dev/neofs-node/pkg/services/util" apistatus "github.com/nspcc-dev/neofs-sdk-go/client/status" "github.com/nspcc-dev/neofs-sdk-go/container" @@ -30,6 +32,7 @@ import ( protosession "github.com/nspcc-dev/neofs-sdk-go/proto/session" protostatus "github.com/nspcc-dev/neofs-sdk-go/proto/status" "github.com/nspcc-dev/neofs-sdk-go/session" + sessionv2 "github.com/nspcc-dev/neofs-sdk-go/session/v2" "github.com/nspcc-dev/neofs-sdk-go/user" "github.com/nspcc-dev/neofs-sdk-go/version" ) @@ -41,6 +44,10 @@ const defaultTxAwaitTimeout = 15 * time.Second // serve NeoFS API Container service. type FSChain interface { InvokeContainedScript(tx *transaction.Transaction, header *block.Header, _ *trigger.Type, _ *bool) (*result.Invoke, error) + + // HasUserInNNS checks whether user with given address is registered in NNS + // under the given name. + HasUserInNNS(name string, addr neoutil.Uint160) (bool, error) } // Contract groups ops of the Container contract deployed in the FS chain @@ -50,7 +57,7 @@ type Contract interface { // transaction is accepted for processing, Put waits for it to be successfully // executed. Waiting is performed within ctx, // [apistatus.ErrContainerAwaitTimeout] is returned on when it is done. - Put(ctx context.Context, _ container.Container, pub, sig []byte, _ *session.Container) (cid.ID, error) + Put(ctx context.Context, _ container.Container, pub, sig []byte, sessionToken []byte) (cid.ID, error) // Get returns container by its ID. Returns [apistatus.ErrContainerNotFound] // error if container is missing. Get(cid.ID) (container.Container, error) @@ -62,7 +69,7 @@ type Contract interface { // credentials. If transaction is accepted for processing, PutEACL waits for it // to be successfully executed. Waiting is performed within ctx, // [apistatus.ErrContainerAwaitTimeout] is when it is done. - PutEACL(ctx context.Context, _ eacl.Table, pub, sig []byte, _ *session.Container) error + PutEACL(ctx context.Context, _ eacl.Table, pub, sig []byte, sessionToken []byte) error // GetEACL returns eACL of the container by its ID. Returns // [apistatus.ErrEACLNotFound] error if eACL is missing. GetEACL(cid.ID) (eacl.Table, error) @@ -70,7 +77,7 @@ type Contract interface { // transaction is accepted for processing, Delete waits for it to be // successfully executed. Waiting is performed within ctx, // [apistatus.ErrContainerAwaitTimeout] is returned when it is done. - Delete(ctx context.Context, _ cid.ID, pub, sig []byte, _ *session.Container) error + Delete(ctx context.Context, _ cid.ID, pub, sig []byte, sessionToken []byte) error // SetAttribute sends transaction setting container attribute with provided // credentials. If transaction is accepted for processing, SetAttribute waits // for it to be successfully executed. Waiting is performed within ctx, @@ -88,6 +95,16 @@ type Contract interface { type NetmapContract interface { // GetEpochBlock returns FS chain height when given NeoFS epoch was ticked. GetEpochBlock(epoch uint64) (uint32, error) + // GetEpochBlockByTime returns FS chain height of block index when the latest epoch that + // started not later than the provided block time came. + GetEpochBlockByTime(t uint32) (uint32, error) +} + +// TimeProvider supplies current FS chain time without calling the chain. +// It should be updated from block header subscriptions and return time +// based on the latest observed header timestamp. +type TimeProvider interface { + Now() time.Time } type historicN3ScriptRunner struct { @@ -96,16 +113,19 @@ type historicN3ScriptRunner struct { } type sessionTokenCommonCheckResult struct { - token session.Container - err error + token session.Container + tokenV2 sessionv2.Token + err error } // Server provides NeoFS API Container service. type Server struct { protocontainer.UnimplementedContainerServiceServer - signer *ecdsa.PrivateKey - net netmap.State - contract Contract + signer *ecdsa.PrivateKey + net netmap.State + contract Contract + resolver *nns.Resolver + chainTime TimeProvider historicN3ScriptRunner sessionTokenCommonCheckCache *lru.Cache[[sha256.Size]byte, sessionTokenCommonCheckResult] @@ -116,15 +136,17 @@ type Server struct { // // All response messages are signed using specified signer and have current // epoch in the meta header. -func New(s *ecdsa.PrivateKey, net netmap.State, fsChain FSChain, c Contract, nc NetmapContract) *Server { +func New(s *ecdsa.PrivateKey, net netmap.State, fsChain FSChain, c Contract, nc NetmapContract, chainTime TimeProvider) *Server { sessionTokenCheckCache, err := lru.New[[sha256.Size]byte, sessionTokenCommonCheckResult](1000) if err != nil { panic(fmt.Errorf("unexpected error in lru.New: %w", err)) } return &Server{ - signer: s, - net: net, - contract: c, + signer: s, + net: net, + contract: c, + resolver: nns.NewResolver(fsChain), + chainTime: chainTime, historicN3ScriptRunner: historicN3ScriptRunner{ FSChain: fsChain, NetmapContract: nc, @@ -144,25 +166,25 @@ func (s *Server) makeResponseMetaHeader(st *protostatus.Status) *protosession.Re // ResetSessionTokenCheckCache resets cache of session token check results. func (s *Server) ResetSessionTokenCheckCache() { s.sessionTokenCommonCheckCache.Purge() + s.resolver.PurgeCache() } // decodes the container session token from the request and checks its // signature, lifetime and applicability to this operation as per request. // Returns both nil if token is not attached to the request. -func (s *Server) getVerifiedSessionToken(mh *protosession.RequestMetaHeader, reqVerb session.ContainerVerb, reqCnr cid.ID) (*session.Container, error) { +func (s *Server) getVerifiedSessionTokenFromMetaHeader(mh *protosession.RequestMetaHeader, reqVerb session.ContainerVerb, reqCnr cid.ID) (*session.Container, []byte, error) { for omh := mh.GetOrigin(); omh != nil; omh = mh.GetOrigin() { mh = omh } m := mh.GetSessionToken() if m == nil { - return nil, nil + return nil, nil, nil } - st, _, err := s.getVerifiedSessionTokenWithBinary(m, reqVerb, reqCnr) - return st, err + return s.getVerifiedSessionToken(m, reqVerb, reqCnr) } -func (s *Server) getVerifiedSessionTokenWithBinary(m *protosession.SessionToken, reqVerb session.ContainerVerb, reqCnr cid.ID) (*session.Container, []byte, error) { +func (s *Server) getVerifiedSessionToken(m *protosession.SessionToken, reqVerb session.ContainerVerb, reqCnr cid.ID) (*session.Container, []byte, error) { b := make([]byte, m.MarshaledSize()) m.MarshalStable(b) @@ -258,6 +280,102 @@ func (s *Server) checkSessionIssuer(id cid.ID, issuer user.ID) error { return nil } +func (s *Server) getVerifiedSessionTokenV2FromMetaHeader(mh *protosession.RequestMetaHeader, reqVerb sessionv2.Verb, reqCnr cid.ID) (*sessionv2.Token, []byte, error) { + for omh := mh.GetOrigin(); omh != nil; omh = mh.GetOrigin() { + mh = omh + } + m := mh.GetSessionTokenV2() + if m == nil { + return nil, nil, nil + } + + return s.getVerifiedSessionTokenV2(m, reqVerb, reqCnr) +} + +func (s *Server) getVerifiedSessionTokenV2(m *protosession.SessionTokenV2, reqVerb sessionv2.Verb, reqCnr cid.ID) (*sessionv2.Token, []byte, error) { + b := make([]byte, m.MarshaledSize()) + m.MarshalStable(b) + + cacheKey := sha256.Sum256(b) + res, ok := s.sessionTokenCommonCheckCache.Get(cacheKey) + if !ok { + res.tokenV2, res.err = s.decodeAndVerifySessionTokenV2Common(m, b) + } + if res.err != nil { + return nil, nil, res.err + } + + currentTime := s.chainTime.Now().Round(time.Second) + if exp := res.tokenV2.Exp(); exp.Before(currentTime) { + return nil, nil, apistatus.ErrSessionTokenExpired + } + if iat := res.tokenV2.Iat(); iat.After(currentTime) { + return nil, nil, fmt.Errorf("token v2 should not be issued yet: IAt: %s, current time: %s", iat, currentTime) + } + if nbf := res.tokenV2.Nbf(); nbf.After(currentTime) { + return nil, nil, fmt.Errorf("token v2 is not valid yet: NBf: %s, current time: %s", nbf, currentTime) + } + if !ok { + s.sessionTokenCommonCheckCache.Add(cacheKey, res) + } + + if err := s.verifySessionTokenV2AgainstRequest(res.tokenV2, reqVerb, reqCnr); err != nil { + return nil, nil, err + } + + return &res.tokenV2, b, nil +} + +type sessionTokenV2WithEncodedBody struct { + sessionv2.Token + body []byte +} + +func (x sessionTokenV2WithEncodedBody) SignedData() []byte { + return x.body +} + +func (s *Server) decodeAndVerifySessionTokenV2Common(m *protosession.SessionTokenV2, mb []byte) (sessionv2.Token, error) { + var token sessionv2.Token + if err := token.FromProtoMessage(m); err != nil { + return token, fmt.Errorf("decode v2: %w", err) + } + + if err := token.Validate(s.resolver); err != nil { + return token, fmt.Errorf("invalid v2 token: %w", err) + } + + body, err := iprotobuf.GetFirstBytesField(mb) + if err != nil { + return token, fmt.Errorf("get body from calculated session token binary: %w", err) + } + + if err := icrypto.AuthenticateTokenV2(sessionTokenV2WithEncodedBody{ + Token: token, + body: body, + }, s.historicN3ScriptRunner); err != nil { + return token, fmt.Errorf("authenticate: %w", err) + } + + return token, nil +} + +func (s *Server) verifySessionTokenV2AgainstRequest(token sessionv2.Token, reqVerb sessionv2.Verb, reqCnr cid.ID) error { + if !token.AssertContainer(reqVerb, reqCnr) { + return errors.New("v2 session token does not authorize this container operation") + } + + if reqCnr.IsZero() { + return nil + } + + if err := s.checkSessionIssuer(reqCnr, token.OriginalIssuer()); err != nil { + return fmt.Errorf("verify v2 session issuer: %w", err) + } + + return nil +} + func (s *Server) makePutResponse(body *protocontainer.PutResponse_Body, err error) (*protocontainer.PutResponse, error) { resp := &protocontainer.PutResponse{ Body: body, @@ -349,15 +467,25 @@ func (s *Server) Put(ctx context.Context, req *protocontainer.PutRequest) (*prot return s.makeFailedPutResponse(fmt.Errorf("invalid container: %w", err)) } - st, err := s.getVerifiedSessionToken(req.GetMetaHeader(), session.VerbContainerPut, cid.ID{}) + stV2, tokenBytes, err := s.getVerifiedSessionTokenV2FromMetaHeader(req.GetMetaHeader(), sessionv2.VerbContainerPut, cid.ID{}) if err != nil { - return s.makeFailedPutResponse(fmt.Errorf("verify session token: %w", err)) + return s.makeFailedPutResponse(fmt.Errorf("verify session token v2: %w", err)) + } + + if stV2 == nil { + st, b, err := s.getVerifiedSessionTokenFromMetaHeader(req.GetMetaHeader(), session.VerbContainerPut, cid.ID{}) + if err != nil { + return s.makeFailedPutResponse(fmt.Errorf("verify session token: %w", err)) + } + if st != nil { + tokenBytes = b + } } ctx, cancel := context.WithTimeout(ctx, defaultTxAwaitTimeout) defer cancel() - id, err := s.contract.Put(ctx, cnr, mSig.Key, mSig.Sign, st) + id, err := s.contract.Put(ctx, cnr, mSig.Key, mSig.Sign, tokenBytes) if err != nil && !errors.Is(err, apistatus.ErrContainerAwaitTimeout) { return s.makeFailedPutResponse(err) } @@ -398,15 +526,25 @@ func (s *Server) Delete(ctx context.Context, req *protocontainer.DeleteRequest) return s.makeDeleteResponse(fmt.Errorf("invalid ID: %w", err)) } - st, err := s.getVerifiedSessionToken(req.GetMetaHeader(), session.VerbContainerDelete, id) + stV2, tokenBytes, err := s.getVerifiedSessionTokenV2FromMetaHeader(req.GetMetaHeader(), sessionv2.VerbContainerDelete, id) if err != nil { - return s.makeDeleteResponse(fmt.Errorf("verify session token: %w", err)) + return s.makeDeleteResponse(fmt.Errorf("verify session token v2: %w", err)) + } + + if stV2 == nil { + st, b, err := s.getVerifiedSessionTokenFromMetaHeader(req.GetMetaHeader(), session.VerbContainerDelete, id) + if err != nil { + return s.makeDeleteResponse(fmt.Errorf("verify session token: %w", err)) + } + if st != nil { + tokenBytes = b + } } ctx, cancel := context.WithTimeout(ctx, defaultTxAwaitTimeout) defer cancel() - err = s.contract.Delete(ctx, id, mSig.Key, mSig.Sign, st) + err = s.contract.Delete(ctx, id, mSig.Key, mSig.Sign, tokenBytes) return s.makeDeleteResponse(err) } @@ -537,15 +675,25 @@ func (s *Server) SetExtendedACL(ctx context.Context, req *protocontainer.SetExte return s.makeSetEACLResponse(errors.New("missing container ID in eACL table")) } - st, err := s.getVerifiedSessionToken(req.GetMetaHeader(), session.VerbContainerSetEACL, cnrID) + stV2, tokenBytes, err := s.getVerifiedSessionTokenV2FromMetaHeader(req.GetMetaHeader(), sessionv2.VerbContainerSetEACL, cnrID) if err != nil { - return s.makeSetEACLResponse(fmt.Errorf("verify session token: %w", err)) + return s.makeSetEACLResponse(fmt.Errorf("verify session token v2: %w", err)) + } + + if stV2 == nil { + st, b, err := s.getVerifiedSessionTokenFromMetaHeader(req.GetMetaHeader(), session.VerbContainerSetEACL, cnrID) + if err != nil { + return s.makeSetEACLResponse(fmt.Errorf("verify session token: %w", err)) + } + if st != nil { + tokenBytes = b + } } ctx, cancel := context.WithTimeout(ctx, defaultTxAwaitTimeout) defer cancel() - err = s.contract.PutEACL(ctx, eACL, mSig.Key, mSig.Sign, st) + err = s.contract.PutEACL(ctx, eACL, mSig.Key, mSig.Sign, tokenBytes) return s.makeSetEACLResponse(err) } @@ -649,10 +797,12 @@ func (s *Server) SetAttribute(ctx context.Context, req *protocontainer.SetAttrib var sessionToken []byte if req.Body.SessionToken != nil { - // TODO - return s.makeSetAttributeResponse(errors.New("sessions V2 are not supported yet")) + _, sessionToken, err = s.getVerifiedSessionTokenV2(req.Body.SessionToken, sessionv2.VerbContainerSetAttribute, id) + if err != nil { + return s.makeSetAttributeResponse(fmt.Errorf("verify session token V2: %w", err)) + } } else if req.Body.SessionTokenV1 != nil { - _, sessionToken, err = s.getVerifiedSessionTokenWithBinary(req.Body.SessionTokenV1, session.VerbContainerSetAttribute, id) + _, sessionToken, err = s.getVerifiedSessionToken(req.Body.SessionTokenV1, session.VerbContainerSetAttribute, id) if err != nil { return s.makeSetAttributeResponse(fmt.Errorf("verify session token V1: %w", err)) } @@ -722,10 +872,12 @@ func (s *Server) RemoveAttribute(ctx context.Context, req *protocontainer.Remove var sessionToken []byte if req.Body.SessionToken != nil { - // TODO - return s.makeRemoveAttributeResponse(errors.New("sessions V2 are not supported yet")) + _, sessionToken, err = s.getVerifiedSessionTokenV2(req.Body.SessionToken, sessionv2.VerbContainerRemoveAttribute, id) + if err != nil { + return s.makeRemoveAttributeResponse(fmt.Errorf("verify session token V2: %w", err)) + } } else if req.Body.SessionTokenV1 != nil { - _, sessionToken, err = s.getVerifiedSessionTokenWithBinary(req.Body.SessionTokenV1, session.VerbContainerRemoveAttribute, id) + _, sessionToken, err = s.getVerifiedSessionToken(req.Body.SessionTokenV1, session.VerbContainerRemoveAttribute, id) if err != nil { return s.makeRemoveAttributeResponse(fmt.Errorf("verify session token V1: %w", err)) } diff --git a/pkg/services/container/server_test.go b/pkg/services/container/server_test.go index a80e1607fd..0e428818a5 100644 --- a/pkg/services/container/server_test.go +++ b/pkg/services/container/server_test.go @@ -4,12 +4,14 @@ import ( "context" "errors" "testing" + "time" "github.com/google/uuid" "github.com/nspcc-dev/neo-go/pkg/core/block" "github.com/nspcc-dev/neo-go/pkg/core/transaction" "github.com/nspcc-dev/neo-go/pkg/neorpc/result" "github.com/nspcc-dev/neo-go/pkg/smartcontract/trigger" + neoutil "github.com/nspcc-dev/neo-go/pkg/util" containerSvc "github.com/nspcc-dev/neofs-node/pkg/services/container" apistatus "github.com/nspcc-dev/neofs-sdk-go/client/status" "github.com/nspcc-dev/neofs-sdk-go/container" @@ -26,6 +28,7 @@ import ( protosession "github.com/nspcc-dev/neofs-sdk-go/proto/session" protostatus "github.com/nspcc-dev/neofs-sdk-go/proto/status" "github.com/nspcc-dev/neofs-sdk-go/session" + sessionv2 "github.com/nspcc-dev/neofs-sdk-go/session/v2" "github.com/nspcc-dev/neofs-sdk-go/user" usertest "github.com/nspcc-dev/neofs-sdk-go/user/test" "github.com/nspcc-dev/neofs-sdk-go/version" @@ -35,13 +38,21 @@ import ( type unimplementedFSChain struct{} +func (c unimplementedFSChain) MsPerBlock() (int64, error) { + panic("unimplemented") +} + func (unimplementedFSChain) InvokeContainedScript(*transaction.Transaction, *block.Header, *trigger.Type, *bool) (*result.Invoke, error) { panic("unimplemented") } +func (unimplementedFSChain) HasUserInNNS(string, neoutil.Uint160) (bool, error) { + panic("unimplemented") +} + type unimplementedContainerContract struct{} -func (unimplementedContainerContract) Put(context.Context, container.Container, []byte, []byte, *session.Container) (cid.ID, error) { +func (unimplementedContainerContract) Put(context.Context, container.Container, []byte, []byte, []byte) (cid.ID, error) { panic("implement me") } @@ -53,7 +64,7 @@ func (unimplementedContainerContract) List(user.ID) ([]cid.ID, error) { panic("unimplemented") } -func (unimplementedContainerContract) PutEACL(context.Context, eacl.Table, []byte, []byte, *session.Container) error { +func (unimplementedContainerContract) PutEACL(context.Context, eacl.Table, []byte, []byte, []byte) error { panic("unimplemented") } @@ -61,7 +72,7 @@ func (unimplementedContainerContract) GetEACL(cid.ID) (eacl.Table, error) { panic("unimplemented") } -func (unimplementedContainerContract) Delete(context.Context, cid.ID, []byte, []byte, *session.Container) error { +func (unimplementedContainerContract) Delete(context.Context, cid.ID, []byte, []byte, []byte) error { panic("unimplemented") } @@ -79,19 +90,30 @@ func (unimplementedNetmapContract) GetEpochBlock(uint64) (uint32, error) { panic("unimplemented") } +func (unimplementedNetmapContract) GetEpochBlockByTime(uint32) (uint32, error) { + panic("unimplemented") +} + type testNodeState struct { epoch uint64 } func (x testNodeState) CurrentEpoch() uint64 { return x.epoch } +type testTimeProvider struct{} + +func (testTimeProvider) Now() time.Time { + return time.Now() +} + type testFSChain struct { testNodeState - getErr error - cnrs map[cid.ID]container.Container + getErr error + cnrs map[cid.ID]container.Container + nnsUsers map[string]neoutil.Uint160 } -func (testFSChain) Put(context.Context, container.Container, []byte, []byte, *session.Container) (cid.ID, error) { +func (testFSChain) Put(context.Context, container.Container, []byte, []byte, []byte) (cid.ID, error) { return cid.ID{}, errors.New("unimplemented") } @@ -110,7 +132,7 @@ func (testFSChain) List(user.ID) ([]cid.ID, error) { return nil, errors.New("unimplemented") } -func (testFSChain) PutEACL(context.Context, eacl.Table, []byte, []byte, *session.Container) error { +func (testFSChain) PutEACL(context.Context, eacl.Table, []byte, []byte, []byte) error { return nil } @@ -118,16 +140,35 @@ func (testFSChain) GetEACL(cid.ID) (eacl.Table, error) { return eacl.Table{}, errors.New("unimplemented") } -func (testFSChain) Delete(context.Context, cid.ID, []byte, []byte, *session.Container) error { +func (testFSChain) Delete(context.Context, cid.ID, []byte, []byte, []byte) error { return nil } func (x testFSChain) GetEpochBlock(uint64) (uint32, error) { panic("unimplemented") } +func (x testFSChain) GetEpochBlockByTime(uint32) (uint32, error) { + panic("unimplemented") +} + func (x testFSChain) InvokeContainedScript(*transaction.Transaction, *block.Header, *trigger.Type, *bool) (*result.Invoke, error) { panic("unimplemented") } +func (x testFSChain) HasUserInNNS(name string, addr neoutil.Uint160) (bool, error) { + if x.nnsUsers == nil { + return false, nil + } + storedAddr, exists := x.nnsUsers[name] + if !exists { + return false, nil + } + return storedAddr.Equals(addr), nil +} + +func (x testFSChain) MsPerBlock() (int64, error) { + return 0, nil +} + func (testFSChain) SetAttribute(context.Context, cid.ID, string, string, uint64, []byte, []byte, []byte) error { return errors.New("unimplemented") } @@ -176,7 +217,7 @@ func TestServer_Delete(t *testing.T) { }, } m.epoch = anyEpoch - svc := containerSvc.New(&usr.ECDSAPrivateKey, m, m, m, m) + svc := containerSvc.New(&usr.ECDSAPrivateKey, m, m, m, m, new(testTimeProvider)) t.Run("session", func(t *testing.T) { t.Run("failure", func(t *testing.T) { @@ -367,7 +408,7 @@ func TestSessionVerb(t *testing.T) { cnrID: cnr, } - s := containerSvc.New(&owner.ECDSAPrivateKey, fsChain, fsChain, fsChain, fsChain) + s := containerSvc.New(&owner.ECDSAPrivateKey, fsChain, fsChain, fsChain, fsChain, new(testTimeProvider)) var st session.Container st.SetID(uuid.New()) @@ -445,9 +486,10 @@ func TestServer_SetExtendedACL_InvalidRequest(t *testing.T) { var fsChain unimplementedFSChain var cnrContract unimplementedContainerContract var nmContract unimplementedNetmapContract + var timeProvider testTimeProvider // fsChain is used for response, other components must not be accessed for invalid request - svc := containerSvc.New(&usr.ECDSAPrivateKey, state, fsChain, cnrContract, nmContract) + svc := containerSvc.New(&usr.ECDSAPrivateKey, state, fsChain, cnrContract, nmContract, timeProvider) t.Run("eACL without container ID", func(t *testing.T) { req := &protocontainer.SetExtendedACLRequest{ @@ -502,7 +544,7 @@ func TestService_SetExtendedACL_SessionIssuer(t *testing.T) { otherID: otherCnr, } - s := containerSvc.New(&owner.ECDSAPrivateKey, &fsChain, &fsChain, &fsChain, &fsChain) + s := containerSvc.New(&owner.ECDSAPrivateKey, &fsChain, &fsChain, &fsChain, &fsChain, new(testTimeProvider)) eACL := eacltest.Table() @@ -604,7 +646,7 @@ func TestService_Delete_SessionIssuer(t *testing.T) { otherID: otherCnr, } - s := containerSvc.New(&owner.ECDSAPrivateKey, &fsChain, &fsChain, &fsChain, &fsChain) + s := containerSvc.New(&owner.ECDSAPrivateKey, &fsChain, &fsChain, &fsChain, &fsChain, new(testTimeProvider)) eACL := eacltest.Table() @@ -671,3 +713,99 @@ func TestService_Delete_SessionIssuer(t *testing.T) { assertOK(t) } + +func TestService_TokenV2(t *testing.T) { + ctx := context.Background() + owner := usertest.User() + other := usertest.User() + id := cidtest.ID() + + var cnr container.Container + cnr.SetOwner(owner.ID) + + ownerAddr := owner.ID.ScriptHash() + ownerNNS := "storage.neofs" + + var fsChain testFSChain + fsChain.epoch = 10 + fsChain.cnrs = map[cid.ID]container.Container{ + id: cnr, + } + fsChain.nnsUsers = map[string]neoutil.Uint160{ + ownerNNS: ownerAddr, + } + + svc := containerSvc.New(&owner.ECDSAPrivateKey, &fsChain, &fsChain, &fsChain, &fsChain, new(testTimeProvider)) + + buildToken := func(t *testing.T, signer usertest.UserSigner) sessionv2.Token { + t.Helper() + var tok sessionv2.Token + tok.SetVersion(sessionv2.TokenCurrentVersion) + tok.SetNonce(sessionv2.RandomNonce()) + + now := time.Now() + tok.SetIat(now) + tok.SetNbf(now) + tok.SetExp(now.Add(10 * time.Minute)) + + ctxS, err := sessionv2.NewContext(id, []sessionv2.Verb{sessionv2.VerbContainerDelete}) + require.NoError(t, err) + require.NoError(t, tok.SetContexts([]sessionv2.Context{ctxS})) + + require.NoError(t, tok.AddSubject(sessionv2.NewTargetNamed("storage.neofs"))) + + require.NoError(t, tok.Sign(signer)) + return tok + } + + makeDelete := func(t *testing.T, tok sessionv2.Token) *protocontainer.DeleteRequest { + t.Helper() + cidSig, err := owner.RFC6979.Sign(id[:]) + require.NoError(t, err) + + delReq := &protocontainer.DeleteRequest{ + Body: &protocontainer.DeleteRequest_Body{ + ContainerId: id.ProtoMessage(), + Signature: &refs.SignatureRFC6979{ + Key: owner.PublicKeyBytes, + Sign: cidSig, + }, + }, + MetaHeader: &protosession.RequestMetaHeader{ + SessionTokenV2: tok.ProtoMessage(), + }, + } + + delReq.VerifyHeader, err = neofscrypto.SignRequestWithBuffer(owner, delReq, nil) + require.NoError(t, err) + return delReq + } + + t.Run("ok", func(t *testing.T) { + tok := buildToken(t, owner) + + req := makeDelete(t, tok) + resp, err := svc.Delete(ctx, req) + require.NoError(t, err) + require.NotNil(t, resp) + require.NoError(t, neofscrypto.VerifyResponseWithBuffer(resp, nil)) + + require.NotNil(t, resp.MetaHeader) + require.Nil(t, resp.MetaHeader.Status) + }) + + t.Run("not a container owner", func(t *testing.T) { + tok := buildToken(t, other) + + req := makeDelete(t, tok) + resp, err := svc.Delete(ctx, req) + require.NoError(t, err) + require.NotNil(t, resp) + require.NoError(t, neofscrypto.VerifyResponseWithBuffer(resp, nil)) + + sts := resp.GetMetaHeader().GetStatus() + require.NotNil(t, sts) + require.EqualValues(t, 1024, sts.Code) + require.Equal(t, "session was not issued by the container owner", sts.Message) + }) +} diff --git a/pkg/services/container/service_internal_test.go b/pkg/services/container/service_internal_test.go index 353804463b..57db896238 100644 --- a/pkg/services/container/service_internal_test.go +++ b/pkg/services/container/service_internal_test.go @@ -3,6 +3,7 @@ package container import ( "context" "testing" + "time" "github.com/google/uuid" "github.com/nspcc-dev/neofs-sdk-go/container" @@ -11,6 +12,7 @@ import ( "github.com/nspcc-dev/neofs-sdk-go/eacl" protosession "github.com/nspcc-dev/neofs-sdk-go/proto/session" "github.com/nspcc-dev/neofs-sdk-go/session" + sessionv2 "github.com/nspcc-dev/neofs-sdk-go/session/v2" "github.com/nspcc-dev/neofs-sdk-go/user" usertest "github.com/nspcc-dev/neofs-sdk-go/user/test" "github.com/stretchr/testify/require" @@ -23,11 +25,17 @@ func (nopNetwork) CurrentEpoch() uint64 { return 0 } +type mockTimeProvider struct{} + +func (mockTimeProvider) Now() time.Time { + return time.Now() +} + type mockContainerContract struct { cnr container.Container } -func (mockContainerContract) Put(context.Context, container.Container, []byte, []byte, *session.Container) (cid.ID, error) { +func (mockContainerContract) Put(context.Context, container.Container, []byte, []byte, []byte) (cid.ID, error) { panic("unimplemented") } @@ -39,7 +47,7 @@ func (mockContainerContract) List(user.ID) ([]cid.ID, error) { panic("unimplemented") } -func (mockContainerContract) PutEACL(context.Context, eacl.Table, []byte, []byte, *session.Container) error { +func (mockContainerContract) PutEACL(context.Context, eacl.Table, []byte, []byte, []byte) error { panic("unimplemented") } @@ -47,7 +55,7 @@ func (mockContainerContract) GetEACL(cid.ID) (eacl.Table, error) { panic("unimplemented") } -func (mockContainerContract) Delete(context.Context, cid.ID, []byte, []byte, *session.Container) error { +func (mockContainerContract) Delete(context.Context, cid.ID, []byte, []byte, []byte) error { panic("unimplemented") } @@ -78,10 +86,49 @@ func BenchmarkSessionTokenVerification(b *testing.B) { SessionToken: tok.ProtoMessage(), } - s := New(&anyUsr.ECDSAPrivateKey, nopNetwork{}, nil, mockContainerContract{cnr: cnr}, nil) + s := New(&anyUsr.ECDSAPrivateKey, nopNetwork{}, nil, mockContainerContract{cnr: cnr}, nil, mockTimeProvider{}) + + for b.Loop() { + _, _, err := s.getVerifiedSessionTokenFromMetaHeader(metaHdr, anyVerb, anyCnrID) + require.NoError(b, err) + s.ResetSessionTokenCheckCache() + } +} + +func BenchmarkSessionTokenV2Verification(b *testing.B) { + const anyVerbV2 = sessionv2.VerbContainerPut + anyCnr := cidtest.ID() + anyUsr := usertest.User() + + var cnr container.Container + cnr.SetOwner(anyUsr.ID) + + var tok sessionv2.Token + tok.SetVersion(sessionv2.TokenCurrentVersion) + tok.SetNonce(sessionv2.RandomNonce()) + + ctx, err := sessionv2.NewContext(anyCnr, []sessionv2.Verb{anyVerbV2}) + require.NoError(b, err) + err = tok.AddContext(ctx) + require.NoError(b, err) + + err = tok.AddSubject(sessionv2.NewTargetUser(anyUsr.UserID())) + require.NoError(b, err) + + currentTime := time.Now() + tok.SetIat(currentTime) + tok.SetNbf(currentTime) + tok.SetExp(currentTime.Add(1 * time.Hour)) + require.NoError(b, tok.Sign(anyUsr)) + + metaHdr := &protosession.RequestMetaHeader{ + SessionTokenV2: tok.ProtoMessage(), + } + + s := New(&anyUsr.ECDSAPrivateKey, nopNetwork{}, nil, mockContainerContract{cnr: cnr}, nil, mockTimeProvider{}) for b.Loop() { - _, err := s.getVerifiedSessionToken(metaHdr, anyVerb, anyCnrID) + _, _, err := s.getVerifiedSessionTokenV2FromMetaHeader(metaHdr, anyVerbV2, anyCnr) require.NoError(b, err) s.ResetSessionTokenCheckCache() } diff --git a/pkg/services/object/acl/v2/classifier_test.go b/pkg/services/object/acl/v2/classifier_test.go index 189163f1d7..38924e56a1 100644 --- a/pkg/services/object/acl/v2/classifier_test.go +++ b/pkg/services/object/acl/v2/classifier_test.go @@ -3,6 +3,7 @@ package v2 import ( "testing" + "github.com/nspcc-dev/neo-go/pkg/util" "github.com/nspcc-dev/neofs-sdk-go/container/acl" cid "github.com/nspcc-dev/neofs-sdk-go/container/id" cidtest "github.com/nspcc-dev/neofs-sdk-go/container/id/test" @@ -26,6 +27,10 @@ func (nopFSChain) InContainerInLastTwoEpochs(cid.ID, []byte) (bool, error) { return false, nil } +func (nopFSChain) HasUserInNNS(string, util.Uint160) (bool, error) { + panic("not implemented") +} + func BenchmarkClassifierLoggerProduction(b *testing.B) { l, err := zap.NewProduction() require.NoError(b, err) diff --git a/pkg/services/object/acl/v2/opts.go b/pkg/services/object/acl/v2/opts.go index 24bed9f246..9cf0101245 100644 --- a/pkg/services/object/acl/v2/opts.go +++ b/pkg/services/object/acl/v2/opts.go @@ -33,3 +33,10 @@ func WithIRFetcher(v InnerRingFetcher) Option { c.irFetcher = v } } + +// WithTimeProvider sets external chain time provider updated from header subscriptions. +func WithTimeProvider(p TimeProvider) Option { + return func(c *cfg) { + c.chainTime = p + } +} diff --git a/pkg/services/object/acl/v2/service.go b/pkg/services/object/acl/v2/service.go index 0563fc84f2..a1dbb2365c 100644 --- a/pkg/services/object/acl/v2/service.go +++ b/pkg/services/object/acl/v2/service.go @@ -4,16 +4,19 @@ import ( "crypto/sha256" "errors" "fmt" + "time" lru "github.com/hashicorp/golang-lru/v2" "github.com/nspcc-dev/neo-go/pkg/core/block" "github.com/nspcc-dev/neo-go/pkg/core/transaction" "github.com/nspcc-dev/neo-go/pkg/neorpc/result" "github.com/nspcc-dev/neo-go/pkg/smartcontract/trigger" + "github.com/nspcc-dev/neo-go/pkg/util" icrypto "github.com/nspcc-dev/neofs-node/internal/crypto" iprotobuf "github.com/nspcc-dev/neofs-node/internal/protobuf" "github.com/nspcc-dev/neofs-node/pkg/core/container" "github.com/nspcc-dev/neofs-node/pkg/core/netmap" + "github.com/nspcc-dev/neofs-node/pkg/core/nns" "github.com/nspcc-dev/neofs-sdk-go/bearer" apistatus "github.com/nspcc-dev/neofs-sdk-go/client/status" "github.com/nspcc-dev/neofs-sdk-go/container/acl" @@ -23,13 +26,15 @@ import ( protoobject "github.com/nspcc-dev/neofs-sdk-go/proto/object" protosession "github.com/nspcc-dev/neofs-sdk-go/proto/session" sessionSDK "github.com/nspcc-dev/neofs-sdk-go/session" + sessionv2 "github.com/nspcc-dev/neofs-sdk-go/session/v2" "github.com/nspcc-dev/neofs-sdk-go/user" "go.uber.org/zap" ) type sessionTokenCommonCheckResult struct { - token sessionSDK.Object - err error + token sessionSDK.Object + tokenV2 sessionv2.Token + err error } type bearerTokenCommonCheckResult struct { @@ -42,6 +47,7 @@ type Service struct { *cfg c senderClassifier + r *nns.Resolver sessionTokenCommonCheckCache *lru.Cache[[sha256.Size]byte, sessionTokenCommonCheckResult] bearerTokenCommonCheckCache *lru.Cache[[sha256.Size]byte, bearerTokenCommonCheckResult] @@ -59,6 +65,9 @@ type FSChain interface { // from the referenced container either in the current or the previous NeoFS // epoch. InContainerInLastTwoEpochs(_ cid.ID, pub []byte) (bool, error) + + // HasUserInNNS checks whether given user is listed in the NNS domain. + HasUserInNNS(name string, addr util.Uint160) (bool, error) } // Netmapper must provide network map information. @@ -70,6 +79,16 @@ type Netmapper interface { ServerInContainer(cid.ID) (bool, error) // GetEpochBlock returns FS chain height when given NeoFS epoch was ticked. GetEpochBlock(epoch uint64) (uint32, error) + // GetEpochBlockByTime returns FS chain height of block index when the latest epoch that + // started not later than the provided block time came. + GetEpochBlockByTime(t uint32) (uint32, error) +} + +// TimeProvider supplies current FS chain time without calling the chain. +// It should be updated from block header subscriptions and return time +// based on the latest observed header timestamp. +type TimeProvider interface { + Now() time.Time } type cfg struct { @@ -80,6 +99,8 @@ type cfg struct { irFetcher InnerRingFetcher nm Netmapper + + chainTime TimeProvider } func defaultCfg() *cfg { @@ -106,6 +127,7 @@ func New(fsChain FSChain, opts ...Option) Service { panicOnNil(cfg.irFetcher, "inner Ring fetcher") panicOnNil(cfg.containers, "container source") panicOnNil(fsChain, "FS chain") + panicOnNil(cfg.chainTime, "chain time provider") sessionTokenCheckCache, err := lru.New[[sha256.Size]byte, sessionTokenCommonCheckResult](1000) if err != nil { @@ -123,6 +145,7 @@ func New(fsChain FSChain, opts ...Option) Service { innerRing: cfg.irFetcher, fsChain: fsChain, }, + r: nns.NewResolver(fsChain), sessionTokenCommonCheckCache: sessionTokenCheckCache, bearerTokenCommonCheckCache: bearerTokenCheckCache, } @@ -132,15 +155,24 @@ func New(fsChain FSChain, opts ...Option) Service { func (b Service) ResetTokenCheckCache() { b.sessionTokenCommonCheckCache.Purge() b.bearerTokenCommonCheckCache.Purge() + b.r.PurgeCache() } -func (b Service) getVerifiedSessionToken(mh *protosession.RequestMetaHeader, reqVerb sessionSDK.ObjectVerb, reqCnr cid.ID, reqObj oid.ID) (*sessionSDK.Object, error) { +func (b Service) getVerifiedSessionToken(mh *protosession.RequestMetaHeader, reqVerb sessionSDK.ObjectVerb, + reqVerbV2 sessionv2.Verb, reqCnr cid.ID, reqObj oid.ID) (user.ID, []byte, error) { for omh := mh.GetOrigin(); omh != nil; omh = mh.GetOrigin() { mh = omh } + + mV2 := mh.GetSessionTokenV2() + if mV2 != nil { + return b.getVerifiedSessionTokenV2(mV2, reqVerbV2, reqCnr) + } + + // Fall back to V1 token m := mh.GetSessionToken() if m == nil { - return nil, nil + return user.ID{}, nil, nil } mb := make([]byte, m.MarshaledSize()) @@ -153,14 +185,19 @@ func (b Service) getVerifiedSessionToken(mh *protosession.RequestMetaHeader, req b.sessionTokenCommonCheckCache.Add(cacheKey, res) } if res.err != nil { - return nil, res.err + return user.ID{}, nil, res.err } if err := b.verifySessionTokenAgainstRequest(res.token, reqVerb, reqCnr, reqObj); err != nil { - return nil, err + return user.ID{}, nil, err } - return &res.token, nil + sig, ok := res.token.Signature() + if !ok { + return user.ID{}, nil, errors.New("missing signature in session token") + } + + return res.token.Issuer(), sig.PublicKeyBytes(), nil } type sessionTokenWithEncodedBody struct { @@ -219,6 +256,86 @@ func (b Service) verifySessionTokenAgainstRequest(token sessionSDK.Object, reqVe return nil } +// getVerifiedSessionTokenV2 validates and returns V2 session token info. +func (b Service) getVerifiedSessionTokenV2(mV2 *protosession.SessionTokenV2, reqVerb sessionv2.Verb, reqCnr cid.ID) (user.ID, []byte, error) { + mb := make([]byte, mV2.MarshaledSize()) + mV2.MarshalStable(mb) + + cacheKey := sha256.Sum256(mb) + res, ok := b.sessionTokenCommonCheckCache.Get(cacheKey) + if !ok { + res.tokenV2, res.err = b.decodeAndVerifySessionTokenV2Common(mV2, mb) + } + if res.err != nil { + return user.ID{}, nil, res.err + } + + currentTime := b.chainTime.Now().Round(time.Second) + if res.tokenV2.Exp().Before(currentTime) { + return user.ID{}, nil, apistatus.ErrSessionTokenExpired + } + if !res.tokenV2.ValidAt(currentTime) { + return user.ID{}, nil, fmt.Errorf("%s: V2 token is invalid at %s, token iat %s, nbf %s, exp %s", invalidRequestMessage, currentTime, res.tokenV2.Iat(), res.tokenV2.Nbf(), res.tokenV2.Exp()) + } + if !ok { + b.sessionTokenCommonCheckCache.Add(cacheKey, res) + } + + if !res.tokenV2.AssertVerb(reqVerb, reqCnr) { + return user.ID{}, nil, errInvalidVerb + } + + var key []byte + origin := &res.tokenV2 + for origin != nil { + sig, ok := origin.Signature() + if !ok { + return user.ID{}, nil, errors.New("missing signature in V2 session token") + } + key = sig.PublicKeyBytes() + origin = origin.Origin() + } + + return res.tokenV2.OriginalIssuer(), key, nil +} + +type sessionTokenV2WithEncodedBody struct { + sessionv2.Token + body []byte +} + +func (x sessionTokenV2WithEncodedBody) SignedData() []byte { + return x.body +} + +func (b Service) decodeAndVerifySessionTokenV2Common(m *protosession.SessionTokenV2, mb []byte) (sessionv2.Token, error) { + var token sessionv2.Token + if err := token.FromProtoMessage(m); err != nil { + return token, fmt.Errorf("invalid V2 session token: %w", err) + } + + if err := token.Validate(b.r); err != nil { + return token, fmt.Errorf("validate V2 session token: %w", err) + } + + body, err := iprotobuf.GetFirstBytesField(mb) + if err != nil { + return token, fmt.Errorf("get body from calculated session token v2 binary: %w", err) + } + + if err := icrypto.AuthenticateTokenV2(sessionTokenV2WithEncodedBody{ + Token: token, + body: body, + }, historicN3ScriptRunner{ + FSChain: b.c.fsChain, + Netmapper: b.nm, + }); err != nil { + return token, fmt.Errorf("authenticate session token v2: %w", err) + } + + return token, nil +} + func (b Service) getVerifiedBearerToken(mh *protosession.RequestMetaHeader, reqCnr cid.ID, ownerCnr user.ID, usrSender user.ID) (*bearer.Token, error) { for omh := mh.GetOrigin(); omh != nil; omh = mh.GetOrigin() { mh = omh @@ -327,7 +444,7 @@ func (b Service) GetRequestToInfo(request *protoobject.GetRequest) (RequestInfo, return RequestInfo{}, err } - return b.findRequestInfo(request, cnr, acl.OpObjectGet, sessionSDK.VerbObjectGet, *obj) + return b.findRequestInfo(request, cnr, acl.OpObjectGet, sessionSDK.VerbObjectGet, sessionv2.VerbObjectGet, *obj) } // HeadRequestToInfo resolves RequestInfo from the request to check it using @@ -343,7 +460,7 @@ func (b Service) HeadRequestToInfo(request *protoobject.HeadRequest) (RequestInf return RequestInfo{}, err } - return b.findRequestInfo(request, cnr, acl.OpObjectHead, sessionSDK.VerbObjectHead, *obj) + return b.findRequestInfo(request, cnr, acl.OpObjectHead, sessionSDK.VerbObjectHead, sessionv2.VerbObjectHead, *obj) } // SearchRequestToInfo resolves RequestInfo from the request to check it using @@ -368,7 +485,7 @@ func (b Service) searchRequestToInfo(request interface { return RequestInfo{}, err } - return b.findRequestInfo(request, id, acl.OpObjectSearch, sessionSDK.VerbObjectSearch, oid.ID{}) + return b.findRequestInfo(request, id, acl.OpObjectSearch, sessionSDK.VerbObjectSearch, sessionv2.VerbObjectSearch, oid.ID{}) } // DeleteRequestToInfo resolves RequestInfo from the request to check it using @@ -384,7 +501,7 @@ func (b Service) DeleteRequestToInfo(request *protoobject.DeleteRequest) (Reques return RequestInfo{}, err } - return b.findRequestInfo(request, cnr, acl.OpObjectDelete, sessionSDK.VerbObjectDelete, *obj) + return b.findRequestInfo(request, cnr, acl.OpObjectDelete, sessionSDK.VerbObjectDelete, sessionv2.VerbObjectDelete, *obj) } // RangeRequestToInfo resolves RequestInfo from the request to check it using @@ -400,7 +517,7 @@ func (b Service) RangeRequestToInfo(request *protoobject.GetRangeRequest) (Reque return RequestInfo{}, err } - return b.findRequestInfo(request, cnr, acl.OpObjectRange, sessionSDK.VerbObjectRange, *obj) + return b.findRequestInfo(request, cnr, acl.OpObjectRange, sessionSDK.VerbObjectRange, sessionv2.VerbObjectRange, *obj) } // HashRequestToInfo resolves RequestInfo from the request to check it using @@ -416,7 +533,7 @@ func (b Service) HashRequestToInfo(request *protoobject.GetRangeHashRequest) (Re return RequestInfo{}, err } - return b.findRequestInfo(request, cnr, acl.OpObjectHash, sessionSDK.VerbObjectRangeHash, *obj) + return b.findRequestInfo(request, cnr, acl.OpObjectHash, sessionSDK.VerbObjectRangeHash, sessionv2.VerbObjectRangeHash, *obj) } var ErrSkipRequest = errors.New("skip request") @@ -483,15 +600,15 @@ func (b Service) PutRequestToInfo(request *protoobject.PutRequest) (RequestInfo, } } - op, verb := acl.OpObjectPut, sessionSDK.VerbObjectPut + op, verb, verbv2 := acl.OpObjectPut, sessionSDK.VerbObjectPut, sessionv2.VerbObjectPut tombstone := header.GetObjectType() == protoobject.ObjectType_TOMBSTONE if tombstone { // such objects are specific - saving them is essentially the removal of other // objects - op, verb = acl.OpObjectDelete, sessionSDK.VerbObjectDelete + op, verb, verbv2 = acl.OpObjectDelete, sessionSDK.VerbObjectDelete, sessionv2.VerbObjectDelete } - reqInfo, err := b.findRequestInfo(request, cnr, op, verb, obj) + reqInfo, err := b.findRequestInfo(request, cnr, op, verb, verbv2, obj) if err != nil { return RequestInfo{}, user.ID{}, err } @@ -512,26 +629,17 @@ func (b Service) PutRequestToInfo(request *protoobject.PutRequest) (RequestInfo, func (b Service) findRequestInfo(req interface { GetMetaHeader() *protosession.RequestMetaHeader GetVerifyHeader() *protosession.RequestVerificationHeader -}, idCnr cid.ID, op acl.Op, verb sessionSDK.ObjectVerb, obj oid.ID) (RequestInfo, error) { +}, idCnr cid.ID, op acl.Op, verb sessionSDK.ObjectVerb, verb2 sessionv2.Verb, obj oid.ID) (RequestInfo, error) { var ( info RequestInfo metaHdr = req.GetMetaHeader() ) - sTok, err := b.getVerifiedSessionToken(metaHdr, verb, idCnr, obj) + reqAuthor, reqAuthorPub, err := b.getVerifiedSessionToken(metaHdr, verb, verb2, idCnr, obj) if err != nil { return info, err } - var reqAuthor user.ID - var reqAuthorPub []byte - if sTok != nil { - reqAuthor = sTok.Issuer() - sig, ok := sTok.Signature() - if !ok { - return info, errors.New("missing signature in session token") - } - reqAuthorPub = sig.PublicKeyBytes() - } else { + if reqAuthor.IsZero() { if reqAuthor, reqAuthorPub, err = icrypto.GetRequestAuthor(req.GetVerifyHeader()); err != nil { return info, fmt.Errorf("get request author: %w", err) } diff --git a/pkg/services/object/acl/v2/service_internal_test.go b/pkg/services/object/acl/v2/service_internal_test.go index 291ac9425a..74e11d3574 100644 --- a/pkg/services/object/acl/v2/service_internal_test.go +++ b/pkg/services/object/acl/v2/service_internal_test.go @@ -2,6 +2,7 @@ package v2 import ( "testing" + "time" "github.com/google/uuid" "github.com/nspcc-dev/neofs-sdk-go/bearer" @@ -13,12 +14,23 @@ import ( oid "github.com/nspcc-dev/neofs-sdk-go/object/id" protosession "github.com/nspcc-dev/neofs-sdk-go/proto/session" "github.com/nspcc-dev/neofs-sdk-go/session" + sessionv2 "github.com/nspcc-dev/neofs-sdk-go/session/v2" usertest "github.com/nspcc-dev/neofs-sdk-go/user/test" "github.com/stretchr/testify/require" ) type nopNetmapContract struct{} +func (c nopNetmapContract) GetEpochBlockByTime(uint32) (uint32, error) { + panic("unimplemented") +} + +type mockChainTime struct{} + +func (mockChainTime) Now() time.Time { + return time.Now() +} + func (nopNetmapContract) GetNetMapByEpoch(uint64) (*netmap.NetMap, error) { panic("unimplemented") } @@ -47,6 +59,7 @@ func (nopContrainerContract) Get(cid.ID) (container.Container, error) { func BenchmarkSessionTokenVerification(b *testing.B) { const anyVerb = session.VerbObjectGet + const anyVerbV2 = sessionv2.VerbObjectGet anyCnr := cidtest.ID() anyUsr := usertest.User() @@ -62,10 +75,15 @@ func BenchmarkSessionTokenVerification(b *testing.B) { SessionToken: tok.ProtoMessage(), } - s := New(nopFSChain{}, WithIRFetcher(nopIR{}), WithNetmapper(nopNetmapContract{}), WithContainerSource(nopContrainerContract{})) + s := New(nopFSChain{}, + WithIRFetcher(nopIR{}), + WithNetmapper(nopNetmapContract{}), + WithContainerSource(nopContrainerContract{}), + WithTimeProvider(mockChainTime{}), + ) for b.Loop() { - _, err := s.getVerifiedSessionToken(metaHdr, anyVerb, anyCnr, oid.ID{}) + _, _, err := s.getVerifiedSessionToken(metaHdr, anyVerb, anyVerbV2, anyCnr, oid.ID{}) require.NoError(b, err) s.ResetTokenCheckCache() } @@ -86,7 +104,12 @@ func BenchmarkBearerTokenVerification(b *testing.B) { BearerToken: tok.ProtoMessage(), } - s := New(nopFSChain{}, WithIRFetcher(nopIR{}), WithNetmapper(nopNetmapContract{}), WithContainerSource(nopContrainerContract{})) + s := New(nopFSChain{}, + WithIRFetcher(nopIR{}), + WithNetmapper(nopNetmapContract{}), + WithContainerSource(nopContrainerContract{}), + WithTimeProvider(mockChainTime{}), + ) for b.Loop() { _, err := s.getVerifiedBearerToken(metaHdr, anyCnr, anyCnrOwner.UserID(), anyReqSender) @@ -94,3 +117,45 @@ func BenchmarkBearerTokenVerification(b *testing.B) { s.ResetTokenCheckCache() } } + +func BenchmarkSessionTokenV2Verification(b *testing.B) { + const anyVerb = session.VerbObjectGet + const anyVerbV2 = sessionv2.VerbObjectGet + anyCnr := cidtest.ID() + anyUsr := usertest.User() + + var tok sessionv2.Token + tok.SetVersion(sessionv2.TokenCurrentVersion) + tok.SetNonce(sessionv2.RandomNonce()) + + ctx, err := sessionv2.NewContext(anyCnr, []sessionv2.Verb{anyVerbV2}) + require.NoError(b, err) + err = tok.AddContext(ctx) + require.NoError(b, err) + + err = tok.AddSubject(sessionv2.NewTargetUser(anyUsr.UserID())) + require.NoError(b, err) + + currentTime := time.Now() + tok.SetIat(currentTime) + tok.SetNbf(currentTime) + tok.SetExp(currentTime.Add(1 * time.Hour)) + require.NoError(b, tok.Sign(anyUsr)) + + metaHdr := &protosession.RequestMetaHeader{ + SessionTokenV2: tok.ProtoMessage(), + } + + s := New(nopFSChain{}, + WithIRFetcher(nopIR{}), + WithNetmapper(nopNetmapContract{}), + WithContainerSource(nopContrainerContract{}), + WithTimeProvider(mockChainTime{}), + ) + + for b.Loop() { + _, _, err := s.getVerifiedSessionToken(metaHdr, anyVerb, anyVerbV2, anyCnr, oid.ID{}) + require.NoError(b, err) + s.ResetTokenCheckCache() + } +} diff --git a/pkg/services/object/acl/v2/service_test.go b/pkg/services/object/acl/v2/service_test.go index b1a26a492c..65e55ca535 100644 --- a/pkg/services/object/acl/v2/service_test.go +++ b/pkg/services/object/acl/v2/service_test.go @@ -2,11 +2,13 @@ package v2_test import ( "testing" + "time" "github.com/nspcc-dev/neo-go/pkg/core/block" "github.com/nspcc-dev/neo-go/pkg/core/transaction" "github.com/nspcc-dev/neo-go/pkg/neorpc/result" "github.com/nspcc-dev/neo-go/pkg/smartcontract/trigger" + "github.com/nspcc-dev/neo-go/pkg/util" aclsvc "github.com/nspcc-dev/neofs-node/pkg/services/object/acl/v2" "github.com/nspcc-dev/neofs-sdk-go/bearer" apistatus "github.com/nspcc-dev/neofs-sdk-go/client/status" @@ -26,6 +28,10 @@ import ( type mockFSChain struct{} +func (x *mockFSChain) MsPerBlock() (res int64, err error) { + panic("unimplemented") +} + func (x *mockFSChain) InvokeContainedScript(*transaction.Transaction, *block.Header, *trigger.Type, *bool) (*result.Invoke, error) { panic("unimplemented") } @@ -34,6 +40,10 @@ func (x *mockFSChain) InContainerInLastTwoEpochs(cid.ID, []byte) (bool, error) { return false, nil } +func (x *mockFSChain) HasUserInNNS(string, util.Uint160) (bool, error) { + panic("unimplemented") +} + type mockIR struct { } @@ -41,6 +51,12 @@ func (x *mockIR) InnerRingKeys() ([][]byte, error) { return nil, nil } +type mockTimeProvider struct{} + +func (mockTimeProvider) Now() time.Time { + return time.Now() +} + type mockContainers struct { cnrs map[cid.ID]container.Container } @@ -61,6 +77,10 @@ func (x *mockNetmapper) GetNetMapByEpoch(uint64) (*netmap.NetMap, error) { panic("unimplemented") } +func (x *mockNetmapper) GetEpochBlockByTime(uint32) (uint32, error) { + panic("unimplemented") +} + func (x *mockNetmapper) Epoch() (uint64, error) { return x.curEpoch, nil } @@ -102,10 +122,12 @@ func testBearerTokenIssuer[REQ any](t *testing.T, exec func(*aclsvc.Service, REQ var fsChain mockFSChain var nm mockNetmapper var ir mockIR + var tp mockTimeProvider svc := aclsvc.New(&fsChain, aclsvc.WithContainerSource(&cnrs), aclsvc.WithNetmapper(&nm), aclsvc.WithIRFetcher(&ir), + aclsvc.WithTimeProvider(&tp), ) var bt bearer.Token diff --git a/pkg/services/object/delete/delete.go b/pkg/services/object/delete/delete.go index 3403a767a0..0f4dc17e42 100644 --- a/pkg/services/object/delete/delete.go +++ b/pkg/services/object/delete/delete.go @@ -4,6 +4,7 @@ import ( "context" "github.com/nspcc-dev/neofs-node/pkg/services/object/util" + "github.com/nspcc-dev/neofs-sdk-go/user" "go.uber.org/zap" ) @@ -11,7 +12,25 @@ import ( func (s *Service) Delete(ctx context.Context, prm Prm) error { // If session token is not found we will fail during tombstone PUT. // Here we fail immediately to ensure no unnecessary network communication is done. - if tok := prm.common.SessionToken(); tok != nil { + if tokV2 := prm.common.SessionTokenV2(); tokV2 != nil { + if _, err := s.keyStorage.GetKeyBySubjects(tokV2.Issuer(), tokV2.Subjects()); err != nil { + if s.nnsResolver == nil { + return err + } + nodeKey, getErr := s.keyStorage.GetKey(nil) + if getErr != nil { + return getErr + } + nodeUser := user.NewFromECDSAPublicKey(nodeKey.PublicKey) + ok, authErr := tokV2.AssertAuthority(nodeUser, s.nnsResolver) + if authErr != nil { + return authErr + } + if !ok { + return err + } + } + } else if tok := prm.common.SessionToken(); tok != nil { _, err := s.keyStorage.GetKey(&util.SessionInfo{ ID: tok.ID(), Owner: tok.Issuer(), diff --git a/pkg/services/object/delete/service.go b/pkg/services/object/delete/service.go index 849c5f22bb..985d1c9c0f 100644 --- a/pkg/services/object/delete/service.go +++ b/pkg/services/object/delete/service.go @@ -5,6 +5,7 @@ import ( putsvc "github.com/nspcc-dev/neofs-node/pkg/services/object/put" "github.com/nspcc-dev/neofs-node/pkg/services/object/util" oid "github.com/nspcc-dev/neofs-sdk-go/object/id" + "github.com/nspcc-dev/neofs-sdk-go/session/v2" "github.com/nspcc-dev/neofs-sdk-go/user" "go.uber.org/zap" ) @@ -40,6 +41,8 @@ type cfg struct { netInfo NetworkInfo keyStorage *util.KeyStorage + + nnsResolver session.NNSResolver } func defaultCfg() *cfg { @@ -89,3 +92,10 @@ func WithKeyStorage(ks *util.KeyStorage) Option { c.keyStorage = ks } } + +// WithNNSResolver returns option to set NNS resolver for checking session token subjects. +func WithNNSResolver(resolver session.NNSResolver) Option { + return func(c *cfg) { + c.nnsResolver = resolver + } +} diff --git a/pkg/services/object/get/exec.go b/pkg/services/object/get/exec.go index 3287629458..47efddb64c 100644 --- a/pkg/services/object/get/exec.go +++ b/pkg/services/object/get/exec.go @@ -13,6 +13,7 @@ import ( "github.com/nspcc-dev/neofs-sdk-go/netmap" "github.com/nspcc-dev/neofs-sdk-go/object" oid "github.com/nspcc-dev/neofs-sdk-go/object/id" + "github.com/nspcc-dev/neofs-sdk-go/user" "go.uber.org/zap" ) @@ -160,16 +161,38 @@ func (exec execCtx) key() (*ecdsa.PrivateKey, error) { return exec.prm.signerKey, nil } - var sessionInfo *util.SessionInfo - - if tok := exec.prm.common.SessionToken(); tok != nil { - sessionInfo = &util.SessionInfo{ + key, err := exec.svc.keyStore.GetKey(nil) + if err != nil { + return nil, err + } + if tokV2 := exec.prm.common.SessionTokenV2(); tokV2 != nil { + // For V2 tokens, the key is stored as the subjects + if keyForSession, err := exec.svc.keyStore.GetKeyBySubjects(tokV2.Issuer(), tokV2.Subjects()); err == nil { + key = keyForSession + } else if exec.svc.nnsResolver != nil { + nodeUser := user.NewFromECDSAPublicKey(key.PublicKey) + ok, authErr := tokV2.AssertAuthority(nodeUser, exec.svc.nnsResolver) + if authErr != nil { + return nil, fmt.Errorf("assert authority for session v2 token: %w", authErr) + } + if !ok { + return nil, fmt.Errorf("session v2 token authority assertion failed") + } + // node key is already in key + } else { + return nil, fmt.Errorf("get key for session v2 token: %w", err) + } + } else if tok := exec.prm.common.SessionToken(); tok != nil { + key, err = exec.svc.keyStore.GetKey(&util.SessionInfo{ ID: tok.ID(), Owner: tok.Issuer(), + }) + if err != nil { + return nil, err } } - return exec.svc.keyStore.GetKey(sessionInfo) + return key, nil } func (exec *execCtx) canAssemble() bool { diff --git a/pkg/services/object/get/service.go b/pkg/services/object/get/service.go index a1178ed684..0b23aefeaf 100644 --- a/pkg/services/object/get/service.go +++ b/pkg/services/object/get/service.go @@ -14,6 +14,8 @@ import ( "github.com/nspcc-dev/neofs-sdk-go/object" oid "github.com/nspcc-dev/neofs-sdk-go/object/id" "github.com/nspcc-dev/neofs-sdk-go/session" + sessionv2 "github.com/nspcc-dev/neofs-sdk-go/session/v2" + "github.com/nspcc-dev/neofs-sdk-go/user" "go.uber.org/zap" ) @@ -104,7 +106,10 @@ type cfg struct { keyStore interface { GetKey(*util.SessionInfo) (*ecdsa.PrivateKey, error) + GetKeyBySubjects(user.ID, []sessionv2.Target) (*ecdsa.PrivateKey, error) } + + nnsResolver sessionv2.NNSResolver } func defaultCfg() *cfg { @@ -165,3 +170,10 @@ func WithKeyStorage(store *util.KeyStorage) Option { c.keyStore = store } } + +// WithNNSResolver returns option to set NNS resolver for checking session token subjects. +func WithNNSResolver(resolver sessionv2.NNSResolver) Option { + return func(c *cfg) { + c.nnsResolver = resolver + } +} diff --git a/pkg/services/object/get/service_test.go b/pkg/services/object/get/service_test.go index 715022b687..6458a0d2d1 100644 --- a/pkg/services/object/get/service_test.go +++ b/pkg/services/object/get/service_test.go @@ -17,6 +17,8 @@ import ( protoobject "github.com/nspcc-dev/neofs-sdk-go/proto/object" protosession "github.com/nspcc-dev/neofs-sdk-go/proto/session" "github.com/nspcc-dev/neofs-sdk-go/session" + sessionv2 "github.com/nspcc-dev/neofs-sdk-go/session/v2" + "github.com/nspcc-dev/neofs-sdk-go/user" ) func newCommonParameters(local bool, sTok *session.Object, xs []string) (*util.CommonPrm, error) { @@ -146,6 +148,10 @@ func (x *mockKeyStorage) GetKey(*util.SessionInfo) (*ecdsa.PrivateKey, error) { return &x.privKey, nil } +func (x *mockKeyStorage) GetKeyBySubjects(user.ID, []sessionv2.Target) (*ecdsa.PrivateKey, error) { + return &x.privKey, nil +} + type unimplementedNeoFSNet struct{} func (x unimplementedNeoFSNet) GetNodesForObject(oid.Address) ([][]netmap.NodeInfo, []uint, []iec.Rule, error) { diff --git a/pkg/services/object/get/util.go b/pkg/services/object/get/util.go index 7a16bdae48..6a6d41da9b 100644 --- a/pkg/services/object/get/util.go +++ b/pkg/services/object/get/util.go @@ -19,6 +19,7 @@ import ( "github.com/nspcc-dev/neofs-sdk-go/object" oid "github.com/nspcc-dev/neofs-sdk-go/object/id" "github.com/nspcc-dev/neofs-sdk-go/session" + sessionv2 "github.com/nspcc-dev/neofs-sdk-go/session/v2" "github.com/nspcc-dev/neofs-sdk-go/user" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -194,7 +195,11 @@ func (c *clientWrapper) getObject(exec *execCtx, info coreclient.NodeInfo) (*obj if exec.prm.common.TTL() < 2 { opts.MarkLocal() } - if st := exec.prm.common.SessionToken(); st != nil && st.AssertObject(id) { + if stV2 := exec.prm.common.SessionTokenV2(); stV2 != nil { + if stV2.AssertVerb(sessionv2.VerbObjectHead, addr.Container()) { + opts.WithinSessionV2(*stV2) + } + } else if st := exec.prm.common.SessionToken(); st != nil && st.AssertObject(id) { opts.WithinSession(*st) } if bt := exec.prm.common.BearerToken(); bt != nil { @@ -229,7 +234,11 @@ func (c *clientWrapper) getObject(exec *execCtx, info coreclient.NodeInfo) (*obj if exec.prm.common.TTL() < 2 { opts.MarkLocal() } - if st := exec.prm.common.SessionToken(); st != nil && st.AssertObject(id) { + if stV2 := exec.prm.common.SessionTokenV2(); stV2 != nil { + if stV2.AssertVerb(sessionv2.VerbObjectRange, addr.Container()) { + opts.WithinSessionV2(*stV2) + } + } else if st := exec.prm.common.SessionToken(); st != nil && st.AssertObject(id) { opts.WithinSession(*st) } if bt := exec.prm.common.BearerToken(); bt != nil { @@ -259,7 +268,11 @@ func (c *clientWrapper) get(exec *execCtx, key *ecdsa.PrivateKey) (*object.Objec if exec.prm.common.TTL() < 2 { opts.MarkLocal() } - if st := exec.prm.common.SessionToken(); st != nil && st.AssertObject(id) { + if stV2 := exec.prm.common.SessionTokenV2(); stV2 != nil { + if stV2.AssertVerb(sessionv2.VerbObjectGet, addr.Container()) { + opts.WithinSessionV2(*stV2) + } + } else if st := exec.prm.common.SessionToken(); st != nil && st.AssertObject(id) { opts.WithinSession(*st) } if bt := exec.prm.common.BearerToken(); bt != nil { diff --git a/pkg/services/object/put/distibuted_test.go b/pkg/services/object/put/distibuted_test.go index 24f6e10631..abdfd01c8e 100644 --- a/pkg/services/object/put/distibuted_test.go +++ b/pkg/services/object/put/distibuted_test.go @@ -40,6 +40,7 @@ func (x testNetwork) IsLocalNodePublicKey(pk []byte) bool { return bytes.Equal(x func (x testNetwork) GetContainerNodes(cid.ID) (ContainerNodes, error) { panic("unimplemented") } func (x testNetwork) GetEpochBlock(uint64) (uint32, error) { panic("unimplemented") } +func (x testNetwork) GetEpochBlockByTime(uint32) (uint32, error) { panic("unimplemented") } type testWorkerPool struct { nCalls int diff --git a/pkg/services/object/put/remote.go b/pkg/services/object/put/remote.go index 27235318d3..df3d1c6d67 100644 --- a/pkg/services/object/put/remote.go +++ b/pkg/services/object/put/remote.go @@ -33,18 +33,30 @@ type RemotePutPrm struct { func putObjectToNode(ctx context.Context, nodeInfo clientcore.NodeInfo, obj *object.Object, keyStorage *util.KeyStorage, clientConstructor ClientConstructor, commonPrm *util.CommonPrm) error { - var sessionInfo *util.SessionInfo + var opts client.PrmObjectPutInit + opts.MarkLocal() - if tok := commonPrm.SessionToken(); tok != nil { - sessionInfo = &util.SessionInfo{ + key, err := keyStorage.GetKey(nil) + if err != nil { + return fmt.Errorf("could not receive local node's private key: %w", err) + } + + if tokV2 := commonPrm.SessionTokenV2(); tokV2 != nil { + // For V2 tokens, the key is stored as the subjects + if keyForSession, err := keyStorage.GetKeyBySubjects(tokV2.Issuer(), tokV2.Subjects()); err == nil { + key = keyForSession + } + opts.WithinSessionV2(*tokV2) + } else if tok := commonPrm.SessionToken(); tok != nil { + sessionInfo := &util.SessionInfo{ ID: tok.ID(), Owner: tok.Issuer(), } - } - - key, err := keyStorage.GetKey(sessionInfo) - if err != nil { - return fmt.Errorf("could not receive private key: %w", err) + key, err = keyStorage.GetKey(sessionInfo) + if err != nil { + return fmt.Errorf("could not receive private key: %w", err) + } + opts.WithinSession(*tok) } c, err := clientConstructor.Get(nodeInfo) @@ -52,11 +64,6 @@ func putObjectToNode(ctx context.Context, nodeInfo clientcore.NodeInfo, obj *obj return fmt.Errorf("could not create SDK client %s: %w", nodeInfo, err) } - var opts client.PrmObjectPutInit - opts.MarkLocal() - if st := commonPrm.SessionToken(); st != nil { - opts.WithinSession(*st) - } if bt := commonPrm.BearerToken(); bt != nil { opts.WithBearerToken(*bt) } diff --git a/pkg/services/object/put/service.go b/pkg/services/object/put/service.go index 2fb5a88b03..cc1377454b 100644 --- a/pkg/services/object/put/service.go +++ b/pkg/services/object/put/service.go @@ -15,6 +15,7 @@ import ( cid "github.com/nspcc-dev/neofs-sdk-go/container/id" netmapsdk "github.com/nspcc-dev/neofs-sdk-go/netmap" oid "github.com/nspcc-dev/neofs-sdk-go/object/id" + "github.com/nspcc-dev/neofs-sdk-go/session/v2" "github.com/nspcc-dev/neofs-sdk-go/user" "go.uber.org/zap" ) @@ -106,6 +107,9 @@ type NeoFSNetwork interface { IsLocalNodePublicKey([]byte) bool // GetEpochBlock returns FS chain height when given NeoFS epoch was ticked. GetEpochBlock(epoch uint64) (uint32, error) + // GetEpochBlockByTime returns FS chain height of block index when the latest epoch that + // started not later than the provided block time came. + GetEpochBlockByTime(t uint32) (uint32, error) } type cfg struct { @@ -137,6 +141,8 @@ type cfg struct { quotaLimiter QuotaLimiter payments PaymentChecker + + nnsResolver session.NNSResolver } func defaultCfg() *cfg { @@ -252,3 +258,9 @@ func WithNetworkMagic(m uint32) Option { c.networkMagic = m } } + +func WithNNSResolver(resolver session.NNSResolver) Option { + return func(c *cfg) { + c.nnsResolver = resolver + } +} diff --git a/pkg/services/object/put/service_test.go b/pkg/services/object/put/service_test.go index 24c4d40c33..106b82ec09 100644 --- a/pkg/services/object/put/service_test.go +++ b/pkg/services/object/put/service_test.go @@ -13,9 +13,11 @@ import ( "strconv" "sync" "testing" + "time" "github.com/google/uuid" "github.com/klauspost/reedsolomon" + "github.com/nspcc-dev/neo-go/pkg/crypto/keys" iec "github.com/nspcc-dev/neofs-node/internal/ec" iobject "github.com/nspcc-dev/neofs-node/internal/object" islices "github.com/nspcc-dev/neofs-node/internal/slices" @@ -42,6 +44,7 @@ import ( protosession "github.com/nspcc-dev/neofs-sdk-go/proto/session" apireputation "github.com/nspcc-dev/neofs-sdk-go/reputation" "github.com/nspcc-dev/neofs-sdk-go/session" + sessionv2 "github.com/nspcc-dev/neofs-sdk-go/session/v2" "github.com/nspcc-dev/neofs-sdk-go/user" usertest "github.com/nspcc-dev/neofs-sdk-go/user/test" "github.com/nspcc-dev/neofs-sdk-go/version" @@ -116,41 +119,78 @@ func TestPayments(t *testing.T) { WithRemoteWorkerPool(nodeWorkerPool), ) - stream, err := s.Put(context.Background()) - require.NoError(t, err) + t.Run("session v1", func(t *testing.T) { + stream, err := s.Put(context.Background()) + require.NoError(t, err) - var sessionToken session.Object - sessionToken.SetID(uuid.New()) - sessionToken.SetExp(1) - sessionToken.BindContainer(cID) - sessionToken.SetAuthKey(cluster.nodeSessions[0].signer.Public()) - require.NoError(t, sessionToken.Sign(owner)) + var sessionToken session.Object + sessionToken.SetID(uuid.New()) + sessionToken.SetExp(1) + sessionToken.BindContainer(cID) + sessionToken.SetAuthKey(cluster.nodeSessions[0].signer.Public()) + require.NoError(t, sessionToken.Sign(owner)) + + req := &protoobject.PutRequest{ + MetaHeader: &protosession.RequestMetaHeader{ + Ttl: 2, + SessionToken: sessionToken.ProtoMessage(), + }, + } + commonPrm, err := objutil.CommonPrmFromRequest(req) + if err != nil { + panic(err) + } - req := &protoobject.PutRequest{ - MetaHeader: &protosession.RequestMetaHeader{ - Ttl: 2, - SessionToken: sessionToken.ProtoMessage(), - }, - } - commonPrm, err := objutil.CommonPrmFromRequest(req) - if err != nil { - panic(err) - } + o := objecttest.Object() + o.SetContainerID(cID) + o.ResetRelations() + o.SetType(object.TypeRegular) + ip := new(PutInitPrm). + WithObject(&o). + WithCommonPrm(commonPrm) - o := objecttest.Object() - o.SetContainerID(cID) - o.ResetRelations() - o.SetType(object.TypeRegular) - ip := new(PutInitPrm). - WithObject(&o). - WithCommonPrm(commonPrm) + err = stream.Init(ip) + require.ErrorContains(t, err, "container is unpaid") + + payments.m[cID] = -1 + + require.NoError(t, stream.Init(ip)) + + payments.m[cID] = 123 // reset for next test + }) + + t.Run("session v2", func(t *testing.T) { + stream, err := s.Put(context.Background()) + require.NoError(t, err) + + sessionTokenV2 := newSessionTokenV2(t, cID, owner, cluster.nodeSessions, []sessionv2.Verb{sessionv2.VerbObjectPut}) + + req := &protoobject.PutRequest{ + MetaHeader: &protosession.RequestMetaHeader{ + Ttl: 2, + SessionTokenV2: sessionTokenV2.ProtoMessage(), + }, + } + commonPrm, err := objutil.CommonPrmFromRequest(req) + if err != nil { + panic(err) + } - err = stream.Init(ip) - require.ErrorContains(t, err, "container is unpaid") + o := objecttest.Object() + o.SetContainerID(cID) + o.ResetRelations() + o.SetType(object.TypeRegular) + ip := new(PutInitPrm). + WithObject(&o). + WithCommonPrm(commonPrm) + + err = stream.Init(ip) + require.ErrorContains(t, err, "container is unpaid") - payments.m[cID] = -1 + payments.m[cID] = -1 - require.NoError(t, stream.Init(ip)) + require.NoError(t, stream.Init(ip)) + }) } func TestQuotas(t *testing.T) { @@ -205,6 +245,19 @@ func TestQuotas(t *testing.T) { panic(err) } + sessionTokenV2 := newSessionTokenV2(t, cID, owner, cluster.nodeSessions, []sessionv2.Verb{sessionv2.VerbObjectPut}) + + reqV2 := &protoobject.PutRequest{ + MetaHeader: &protosession.RequestMetaHeader{ + Ttl: 2, + SessionTokenV2: sessionTokenV2.ProtoMessage(), + }, + } + commonPrmV2, err := objutil.CommonPrmFromRequest(reqV2) + if err != nil { + panic(err) + } + t.Run("known size before streaming", func(t *testing.T) { stream, err := s.Put(context.Background()) require.NoError(t, err) @@ -224,6 +277,25 @@ func TestQuotas(t *testing.T) { require.ErrorIs(t, err, apistatus.QuotaExceeded{}) }) + t.Run("known size before streaming/sessionv2", func(t *testing.T) { + stream, err := s.Put(context.Background()) + require.NoError(t, err) + + o := objecttest.Object() + o.SetPayloadSize(hardLimit + 1) + o.SetContainerID(cID) + o.SetOwner(owner.ID) + o.ResetRelations() + o.SetType(object.TypeRegular) + + ip := new(PutInitPrm). + WithObject(&o). + WithCommonPrm(commonPrmV2) + + err = stream.Init(ip) + require.ErrorIs(t, err, apistatus.QuotaExceeded{}) + }) + t.Run("payload exceeded", func(t *testing.T) { stream, err := s.Put(context.Background()) require.NoError(t, err) @@ -245,6 +317,28 @@ func TestQuotas(t *testing.T) { err = stream.SendChunk(&sendPrm) require.ErrorIs(t, err, apistatus.ErrQuotaExceeded) }) + + t.Run("payload exceeded/sessionv2", func(t *testing.T) { + stream, err := s.Put(context.Background()) + require.NoError(t, err) + + o := objecttest.Object() + o.SetPayloadSize(hardLimit - 1) + o.SetContainerID(cID) + o.SetOwner(owner.ID) + o.ResetRelations() + o.SetType(object.TypeRegular) + + ip := new(PutInitPrm). + WithObject(&o). + WithCommonPrm(commonPrmV2) + err = stream.Init(ip) + require.NoError(t, err) + + sendPrm := PutChunkPrm{make([]byte, hardLimit+1)} + err = stream.SendChunk(&sendPrm) + require.ErrorIs(t, err, apistatus.ErrQuotaExceeded) + }) } func Test_Slicing_REP3(t *testing.T) { @@ -268,16 +362,25 @@ func Test_Slicing_REP3(t *testing.T) { {name: "limitX5", ln: maxObjectSize * 5}, } { t.Run(tc.name, func(t *testing.T) { - testSlicingREP3(t, cluster, tc.ln, repNodes, cnrReserveNodes, outCnrNodes) + testSlicingREP3(t, cluster, tc.ln, repNodes, cnrReserveNodes, outCnrNodes, false) + t.Run("sessionv2", func(t *testing.T) { + testSlicingREP3(t, cluster, tc.ln, repNodes, cnrReserveNodes, outCnrNodes, true) + }) }) } t.Run("tombstone", func(t *testing.T) { - testTombstoneSlicing(t, cluster, repNodes+cnrReserveNodes, outCnrNodes) + testTombstoneSlicing(t, cluster, repNodes+cnrReserveNodes, outCnrNodes, false) + t.Run("sessionv2", func(t *testing.T) { + testTombstoneSlicing(t, cluster, repNodes+cnrReserveNodes, outCnrNodes, true) + }) }) t.Run("lock", func(t *testing.T) { - testLockSlicing(t, cluster, repNodes+cnrReserveNodes, outCnrNodes) + testLockSlicing(t, cluster, repNodes+cnrReserveNodes, outCnrNodes, false) + t.Run("sessionv2", func(t *testing.T) { + testLockSlicing(t, cluster, repNodes+cnrReserveNodes, outCnrNodes, true) + }) }) } @@ -336,45 +439,68 @@ func Test_Slicing_EC(t *testing.T) { if tc.skip != "" { t.Skip(tc.skip) } - testSlicingECRules(t, cluster, tc.ln, rules, maxTotalParts, cnrReserveNodes, outCnrNodes) + testSlicingECRules(t, cluster, tc.ln, rules, maxTotalParts, cnrReserveNodes, outCnrNodes, false) + t.Run("sessionv2", func(t *testing.T) { + testSlicingECRules(t, cluster, tc.ln, rules, maxTotalParts, cnrReserveNodes, outCnrNodes, true) + }) }) } t.Run("tombstone", func(t *testing.T) { - testTombstoneSlicing(t, cluster, maxTotalParts+cnrReserveNodes, outCnrNodes) + testTombstoneSlicing(t, cluster, maxTotalParts+cnrReserveNodes, outCnrNodes, false) + t.Run("sessionv2", func(t *testing.T) { + testTombstoneSlicing(t, cluster, maxTotalParts+cnrReserveNodes, outCnrNodes, true) + }) }) t.Run("lock", func(t *testing.T) { - testLockSlicing(t, cluster, maxTotalParts+cnrReserveNodes, outCnrNodes) + testLockSlicing(t, cluster, maxTotalParts+cnrReserveNodes, outCnrNodes, false) + t.Run("sessionv2", func(t *testing.T) { + testLockSlicing(t, cluster, maxTotalParts+cnrReserveNodes, outCnrNodes, true) + }) }) } -func testSlicingREP3(t *testing.T, cluster *testCluster, ln uint64, repNodes, cnrReserveNodes, outCnrNodes int) { +func testSlicingREP3(t *testing.T, cluster *testCluster, ln uint64, repNodes, cnrReserveNodes, outCnrNodes int, isSessionTokenV2 bool) { + owner := usertest.User() + var srcObj object.Object srcObj.SetContainerID(cidtest.ID()) - srcObj.SetOwner(usertest.ID()) + srcObj.SetOwner(owner.UserID()) srcObj.SetAttributes( object.NewAttribute("attr1", "val1"), object.NewAttribute("attr2", "val2"), ) srcObj.SetPayload(testutil.RandByteSlice(ln)) - var sessionToken session.Object - sessionToken.SetID(uuid.New()) - sessionToken.SetExp(1) - sessionToken.BindContainer(cidtest.ID()) + var ( + sessionToken *session.Object + sessionTokenV2 *sessionv2.Token + ) + if isSessionTokenV2 { + sessionTokenV2 = newSessionTokenV2(t, cidtest.ID(), owner, cluster.nodeSessions, []sessionv2.Verb{sessionv2.VerbObjectPut}) + } else { + sessionToken = &session.Object{} + sessionToken.SetID(uuid.New()) + sessionToken.SetExp(1) + sessionToken.BindContainer(cidtest.ID()) + } testThroughNode := func(t *testing.T, idx int) { - sessionToken.SetAuthKey(cluster.nodeSessions[idx].signer.Public()) - require.NoError(t, sessionToken.Sign(usertest.User())) + if isSessionTokenV2 { + storeObjectWithSession(t, cluster.nodeServices[idx], srcObj, nil, sessionTokenV2) + } else { + sessionToken.SetAuthKey(cluster.nodeSessions[idx].signer.Public()) + require.NoError(t, sessionToken.Sign(owner)) - storeObjectWithSession(t, cluster.nodeServices[idx], srcObj, sessionToken) + storeObjectWithSession(t, cluster.nodeServices[idx], srcObj, sessionToken, nil) + } nodeObjLists := cluster.allStoredObjects() var restoredObj object.Object if ln > maxObjectSize { - restoredObj = assertSplitChain(t, maxObjectSize, ln, sessionToken, nodeObjLists[0]) + restoredObj = assertSplitChain(t, maxObjectSize, ln, sessionToken, sessionTokenV2, nodeObjLists[0]) for i := 1; i < repNodes; i++ { require.Equal(t, nodeObjLists[0], nodeObjLists[i], i) @@ -405,9 +531,14 @@ func testSlicingREP3(t *testing.T, cluster *testCluster, ln uint64, repNodes, cn assertObjectIntegrity(t, restoredObj) require.True(t, bytes.Equal(srcObj.Payload(), restoredObj.Payload())) - require.Equal(t, sessionToken, *restoredObj.SessionToken()) require.Equal(t, srcObj.GetContainerID(), restoredObj.GetContainerID()) - require.Equal(t, sessionToken.Issuer(), restoredObj.Owner()) + if isSessionTokenV2 { + require.Equal(t, sessionTokenV2, restoredObj.SessionTokenV2()) + require.Equal(t, sessionTokenV2.Issuer(), restoredObj.Owner()) + } else { + require.Equal(t, sessionToken, restoredObj.SessionToken()) + require.Equal(t, sessionToken.Issuer(), restoredObj.Owner()) + } require.EqualValues(t, currentEpoch, restoredObj.CreationEpoch()) require.Equal(t, object.TypeRegular, restoredObj.Type()) require.Equal(t, srcObj.Attributes(), restoredObj.Attributes()) @@ -421,32 +552,50 @@ func testSlicingREP3(t *testing.T, cluster *testCluster, ln uint64, repNodes, cn } } -func testSlicingECRules(t *testing.T, cluster *testCluster, ln uint64, rules []iec.Rule, maxTotalParts, cnrReserveNodes, outCnrNodes int) { +func testSlicingECRules(t *testing.T, cluster *testCluster, ln uint64, rules []iec.Rule, maxTotalParts, cnrReserveNodes, outCnrNodes int, isSessionV2 bool) { + owner := usertest.User() var srcObj object.Object srcObj.SetContainerID(cidtest.ID()) - srcObj.SetOwner(usertest.ID()) + srcObj.SetOwner(owner.UserID()) srcObj.SetAttributes( object.NewAttribute("attr1", "val1"), object.NewAttribute("attr2", "val2"), ) - var sessionToken session.Object - sessionToken.SetID(uuid.New()) - sessionToken.SetExp(1) - sessionToken.BindContainer(cidtest.ID()) - srcObj.SetPayload(testutil.RandByteSlice(ln)) + var ( + sessionTokenV2 *sessionv2.Token + sessionToken *session.Object + ) + + if isSessionV2 { + sessionTokenV2 = newSessionTokenV2(t, cidtest.ID(), owner, nil, []sessionv2.Verb{sessionv2.VerbObjectPut}) + } else { + sessionToken = &session.Object{} + sessionToken.SetID(uuid.New()) + sessionToken.SetExp(1) + sessionToken.BindContainer(cidtest.ID()) + srcObj.SetPayload(testutil.RandByteSlice(ln)) + } testThroughNode := func(t *testing.T, idx int) { - sessionToken.SetAuthKey(cluster.nodeSessions[idx].signer.Public()) - require.NoError(t, sessionToken.Sign(usertest.User())) + if isSessionV2 { + pk := cluster.nodeSessions[idx].signer.ECDSAPrivateKey.PublicKey + require.NoError(t, sessionTokenV2.SetSubjects([]sessionv2.Target{sessionv2.NewTargetUser(user.NewFromECDSAPublicKey(pk))})) - storeObjectWithSession(t, cluster.nodeServices[idx], srcObj, sessionToken) + require.NoError(t, sessionTokenV2.Sign(owner)) + storeObjectWithSession(t, cluster.nodeServices[idx], srcObj, nil, sessionTokenV2) + } else { + sessionToken.SetAuthKey(cluster.nodeSessions[idx].signer.Public()) + require.NoError(t, sessionToken.Sign(owner)) + + storeObjectWithSession(t, cluster.nodeServices[idx], srcObj, sessionToken, nil) + } nodeObjLists := cluster.allStoredObjects() var restoredObj object.Object if ln > maxObjectSize { - restoredObj = checkAndCutSplitECObject(t, ln, sessionToken, rules, nodeObjLists) + restoredObj = checkAndCutSplitECObject(t, ln, sessionToken, sessionTokenV2, rules, nodeObjLists) } else { restoredObj = checkAndCutUnsplitECObject(t, rules, nodeObjLists) } @@ -454,9 +603,14 @@ func testSlicingECRules(t *testing.T, cluster *testCluster, ln uint64, rules []i require.Zero(t, islices.TwoDimSliceElementCount(nodeObjLists)) assertObjectIntegrity(t, restoredObj) - require.Equal(t, sessionToken, *restoredObj.SessionToken()) require.Equal(t, srcObj.GetContainerID(), restoredObj.GetContainerID()) - require.Equal(t, sessionToken.Issuer(), restoredObj.Owner()) + if isSessionV2 { + require.Equal(t, sessionTokenV2, restoredObj.SessionTokenV2()) + require.Equal(t, sessionTokenV2.Issuer(), restoredObj.Owner()) + } else { + require.Equal(t, sessionToken, restoredObj.SessionToken()) + require.Equal(t, sessionToken.Issuer(), restoredObj.Owner()) + } require.EqualValues(t, currentEpoch, restoredObj.CreationEpoch()) require.Equal(t, object.TypeRegular, restoredObj.Type()) require.Equal(t, srcObj.Attributes(), restoredObj.Attributes()) @@ -471,37 +625,55 @@ func testSlicingECRules(t *testing.T, cluster *testCluster, ln uint64, rules []i } } -func testTombstoneSlicing(t *testing.T, cluster *testCluster, cnrNodeNum, outCnrNodeNum int) { - testSysObjectSlicing(t, cluster, cnrNodeNum, outCnrNodeNum, object.TypeTombstone, (*object.Object).AssociateDeleted) +func testTombstoneSlicing(t *testing.T, cluster *testCluster, cnrNodeNum, outCnrNodeNum int, isSessionV2 bool) { + testSysObjectSlicing(t, cluster, cnrNodeNum, outCnrNodeNum, object.TypeTombstone, (*object.Object).AssociateDeleted, isSessionV2) } -func testLockSlicing(t *testing.T, cluster *testCluster, cnrNodeNum, outCnrNodeNum int) { - testSysObjectSlicing(t, cluster, cnrNodeNum, outCnrNodeNum, object.TypeLock, (*object.Object).AssociateLocked) +func testLockSlicing(t *testing.T, cluster *testCluster, cnrNodeNum, outCnrNodeNum int, isSessionV2 bool) { + testSysObjectSlicing(t, cluster, cnrNodeNum, outCnrNodeNum, object.TypeLock, (*object.Object).AssociateLocked, isSessionV2) } -func testSysObjectSlicing(t *testing.T, cluster *testCluster, cnrNodeNum, outCnrNodeNum int, typ object.Type, associate func(*object.Object, oid.ID)) { +func testSysObjectSlicing(t *testing.T, cluster *testCluster, cnrNodeNum, outCnrNodeNum int, typ object.Type, associate func(*object.Object, oid.ID), isSessionV2 bool) { target := oidtest.ID() + owner := usertest.User() var verCur = version.Current() var srcObj object.Object srcObj.SetVersion(&verCur) srcObj.SetContainerID(cidtest.ID()) - srcObj.SetOwner(usertest.ID()) + srcObj.SetOwner(owner.UserID()) srcObj.SetAttributes( object.NewAttribute(object.AttributeExpirationEpoch, "123"), ) associate(&srcObj, target) - var sessionToken session.Object - sessionToken.SetID(uuid.New()) - sessionToken.SetExp(1) - sessionToken.BindContainer(cidtest.ID()) + var ( + sessionToken *session.Object + sessionTokenV2 *sessionv2.Token + ) + if isSessionV2 { + sessionTokenV2 = newSessionTokenV2(t, cidtest.ID(), owner, nil, []sessionv2.Verb{sessionv2.VerbObjectPut}) + } else { + sessionToken = &session.Object{} + sessionToken.SetID(uuid.New()) + sessionToken.SetExp(1) + sessionToken.BindContainer(cidtest.ID()) + } testThroughNode := func(t *testing.T, idx int) { - sessionToken.SetAuthKey(cluster.nodeSessions[idx].signer.Public()) - require.NoError(t, sessionToken.Sign(usertest.User())) + if isSessionV2 { + pk := cluster.nodeSessions[idx].signer.ECDSAPrivateKey.PublicKey + require.NoError(t, sessionTokenV2.SetSubjects([]sessionv2.Target{sessionv2.NewTargetUser(user.NewFromECDSAPublicKey(pk))})) - storeObjectWithSession(t, cluster.nodeServices[idx], srcObj, sessionToken) + require.NoError(t, sessionTokenV2.Sign(owner)) + + storeObjectWithSession(t, cluster.nodeServices[idx], srcObj, nil, sessionTokenV2) + } else { + sessionToken.SetAuthKey(cluster.nodeSessions[idx].signer.Public()) + require.NoError(t, sessionToken.Sign(usertest.User())) + + storeObjectWithSession(t, cluster.nodeServices[idx], srcObj, sessionToken, nil) + } nodeObjLists := cluster.allStoredObjects() @@ -522,9 +694,14 @@ func testSysObjectSlicing(t *testing.T, cluster *testCluster, cnrNodeNum, outCnr assertObjectIntegrity(t, restoredObj) require.Empty(t, restoredObj.Payload()) - require.Equal(t, sessionToken, *restoredObj.SessionToken()) require.Equal(t, srcObj.GetContainerID(), restoredObj.GetContainerID()) - require.Equal(t, sessionToken.Issuer(), restoredObj.Owner()) + if isSessionV2 { + require.Equal(t, sessionTokenV2, restoredObj.SessionTokenV2()) + require.Equal(t, sessionTokenV2.Issuer(), restoredObj.Owner()) + } else { + require.Equal(t, sessionToken, restoredObj.SessionToken()) + require.Equal(t, sessionToken.Issuer(), restoredObj.Owner()) + } require.EqualValues(t, currentEpoch, restoredObj.CreationEpoch()) require.Equal(t, typ, restoredObj.Type()) require.Equal(t, target, restoredObj.AssociatedObject()) @@ -544,10 +721,19 @@ func testSysObjectSlicing(t *testing.T, cluster *testCluster, cnrNodeNum, outCnr } for i := range cnrNodeNum { - sessionToken.SetAuthKey(cluster.nodeSessions[i].signer.Public()) - require.NoError(t, sessionToken.Sign(usertest.User())) + var err error + if isSessionV2 { + pk := cluster.nodeSessions[i].signer.ECDSAPrivateKey.PublicKey + require.NoError(t, sessionTokenV2.SetSubjects([]sessionv2.Target{sessionv2.NewTargetUser(user.NewFromECDSAPublicKey(pk))})) - err := putObjectWithSession(cluster.nodeServices[i], srcObj, sessionToken) + require.NoError(t, sessionTokenV2.Sign(owner)) + err = putObjectWithSession(cluster.nodeServices[i], srcObj, nil, sessionTokenV2) + } else { + sessionToken.SetAuthKey(cluster.nodeSessions[i].signer.Public()) + require.NoError(t, sessionToken.Sign(owner)) + + err = putObjectWithSession(cluster.nodeServices[i], srcObj, sessionToken, nil) + } require.ErrorContains(t, err, "incomplete object PUT by placement: number of replicas cannot be met for list #0") require.ErrorContains(t, err, "some error") require.NotErrorIs(t, err, apistatus.ErrIncomplete) @@ -581,7 +767,9 @@ func newTestClusterForRepPolicyWithContainer(t *testing.T, repNodes, cnrReserveN } for i := range allNodes { - nodeKey := neofscryptotest.ECDSAPrivateKey() + nodeKey, err := keys.NewPrivateKey() + require.NoError(t, err) + allNodes[i].SetPublicKey(nodeKey.PublicKey().Bytes()) nodeWorkerPool, err := ants.NewPool(len(cnrNodes), ants.WithNonblocking(true)) require.NoError(t, err) @@ -603,7 +791,7 @@ func newTestClusterForRepPolicyWithContainer(t *testing.T, repNodes, cnrReserveN quotas{math.MaxUint64, math.MaxUint64}, &payments{}, WithLogger(zaptest.NewLogger(t).With(zap.Int("node", i))), - WithKeyStorage(objutil.NewKeyStorage(&nodeKey, cluster.nodeSessions[i], &cluster.nodeNetworks[i])), + WithKeyStorage(objutil.NewKeyStorage(&nodeKey.PrivateKey, cluster.nodeSessions[i], &cluster.nodeNetworks[i])), WithObjectStorage(&cluster.nodeLocalStorages[i]), WithMaxSizeSource(mockMaxSize(maxObjectSize)), WithContainerSource(mockContainer(cnr)), @@ -667,6 +855,10 @@ func (*mockNetwork) GetEpochBlock(uint64) (uint32, error) { panic("unimplemented") } +func (*mockNetwork) GetEpochBlockByTime(uint32) (uint32, error) { + panic("unimplemented") +} + type mockContainerNodes struct { unsorted [][]netmap.NodeInfo sorted [][]netmap.NodeInfo @@ -701,6 +893,10 @@ type mockNodeSession struct { expiresAt uint64 } +func (x mockNodeSession) FindTokenBySubjects(user.ID, []sessionv2.Target) *storage.PrivateToken { + return storage.NewPrivateToken(&x.signer.ECDSAPrivateKey, x.expiresAt) +} + func (x mockNodeSession) GetToken(user.ID, []byte) *storage.PrivateToken { return storage.NewPrivateToken(&x.signer.ECDSAPrivateKey, x.expiresAt) } @@ -921,11 +1117,11 @@ func (x *testCluster) resetAllStoredObjects() { } } -func storeObjectWithSession(t *testing.T, svc *Service, obj object.Object, st session.Object) { - require.NoError(t, putObjectWithSession(svc, obj, st)) +func storeObjectWithSession(t *testing.T, svc *Service, obj object.Object, st *session.Object, st2 *sessionv2.Token) { + require.NoError(t, putObjectWithSession(svc, obj, st, st2)) } -func putObjectWithSession(svc *Service, obj object.Object, st session.Object) error { +func putObjectWithSession(svc *Service, obj object.Object, st *session.Object, st2 *sessionv2.Token) error { stream, err := svc.Put(context.Background()) if err != nil { return fmt.Errorf("init stream: %w", err) @@ -933,10 +1129,14 @@ func putObjectWithSession(svc *Service, obj object.Object, st session.Object) er req := &protoobject.PutRequest{ MetaHeader: &protosession.RequestMetaHeader{ - Ttl: 2, - SessionToken: st.ProtoMessage(), + Ttl: 2, }, } + if st2 != nil { + req.MetaHeader.SessionTokenV2 = st2.ProtoMessage() + } else if st != nil { + req.MetaHeader.SessionToken = st.ProtoMessage() + } commonPrm, err := objutil.CommonPrmFromRequest(req) if err != nil { @@ -963,15 +1163,21 @@ func putObjectWithSession(svc *Service, obj object.Object, st session.Object) er return nil } -func assertSplitChain(t *testing.T, limit, ln uint64, sessionToken session.Object, members []object.Object) object.Object { +func assertSplitChain(t *testing.T, limit, ln uint64, sessionToken *session.Object, sessionTokenV2 *sessionv2.Token, members []object.Object) object.Object { require.Len(t, members, splitMembersCount(limit, ln)) // all for _, member := range members { assertObjectIntegrity(t, member) require.LessOrEqual(t, member.PayloadSize(), limit) - require.Equal(t, sessionToken, *member.SessionToken()) - require.Equal(t, sessionToken.Issuer(), member.Owner()) + if sessionToken != nil { + require.Equal(t, sessionToken, member.SessionToken()) + require.Equal(t, sessionToken.Issuer(), member.Owner()) + } + if sessionTokenV2 != nil { + require.Equal(t, sessionTokenV2, member.SessionTokenV2()) + require.Equal(t, sessionTokenV2.OriginalIssuer(), member.Owner()) + } require.EqualValues(t, currentEpoch, member.CreationEpoch()) require.Empty(t, member.Attributes()) require.True(t, member.HasParent()) @@ -1170,7 +1376,7 @@ func checkAndCutParentHeaderFromECPart(t *testing.T, part object.Object) object. require.Equal(t, par.Owner(), part.Owner()) require.Equal(t, par.CreationEpoch(), part.CreationEpoch()) require.Equal(t, object.TypeRegular, part.Type()) - require.NotZero(t, par.SessionToken()) + require.True(t, par.SessionToken() != nil || par.SessionTokenV2() != nil) return *par } @@ -1191,7 +1397,7 @@ func checkAndGetECPartInfo(t testing.TB, part object.Object) (int, int) { return ruleIdx, partIdx } -func checkAndCutSplitECObject(t *testing.T, ln uint64, sessionToken session.Object, rules []iec.Rule, nodeObjLists [][]object.Object) object.Object { +func checkAndCutSplitECObject(t *testing.T, ln uint64, sessionToken *session.Object, sessionTokenV2 *sessionv2.Token, rules []iec.Rule, nodeObjLists [][]object.Object) object.Object { splitPartCount := splitMembersCount(maxObjectSize, ln) var expectedCount int @@ -1207,7 +1413,7 @@ func checkAndCutSplitECObject(t *testing.T, ln uint64, sessionToken session.Obje splitParts = append(splitParts, splitPart) } - restoredObj := assertSplitChain(t, maxObjectSize, ln, sessionToken, splitParts) + restoredObj := assertSplitChain(t, maxObjectSize, ln, sessionToken, sessionTokenV2, splitParts) return restoredObj } @@ -1239,3 +1445,27 @@ func checkAndCutECPartsForRule(t *testing.T, ruleIdx int, rule iec.Rule, nodeObj return parts } + +func newSessionTokenV2(t *testing.T, cnrID cid.ID, owner user.Signer, nodes []mockNodeSession, verbs []sessionv2.Verb) *sessionv2.Token { + var sessionTokenV2 sessionv2.Token + sessionTokenV2.SetVersion(sessionv2.TokenCurrentVersion) + sessionTokenV2.SetNonce(sessionv2.RandomNonce()) + + currentTime := time.Now() + sessionTokenV2.SetIat(currentTime) + sessionTokenV2.SetNbf(currentTime) + sessionTokenV2.SetExp(currentTime.Add(10 * time.Hour)) + ctx, err := sessionv2.NewContext(cnrID, verbs) + require.NoError(t, err) + require.NoError(t, sessionTokenV2.SetContexts([]sessionv2.Context{ctx})) + + if nodes != nil { + for _, s := range nodes { + require.NoError(t, sessionTokenV2.AddSubject(sessionv2.NewTargetUser(user.NewFromECDSAPublicKey(s.signer.ECDSAPrivateKey.PublicKey)))) + } + + require.NoError(t, sessionTokenV2.Sign(owner)) + } + + return &sessionTokenV2 +} diff --git a/pkg/services/object/put/slice.go b/pkg/services/object/put/slice.go index e4a6036d1d..4d9776ba64 100644 --- a/pkg/services/object/put/slice.go +++ b/pkg/services/object/put/slice.go @@ -13,6 +13,7 @@ import ( oid "github.com/nspcc-dev/neofs-sdk-go/object/id" "github.com/nspcc-dev/neofs-sdk-go/object/slicer" "github.com/nspcc-dev/neofs-sdk-go/session" + sessionv2 "github.com/nspcc-dev/neofs-sdk-go/session/v2" "github.com/nspcc-dev/neofs-sdk-go/user" ) @@ -20,6 +21,7 @@ type slicingTarget struct { ctx context.Context signer user.Signer sessionToken *session.Object + sessionTokenV2 *sessionv2.Token currentEpoch uint64 maxObjSize uint64 homoHashDisabled bool @@ -38,6 +40,7 @@ func newSlicingTarget( homoHashDisabled bool, signer user.Signer, sessionToken *session.Object, + sessionTokenV2 *sessionv2.Token, curEpoch uint64, initNextTarget internal.Target, ) internal.Target { @@ -45,6 +48,7 @@ func newSlicingTarget( ctx: ctx, signer: signer, sessionToken: sessionToken, + sessionTokenV2: sessionTokenV2, currentEpoch: curEpoch, maxObjSize: maxObjSize, homoHashDisabled: homoHashDisabled, @@ -56,7 +60,9 @@ func (x *slicingTarget) WriteHeader(hdr *object.Object) error { var opts slicer.Options opts.SetObjectPayloadLimit(x.maxObjSize) opts.SetCurrentNeoFSEpoch(x.currentEpoch) - if x.sessionToken != nil { + if x.sessionTokenV2 != nil { + opts.SetSessionV2(*x.sessionTokenV2) + } else if x.sessionToken != nil { opts.SetSession(*x.sessionToken) } if !x.homoHashDisabled { diff --git a/pkg/services/object/put/streamer.go b/pkg/services/object/put/streamer.go index 15a4ca8f90..e7f91c1ae1 100644 --- a/pkg/services/object/put/streamer.go +++ b/pkg/services/object/put/streamer.go @@ -94,29 +94,47 @@ func (p *Streamer) initTarget(prm *PutInitPrm) error { } sToken := prm.common.SessionToken() + sTokenV2 := prm.common.SessionTokenV2() // prepare trusted-Put object target - // get private token from local storage - var sessionInfo *util.SessionInfo - - if sToken != nil { - sessionInfo = &util.SessionInfo{ + sessionKey, err := p.keyStorage.GetKey(nil) + if err != nil { + return fmt.Errorf("(%T) could not receive node key for V2 token: %w", p, err) + } + if sTokenV2 != nil { + // For V2 tokens, the key is stored as the subjects + if keyForSession, err := p.keyStorage.GetKeyBySubjects(sTokenV2.Issuer(), sTokenV2.Subjects()); err == nil { + sessionKey = keyForSession + } else if p.nnsResolver != nil { + nodeUser := user.NewFromECDSAPublicKey(sessionKey.PublicKey) + ok, authErr := sTokenV2.AssertAuthority(nodeUser, p.nnsResolver) + if authErr != nil { + return fmt.Errorf("assert authority for session v2 token: %w", authErr) + } + if !ok { + return fmt.Errorf("session v2 token authority assertion failed") + } + // node key is already in key + } else { + return fmt.Errorf("get key for session v2 token: %w", err) + } + } else if sToken != nil { + sessionInfo := &util.SessionInfo{ ID: sToken.ID(), Owner: sToken.Issuer(), } - } - - sessionKey, err := p.keyStorage.GetKey(sessionInfo) - if err != nil { - return fmt.Errorf("(%T) could not receive session key: %w", p, err) + sessionKey, err = p.keyStorage.GetKey(sessionInfo) + if err != nil { + return fmt.Errorf("(%T) could not receive session key: %w", p, err) + } } signer := neofsecdsa.SignerRFC6979(*sessionKey) // In case session token is missing, the line above returns the default key. // If it isn't owner key, replication attempts will fail, thus this check. - if sToken == nil { + if sToken == nil && sTokenV2 == nil { ownerObj := prm.hdr.Owner() if ownerObj.IsZero() { return errors.New("missing object owner") @@ -144,6 +162,7 @@ func (p *Streamer) initTarget(prm *PutInitPrm) error { !homomorphicChecksumRequired, sessionSigner, sToken, + sTokenV2, p.networkState.CurrentEpoch(), p.newCommonTarget(prm), ), diff --git a/pkg/services/object/search/service.go b/pkg/services/object/search/service.go index 646bb70712..15edab15b9 100644 --- a/pkg/services/object/search/service.go +++ b/pkg/services/object/search/service.go @@ -9,6 +9,7 @@ import ( cid "github.com/nspcc-dev/neofs-sdk-go/container/id" netmapsdk "github.com/nspcc-dev/neofs-sdk-go/netmap" oid "github.com/nspcc-dev/neofs-sdk-go/object/id" + "github.com/nspcc-dev/neofs-sdk-go/session/v2" "go.uber.org/zap" ) @@ -56,6 +57,8 @@ type cfg struct { } keyStore *util.KeyStorage + + nnsResolver session.NNSResolver } func defaultCfg() *cfg { @@ -111,3 +114,10 @@ func WithKeyStorage(store *util.KeyStorage) Option { c.keyStore = store } } + +// WithNNSResolver returns option to set NNS resolver for checking session token subjects. +func WithNNSResolver(resolver session.NNSResolver) Option { + return func(c *cfg) { + c.nnsResolver = resolver + } +} diff --git a/pkg/services/object/search/util.go b/pkg/services/object/search/util.go index a0980ef393..96a8959cf9 100644 --- a/pkg/services/object/search/util.go +++ b/pkg/services/object/search/util.go @@ -10,6 +10,7 @@ import ( "github.com/nspcc-dev/neofs-node/pkg/services/object/util" sdkclient "github.com/nspcc-dev/neofs-sdk-go/client" oid "github.com/nspcc-dev/neofs-sdk-go/object/id" + sessionv2 "github.com/nspcc-dev/neofs-sdk-go/session/v2" "github.com/nspcc-dev/neofs-sdk-go/user" ) @@ -76,25 +77,46 @@ func (c *clientWrapper) searchObjects(ctx context.Context, exec *execCtx, info c return exec.prm.forwarder(info, c.client) } - var sessionInfo *util.SessionInfo - - if tok := exec.prm.common.SessionToken(); tok != nil { - sessionInfo = &util.SessionInfo{ + key, err := exec.svc.keyStore.GetKey(nil) + if err != nil { + return nil, err + } + if tokV2 := exec.prm.common.SessionTokenV2(); tokV2 != nil { + // For V2 tokens, the key is stored as the subjects + if keyForSession, err := exec.svc.keyStore.GetKeyBySubjects(tokV2.Issuer(), tokV2.Subjects()); err == nil { + key = keyForSession + } else if exec.svc.nnsResolver != nil { + nodeUser := user.NewFromECDSAPublicKey(key.PublicKey) + ok, authErr := tokV2.AssertAuthority(nodeUser, exec.svc.nnsResolver) + if authErr != nil { + return nil, fmt.Errorf("assert authority for session v2 token: %w", authErr) + } + if !ok { + return nil, fmt.Errorf("session v2 token authority assertion failed") + } + // node key is already in key + } else { + return nil, fmt.Errorf("get key for session v2 token: %w", err) + } + } else if tok := exec.prm.common.SessionToken(); tok != nil { + key, err = exec.svc.keyStore.GetKey(&util.SessionInfo{ ID: tok.ID(), Owner: tok.Issuer(), + }) + if err != nil { + return nil, err } } - key, err := exec.svc.keyStore.GetKey(sessionInfo) - if err != nil { - return nil, err - } - var opts sdkclient.PrmObjectSearch if exec.prm.common.TTL() < 2 { opts.MarkLocal() } - if st := exec.prm.common.SessionToken(); st != nil { + if stV2 := exec.prm.common.SessionTokenV2(); stV2 != nil { + if stV2.AssertVerb(sessionv2.VerbObjectSearch, exec.containerID()) { + opts.WithinSessionV2(*stV2) + } + } else if st := exec.prm.common.SessionToken(); st != nil { opts.WithinSession(*st) } if bt := exec.prm.common.BearerToken(); bt != nil { diff --git a/pkg/services/object/server.go b/pkg/services/object/server.go index 48e6f25d86..8bfed63ef9 100644 --- a/pkg/services/object/server.go +++ b/pkg/services/object/server.go @@ -43,6 +43,7 @@ import ( "github.com/nspcc-dev/neofs-sdk-go/proto/refs" protosession "github.com/nspcc-dev/neofs-sdk-go/proto/session" protostatus "github.com/nspcc-dev/neofs-sdk-go/proto/status" + sessionv2 "github.com/nspcc-dev/neofs-sdk-go/session/v2" "github.com/nspcc-dev/neofs-sdk-go/stat" "github.com/nspcc-dev/neofs-sdk-go/user" "github.com/nspcc-dev/neofs-sdk-go/version" @@ -126,6 +127,11 @@ type sessions interface { // Returns [apistatus.ErrSessionTokenNotFound] if there is no data for the // referenced session. GetSessionPrivateKey(usr user.ID, uid uuid.UUID) (ecdsa.PrivateKey, error) + + // GetSessionV2PrivateKey reads private session key by user ID and session + // subject. Returns [apistatus.ErrSessionTokenNotFound] if there is no data + // for the referenced session. + GetSessionV2PrivateKey(issuer user.ID, subject []sessionv2.Target) (ecdsa.PrivateKey, error) } // Storage groups ops of the node's storage required to serve NeoFS API Object @@ -893,7 +899,17 @@ func convertHashPrm(signer ecdsa.PrivateKey, ss sessions, req *protoobject.GetRa p.SetHashGenerator(tz.New) } - if tok := cp.SessionToken(); tok != nil { + if tokV2 := cp.SessionTokenV2(); tokV2 != nil { + signerKey, err := ss.GetSessionV2PrivateKey(tokV2.Issuer(), tokV2.Subjects()) + if err != nil { + if !errors.Is(err, apistatus.ErrSessionTokenNotFound) { + return getsvc.RangeHashPrm{}, fmt.Errorf("fetching session v2 key: %w", err) + } + cp.ForgetTokens() + signerKey = signer + } + p.WithCachedSignerKey(&signerKey) + } else if tok := cp.SessionToken(); tok != nil { signerKey, err := ss.GetSessionPrivateKey(tok.Issuer(), tok.ID()) if err != nil { if !errors.Is(err, apistatus.ErrSessionTokenNotFound) { diff --git a/pkg/services/object/server_test.go b/pkg/services/object/server_test.go index 1207137746..516a807a05 100644 --- a/pkg/services/object/server_test.go +++ b/pkg/services/object/server_test.go @@ -42,6 +42,7 @@ import ( objecttest "github.com/nspcc-dev/neofs-sdk-go/object/test" protoobject "github.com/nspcc-dev/neofs-sdk-go/proto/object" "github.com/nspcc-dev/neofs-sdk-go/proto/refs" + sessionv2 "github.com/nspcc-dev/neofs-sdk-go/session/v2" "github.com/nspcc-dev/neofs-sdk-go/stat" "github.com/nspcc-dev/neofs-sdk-go/user" "github.com/panjf2000/ants/v2" @@ -115,6 +116,10 @@ func (noCallTestStorage) GetSessionPrivateKey(user.ID, uuid.UUID) (ecdsa.Private panic("implement me") } +func (s noCallTestStorage) GetSessionV2PrivateKey(user.ID, []sessionv2.Target) (ecdsa.PrivateKey, error) { + panic("implement me") +} + type noCallTestACLChecker struct{} func (noCallTestACLChecker) CheckBasicACL(v2.RequestInfo) bool { panic("must not be called") } @@ -632,6 +637,9 @@ func (nopStorage) VerifyAndStoreObjectLocally(object.Object) error { return nil func (nopStorage) GetSessionPrivateKey(user.ID, uuid.UUID) (ecdsa.PrivateKey, error) { return ecdsa.PrivateKey{}, apistatus.ErrSessionTokenNotFound } +func (s nopStorage) GetSessionV2PrivateKey(user.ID, []sessionv2.Target) (ecdsa.PrivateKey, error) { + return ecdsa.PrivateKey{}, apistatus.ErrSessionTokenNotFound +} func (nopStorage) SearchObjects(cid.ID, []objectcore.SearchFilter, []string, *objectcore.SearchCursor, uint16) ([]client.SearchResultItem, []byte, error) { return nil, nil, nil } diff --git a/pkg/services/object/util/key.go b/pkg/services/object/util/key.go index 61bc14166f..dbf0b4d0ca 100644 --- a/pkg/services/object/util/key.go +++ b/pkg/services/object/util/key.go @@ -8,6 +8,7 @@ import ( "github.com/nspcc-dev/neofs-node/pkg/core/netmap" "github.com/nspcc-dev/neofs-node/pkg/util/state/session" apistatus "github.com/nspcc-dev/neofs-sdk-go/client/status" + session2 "github.com/nspcc-dev/neofs-sdk-go/session/v2" "github.com/nspcc-dev/neofs-sdk-go/user" ) @@ -21,6 +22,11 @@ type SessionSource interface { // of it is impossible to get information about the // token Get must return nil. GetToken(owner user.ID, tokenID []byte) *session.PrivateToken + + // FindTokenBySubjects searches for a non-expired private token whose public key + // matches any of the given Target. Used for V2 session tokens where keys + // are identified by their Target. Returns nil if no matching token is found. + FindTokenBySubjects(owner user.ID, subjects []session2.Target) *session.PrivateToken } // KeyStorage represents private key storage of the local node. @@ -82,3 +88,22 @@ func (s *KeyStorage) GetKey(info *SessionInfo) (*ecdsa.PrivateKey, error) { return s.key, nil } + +// GetKeyBySubjects fetches private key for V2 session token by any of the subjects. +// +// Returns apistatus.SessionTokenNotFound if no matching key is found +// or apistatus.SessionTokenExpired if the found token is expired. +func (s *KeyStorage) GetKeyBySubjects(issuer user.ID, subjects []session2.Target) (*ecdsa.PrivateKey, error) { + if len(subjects) == 0 { + return nil, apistatus.ErrSessionTokenNotFound + } + pToken := s.tokenStore.FindTokenBySubjects(issuer, subjects) + if pToken != nil { + if pToken.ExpiredAt() <= s.networkState.CurrentEpoch() { + return nil, apistatus.ErrSessionTokenExpired + } + return pToken.SessionKey(), nil + } + + return nil, apistatus.ErrSessionTokenNotFound +} diff --git a/pkg/services/object/util/key_test.go b/pkg/services/object/util/key_test.go index 4a491b448e..d0d0a18607 100644 --- a/pkg/services/object/util/key_test.go +++ b/pkg/services/object/util/key_test.go @@ -1,12 +1,13 @@ package util_test import ( + "path" "testing" "github.com/google/uuid" "github.com/nspcc-dev/neo-go/pkg/crypto/keys" "github.com/nspcc-dev/neofs-node/pkg/services/object/util" - tokenStorage "github.com/nspcc-dev/neofs-node/pkg/util/state/session/temporary" + "github.com/nspcc-dev/neofs-node/pkg/util/state" neofsecdsa "github.com/nspcc-dev/neofs-sdk-go/crypto/ecdsa" neofscryptotest "github.com/nspcc-dev/neofs-sdk-go/crypto/test" "github.com/nspcc-dev/neofs-sdk-go/session" @@ -19,7 +20,8 @@ func TestNewKeyStorage(t *testing.T) { nodeKey, err := keys.NewPrivateKey() require.NoError(t, err) - tokenStor := tokenStorage.NewTokenStore() + tokenStor, err := state.NewPersistentStorage(path.Join(t.TempDir(), "storage"), true) + require.NoError(t, err) stor := util.NewKeyStorage(&nodeKey.PrivateKey, tokenStor, mockedNetworkState{42}) owner := usertest.ID() @@ -59,7 +61,7 @@ func TestNewKeyStorage(t *testing.T) { }) } -func createToken(t *testing.T, store *tokenStorage.TokenStore, owner user.ID, exp uint64) session.Object { +func createToken(t *testing.T, store *state.PersistentStorage, owner user.ID, exp uint64) session.Object { key := neofscryptotest.ECDSAPrivateKey() id := uuid.New() err := store.Store(key, owner, id[:], exp) diff --git a/pkg/services/object/util/prm.go b/pkg/services/object/util/prm.go index 9c47d416f3..70b4390312 100644 --- a/pkg/services/object/util/prm.go +++ b/pkg/services/object/util/prm.go @@ -6,6 +6,7 @@ import ( "github.com/nspcc-dev/neofs-sdk-go/bearer" protosession "github.com/nspcc-dev/neofs-sdk-go/proto/session" sessionsdk "github.com/nspcc-dev/neofs-sdk-go/session" + sessionv2 "github.com/nspcc-dev/neofs-sdk-go/session/v2" ) // maxLocalTTL is maximum TTL for an operation to be considered local. @@ -14,7 +15,8 @@ const maxLocalTTL = 1 type CommonPrm struct { local bool - token *sessionsdk.Object + token *sessionsdk.Object + tokenV2 *sessionv2.Token bearer *bearer.Token @@ -65,6 +67,14 @@ func (p *CommonPrm) SessionToken() *sessionsdk.Object { return nil } +func (p *CommonPrm) SessionTokenV2() *sessionv2.Token { + if p != nil { + return p.tokenV2 + } + + return nil +} + func (p *CommonPrm) BearerToken() *bearer.Token { if p != nil { return p.bearer @@ -78,6 +88,7 @@ func (p *CommonPrm) BearerToken() *bearer.Token { func (p *CommonPrm) ForgetTokens() { if p != nil { p.token = nil + p.tokenV2 = nil p.bearer = nil } } @@ -95,7 +106,18 @@ func CommonPrmFromRequest(req interface { } var st *sessionsdk.Object - if meta.SessionToken != nil { + var stV2 *sessionv2.Token + + if meta.SessionToken != nil && meta.SessionTokenV2 != nil { + return nil, fmt.Errorf("both V1 and V2 session tokens are set") + } + + if meta.SessionTokenV2 != nil { + stV2 = new(sessionv2.Token) + if err := stV2.FromProtoMessage(meta.SessionTokenV2); err != nil { + return nil, fmt.Errorf("invalid V2 session token: %w", err) + } + } else if meta.SessionToken != nil { st = new(sessionsdk.Object) if err := st.FromProtoMessage(meta.SessionToken); err != nil { return nil, fmt.Errorf("invalid session token: %w", err) @@ -112,11 +134,12 @@ func CommonPrmFromRequest(req interface { xHdrs := meta.XHeaders prm := &CommonPrm{ - local: ttl <= maxLocalTTL, - token: st, - bearer: bt, - ttl: ttl - 1, // decrease TTL for new requests - xhdrs: make([]string, 0, 2*len(xHdrs)), + local: ttl <= maxLocalTTL, + token: st, + tokenV2: stV2, + bearer: bt, + ttl: ttl - 1, // decrease TTL for new requests + xhdrs: make([]string, 0, 2*len(xHdrs)), } for i := range xHdrs { prm.xhdrs = append(prm.xhdrs, xHdrs[i].GetKey(), xHdrs[i].GetValue()) diff --git a/pkg/services/session/server.go b/pkg/services/session/server.go index 8e40ca330d..fd70a52ee7 100644 --- a/pkg/services/session/server.go +++ b/pkg/services/session/server.go @@ -90,6 +90,12 @@ func (s *server) Create(_ context.Context, req *protosession.CreateRequest) (*pr return s.makeFailedCreateResponse(fmt.Errorf("store private key locally: %w", err)) } + // also store the key using account as key ID + keyUser := user.NewFromECDSAPublicKey(key.PublicKey) + if err := s.keys.Store(*key, usr, keyUser[:], reqBody.Expiration); err != nil { + return s.makeFailedCreateResponse(fmt.Errorf("store private key with public key locally: %w", err)) + } + body := &protosession.CreateResponse_Body{ Id: uid[:], SessionKey: neofscrypto.PublicKeyBytes((*neofsecdsa.PublicKey)(&key.PublicKey)), diff --git a/pkg/util/state/executor_test.go b/pkg/util/state/executor_test.go index 5190f4ffaf..b3caadfbbc 100644 --- a/pkg/util/state/executor_test.go +++ b/pkg/util/state/executor_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/nspcc-dev/bbolt" + sessionv2 "github.com/nspcc-dev/neofs-sdk-go/session/v2" "github.com/nspcc-dev/neofs-sdk-go/user" usertest "github.com/nspcc-dev/neofs-sdk-go/user/test" "github.com/stretchr/testify/require" @@ -193,3 +194,62 @@ func TestBolt_Cursor(t *testing.T) { t.Fatal("unexpectedly skipped '2' value") } } + +func TestTokenStore_FindTokenBySubjects(t *testing.T) { + ts := newStorageWithSession(t, filepath.Join(t.TempDir(), ".storage")) + + const tokenNumber = 3 + tokens := make([]struct { + owner user.ID + key ecdsa.PrivateKey + }, tokenNumber) + + multiUsr := usertest.ID() + for i := range tokenNumber { + var usr user.ID + if i == 0 { + usr = usertest.ID() + } else { + usr = multiUsr + } + + subject := usertest.User() + + err := ts.Store(subject.ECDSAPrivateKey, usr, subject.ID[:], uint64(100+i)) + require.NoError(t, err) + + tokens[i].owner = usr + tokens[i].key = subject.ECDSAPrivateKey + } + + subjects := make([]sessionv2.Target, 0, tokenNumber) + for _, tok := range tokens { + userID := user.NewFromECDSAPublicKey(tok.key.PublicKey) + subjects = append(subjects, sessionv2.NewTargetUser(userID)) + } + + for i, tok := range tokens { + foundToken := ts.FindTokenBySubjects(tok.owner, []sessionv2.Target{subjects[i]}) + require.NotNil(t, foundToken) + require.EqualValues(t, 100+i, foundToken.ExpiredAt()) + require.Equal(t, tok.key, *foundToken.SessionKey()) + } + + foundToken := ts.FindTokenBySubjects(tokens[0].owner, []sessionv2.Target{subjects[2], subjects[1]}) + require.Nil(t, foundToken) + foundToken = ts.FindTokenBySubjects(tokens[0].owner, []sessionv2.Target{subjects[2], subjects[0]}) + require.NotNil(t, foundToken) + require.EqualValues(t, 100, foundToken.ExpiredAt()) + + // first matching subject in db + foundToken = ts.FindTokenBySubjects(tokens[1].owner, subjects) + require.NotNil(t, foundToken) + require.EqualValues(t, 101, foundToken.ExpiredAt()) + + nonExistentSubject := sessionv2.NewTargetUser(usertest.ID()) + foundToken = ts.FindTokenBySubjects(tokens[0].owner, []sessionv2.Target{nonExistentSubject}) + require.Nil(t, foundToken) + + foundToken = ts.FindTokenBySubjects(tokens[0].owner, []sessionv2.Target{}) + require.Nil(t, foundToken) +} diff --git a/pkg/util/state/session/temporary/executor.go b/pkg/util/state/session/temporary/executor.go deleted file mode 100644 index 88aa044cd2..0000000000 --- a/pkg/util/state/session/temporary/executor.go +++ /dev/null @@ -1,24 +0,0 @@ -package temporary - -import ( - "crypto/ecdsa" - - "github.com/mr-tron/base58" - "github.com/nspcc-dev/neofs-node/pkg/util/state/session" - "github.com/nspcc-dev/neofs-sdk-go/user" -) - -// Store saves parameterized private key in-memory. -func (s *TokenStore) Store(sk ecdsa.PrivateKey, usr user.ID, id []byte, exp uint64) error { - s.mtx.Lock() - s.tokens[key{ - tokenID: base58.Encode(id), - ownerID: base58.Encode(usr[:]), - }] = session.NewPrivateToken(&sk, exp) - s.mtx.Unlock() - return nil -} - -func (s *TokenStore) Close() error { - return nil -} diff --git a/pkg/util/state/session/temporary/storage.go b/pkg/util/state/session/temporary/storage.go deleted file mode 100644 index 95dafd2e22..0000000000 --- a/pkg/util/state/session/temporary/storage.go +++ /dev/null @@ -1,59 +0,0 @@ -package temporary - -import ( - "maps" - "sync" - - "github.com/mr-tron/base58" - "github.com/nspcc-dev/neofs-node/pkg/util/state/session" - "github.com/nspcc-dev/neofs-sdk-go/user" -) - -type key struct { - tokenID string - ownerID string -} - -// TokenStore is an in-memory session token store. -// It allows creating (storing), retrieving and -// expiring (removing) session tokens. -// Must be created only via calling NewTokenStore. -type TokenStore struct { - mtx *sync.RWMutex - - tokens map[key]*session.PrivateToken -} - -// NewTokenStore creates, initializes and returns a new TokenStore instance. -// -// The elements of the instance are stored in the map. -func NewTokenStore() *TokenStore { - return &TokenStore{ - mtx: new(sync.RWMutex), - tokens: make(map[key]*session.PrivateToken), - } -} - -// GetToken returns private token corresponding to the given identifiers. -// -// Returns nil is there is no element in storage. -func (s *TokenStore) GetToken(ownerID user.ID, tokenID []byte) *session.PrivateToken { - s.mtx.RLock() - t := s.tokens[key{ - tokenID: base58.Encode(tokenID), - ownerID: base58.Encode(ownerID[:]), - }] - s.mtx.RUnlock() - - return t -} - -// RemoveOldTokens removes all tokens expired since provided epoch. -func (s *TokenStore) RemoveOldTokens(epoch uint64) { - s.mtx.Lock() - defer s.mtx.Unlock() - - maps.DeleteFunc(s.tokens, func(_ key, tok *session.PrivateToken) bool { - return tok.ExpiredAt() <= epoch - }) -} diff --git a/pkg/util/state/token.go b/pkg/util/state/token.go index 9e3bdf65c6..aafc475eba 100644 --- a/pkg/util/state/token.go +++ b/pkg/util/state/token.go @@ -8,6 +8,7 @@ import ( "github.com/nspcc-dev/bbolt" "github.com/nspcc-dev/neofs-node/pkg/util/state/session" + sessionv2 "github.com/nspcc-dev/neofs-sdk-go/session/v2" "github.com/nspcc-dev/neofs-sdk-go/user" "go.uber.org/zap" ) @@ -119,3 +120,50 @@ func (p PersistentStorage) RemoveOldTokens(epoch uint64) { ) } } + +// FindTokenBySubjects searches for a private token whose public key +// matches any of the given user ID Targets. +// Returns nil if no matching non-expired token is found. +func (p PersistentStorage) FindTokenBySubjects(ownerID user.ID, subjects []sessionv2.Target) *session.PrivateToken { + var token *session.PrivateToken + err := p.db.View(func(tx *bbolt.Tx) error { + rootBucket := tx.Bucket(sessionsBucket) + if rootBucket == nil { + return nil + } + + ownerBucket := rootBucket.Bucket(ownerID[:]) + if ownerBucket == nil { + return nil + } + + for _, subject := range subjects { + if subjectUser := subject.UserID(); !subjectUser.IsZero() { + rawToken := ownerBucket.Get(subjectUser[:]) + if rawToken == nil { + continue + } + + var err error + token, err = p.unpackToken(rawToken) + if err != nil { + return err + } + + return nil + } + } + + return nil + }) + + if err != nil { + p.l.Error("could not search for any subject in persistent storage", + zap.Error(err), + zap.Stringer("ownerID", ownerID), + zap.Stringers("subjects", subjects), + ) + } + + return token +}