diff --git a/cmd/neofs-cli/modules/container/create.go b/cmd/neofs-cli/modules/container/create.go index 2f8856be32..26742b64ee 100644 --- a/cmd/neofs-cli/modules/container/create.go +++ b/cmd/neofs-cli/modules/container/create.go @@ -16,6 +16,7 @@ import ( "github.com/nspcc-dev/neofs-sdk-go/container/acl" cid "github.com/nspcc-dev/neofs-sdk-go/container/id" "github.com/nspcc-dev/neofs-sdk-go/netmap" + "github.com/nspcc-dev/neofs-sdk-go/session" "github.com/nspcc-dev/neofs-sdk-go/user" "github.com/nspcc-dev/neofs-sdk-go/waiter" "github.com/spf13/cobra" @@ -113,14 +114,24 @@ It will be stored in FS chain when inner ring will accepts it.`, return fmt.Errorf("decode basic ACL string: %w", err) } - tok, err := getSession(cmd) + tokAny, err := getSessionAnyVersion(cmd) if err != nil { return err } - if tok != nil { - issuer := tok.Issuer() - cnr.SetOwner(issuer) + // Set owner based on session token + if tokAny != nil { + switch tok := tokAny.(type) { + case *session.TokenV2: + issuer := tok.Issuer() + if issuer.IsOwnerID() { + cnr.SetOwner(issuer.OwnerID()) + } else { + return errors.New("v2 session issuer must be OwnerID for container create") + } + case *session.Container: + cnr.SetOwner(tok.Issuer()) + } } else { cnr.SetOwner(user.NewFromECDSAPublicKey(key.PublicKey)) } @@ -141,8 +152,16 @@ It will be stored in FS chain when inner ring will accepts it.`, } var putPrm client.PrmContainerPut - if tok != nil { - putPrm.WithinSession(*tok) + if tokAny != nil { + switch tok := tokAny.(type) { + case *session.TokenV2: + if err := validateSessionV2ForContainer(cmd, tok, key, cid.ID{}, session.VerbV2ContainerPut); err != nil { + return err + } + putPrm.WithinSessionV2(*tok) + case *session.Container: + putPrm.WithinSession(*tok) + } } id, err := actor.ContainerPut(ctx, cnr, user.NewAutoIDSignerRFC6979(*key), putPrm) diff --git a/cmd/neofs-cli/modules/container/delete.go b/cmd/neofs-cli/modules/container/delete.go index 4851c413ac..b65771d513 100644 --- a/cmd/neofs-cli/modules/container/delete.go +++ b/cmd/neofs-cli/modules/container/delete.go @@ -1,6 +1,7 @@ package container import ( + "errors" "fmt" internalclient "github.com/nspcc-dev/neofs-node/cmd/neofs-cli/internal/client" @@ -10,6 +11,7 @@ import ( "github.com/nspcc-dev/neofs-sdk-go/client" neofsecdsa "github.com/nspcc-dev/neofs-sdk-go/crypto/ecdsa" objectSDK "github.com/nspcc-dev/neofs-sdk-go/object" + "github.com/nspcc-dev/neofs-sdk-go/session" "github.com/nspcc-dev/neofs-sdk-go/user" "github.com/nspcc-dev/neofs-sdk-go/waiter" "github.com/spf13/cobra" @@ -27,7 +29,7 @@ Only owner of the container has a permission to remove container.`, return err } - tok, err := getSession(cmd) + tokAny, err := getSessionAnyVersion(cmd) if err != nil { return err } @@ -56,11 +58,25 @@ Only owner of the container has a permission to remove container.`, owner := cnr.Owner() - if tok != nil { + if tokAny != nil { common.PrintVerbose(cmd, "Checking session issuer...") - if tok.Issuer() != owner { - return fmt.Errorf("session issuer differs with the container owner: expected %s, has %s", owner, tok.Issuer()) + switch tok := tokAny.(type) { + case *session.TokenV2: + issuer := tok.Issuer() + var issuerID user.ID + if issuer.IsOwnerID() { + issuerID = issuer.OwnerID() + } else { + return errors.New("v2 session issuer must be OwnerID") + } + if issuerID != owner { + return fmt.Errorf("session issuer differs with the container owner: expected %s, has %s", owner, issuerID) + } + case *session.Container: + if tok.Issuer() != owner { + return fmt.Errorf("session issuer differs with the container owner: expected %s, has %s", owner, tok.Issuer()) + } } } else { common.PrintVerbose(cmd, "Checking provided account...") @@ -74,7 +90,7 @@ Only owner of the container has a permission to remove container.`, common.PrintVerbose(cmd, "Account matches the container owner.") - if tok != nil { + if tokAny != nil { common.PrintVerbose(cmd, "Skip searching for LOCK objects - session provided.") } else { fs := objectSDK.NewSearchFilters() @@ -108,8 +124,16 @@ Only owner of the container has a permission to remove container.`, } var delPrm client.PrmContainerDelete - if tok != nil { - delPrm.WithinSession(*tok) + if tokAny != nil { + switch tok := tokAny.(type) { + case *session.TokenV2: + if err := validateSessionV2ForContainer(cmd, tok, pk, id, session.VerbV2ContainerDelete); err != nil { + return err + } + delPrm.WithinSessionV2(*tok) + case *session.Container: + delPrm.WithinSession(*tok) + } } err = actor.ContainerDelete(ctx, id, user.NewAutoIDSignerRFC6979(*pk), delPrm) diff --git a/cmd/neofs-cli/modules/container/set_eacl.go b/cmd/neofs-cli/modules/container/set_eacl.go index 64c59b0c1a..308395d336 100644 --- a/cmd/neofs-cli/modules/container/set_eacl.go +++ b/cmd/neofs-cli/modules/container/set_eacl.go @@ -10,6 +10,7 @@ import ( "github.com/nspcc-dev/neofs-node/cmd/neofs-cli/internal/key" "github.com/nspcc-dev/neofs-node/cmd/neofs-cli/modules/util" "github.com/nspcc-dev/neofs-sdk-go/client" + "github.com/nspcc-dev/neofs-sdk-go/session" "github.com/nspcc-dev/neofs-sdk-go/user" "github.com/nspcc-dev/neofs-sdk-go/waiter" "github.com/spf13/cobra" @@ -35,7 +36,7 @@ Container ID in EACL table will be substituted with ID from the CLI.`, return err } - tok, err := getSession(cmd) + tokAny, err := getSessionAnyVersion(cmd) if err != nil { return err } @@ -73,11 +74,25 @@ Container ID in EACL table will be substituted with ID from the CLI.`, owner := cnr.Owner() - if tok != nil { + if tokAny != nil { common.PrintVerbose(cmd, "Checking session issuer...") - if tok.Issuer() != owner { - return fmt.Errorf("session issuer differs with the container owner: expected %s, has %s", owner, tok.Issuer()) + switch tok := tokAny.(type) { + case *session.TokenV2: + issuer := tok.Issuer() + var issuerID user.ID + if issuer.IsOwnerID() { + issuerID = issuer.OwnerID() + } else { + return errors.New("v2 session issuer must be OwnerID") + } + if issuerID != owner { + return fmt.Errorf("session issuer differs with the container owner: expected %s, has %s", owner, issuerID) + } + case *session.Container: + if tok.Issuer() != owner { + return fmt.Errorf("session issuer differs with the container owner: expected %s, has %s", owner, tok.Issuer()) + } } } else { common.PrintVerbose(cmd, "Checking provided account...") @@ -112,8 +127,16 @@ Container ID in EACL table will be substituted with ID from the CLI.`, } var setEACLPrm client.PrmContainerSetEACL - if tok != nil { - setEACLPrm.WithinSession(*tok) + if tokAny != nil { + switch tok := tokAny.(type) { + case *session.TokenV2: + if err := validateSessionV2ForContainer(cmd, tok, pk, id, session.VerbV2ContainerSetEACL); err != nil { + return err + } + setEACLPrm.WithinSessionV2(*tok) + case *session.Container: + setEACLPrm.WithinSession(*tok) + } } err = actor.ContainerSetEACL(ctx, eaclTable, user.NewAutoIDSignerRFC6979(*pk), setEACLPrm) if err != nil { diff --git a/cmd/neofs-cli/modules/container/util_session_v2.go b/cmd/neofs-cli/modules/container/util_session_v2.go new file mode 100644 index 0000000000..2c170ca29e --- /dev/null +++ b/cmd/neofs-cli/modules/container/util_session_v2.go @@ -0,0 +1,81 @@ +package container + +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" + cid "github.com/nspcc-dev/neofs-sdk-go/container/id" + "github.com/nspcc-dev/neofs-sdk-go/session" + "github.com/nspcc-dev/neofs-sdk-go/user" + "github.com/spf13/cobra" +) + +func getSessionV2(cmd *cobra.Command) (*session.TokenV2, error) { + common.PrintVerbose(cmd, "Trying to read V2 container session from the file...") + + path, _ := cmd.Flags().GetString(commonflags.SessionToken) + if path == "" { + common.PrintVerbose(cmd, "Session not provided.") + return nil, nil + } + + common.PrintVerbose(cmd, "Reading V2 container session from the file [%s]...", path) + + var tok session.TokenV2 + + err := common.ReadBinaryOrJSON(cmd, &tok, path) + if err != nil { + return nil, fmt.Errorf("read V2 container session: %w", err) + } + + common.PrintVerbose(cmd, "V2 session successfully read.") + return &tok, nil +} + +// getSessionAnyVersion tries V2 token first, then V1. +func getSessionAnyVersion(cmd *cobra.Command) (any, error) { + tokV2, err := getSessionV2(cmd) + if err == nil && tokV2 != nil { + return tokV2, nil + } + + tok, err := getSession(cmd) + if err != nil { + return nil, err + } + if tok != nil { + return tok, nil + } + + return nil, nil +} + +// validateSessionV2ForContainer validates V2 token for container operations. +func validateSessionV2ForContainer(cmd *cobra.Command, tok *session.TokenV2, key *ecdsa.PrivateKey, cnrID cid.ID, verb session.VerbV2) error { + common.PrintVerbose(cmd, "Validating V2 session token...") + + if err := tok.Validate(); err != nil { + return fmt.Errorf("invalid V2 session token: %w", err) + } + + if !tok.VerifySignature() { + return errors.New("v2 session token signature verification failed") + } + + signer := user.NewAutoIDSigner(*key) + target := session.NewTarget(signer.UserID()) + + if !tok.AssertAuthority(target) { + return fmt.Errorf("user %s is not authorized by V2 session token", signer.UserID().String()) + } + + if !tok.AssertContainer(verb, cnrID) { + return fmt.Errorf("v2 session token does not authorize %v for container %s", verb, cnrID.String()) + } + + common.PrintVerbose(cmd, "V2 session token validated successfully") + return nil +} diff --git a/cmd/neofs-cli/modules/object/util.go b/cmd/neofs-cli/modules/object/util.go index 49230aabdf..8175c8f03e 100644 --- a/cmd/neofs-cli/modules/object/util.go +++ b/cmd/neofs-cli/modules/object/util.go @@ -3,12 +3,15 @@ package object import ( "context" "crypto/ecdsa" + "crypto/elliptic" "errors" "fmt" "io" "os" "strings" + "github.com/google/uuid" + "github.com/nspcc-dev/neo-go/pkg/crypto/keys" internal "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" @@ -146,6 +149,7 @@ func readOID(cmd *cobra.Command, id *oid.ID) error { // sessions. type SessionPrm interface { WithinSession(session.Object) + WithinSessionV2(session.TokenV2) } // forwards all parameters to _readVerifiedSession and object as nil. @@ -191,6 +195,9 @@ func getSession(cmd *cobra.Command) (*session.Object, error) { // - relation to the given private key used within the command // - session signature // +// Supports both V1 (session.Object) and V2 (session.TokenV2) tokens. +// V2 tokens are tried first, then falls back to V1 for backward compatibility. +// // SessionPrm MUST be one of: // // *internal.GetObjectPrm @@ -199,6 +206,16 @@ 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) + if err != nil { + return fmt.Errorf("v2 session validation failed: %w", err) + } + if isV2 { + common.PrintVerbose(cmd, "Using V2 session token") + return nil + } + + // Fall back to V1 token var cmdVerb session.ObjectVerb switch dst.(type) { @@ -221,13 +238,13 @@ func _readVerifiedSession(cmd *cobra.Command, dst SessionPrm, key *ecdsa.Private return err } - common.PrintVerbose(cmd, "Checking session correctness...") + common.PrintVerbose(cmd, "Checking V1 session correctness...") if obj != nil && !tok.AssertObject(*obj) { return errors.New("unrelated object in the session") } - common.PrintVerbose(cmd, "Session is correct.") + common.PrintVerbose(cmd, "V1 session is correct.") dst.WithinSession(*tok) return nil @@ -256,24 +273,30 @@ func getVerifiedSession(cmd *cobra.Command, cmdVerb session.ObjectVerb, key *ecd return tok, nil } -// ReadOrOpenSessionViaClient tries to read session from the file 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. +// ReadOrOpenSessionViaClient tries to read session from file (V2 first, then V1). +// If no file provided, creates V2 token locally (no SessionCreate RPC needed). +// cli parameter kept for backward compatibility but not used for V2 tokens. func ReadOrOpenSessionViaClient(ctx context.Context, cmd *cobra.Command, dst SessionPrm, cli *client.Client, key *ecdsa.PrivateKey, cnr cid.ID, objs ...oid.ID) error { - tok, err := getSession(cmd) - if err != nil { - return err - } - if tok == nil { - err = OpenSessionViaClient(ctx, cmd, dst, cli, key, cnr, objs...) + path, _ := cmd.Flags().GetString(commonflags.SessionToken) + + if path != "" { + tokV2, err := getSessionV2(cmd) + if err == nil && tokV2 != nil { + return finalizeSessionV2(cmd, dst, tokV2, key, cnr, objs...) + } + + // Fall back to V1 token from file + tok, err := getSession(cmd) if err != nil { return err } - return nil + if tok != nil { + return finalizeSession(cmd, dst, tok, key, cnr, objs...) + } } - err = finalizeSession(cmd, dst, tok, key, cnr, objs...) + // No token file provided - create V2 token locally (no RPC) + err := CreateSessionV2Local(ctx, cmd, dst, key, cnr, objs...) if err != nil { return err } @@ -358,6 +381,158 @@ func finalizeSession(cmd *cobra.Command, dst SessionPrm, tok *session.Object, ke return nil } +// CreateSessionV2Local creates V2 token locally without SessionCreate RPC. +func CreateSessionV2Local(ctx context.Context, cmd *cobra.Command, dst SessionPrm, key *ecdsa.PrivateKey, cnr cid.ID, objs ...oid.ID) error { + const sessionLifetime = 10 + + common.PrintVerbose(cmd, "Creating V2 session token locally...") + + endpoint := viper.GetString(commonflags.RPC) + currEpoch, err := internal.GetCurrentEpoch(ctx, endpoint) + if err != nil { + return fmt.Errorf("can't fetch current epoch: %w", err) + } + + common.PrintVerbose(cmd, "Fetching NetMap to get node public keys...") + cli, err := internal.GetSDKClientByFlag(ctx, commonflags.RPC) + if err != nil { + return fmt.Errorf("can't create SDK client: %w", err) + } + defer func() { _ = cli.Close() }() + + nm, err := cli.NetMapSnapshot(ctx, client.PrmNetMapSnapshot{}) + if err != nil { + return fmt.Errorf("can't get NetMap snapshot: %w", err) + } + + common.PrintVerbose(cmd, "Getting container info...") + cnrObj, err := cli.ContainerGet(ctx, cnr, client.PrmContainerGet{}) + if err != nil { + return fmt.Errorf("can't get container: %w", err) + } + + policy := cnrObj.PlacementPolicy() + + common.PrintVerbose(cmd, "Getting container nodes from NetMap...") + nodes, err := nm.ContainerNodes(policy, cnr) + if err != nil { + return fmt.Errorf("can't get container nodes: %w", err) + } + + var subjects []session.Target + nodeCount := 0 + seenKeys := make(map[string]bool) // To avoid duplicates + + for _, replica := range nodes { + for _, node := range replica { + pubKeyBytes := node.PublicKey() + keyStr := string(pubKeyBytes) + if seenKeys[keyStr] { + continue + } + seenKeys[keyStr] = true + + neoPubKey, err := keys.NewPublicKeyFromBytes(pubKeyBytes, elliptic.P256()) + if err != nil { + common.PrintVerbose(cmd, "Warning: failed to parse node public key: %v", err) + continue + } + + ecdsaPubKey := (*ecdsa.PublicKey)(neoPubKey) + + userID := user.NewFromECDSAPublicKey(*ecdsaPubKey) + + subjects = append(subjects, session.NewTarget(userID)) + nodeCount++ + } + } + + common.PrintVerbose(cmd, "Added %d unique node public keys as subjects", nodeCount) + + var tokV2 session.TokenV2 + signer := user.NewAutoIDSigner(*key) + + tokV2.SetVersion(session.TokenV2CurrentVersion) + tokV2.SetID(uuid.New()) + tokV2.SetIat(currEpoch) + tokV2.SetNbf(currEpoch) + tokV2.SetExp(currEpoch + sessionLifetime) + tokV2.SetIssuer(session.NewTarget(signer.UserID())) + tokV2.SetSubjects(subjects) + + var verb session.VerbV2 + switch dst.(type) { + case *client.PrmObjectPutInit: + verb = session.VerbV2ObjectPut + case *client.PrmObjectDelete: + verb = session.VerbV2ObjectDelete + default: + return fmt.Errorf("unsupported operation type for V2 session: %T", dst) + } + + ctx2 := session.NewContextV2(cnr, []session.VerbV2{verb}) + if len(objs) > 0 { + ctx2.SetObjects(objs) + } + tokV2.SetContexts([]session.ContextV2{ctx2}) + + if err := tokV2.Sign(signer); err != nil { + return fmt.Errorf("sign V2 session: %w", err) + } + + common.PrintVerbose(cmd, "V2 session token successfully created locally and attached to the request.") + + dst.WithinSessionV2(tokV2) + common.PrettyPrintJSON(cmd, tokV2, "V2 session token JSON") + return nil +} + +// finalizeSessionV2 validates and attaches V2 token to the request. +func finalizeSessionV2(cmd *cobra.Command, dst SessionPrm, tok *session.TokenV2, key *ecdsa.PrivateKey, cnr cid.ID, objs ...oid.ID) error { + common.PrintVerbose(cmd, "Finalizing V2 session token...") + + if err := tok.Validate(); err != nil { + return fmt.Errorf("invalid V2 session token: %w", err) + } + + if !tok.VerifySignature() { + return errors.New("v2 session token signature verification failed") + } + + signer := user.NewAutoIDSigner(*key) + target := session.NewTarget(signer.UserID()) + if !tok.AssertAuthority(target) { + return fmt.Errorf("user %s is not authorized by V2 session token", signer.UserID().String()) + } + + var verb session.VerbV2 + switch dst.(type) { + case *client.PrmObjectPutInit: + verb = session.VerbV2ObjectPut + case *client.PrmObjectDelete: + verb = session.VerbV2ObjectDelete + default: + return fmt.Errorf("unsupported operation type: %T", dst) + } + + if !tok.AssertVerb(verb, cnr) { + return fmt.Errorf("v2 session token does not authorize verb for container %s", cnr.String()) + } + + if len(objs) > 0 { + for _, obj := range objs { + if !tok.AssertObject(verb, cnr, obj) { + return fmt.Errorf("v2 session token does not authorize access to object %s", obj.String()) + } + } + } + + common.PrintVerbose(cmd, "V2 session token successfully validated and attached to the request.") + + dst.WithinSessionV2(*tok) + return nil +} + // calls commonflags.InitSession with "object " name. func initFlagSession(cmd *cobra.Command, verb string) { commonflags.InitSession(cmd, "object "+verb) diff --git a/cmd/neofs-cli/modules/object/util_session_v2.go b/cmd/neofs-cli/modules/object/util_session_v2.go new file mode 100644 index 0000000000..360b9ddff5 --- /dev/null +++ b/cmd/neofs-cli/modules/object/util_session_v2.go @@ -0,0 +1,152 @@ +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" + "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" + "github.com/nspcc-dev/neofs-sdk-go/session" + "github.com/nspcc-dev/neofs-sdk-go/user" + "github.com/spf13/cobra" +) + +func getSessionV2(cmd *cobra.Command) (*session.TokenV2, error) { + common.PrintVerbose(cmd, "Trying to read V2 session from the file...") + + path, _ := cmd.Flags().GetString(commonflags.SessionToken) + if path == "" { + common.PrintVerbose(cmd, "File with session token is not provided.") + return nil, nil + } + + common.PrintVerbose(cmd, "Reading V2 session from the file [%s]...", path) + + var tok session.TokenV2 + + err := common.ReadBinaryOrJSON(cmd, &tok, path) + if err != nil { + return nil, fmt.Errorf("read V2 session: %w", err) + } + + return &tok, nil +} + +func verbObjectToVerbV2(v1Verb session.ObjectVerb) session.VerbV2 { + switch v1Verb { + case session.VerbObjectGet: + return session.VerbV2ObjectGet + case session.VerbObjectHead: + return session.VerbV2ObjectHead + case session.VerbObjectPut: + return session.VerbV2ObjectPut + case session.VerbObjectDelete: + return session.VerbV2ObjectDelete + case session.VerbObjectSearch: + return session.VerbV2ObjectSearch + case session.VerbObjectRange: + return session.VerbV2ObjectRange + case session.VerbObjectRangeHash: + return session.VerbV2ObjectRangeHash + default: + return session.VerbV2ObjectGet + } +} + +func getVerifiedSessionV2(cmd *cobra.Command, cmdVerb session.ObjectVerb, key *ecdsa.PrivateKey, cnr cid.ID) (*session.TokenV2, error) { + tok, err := getSessionV2(cmd) + if err != nil || tok == nil { + return tok, err + } + + common.PrintVerbose(cmd, "Validating V2 session token...") + + if err := tok.Validate(); err != nil { + return nil, fmt.Errorf("invalid V2 session token: %w", err) + } + + if !tok.VerifySignature() { + return nil, errors.New("v2 session token signature verification failed") + } + + verbV2 := verbObjectToVerbV2(cmdVerb) + + if !tok.AssertVerb(verbV2, cnr) { + return nil, fmt.Errorf("v2 session token does not authorize %v for container %s", cmdVerb, cnr.String()) + } + + signer := user.NewAutoIDSigner(*key) + target := session.NewTarget(signer.UserID()) + + if !tok.AssertAuthority(target) { + return nil, fmt.Errorf("user %s is not authorized by V2 session token", signer.UserID().String()) + } + + 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 { + var cmdVerb session.ObjectVerb + + switch dst.(type) { + default: + panic(fmt.Sprintf("unsupported op parameters %T", dst)) + case *client.PrmObjectGet: + cmdVerb = session.VerbObjectGet + case *client.PrmObjectHead: + cmdVerb = session.VerbObjectHead + case *client.PrmObjectSearch: + cmdVerb = session.VerbObjectSearch + case *client.PrmObjectRange: + cmdVerb = session.VerbObjectRange + case *client.PrmObjectHash: + cmdVerb = session.VerbObjectRangeHash + } + + tok, err := getVerifiedSessionV2(cmd, cmdVerb, key, cnr) + if err != nil || tok == nil { + return err + } + + common.PrintVerbose(cmd, "Checking V2 session correctness...") + + if obj != nil { + verbV2 := verbObjectToVerbV2(cmdVerb) + if !tok.AssertObject(verbV2, cnr, *obj) { + return fmt.Errorf("v2 session token does not authorize access to object %s", obj.String()) + } + } + + common.PrintVerbose(cmd, "V2 session is correct.") + + dst.WithinSessionV2(*tok) + return nil +} + +// tryReadSessionV2 attempts V2 token first, returns true if V2 was found. +func tryReadSessionV2(cmd *cobra.Command, dst SessionPrm, key *ecdsa.PrivateKey, cnr cid.ID, obj *oid.ID) (bool, error) { + path, _ := cmd.Flags().GetString(commonflags.SessionToken) + if path == "" { + return false, nil + } + + tok, err := getSessionV2(cmd) + if err != nil { + return false, nil + } + if tok == nil { + return false, nil + } + + err = _readVerifiedSessionV2(cmd, dst, key, cnr, obj) + if err != nil { + return true, err + } + + return true, nil +} diff --git a/cmd/neofs-cli/modules/session/create_v2.go b/cmd/neofs-cli/modules/session/create_v2.go new file mode 100644 index 0000000000..53571a2335 --- /dev/null +++ b/cmd/neofs-cli/modules/session/create_v2.go @@ -0,0 +1,361 @@ +package session + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "errors" + "fmt" + "os" + "strconv" + "strings" + + "github.com/google/uuid" + "github.com/nspcc-dev/neo-go/pkg/crypto/keys" + 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" + oid "github.com/nspcc-dev/neofs-sdk-go/object/id" + "github.com/nspcc-dev/neofs-sdk-go/session" + "github.com/nspcc-dev/neofs-sdk-go/user" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +const ( + v2SubjectsFlag = "subject" + v2SubjectsNNSFlag = "subject-nns" + v2ContainerFlag = "container" + v2ObjectsFlag = "objects" + v2VerbsFlag = "verbs" +) + +var createV2Cmd = &cobra.Command{ + Use: "create-v2", + Short: "Create V2 session token", + Long: `Create V2 session token with subjects, contexts, and verbs. + +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) +- Delegation chains (future feature) + +Example usage: + neofs-cli session create-v2 \ + --wallet wallet.json \ + --r node.neofs.network:8080 \ + --lifetime 100 \ + --out token.json \ + --json \ + --subject NbUgTSFvPmsRxmGeWpuuGeJUoRoi6PErcM \ + --container 5HqniP5vq5xXr3FdijTSekrQJHu1WnADt2uLg7KSViZM \ + --verbs GET,HEAD,SEARCH + +Default lifetime of session token is ` + strconv.Itoa(defaultLifetime) + ` epochs +if none of --` + commonflags.ExpireAt + ` or --` + commonflags.Lifetime + ` flags is specified. +`, + Args: cobra.NoArgs, + RunE: createSessionV2, + PersistentPreRun: func(cmd *cobra.Command, _ []string) { + _ = viper.BindPFlag(commonflags.WalletPath, cmd.Flags().Lookup(commonflags.WalletPath)) + _ = viper.BindPFlag(commonflags.Account, cmd.Flags().Lookup(commonflags.Account)) + }, +} + +func init() { + createV2Cmd.Flags().Uint64P(commonflags.Lifetime, "l", defaultLifetime, "Number of epochs for token to stay valid") + createV2Cmd.Flags().StringP(commonflags.WalletPath, commonflags.WalletPathShorthand, commonflags.WalletPathDefault, commonflags.WalletPathUsage) + createV2Cmd.Flags().StringP(commonflags.Account, commonflags.AccountShorthand, commonflags.AccountDefault, commonflags.AccountUsage) + createV2Cmd.Flags().String(outFlag, "", "File to write session token to") + createV2Cmd.Flags().Bool(jsonFlag, false, "Output token in JSON") + createV2Cmd.Flags().StringP(commonflags.RPC, commonflags.RPCShorthand, commonflags.RPCDefault, commonflags.RPCUsage) + createV2Cmd.Flags().Uint64P(commonflags.ExpireAt, "e", 0, "The last active epoch for token to stay valid") + + // V2-specific flags + createV2Cmd.Flags().StringSlice(v2SubjectsFlag, nil, "Subject user IDs (can be specified multiple times)") + createV2Cmd.Flags().StringSlice(v2SubjectsNNSFlag, nil, "Subject NNS names (can be specified multiple times)") + createV2Cmd.Flags().String(v2ContainerFlag, "", "Container ID for the context") + createV2Cmd.Flags().StringSlice(v2ObjectsFlag, nil, "Object IDs for the context (empty = all objects in container)") + createV2Cmd.Flags().String(v2VerbsFlag, "", "Comma-separated list of verbs (GET,PUT,HEAD,SEARCH,DELETE,RANGE,RANGEHASH,CONTAINERSET,CONTAINERPUT,CONTAINERDELETE)") + + _ = cobra.MarkFlagRequired(createV2Cmd.Flags(), commonflags.WalletPath) + _ = cobra.MarkFlagRequired(createV2Cmd.Flags(), outFlag) + _ = cobra.MarkFlagRequired(createV2Cmd.Flags(), commonflags.RPC) + _ = cobra.MarkFlagRequired(createV2Cmd.Flags(), v2ContainerFlag) + _ = cobra.MarkFlagRequired(createV2Cmd.Flags(), v2VerbsFlag) + createV2Cmd.MarkFlagsOneRequired(commonflags.ExpireAt, commonflags.Lifetime) + createV2Cmd.MarkFlagsOneRequired(v2SubjectsFlag, v2SubjectsNNSFlag) +} + +func createSessionV2(cmd *cobra.Command, _ []string) error { + privKey, err := key.Get(cmd) + if err != nil { + return err + } + + var netAddr network.Address + addrStr, _ := cmd.Flags().GetString(commonflags.RPC) + if err := netAddr.FromString(addrStr); err != nil { + return fmt.Errorf("can't parse endpoint: %w", err) + } + + ctx := context.Background() + endpoint, _ := cmd.Flags().GetString(commonflags.RPC) + currEpoch, err := internalclient.GetCurrentEpoch(ctx, endpoint) + if err != nil { + return fmt.Errorf("can't get current epoch: %w", err) + } + + var exp uint64 + if exp, _ = cmd.Flags().GetUint64(commonflags.ExpireAt); exp == 0 { + lifetime, _ := cmd.Flags().GetUint64(commonflags.Lifetime) + exp = currEpoch + lifetime + } + if exp <= currEpoch { + return errors.New("expiration epoch must be greater than current epoch") + } + + var tokV2 session.TokenV2 + signer := user.NewAutoIDSigner(*privKey) + + tokV2.SetVersion(session.TokenV2CurrentVersion) + tokV2.SetID(uuid.New()) + tokV2.SetNbf(currEpoch) + tokV2.SetIat(currEpoch) + tokV2.SetExp(exp) + tokV2.SetIssuer(session.NewTarget(signer.UserID())) + + subjects, err := parseSubjects(cmd, netAddr) + if err != nil { + return err + } + tokV2.SetSubjects(subjects) + + common.PrintVerbose(cmd, "Token issuer: %s", signer.UserID().String()) + common.PrintVerbose(cmd, "Number of subjects: %d", len(subjects)) + for i, subj := range subjects { + if subj.IsOwnerID() { + common.PrintVerbose(cmd, " Subject %d (UserID): %s", i+1, subj.OwnerID().String()) + } else if subj.IsNNS() { + common.PrintVerbose(cmd, " Subject %d (NNS): %s", i+1, subj.NNSName()) + } + } + + contexts, err := parseContexts(cmd) + if err != nil { + return err + } + tokV2.SetContexts(contexts) + + common.PrintVerbose(cmd, "Number of contexts: %d", len(contexts)) + for i, ctx := range contexts { + common.PrintVerbose(cmd, " Context %d: container=%s, objects=%d, verbs=%d", + i+1, ctx.Container().String(), len(ctx.Objects()), len(ctx.Verbs())) + } + + if err := tokV2.Sign(signer); err != nil { + return fmt.Errorf("failed to sign token: %w", err) + } + + common.PrintVerbose(cmd, "Token signed successfully") + + var data []byte + if toJSON, _ := cmd.Flags().GetBool(jsonFlag); toJSON { + data, err = tokV2.MarshalJSON() + if err != nil { + return fmt.Errorf("can't marshal session token to JSON: %w", err) + } + common.PrintVerbose(cmd, "Token marshalled to JSON") + } else { + data = tokV2.Marshal() + common.PrintVerbose(cmd, "Token marshalled to binary") + } + + filename, _ := cmd.Flags().GetString(outFlag) + err = os.WriteFile(filename, data, 0o644) + if err != nil { + return fmt.Errorf("can't write token to file: %w", err) + } + + fmt.Printf("V2 session token successfully written to: %s\n", filename) + return nil +} + +func parseSubjects(cmd *cobra.Command, netAddr network.Address) ([]session.Target, error) { + subjectIDs, _ := cmd.Flags().GetStringSlice(v2SubjectsFlag) + subjectNNS, _ := cmd.Flags().GetStringSlice(v2SubjectsNNSFlag) + + if len(subjectIDs) == 0 && len(subjectNNS) == 0 { + return nil, errors.New("at least one subject (--subject or --subject-nns) must be specified") + } + + subjects := make([]session.Target, 0, len(subjectIDs)+len(subjectNNS)) + + for _, idStr := range subjectIDs { + var userID user.ID + if err := userID.DecodeString(idStr); err != nil { + return nil, fmt.Errorf("invalid subject user ID %q: %w", idStr, err) + } + subjects = append(subjects, session.NewTarget(userID)) + } + + for _, nnsName := range subjectNNS { + if nnsName == "" { + return nil, errors.New("NNS name cannot be empty") + } + subjects = append(subjects, session.NewTargetFromNNS(nnsName)) + } + + // Fetch node public keys from NetMap for the container + cnrStr, _ := cmd.Flags().GetString(v2ContainerFlag) + if cnrStr != "" { + var cnrID cid.ID + if err := cnrID.DecodeString(cnrStr); err != nil { + common.PrintVerbose(cmd, "Warning: can't parse container ID for node fetching: %v", err) + } else { + nodeSubjects, err := fetchNodeSubjects(cmd, cnrID, netAddr) + if err != nil { + common.PrintVerbose(cmd, "Warning: can't fetch node subjects: %v", err) + } else { + subjects = append(subjects, nodeSubjects...) + common.PrintVerbose(cmd, "Added %d node public keys as subjects from NetMap", len(nodeSubjects)) + } + } + } + + return subjects, nil +} + +func fetchNodeSubjects(cmd *cobra.Command, cnrID cid.ID, netAddr network.Address) ([]session.Target, error) { + ctx := context.Background() + + cli, err := internalclient.GetSDKClient(ctx, netAddr) + if err != nil { + return nil, fmt.Errorf("can't create SDK client: %w", err) + } + defer func() { + _ = cli.Close() + }() + + nm, err := cli.NetMapSnapshot(ctx, client.PrmNetMapSnapshot{}) + if err != nil { + return nil, fmt.Errorf("can't get NetMap snapshot: %w", err) + } + + cnrObj, err := cli.ContainerGet(ctx, cnrID, client.PrmContainerGet{}) + if err != nil { + return nil, fmt.Errorf("can't get container: %w", err) + } + + policy := cnrObj.PlacementPolicy() + + nodes, err := nm.ContainerNodes(policy, cnrID) + if err != nil { + return nil, fmt.Errorf("can't get container nodes: %w", err) + } + + var subjects []session.Target + seenKeys := make(map[string]bool) + + for _, replica := range nodes { + for _, node := range replica { + pubKeyBytes := node.PublicKey() + keyStr := string(pubKeyBytes) + if seenKeys[keyStr] { + continue + } + seenKeys[keyStr] = true + + neoPubKey, err := keys.NewPublicKeyFromBytes(pubKeyBytes, elliptic.P256()) + if err != nil { + common.PrintVerbose(cmd, "Warning: failed to parse node public key: %v", err) + continue + } + + userID := user.NewFromECDSAPublicKey(*(*ecdsa.PublicKey)(neoPubKey)) + + subjects = append(subjects, session.NewTarget(userID)) + } + } + + return subjects, nil +} + +func parseContexts(cmd *cobra.Command) ([]session.ContextV2, error) { + cnrStr, _ := cmd.Flags().GetString(v2ContainerFlag) + var cnrID cid.ID + if err := cnrID.DecodeString(cnrStr); err != nil { + return nil, fmt.Errorf("invalid container ID: %w", err) + } + + verbsStr, _ := cmd.Flags().GetString(v2VerbsFlag) + verbs, err := parseVerbs(verbsStr) + if err != nil { + return nil, err + } + + ctx := session.NewContextV2(cnrID, verbs) + + objStrs, _ := cmd.Flags().GetStringSlice(v2ObjectsFlag) + if len(objStrs) > 0 { + var objIDs []oid.ID + for _, objStr := range objStrs { + var objID oid.ID + if err := objID.DecodeString(objStr); err != nil { + return nil, fmt.Errorf("invalid object ID %q: %w", objStr, err) + } + objIDs = append(objIDs, objID) + } + ctx.SetObjects(objIDs) + } + + return []session.ContextV2{ctx}, nil +} + +func parseVerbs(verbsStr string) ([]session.VerbV2, error) { + if verbsStr == "" { + return nil, errors.New("verbs cannot be empty") + } + + verbStrs := strings.Split(verbsStr, ",") + verbs := make([]session.VerbV2, 0, len(verbStrs)) + + for _, verbStr := range verbStrs { + verbStr = strings.TrimSpace(strings.ToUpper(verbStr)) + + var verb session.VerbV2 + switch verbStr { + case "GET", "OBJECTGET": + verb = session.VerbV2ObjectGet + case "PUT", "OBJECTPUT": + verb = session.VerbV2ObjectPut + case "HEAD", "OBJECTHEAD": + verb = session.VerbV2ObjectHead + case "SEARCH", "OBJECTSEARCH": + verb = session.VerbV2ObjectSearch + case "DELETE", "OBJECTDELETE": + verb = session.VerbV2ObjectDelete + case "RANGE", "OBJECTRANGE": + verb = session.VerbV2ObjectRange + case "RANGEHASH", "OBJECTRANGEHASH", "RANGE_HASH", "OBJECT_RANGE_HASH": + verb = session.VerbV2ObjectRangeHash + case "CONTAINERSET", "CONTAINERSETACL", "CONTAINER_SET", "CONTAINER_SET_ACL": + verb = session.VerbV2ContainerSetEACL + case "CONTAINERPUT", "CONTAINER_PUT": + verb = session.VerbV2ContainerPut + case "CONTAINERDELETE", "CONTAINER_DELETE": + verb = session.VerbV2ContainerDelete + default: + return nil, fmt.Errorf("unknown verb: %s (supported: GET,PUT,HEAD,SEARCH,DELETE,RANGE,RANGEHASH,CONTAINERSET,CONTAINERPUT,CONTAINERDELETE)", verbStr) + } + + verbs = append(verbs, verb) + } + + return verbs, nil +} diff --git a/cmd/neofs-cli/modules/session/inspect.go b/cmd/neofs-cli/modules/session/inspect.go new file mode 100644 index 0000000000..86e02618c8 --- /dev/null +++ b/cmd/neofs-cli/modules/session/inspect.go @@ -0,0 +1,245 @@ +package session + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/nspcc-dev/neofs-sdk-go/session" + "github.com/spf13/cobra" +) + +var inspectCmd = &cobra.Command{ + Use: "inspect", + Short: "Inspect session token (V1 or V2)", + Long: `Inspect and display information about a session token. + +Supports both V1 (session.Object) and V2 (session.TokenV2) tokens. +Automatically detects the token version and displays relevant information. + +Example usage: + neofs-cli session inspect --token token.json + neofs-cli session inspect --token token.bin +`, + Args: cobra.NoArgs, + RunE: inspectSession, +} + +const ( + inspectTokenFlag = "token" +) + +func init() { + inspectCmd.Flags().String(inspectTokenFlag, "", "Path to session token file (JSON or binary)") + _ = cobra.MarkFlagRequired(inspectCmd.Flags(), inspectTokenFlag) +} + +func inspectSession(cmd *cobra.Command, _ []string) error { + tokenPath, _ := cmd.Flags().GetString(inspectTokenFlag) + + // Read the file + data, err := os.ReadFile(tokenPath) + if err != nil { + return fmt.Errorf("failed to read token file: %w", err) + } + + // Try to parse as V2 first + var tokV2 session.TokenV2 + if err := tokV2.UnmarshalJSON(data); err == nil { + return displayTokenV2(&tokV2) + } + + if err := tokV2.Unmarshal(data); err == nil { + return displayTokenV2(&tokV2) + } + + // Try to parse as V1 + var tokV1 session.Object + if err := tokV1.UnmarshalJSON(data); err == nil { + return displayTokenV1(&tokV1) + } + + if err := tokV1.Unmarshal(data); err == nil { + return displayTokenV1(&tokV1) + } + + return fmt.Errorf("failed to parse token as V1 or V2 session token") +} + +func displayTokenV2(tok *session.TokenV2) error { + fmt.Println("=== Session Token V2 ===") + fmt.Printf("Version: %d\n", tok.Version()) + fmt.Printf("ID: %s\n", tok.ID().String()) + + issuer := tok.Issuer() + if issuer.IsOwnerID() { + fmt.Printf("Issuer: %s (UserID)\n", issuer.OwnerID().String()) + } else if issuer.IsNNS() { + fmt.Printf("Issuer: %s (NNS)\n", issuer.NNSName()) + } + + // Display subjects + subjects := tok.Subjects() + fmt.Printf("\nSubjects (%d):\n", len(subjects)) + for i, subj := range subjects { + if subj.IsOwnerID() { + fmt.Printf(" %d. %s (UserID)\n", i+1, subj.OwnerID().String()) + } else if subj.IsNNS() { + fmt.Printf(" %d. %s (NNS)\n", i+1, subj.NNSName()) + } + } + + // Display lifetime + fmt.Printf("\nLifetime:\n") + fmt.Printf(" Issued At (iat): %d\n", tok.Iat()) + fmt.Printf(" Not Before (nbf): %d\n", tok.Nbf()) + fmt.Printf(" Expires At (exp): %d\n", tok.Exp()) + + // Display contexts + contexts := tok.Contexts() + fmt.Printf("\nContexts (%d):\n", len(contexts)) + for i, ctx := range contexts { + fmt.Printf(" Context %d:\n", i+1) + fmt.Printf(" Container: %s\n", ctx.Container().String()) + + objects := ctx.Objects() + if len(objects) > 0 { + fmt.Printf(" Objects (%d):\n", len(objects)) + for j, obj := range objects { + fmt.Printf(" %d. %s\n", j+1, obj.String()) + } + } else { + fmt.Printf(" Objects: All (unlimited)\n") + } + + verbs := ctx.Verbs() + fmt.Printf(" Verbs (%d): ", len(verbs)) + for j, verb := range verbs { + if j > 0 { + fmt.Print(", ") + } + fmt.Print(verbV2ToString(verb)) + } + fmt.Println() + } + + // Display delegation chain + chain := tok.DelegationChain() + if len(chain) > 0 { + fmt.Printf("\nDelegation Chain (%d):\n", len(chain)) + for i, delegation := range chain { + fmt.Printf(" Link %d:\n", i+1) + + issuer := delegation.Issuer() + if issuer.IsOwnerID() { + fmt.Printf(" Issuer: %s (UserID)\n", issuer.OwnerID().String()) + } else if issuer.IsNNS() { + fmt.Printf(" Issuer: %s (NNS)\n", issuer.NNSName()) + } + + subject := delegation.Subject() + if subject.IsOwnerID() { + fmt.Printf(" Subject: %s (UserID)\n", subject.OwnerID().String()) + } else if subject.IsNNS() { + fmt.Printf(" Subject: %s (NNS)\n", subject.NNSName()) + } + + fmt.Printf(" Timestamp: %s\n", delegation.Timestamp().String()) + + verbs := delegation.Verbs() + fmt.Printf(" Verbs: ") + for j, verb := range verbs { + if j > 0 { + fmt.Print(", ") + } + fmt.Print(verbV2ToString(verb)) + } + fmt.Println() + } + } + + // Validate and show signature status + fmt.Printf("\nValidation:\n") + if err := tok.Validate(); err != nil { + fmt.Printf(" Structure: INVALID (%v)\n", err) + } else { + fmt.Printf(" Structure: VALID\n") + } + + if tok.VerifySignature() { + fmt.Printf(" Signature: VALID\n") + } else { + fmt.Printf(" Signature: INVALID\n") + } + + if err := tok.ValidateDelegationChain(); err != nil { + fmt.Printf(" Delegation Chain: INVALID (%v)\n", err) + } else if len(chain) > 0 { + fmt.Printf(" Delegation Chain: VALID\n") + } + + return nil +} + +func displayTokenV1(tok *session.Object) error { + fmt.Println("=== Session Token V1 ===") + fmt.Printf("ID: %s\n", tok.ID().String()) + + // Display issuer + issuer := tok.Issuer() + fmt.Printf("Issuer: %s\n", issuer.String()) + + // Display lifetime + fmt.Printf("\nLifetime:\n") + fmt.Printf(" Issued At (iat): %d\n", tok.Iat()) + fmt.Printf(" Not Before (nbf): %d\n", tok.Nbf()) + fmt.Printf(" Expires At (exp): %d\n", tok.Exp()) + + // Display full JSON representation + fmt.Printf("\nFull Token (JSON):\n") + jsonData, err := tok.MarshalJSON() + if err != nil { + return fmt.Errorf("failed to marshal token to JSON: %w", err) + } + + // Pretty print the JSON + var prettyJSON map[string]any + if err := json.Unmarshal(jsonData, &prettyJSON); err != nil { + return fmt.Errorf("failed to parse JSON: %w", err) + } + + prettyData, err := json.MarshalIndent(prettyJSON, "", " ") + if err != nil { + return fmt.Errorf("failed to format JSON: %w", err) + } + + fmt.Println(string(prettyData)) + return nil +} + +func verbV2ToString(verb session.VerbV2) string { + switch verb { + case session.VerbV2ObjectGet: + return "GET" + case session.VerbV2ObjectPut: + return "PUT" + case session.VerbV2ObjectHead: + return "HEAD" + case session.VerbV2ObjectSearch: + return "SEARCH" + case session.VerbV2ObjectDelete: + return "DELETE" + case session.VerbV2ObjectRange: + return "RANGE" + case session.VerbV2ObjectRangeHash: + return "RANGEHASH" + case session.VerbV2ContainerPut: + return "CONTAINER_PUT" + case session.VerbV2ContainerDelete: + return "CONTAINER_DELETE" + case session.VerbV2ContainerSetEACL: + return "CONTAINER_SET_EACL" + default: + return fmt.Sprintf("UNKNOWN(%d)", verb) + } +} diff --git a/cmd/neofs-cli/modules/session/root.go b/cmd/neofs-cli/modules/session/root.go index 3554a0ee1e..3fe1d036f6 100644 --- a/cmd/neofs-cli/modules/session/root.go +++ b/cmd/neofs-cli/modules/session/root.go @@ -11,4 +11,6 @@ var Cmd = &cobra.Command{ func init() { Cmd.AddCommand(createCmd) + Cmd.AddCommand(createV2Cmd) + Cmd.AddCommand(inspectCmd) } diff --git a/cmd/neofs-node/object.go b/cmd/neofs-node/object.go index 8bd69deb3c..e7d7dc80f5 100644 --- a/cmd/neofs-node/object.go +++ b/cmd/neofs-node/object.go @@ -292,6 +292,7 @@ func initObjectService(c *cfg) { fsChain: fsChain, netmapContract: c.nCli, }), + v2.WithNodeKey(&c.key.PrivateKey.PublicKey), v2.WithContainerSource(c.cnrSrc), ) addNewEpochAsyncNotificationHandler(c, func(event.Event) { diff --git a/docs/cli-commands/neofs-cli_session.md b/docs/cli-commands/neofs-cli_session.md index 3a6cbcd47d..f7a83371b6 100644 --- a/docs/cli-commands/neofs-cli_session.md +++ b/docs/cli-commands/neofs-cli_session.md @@ -19,4 +19,6 @@ Operations with session token * [neofs-cli](neofs-cli.md) - Command Line Tool to work with NeoFS * [neofs-cli session create](neofs-cli_session_create.md) - Create session token +* [neofs-cli session create-v2](neofs-cli_session_create-v2.md) - Create V2 session token +* [neofs-cli session inspect](neofs-cli_session_inspect.md) - Inspect session token (V1 or V2) diff --git a/docs/cli-commands/neofs-cli_session_create-v2.md b/docs/cli-commands/neofs-cli_session_create-v2.md new file mode 100644 index 0000000000..836bf22cfd --- /dev/null +++ b/docs/cli-commands/neofs-cli_session_create-v2.md @@ -0,0 +1,62 @@ +## neofs-cli session create-v2 + +Create V2 session token + +### Synopsis + +Create V2 session token with subjects, contexts, and verbs. + +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) +- Delegation chains (future feature) + +Example usage: + neofs-cli session create-v2 \ + --wallet wallet.json \ + --r node.neofs.network:8080 \ + --lifetime 100 \ + --out token.json \ + --json \ + --subject NbUgTSFvPmsRxmGeWpuuGeJUoRoi6PErcM \ + --container 5HqniP5vq5xXr3FdijTSekrQJHu1WnADt2uLg7KSViZM \ + --verbs GET,HEAD,SEARCH + +Default lifetime of session token is 10 epochs +if none of --expire-at or --lifetime flags is specified. + + +``` +neofs-cli session create-v2 [flags] +``` + +### Options + +``` + --address string Address of wallet account + --container string Container ID for the context + -e, --expire-at uint The last active epoch for token to stay valid + -h, --help help for create-v2 + --json Output token in JSON + -l, --lifetime uint Number of epochs for token to stay valid (default 10) + --objects strings Object IDs for the context (empty = all objects in container) + --out string File to write session token to + -r, --rpc-endpoint string Remote node address (as 'multiaddr' or ':') + --subject strings Subject user IDs (can be specified multiple times) + --subject-nns strings Subject NNS names (can be specified multiple times) + --verbs string Comma-separated list of verbs (GET,PUT,HEAD,SEARCH,DELETE,RANGE,RANGEHASH,CONTAINERSET,CONTAINERPUT,CONTAINERDELETE) + -w, --wallet string Path to the wallet +``` + +### Options inherited from parent commands + +``` + -c, --config string Config file (default is $HOME/.config/neofs-cli/config.yaml) + -v, --verbose Verbose output +``` + +### SEE ALSO + +* [neofs-cli session](neofs-cli_session.md) - Operations with session token + diff --git a/docs/cli-commands/neofs-cli_session_inspect.md b/docs/cli-commands/neofs-cli_session_inspect.md new file mode 100644 index 0000000000..12bb86177f --- /dev/null +++ b/docs/cli-commands/neofs-cli_session_inspect.md @@ -0,0 +1,38 @@ +## neofs-cli session inspect + +Inspect session token (V1 or V2) + +### Synopsis + +Inspect and display information about a session token. + +Supports both V1 (session.Object) and V2 (session.TokenV2) tokens. +Automatically detects the token version and displays relevant information. + +Example usage: + neofs-cli session inspect --token token.json + neofs-cli session inspect --token token.bin + + +``` +neofs-cli session inspect [flags] +``` + +### Options + +``` + -h, --help help for inspect + --token string Path to session token file (JSON or binary) +``` + +### Options inherited from parent commands + +``` + -c, --config string Config file (default is $HOME/.config/neofs-cli/config.yaml) + -v, --verbose Verbose output +``` + +### SEE ALSO + +* [neofs-cli session](neofs-cli_session.md) - Operations with session token + diff --git a/go.mod b/go.mod index dc541bbd1d..e8a50824a7 100644 --- a/go.mod +++ b/go.mod @@ -22,7 +22,7 @@ require ( github.com/nspcc-dev/neo-go v0.113.0 github.com/nspcc-dev/neofs-api-go/v2 v2.14.1-0.20240827150555-5ce597aa14ea github.com/nspcc-dev/neofs-contract v0.24.0 - github.com/nspcc-dev/neofs-sdk-go v1.0.0-rc.15.0.20251015122943-b38583ddd311 + github.com/nspcc-dev/neofs-sdk-go v1.0.0-rc.15.0.20251107143921-1a15e5c991e7 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 736a591435..fa3420c5e2 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.24.0 h1:lQHtfRc00WEhW9qcnVNbM2sMa4oCBQ5v7vcunJKk9rA= github.com/nspcc-dev/neofs-contract v0.24.0/go.mod h1:PPxjwRiK6hhXPXduvyojEqLMHNpgPaF+rULPhdFlzDg= -github.com/nspcc-dev/neofs-sdk-go v1.0.0-rc.15.0.20251015122943-b38583ddd311 h1:iHjokyLIiOW7zvaNZZPmch0c1tghwkfOniL+JOt+F7M= -github.com/nspcc-dev/neofs-sdk-go v1.0.0-rc.15.0.20251015122943-b38583ddd311/go.mod h1:Vukuf6qDOQESOWAx5yOjYtVC5wdsQp3hiZrxbJIa2fs= +github.com/nspcc-dev/neofs-sdk-go v1.0.0-rc.15.0.20251107143921-1a15e5c991e7 h1:DAlKwCd5zgNdahnj/cA9sZIxvILPdpK4N6EFdYCCfNg= +github.com/nspcc-dev/neofs-sdk-go v1.0.0-rc.15.0.20251107143921-1a15e5c991e7/go.mod h1:Vukuf6qDOQESOWAx5yOjYtVC5wdsQp3hiZrxbJIa2fs= 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/crypto/requests_test.go b/internal/crypto/requests_test.go index aad5cf8895..d1e7939964 100644 --- a/internal/crypto/requests_test.go +++ b/internal/crypto/requests_test.go @@ -153,7 +153,7 @@ var reqMetaHdr = &protosession.RequestMetaHeader{ Body: &protosession.SessionToken_Body{ Id: []byte("any_ID"), OwnerId: &refs.OwnerID{Value: []byte("any_session_owner")}, - Lifetime: &protosession.SessionToken_Body_TokenLifetime{ + Lifetime: &protosession.TokenLifetime{ Exp: 9296388864757340046, Nbf: 7616299382059580946, Iat: 7881369180031591601, }, SessionKey: []byte("any_session_key"), diff --git a/pkg/innerring/processors/container/common.go b/pkg/innerring/processors/container/common.go index 725c5d9de3..7c310f2553 100644 --- a/pkg/innerring/processors/container/common.go +++ b/pkg/innerring/processors/container/common.go @@ -42,7 +42,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" @@ -50,6 +50,13 @@ func (cp *Processor) verifySignature(v signatureVerificationData) error { var err error if len(v.binTokenSession) > 0 { + var tokV2 session.TokenV2 + err = tokV2.Unmarshal(v.binTokenSession) + if err == nil { + return cp.verifySessionV2(tokV2, v) + } + + // Fall back to V1 token var tok session.Container err = tok.Unmarshal(v.binTokenSession) @@ -103,3 +110,67 @@ func (cp *Processor) checkTokenLifetime(token session.Container) error { return nil } + +func (cp *Processor) checkTokenV2Lifetime(token session.TokenV2) error { + curEpoch, err := cp.netState.Epoch() + if err != nil { + return fmt.Errorf("could not read current epoch: %w", err) + } + + if !token.ValidAt(curEpoch) { + return fmt.Errorf("token is not valid at %d", curEpoch) + } + + return nil +} + +// verifySessionV2 validates V2 session token for container operations. +func (cp *Processor) verifySessionV2(tok session.TokenV2, v signatureVerificationData) error { + if err := tok.Validate(); err != nil { + return fmt.Errorf("invalid V2 session token: %w", err) + } + + if !tok.VerifySignature() { + return errors.New("v2 session token signature verification failed") + } + + verbV2 := containerVerbToVerbV2(v.verb) + if verbV2 == 0 { + return fmt.Errorf("unsupported container verb: %v", v.verb) + } + + if v.idContainerSet { + if !tok.AssertContainer(verbV2, v.idContainer) { + return errWrongCID + } + } + + issuer := tok.Issuer() + if !issuer.IsOwnerID() { + return errors.New("v2 session issuer must be OwnerID") + } + + if issuer.OwnerID() != v.ownerContainer { + return errors.New("owner differs with token issuer") + } + + if err := cp.checkTokenV2Lifetime(tok); err != nil { + return fmt.Errorf("check V2 session lifetime: %w", err) + } + + return nil +} + +// containerVerbToVerbV2 converts V1 ContainerVerb to V2 VerbV2. +func containerVerbToVerbV2(v1Verb session.ContainerVerb) session.VerbV2 { + switch v1Verb { + case session.VerbContainerPut: + return session.VerbV2ContainerPut + case session.VerbContainerDelete: + return session.VerbV2ContainerDelete + case session.VerbContainerSetEACL: + return session.VerbV2ContainerSetEACL + default: + return 0 + } +} diff --git a/pkg/services/container/server.go b/pkg/services/container/server.go index 181d997237..643c987676 100644 --- a/pkg/services/container/server.go +++ b/pkg/services/container/server.go @@ -79,6 +79,11 @@ type sessionTokenCommonCheckResult struct { err error } +type sessionTokenV2CommonCheckResult struct { + token session.TokenV2 + err error +} + // Server provides NeoFS API Container service. type Server struct { protocontainer.UnimplementedContainerServiceServer @@ -87,7 +92,8 @@ type Server struct { contract Contract historicN3ScriptRunner - sessionTokenCommonCheckCache *lru.Cache[[sha256.Size]byte, sessionTokenCommonCheckResult] + sessionTokenCommonCheckCache *lru.Cache[[sha256.Size]byte, sessionTokenCommonCheckResult] + sessionTokenV2CommonCheckCache *lru.Cache[[sha256.Size]byte, sessionTokenV2CommonCheckResult] } // New provides protocontainer.ContainerServiceServer based on specified @@ -100,6 +106,10 @@ func New(s *ecdsa.PrivateKey, net netmap.State, fsChain FSChain, c Contract, nc if err != nil { panic(fmt.Errorf("unexpected error in lru.New: %w", err)) } + sessionTokenV2CheckCache, err := lru.New[[sha256.Size]byte, sessionTokenV2CommonCheckResult](1000) + if err != nil { + panic(fmt.Errorf("unexpected error in lru.New for v2: %w", err)) + } return &Server{ signer: s, net: net, @@ -108,7 +118,8 @@ func New(s *ecdsa.PrivateKey, net netmap.State, fsChain FSChain, c Contract, nc FSChain: fsChain, NetmapContract: nc, }, - sessionTokenCommonCheckCache: sessionTokenCheckCache, + sessionTokenCommonCheckCache: sessionTokenCheckCache, + sessionTokenV2CommonCheckCache: sessionTokenV2CheckCache, } } @@ -216,6 +227,101 @@ func (s *Server) checkSessionIssuer(id cid.ID, issuer user.ID) error { return nil } +func (s *Server) getVerifiedSessionTokenV2(mh *protosession.RequestMetaHeader, reqVerb session.ContainerVerb, reqCnr cid.ID) (*session.TokenV2, error) { + for omh := mh.GetOrigin(); omh != nil; omh = mh.GetOrigin() { + mh = omh + } + m := mh.GetSessionTokenV2() + if m == nil { + return nil, nil + } + + b := make([]byte, m.MarshaledSize()) + m.MarshalStable(b) + + cacheKey := sha256.Sum256(b) + res, ok := s.sessionTokenV2CommonCheckCache.Get(cacheKey) + if !ok { + res.token, res.err = s.decodeAndVerifySessionTokenV2Common(m) + s.sessionTokenV2CommonCheckCache.Add(cacheKey, res) + } + if res.err != nil { + return nil, res.err + } + + if err := s.verifySessionTokenV2AgainstRequest(res.token, reqVerb, reqCnr); err != nil { + return nil, err + } + + return &res.token, nil +} + +func (s *Server) decodeAndVerifySessionTokenV2Common(m *protosession.SessionTokenV2) (session.TokenV2, error) { + var token session.TokenV2 + if err := token.FromProtoMessage(m); err != nil { + return token, fmt.Errorf("decode v2: %w", err) + } + + if err := token.Validate(); err != nil { + return token, fmt.Errorf("invalid v2 token: %w", err) + } + + if !token.VerifySignature() { + return token, errors.New("v2 session token signature verification failed") + } + + serverTarget := session.NewTarget(user.NewFromECDSAPublicKey(s.signer.PublicKey)) + if !token.AssertAuthority(serverTarget) { + return token, errors.New("v2 token doesn't authorize this node") + } + + cur := s.net.CurrentEpoch() + if !token.ValidAt(cur) { + return token, fmt.Errorf("v2 token is invalid at epoch %d", cur) + } + + return token, nil +} + +func (s *Server) verifySessionTokenV2AgainstRequest(token session.TokenV2, reqVerb session.ContainerVerb, reqCnr cid.ID) error { + verbV2 := containerVerbToVerbV2(reqVerb) + if verbV2 == 0 { + return fmt.Errorf("unsupported container verb for V2: %v", reqVerb) + } + + if !token.AssertContainer(verbV2, reqCnr) { + return errors.New("v2 session token does not authorize this container operation") + } + + if reqCnr.IsZero() { + return nil + } + + issuer := token.Issuer() + if !issuer.IsOwnerID() { + return errors.New("v2 session token issuer must be OwnerID for container operations") + } + + if err := s.checkSessionIssuer(reqCnr, issuer.OwnerID()); err != nil { + return fmt.Errorf("verify v2 session issuer: %w", err) + } + + return nil +} + +func containerVerbToVerbV2(verb session.ContainerVerb) session.VerbV2 { + switch verb { + case session.VerbContainerPut: + return session.VerbV2ContainerPut + case session.VerbContainerDelete: + return session.VerbV2ContainerDelete + case session.VerbContainerSetEACL: + return session.VerbV2ContainerSetEACL + default: + return session.VerbV2Unspecified + } +} + func (s *Server) makePutResponse(body *protocontainer.PutResponse_Body, st *protostatus.Status) (*protocontainer.PutResponse, error) { resp := &protocontainer.PutResponse{ Body: body, @@ -307,9 +413,17 @@ func (s *Server) Put(_ context.Context, req *protocontainer.PutRequest) (*protoc return s.makeFailedPutResponse(fmt.Errorf("invalid container: %w", err)) } - st, err := s.getVerifiedSessionToken(req.GetMetaHeader(), session.VerbContainerPut, cid.ID{}) + stV2, err := s.getVerifiedSessionTokenV2(req.GetMetaHeader(), session.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)) + } + + var st *session.Container + if stV2 == nil { + st, err = s.getVerifiedSessionToken(req.GetMetaHeader(), session.VerbContainerPut, cid.ID{}) + if err != nil { + return s.makeFailedPutResponse(fmt.Errorf("verify session token: %w", err)) + } } id, err := s.contract.Put(cnr, mSig.Key, mSig.Sign, st) @@ -353,9 +467,17 @@ func (s *Server) Delete(_ context.Context, req *protocontainer.DeleteRequest) (* return s.makeDeleteResponse(fmt.Errorf("invalid ID: %w", err)) } - st, err := s.getVerifiedSessionToken(req.GetMetaHeader(), session.VerbContainerDelete, id) + stV2, err := s.getVerifiedSessionTokenV2(req.GetMetaHeader(), session.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)) + } + + var st *session.Container + if stV2 == nil { + st, err = s.getVerifiedSessionToken(req.GetMetaHeader(), session.VerbContainerDelete, id) + if err != nil { + return s.makeDeleteResponse(fmt.Errorf("verify session token: %w", err)) + } } if err := s.contract.Delete(id, mSig.Key, mSig.Sign, st); err != nil { @@ -491,9 +613,17 @@ func (s *Server) SetExtendedACL(_ context.Context, req *protocontainer.SetExtend return s.makeSetEACLResponse(errors.New("missing container ID in eACL table")) } - st, err := s.getVerifiedSessionToken(req.GetMetaHeader(), session.VerbContainerSetEACL, cnrID) + stV2, err := s.getVerifiedSessionTokenV2(req.GetMetaHeader(), session.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)) + } + + var st *session.Container + if stV2 == nil { + st, err = s.getVerifiedSessionToken(req.GetMetaHeader(), session.VerbContainerSetEACL, cnrID) + if err != nil { + return s.makeSetEACLResponse(fmt.Errorf("verify session token: %w", err)) + } } if err := s.contract.PutEACL(eACL, mSig.Key, mSig.Sign, st); err != nil { diff --git a/pkg/services/object/acl/v2/opts.go b/pkg/services/object/acl/v2/opts.go index 24bed9f246..e1657cbf9e 100644 --- a/pkg/services/object/acl/v2/opts.go +++ b/pkg/services/object/acl/v2/opts.go @@ -1,6 +1,8 @@ package v2 import ( + "crypto/ecdsa" + "github.com/nspcc-dev/neofs-node/pkg/core/container" "go.uber.org/zap" ) @@ -33,3 +35,10 @@ func WithIRFetcher(v InnerRingFetcher) Option { c.irFetcher = v } } + +// WithNodeKey returns option to set node's public key for SessionTokenV2 authority checks. +func WithNodeKey(v *ecdsa.PublicKey) Option { + return func(c *cfg) { + c.nodeKey = v + } +} diff --git a/pkg/services/object/acl/v2/service.go b/pkg/services/object/acl/v2/service.go index bbc034ceaf..8d139edacf 100644 --- a/pkg/services/object/acl/v2/service.go +++ b/pkg/services/object/acl/v2/service.go @@ -1,6 +1,7 @@ package v2 import ( + "crypto/ecdsa" "crypto/sha256" "errors" "fmt" @@ -31,6 +32,11 @@ type sessionTokenCommonCheckResult struct { err error } +type sessionTokenV2CommonCheckResult struct { + token sessionSDK.TokenV2 + err error +} + type bearerTokenCommonCheckResult struct { token bearer.Token err error @@ -42,8 +48,9 @@ type Service struct { c senderClassifier - sessionTokenCommonCheckCache *lru.Cache[[sha256.Size]byte, sessionTokenCommonCheckResult] - bearerTokenCommonCheckCache *lru.Cache[[sha256.Size]byte, bearerTokenCommonCheckResult] + sessionTokenCommonCheckCache *lru.Cache[[sha256.Size]byte, sessionTokenCommonCheckResult] + sessionTokenV2CommonCheckCache *lru.Cache[[sha256.Size]byte, sessionTokenV2CommonCheckResult] + bearerTokenCommonCheckCache *lru.Cache[[sha256.Size]byte, bearerTokenCommonCheckResult] } // Option represents Service constructor option. @@ -79,6 +86,8 @@ type cfg struct { irFetcher InnerRingFetcher nm Netmapper + + nodeKey *ecdsa.PublicKey } func defaultCfg() *cfg { @@ -114,6 +123,10 @@ func New(fsChain FSChain, opts ...Option) Service { if err != nil { panic(fmt.Errorf("unexpected error in lru.New: %w", err)) } + sessionTokenV2CheckCache, err := lru.New[[sha256.Size]byte, sessionTokenV2CommonCheckResult](1000) + if err != nil { + panic(fmt.Errorf("unexpected error in lru.New: %w", err)) + } return Service{ cfg: cfg, @@ -122,8 +135,9 @@ func New(fsChain FSChain, opts ...Option) Service { innerRing: cfg.irFetcher, fsChain: fsChain, }, - sessionTokenCommonCheckCache: sessionTokenCheckCache, - bearerTokenCommonCheckCache: bearerTokenCheckCache, + sessionTokenCommonCheckCache: sessionTokenCheckCache, + bearerTokenCommonCheckCache: bearerTokenCheckCache, + sessionTokenV2CommonCheckCache: sessionTokenV2CheckCache, } } @@ -131,12 +145,21 @@ func New(fsChain FSChain, opts ...Option) Service { func (b Service) ResetTokenCheckCache() { b.sessionTokenCommonCheckCache.Purge() b.bearerTokenCommonCheckCache.Purge() + b.sessionTokenV2CommonCheckCache.Purge() } func (b Service) getVerifiedSessionToken(mh *protosession.RequestMetaHeader, reqVerb sessionSDK.ObjectVerb, reqCnr cid.ID, reqObj oid.ID) (*sessionSDK.Object, error) { for omh := mh.GetOrigin(); omh != nil; omh = mh.GetOrigin() { mh = omh } + + mV2 := mh.GetSessionTokenV2() + if mV2 != nil { + b.log.Debug("Verifying V2 session token") + return b.getVerifiedSessionTokenV2(mV2, reqVerb, reqCnr, reqObj) + } + + // Fall back to V1 token m := mh.GetSessionToken() if m == nil { return nil, nil @@ -202,6 +225,105 @@ func (b Service) verifySessionTokenAgainstRequest(token sessionSDK.Object, reqVe return nil } +// getVerifiedSessionTokenV2 validates and returns V2 session token, returns nil if V2 doesn't apply. +func (b Service) getVerifiedSessionTokenV2(mV2 *protosession.SessionTokenV2, reqVerb sessionSDK.ObjectVerb, reqCnr cid.ID, reqObj oid.ID) (*sessionSDK.Object, error) { + mb := make([]byte, mV2.MarshaledSize()) + mV2.MarshalStable(mb) + + cacheKey := sha256.Sum256(mb) + res, ok := b.sessionTokenV2CommonCheckCache.Get(cacheKey) + if !ok { + res.token, res.err = b.decodeAndVerifySessionTokenV2Common(mV2) + b.sessionTokenV2CommonCheckCache.Add(cacheKey, res) + } + if res.err != nil { + return nil, res.err + } + + if err := b.verifySessionTokenV2AgainstRequest(res.token, reqVerb, reqCnr, reqObj); err != nil { + return nil, err + } + + // V2 tokens work differently - return nil as they don't convert to V1 + // The caller needs to handle this properly + return nil, nil +} + +func (b Service) decodeAndVerifySessionTokenV2Common(m *protosession.SessionTokenV2) (sessionSDK.TokenV2, error) { + b.log.Debug("Decoding V2 session token") + var token sessionSDK.TokenV2 + if err := token.FromProtoMessage(m); err != nil { + return token, fmt.Errorf("invalid V2 session token: %w", err) + } + + if err := token.Validate(); err != nil { + return token, fmt.Errorf("invalid V2 session token: %w", err) + } + + if !token.VerifySignature() { + return token, errors.New("v2 session token signature verification failed") + } + + if b.nodeKey != nil { + serverTarget := sessionSDK.NewTarget(user.NewFromECDSAPublicKey(*b.nodeKey)) + if !token.AssertAuthority(serverTarget) { + return token, errors.New("v2 token doesn't authorize this node") + } + } + + currentEpoch, err := b.nm.Epoch() + if err != nil { + return token, errors.New("can't fetch current epoch") + } + + if !token.ValidAt(currentEpoch) { + return token, fmt.Errorf("%s: V2 token is invalid at %d epoch", invalidRequestMessage, currentEpoch) + } + + return token, nil +} + +func (b Service) verifySessionTokenV2AgainstRequest(token sessionSDK.TokenV2, reqVerb sessionSDK.ObjectVerb, reqCnr cid.ID, reqObj oid.ID) error { + verbV2 := objectVerbToVerbV2(reqVerb) + if verbV2 == 0 { + return fmt.Errorf("unsupported object verb for V2: %v", reqVerb) + } + + if !reqObj.IsZero() { + if !token.AssertObject(verbV2, reqCnr, reqObj) { + return errors.New("V2 session token does not authorize access to the object") + } + } else { + if !token.AssertVerb(verbV2, reqCnr) { + return errInvalidVerb + } + } + + return nil +} + +// objectVerbToVerbV2 converts V1 ObjectVerb to V2 VerbV2. +func objectVerbToVerbV2(v1Verb sessionSDK.ObjectVerb) sessionSDK.VerbV2 { + switch v1Verb { + case sessionSDK.VerbObjectGet: + return sessionSDK.VerbV2ObjectGet + case sessionSDK.VerbObjectHead: + return sessionSDK.VerbV2ObjectHead + case sessionSDK.VerbObjectPut: + return sessionSDK.VerbV2ObjectPut + case sessionSDK.VerbObjectDelete: + return sessionSDK.VerbV2ObjectDelete + case sessionSDK.VerbObjectSearch: + return sessionSDK.VerbV2ObjectSearch + case sessionSDK.VerbObjectRange: + return sessionSDK.VerbV2ObjectRange + case sessionSDK.VerbObjectRangeHash: + return sessionSDK.VerbV2ObjectRangeHash + default: + return 0 + } +} + 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 diff --git a/pkg/services/object/delete/delete.go b/pkg/services/object/delete/delete.go index 3403a767a0..a0b4c972a6 100644 --- a/pkg/services/object/delete/delete.go +++ b/pkg/services/object/delete/delete.go @@ -11,7 +11,7 @@ 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 tok := prm.common.SessionToken(); tok != nil && prm.common.SessionTokenV2() == nil { _, err := s.keyStorage.GetKey(&util.SessionInfo{ ID: tok.ID(), Owner: tok.Issuer(), diff --git a/pkg/services/object/delete/local.go b/pkg/services/object/delete/local.go index 0dc63a51aa..f51a737c6f 100644 --- a/pkg/services/object/delete/local.go +++ b/pkg/services/object/delete/local.go @@ -43,10 +43,20 @@ func (exec *execCtx) formTombstone() (ok bool) { exec.tombstoneObj.AssociateDeleted(exec.address().Object()) tokenSession := exec.commonParameters().SessionToken() - if tokenSession != nil { + tokenSessionV2 := exec.commonParameters().SessionTokenV2() + + if tokenSessionV2 != nil { + issuer := tokenSessionV2.Issuer() + if issuer.IsOwnerID() { + exec.tombstoneObj.SetOwner(issuer.OwnerID()) + } else { + // Fallback to local node if issuer is not OwnerID + exec.tombstoneObj.SetOwner(exec.svc.netInfo.LocalNodeID()) + } + } else if tokenSession != nil { exec.tombstoneObj.SetOwner(tokenSession.Issuer()) } else { - // make local node a tombstone object owner + // No token: make local node a tombstone object owner exec.tombstoneObj.SetOwner(exec.svc.netInfo.LocalNodeID()) } diff --git a/pkg/services/object/get/util.go b/pkg/services/object/get/util.go index f4f560acd7..b47386ff8f 100644 --- a/pkg/services/object/get/util.go +++ b/pkg/services/object/get/util.go @@ -195,7 +195,12 @@ 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 { + verbV2 := session.VerbV2ObjectHead + if stV2.AssertObject(verbV2, addr.Container(), id) { + 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 { @@ -230,7 +235,12 @@ 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 { + verbV2 := session.VerbV2ObjectRange + if stV2.AssertObject(verbV2, addr.Container(), id) { + 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 { @@ -260,7 +270,12 @@ 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 { + verbV2 := session.VerbV2ObjectGet + if stV2.AssertObject(verbV2, addr.Container(), id) { + 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/remote.go b/pkg/services/object/put/remote.go index 27235318d3..16a4f3de7f 100644 --- a/pkg/services/object/put/remote.go +++ b/pkg/services/object/put/remote.go @@ -35,7 +35,15 @@ func putObjectToNode(ctx context.Context, nodeInfo clientcore.NodeInfo, obj *obj keyStorage *util.KeyStorage, clientConstructor ClientConstructor, commonPrm *util.CommonPrm) error { var sessionInfo *util.SessionInfo - if tok := commonPrm.SessionToken(); tok != nil { + if tokV2 := commonPrm.SessionTokenV2(); tokV2 != nil { + issuer := tokV2.Issuer() + if issuer.IsOwnerID() { + sessionInfo = &util.SessionInfo{ + ID: tokV2.ID(), + Owner: issuer.OwnerID(), + } + } + } else if tok := commonPrm.SessionToken(); tok != nil { sessionInfo = &util.SessionInfo{ ID: tok.ID(), Owner: tok.Issuer(), @@ -54,7 +62,9 @@ func putObjectToNode(ctx context.Context, nodeInfo clientcore.NodeInfo, obj *obj var opts client.PrmObjectPutInit opts.MarkLocal() - if st := commonPrm.SessionToken(); st != nil { + if stV2 := commonPrm.SessionTokenV2(); stV2 != nil { + opts.WithinSessionV2(*stV2) + } else if st := commonPrm.SessionToken(); st != nil { opts.WithinSession(*st) } if bt := commonPrm.BearerToken(); bt != nil { diff --git a/pkg/services/object/put/streamer.go b/pkg/services/object/put/streamer.go index e908a9d769..21ea6c90b4 100644 --- a/pkg/services/object/put/streamer.go +++ b/pkg/services/object/put/streamer.go @@ -2,6 +2,7 @@ package putsvc import ( "context" + "crypto/ecdsa" "errors" "fmt" @@ -91,29 +92,44 @@ 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 + var ( + err error + sessionKey *ecdsa.PrivateKey + sessionInfo *util.SessionInfo + ) - if sToken != nil { + if sTokenV2 != nil { + // V2 token: use node's own key + sessionKey, err = p.keyStorage.GetKey(nil) + if err != nil { + return fmt.Errorf("(%T) could not receive node key for V2 token: %w", p, 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) + } + } else { + // No token: use node's own key + sessionKey, err = p.keyStorage.GetKey(nil) + if err != nil { + return fmt.Errorf("(%T) could not receive node 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") diff --git a/pkg/services/object/search/util.go b/pkg/services/object/search/util.go index a0980ef393..f09e8eadc0 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" + sessionsdk "github.com/nspcc-dev/neofs-sdk-go/session" "github.com/nspcc-dev/neofs-sdk-go/user" ) @@ -94,7 +95,12 @@ func (c *clientWrapper) searchObjects(ctx context.Context, exec *execCtx, info c if exec.prm.common.TTL() < 2 { opts.MarkLocal() } - if st := exec.prm.common.SessionToken(); st != nil { + if stV2 := exec.prm.common.SessionTokenV2(); stV2 != nil { + verbV2 := sessionsdk.VerbV2ObjectSearch + if stV2.AssertVerb(verbV2, 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 4ec8eea3da..d9fd56b9bf 100644 --- a/pkg/services/object/server.go +++ b/pkg/services/object/server.go @@ -899,6 +899,9 @@ func convertHashPrm(signer ecdsa.PrivateKey, ss sessions, req *protoobject.GetRa signerKey = signer } p.WithCachedSignerKey(&signerKey) + } else if cp.SessionTokenV2() != nil { + // V2 tokens don't use server-side session keys, use node's own key + p.WithCachedSignerKey(&signer) } mr := body.GetRanges() diff --git a/pkg/services/object/util/prm.go b/pkg/services/object/util/prm.go index 9c47d416f3..474154b3bd 100644 --- a/pkg/services/object/util/prm.go +++ b/pkg/services/object/util/prm.go @@ -14,7 +14,8 @@ const maxLocalTTL = 1 type CommonPrm struct { local bool - token *sessionsdk.Object + token *sessionsdk.Object + tokenV2 *sessionsdk.TokenV2 bearer *bearer.Token @@ -65,6 +66,14 @@ func (p *CommonPrm) SessionToken() *sessionsdk.Object { return nil } +func (p *CommonPrm) SessionTokenV2() *sessionsdk.TokenV2 { + if p != nil { + return p.tokenV2 + } + + return nil +} + func (p *CommonPrm) BearerToken() *bearer.Token { if p != nil { return p.bearer @@ -78,6 +87,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 +105,14 @@ func CommonPrmFromRequest(req interface { } var st *sessionsdk.Object - if meta.SessionToken != nil { + var stV2 *sessionsdk.TokenV2 + + if meta.SessionTokenV2 != nil { + stV2 = new(sessionsdk.TokenV2) + 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 +129,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())