package authmate import ( "context" "crypto/ecdsa" "encoding/hex" "encoding/json" "errors" "fmt" "io" "os" "time" "git.frostfs.info/TrueCloudLab/frostfs-api-go/v2/acl" sessionv2 "git.frostfs.info/TrueCloudLab/frostfs-api-go/v2/session" "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/cache" "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/handler" "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/layer/frostfs" "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/creds/accessbox" "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/creds/tokens" "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/internal/logs" "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/pkg/retryer" "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/bearer" cid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/id" frostfsecdsa "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/crypto/ecdsa" "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/netmap" "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/object" oid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/object/id" "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/session" "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/user" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/aws/retry" "github.com/google/uuid" "github.com/nspcc-dev/neo-go/pkg/crypto/keys" "go.uber.org/zap" ) // PrmContainerCreate groups parameters of containers created by authmate. type PrmContainerCreate struct { // FrostFS identifier of the container creator. Owner *keys.PublicKey // Container placement policy. Policy netmap.PlacementPolicy // Friendly name for the container (optional). FriendlyName string } // NetworkState represents FrostFS network state which is needed for authmate processing. type NetworkState struct { // Current FrostFS time. Epoch uint64 // Duration of the Morph chain block in ms. BlockDuration int64 // Duration of the FrostFS epoch in Morph chain blocks. EpochDuration uint64 } // FrostFS represents virtual connection to FrostFS network. type FrostFS interface { // FrostFS interface required by credential tool. tokens.FrostFS // ContainerExists checks container presence in FrostFS by identifier. // Returns nil if container exists. ContainerExists(context.Context, cid.ID) error // CreateContainer creates and saves parameterized container in FrostFS. // It sets 'Timestamp' attribute to the current time. // It returns the ID of the saved container. // // The container must be private with GET access for OTHERS group. // Creation time should also be stamped. // // It returns exactly one non-nil value. It returns any error encountered which // prevented the container from being created. CreateContainer(context.Context, PrmContainerCreate) (cid.ID, error) // TimeToEpoch computes the current epoch and the epoch that corresponds to the provided time. // Note: // * time must be in the future // * time will be ceil rounded to match epoch // // It returns any error encountered which prevented computing epochs. TimeToEpoch(context.Context, time.Time) (uint64, uint64, error) } // Agent contains client communicating with FrostFS and logger. type Agent struct { frostFS FrostFS log *zap.Logger cfg *config } type config struct { RetryMaxAttempts int RetryMaxBackoff time.Duration RetryStrategy handler.RetryStrategy } func defaultConfig() *config { return &config{ RetryMaxAttempts: 4, RetryMaxBackoff: 30 * time.Second, RetryStrategy: handler.RetryStrategyExponential, } } type Option func(cfg *config) func WithRetryMaxAttempts(attempts int) func(*config) { return func(cfg *config) { cfg.RetryMaxAttempts = attempts } } func WithRetryMaxBackoff(backoff time.Duration) func(*config) { return func(cfg *config) { cfg.RetryMaxBackoff = backoff } } func WithRetryStrategy(strategy handler.RetryStrategy) func(*config) { return func(cfg *config) { cfg.RetryStrategy = strategy } } // New creates an object of type Agent that consists of Client and logger. func New(log *zap.Logger, frostFS FrostFS, options ...Option) *Agent { cfg := defaultConfig() for _, opt := range options { opt(cfg) } return &Agent{ log: log, frostFS: frostFS, cfg: cfg, } } type ( // ContainerPolicies contains mapping of aws LocationConstraint to frostfs PlacementPolicy. ContainerPolicies map[string]string // IssueSecretOptions contains options for passing to Agent.IssueSecret method. IssueSecretOptions struct { Container cid.ID AccessKeyID string SecretAccessKey string FrostFSKey *keys.PrivateKey GatesPublicKeys []*keys.PublicKey Impersonate bool SessionTokenRules []byte SkipSessionRules bool Lifetime time.Duration AwsCliCredentialsFile string ContainerPolicies ContainerPolicies CustomAttributes []object.Attribute } // UpdateSecretOptions contains options for passing to Agent.UpdateSecret method. UpdateSecretOptions struct { FrostFSKey *keys.PrivateKey GatesPublicKeys []*keys.PublicKey IsCustom bool AccessKeyID string ContainerID cid.ID GatePrivateKey *keys.PrivateKey CustomAttributes []object.Attribute } tokenUpdateOptions struct { frostFSKey *keys.PrivateKey gatesPublicKeys []*keys.PublicKey lifetime lifetimeOptions box *accessbox.Box } // ContainerOptions groups parameters of auth container to put the secret into. ContainerOptions struct { ID cid.ID FriendlyName string PlacementPolicy string } // UpdateOptions groups parameters to update existing the secret into. UpdateOptions struct { Address oid.Address SecretAccessKey []byte } // ObtainSecretOptions contains options for passing to Agent.ObtainSecret method. ObtainSecretOptions struct { Container cid.ID AccessKeyID string GatePrivateKey *keys.PrivateKey } ) // lifetimeOptions holds FrostFS epochs, iat -- epoch which the token was issued at, exp -- epoch when the token expires. type lifetimeOptions struct { Iat uint64 Exp uint64 } type ( issuingResult struct { InitialAccessKeyID string `json:"initial_access_key_id"` AccessKeyID string `json:"access_key_id"` SecretAccessKey string `json:"secret_access_key"` OwnerPrivateKey string `json:"owner_private_key"` WalletPublicKey string `json:"wallet_public_key"` ContainerID string `json:"container_id"` } obtainingResult struct { BearerToken *bearer.Token `json:"bearer_token"` SecretAccessKey string `json:"secret_access_key"` } ) func (a *Agent) checkContainer(ctx context.Context, cnrID cid.ID) error { a.log.Info(logs.CheckContainer, zap.Stringer("cid", cnrID)) return a.frostFS.ContainerExists(ctx, cnrID) } func checkPolicy(policyString string) (*netmap.PlacementPolicy, error) { var result netmap.PlacementPolicy err := result.DecodeString(policyString) if err == nil { return &result, nil } if err = result.UnmarshalJSON([]byte(policyString)); err == nil { return &result, nil } return nil, errors.New("can't parse placement policy") } func preparePolicy(policy ContainerPolicies) ([]*accessbox.AccessBox_ContainerPolicy, error) { if policy == nil { return nil, nil } var result []*accessbox.AccessBox_ContainerPolicy for locationConstraint, placementPolicy := range policy { parsedPolicy, err := checkPolicy(placementPolicy) if err != nil { return nil, fmt.Errorf("check placement policy: %w", err) } result = append(result, &accessbox.AccessBox_ContainerPolicy{ LocationConstraint: locationConstraint, Policy: parsedPolicy.Marshal(), }) } return result, nil } // IssueSecret creates an auth token, puts it in the FrostFS network and writes to io.Writer a new secret access key. func (a *Agent) IssueSecret(ctx context.Context, w io.Writer, options *IssueSecretOptions) error { var ( err error box *accessbox.AccessBox lifetime lifetimeOptions ) policies, err := preparePolicy(options.ContainerPolicies) if err != nil { return fmt.Errorf("prepare policies: %w", err) } lifetime.Iat, lifetime.Exp, err = a.frostFS.TimeToEpoch(ctx, time.Now().Add(options.Lifetime)) if err != nil { return fmt.Errorf("fetch time to epoch: %w", err) } gatesData, err := createTokens(options, lifetime) if err != nil { return fmt.Errorf("create tokens: %w", err) } var secret []byte isCustom := options.AccessKeyID != "" if isCustom { secret = []byte(options.SecretAccessKey) } box, secrets, err := accessbox.PackTokens(gatesData, secret, isCustom) if err != nil { return fmt.Errorf("pack tokens: %w", err) } box.ContainerPolicy = policies if err = a.checkContainer(ctx, options.Container); err != nil { return fmt.Errorf("check container: %w", err) } var idOwner user.ID user.IDFromKey(&idOwner, options.FrostFSKey.PrivateKey.PublicKey) a.log.Info(logs.StoreBearerTokenIntoFrostFS, zap.Stringer("owner_tkn", idOwner)) cfg := tokens.Config{ FrostFS: a.frostFS, Key: secrets.EphemeralKey, CacheConfig: cache.DefaultAccessBoxConfig(a.log), } creds := tokens.New(cfg) prm := tokens.CredentialsParam{ Container: options.Container, AccessKeyID: options.AccessKeyID, AccessBox: box, Expiration: lifetime.Exp, Keys: options.GatesPublicKeys, CustomAttributes: options.CustomAttributes, } var addr oid.Address err = retryer.MakeWithRetry(ctx, func() error { var inErr error addr, inErr = creds.Put(ctx, prm) return inErr }, a.credsPutRetryer()) if err != nil { return fmt.Errorf("failed to put creds: %w", err) } accessKeyID := options.AccessKeyID if accessKeyID == "" { accessKeyID = accessKeyIDFromAddr(addr) } ir := &issuingResult{ InitialAccessKeyID: accessKeyID, AccessKeyID: accessKeyID, SecretAccessKey: secrets.SecretKey, OwnerPrivateKey: hex.EncodeToString(secrets.EphemeralKey.Bytes()), WalletPublicKey: hex.EncodeToString(options.FrostFSKey.PublicKey().Bytes()), ContainerID: options.Container.EncodeToString(), } enc := json.NewEncoder(w) enc.SetIndent("", " ") if err = enc.Encode(ir); err != nil { return err } if options.AwsCliCredentialsFile != "" { profileName := "authmate_cred_" + addr.Object().EncodeToString() if _, err = os.Stat(options.AwsCliCredentialsFile); os.IsNotExist(err) { profileName = "default" } file, err := os.OpenFile(options.AwsCliCredentialsFile, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0600) if err != nil { return fmt.Errorf("couldn't open aws cli credentials file: %w", err) } defer file.Close() if _, err = file.WriteString(fmt.Sprintf("\n[%s]\naws_access_key_id = %s\naws_secret_access_key = %s\n", profileName, accessKeyID, secrets.SecretKey)); err != nil { return fmt.Errorf("fails to write to file: %w", err) } } return nil } // UpdateSecret updates an auth token (change list of gates that can use credential), puts new cred version to the FrostFS network and writes to io.Writer a result. func (a *Agent) UpdateSecret(ctx context.Context, w io.Writer, options *UpdateSecretOptions) error { cfg := tokens.Config{ FrostFS: a.frostFS, Key: options.GatePrivateKey, CacheConfig: cache.DefaultAccessBoxConfig(a.log), } creds := tokens.New(cfg) box, _, err := creds.GetBox(ctx, options.ContainerID, options.AccessKeyID) if err != nil { return fmt.Errorf("get accessbox: %w", err) } var secret []byte if options.IsCustom { secret = []byte(box.Gate.SecretKey) } else if secret, err = hex.DecodeString(box.Gate.SecretKey); err != nil { return fmt.Errorf("failed to decode secret key access box: %w", err) } lifetime := getLifetimeFromGateData(box.Gate) tokenOptions := tokenUpdateOptions{ frostFSKey: options.FrostFSKey, gatesPublicKeys: options.GatesPublicKeys, lifetime: lifetime, box: box, } gatesData, err := formTokensToUpdate(tokenOptions) if err != nil { return fmt.Errorf("create tokens: %w", err) } updatedBox, secrets, err := accessbox.PackTokens(gatesData, secret, options.IsCustom) if err != nil { return fmt.Errorf("pack tokens: %w", err) } var idOwner user.ID user.IDFromKey(&idOwner, options.FrostFSKey.PrivateKey.PublicKey) a.log.Info(logs.UpdateAccessCredObjectIntoFrostFS, zap.Stringer("owner_tkn", idOwner)) prm := tokens.CredentialsParam{ Container: options.ContainerID, AccessBox: updatedBox, Expiration: lifetime.Exp, Keys: options.GatesPublicKeys, CustomAttributes: options.CustomAttributes, } addr, err := creds.Update(ctx, prm) if err != nil { return fmt.Errorf("failed to update creds: %w", err) } accessKeyID := options.AccessKeyID if !options.IsCustom { accessKeyID = accessKeyIDFromAddr(addr) } ir := &issuingResult{ AccessKeyID: accessKeyID, InitialAccessKeyID: options.AccessKeyID, SecretAccessKey: secrets.SecretKey, OwnerPrivateKey: hex.EncodeToString(secrets.EphemeralKey.Bytes()), WalletPublicKey: hex.EncodeToString(options.FrostFSKey.PublicKey().Bytes()), ContainerID: addr.Container().EncodeToString(), } enc := json.NewEncoder(w) enc.SetIndent("", " ") return enc.Encode(ir) } func getLifetimeFromGateData(gateData *accessbox.GateData) lifetimeOptions { var btokenv2 acl.BearerToken gateData.BearerToken.WriteToV2(&btokenv2) return lifetimeOptions{ Iat: btokenv2.GetBody().GetLifetime().GetIat(), Exp: btokenv2.GetBody().GetLifetime().GetExp(), } } // ObtainSecret receives an existing secret access key from FrostFS and // writes to io.Writer the secret access key. func (a *Agent) ObtainSecret(ctx context.Context, w io.Writer, options *ObtainSecretOptions) error { cfg := tokens.Config{ FrostFS: a.frostFS, Key: options.GatePrivateKey, CacheConfig: cache.DefaultAccessBoxConfig(a.log), } bearerCreds := tokens.New(cfg) box, _, err := bearerCreds.GetBox(ctx, options.Container, options.AccessKeyID) if err != nil { return fmt.Errorf("failed to get tokens: %w", err) } or := &obtainingResult{ BearerToken: box.Gate.BearerToken, SecretAccessKey: box.Gate.SecretKey, } enc := json.NewEncoder(w) enc.SetIndent("", " ") return enc.Encode(or) } func (a *Agent) credsPutRetryer() aws.RetryerV2 { return retry.NewStandard(func(options *retry.StandardOptions) { options.MaxAttempts = a.cfg.RetryMaxAttempts options.MaxBackoff = a.cfg.RetryMaxBackoff if a.cfg.RetryStrategy == handler.RetryStrategyExponential { options.Backoff = retry.NewExponentialJitterBackoff(options.MaxBackoff) } else { options.Backoff = retry.BackoffDelayerFunc(func(int, error) (time.Duration, error) { return options.MaxBackoff, nil }) } options.Retryables = []retry.IsErrorRetryable{retry.IsErrorRetryableFunc(func(err error) aws.Ternary { if errors.Is(err, frostfs.ErrAccessDenied) { return aws.TrueTernary } return aws.FalseTernary })} }) } func buildBearerToken(key *keys.PrivateKey, impersonate bool, lifetime lifetimeOptions, gateKey *keys.PublicKey) (*bearer.Token, error) { var ownerID user.ID user.IDFromKey(&ownerID, (ecdsa.PublicKey)(*gateKey)) var bearerToken bearer.Token bearerToken.ForUser(ownerID) bearerToken.SetExp(lifetime.Exp) bearerToken.SetIat(lifetime.Iat) bearerToken.SetNbf(lifetime.Iat) bearerToken.SetImpersonate(impersonate) err := bearerToken.Sign(key.PrivateKey) if err != nil { return nil, fmt.Errorf("sign bearer token: %w", err) } return &bearerToken, nil } func buildBearerTokens(key *keys.PrivateKey, impersonate bool, lifetime lifetimeOptions, gatesKeys []*keys.PublicKey) ([]*bearer.Token, error) { bearerTokens := make([]*bearer.Token, 0, len(gatesKeys)) for _, gateKey := range gatesKeys { tkn, err := buildBearerToken(key, impersonate, lifetime, gateKey) if err != nil { return nil, fmt.Errorf("build bearer token: %w", err) } bearerTokens = append(bearerTokens, tkn) } return bearerTokens, nil } func buildSessionToken(key *keys.PrivateKey, lifetime lifetimeOptions, ctx sessionTokenContext, gateKey *keys.PublicKey) (*session.Container, error) { tok := new(session.Container) tok.ForVerb(ctx.verb) if !ctx.containerID.Equals(cid.ID{}) { tok.ApplyOnlyTo(ctx.containerID) } tok.SetID(uuid.New()) tok.SetAuthKey((*frostfsecdsa.PublicKey)(gateKey)) tok.SetIat(lifetime.Iat) tok.SetNbf(lifetime.Iat) tok.SetExp(lifetime.Exp) return tok, tok.Sign(key.PrivateKey) } func buildSessionTokens(key *keys.PrivateKey, lifetime lifetimeOptions, ctxs []sessionTokenContext, gatesKeys []*keys.PublicKey) ([][]*session.Container, error) { sessionTokens := make([][]*session.Container, 0, len(gatesKeys)) for _, gateKey := range gatesKeys { tkns := make([]*session.Container, len(ctxs)) for i, ctx := range ctxs { tkn, err := buildSessionToken(key, lifetime, ctx, gateKey) if err != nil { return nil, fmt.Errorf("build session token: %w", err) } tkns[i] = tkn } sessionTokens = append(sessionTokens, tkns) } return sessionTokens, nil } func createTokens(options *IssueSecretOptions, lifetime lifetimeOptions) ([]*accessbox.GateData, error) { gates := make([]*accessbox.GateData, len(options.GatesPublicKeys)) bearerTokens, err := buildBearerTokens(options.FrostFSKey, options.Impersonate, lifetime, options.GatesPublicKeys) if err != nil { return nil, fmt.Errorf("failed to build bearer tokens: %w", err) } for i, gateKey := range options.GatesPublicKeys { gates[i] = accessbox.NewGateData(gateKey, bearerTokens[i]) } if !options.SkipSessionRules { sessionRules, err := buildContext(options.SessionTokenRules) if err != nil { return nil, fmt.Errorf("failed to build context for session token: %w", err) } sessionTokens, err := buildSessionTokens(options.FrostFSKey, lifetime, sessionRules, options.GatesPublicKeys) if err != nil { return nil, fmt.Errorf("failed to biuild session token: %w", err) } for i, sessionTkns := range sessionTokens { gates[i].SessionTokens = sessionTkns } } return gates, nil } func formTokensToUpdate(options tokenUpdateOptions) ([]*accessbox.GateData, error) { btoken := options.box.Gate.BearerToken btokenv2 := new(acl.BearerToken) btoken.WriteToV2(btokenv2) if btokenv2.GetBody().GetEACL() != nil { return nil, errors.New("EACL table in bearer token isn't supported") } bearerTokens, err := buildBearerTokens(options.frostFSKey, btoken.Impersonate(), options.lifetime, options.gatesPublicKeys) if err != nil { return nil, fmt.Errorf("failed to build bearer tokens: %w", err) } gates := make([]*accessbox.GateData, len(options.gatesPublicKeys)) for i, gateKey := range options.gatesPublicKeys { gates[i] = accessbox.NewGateData(gateKey, bearerTokens[i]) } sessionRules := make([]sessionTokenContext, len(options.box.Gate.SessionTokens)) for i, token := range options.box.Gate.SessionTokens { var stoken sessionv2.Token token.WriteToV2(&stoken) sessionCtx, ok := stoken.GetBody().GetContext().(*sessionv2.ContainerSessionContext) if !ok { return nil, fmt.Errorf("get context from session token: %w", err) } var cnrID cid.ID if cnrIDv2 := sessionCtx.ContainerID(); cnrIDv2 != nil { if err = cnrID.ReadFromV2(*cnrIDv2); err != nil { return nil, fmt.Errorf("read from v2 container id: %w", err) } } sessionRules[i] = sessionTokenContext{ verb: session.ContainerVerb(sessionCtx.Verb()), containerID: cnrID, } } sessionTokens, err := buildSessionTokens(options.frostFSKey, options.lifetime, sessionRules, options.gatesPublicKeys) if err != nil { return nil, fmt.Errorf("failed to biuild session token: %w", err) } for i, sessionTkns := range sessionTokens { gates[i].SessionTokens = sessionTkns } return gates, nil } func accessKeyIDFromAddr(addr oid.Address) string { return addr.Container().EncodeToString() + "0" + addr.Object().EncodeToString() }